diff --git a/build/harness-node-entry.mjs b/build/harness-node-entry.mjs index c86fbd90..e774aa09 100644 --- a/build/harness-node-entry.mjs +++ b/build/harness-node-entry.mjs @@ -1,4 +1,5 @@ import { pathToFileURL } from 'node:url' +import { inspect } from 'node:util' const [dshEntryPath, ...dshArguments] = process.argv.slice(2) @@ -6,8 +7,12 @@ function report(label, value) { process.stderr.write(`[harness-node] ${label}: ${value}\n`) } -process.on('uncaughtException', (error) => report('uncaught exception', error?.stack ?? error)) -process.on('unhandledRejection', (error) => report('unhandled rejection', error?.stack ?? error)) +function formatError(error) { + return error instanceof Error ? inspect(error, { depth: null }) : String(error) +} + +process.on('uncaughtException', (error) => report('uncaught exception', formatError(error))) +process.on('unhandledRejection', (error) => report('unhandled rejection', formatError(error))) process.stdout.write( `[harness-node] runtime node=${process.version} platform=${process.platform} arch=${process.arch}\n` @@ -26,7 +31,7 @@ if (!dshEntryPath) { await import(pathToFileURL(dshEntryPath).href) process.stdout.write('[harness-node] DSH entry loaded\n') } catch (error) { - report('DSH entry failed', error?.stack ?? error) + report('DSH entry failed', formatError(error)) process.exitCode = 1 } } diff --git a/src/main/index.ts b/src/main/index.ts index d118507e..d0166df6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -265,6 +265,31 @@ function showUnexpectedError(error: unknown): void { dialog.showErrorBox('DSH Desktop encountered an error', message) } +async function showPluginRecoveryNotice(snapshot: RuntimeSnapshot): Promise { + if (!snapshot.disabledPlugins?.length || quitting) return + const isChinese = harnessLocale() === 'zh' + const options: MessageBoxOptions = { + type: 'warning', + title: isChinese ? '已禁用不兼容插件' : 'Incompatible plugins disabled', + message: isChinese + ? 'DSH Desktop 已自动禁用不兼容插件并恢复启动。' + : 'DSH Desktop disabled incompatible plugins and recovered startup.', + detail: `${snapshot.disabledPlugins.join('\n')}\n\n${ + isChinese + ? '请更新这些插件后再重新启用。' + : 'Update these plugins before enabling them again.' + }`, + buttons: [isChinese ? '知道了' : 'OK'], + defaultId: 0, + noLink: true + } + if (mainWindow && !mainWindow.isDestroyed()) { + await dialog.showMessageBox(mainWindow, options) + } else { + await dialog.showMessageBox(options) + } +} + async function showRuntimeFailure(snapshot: RuntimeSnapshot): Promise { if (failureDialogVisible || quitting) return failureDialogVisible = true @@ -463,7 +488,9 @@ async function bootstrap(): Promise { launchProcess: (executablePath, args, options) => spawn(executablePath, args, options), onChanged: (snapshot) => { if (snapshot.phase === 'ready' && snapshot.url) { - void openHarness(snapshot.url).catch(showUnexpectedError) + void openHarness(snapshot.url) + .then(() => showPluginRecoveryNotice(snapshot)) + .catch(showUnexpectedError) } else if (snapshot.phase === 'failed') { void showRuntimeFailure(snapshot) } diff --git a/src/main/runtime/harness-runtime.ts b/src/main/runtime/harness-runtime.ts index 7555bdbb..6cadd8b7 100644 --- a/src/main/runtime/harness-runtime.ts +++ b/src/main/runtime/harness-runtime.ts @@ -4,6 +4,7 @@ import { mkdir } from 'node:fs/promises' import { createServer } from 'node:net' import { dirname, join } from 'node:path' import type { RuntimePhase, RuntimeSnapshot } from '../../shared/contracts' +import { disableIncompatibleUserPlugins } from './plugin-recovery' export interface HarnessRuntimeOptions { dshEntryPath: string @@ -21,7 +22,10 @@ export interface HarnessRuntimeOptions { onChanged(snapshot: RuntimeSnapshot): void } -export function buildHarnessArguments(port: number, patchPath?: string): string[] { +export function buildHarnessArguments( + port: number, + patchPath?: string +): string[] { return [ 'web', ...(patchPath ? ['--patch', patchPath] : []), @@ -75,6 +79,7 @@ export class HarnessRuntime { private message = 'Harness is not running.' private launchDirectory?: string private url?: string + private disabledPlugins: string[] = [] private readonly logLines: string[] = [] constructor(private readonly options: HarnessRuntimeOptions) {} @@ -85,6 +90,7 @@ export class HarnessRuntime { message: this.message, launchDirectory: this.launchDirectory, url: this.url, + disabledPlugins: this.disabledPlugins.length > 0 ? [...this.disabledPlugins] : undefined, logs: [...this.logLines] } } @@ -93,6 +99,7 @@ export class HarnessRuntime { await this.stop() this.launchDirectory = launchDirectory this.url = undefined + this.disabledPlugins = [] if (!existsSync(this.options.dshEntryPath)) { this.setState('failed', `Harness entry was not found: ${this.options.dshEntryPath}`) @@ -115,77 +122,113 @@ export class HarnessRuntime { await mkdir(dirname(this.options.logPath), { recursive: true }) this.logStream = createWriteStream(this.options.logPath, { flags: 'a' }) - const port = await reservePort() - const url = `http://127.0.0.1:${port}` - const args = buildNodeArguments( - this.options.nodeEntryPath, - this.options.dshEntryPath, - port, - this.options.dshPatchPath - ) const startupTimeoutMs = this.options.startupTimeoutMs ?? (process.platform === 'win32' ? 120_000 : 45_000) this.writeLog(`\n[desktop] starting ${new Date().toISOString()}`) this.writeLog(`[desktop] launch directory ${launchDirectory}`) - this.writeLog(`[desktop] endpoint ${url}`) this.setState('starting', 'Starting DeepSeek Harness…') - let child: ChildProcessWithoutNullStreams - try { - child = this.options.launchProcess( - this.options.nodeExecutablePath, - args, - buildHarnessSpawnOptions(launchDirectory, this.options.dshHome) + for (let attempt = 0; attempt < 2; attempt += 1) { + const port = await reservePort() + const url = `http://127.0.0.1:${port}` + const args = buildNodeArguments( + this.options.nodeEntryPath, + this.options.dshEntryPath, + port, + this.options.dshPatchPath ) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - this.writeLog(`[utility] launch failed: ${message}`) - this.setState('failed', `Harness could not start: ${message}`) - return - } - this.child = child + this.writeLog(`[desktop] endpoint ${url}`) - child.stdout.on('data', (chunk: Buffer) => this.writeChunk('stdout', chunk)) - child.stderr.on('data', (chunk: Buffer) => this.writeChunk('stderr', chunk)) - child.once('spawn', () => this.writeLog('[desktop] Bundled Node.js Harness process started')) - child.once('error', (error) => { - this.writeLog(`[node] ${error.stack ?? error.message}`) - if (this.child !== child) return - this.child = undefined - this.setState('failed', `Harness could not start: ${error.message}`) - }) - child.once('exit', (code, signal) => { - const detail = signal ? `signal ${signal}` : formatExitCode(code ?? -1) - this.writeLog(`[node] Harness process exited (${detail})`) - if (this.child !== child) return - this.child = undefined - this.setState('failed', `Harness stopped unexpectedly (${detail}).`) - }) + let child: ChildProcessWithoutNullStreams + try { + child = this.options.launchProcess( + this.options.nodeExecutablePath, + args, + buildHarnessSpawnOptions(launchDirectory, this.options.dshHome) + ) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + this.writeLog(`[utility] launch failed: ${message}`) + this.setState('failed', `Harness could not start: ${message}`) + return + } + this.child = child - const startedAt = Date.now() - const progressTimer = setInterval( - () => this.writeLog(`[desktop] waiting for Harness (${Math.round((Date.now() - startedAt) / 1000)}s)`), - 10_000 - ) - const ready = await waitUntilReady( - url, - () => this.child === child && child.exitCode === null, - startupTimeoutMs - ).finally(() => clearInterval(progressTimer)) + let startupComplete = false + let failureMessage: string | undefined + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk: Buffer) => { + stdout = `${stdout}${chunk.toString('utf8')}`.slice(-32 * 1024) + this.writeChunk('stdout', chunk) + }) + child.stderr.on('data', (chunk: Buffer) => { + stderr = `${stderr}${chunk.toString('utf8')}`.slice(-128 * 1024) + this.writeChunk('stderr', chunk) + }) + child.once('spawn', () => this.writeLog('[desktop] Bundled Node.js Harness process started')) + child.once('error', (error) => { + this.writeLog(`[node] ${error.stack ?? error.message}`) + if (this.child !== child) return + this.child = undefined + failureMessage = `Harness could not start: ${error.message}` + if (startupComplete) this.setState('failed', failureMessage) + }) + child.once('exit', (code, signal) => { + const detail = signal ? `signal ${signal}` : formatExitCode(code ?? -1) + this.writeLog(`[node] Harness process exited (${detail})`) + if (this.child !== child) return + this.child = undefined + failureMessage = `Harness stopped unexpectedly (${detail}).` + if (startupComplete) this.setState('failed', failureMessage) + }) - if (this.child !== child) return - if (!ready) { - await this.stopChild(child) - this.setState( - 'failed', - `Harness did not become ready within ${Math.round(startupTimeoutMs / 1000)} seconds.` + const startedAt = Date.now() + const progressTimer = setInterval( + () => this.writeLog(`[desktop] waiting for Harness (${Math.round((Date.now() - startedAt) / 1000)}s)`), + 10_000 ) + const ready = await waitUntilReady( + url, + () => this.child === child && child.exitCode === null, + () => stdout.includes('[harness-node] DSH entry loaded'), + startupTimeoutMs + ).finally(() => clearInterval(progressTimer)) + + if (this.child !== child) { + if (this.phase === 'idle' || this.phase === 'stopping') return + if (attempt === 0) { + try { + const disabled = await disableIncompatibleUserPlugins(this.options.dshHome, stderr) + if (disabled.length > 0) { + this.disabledPlugins = disabled + this.writeLog(`[desktop] disabled incompatible plugins: ${disabled.join(', ')}`) + this.writeLog('[desktop] retrying Harness startup once') + continue + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + this.writeLog(`[desktop] plugin recovery failed: ${message}`) + } + } + this.setState('failed', failureMessage ?? 'Harness stopped unexpectedly during startup.') + return + } + if (!ready) { + await this.stopChild(child) + this.setState( + 'failed', + `Harness did not become ready within ${Math.round(startupTimeoutMs / 1000)} seconds.` + ) + return + } + + startupComplete = true + this.url = url + this.setState('ready', 'Harness is ready.') return } - - this.url = url - this.setState('ready', 'Harness is ready.') } async stop(): Promise { @@ -271,15 +314,18 @@ async function reservePort(): Promise { async function waitUntilReady( url: string, isAlive: () => boolean, + isBooted: () => boolean, timeoutMs: number ): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline && isAlive()) { - try { - const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(1_000) }) - if (response.status >= 200 && response.status < 500) return true - } catch { - // The server is expected to reject connections while it is booting. + if (isBooted()) { + try { + const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(1_000) }) + if (response.status >= 200 && response.status < 500) return true + } catch { + // The server is expected to reject connections while it is booting. + } } await new Promise((resolve) => setTimeout(resolve, 250)) } diff --git a/src/main/runtime/plugin-recovery.ts b/src/main/runtime/plugin-recovery.ts new file mode 100644 index 00000000..a12f9a3c --- /dev/null +++ b/src/main/runtime/plugin-recovery.ts @@ -0,0 +1,165 @@ +import { existsSync } from 'node:fs' +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' +import { dirname, join, resolve, sep } from 'node:path' + +export interface LoaderEntryFailure { + id: string + name: string + reason: string +} + +interface MarketState { + disabled: string[] + groups: Record + groupOrder: string[] + raw: Record +} + +export function extractLoaderEntryFailures(stderr: string): LoaderEntryFailure[] { + const failures = new Map() + const pattern = /failed to apply loader entry ([^\s(]+) \(([^)]+)\): ([^\r\n]+)/g + for (const match of stderr.matchAll(pattern)) { + const [, id, name, reason] = match + if (id && name && reason) failures.set(id, { id, name, reason }) + } + return [...failures.values()] +} + +function isCompatibilityFailure(reason: string): boolean { + return /service "[^"]+" has been registered|unsupported JSON schema:/.test(reason) +} + +export function profilePatchPath(dshHome: string): string { + return join(dshHome, 'profiles', 'web', 'cordis.patch.yml') +} + +export function marketStatePath(dshHome: string): string { + return join(dshHome, 'profiles', 'web', '.dsh-market', 'state.json') +} + +function uniqueStrings(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return [...new Set(value.filter((item): item is string => typeof item === 'string'))] +} + +function readPatchDisabled(text: string): Set { + const disabled = new Set() + const lines = text.split(/\r?\n/u) + let inInsert = false + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? '' + if (/^- insert:\s*$/u.test(line)) { + inInsert = true + continue + } + if (/^- /u.test(line)) inInsert = false + if (inInsert) continue + const row = /^- id: ([A-Za-z0-9_.-]+)\s*$/u.exec(line) + const id = row?.[1] + const next = lines[index + 1] ?? '' + if (id !== undefined && /^ {2}disabled: true\s*$/u.test(next)) { + disabled.add(id) + } else if (id !== undefined && /^ {2}disabled: false\s*$/u.test(next)) { + disabled.delete(id) + } + } + return disabled +} + +function appendPatchBlocks(text: string, rowIds: string[]): string { + const blocks = rowIds.map((id) => `- id: ${id}\n disabled: true\n`).join('') + const withoutComments = text.replace(/^[ \t]*#.*$/gmu, '').trim() + if (text.trim() === '') return blocks + if (withoutComments === '') return `${text.endsWith('\n') ? text : `${text}\n`}${blocks}` + if (withoutComments === '[]' || withoutComments === '[ ]') { + const commented = text.replace(/^[ \t]*\[[ \t]*\][ \t]*(?:#.*)?(?:\r?\n|$)/mu, '# []\n') + return `${commented.endsWith('\n') ? commented : `${commented}\n`}${blocks}` + } + return `${text.endsWith('\n') ? text : `${text}\n`}${blocks}` +} + +async function atomicWrite(path: string, contents: string): Promise { + await mkdir(dirname(path), { recursive: true }) + const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp` + await writeFile(temporaryPath, contents, 'utf8') + await rename(temporaryPath, path) +} + +async function readMarketState(path: string): Promise { + try { + const raw = JSON.parse(await readFile(path, 'utf8')) as Record + const groups: Record = {} + if (raw.groups !== null && typeof raw.groups === 'object' && !Array.isArray(raw.groups)) { + for (const [name, members] of Object.entries(raw.groups)) { + groups[name] = uniqueStrings(members) + } + } + return { + disabled: uniqueStrings(raw.disabled), + groups, + groupOrder: uniqueStrings(raw.groupOrder), + raw + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + return { disabled: [], groups: {}, groupOrder: [], raw: {} } + } +} + +export async function disableIncompatibleUserPlugins( + dshHome: string, + stderr: string +): Promise { + const nodeModules = resolve(dshHome, 'profiles', 'web', 'node_modules') + const failures = extractLoaderEntryFailures(stderr).filter(({ reason }) => { + return isCompatibilityFailure(reason) + }) + const userPlugins: LoaderEntryFailure[] = [] + + for (const failure of failures) { + const manifestPath = resolve(nodeModules, ...failure.name.split('/'), 'package.json') + if (!manifestPath.startsWith(`${nodeModules}${sep}`) || !existsSync(manifestPath)) continue + try { + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) + if (manifest?.name === failure.name) userPlugins.push(failure) + } catch { + // Invalid packages are not safe recovery candidates. + } + } + if (userPlugins.length === 0) return [] + + const patchPath = profilePatchPath(dshHome) + let patchText = '' + try { + patchText = await readFile(patchPath, 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + + const patchDisabled = readPatchDisabled(patchText) + const statePath = marketStatePath(dshHome) + const state = await readMarketState(statePath) + const stateDisabled = new Set(state.disabled) + const patchAdded = userPlugins.filter((failure) => !patchDisabled.has(failure.id)) + const stateAdded = userPlugins.filter((failure) => !stateDisabled.has(failure.name)) + if (patchAdded.length === 0 && stateAdded.length === 0) return [] + + if (patchAdded.length > 0) { + await atomicWrite(patchPath, appendPatchBlocks(patchText, patchAdded.map(({ id }) => id))) + } + for (const failure of stateAdded) stateDisabled.add(failure.name) + if (stateAdded.length > 0) { + await atomicWrite( + statePath, + JSON.stringify({ + ...state.raw, + disabled: [...stateDisabled], + groups: state.groups, + groupOrder: state.groupOrder + }) + ) + } + return userPlugins + .filter((failure) => patchAdded.includes(failure) || stateAdded.includes(failure)) + .map(({ name }) => name) +} diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 50f55a48..5e4d0b6a 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -11,6 +11,7 @@ export interface RuntimeSnapshot { launchDirectory?: string logs: string[] url?: string + disabledPlugins?: string[] } export type UpdatePhase = diff --git a/test/runtime.test.ts b/test/runtime.test.ts index 816548ab..bfcde090 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -1,10 +1,22 @@ import { describe, expect, it } from 'vitest' +import { spawn } from 'node:child_process' +import { readFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { parse } from 'yaml' import { buildHarnessArguments, buildHarnessSpawnOptions, buildNodeArguments, - formatExitCode + formatExitCode, + HarnessRuntime } from '../src/main/runtime/harness-runtime' +import { + disableIncompatibleUserPlugins, + extractLoaderEntryFailures, + marketStatePath, + profilePatchPath +} from '../src/main/runtime/plugin-recovery' import { canGrantWindowPermission, isTrustedAppUrl } from '../src/main/security-policy' import { isAbortedNavigationError, @@ -88,6 +100,217 @@ describe('Harness launch contract', () => { }) }) +describe('incompatible user plugin recovery', () => { + it('disables only failed entries backed by user-installed packages', async () => { + const home = await mkdtemp(join(tmpdir(), 'dsh-desktop-recovery-')) + const pluginDirectory = join(home, 'profiles', 'web', 'node_modules', 'broken-plugin') + const transientPluginDirectory = join( + home, + 'profiles', + 'web', + 'node_modules', + 'transient-plugin' + ) + await mkdir(pluginDirectory, { recursive: true }) + await mkdir(transientPluginDirectory, { recursive: true }) + await writeFile( + join(pluginDirectory, 'package.json'), + JSON.stringify({ name: 'broken-plugin' }), + 'utf8' + ) + await writeFile( + join(transientPluginDirectory, 'package.json'), + JSON.stringify({ name: 'transient-plugin' }), + 'utf8' + ) + await writeFile( + profilePatchPath(home), + [ + '# profile comment', + '- id: broken', + ' disabled: true', + '- id: broken', + ' disabled: false', + '- id: kept', + ' disabled: !!js process.env.KEEP_PLUGIN_DISABLED', + '- id: forced', + ' disabled: false', + '' + ].join('\n'), + 'utf8' + ) + await mkdir(join(home, 'profiles', 'web', '.dsh-market'), { recursive: true }) + await writeFile( + marketStatePath(home), + JSON.stringify({ + disabled: ['already-disabled'], + groups: { pinned: ['already-disabled'] }, + groupOrder: ['pinned'] + }), + 'utf8' + ) + const stderr = [ + 'Error: failed to apply loader entry broken (broken-plugin): unsupported JSON schema: schema.required is not supported', + 'Error: failed to apply loader entry transient (transient-plugin): ENOENT: missing user config', + 'Error: failed to apply loader entry core (@deepseek-ai/dsh-core): internal failure' + ].join('\n') + + try { + expect(extractLoaderEntryFailures(stderr)).toEqual([ + { + id: 'broken', + name: 'broken-plugin', + reason: 'unsupported JSON schema: schema.required is not supported' + }, + { + id: 'transient', + name: 'transient-plugin', + reason: 'ENOENT: missing user config' + }, + { id: 'core', name: '@deepseek-ai/dsh-core', reason: 'internal failure' } + ]) + await expect(disableIncompatibleUserPlugins(home, stderr)).resolves.toEqual([ + 'broken-plugin' + ]) + const patchText = await readFile(profilePatchPath(home), 'utf8') + expect(patchText).toContain('# profile comment') + expect(patchText).toContain('disabled: !!js process.env.KEEP_PLUGIN_DISABLED') + expect(patchText).toContain('- id: forced\n disabled: false') + expect(patchText.match(/- id: broken\n disabled: true/g)).toHaveLength(2) + expect(parse(patchText.replace('!!js ', ''))).toEqual([ + { id: 'broken', disabled: true }, + { id: 'broken', disabled: false }, + { + id: 'kept', + disabled: 'process.env.KEEP_PLUGIN_DISABLED' + }, + { id: 'forced', disabled: false }, + { id: 'broken', disabled: true } + ]) + expect(JSON.parse(await readFile(marketStatePath(home), 'utf8'))).toEqual({ + disabled: ['already-disabled', 'broken-plugin'], + groups: { pinned: ['already-disabled'] }, + groupOrder: ['pinned'] + }) + await expect(disableIncompatibleUserPlugins(home, stderr)).resolves.toEqual([]) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + + it('does not disable plugins for ENOENT startup errors', async () => { + const home = await mkdtemp(join(tmpdir(), 'dsh-desktop-recovery-')) + const pluginDirectory = join(home, 'profiles', 'web', 'node_modules', 'missing-config') + await mkdir(pluginDirectory, { recursive: true }) + await writeFile(join(pluginDirectory, 'package.json'), '{"name":"missing-config"}', 'utf8') + const stderr = + 'Error: failed to apply loader entry missing (missing-config): ENOENT: no such file or directory' + + try { + await expect(disableIncompatibleUserPlugins(home, stderr)).resolves.toEqual([]) + await expect(readFile(profilePatchPath(home), 'utf8')).rejects.toMatchObject({ + code: 'ENOENT' + }) + await expect(readFile(marketStatePath(home), 'utf8')).rejects.toMatchObject({ + code: 'ENOENT' + }) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + + it('does not overwrite invalid plugin market state', async () => { + const home = await mkdtemp(join(tmpdir(), 'dsh-desktop-recovery-')) + const pluginDirectory = join(home, 'profiles', 'web', 'node_modules', 'broken-plugin') + await mkdir(pluginDirectory, { recursive: true }) + await writeFile(join(pluginDirectory, 'package.json'), '{"name":"broken-plugin"}', 'utf8') + await mkdir(join(home, 'profiles', 'web', '.dsh-market'), { recursive: true }) + await writeFile(marketStatePath(home), '{invalid', 'utf8') + const stderr = + 'Error: failed to apply loader entry broken (broken-plugin): unsupported JSON schema: unsupported' + + try { + await expect(disableIncompatibleUserPlugins(home, stderr)).rejects.toThrow() + await expect(readFile(marketStatePath(home), 'utf8')).resolves.toBe('{invalid') + await expect(readFile(profilePatchPath(home), 'utf8')).rejects.toMatchObject({ + code: 'ENOENT' + }) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + + it('retries startup after disabling a failed user plugin', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-desktop-runtime-')) + const home = join(root, 'harness') + const profileDirectory = join(home, 'profiles', 'web') + const pluginDirectory = join(profileDirectory, 'node_modules', 'broken-plugin') + const dshEntry = join(root, 'fake-dsh.mjs') + const desktopPatch = join(root, 'desktop.yml') + await mkdir(pluginDirectory, { recursive: true }) + await writeFile(join(pluginDirectory, 'package.json'), '{"name":"broken-plugin"}', 'utf8') + await writeFile( + profilePatchPath(home), + '# profile comment\n[]\n', + 'utf8' + ) + await writeFile(desktopPatch, '[]\n', 'utf8') + await writeFile( + dshEntry, + `import { existsSync } from 'node:fs' +import { createServer } from 'node:http' +import { join } from 'node:path' +const patch = join(process.env.DSH_HOME, 'profiles', 'web', 'cordis.patch.yml') +if (!existsSync(patch) || !String(await import('node:fs/promises').then(fs => fs.readFile(patch, 'utf8'))).includes('id: broken')) { + const port = Number(process.argv[process.argv.indexOf('--port') + 1]) + const transientServer = createServer((_request, response) => response.end('not ready')) + await new Promise(resolve => transientServer.listen(port, '127.0.0.1', resolve)) + await new Promise(resolve => setTimeout(resolve, 500)) + await new Promise((resolve, reject) => transientServer.close(error => error ? reject(error) : resolve())) + throw new Error('profile failed', { cause: new AggregateError([ + new Error('failed to apply loader entry broken (broken-plugin): service "example" has been registered') + ]) }) +} +const port = Number(process.argv[process.argv.indexOf('--port') + 1]) +createServer((_request, response) => response.end('ok')).listen(port, '127.0.0.1') +`, + 'utf8' + ) + + const runtime = new HarnessRuntime({ + dshEntryPath: dshEntry, + nodeExecutablePath: process.execPath, + nodeEntryPath: resolve('build/harness-node-entry.mjs'), + dshPatchPath: desktopPatch, + dshHome: home, + logPath: join(root, 'harness.log'), + launchProcess: (executablePath, args, options) => spawn(executablePath, args, options), + startupTimeoutMs: 5_000, + onChanged: () => undefined + }) + + try { + await runtime.start(root) + expect(runtime.snapshot().phase).toBe('ready') + expect(runtime.snapshot().logs.join('\n')).toContain( + '[desktop] disabled incompatible plugins: broken-plugin' + ) + expect(runtime.snapshot().disabledPlugins).toEqual(['broken-plugin']) + expect(parse(await readFile(profilePatchPath(home), 'utf8'))).toEqual([ + { id: 'broken', disabled: true } + ]) + expect(JSON.parse(await readFile(marketStatePath(home), 'utf8'))).toEqual({ + disabled: ['broken-plugin'], + groups: {}, + groupOrder: [] + }) + } finally { + await runtime.stop() + await rm(root, { recursive: true, force: true }) + } + }) +}) + describe('navigation trust boundary', () => { it('only trusts the launcher and loopback HTTP pages', () => { expect(isTrustedAppUrl('file:///app/index.html')).toBe(true)