Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 90 additions & 2 deletions apps/desktop/build/installer.nsh
Original file line number Diff line number Diff line change
@@ -1,12 +1,80 @@
; PowerShell helpers are written to $PLUGINSDIR at run time. NSIS strings are
; length-limited and `$`, `"` and `${}` all need escaping, so keep each helper
; one FileWrite per line and use single-quoted PowerShell string literals.

; Stops every process whose executable lives under the install directory and
; waits for the process list to drain instead of sleeping a fixed second.
; Exit code: number of processes still alive at the deadline.
!macro writeStopInstallProcessesScript PATH
FileOpen $R9 "${PATH}" w
FileWrite $R9 "param([string]$$Root, [int]$$TimeoutSeconds = 20)$\r$\n"
FileWrite $R9 "$$deadline = [DateTime]::UtcNow.AddSeconds($$TimeoutSeconds)$\r$\n"
FileWrite $R9 "$$running = @()$\r$\n"
FileWrite $R9 "do {$\r$\n"
FileWrite $R9 " $$running = @(Get-CimInstance -ClassName Win32_Process | Where-Object { $$_.ExecutablePath -and $$_.ExecutablePath.StartsWith($$Root, [System.StringComparison]::OrdinalIgnoreCase) })$\r$\n"
FileWrite $R9 " foreach ($$p in $$running) { Stop-Process -Id $$p.ProcessId -Force -ErrorAction SilentlyContinue }$\r$\n"
FileWrite $R9 " if ($$running.Count -eq 0) { break }$\r$\n"
FileWrite $R9 " Start-Sleep -Milliseconds 250$\r$\n"
FileWrite $R9 "} while ([DateTime]::UtcNow -lt $$deadline)$\r$\n"
FileWrite $R9 "foreach ($$p in $$running) { Write-Output ('still running: ' + $$p.ProcessId + ' ' + $$p.ExecutablePath) }$\r$\n"
FileWrite $R9 "exit $$running.Count$\r$\n"
FileClose $R9
!macroend

; Compares the installed resources tree with the inventory that
; scripts/desktop-after-pack.mjs wrote beside app.asar. Windows PowerShell's
; ConvertFrom-Json caps input near 2 MB, so the inventory is parsed with the
; underlying serializer and an explicit limit. Exit code: 0 complete,
; 1 missing/truncated files, 2 inventory unreadable.
!macro writeVerifyInstallScript PATH
FileOpen $R9 "${PATH}" w
FileWrite $R9 "param([string]$$Root)$\r$\n"
FileWrite $R9 "$$manifest = Join-Path $$Root 'openalice-integrity.json'$\r$\n"
FileWrite $R9 "if (-not [System.IO.File]::Exists($$manifest)) { Write-Output 'inventory missing: openalice-integrity.json'; exit 2 }$\r$\n"
FileWrite $R9 "$$data = $$null$\r$\n"
FileWrite $R9 "try {$\r$\n"
FileWrite $R9 " Add-Type -AssemblyName System.Web.Extensions$\r$\n"
FileWrite $R9 " $$serializer = New-Object System.Web.Script.Serialization.JavaScriptSerializer$\r$\n"
FileWrite $R9 " $$serializer.MaxJsonLength = [int]::MaxValue$\r$\n"
FileWrite $R9 " $$data = $$serializer.DeserializeObject([System.IO.File]::ReadAllText($$manifest))$\r$\n"
FileWrite $R9 "} catch {$\r$\n"
FileWrite $R9 " try { $$data = [System.IO.File]::ReadAllText($$manifest) | ConvertFrom-Json } catch { Write-Output ('inventory unreadable: ' + $$_.Exception.Message); exit 2 }$\r$\n"
FileWrite $R9 "}$\r$\n"
FileWrite $R9 "$$files = @($$data.files)$\r$\n"
FileWrite $R9 "$$bad = 0$\r$\n"
FileWrite $R9 "foreach ($$entry in $$files) {$\r$\n"
FileWrite $R9 " $$ok = $$false$\r$\n"
FileWrite $R9 " try {$\r$\n"
FileWrite $R9 " $$info = New-Object System.IO.FileInfo (Join-Path $$Root ([string]$$entry[0]))$\r$\n"
FileWrite $R9 " $$ok = $$info.Exists -and ($$null -eq $$entry[1] -or $$info.Length -eq [int64]$$entry[1])$\r$\n"
FileWrite $R9 " } catch { $$ok = $$false }$\r$\n"
FileWrite $R9 " if (-not $$ok) {$\r$\n"
FileWrite $R9 " $$bad++$\r$\n"
FileWrite $R9 " if ($$bad -le 5) { Write-Output ([string]$$entry[0]) }$\r$\n"
FileWrite $R9 " }$\r$\n"
FileWrite $R9 "}$\r$\n"
FileWrite $R9 "Write-Output ('checked ' + $$files.Count + ' files, problems ' + $$bad)$\r$\n"
FileWrite $R9 "if ($$bad -gt 0) { exit 1 }$\r$\n"
FileWrite $R9 "exit 0$\r$\n"
FileClose $R9
!macroend

!macro customInit
${if} ${isUpdated}
InitPluginsDir
DetailPrint "Closing the legacy OpenAlice process tree before update."
nsExec::ExecToLog '"$SYSDIR\taskkill.exe" /T /F /IM "${APP_EXECUTABLE_FILENAME}"'
Pop $0
; Guardian children (UTA, Workspace CLIs, managed Node/Git) can outlive the
; Electron tree briefly. Extracting while they hold handles open leaves a
; partial install, so wait for the process list under $INSTDIR to drain.
DetailPrint "Closing remaining processes launched from the OpenAlice install directory."
nsExec::ExecToLog `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -Command "Get-CimInstance -ClassName Win32_Process | Where-Object {$$_.ExecutablePath -and $$_.ExecutablePath.StartsWith('$INSTDIR', [System.StringComparison]::OrdinalIgnoreCase)} | ForEach-Object { Stop-Process -Id $$_.ProcessId -Force -ErrorAction SilentlyContinue }"`
!insertmacro writeStopInstallProcessesScript "$PLUGINSDIR\openalice-stop-install-processes.ps1"
nsExec::ExecToLog '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\openalice-stop-install-processes.ps1" -Root "$INSTDIR"'
Pop $0
Sleep 1000
${if} $0 != 0
DetailPrint "Processes from the OpenAlice install directory are still running (count $0)."
${endif}

; Legacy non-ASAR releases and external runtime payloads can contain paths
; beyond the legacy MAX_PATH limit. Their NSIS uninstaller repeatedly
Expand All @@ -30,3 +98,23 @@
DeleteRegValue SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}" "QuietUninstallString"
${endif}
!macroend

; Runs after extraction and before electron-builder's force-run/finish launch.
; electron-builder's extraction falls back to a non-atomic 7z extract that
; ignores per-file errors, so a locked or long path can otherwise ship a
; partial tree that only fails at first launch. Refuse to hand off to the app.
!macro customInstall
DetailPrint "Verifying the installed OpenAlice files."
!insertmacro writeVerifyInstallScript "$PLUGINSDIR\openalice-verify-install.ps1"
nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\openalice-verify-install.ps1" -Root "$INSTDIR\resources"'
Pop $0
Pop $1
${if} $0 != 0
DetailPrint "OpenAlice install verification failed (exit $0)."
DetailPrint "$1"
MessageBox MB_OK|MB_ICONSTOP "OpenAlice was not installed completely.$\r$\n$\r$\nFiles are missing or truncated under:$\r$\n$INSTDIR$\r$\n$\r$\nClose OpenAlice and any program scanning that folder, then run this installer again. Your OpenAlice data is not affected.$\r$\n$\r$\n$1" /SD IDOK
SetErrorLevel 3
Abort "OpenAlice was not installed completely."
${endif}
DetailPrint "OpenAlice install verified: $1"
!macroend
100 changes: 100 additions & 0 deletions apps/desktop/src/install-integrity.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
INSTALL_INTEGRITY_FILE,
describeInstallIntegrityFailure,
summarizeInstallIntegrity,
verifyInstallIntegrity,
} from './install-integrity.js'

const roots: string[] = []

async function resourcesFixture(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'openalice-install-integrity-'))
roots.push(root)
await mkdir(join(root, 'runtime/vendor/pi'), { recursive: true })
await mkdir(join(root, 'app.asar.unpacked/node_modules/node-pty/build/Release'), { recursive: true })
const contents: Array<[string, string]> = [
['app.asar', 'archive-bytes'],
['app.asar.unpacked/node_modules/node-pty/build/Release/pty.node', 'native'],
['runtime/package.json', '{"version":"0.92.1"}'],
['runtime/vendor/pi/package.json', '{"name":"pi"}'],
]
for (const [file, body] of contents) await writeFile(join(root, file), body)
await writeFile(join(root, INSTALL_INTEGRITY_FILE), JSON.stringify({
version: '0.92.1',
files: contents.map(([file, body]) => [file, Buffer.byteLength(body)]),
}))
return root
}

afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})

describe('packaged install integrity', () => {
it('verifies every inventoried file by presence and size', async () => {
const root = await resourcesFixture()
const result = await verifyInstallIntegrity(root, { concurrency: 2 })
expect(result).toMatchObject({ status: 'verified', checked: 4 })
expect(summarizeInstallIntegrity(result)).toMatch(/^verified 4 files in \d+ms$/)
})

it('reports missing and truncated files sorted for the reinstall dialog', async () => {
const root = await resourcesFixture()
await rm(join(root, 'runtime/vendor/pi/package.json'))
await writeFile(join(root, 'app.asar.unpacked/node_modules/node-pty/build/Release/pty.node'), 'nat')
await writeFile(join(root, 'app.asar'), 'archive-bytes-plus-trailing-garbage')

const result = await verifyInstallIntegrity(root)
expect(result).toMatchObject({
status: 'damaged',
checked: 4,
missing: ['runtime/vendor/pi/package.json'],
mismatched: ['app.asar', 'app.asar.unpacked/node_modules/node-pty/build/Release/pty.node'],
})
if (result.status !== 'damaged') throw new Error('expected damaged result')
expect(summarizeInstallIntegrity(result)).toContain('damaged: 1 missing, 2 truncated of 4 files')
const message = describeInstallIntegrityFailure(result, {
version: '0.92.1',
installRoot: root,
diagnosticsPath: join(root, 'desktop.log'),
})
expect(message).toContain('OpenAlice 0.92.1 is missing part of its installation')
expect(message).toContain('1 file(s) are missing and 2 are truncated under:')
expect(message).toContain(root)
expect(message).toContain(' runtime/vendor/pi/package.json')
expect(message).toContain('Download the installer again and reinstall OpenAlice.')
expect(message).toContain(join(root, 'desktop.log'))
})

it('treats a missing or malformed inventory as unverifiable', async () => {
const root = await resourcesFixture()
await writeFile(join(root, INSTALL_INTEGRITY_FILE), '{"files":"nope"}')
const malformed = await verifyInstallIntegrity(root)
expect(malformed).toMatchObject({ status: 'unverifiable' })
if (malformed.status !== 'unverifiable') throw new Error('expected unverifiable result')
expect(malformed.reason).toContain(INSTALL_INTEGRITY_FILE)
expect(describeInstallIntegrityFailure(malformed, { version: '0.92.1', installRoot: root }))
.toContain('could not verify its installed files')

await rm(join(root, INSTALL_INTEGRITY_FILE))
await expect(verifyInstallIntegrity(root)).resolves.toMatchObject({ status: 'unverifiable' })
})

it.skipIf(process.platform === 'win32')('checks symlinks by presence only', async () => {
const root = await resourcesFixture()
await symlink('../package.json', join(root, 'runtime/vendor/link'))
await writeFile(join(root, INSTALL_INTEGRITY_FILE), JSON.stringify({
version: '0.92.1',
files: [['runtime/vendor/link', null], ['runtime/vendor/missing-link', null]],
}))
await expect(verifyInstallIntegrity(root)).resolves.toMatchObject({
status: 'damaged',
missing: ['runtime/vendor/missing-link'],
mismatched: [],
})
})
})
120 changes: 120 additions & 0 deletions apps/desktop/src/install-integrity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { lstat, readFile } from 'node:fs/promises'
import { join } from 'node:path'

// Written by scripts/desktop-after-pack.mjs beside app.asar. Keep the file
// name and entry shape in sync with scripts/desktop-install-integrity.mjs.
export const INSTALL_INTEGRITY_FILE = 'openalice-integrity.json'
export const INSTALL_INTEGRITY_SKIP_ENV = 'OPENALICE_DESKTOP_SKIP_INSTALL_INTEGRITY'
export const REINSTALL_URL = 'https://github.com/TraderAlice/OpenAlice/releases/latest'

export interface InstallIntegrityManifest {
version: string
files: Array<[path: string, size: number | null]>
}

export type InstallIntegrityResult =
| { status: 'verified'; checked: number; durationMs: number }
| { status: 'damaged'; checked: number; missing: string[]; mismatched: string[]; durationMs: number }
| { status: 'unverifiable'; reason: string }

const MAX_REPORTED_PATHS = 8

export async function readInstallIntegrityManifest(resourcesPath: string): Promise<InstallIntegrityManifest> {
const raw = await readFile(join(resourcesPath, INSTALL_INTEGRITY_FILE), 'utf8')
const parsed: unknown = JSON.parse(raw)
if (
typeof parsed !== 'object' || parsed === null ||
typeof (parsed as { version?: unknown }).version !== 'string' ||
!Array.isArray((parsed as { files?: unknown }).files)
) {
throw new Error(`${INSTALL_INTEGRITY_FILE} has no version/files inventory`)
}
return parsed as InstallIntegrityManifest
}

export async function verifyInstallIntegrity(
resourcesPath: string,
options: { manifest?: InstallIntegrityManifest; concurrency?: number } = {},
): Promise<InstallIntegrityResult> {
const startedAt = Date.now()
let manifest = options.manifest
if (!manifest) {
try {
manifest = await readInstallIntegrityManifest(resourcesPath)
} catch (error) {
return { status: 'unverifiable', reason: error instanceof Error ? error.message : String(error) }
}
}
const missing: string[] = []
const mismatched: string[] = []
const entries = manifest.files
const concurrency = Math.max(1, options.concurrency ?? 32)
let cursor = 0
const worker = async () => {
while (cursor < entries.length) {
const entry = entries[cursor++]
if (!entry) continue
const [relativePath, size] = entry
try {
const stat = await lstat(join(resourcesPath, relativePath))
if (size !== null && !stat.isSymbolicLink() && stat.size !== size) mismatched.push(relativePath)
} catch {
missing.push(relativePath)
}
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, entries.length || 1) }, worker))
const durationMs = Date.now() - startedAt
if (missing.length === 0 && mismatched.length === 0) {
return { status: 'verified', checked: entries.length, durationMs }
}
missing.sort()
mismatched.sort()
return { status: 'damaged', checked: entries.length, missing, mismatched, durationMs }
}

export function summarizeInstallIntegrity(result: InstallIntegrityResult): string {
switch (result.status) {
case 'verified':
return `verified ${result.checked} files in ${result.durationMs}ms`
case 'unverifiable':
return `inventory unavailable: ${result.reason}`
case 'damaged': {
const sample = [...result.missing, ...result.mismatched].slice(0, MAX_REPORTED_PATHS)
return (
`damaged: ${result.missing.length} missing, ${result.mismatched.length} truncated ` +
`of ${result.checked} files in ${result.durationMs}ms; first: ${sample.join(', ')}`
)
}
}
}

export function describeInstallIntegrityFailure(
result: Exclude<InstallIntegrityResult, { status: 'verified' }>,
context: { version: string; installRoot: string; diagnosticsPath?: string },
): string {
const lines: string[] = []
if (result.status === 'unverifiable') {
lines.push(`OpenAlice ${context.version} could not verify its installed files.`, '', result.reason)
} else {
lines.push(
`OpenAlice ${context.version} is missing part of its installation, so it did not start.`,
'',
`${result.missing.length} file(s) are missing and ${result.mismatched.length} are truncated under:`,
context.installRoot,
)
const sample = [...result.missing, ...result.mismatched].slice(0, MAX_REPORTED_PATHS)
if (sample.length > 0) {
lines.push('', ...sample.map((file) => ` ${file}`))
const remaining = result.missing.length + result.mismatched.length - sample.length
if (remaining > 0) lines.push(` ... and ${remaining} more`)
}
}
lines.push(
'',
'This usually means the installer or an update was interrupted, or another program removed files from the install directory.',
'Download the installer again and reinstall OpenAlice. Your data directory is not affected.',
)
if (context.diagnosticsPath) lines.push('', `Diagnostic log:\n${context.diagnosticsPath}`)
return lines.join('\n')
}
38 changes: 37 additions & 1 deletion apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
* Out of scope (future iterations): tray icon, multi-window, native menus.
*/

import { app, BrowserWindow, dialog, Menu, Notification, protocol, session } from 'electron'
import { app, BrowserWindow, dialog, Menu, Notification, protocol, session, shell } from 'electron'
import { runRendererTradingModeSmoke } from './trading-mode-smoke.js'
import { runRendererDataHomeSmoke } from './data-home-smoke.js'
import { runRendererWorkspaceAcceptanceSmoke } from './workspace-acceptance-smoke.js'
Expand Down Expand Up @@ -59,6 +59,13 @@ import { existingOwnerSmokeMode, resolveExistingOwnerStartup } from './existing-
import { inspectPreviousUpdateAttempt, recordUpdateAttempt } from './update-attempt.js'
import { childIsRunning, stopChild } from './child-shutdown.js'
import { exitDesktopProcess } from './app-exit.js'
import {
INSTALL_INTEGRITY_SKIP_ENV,
REINSTALL_URL,
describeInstallIntegrityFailure,
summarizeInstallIntegrity,
verifyInstallIntegrity,
} from './install-integrity.js'

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
Expand Down Expand Up @@ -583,6 +590,35 @@ app.whenReady().then(async () => {
)
}

// A partially extracted or partially deleted install otherwise fails later
// with an arbitrary missing module or toolchain error. Refuse to start and
// point at a reinstall before touching the selected data home.
if (app.isPackaged && !truthyEnv(process.env[INSTALL_INTEGRITY_SKIP_ENV])) {
const integrity = await verifyInstallIntegrity(process.resourcesPath)
desktopDiagnostics.write('install-integrity', summarizeInstallIntegrity(integrity))
if (integrity.status !== 'verified') {
const choice = dialog.showMessageBoxSync({
type: 'error',
title: 'OpenAlice — installation incomplete',
message: 'OpenAlice cannot start because its installation is incomplete.',
detail: describeInstallIntegrityFailure(integrity, {
version: app.getVersion(),
installRoot: process.platform === 'darwin'
? dirname(dirname(process.resourcesPath))
: dirname(process.resourcesPath),
diagnosticsPath: desktopDiagnostics.path,
}),
buttons: ['Download installer', 'Quit'],
defaultId: 0,
cancelId: 1,
noLink: true,
})
if (choice === 0) await shell.openExternal(REINSTALL_URL)
app.quit()
return
}
}

// Build output lives at <repo>/dist/electron/main.js, <repo>/dist/main.js
// (Alice), <repo>/services/uta/dist/uta.js (UTA), and the optional
// <repo>/services/connector/dist/connector.js. The desktop package
Expand Down
Loading