From a8ceec2f3920d9f33d7fe3d1fb2a67c23384ccef Mon Sep 17 00:00:00 2001 From: t4we fan Date: Fri, 21 Aug 2026 17:42:02 +0800 Subject: [PATCH] feat(desktop): materialize restored profiles --- .../src/profile-materializer.ts | 229 ++++++++++++++++++ .../tests/profile-materializer.spec.ts | 112 +++++++++ 2 files changed, 341 insertions(+) create mode 100644 dsh-plugin-desktop/src/profile-materializer.ts create mode 100644 dsh-plugin-desktop/tests/profile-materializer.spec.ts diff --git a/dsh-plugin-desktop/src/profile-materializer.ts b/dsh-plugin-desktop/src/profile-materializer.ts new file mode 100644 index 0000000000..3483195498 --- /dev/null +++ b/dsh-plugin-desktop/src/profile-materializer.ts @@ -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 { + 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((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 diff --git a/dsh-plugin-desktop/tests/profile-materializer.spec.ts b/dsh-plugin-desktop/tests/profile-materializer.spec.ts new file mode 100644 index 0000000000..9a12213acf --- /dev/null +++ b/dsh-plugin-desktop/tests/profile-materializer.spec.ts @@ -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 +} + +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') + }) +})