From 6fc33cc8a4f790f6e8538d0d59ba55b7a0411a81 Mon Sep 17 00:00:00 2001 From: reaper Date: Sat, 22 Aug 2026 03:47:09 -0500 Subject: [PATCH] Add secure native runtime installation --- docs/AURORA_NATIVE_RUNTIME.md | 2 + electron-builder.yml | 4 + runtime-build/linux-x64/stage-runtime.sh | 32 +++- scripts/smoke-native-runtime.cjs | 27 ++- src/main/ipc/runtime.ts | 8 +- src/main/native/nativeProject.test.ts | 27 +++ src/main/native/nativeProject.ts | 173 ++++++++++++++++++ src/main/nativeRuntime.test.ts | 11 +- src/main/nativeRuntime.ts | 129 +++++++++++-- src/preload/index.ts | 39 +++- .../src/components/settings/SettingsModal.tsx | 120 ++++++++++-- src/renderer/src/hooks/useRuntime.ts | 25 ++- 12 files changed, 556 insertions(+), 41 deletions(-) create mode 100644 src/main/native/nativeProject.test.ts create mode 100644 src/main/native/nativeProject.ts diff --git a/docs/AURORA_NATIVE_RUNTIME.md b/docs/AURORA_NATIVE_RUNTIME.md index 77cd8f4..053b3e3 100644 --- a/docs/AURORA_NATIVE_RUNTIME.md +++ b/docs/AURORA_NATIVE_RUNTIME.md @@ -14,6 +14,8 @@ Runtime archives are created from a staging directory with `npm run build:native The first reproducible bundle target is Linux x64. Run `npm run build:native-linux-x64` on a Docker-capable build machine. Docker is used only to create the portable artifact; users of that artifact do not need Docker. The recipe pins PHP 8.5.9, nginx 1.30.4, and MariaDB 11.8.8 with their runtime libraries, runs version smoke checks outside the build container, and then invokes the normal checksum packager. +Dockside can install a bundled runtime or a user-selected runtime archive from Settings. Installation rejects absolute and parent-traversing archive entries, rejects symbolic links, verifies the target platform and architecture, and verifies every declared executable checksum before atomically replacing an older runtime. Electron packages include matching archives from `dist/native-runtime` when they are present at packaging time. + ## Isolation model Every project receives reserved loopback ports, generated service configuration, isolated database data, logs, PID files, and environment variables below `.aurora/native`. A shared Aurora router owns friendly HTTPS project hostnames. Project files remain directly accessible on the host. diff --git a/electron-builder.yml b/electron-builder.yml index 075fdff..7727807 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -18,6 +18,10 @@ extraResources: to: module-catalog filter: - '*.pac' + - from: dist/native-runtime + to: native-runtime + filter: + - 'aurora-native-*.tar.gz' win: executableName: aurora-dockside nsis: diff --git a/runtime-build/linux-x64/stage-runtime.sh b/runtime-build/linux-x64/stage-runtime.sh index 6ec30d8..65597e9 100644 --- a/runtime-build/linux-x64/stage-runtime.sh +++ b/runtime-build/linux-x64/stage-runtime.sh @@ -27,6 +27,8 @@ copy_binary_and_libraries /usr/local/sbin/php-fpm copy_binary_and_libraries /usr/sbin/nginx copy_binary_and_libraries /usr/bin/mariadbd copy_binary_and_libraries /usr/bin/mariadb-install-db +copy_binary_and_libraries /usr/bin/my_print_defaults +copy_binary_and_libraries /usr/bin/resolveip mkdir -p "$root/lib" "$root/usr/lib" cp -aL /lib/. "$root/lib/" cp -aL /usr/lib/. "$root/usr/lib/" @@ -51,7 +53,7 @@ exec "$runtime_root/root/lib/ld-musl-x86_64.so.1" --library-path "$LD_LIBRARY_PA EOF chmod +x "$stage/bin/aurora-exec" -for entry in 'php:/usr/local/bin/php' 'php-fpm:/usr/local/sbin/php-fpm' 'nginx:/usr/sbin/nginx' 'mariadbd:/usr/bin/mariadbd' 'mariadb-install-db:/usr/bin/mariadb-install-db'; do +for entry in 'php:/usr/local/bin/php' 'php-fpm:/usr/local/sbin/php-fpm' 'nginx:/usr/sbin/nginx' 'mariadbd:/usr/bin/mariadbd'; do name=${entry%%:*} target=${entry#*:} cat > "$stage/bin/$name" < "$root/usr/bin/$name" < "$stage/bin/mariadb-install-db" <<'EOF' +#!/bin/sh +set -eu +runtime_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +exec /bin/sh "$runtime_root/root/usr/bin/mariadb-install-db" --basedir="$runtime_root/root/usr" "$@" +EOF +chmod +x "$stage/bin/mariadb-install-db" + +# The public daemon launcher must now target the relocated ELF binary. +cat > "$stage/bin/mariadbd" <<'EOF' +#!/bin/sh +exec "$(dirname "$0")/aurora-exec" /usr/libexec/aurora/mariadbd "$@" +EOF +chmod +x "$stage/bin/mariadbd" + php_version=$(php -r 'echo PHP_VERSION;') nginx_version=$(nginx -v 2>&1 | sed 's#nginx version: nginx/##') mariadb_version=$(mariadbd --version | sed -n 's/.* Ver \([^ -]*\).*/\1/p') diff --git a/scripts/smoke-native-runtime.cjs b/scripts/smoke-native-runtime.cjs index 2efd60b..b274aba 100644 --- a/scripts/smoke-native-runtime.cjs +++ b/scripts/smoke-native-runtime.cjs @@ -1,7 +1,8 @@ 'use strict' /* eslint-disable @typescript-eslint/no-require-imports */ -const { existsSync, readFileSync } = require('fs') +const { existsSync, mkdtempSync, readFileSync, rmSync } = require('fs') +const { tmpdir } = require('os') const { join, resolve } = require('path') const { spawnSync } = require('child_process') @@ -38,3 +39,27 @@ for (const [name, command, args, version] of checks) { throw new Error(`${name} did not report expected version ${version}: ${output}`) process.stdout.write(`${name} ${version} OK\n`) } + +const databaseDirectory = mkdtempSync(join(tmpdir(), 'aurora-native-mariadb-')) +const temporaryDirectory = mkdtempSync(join(tmpdir(), 'aurora-native-mariadb-tmp-')) +try { + const result = spawnSync( + join(root, 'bin/mariadb-install-db'), + [ + '--no-defaults', + `--datadir=${databaseDirectory}`, + `--tmpdir=${temporaryDirectory}`, + '--auth-root-authentication-method=normal', + '--skip-test-db' + ], + { encoding: 'utf8' } + ) + if (result.error || result.status !== 0) + throw result.error || new Error(`MariaDB initialization failed: ${result.stderr}`) + if (!existsSync(join(databaseDirectory, 'mysql'))) + throw new Error('MariaDB initialization did not create system tables.') + process.stdout.write('MariaDB initialization OK\n') +} finally { + rmSync(databaseDirectory, { recursive: true, force: true }) + rmSync(temporaryDirectory, { recursive: true, force: true }) +} diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index e8999c4..77094bb 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -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()) } diff --git a/src/main/native/nativeProject.test.ts b/src/main/native/nativeProject.test.ts new file mode 100644 index 0000000..8f14122 --- /dev/null +++ b/src/main/native/nativeProject.test.ts @@ -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') + }) +}) diff --git a/src/main/native/nativeProject.ts b/src/main/native/nativeProject.ts new file mode 100644 index 0000000..d22776f --- /dev/null +++ b/src/main/native/nativeProject.ts @@ -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 { + 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 => ({ + 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 { + await provisionNativeProject(project) + for (const spec of nativeServiceSpecs(project)) await supervisor.start(spec) +} + +export async function stopNativeProject(project: NativeProjectDefinition): Promise { + for (const spec of nativeServiceSpecs(project).reverse()) await supervisor.stop(spec) +} + +export async function nativeProjectStatus( + project: NativeProjectDefinition +): Promise<{ running: boolean; services: Record }> { + 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 + } +} diff --git a/src/main/nativeRuntime.test.ts b/src/main/nativeRuntime.test.ts index f9243d9..611f8db 100644 --- a/src/main/nativeRuntime.test.ts +++ b/src/main/nativeRuntime.test.ts @@ -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/ + ) + }) }) diff --git a/src/main/nativeRuntime.ts b/src/main/nativeRuntime.ts index 680e6e0..8966a9e 100644 --- a/src/main/nativeRuntime.ts +++ b/src/main/nativeRuntime.ts @@ -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 { }) } +async function verifyRuntimeAt(root: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { if (!supportedPlatforms.has(process.platform) || !supportedArchitectures.has(process.arch)) return { @@ -90,20 +196,7 @@ async function nativeStatus(): Promise { } 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, diff --git a/src/preload/index.ts b/src/preload/index.ts index 4449475..c7e33aa 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -87,23 +87,31 @@ const api = { }, modules: { listRegistry: (): Promise => ipcRenderer.invoke('modules:listRegistry'), - listAvailable: (): Promise => ipcRenderer.invoke('modules:listAvailable'), + listAvailable: (): Promise => + ipcRenderer.invoke('modules:listAvailable'), listInstalled: (name: string): Promise => ipcRenderer.invoke('modules:listInstalled', name), pickAndInstallPackage: (): Promise => ipcRenderer.invoke('modules:pickAndInstallPackage'), installPackage: (source: string): Promise => ipcRenderer.invoke('modules:installPackage', source), - uninstallPackage: (operationId: string, id: string): Promise => ipcRenderer.invoke('modules:uninstallPackage', operationId, id), + uninstallPackage: (operationId: string, id: string): Promise => + ipcRenderer.invoke('modules:uninstallPackage', operationId, id), install: ( operationId: string, name: string, moduleId: string, settings: Record - ): Promise => ipcRenderer.invoke('modules:install', operationId, name, moduleId, settings), + ): Promise => + ipcRenderer.invoke('modules:install', operationId, name, moduleId, settings), remove: (operationId: string, name: string, moduleId: string): Promise => ipcRenderer.invoke('modules:remove', operationId, name, moduleId), - runProjectTool: (operationId: string, name: string, moduleId: string, toolId: string): Promise => + runProjectTool: ( + operationId: string, + name: string, + moduleId: string, + toolId: string + ): Promise => 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): Promise => - ipcRenderer.invoke('create:runModuleProjectCreate', operationId, moduleId, directory, projectName, settings), + runModuleProjectCreate: ( + operationId: string, + moduleId: string, + directory: string, + projectName: string, + settings: Record + ): Promise => + ipcRenderer.invoke( + 'create:runModuleProjectCreate', + operationId, + moduleId, + directory, + projectName, + settings + ) }, secrets: { getSiteCredentials: (approot: string): Promise => @@ -159,7 +180,11 @@ const api = { }, runtime: { status: (): Promise => ipcRenderer.invoke('runtime:status'), - updates: (): Promise => ipcRenderer.invoke('runtime:updates') + updates: (): Promise => ipcRenderer.invoke('runtime:updates'), + installBundled: (): Promise => + ipcRenderer.invoke('runtime:installBundled'), + pickAndInstall: (): Promise => + ipcRenderer.invoke('runtime:pickAndInstall') }, zoom: { in: (): Promise => ipcRenderer.invoke('window:zoomIn'), diff --git a/src/renderer/src/components/settings/SettingsModal.tsx b/src/renderer/src/components/settings/SettingsModal.tsx index 6a3c903..1bdf8a6 100644 --- a/src/renderer/src/components/settings/SettingsModal.tsx +++ b/src/renderer/src/components/settings/SettingsModal.tsx @@ -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 { + 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 (
-
+

Settings

-
+
+
+

+ Aurora Native engine +

+
+

+ {runtimeStatus.data?.native.available + ? `Runtime ${runtimeStatus.data.native.runtimeVersion} installed` + : 'Runtime not installed'} +

+

+ {runtimeStatus.data?.native.available + ? runtimeStatus.data.native.components + .map((item) => `${item.id} ${item.version}`) + .join(' · ') + : runtimeStatus.data?.native.reason} +

+
+ + +
+
+
-

Runtime updates

- +

+ Runtime updates +

+
- {runtimeUpdates.isLoading ?

Checking trusted runtime catalog…

: runtimeUpdates.data?.updates.length ? ( -
{runtimeUpdates.data.updates.map((update) =>
{update.component}{update.installedVersion} → {update.availableVersion}
)}
- ) :

Installed Aurora runtimes are current.

} - {runtimeUpdates.data?.message &&

Using the {runtimeUpdates.data.source} catalog. {runtimeUpdates.data.message}

} -

Updates are announced automatically. Installation remains a user-approved action.

+ {runtimeUpdates.isLoading ? ( +

Checking trusted runtime catalog…

+ ) : runtimeUpdates.data?.updates.length ? ( +
+ {runtimeUpdates.data.updates.map((update) => ( +
+ {update.component} + + {update.installedVersion} → {update.availableVersion} + +
+ ))} +
+ ) : ( +

+ + Installed Aurora runtimes are current. +

+ )} + {runtimeUpdates.data?.message && ( +

+ Using the {runtimeUpdates.data.source} catalog. {runtimeUpdates.data.message} +

+ )} +

+ Updates are announced automatically. Installation remains a user-approved action. +

diff --git a/src/renderer/src/hooks/useRuntime.ts b/src/renderer/src/hooks/useRuntime.ts index b18f1ba..f217bf3 100644 --- a/src/renderer/src/hooks/useRuntime.ts +++ b/src/renderer/src/hooks/useRuntime.ts @@ -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 { @@ -9,6 +15,23 @@ export function useRuntimeStatus(): UseQueryResult { }) } +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 { return useQuery({ queryKey: ['runtime', 'updates'],