Add secure native runtime installation

This commit is contained in:
reaper
2026-08-22 03:47:09 -05:00
parent bd21884925
commit 6fc33cc8a4
12 changed files with 556 additions and 41 deletions
+2
View File
@@ -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.
+4
View File
@@ -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:
+31 -1
View File
@@ -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" <<EOF
@@ -61,6 +63,34 @@ EOF
chmod +x "$stage/bin/$name"
done
# mariadb-install-db is a shell script that calls helpers below --basedir.
# Put the ELF programs behind relocatable wrappers so those calls also use the
# bundled musl loader instead of the host's /lib interpreter.
mkdir -p "$root/usr/libexec/aurora"
for name in mariadbd my_print_defaults resolveip; do
mv "$root/usr/bin/$name" "$root/usr/libexec/aurora/$name"
cat > "$root/usr/bin/$name" <<EOF
#!/bin/sh
runtime_root=\$(CDPATH= cd -- "\$(dirname -- "\$0")/../../.." && pwd)
exec "\$runtime_root/bin/aurora-exec" "/usr/libexec/aurora/$name" "\$@"
EOF
chmod +x "$root/usr/bin/$name"
done
cat > "$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')
+26 -1
View File
@@ -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 })
}
+7 -1
View File
@@ -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())
}
+27
View File
@@ -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')
})
})
+173
View File
@@ -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
}
}
+10 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+24 -1
View File
@@ -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'],