feat(desktop): materialize restored profiles

This commit is contained in:
t4we fan
2026-08-21 17:42:02 +08:00
parent 3d7fadc0e6
commit a8ceec2f39
2 changed files with 341 additions and 0 deletions
@@ -0,0 +1,229 @@
/** Re-materialize a restored profile before the Cordis Host is created. */
import { spawn as childSpawn } from 'node:child_process'
import type { ChildProcess, SpawnOptions } from 'node:child_process'
import { delimiter, isAbsolute } from 'node:path'
import { pathToFileURL } from 'node:url'
const ELECTRON_HEADERS_URL = 'https://electronjs.org/headers'
const DEFAULT_TIMEOUT_MS = 120_000
const DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024
const TERMINATION_GRACE_MS = 3_000
/** Runtime inputs resolved by the Electron bootstrap. */
export interface ProfileMaterializerOptions {
readonly appExecutable: string
readonly clearEnvironmentPath: string
readonly pnpmBinPath: string
readonly nodeBinDir: string
readonly nodeShimPath: string
readonly homeDir: string
readonly profileDir: string
readonly electronVersion: string
readonly signal?: AbortSignal
readonly timeoutMs?: number
readonly maxOutputBytes?: number
/** Injectable only for headless tests; production uses node:child_process.spawn. */
readonly spawn?: ProfileMaterializerSpawn
}
/** The small spawn seam used by tests and embedders. */
export type ProfileMaterializerSpawn = (
command: string,
args: readonly string[],
options: SpawnOptions,
) => ChildProcess
/** Completed package-manager invocation. */
export interface ProfileMaterializationResult {
readonly argv: readonly string[]
readonly cwd: string
readonly exitCode: number | null
readonly signal: NodeJS.Signals | null
readonly stdout: string
readonly stderr: string
}
/** Error raised when profile materialization cannot complete successfully. */
export class ProfileMaterializationError extends Error {
readonly result?: ProfileMaterializationResult
constructor(message: string, result?: ProfileMaterializationResult) {
super(message)
this.name = 'ProfileMaterializationError'
if (result !== undefined) this.result = result
}
}
function inheritedPath(): string {
const exact = process.env.PATH
if (exact !== undefined || process.platform !== 'win32') return exact ?? ''
return Object.entries(process.env)
.find(([key]) => key.toUpperCase() === 'PATH')?.[1] ?? ''
}
function assertAbsolutePath(label: string, value: string): void {
if (value.length === 0 || value.includes('\0') || !isAbsolute(value)) {
throw new ProfileMaterializationError(`profile materializer ${label} must be an absolute path without NUL`)
}
}
function assertPositiveFinite(label: string, value: number): void {
if (!Number.isFinite(value) || value <= 0) {
throw new ProfileMaterializationError(`profile materializer ${label} must be positive`)
}
}
function killProcessTree(child: ChildProcess, force = false): void {
const signal: NodeJS.Signals = force ? 'SIGKILL' : 'SIGTERM'
try {
if (process.platform !== 'win32' && child.pid !== undefined) {
process.kill(-child.pid, signal)
} else {
child.kill(signal)
}
} catch {
try { child.kill(signal) } catch { /* It already exited. */ }
}
}
function appendOutput(
chunks: Buffer[],
chunk: Buffer | string,
currentLength: number,
maxBytes: number,
): number {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
const nextLength = currentLength + buffer.byteLength
if (nextLength > maxBytes) {
throw new ProfileMaterializationError(`profile materializer output exceeded ${String(maxBytes)} bytes`)
}
chunks.push(buffer)
return nextLength
}
/**
* Restore dependencies for an already-restored profile without creating a
* Host or using a shell. This intentionally performs only the fixed command
* `pnpm install --frozen-lockfile`.
*/
export async function materializeProfile(
options: ProfileMaterializerOptions,
): Promise<ProfileMaterializationResult> {
for (const [label, value] of [
['application executable', options.appExecutable],
['environment preloader', options.clearEnvironmentPath],
['pnpm entry', options.pnpmBinPath],
['Node command directory', options.nodeBinDir],
['Node command', options.nodeShimPath],
['Harness home', options.homeDir],
['profile directory', options.profileDir],
] as const) assertAbsolutePath(label, value)
if (options.electronVersion.length === 0 || options.electronVersion.includes('\0')) {
throw new ProfileMaterializationError('profile materializer Electron version must not be empty or contain NUL')
}
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES
assertPositiveFinite('timeout', timeoutMs)
assertPositiveFinite('maximum output', maxOutputBytes)
options.signal?.throwIfAborted()
const argv = [
options.appExecutable,
'--import',
pathToFileURL(options.clearEnvironmentPath).href,
options.pnpmBinPath,
'install',
'--frozen-lockfile',
] as const
const path = inheritedPath()
const environment: NodeJS.ProcessEnv = {
...process.env,
PATH: path.length === 0 ? options.nodeBinDir : `${options.nodeBinDir}${delimiter}${path}`,
NODE: options.nodeShimPath,
ELECTRON_RUN_AS_NODE: '1',
DSH_HOME: options.homeDir,
CI: 'true',
npm_config_runtime: 'electron',
npm_config_target: options.electronVersion,
npm_config_disturl: ELECTRON_HEADERS_URL,
}
const spawn = options.spawn ?? childSpawn
const child = spawn(options.appExecutable, argv.slice(1), {
cwd: options.profileDir,
env: environment,
shell: false,
detached: process.platform !== 'win32',
stdio: ['ignore', 'pipe', 'pipe'],
})
if (child.stdout === null || child.stderr === null) {
killProcessTree(child)
throw new ProfileMaterializationError('profile materializer subprocess did not expose piped output')
}
const stdout = child.stdout
const stderr = child.stderr
const stdoutChunks: Buffer[] = []
const stderrChunks: Buffer[] = []
let stdoutLength = 0
let stderrLength = 0
let failure: Error | undefined
let terminationTimer: NodeJS.Timeout | undefined
let timeoutTimer: NodeJS.Timeout | undefined
let settled = false
return await new Promise<ProfileMaterializationResult>((resolve, reject) => {
const terminate = (cause: Error): void => {
if (failure !== undefined) return
failure = cause
killProcessTree(child)
terminationTimer = setTimeout(() => { killProcessTree(child, true) }, TERMINATION_GRACE_MS)
terminationTimer.unref()
}
const acceptStdout = (chunk: Buffer | string): void => {
try { stdoutLength = appendOutput(stdoutChunks, chunk, stdoutLength, maxOutputBytes) }
catch (cause) { terminate(cause instanceof Error ? cause : new Error(String(cause))) }
}
const acceptStderr = (chunk: Buffer | string): void => {
try { stderrLength = appendOutput(stderrChunks, chunk, stderrLength, maxOutputBytes) }
catch (cause) { terminate(cause instanceof Error ? cause : new Error(String(cause))) }
}
const onAbort = (): void => { terminate(new ProfileMaterializationError('profile materializer was aborted')) }
stdout.on('data', acceptStdout)
stderr.on('data', acceptStderr)
child.once('error', (cause) => { terminate(cause instanceof Error ? cause : new Error(String(cause))) })
options.signal?.addEventListener('abort', onAbort, { once: true })
timeoutTimer = setTimeout(() => {
terminate(new ProfileMaterializationError(`profile materializer timed out after ${String(timeoutMs)}ms`))
}, timeoutMs)
timeoutTimer.unref()
child.once('close', (exitCode, signal) => {
if (settled) return
settled = true
if (timeoutTimer !== undefined) clearTimeout(timeoutTimer)
if (terminationTimer !== undefined) clearTimeout(terminationTimer)
options.signal?.removeEventListener('abort', onAbort)
const result: ProfileMaterializationResult = {
argv,
cwd: options.profileDir,
exitCode,
signal,
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
stderr: Buffer.concat(stderrChunks).toString('utf8'),
}
if (failure !== undefined) {
reject(new ProfileMaterializationError(failure.message, result))
} else if (exitCode !== 0 || signal !== null) {
reject(new ProfileMaterializationError(
`profile materializer exited unsuccessfully (code=${String(exitCode)}, signal=${String(signal)})`,
result,
))
} else {
resolve(result)
}
})
})
}
/** Compatibility alias for callers that describe this as re-materialization. */
export const rematerializeProfile = materializeProfile
@@ -0,0 +1,112 @@
import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'
import type { ChildProcess, SpawnOptions } from 'node:child_process'
import { delimiter } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import {
materializeProfile,
type ProfileMaterializerOptions,
type ProfileMaterializerSpawn,
} from '../src/profile-materializer.ts'
interface FakeChild extends EventEmitter {
readonly stdout: PassThrough
readonly stderr: PassThrough
readonly pid: number
kill: ReturnType<typeof vi.fn>
}
function fakeChild(): FakeChild {
const child = new EventEmitter() as FakeChild
Object.assign(child, {
stdout: new PassThrough(),
stderr: new PassThrough(),
pid: 7301,
kill: vi.fn(() => true),
})
return child
}
function options(spawn: ProfileMaterializerSpawn): ProfileMaterializerOptions {
return {
appExecutable: '/Applications/DSH Desktop.app/Contents/MacOS/DSH Desktop',
clearEnvironmentPath: '/private/clear-env.mjs',
pnpmBinPath: '/private/pnpm/bin/pnpm.mjs',
nodeBinDir: '/private/node-bin',
nodeShimPath: '/private/node-bin/node',
homeDir: '/Users/test/.dsh',
profileDir: '/Users/test/.dsh/profiles/desktop',
electronVersion: '43.4.0',
spawn,
}
}
describe('profile materializer', () => {
it('runs the fixed packaged pnpm command with the desktop lifecycle environment', async () => {
const child = fakeChild()
let command = ''
let args: readonly string[] = []
let spawnOptions: SpawnOptions | undefined
const spawn = vi.fn((selectedCommand: string, selectedArgs: readonly string[], selectedOptions: SpawnOptions) => {
command = selectedCommand
args = selectedArgs
spawnOptions = selectedOptions
return child as unknown as ChildProcess
}) as unknown as ProfileMaterializerSpawn
const resultPromise = materializeProfile(options(spawn))
child.stdout.end('installed\n')
child.stderr.end('')
child.emit('close', 0, null)
const result = await resultPromise
expect(command).toBe('/Applications/DSH Desktop.app/Contents/MacOS/DSH Desktop')
expect(args).toEqual([
'--import',
'file:///private/clear-env.mjs',
'/private/pnpm/bin/pnpm.mjs',
'install',
'--frozen-lockfile',
])
expect(spawnOptions).toMatchObject({
cwd: '/Users/test/.dsh/profiles/desktop',
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
env: {
PATH: `/private/node-bin${delimiter}${process.env.PATH ?? ''}`,
NODE: '/private/node-bin/node',
ELECTRON_RUN_AS_NODE: '1',
DSH_HOME: '/Users/test/.dsh',
CI: 'true',
npm_config_runtime: 'electron',
npm_config_target: '43.4.0',
npm_config_disturl: 'https://electronjs.org/headers',
},
})
expect(result.stdout).toBe('installed\n')
expect(result.exitCode).toBe(0)
})
it('rejects a non-zero package-manager exit and preserves bounded diagnostics', async () => {
const child = fakeChild()
const spawn = vi.fn(() => child as unknown as ChildProcess) as unknown as ProfileMaterializerSpawn
const resultPromise = materializeProfile(options(spawn))
child.stderr.end('lockfile is out of date')
child.emit('close', 1, null)
await expect(resultPromise).rejects.toMatchObject({
name: 'ProfileMaterializationError',
result: { exitCode: 1, stderr: 'lockfile is out of date' },
})
})
it('terminates and rejects when the caller aborts', async () => {
const child = fakeChild()
const spawn = vi.fn(() => child as unknown as ChildProcess) as unknown as ProfileMaterializerSpawn
const controller = new AbortController()
const resultPromise = materializeProfile({ ...options(spawn), signal: controller.signal })
controller.abort()
expect(child.kill).toHaveBeenCalled()
child.emit('close', null, 'SIGTERM')
await expect(resultPromise).rejects.toThrow('aborted')
})
})