Add secure native runtime installation
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { getRuntimeStatus } from '../nativeRuntime'
|
||||
import {
|
||||
getRuntimeStatus,
|
||||
installBundledNativeRuntime,
|
||||
pickAndInstallNativeRuntime
|
||||
} from '../nativeRuntime'
|
||||
import { getRuntimeUpdates } from '../runtimeCatalog'
|
||||
|
||||
export function registerRuntimeIpc(): void {
|
||||
@@ -8,4 +12,6 @@ export function registerRuntimeIpc(): void {
|
||||
const status = await getRuntimeStatus()
|
||||
return getRuntimeUpdates(status.native.components)
|
||||
})
|
||||
ipcMain.handle('runtime:installBundled', () => installBundledNativeRuntime())
|
||||
ipcMain.handle('runtime:pickAndInstall', () => pickAndInstallNativeRuntime())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderMariaDbConfig, renderNginxConfig, renderPhpFpmConfig } from './nativeProject'
|
||||
|
||||
const project = {
|
||||
name: 'demo',
|
||||
root: '/tmp/aurora demo',
|
||||
docroot: 'public',
|
||||
ports: { http: 41001, php: 41002, database: 41003, node: 41004 }
|
||||
}
|
||||
|
||||
describe('native project configuration', () => {
|
||||
it('isolates PHP-FPM on its allocated loopback port', () =>
|
||||
expect(renderPhpFpmConfig(project)).toContain('listen = 127.0.0.1:41002'))
|
||||
it('routes nginx PHP requests to the project PHP-FPM service', () => {
|
||||
const config = renderNginxConfig(project)
|
||||
expect(config).toContain('listen 127.0.0.1:41001')
|
||||
expect(config).toContain('fastcgi_pass 127.0.0.1:41002')
|
||||
expect(config).toContain('location ~ \\.php$')
|
||||
expect(config).toContain('aurora demo/public')
|
||||
})
|
||||
it('isolates MariaDB data and networking', () => {
|
||||
const config = renderMariaDbConfig(project, '/tmp/aurora-runtime')
|
||||
expect(config).toContain('bind-address=127.0.0.1')
|
||||
expect(config).toContain('port=41003')
|
||||
expect(config).toContain('/.aurora/native/data/mariadb')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { access, mkdir, writeFile } from 'fs/promises'
|
||||
import { join, resolve } from 'path'
|
||||
import { promisify } from 'util'
|
||||
import type { AuroraNativePorts } from './portAllocator'
|
||||
import { NativeProcessSupervisor, type NativeServiceSpec } from './processSupervisor'
|
||||
import { runtimeRoot } from '../nativeRuntime'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const supervisor = new NativeProcessSupervisor()
|
||||
|
||||
export interface NativeProjectDefinition {
|
||||
name: string
|
||||
root: string
|
||||
docroot: string
|
||||
ports: AuroraNativePorts
|
||||
}
|
||||
|
||||
function nativeDirectory(root: string): string {
|
||||
return join(root, '.aurora', 'native')
|
||||
}
|
||||
|
||||
function quoteNginx(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
}
|
||||
|
||||
export function renderPhpFpmConfig(project: NativeProjectDefinition): string {
|
||||
const directory = nativeDirectory(project.root)
|
||||
return `[global]
|
||||
daemonize = no
|
||||
pid = ${join(directory, 'pids', 'php.pid')}
|
||||
error_log = ${join(directory, 'logs', 'php.log')}
|
||||
|
||||
[www]
|
||||
listen = 127.0.0.1:${project.ports.php}
|
||||
pm = dynamic
|
||||
pm.max_children = 8
|
||||
pm.start_servers = 2
|
||||
pm.min_spare_servers = 1
|
||||
pm.max_spare_servers = 3
|
||||
clear_env = no
|
||||
catch_workers_output = yes
|
||||
chdir = ${project.root}
|
||||
`
|
||||
}
|
||||
|
||||
export function renderNginxConfig(project: NativeProjectDefinition): string {
|
||||
const directory = nativeDirectory(project.root)
|
||||
const webroot = resolve(project.root, project.docroot || '.')
|
||||
return `daemon off;
|
||||
pid "${quoteNginx(join(directory, 'pids', 'nginx.pid'))}";
|
||||
error_log "${quoteNginx(join(directory, 'logs', 'nginx.log'))}" info;
|
||||
events { worker_connections 256; }
|
||||
http {
|
||||
access_log "${quoteNginx(join(directory, 'logs', 'nginx-access.log'))}";
|
||||
server {
|
||||
listen 127.0.0.1:${project.ports.http};
|
||||
server_name localhost;
|
||||
root "${quoteNginx(webroot)}";
|
||||
index index.php index.html;
|
||||
location / { try_files $uri $uri/ /index.php?$query_string; }
|
||||
location ~ \\.php$ {
|
||||
fastcgi_pass 127.0.0.1:${project.ports.php};
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
|
||||
fastcgi_param REQUEST_METHOD $request_method;
|
||||
fastcgi_param QUERY_STRING $query_string;
|
||||
fastcgi_param CONTENT_TYPE $content_type;
|
||||
fastcgi_param CONTENT_LENGTH $content_length;
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
export function renderMariaDbConfig(
|
||||
project: NativeProjectDefinition,
|
||||
installedRuntimeRoot = runtimeRoot()
|
||||
): string {
|
||||
const directory = nativeDirectory(project.root)
|
||||
return `[mariadbd]
|
||||
basedir=${join(installedRuntimeRoot, 'root', 'usr')}
|
||||
datadir=${join(directory, 'data', 'mariadb')}
|
||||
tmpdir=${join(directory, 'tmp')}
|
||||
bind-address=127.0.0.1
|
||||
port=${project.ports.database}
|
||||
socket=${join(directory, 'mariadb.sock')}
|
||||
pid-file=${join(directory, 'pids', 'mariadb.pid')}
|
||||
log-error=${join(directory, 'logs', 'mariadb.log')}
|
||||
skip-name-resolve
|
||||
`
|
||||
}
|
||||
|
||||
export async function provisionNativeProject(project: NativeProjectDefinition): Promise<void> {
|
||||
const directory = nativeDirectory(project.root)
|
||||
for (const child of ['config', 'data/mariadb', 'logs', 'pids', 'tmp'])
|
||||
await mkdir(join(directory, child), { recursive: true })
|
||||
await Promise.all([
|
||||
writeFile(join(directory, 'config', 'php-fpm.conf'), renderPhpFpmConfig(project)),
|
||||
writeFile(join(directory, 'config', 'nginx.conf'), renderNginxConfig(project)),
|
||||
writeFile(join(directory, 'config', 'mariadb.cnf'), renderMariaDbConfig(project))
|
||||
])
|
||||
try {
|
||||
await access(join(directory, 'data', 'mariadb', 'mysql'))
|
||||
} catch {
|
||||
await execFileAsync(
|
||||
join(runtimeRoot(), 'bin', 'mariadb-install-db'),
|
||||
[
|
||||
'--no-defaults',
|
||||
`--datadir=${join(directory, 'data', 'mariadb')}`,
|
||||
`--tmpdir=${join(directory, 'tmp')}`,
|
||||
'--auth-root-authentication-method=normal',
|
||||
'--skip-test-db'
|
||||
],
|
||||
{ cwd: project.root, maxBuffer: 16 * 1024 * 1024 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function nativeServiceSpecs(project: NativeProjectDefinition): NativeServiceSpec[] {
|
||||
const directory = nativeDirectory(project.root)
|
||||
const common = (id: string): Pick<NativeServiceSpec, 'id' | 'cwd' | 'logPath' | 'pidPath'> => ({
|
||||
id: `${project.name}:${id}`,
|
||||
cwd: project.root,
|
||||
logPath: join(directory, 'logs', `${id}-process.log`),
|
||||
pidPath: join(directory, 'pids', `${id}-process.json`)
|
||||
})
|
||||
return [
|
||||
{
|
||||
...common('database'),
|
||||
command: join(runtimeRoot(), 'bin', 'mariadbd'),
|
||||
args: [`--defaults-file=${join(directory, 'config', 'mariadb.cnf')}`],
|
||||
ready: { port: project.ports.database, timeoutMs: 30000 }
|
||||
},
|
||||
{
|
||||
...common('php'),
|
||||
command: join(runtimeRoot(), 'bin', 'php-fpm'),
|
||||
args: ['--nodaemonize', '--fpm-config', join(directory, 'config', 'php-fpm.conf')],
|
||||
ready: { port: project.ports.php }
|
||||
},
|
||||
{
|
||||
...common('web'),
|
||||
command: join(runtimeRoot(), 'bin', 'nginx'),
|
||||
args: ['-c', join(directory, 'config', 'nginx.conf'), '-p', `${directory}/`],
|
||||
ready: { port: project.ports.http }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export async function startNativeProject(project: NativeProjectDefinition): Promise<void> {
|
||||
await provisionNativeProject(project)
|
||||
for (const spec of nativeServiceSpecs(project)) await supervisor.start(spec)
|
||||
}
|
||||
|
||||
export async function stopNativeProject(project: NativeProjectDefinition): Promise<void> {
|
||||
for (const spec of nativeServiceSpecs(project).reverse()) await supervisor.stop(spec)
|
||||
}
|
||||
|
||||
export async function nativeProjectStatus(
|
||||
project: NativeProjectDefinition
|
||||
): Promise<{ running: boolean; services: Record<string, 'running' | 'stopped'> }> {
|
||||
const entries = await Promise.all(
|
||||
nativeServiceSpecs(project).map(
|
||||
async (spec) => [spec.id.split(':').at(-1)!, await supervisor.status(spec)] as const
|
||||
)
|
||||
)
|
||||
const services = Object.fromEntries(entries)
|
||||
return {
|
||||
running: entries.length > 0 && entries.every(([, status]) => status === 'running'),
|
||||
services
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { validateNativeRuntimeManifest } from './nativeRuntime'
|
||||
import { validateArchiveEntries, validateNativeRuntimeManifest } from './nativeRuntime'
|
||||
|
||||
const valid = {
|
||||
schema: 1,
|
||||
@@ -28,4 +28,13 @@ describe('Aurora Native runtime manifest', () => {
|
||||
components: [{ ...valid.components[0], sha256: 'bad' }]
|
||||
})
|
||||
).toThrow(/checksum/))
|
||||
it('rejects archive entries that escape the installation directory', () => {
|
||||
expect(() => validateArchiveEntries('./runtime.json\n./bin/php\n')).not.toThrow()
|
||||
expect(() => validateArchiveEntries('./runtime.json\n../outside\n')).toThrow(
|
||||
/Unsafe runtime archive entry/
|
||||
)
|
||||
expect(() => validateArchiveEntries('/absolute/runtime.json\n')).toThrow(
|
||||
/Unsafe runtime archive entry/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
+111
-18
@@ -1,9 +1,9 @@
|
||||
import { app } from 'electron'
|
||||
import { app, dialog } from 'electron'
|
||||
import { execFile } from 'child_process'
|
||||
import { createHash } from 'crypto'
|
||||
import { createReadStream } from 'fs'
|
||||
import { access, readFile } from 'fs/promises'
|
||||
import { isAbsolute, join, resolve, sep } from 'path'
|
||||
import { access, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm } from 'fs/promises'
|
||||
import { basename, isAbsolute, join, resolve, sep } from 'path'
|
||||
import { promisify } from 'util'
|
||||
import type { AuroraNativeRuntimeManifest, AuroraRuntimeStatus } from '../shared/types'
|
||||
|
||||
@@ -65,7 +65,7 @@ export function validateNativeRuntimeManifest(value: unknown): AuroraNativeRunti
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeRoot(): string {
|
||||
export function runtimeRoot(): string {
|
||||
return join(app.getPath('userData'), 'runtimes', `${process.platform}-${process.arch}`)
|
||||
}
|
||||
|
||||
@@ -79,6 +79,112 @@ async function sha256(path: string): Promise<string> {
|
||||
})
|
||||
}
|
||||
|
||||
async function verifyRuntimeAt(root: string): Promise<AuroraNativeRuntimeManifest> {
|
||||
const manifest = validateNativeRuntimeManifest(
|
||||
JSON.parse(await readFile(join(root, 'runtime.json'), 'utf8'))
|
||||
)
|
||||
if (manifest.platform !== process.platform || manifest.arch !== process.arch)
|
||||
throw new Error(
|
||||
`This runtime targets ${manifest.platform}-${manifest.arch}, not ${process.platform}-${process.arch}.`
|
||||
)
|
||||
const canonicalRoot = resolve(root)
|
||||
for (const component of manifest.components) {
|
||||
const executable = resolve(root, component.executable)
|
||||
if (executable !== canonicalRoot && !executable.startsWith(`${canonicalRoot}${sep}`))
|
||||
throw new Error(`Unsafe executable path for ${component.id}.`)
|
||||
await access(executable)
|
||||
if ((await sha256(executable)) !== component.sha256.toLowerCase())
|
||||
throw new Error(`Checksum verification failed for ${component.id}.`)
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
async function rejectLinks(root: string, current = root): Promise<void> {
|
||||
for (const entry of await readdir(current, { withFileTypes: true })) {
|
||||
const path = join(current, entry.name)
|
||||
const stat = await lstat(path)
|
||||
if (stat.isSymbolicLink())
|
||||
throw new Error(`Runtime archive contains a symbolic link: ${path.slice(root.length + 1)}`)
|
||||
if (stat.isDirectory()) await rejectLinks(root, path)
|
||||
}
|
||||
}
|
||||
|
||||
export function validateArchiveEntries(output: string): void {
|
||||
const entries = output.split(/\r?\n/).filter(Boolean)
|
||||
if (!entries.length || entries.length > 50000)
|
||||
throw new Error('Runtime archive has an invalid file count.')
|
||||
for (const entry of entries) {
|
||||
const normalized = entry.replace(/^\.\//, '')
|
||||
if (!normalized || isAbsolute(normalized) || normalized.split('/').includes('..'))
|
||||
throw new Error(`Unsafe runtime archive entry: ${entry}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function installNativeRuntimeArchive(source: string): Promise<AuroraRuntimeStatus> {
|
||||
if (!source.endsWith('.tar.gz'))
|
||||
throw new Error('Aurora Native runtimes must be .tar.gz archives.')
|
||||
const runtimes = join(app.getPath('userData'), 'runtimes')
|
||||
await mkdir(runtimes, { recursive: true })
|
||||
const staging = await mkdtemp(join(runtimes, '.install-'))
|
||||
const target = runtimeRoot()
|
||||
const backup = `${target}.previous`
|
||||
try {
|
||||
const { stdout } = await execFileAsync('tar', ['-tzf', source], { maxBuffer: 16 * 1024 * 1024 })
|
||||
validateArchiveEntries(stdout)
|
||||
await execFileAsync(
|
||||
'tar',
|
||||
['-xzf', source, '--no-same-owner', '--no-same-permissions', '-C', staging],
|
||||
{ maxBuffer: 16 * 1024 * 1024 }
|
||||
)
|
||||
await rejectLinks(staging)
|
||||
await verifyRuntimeAt(staging)
|
||||
await rm(backup, { recursive: true, force: true })
|
||||
try {
|
||||
await rename(target, backup)
|
||||
} catch {
|
||||
/* first installation */
|
||||
}
|
||||
try {
|
||||
await rename(staging, target)
|
||||
await rm(backup, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
try {
|
||||
await rename(backup, target)
|
||||
} catch {
|
||||
/* no previous runtime */
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return getRuntimeStatus()
|
||||
} finally {
|
||||
await rm(staging, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
export async function pickAndInstallNativeRuntime(): Promise<AuroraRuntimeStatus | null> {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: 'Aurora Native Runtime', extensions: ['gz'] }]
|
||||
})
|
||||
return result.canceled || !result.filePaths[0]
|
||||
? null
|
||||
: installNativeRuntimeArchive(result.filePaths[0])
|
||||
}
|
||||
|
||||
export async function installBundledNativeRuntime(): Promise<AuroraRuntimeStatus> {
|
||||
const directory = join(process.resourcesPath, 'native-runtime')
|
||||
const expected = `aurora-native-0.1.0-${process.platform === 'win32' ? 'win32' : process.platform}-${process.arch}.tar.gz`
|
||||
const candidates = await readdir(directory)
|
||||
const archive =
|
||||
candidates.find((entry) => entry === expected) ??
|
||||
candidates.find((entry) => entry.endsWith(`-${process.platform}-${process.arch}.tar.gz`))
|
||||
if (!archive)
|
||||
throw new Error(
|
||||
`No bundled Aurora Native runtime is available for ${process.platform}-${process.arch}.`
|
||||
)
|
||||
return installNativeRuntimeArchive(join(directory, basename(archive)))
|
||||
}
|
||||
|
||||
async function nativeStatus(): Promise<AuroraRuntimeStatus['native']> {
|
||||
if (!supportedPlatforms.has(process.platform) || !supportedArchitectures.has(process.arch))
|
||||
return {
|
||||
@@ -90,20 +196,7 @@ async function nativeStatus(): Promise<AuroraRuntimeStatus['native']> {
|
||||
}
|
||||
const root = runtimeRoot()
|
||||
try {
|
||||
const manifest = validateNativeRuntimeManifest(
|
||||
JSON.parse(await readFile(join(root, 'runtime.json'), 'utf8'))
|
||||
)
|
||||
if (manifest.platform !== process.platform || manifest.arch !== process.arch)
|
||||
throw new Error('The installed runtime targets a different platform.')
|
||||
const canonicalRoot = resolve(root)
|
||||
for (const component of manifest.components) {
|
||||
const executable = resolve(root, component.executable)
|
||||
if (executable !== canonicalRoot && !executable.startsWith(`${canonicalRoot}${sep}`))
|
||||
throw new Error(`Unsafe executable path for ${component.id}.`)
|
||||
await access(executable)
|
||||
if ((await sha256(executable)) !== component.sha256.toLowerCase())
|
||||
throw new Error(`Checksum verification failed for ${component.id}.`)
|
||||
}
|
||||
const manifest = await verifyRuntimeAt(root)
|
||||
return {
|
||||
available: true,
|
||||
platform: process.platform,
|
||||
|
||||
+32
-7
@@ -87,23 +87,31 @@ const api = {
|
||||
},
|
||||
modules: {
|
||||
listRegistry: (): Promise<AuroraModuleManifest[]> => ipcRenderer.invoke('modules:listRegistry'),
|
||||
listAvailable: (): Promise<AuroraAvailableModule[]> => ipcRenderer.invoke('modules:listAvailable'),
|
||||
listAvailable: (): Promise<AuroraAvailableModule[]> =>
|
||||
ipcRenderer.invoke('modules:listAvailable'),
|
||||
listInstalled: (name: string): Promise<AuroraInstalledModule[]> =>
|
||||
ipcRenderer.invoke('modules:listInstalled', name),
|
||||
pickAndInstallPackage: (): Promise<AuroraModuleInstallResult | null> =>
|
||||
ipcRenderer.invoke('modules:pickAndInstallPackage'),
|
||||
installPackage: (source: string): Promise<AuroraModuleInstallResult> =>
|
||||
ipcRenderer.invoke('modules:installPackage', source),
|
||||
uninstallPackage: (operationId: string, id: string): Promise<void> => ipcRenderer.invoke('modules:uninstallPackage', operationId, id),
|
||||
uninstallPackage: (operationId: string, id: string): Promise<void> =>
|
||||
ipcRenderer.invoke('modules:uninstallPackage', operationId, id),
|
||||
install: (
|
||||
operationId: string,
|
||||
name: string,
|
||||
moduleId: string,
|
||||
settings: Record<string, string | number | boolean>
|
||||
): Promise<void> => ipcRenderer.invoke('modules:install', operationId, name, moduleId, settings),
|
||||
): Promise<void> =>
|
||||
ipcRenderer.invoke('modules:install', operationId, name, moduleId, settings),
|
||||
remove: (operationId: string, name: string, moduleId: string): Promise<void> =>
|
||||
ipcRenderer.invoke('modules:remove', operationId, name, moduleId),
|
||||
runProjectTool: (operationId: string, name: string, moduleId: string, toolId: string): Promise<void> =>
|
||||
runProjectTool: (
|
||||
operationId: string,
|
||||
name: string,
|
||||
moduleId: string,
|
||||
toolId: string
|
||||
): Promise<void> =>
|
||||
ipcRenderer.invoke('modules:runProjectTool', operationId, name, moduleId, toolId)
|
||||
},
|
||||
logs: {
|
||||
@@ -139,8 +147,21 @@ const api = {
|
||||
docroot,
|
||||
stack
|
||||
),
|
||||
runModuleProjectCreate: (operationId: string, moduleId: string, directory: string, projectName: string, settings: Record<string, string | number | boolean>): Promise<void> =>
|
||||
ipcRenderer.invoke('create:runModuleProjectCreate', operationId, moduleId, directory, projectName, settings),
|
||||
runModuleProjectCreate: (
|
||||
operationId: string,
|
||||
moduleId: string,
|
||||
directory: string,
|
||||
projectName: string,
|
||||
settings: Record<string, string | number | boolean>
|
||||
): Promise<void> =>
|
||||
ipcRenderer.invoke(
|
||||
'create:runModuleProjectCreate',
|
||||
operationId,
|
||||
moduleId,
|
||||
directory,
|
||||
projectName,
|
||||
settings
|
||||
)
|
||||
},
|
||||
secrets: {
|
||||
getSiteCredentials: (approot: string): Promise<AuroraSiteCredentials | null> =>
|
||||
@@ -159,7 +180,11 @@ const api = {
|
||||
},
|
||||
runtime: {
|
||||
status: (): Promise<AuroraRuntimeStatus> => ipcRenderer.invoke('runtime:status'),
|
||||
updates: (): Promise<AuroraRuntimeUpdateStatus> => ipcRenderer.invoke('runtime:updates')
|
||||
updates: (): Promise<AuroraRuntimeUpdateStatus> => ipcRenderer.invoke('runtime:updates'),
|
||||
installBundled: (): Promise<AuroraRuntimeStatus> =>
|
||||
ipcRenderer.invoke('runtime:installBundled'),
|
||||
pickAndInstall: (): Promise<AuroraRuntimeStatus | null> =>
|
||||
ipcRenderer.invoke('runtime:pickAndInstall')
|
||||
},
|
||||
zoom: {
|
||||
in: (): Promise<number> => ipcRenderer.invoke('window:zoomIn'),
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import { CheckCircle2, Minus, Plus, RefreshCw, RotateCcw, X } from 'lucide-react'
|
||||
import {
|
||||
CheckCircle2,
|
||||
Download,
|
||||
FolderArchive,
|
||||
Minus,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
import { useThemeStore, type Theme } from '../../stores/themeStore'
|
||||
import { useRuntimeUpdates } from '../../hooks/useRuntime'
|
||||
import {
|
||||
useInstallNativeRuntime,
|
||||
useRuntimeStatus,
|
||||
useRuntimeUpdates
|
||||
} from '../../hooks/useRuntime'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
|
||||
const THEMES: { value: Theme; label: string }[] = [
|
||||
{ value: 'light', label: 'Light' },
|
||||
@@ -20,10 +34,23 @@ export function SettingsModal({ onClose }: { onClose: () => void }): React.JSX.E
|
||||
const theme = useThemeStore((s) => s.theme)
|
||||
const setTheme = useThemeStore((s) => s.setTheme)
|
||||
const runtimeUpdates = useRuntimeUpdates()
|
||||
const runtimeStatus = useRuntimeStatus()
|
||||
const installRuntime = useInstallNativeRuntime()
|
||||
const addToast = useToastStore((s) => s.addToast)
|
||||
|
||||
async function install(source: 'bundled' | 'file'): Promise<void> {
|
||||
try {
|
||||
const installed = await installRuntime.mutateAsync(source)
|
||||
if (installed)
|
||||
addToast('success', `Aurora Native ${installed.native.runtimeVersion} installed.`)
|
||||
} catch (error) {
|
||||
addToast('error', error instanceof Error ? error.message : 'Runtime installation failed.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-8">
|
||||
<div className="flex w-full max-w-md flex-col rounded-xl bg-white shadow-2xl dark:bg-neutral-900">
|
||||
<div className="flex max-h-[calc(100vh-4rem)] w-full max-w-md flex-col rounded-xl bg-white shadow-2xl dark:bg-neutral-900">
|
||||
<div className="flex items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
|
||||
<h2 className="text-sm font-semibold">Settings</h2>
|
||||
<button
|
||||
@@ -35,18 +62,89 @@ export function SettingsModal({ onClose }: { onClose: () => void }): React.JSX.E
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 p-4">
|
||||
<div className="flex flex-col gap-6 overflow-y-auto p-4">
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
||||
Aurora Native engine
|
||||
</h3>
|
||||
<div className="rounded-lg border border-neutral-200 p-3 text-sm dark:border-neutral-700">
|
||||
<p className="font-medium">
|
||||
{runtimeStatus.data?.native.available
|
||||
? `Runtime ${runtimeStatus.data.native.runtimeVersion} installed`
|
||||
: 'Runtime not installed'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
{runtimeStatus.data?.native.available
|
||||
? runtimeStatus.data.native.components
|
||||
.map((item) => `${item.id} ${item.version}`)
|
||||
.join(' · ')
|
||||
: runtimeStatus.data?.native.reason}
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={installRuntime.isPending}
|
||||
onClick={() => void install('bundled')}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-cyan-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-cyan-500 disabled:opacity-50"
|
||||
>
|
||||
<Download size={13} />
|
||||
Install bundled
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={installRuntime.isPending}
|
||||
onClick={() => void install('file')}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 px-3 py-1.5 text-xs font-semibold hover:bg-neutral-50 disabled:opacity-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<FolderArchive size={13} />
|
||||
Install from file
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-xs font-medium text-neutral-500 dark:text-neutral-400">Runtime updates</h3>
|
||||
<button type="button" onClick={() => runtimeUpdates.refetch()} disabled={runtimeUpdates.isFetching} className="inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-cyan-700 hover:bg-cyan-50 disabled:opacity-50 dark:text-cyan-300 dark:hover:bg-cyan-400/10"><RefreshCw size={12} className={runtimeUpdates.isFetching ? 'animate-spin' : ''}/>Check now</button>
|
||||
<h3 className="text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
||||
Runtime updates
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runtimeUpdates.refetch()}
|
||||
disabled={runtimeUpdates.isFetching}
|
||||
className="inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-cyan-700 hover:bg-cyan-50 disabled:opacity-50 dark:text-cyan-300 dark:hover:bg-cyan-400/10"
|
||||
>
|
||||
<RefreshCw size={12} className={runtimeUpdates.isFetching ? 'animate-spin' : ''} />
|
||||
Check now
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-lg border border-neutral-200 p-3 text-sm dark:border-neutral-700">
|
||||
{runtimeUpdates.isLoading ? <p className="text-neutral-500">Checking trusted runtime catalog…</p> : runtimeUpdates.data?.updates.length ? (
|
||||
<div className="space-y-2">{runtimeUpdates.data.updates.map((update) => <div key={update.component} className="flex items-center justify-between"><span className="font-medium uppercase">{update.component}</span><span className="text-xs text-amber-700 dark:text-amber-300">{update.installedVersion} → {update.availableVersion}</span></div>)}</div>
|
||||
) : <p className="flex items-center gap-2 text-neutral-600 dark:text-neutral-300"><CheckCircle2 size={15} className="text-emerald-600"/>Installed Aurora runtimes are current.</p>}
|
||||
{runtimeUpdates.data?.message && <p className="mt-2 text-xs text-neutral-500">Using the {runtimeUpdates.data.source} catalog. {runtimeUpdates.data.message}</p>}
|
||||
<p className="mt-2 text-xs text-neutral-400">Updates are announced automatically. Installation remains a user-approved action.</p>
|
||||
{runtimeUpdates.isLoading ? (
|
||||
<p className="text-neutral-500">Checking trusted runtime catalog…</p>
|
||||
) : runtimeUpdates.data?.updates.length ? (
|
||||
<div className="space-y-2">
|
||||
{runtimeUpdates.data.updates.map((update) => (
|
||||
<div key={update.component} className="flex items-center justify-between">
|
||||
<span className="font-medium uppercase">{update.component}</span>
|
||||
<span className="text-xs text-amber-700 dark:text-amber-300">
|
||||
{update.installedVersion} → {update.availableVersion}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="flex items-center gap-2 text-neutral-600 dark:text-neutral-300">
|
||||
<CheckCircle2 size={15} className="text-emerald-600" />
|
||||
Installed Aurora runtimes are current.
|
||||
</p>
|
||||
)}
|
||||
{runtimeUpdates.data?.message && (
|
||||
<p className="mt-2 text-xs text-neutral-500">
|
||||
Using the {runtimeUpdates.data.source} catalog. {runtimeUpdates.data.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-neutral-400">
|
||||
Updates are announced automatically. Installation remains a user-approved action.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { useQuery, type UseQueryResult } from '@tanstack/react-query'
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type UseMutationResult,
|
||||
type UseQueryResult
|
||||
} from '@tanstack/react-query'
|
||||
import type { AuroraRuntimeStatus, AuroraRuntimeUpdateStatus } from '@shared/types'
|
||||
|
||||
export function useRuntimeStatus(): UseQueryResult<AuroraRuntimeStatus, Error> {
|
||||
@@ -9,6 +15,23 @@ export function useRuntimeStatus(): UseQueryResult<AuroraRuntimeStatus, Error> {
|
||||
})
|
||||
}
|
||||
|
||||
export function useInstallNativeRuntime(): UseMutationResult<
|
||||
AuroraRuntimeStatus | null,
|
||||
Error,
|
||||
'bundled' | 'file'
|
||||
> {
|
||||
const client = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (source) =>
|
||||
source === 'bundled'
|
||||
? window.api.runtime.installBundled()
|
||||
: window.api.runtime.pickAndInstall(),
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ['runtime'] })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useRuntimeUpdates(): UseQueryResult<AuroraRuntimeUpdateStatus, Error> {
|
||||
return useQuery({
|
||||
queryKey: ['runtime', 'updates'],
|
||||
|
||||
Reference in New Issue
Block a user