From d35c729af413700a08f1735810f44aa0e9b218f6 Mon Sep 17 00:00:00 2001 From: QinRui Date: Sat, 15 Aug 2026 18:09:27 +0800 Subject: [PATCH 01/10] Fix Windows Harness process startup --- src/main/index.ts | 4 +- src/main/runtime/harness-runtime.ts | 107 ++++++++++++++++++---------- test/runtime.test.ts | 46 +++++++++--- 3 files changed, 109 insertions(+), 48 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index e9a3f18..72cef11 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -7,6 +7,7 @@ import { Menu, nativeTheme, shell, + utilityProcess, type MessageBoxOptions } from 'electron' import { HarnessRuntime } from './runtime/harness-runtime' @@ -310,7 +311,8 @@ async function bootstrap(): Promise { dshEntryPath: dshEntryPath(), dshHome: join(app.getPath('userData'), 'harness'), logPath: join(app.getPath('logs'), 'harness.log'), - nodeExecutable: process.execPath, + launchProcess: (modulePath, args, options) => + utilityProcess.fork(modulePath, args, options), onChanged: (snapshot) => { if (snapshot.phase === 'ready' && snapshot.url) { void openHarness(snapshot.url).catch(showUnexpectedError) diff --git a/src/main/runtime/harness-runtime.ts b/src/main/runtime/harness-runtime.ts index 9bb7c4f..d40a64c 100644 --- a/src/main/runtime/harness-runtime.ts +++ b/src/main/runtime/harness-runtime.ts @@ -1,15 +1,15 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import { createWriteStream, existsSync, type WriteStream } from 'node:fs' import { mkdir } from 'node:fs/promises' import { createServer } from 'node:net' import { dirname, join } from 'node:path' +import type { ForkOptions, UtilityProcess } from 'electron' import type { RuntimePhase, RuntimeSnapshot } from '../../shared/contracts' export interface HarnessRuntimeOptions { dshEntryPath: string dshHome: string logPath: string - nodeExecutable: string + launchProcess(modulePath: string, args: string[], options: ForkOptions): UtilityProcess startupTimeoutMs?: number onChanged(snapshot: RuntimeSnapshot): void } @@ -18,14 +18,33 @@ export function buildHarnessArguments(port: number): string[] { return ['web', '--host', '127.0.0.1', '--port', String(port)] } -export function buildNodeArguments(dshEntryPath: string, port: number): string[] { - // Cordis HMR needs access to Node's internal ESM loader. This flag is only - // granted to the isolated Harness child process, never to the renderer. - return ['--expose-internals', dshEntryPath, ...buildHarnessArguments(port)] +export function buildHarnessForkOptions( + launchDirectory: string, + dshHome: string, + platform: NodeJS.Platform = process.platform, + environment: NodeJS.ProcessEnv = process.env +): ForkOptions { + const { ELECTRON_RUN_AS_NODE: _runAsNode, ...parentEnvironment } = environment + const pathKey = platform === 'win32' ? 'Path' : 'PATH' + + return { + cwd: launchDirectory, + env: { + ...parentEnvironment, + DSH_HOME: dshHome, + NO_COLOR: '1', + [pathKey]: environment[pathKey] ?? environment.PATH ?? '' + }, + // Cordis HMR needs access to Node's internal ESM loader. This flag is only + // granted to the isolated Harness utility process, never to the renderer. + execArgv: ['--expose-internals'], + stdio: 'pipe', + serviceName: 'DSH Harness' + } } export class HarnessRuntime { - private child?: ChildProcessWithoutNullStreams + private child?: UtilityProcess private logStream?: WriteStream private phase: RuntimePhase = 'idle' private message = 'Harness is not running.' @@ -61,45 +80,43 @@ export class HarnessRuntime { const port = await reservePort() const url = `http://127.0.0.1:${port}` - const args = buildNodeArguments(this.options.dshEntryPath, port) - const pathKey = process.platform === 'win32' ? 'Path' : 'PATH' + const args = buildHarnessArguments(port) 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…') - const child = spawn(this.options.nodeExecutable, args, { - cwd: launchDirectory, - env: { - ...process.env, - ELECTRON_RUN_AS_NODE: '1', - DSH_HOME: this.options.dshHome, - NO_COLOR: '1', - [pathKey]: process.env[pathKey] ?? process.env.PATH ?? '' - }, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true - }) + let child: UtilityProcess + try { + child = this.options.launchProcess( + this.options.dshEntryPath, + args, + buildHarnessForkOptions(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 - child.stdout.on('data', (chunk: Buffer) => this.writeChunk('stdout', chunk)) - child.stderr.on('data', (chunk: Buffer) => this.writeChunk('stderr', chunk)) - child.once('error', (error) => { - if (this.child !== child) return - this.child = undefined - this.setState('failed', `Harness could not start: ${error.message}`) + 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] Harness utility process started')) + child.once('error', (type, location) => { + this.writeLog(`[utility] ${type}${location.length > 0 ? ` at ${location}` : ''}`) }) - child.once('exit', (code, signal) => { + child.once('exit', (code) => { if (this.child !== child) return this.child = undefined - const detail = signal ? `signal ${signal}` : `exit code ${code ?? 'unknown'}` - this.setState('failed', `Harness stopped unexpectedly (${detail}).`) + this.setState('failed', `Harness stopped unexpectedly (${formatExitCode(code)}).`) }) const ready = await waitUntilReady( url, - () => this.child === child && child.exitCode === null, + () => this.child === child, this.options.startupTimeoutMs ?? 45_000 ) @@ -130,14 +147,23 @@ export class HarnessRuntime { this.setState('idle', 'Harness is not running.') } - private async stopChild(child: ChildProcessWithoutNullStreams): Promise { - if (child.exitCode !== null) return - child.kill('SIGTERM') + private async stopChild(child: UtilityProcess): Promise { + if (child.pid === undefined) return + const exitPromise = new Promise((resolve) => + child.once('exit', () => resolve(true)) + ) + child.kill() const exited = await Promise.race([ - new Promise((resolve) => child.once('exit', () => resolve(true))), + exitPromise, new Promise((resolve) => setTimeout(() => resolve(false), 4_000)) ]) - if (!exited && child.exitCode === null) child.kill('SIGKILL') + if (!exited && child.pid !== undefined) { + try { + process.kill(child.pid, 'SIGKILL') + } catch { + // The utility process may have exited between the timeout and this check. + } + } } private setState(phase: RuntimePhase, message: string): void { @@ -164,6 +190,15 @@ export class HarnessRuntime { } } +export function formatExitCode(code: number): string { + const unsigned = code >>> 0 + const hexadecimal = `0x${unsigned.toString(16).padStart(8, '0').toUpperCase()}` + if (unsigned === 0xffff7003) { + return `exit code ${unsigned} (${hexadecimal}, Crashpad handler unavailable)` + } + return `exit code ${code} (${hexadecimal})` +} + async function reservePort(): Promise { return new Promise((resolve, reject) => { const server = createServer() diff --git a/test/runtime.test.ts b/test/runtime.test.ts index 3060334..cafc37d 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { buildHarnessArguments, buildNodeArguments } from '../src/main/runtime/harness-runtime' +import { + buildHarnessArguments, + buildHarnessForkOptions, + formatExitCode +} from '../src/main/runtime/harness-runtime' import { canGrantWindowPermission, isTrustedAppUrl } from '../src/main/security-policy' import { isAbortedNavigationError, @@ -17,16 +21,36 @@ describe('Harness launch contract', () => { ]) }) - it('grants Node internals only to the Harness child process', () => { - expect(buildNodeArguments('/runtime/dsh.js', 43127)).toEqual([ - '--expose-internals', - '/runtime/dsh.js', - 'web', - '--host', - '127.0.0.1', - '--port', - '43127' - ]) + it('launches Harness as an isolated Electron utility process', () => { + const options = buildHarnessForkOptions( + 'C:\\Users\\tester\\AppData\\Roaming\\dsh-desktop\\launch-root', + 'C:\\Users\\tester\\AppData\\Roaming\\dsh-desktop\\harness', + 'win32', + { + ELECTRON_RUN_AS_NODE: '1', + PATH: 'fallback-path', + Path: 'windows-path' + } + ) + + expect(options).toMatchObject({ + cwd: 'C:\\Users\\tester\\AppData\\Roaming\\dsh-desktop\\launch-root', + execArgv: ['--expose-internals'], + stdio: 'pipe', + serviceName: 'DSH Harness', + env: { + DSH_HOME: 'C:\\Users\\tester\\AppData\\Roaming\\dsh-desktop\\harness', + NO_COLOR: '1', + Path: 'windows-path' + } + }) + expect(options.env).not.toHaveProperty('ELECTRON_RUN_AS_NODE') + }) + + it('makes native Windows termination codes diagnosable', () => { + expect(formatExitCode(4294930435)).toContain( + '0xFFFF7003, Crashpad handler unavailable' + ) }) }) From 0d858e38524aa637ab7d636e213f6e062d0ddc78 Mon Sep 17 00:00:00 2001 From: QinRui Date: Sat, 15 Aug 2026 18:18:46 +0800 Subject: [PATCH 02/10] Build isolated Windows test package --- .github/workflows/release.yml | 17 ++++++++++++++++- electron-builder.dev.cjs | 4 ++++ package.json | 1 + test/release.test.ts | 9 +++++++++ 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af12c91..06057fb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -268,8 +268,14 @@ jobs: - run: npm ci - run: npm test - run: npm run typecheck - - run: npm run package:win + - name: Build Windows release package + if: startsWith(github.ref, 'refs/tags/v') + run: npm run package:win + - name: Build isolated Windows development package + if: github.event_name == 'workflow_dispatch' + run: npm run package:dev:win - uses: actions/upload-artifact@v4 + if: startsWith(github.ref, 'refs/tags/v') with: name: windows-x64 path: | @@ -277,6 +283,15 @@ jobs: dist/dsh-desktop-windows-x64-setup.exe.blockmap dist/latest.yml if-no-files-found: error + - uses: actions/upload-artifact@v4 + if: github.event_name == 'workflow_dispatch' + with: + name: windows-x64-dev + path: | + dist-dev/dsh-desktop-dev-windows-x64-setup.exe + dist-dev/dsh-desktop-dev-windows-x64-setup.exe.blockmap + dist-dev/latest.yml + if-no-files-found: error publish: name: Publish GitHub Release diff --git a/electron-builder.dev.cjs b/electron-builder.dev.cjs index a49b750..4ec5151 100644 --- a/electron-builder.dev.cjs +++ b/electron-builder.dev.cjs @@ -13,5 +13,9 @@ module.exports = { productName: 'DSH Desktop Dev', dshDesktopChannel: 'development' }, + nsis: { + ...packageJson.build.nsis, + artifactName: 'dsh-desktop-dev-windows-${arch}-setup.${ext}' + }, publish: null } diff --git a/package.json b/package.json index 5e60183..051b8e9 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "test:watch": "vitest", "package:dir": "npm run build && electron-builder --dir", "package:dev:dir": "npm run build && electron-builder --dir --config electron-builder.dev.cjs", + "package:dev:win": "node scripts/verify-target.mjs win32 x64 && npm run build && electron-builder --win --x64 --publish never --config electron-builder.dev.cjs", "package:mac": "npm run build && electron-builder --mac --publish never", "package:mac:arm64": "node scripts/verify-target.mjs darwin arm64 && npm run build && electron-builder --mac --arm64 --publish never", "package:mac:x64": "node scripts/verify-target.mjs darwin x64 && npm run build && electron-builder --mac --x64 --publish never", diff --git a/test/release.test.ts b/test/release.test.ts index b1a50a5..87e7733 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -128,10 +128,16 @@ describe('GitHub release contract', () => { expect(packageJson.scripts['package:dev:dir']).toContain('npm run build') expect(packageJson.scripts['package:dev:dir']).toContain('electron-builder.dev.cjs') + expect(packageJson.scripts['package:dev:win']).toContain('verify-target.mjs win32 x64') + expect(packageJson.scripts['package:dev:win']).toContain('electron-builder.dev.cjs') + expect(packageJson.scripts['package:dev:win']).toContain('--publish never') expect(developmentConfig).toContain("appId: 'io.dsh.desktop.dev'") expect(developmentConfig).toContain("productName: 'DSH Desktop Dev'") expect(developmentConfig).toContain("output: 'dist-dev'") expect(developmentConfig).toContain("dshDesktopChannel: 'development'") + expect(developmentConfig).toContain( + "artifactName: 'dsh-desktop-dev-windows-${arch}-setup.${ext}'" + ) expect(main).toContain("app.setPath('userData', join(app.getPath('appData'), 'dsh-desktop-dev'))") expect(main).toContain("app.setPath('userData', join(app.getPath('appData'), 'dsh-desktop'))") expect(main).toContain('if (!developmentBuild)') @@ -146,6 +152,9 @@ describe('GitHub release contract', () => { expect(workflow).toContain('runs-on: macos-15') expect(workflow).toContain('runs-on: macos-15-intel') expect(workflow).toContain('runs-on: windows-2022') + expect(workflow).toContain('npm run package:dev:win') + expect(workflow).toContain('name: windows-x64-dev') + expect(workflow).toContain('dist-dev/dsh-desktop-dev-windows-x64-setup.exe') for (const asset of releaseAssets) expect(workflow).toContain(asset) expect( workflow.match( From fa2346947e55d991d0f8bd077d9d015d6864c3cc Mon Sep 17 00:00:00 2001 From: QinRui Date: Sat, 15 Aug 2026 19:01:00 +0800 Subject: [PATCH 03/10] Smoke test packaged Windows Harness --- .github/workflows/release.yml | 40 +++++++++++++++++++++++++++++ build/harness-worker.cjs | 25 ++++++++++++++++++ package.json | 4 +++ src/main/index.ts | 7 +++++ src/main/runtime/harness-runtime.ts | 9 +++++-- test/release.test.ts | 3 +++ test/runtime.test.ts | 8 ++++++ 7 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 build/harness-worker.cjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 06057fb..7d13724 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -274,6 +274,46 @@ jobs: - name: Build isolated Windows development package if: github.event_name == 'workflow_dispatch' run: npm run package:dev:win + - name: Smoke test packaged Windows Harness + if: github.event_name == 'workflow_dispatch' + shell: pwsh + run: | + $userData = Join-Path $env:APPDATA 'dsh-desktop-dev' + $logPath = Join-Path $userData 'logs\harness.log' + $executable = 'dist-dev\win-unpacked\DSH Desktop Dev.exe' + if (Test-Path $userData) { Remove-Item -Recurse -Force $userData } + $desktop = Start-Process -FilePath $executable -PassThru + try { + $deadline = (Get-Date).AddSeconds(75) + $ready = $false + while ((Get-Date) -lt $deadline) { + if ($desktop.HasExited) { + throw "DSH Desktop Dev exited before Harness was ready (exit code $($desktop.ExitCode))." + } + if (Test-Path $logPath) { + $log = Get-Content -Raw $logPath + $match = [regex]::Match($log, '\[desktop\] endpoint (http://127\.0\.0\.1:\d+)') + if ($match.Success) { + try { + $response = Invoke-WebRequest -UseBasicParsing -Uri $match.Groups[1].Value -TimeoutSec 2 + if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 500) { + $ready = $true + break + } + } catch { } + } + } + Start-Sleep -Milliseconds 500 + } + if (-not $ready) { throw 'Packaged Harness did not become ready within 75 seconds.' } + Write-Host 'Packaged Windows Harness smoke test passed.' + } finally { + if (Test-Path $logPath) { + Write-Host '--- harness.log ---' + Get-Content $logPath + } + if (-not $desktop.HasExited) { Stop-Process -Id $desktop.Id -Force } + } - uses: actions/upload-artifact@v4 if: startsWith(github.ref, 'refs/tags/v') with: diff --git a/build/harness-worker.cjs b/build/harness-worker.cjs new file mode 100644 index 0000000..ee41cce --- /dev/null +++ b/build/harness-worker.cjs @@ -0,0 +1,25 @@ +const { pathToFileURL } = require('node:url') + +const [dshEntryPath, ...dshArguments] = process.argv.slice(2) + +function reportError(label, error) { + const details = error instanceof Error ? error.stack || error.message : String(error) + process.stderr.write(`[harness-worker] ${label}: ${details}\n`) +} + +process.on('uncaughtException', (error) => reportError('uncaught exception', error)) +process.on('unhandledRejection', (error) => reportError('unhandled rejection', error)) + +if (!dshEntryPath) { + process.stderr.write('[harness-worker] missing DSH entry path\n') + process.exitCode = 1 +} else { + process.stdout.write(`[harness-worker] loading ${dshEntryPath}\n`) + process.argv = [process.execPath, dshEntryPath, ...dshArguments] + import(pathToFileURL(dshEntryPath).href) + .then(() => process.stdout.write('[harness-worker] DSH entry loaded\n')) + .catch((error) => { + reportError('could not load DSH entry', error) + process.exitCode = 1 + }) +} diff --git a/package.json b/package.json index 051b8e9..dc01f37 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,10 @@ { "from": "build/app-icon.png", "to": "icon.png" + }, + { + "from": "build/harness-worker.cjs", + "to": "harness-worker.cjs" } ], "publish": [ diff --git a/src/main/index.ts b/src/main/index.ts index 72cef11..6870bfa 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -110,6 +110,12 @@ function dshEntryPath(): string { return join(app.getAppPath(), 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js') } +function harnessWorkerPath(): string { + return app.isPackaged + ? join(process.resourcesPath, 'harness-worker.cjs') + : join(app.getAppPath(), 'build', 'harness-worker.cjs') +} + function desktopIconPath(): string { return app.isPackaged ? join(process.resourcesPath, 'icon.png') @@ -309,6 +315,7 @@ async function bootstrap(): Promise { createWindow() runtime = new HarnessRuntime({ dshEntryPath: dshEntryPath(), + workerEntryPath: harnessWorkerPath(), dshHome: join(app.getPath('userData'), 'harness'), logPath: join(app.getPath('logs'), 'harness.log'), launchProcess: (modulePath, args, options) => diff --git a/src/main/runtime/harness-runtime.ts b/src/main/runtime/harness-runtime.ts index d40a64c..953b254 100644 --- a/src/main/runtime/harness-runtime.ts +++ b/src/main/runtime/harness-runtime.ts @@ -7,6 +7,7 @@ import type { RuntimePhase, RuntimeSnapshot } from '../../shared/contracts' export interface HarnessRuntimeOptions { dshEntryPath: string + workerEntryPath: string dshHome: string logPath: string launchProcess(modulePath: string, args: string[], options: ForkOptions): UtilityProcess @@ -73,6 +74,10 @@ export class HarnessRuntime { this.setState('failed', `Harness entry was not found: ${this.options.dshEntryPath}`) return } + if (!existsSync(this.options.workerEntryPath)) { + this.setState('failed', `Harness worker was not found: ${this.options.workerEntryPath}`) + return + } await mkdir(this.options.dshHome, { recursive: true }) await mkdir(dirname(this.options.logPath), { recursive: true }) @@ -90,8 +95,8 @@ export class HarnessRuntime { let child: UtilityProcess try { child = this.options.launchProcess( - this.options.dshEntryPath, - args, + this.options.workerEntryPath, + [this.options.dshEntryPath, ...args], buildHarnessForkOptions(launchDirectory, this.options.dshHome) ) } catch (error) { diff --git a/test/release.test.ts b/test/release.test.ts index 87e7733..4b8b209 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -153,6 +153,9 @@ describe('GitHub release contract', () => { expect(workflow).toContain('runs-on: macos-15-intel') expect(workflow).toContain('runs-on: windows-2022') expect(workflow).toContain('npm run package:dev:win') + expect(workflow).toContain('Smoke test packaged Windows Harness') + expect(workflow).toContain("$executable = 'dist-dev\\win-unpacked\\DSH Desktop Dev.exe'") + expect(workflow).toContain('Packaged Windows Harness smoke test passed.') expect(workflow).toContain('name: windows-x64-dev') expect(workflow).toContain('dist-dev/dsh-desktop-dev-windows-x64-setup.exe') for (const asset of releaseAssets) expect(workflow).toContain(asset) diff --git a/test/runtime.test.ts b/test/runtime.test.ts index cafc37d..930daac 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { buildHarnessArguments, @@ -47,6 +49,12 @@ describe('Harness launch contract', () => { expect(options.env).not.toHaveProperty('ELECTRON_RUN_AS_NODE') }) + it('ships a CommonJS worker that reports ESM entry loading failures', () => { + const worker = readFileSync(join(process.cwd(), 'build', 'harness-worker.cjs'), 'utf8') + expect(worker).toContain("import(pathToFileURL(dshEntryPath).href)") + expect(worker).toContain("reportError('could not load DSH entry', error)") + }) + it('makes native Windows termination codes diagnosable', () => { expect(formatExitCode(4294930435)).toContain( '0xFFFF7003, Crashpad handler unavailable' From 34a1e86938b43bf45f212e5b2f1524f78eb1a368 Mon Sep 17 00:00:00 2001 From: QinRui Date: Sat, 15 Aug 2026 19:06:22 +0800 Subject: [PATCH 04/10] Require stable Windows Harness startup --- .github/workflows/release.yml | 8 ++++++++ build/harness-worker.cjs | 7 +++++++ test/release.test.ts | 1 + test/runtime.test.ts | 1 + 4 files changed, 17 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7d13724..f2fb0ae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -297,6 +297,14 @@ jobs: try { $response = Invoke-WebRequest -UseBasicParsing -Uri $match.Groups[1].Value -TimeoutSec 2 if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 500) { + Start-Sleep -Seconds 5 + $stableLog = Get-Content -Raw $logPath + if ($stableLog -match '\[harness-worker\].*(could not load|uncaught|unhandled)') { + throw 'Harness worker reported a fatal startup error after HTTP became ready.' + } + if ($desktop.HasExited) { + throw "DSH Desktop Dev exited after Harness became ready (exit code $($desktop.ExitCode))." + } $ready = $true break } diff --git a/build/harness-worker.cjs b/build/harness-worker.cjs index ee41cce..f059ede 100644 --- a/build/harness-worker.cjs +++ b/build/harness-worker.cjs @@ -14,6 +14,13 @@ if (!dshEntryPath) { process.stderr.write('[harness-worker] missing DSH entry path\n') process.exitCode = 1 } else { + if (!process.execArgv.includes('--expose-internals')) { + // Electron's utility process applies the flag but does not consistently + // retain it in process.execArgv on Windows. Cordis uses this marker before + // probing the internal ESM loader, so restore the marker for that probe. + process.execArgv.push('--expose-internals') + } + process.stdout.write(`[harness-worker] execArgv ${JSON.stringify(process.execArgv)}\n`) process.stdout.write(`[harness-worker] loading ${dshEntryPath}\n`) process.argv = [process.execPath, dshEntryPath, ...dshArguments] import(pathToFileURL(dshEntryPath).href) diff --git a/test/release.test.ts b/test/release.test.ts index 4b8b209..a1ca254 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -156,6 +156,7 @@ describe('GitHub release contract', () => { expect(workflow).toContain('Smoke test packaged Windows Harness') expect(workflow).toContain("$executable = 'dist-dev\\win-unpacked\\DSH Desktop Dev.exe'") expect(workflow).toContain('Packaged Windows Harness smoke test passed.') + expect(workflow).toContain('Harness worker reported a fatal startup error') expect(workflow).toContain('name: windows-x64-dev') expect(workflow).toContain('dist-dev/dsh-desktop-dev-windows-x64-setup.exe') for (const asset of releaseAssets) expect(workflow).toContain(asset) diff --git a/test/runtime.test.ts b/test/runtime.test.ts index 930daac..0daabe3 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -52,6 +52,7 @@ describe('Harness launch contract', () => { it('ships a CommonJS worker that reports ESM entry loading failures', () => { const worker = readFileSync(join(process.cwd(), 'build', 'harness-worker.cjs'), 'utf8') expect(worker).toContain("import(pathToFileURL(dshEntryPath).href)") + expect(worker).toContain("process.execArgv.push('--expose-internals')") expect(worker).toContain("reportError('could not load DSH entry', error)") }) From 8ba375ee494259fc79467d7540f15addcde31448 Mon Sep 17 00:00:00 2001 From: QinRui Date: Sat, 15 Aug 2026 19:12:24 +0800 Subject: [PATCH 05/10] Run Harness with bundled Node.js --- build/harness-worker.cjs | 32 --------- package-lock.json | 103 +++++++--------------------- package.json | 7 +- src/main/index.ts | 14 ++-- src/main/runtime/harness-runtime.ts | 75 ++++++++++---------- test/runtime.test.ts | 29 ++++---- 6 files changed, 87 insertions(+), 173 deletions(-) delete mode 100644 build/harness-worker.cjs diff --git a/build/harness-worker.cjs b/build/harness-worker.cjs deleted file mode 100644 index f059ede..0000000 --- a/build/harness-worker.cjs +++ /dev/null @@ -1,32 +0,0 @@ -const { pathToFileURL } = require('node:url') - -const [dshEntryPath, ...dshArguments] = process.argv.slice(2) - -function reportError(label, error) { - const details = error instanceof Error ? error.stack || error.message : String(error) - process.stderr.write(`[harness-worker] ${label}: ${details}\n`) -} - -process.on('uncaughtException', (error) => reportError('uncaught exception', error)) -process.on('unhandledRejection', (error) => reportError('unhandled rejection', error)) - -if (!dshEntryPath) { - process.stderr.write('[harness-worker] missing DSH entry path\n') - process.exitCode = 1 -} else { - if (!process.execArgv.includes('--expose-internals')) { - // Electron's utility process applies the flag but does not consistently - // retain it in process.execArgv on Windows. Cordis uses this marker before - // probing the internal ESM loader, so restore the marker for that probe. - process.execArgv.push('--expose-internals') - } - process.stdout.write(`[harness-worker] execArgv ${JSON.stringify(process.execArgv)}\n`) - process.stdout.write(`[harness-worker] loading ${dshEntryPath}\n`) - process.argv = [process.execPath, dshEntryPath, ...dshArguments] - import(pathToFileURL(dshEntryPath).href) - .then(() => process.stdout.write('[harness-worker] DSH entry loaded\n')) - .catch((error) => { - reportError('could not load DSH entry', error) - process.exitCode = 1 - }) -} diff --git a/package-lock.json b/package-lock.json index f4da4c3..77230bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,8 @@ "@deepseek-ai/dsh-subprocess": "0.1.0-rc.6", "@deepseek-ai/dsh-timeout": "0.1.0-rc.6", "@deepseek-ai/dsh-workflow": "0.1.0-rc.6", - "electron-updater": "^6.8.9" + "electron-updater": "^6.8.9", + "node": "24.9.0" }, "devDependencies": { "@types/node": "24.10.1", @@ -4683,45 +4684,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@electron/windows-sign": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", - "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "cross-dirname": "^0.1.0", - "debug": "^4.3.4", - "fs-extra": "^11.1.1", - "minimist": "^1.2.8", - "postject": "^1.0.0-alpha.6" - }, - "bin": { - "electron-windows-sign": "bin/electron-windows-sign.js" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/windows-sign/node_modules/fs-extra": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", - "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, "node_modules/@emnapi/runtime": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", @@ -8600,15 +8562,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/cross-dirname": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", - "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -11758,6 +11711,22 @@ "node": ">= 0.6" } }, + "node_modules/node": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/node/-/node-24.9.0.tgz", + "integrity": "sha512-cczSuf6uJejZ+dR+BAUEd6t2TxW31GvSexzEEvUKKRT59E/oYxUk3fixnUUqMG4tCtjg2wpZp3hfPdGuSg4pgw==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "node-bin-setup": "^1.0.0" + }, + "bin": { + "node": "bin/node" + }, + "engines": { + "npm": ">=5.0.0" + } + }, "node_modules/node-abi": { "version": "4.33.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.33.0.tgz", @@ -11950,6 +11919,12 @@ "semver": "^7.3.5" } }, + "node_modules/node-bin-setup": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/node-bin-setup/-/node-bin-setup-1.1.4.tgz", + "integrity": "sha512-vWNHOne0ZUavArqPP5LJta50+S8R261Fr5SvGul37HbEDcowvLjwdvd0ZeSr0r2lTSrPxl6okq9QUw8BFGiAxA==", + "license": "ISC" + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -12489,36 +12464,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postject": { - "version": "1.0.0-alpha.6", - "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", - "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "commander": "^9.4.0" - }, - "bin": { - "postject": "dist/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/postject/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^12.20.0 || >=14" - } - }, "node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", diff --git a/package.json b/package.json index dc01f37..7c346c0 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,8 @@ "@deepseek-ai/dsh-subprocess": "0.1.0-rc.6", "@deepseek-ai/dsh-timeout": "0.1.0-rc.6", "@deepseek-ai/dsh-workflow": "0.1.0-rc.6", - "electron-updater": "^6.8.9" + "electron-updater": "^6.8.9", + "node": "24.9.0" }, "devDependencies": { "@types/node": "24.10.1", @@ -90,10 +91,6 @@ { "from": "build/app-icon.png", "to": "icon.png" - }, - { - "from": "build/harness-worker.cjs", - "to": "harness-worker.cjs" } ], "publish": [ diff --git a/src/main/index.ts b/src/main/index.ts index 6870bfa..66c511a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,3 +1,4 @@ +import { spawn } from 'node:child_process' import { join } from 'node:path' import { readFileSync } from 'node:fs' import { @@ -7,7 +8,6 @@ import { Menu, nativeTheme, shell, - utilityProcess, type MessageBoxOptions } from 'electron' import { HarnessRuntime } from './runtime/harness-runtime' @@ -110,10 +110,9 @@ function dshEntryPath(): string { return join(app.getAppPath(), 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js') } -function harnessWorkerPath(): string { - return app.isPackaged - ? join(process.resourcesPath, 'harness-worker.cjs') - : join(app.getAppPath(), 'build', 'harness-worker.cjs') +function bundledNodePath(): string { + const executable = process.platform === 'win32' ? 'node.exe' : 'node' + return join(app.getAppPath(), 'node_modules', 'node', 'bin', executable) } function desktopIconPath(): string { @@ -315,11 +314,10 @@ async function bootstrap(): Promise { createWindow() runtime = new HarnessRuntime({ dshEntryPath: dshEntryPath(), - workerEntryPath: harnessWorkerPath(), + nodeExecutablePath: bundledNodePath(), dshHome: join(app.getPath('userData'), 'harness'), logPath: join(app.getPath('logs'), 'harness.log'), - launchProcess: (modulePath, args, options) => - utilityProcess.fork(modulePath, args, options), + launchProcess: (executablePath, args, options) => spawn(executablePath, args, options), onChanged: (snapshot) => { if (snapshot.phase === 'ready' && snapshot.url) { void openHarness(snapshot.url).catch(showUnexpectedError) diff --git a/src/main/runtime/harness-runtime.ts b/src/main/runtime/harness-runtime.ts index 953b254..8b728ca 100644 --- a/src/main/runtime/harness-runtime.ts +++ b/src/main/runtime/harness-runtime.ts @@ -1,16 +1,20 @@ +import type { ChildProcessWithoutNullStreams, SpawnOptionsWithoutStdio } from 'node:child_process' import { createWriteStream, existsSync, type WriteStream } from 'node:fs' import { mkdir } from 'node:fs/promises' import { createServer } from 'node:net' import { dirname, join } from 'node:path' -import type { ForkOptions, UtilityProcess } from 'electron' import type { RuntimePhase, RuntimeSnapshot } from '../../shared/contracts' export interface HarnessRuntimeOptions { dshEntryPath: string - workerEntryPath: string + nodeExecutablePath: string dshHome: string logPath: string - launchProcess(modulePath: string, args: string[], options: ForkOptions): UtilityProcess + launchProcess( + executablePath: string, + args: string[], + options: SpawnOptionsWithoutStdio + ): ChildProcessWithoutNullStreams startupTimeoutMs?: number onChanged(snapshot: RuntimeSnapshot): void } @@ -19,12 +23,12 @@ export function buildHarnessArguments(port: number): string[] { return ['web', '--host', '127.0.0.1', '--port', String(port)] } -export function buildHarnessForkOptions( +export function buildHarnessSpawnOptions( launchDirectory: string, dshHome: string, platform: NodeJS.Platform = process.platform, environment: NodeJS.ProcessEnv = process.env -): ForkOptions { +): SpawnOptionsWithoutStdio { const { ELECTRON_RUN_AS_NODE: _runAsNode, ...parentEnvironment } = environment const pathKey = platform === 'win32' ? 'Path' : 'PATH' @@ -36,16 +40,17 @@ export function buildHarnessForkOptions( NO_COLOR: '1', [pathKey]: environment[pathKey] ?? environment.PATH ?? '' }, - // Cordis HMR needs access to Node's internal ESM loader. This flag is only - // granted to the isolated Harness utility process, never to the renderer. - execArgv: ['--expose-internals'], - stdio: 'pipe', - serviceName: 'DSH Harness' + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true } } +export function buildNodeArguments(dshEntryPath: string, port: number): string[] { + return ['--expose-internals', dshEntryPath, ...buildHarnessArguments(port)] +} + export class HarnessRuntime { - private child?: UtilityProcess + private child?: ChildProcessWithoutNullStreams private logStream?: WriteStream private phase: RuntimePhase = 'idle' private message = 'Harness is not running.' @@ -74,8 +79,8 @@ export class HarnessRuntime { this.setState('failed', `Harness entry was not found: ${this.options.dshEntryPath}`) return } - if (!existsSync(this.options.workerEntryPath)) { - this.setState('failed', `Harness worker was not found: ${this.options.workerEntryPath}`) + if (!existsSync(this.options.nodeExecutablePath)) { + this.setState('failed', `Bundled Node.js runtime was not found: ${this.options.nodeExecutablePath}`) return } @@ -85,19 +90,19 @@ export class HarnessRuntime { const port = await reservePort() const url = `http://127.0.0.1:${port}` - const args = buildHarnessArguments(port) + const args = buildNodeArguments(this.options.dshEntryPath, port) 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: UtilityProcess + let child: ChildProcessWithoutNullStreams try { child = this.options.launchProcess( - this.options.workerEntryPath, - [this.options.dshEntryPath, ...args], - buildHarnessForkOptions(launchDirectory, this.options.dshHome) + this.options.nodeExecutablePath, + args, + buildHarnessSpawnOptions(launchDirectory, this.options.dshHome) ) } catch (error) { const message = error instanceof Error ? error.message : String(error) @@ -107,21 +112,25 @@ export class HarnessRuntime { } this.child = child - 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] Harness utility process started')) - child.once('error', (type, location) => { - this.writeLog(`[utility] ${type}${location.length > 0 ? ` at ${location}` : ''}`) + 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) => { + child.once('exit', (code, signal) => { if (this.child !== child) return this.child = undefined - this.setState('failed', `Harness stopped unexpectedly (${formatExitCode(code)}).`) + const detail = signal ? `signal ${signal}` : formatExitCode(code ?? -1) + this.setState('failed', `Harness stopped unexpectedly (${detail}).`) }) const ready = await waitUntilReady( url, - () => this.child === child, + () => this.child === child && child.exitCode === null, this.options.startupTimeoutMs ?? 45_000 ) @@ -152,23 +161,17 @@ export class HarnessRuntime { this.setState('idle', 'Harness is not running.') } - private async stopChild(child: UtilityProcess): Promise { - if (child.pid === undefined) return + private async stopChild(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null) return const exitPromise = new Promise((resolve) => child.once('exit', () => resolve(true)) ) - child.kill() + child.kill('SIGTERM') const exited = await Promise.race([ exitPromise, new Promise((resolve) => setTimeout(() => resolve(false), 4_000)) ]) - if (!exited && child.pid !== undefined) { - try { - process.kill(child.pid, 'SIGKILL') - } catch { - // The utility process may have exited between the timeout and this check. - } - } + if (!exited && child.exitCode === null) child.kill('SIGKILL') } private setState(phase: RuntimePhase, message: string): void { diff --git a/test/runtime.test.ts b/test/runtime.test.ts index 0daabe3..232b08e 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -1,9 +1,8 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { buildHarnessArguments, - buildHarnessForkOptions, + buildHarnessSpawnOptions, + buildNodeArguments, formatExitCode } from '../src/main/runtime/harness-runtime' import { canGrantWindowPermission, isTrustedAppUrl } from '../src/main/security-policy' @@ -23,8 +22,8 @@ describe('Harness launch contract', () => { ]) }) - it('launches Harness as an isolated Electron utility process', () => { - const options = buildHarnessForkOptions( + it('launches Harness with the bundled Node.js runtime', () => { + const options = buildHarnessSpawnOptions( 'C:\\Users\\tester\\AppData\\Roaming\\dsh-desktop\\launch-root', 'C:\\Users\\tester\\AppData\\Roaming\\dsh-desktop\\harness', 'win32', @@ -37,9 +36,8 @@ describe('Harness launch contract', () => { expect(options).toMatchObject({ cwd: 'C:\\Users\\tester\\AppData\\Roaming\\dsh-desktop\\launch-root', - execArgv: ['--expose-internals'], - stdio: 'pipe', - serviceName: 'DSH Harness', + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, env: { DSH_HOME: 'C:\\Users\\tester\\AppData\\Roaming\\dsh-desktop\\harness', NO_COLOR: '1', @@ -49,11 +47,16 @@ describe('Harness launch contract', () => { expect(options.env).not.toHaveProperty('ELECTRON_RUN_AS_NODE') }) - it('ships a CommonJS worker that reports ESM entry loading failures', () => { - const worker = readFileSync(join(process.cwd(), 'build', 'harness-worker.cjs'), 'utf8') - expect(worker).toContain("import(pathToFileURL(dshEntryPath).href)") - expect(worker).toContain("process.execArgv.push('--expose-internals')") - expect(worker).toContain("reportError('could not load DSH entry', error)") + it('passes the internal-loader flag directly to bundled Node.js', () => { + expect(buildNodeArguments('C:\\app\\dsh\\lib\\bin.js', 43127)).toEqual([ + '--expose-internals', + 'C:\\app\\dsh\\lib\\bin.js', + 'web', + '--host', + '127.0.0.1', + '--port', + '43127' + ]) }) it('makes native Windows termination codes diagnosable', () => { From 6729668fa8d900292f025fdc68a8211fc1c3f9a6 Mon Sep 17 00:00:00 2001 From: QinRui Date: Sat, 15 Aug 2026 19:51:25 +0800 Subject: [PATCH 06/10] Publish validated Windows prereleases --- .github/workflows/release.yml | 23 +++++++++++++++++++++-- test/release.test.ts | 6 +++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f2fb0ae..7079cb7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,11 @@ on: branches: - main workflow_dispatch: + inputs: + windows_prerelease_tag: + description: Optional non-v tag for a Windows Dev pre-release + required: false + type: string permissions: contents: write @@ -299,8 +304,8 @@ jobs: if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 500) { Start-Sleep -Seconds 5 $stableLog = Get-Content -Raw $logPath - if ($stableLog -match '\[harness-worker\].*(could not load|uncaught|unhandled)') { - throw 'Harness worker reported a fatal startup error after HTTP became ready.' + if ($stableLog -match '\[stderr\]') { + throw 'Harness reported stderr after HTTP became ready.' } if ($desktop.HasExited) { throw "DSH Desktop Dev exited after Harness became ready (exit code $($desktop.ExitCode))." @@ -322,6 +327,20 @@ jobs: } if (-not $desktop.HasExited) { Stop-Process -Id $desktop.Id -Force } } + - name: Publish validated Windows development pre-release + if: github.event_name == 'workflow_dispatch' && inputs.windows_prerelease_tag != '' + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + PRERELEASE_TAG: ${{ inputs.windows_prerelease_tag }} + run: | + gh release create $env:PRERELEASE_TAG ` + --repo $env:GITHUB_REPOSITORY ` + --target $env:GITHUB_SHA ` + --prerelease ` + --title 'DSH Desktop Windows Harness Test' ` + --notes "Windows x64 development build for validating the Harness startup fix from PR #39. This build uses an isolated app identity and user data directory, and has passed the packaged Windows Harness smoke test." ` + 'dist-dev/dsh-desktop-dev-windows-x64-setup.exe#DSH Desktop Dev Windows x64 Setup' - uses: actions/upload-artifact@v4 if: startsWith(github.ref, 'refs/tags/v') with: diff --git a/test/release.test.ts b/test/release.test.ts index a1ca254..1038e92 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -156,7 +156,11 @@ describe('GitHub release contract', () => { expect(workflow).toContain('Smoke test packaged Windows Harness') expect(workflow).toContain("$executable = 'dist-dev\\win-unpacked\\DSH Desktop Dev.exe'") expect(workflow).toContain('Packaged Windows Harness smoke test passed.') - expect(workflow).toContain('Harness worker reported a fatal startup error') + expect(workflow).toContain('Harness reported stderr after HTTP became ready') + expect(workflow).toContain('windows_prerelease_tag:') + expect(workflow).toContain('Publish validated Windows development pre-release') + expect(workflow).toContain('gh release create $env:PRERELEASE_TAG') + expect(workflow).toContain('--prerelease') expect(workflow).toContain('name: windows-x64-dev') expect(workflow).toContain('dist-dev/dsh-desktop-dev-windows-x64-setup.exe') for (const asset of releaseAssets) expect(workflow).toContain(asset) From 32e3393812399399f9d146ebfbefab5c43802b3a Mon Sep 17 00:00:00 2001 From: QinRui Date: Sat, 15 Aug 2026 20:12:18 +0800 Subject: [PATCH 07/10] Add comprehensive Harness startup diagnostics --- build/harness-node-entry.mjs | 32 +++++++++++++++++++++++++++ package.json | 4 ++++ src/main/index.ts | 7 ++++++ src/main/runtime/harness-runtime.ts | 34 +++++++++++++++++++++++------ test/runtime.test.ts | 9 +++++++- 5 files changed, 78 insertions(+), 8 deletions(-) create mode 100644 build/harness-node-entry.mjs diff --git a/build/harness-node-entry.mjs b/build/harness-node-entry.mjs new file mode 100644 index 0000000..c86fbd9 --- /dev/null +++ b/build/harness-node-entry.mjs @@ -0,0 +1,32 @@ +import { pathToFileURL } from 'node:url' + +const [dshEntryPath, ...dshArguments] = process.argv.slice(2) + +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)) + +process.stdout.write( + `[harness-node] runtime node=${process.version} platform=${process.platform} arch=${process.arch}\n` +) +process.stdout.write(`[harness-node] execPath=${process.execPath}\n`) +process.stdout.write(`[harness-node] cwd=${process.cwd()}\n`) +process.stdout.write(`[harness-node] DSH_HOME=${process.env.DSH_HOME ?? ''}\n`) + +if (!dshEntryPath) { + report('startup error', 'missing DSH entry path') + process.exitCode = 1 +} else { + process.stdout.write(`[harness-node] loading=${dshEntryPath}\n`) + process.argv = [process.execPath, dshEntryPath, ...dshArguments] + try { + await import(pathToFileURL(dshEntryPath).href) + process.stdout.write('[harness-node] DSH entry loaded\n') + } catch (error) { + report('DSH entry failed', error?.stack ?? error) + process.exitCode = 1 + } +} diff --git a/package.json b/package.json index 7c346c0..7b0d950 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,10 @@ { "from": "build/app-icon.png", "to": "icon.png" + }, + { + "from": "build/harness-node-entry.mjs", + "to": "harness-node-entry.mjs" } ], "publish": [ diff --git a/src/main/index.ts b/src/main/index.ts index 66c511a..beaa322 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -115,6 +115,12 @@ function bundledNodePath(): string { return join(app.getAppPath(), 'node_modules', 'node', 'bin', executable) } +function harnessNodeEntryPath(): string { + return app.isPackaged + ? join(process.resourcesPath, 'harness-node-entry.mjs') + : join(app.getAppPath(), 'build', 'harness-node-entry.mjs') +} + function desktopIconPath(): string { return app.isPackaged ? join(process.resourcesPath, 'icon.png') @@ -315,6 +321,7 @@ async function bootstrap(): Promise { runtime = new HarnessRuntime({ dshEntryPath: dshEntryPath(), nodeExecutablePath: bundledNodePath(), + nodeEntryPath: harnessNodeEntryPath(), dshHome: join(app.getPath('userData'), 'harness'), logPath: join(app.getPath('logs'), 'harness.log'), launchProcess: (executablePath, args, options) => spawn(executablePath, args, options), diff --git a/src/main/runtime/harness-runtime.ts b/src/main/runtime/harness-runtime.ts index 8b728ca..e509a2d 100644 --- a/src/main/runtime/harness-runtime.ts +++ b/src/main/runtime/harness-runtime.ts @@ -8,6 +8,7 @@ import type { RuntimePhase, RuntimeSnapshot } from '../../shared/contracts' export interface HarnessRuntimeOptions { dshEntryPath: string nodeExecutablePath: string + nodeEntryPath: string dshHome: string logPath: string launchProcess( @@ -45,8 +46,12 @@ export function buildHarnessSpawnOptions( } } -export function buildNodeArguments(dshEntryPath: string, port: number): string[] { - return ['--expose-internals', dshEntryPath, ...buildHarnessArguments(port)] +export function buildNodeArguments( + nodeEntryPath: string, + dshEntryPath: string, + port: number +): string[] { + return ['--expose-internals', nodeEntryPath, dshEntryPath, ...buildHarnessArguments(port)] } export class HarnessRuntime { @@ -83,6 +88,10 @@ export class HarnessRuntime { this.setState('failed', `Bundled Node.js runtime was not found: ${this.options.nodeExecutablePath}`) return } + if (!existsSync(this.options.nodeEntryPath)) { + this.setState('failed', `Harness diagnostic entry was not found: ${this.options.nodeEntryPath}`) + return + } await mkdir(this.options.dshHome, { recursive: true }) await mkdir(dirname(this.options.logPath), { recursive: true }) @@ -90,7 +99,9 @@ export class HarnessRuntime { const port = await reservePort() const url = `http://127.0.0.1:${port}` - const args = buildNodeArguments(this.options.dshEntryPath, port) + const args = buildNodeArguments(this.options.nodeEntryPath, this.options.dshEntryPath, port) + 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}`) @@ -122,22 +133,31 @@ export class HarnessRuntime { 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 - const detail = signal ? `signal ${signal}` : formatExitCode(code ?? -1) this.setState('failed', `Harness stopped unexpectedly (${detail}).`) }) + 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, - this.options.startupTimeoutMs ?? 45_000 - ) + startupTimeoutMs + ).finally(() => clearInterval(progressTimer)) if (this.child !== child) return if (!ready) { await this.stopChild(child) - this.setState('failed', 'Harness did not become ready within 45 seconds.') + this.setState( + 'failed', + `Harness did not become ready within ${Math.round(startupTimeoutMs / 1000)} seconds.` + ) return } diff --git a/test/runtime.test.ts b/test/runtime.test.ts index 232b08e..561c88d 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -48,8 +48,15 @@ describe('Harness launch contract', () => { }) it('passes the internal-loader flag directly to bundled Node.js', () => { - expect(buildNodeArguments('C:\\app\\dsh\\lib\\bin.js', 43127)).toEqual([ + expect( + buildNodeArguments( + 'C:\\app\\harness-node-entry.mjs', + 'C:\\app\\dsh\\lib\\bin.js', + 43127 + ) + ).toEqual([ '--expose-internals', + 'C:\\app\\harness-node-entry.mjs', 'C:\\app\\dsh\\lib\\bin.js', 'web', '--host', From df81fd02a0e90af087033e6d16fbde199519cb0a Mon Sep 17 00:00:00 2001 From: QinRui Date: Sat, 15 Aug 2026 20:57:27 +0800 Subject: [PATCH 08/10] Add startup splash and native folder picker --- build/dsh-desktop.patch.yml | 8 ++++++ build/splash.html | 42 +++++++++++++++++++++++++++++ package.json | 8 ++++++ src/main/index.ts | 15 ++++++++++- src/main/runtime/harness-runtime.ts | 33 +++++++++++++++++++---- test/release.test.ts | 24 +++++++++++++++++ test/runtime.test.ts | 17 +++++++++++- 7 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 build/dsh-desktop.patch.yml create mode 100644 build/splash.html diff --git a/build/dsh-desktop.patch.yml b/build/dsh-desktop.patch.yml new file mode 100644 index 0000000..e56a90e --- /dev/null +++ b/build/dsh-desktop.patch.yml @@ -0,0 +1,8 @@ +# DSH Desktop always runs the Host on the same machine as its operator. +# Pin the dual-face native interaction instead of the web/remote browse fallback. +- id: directory-picker + disabled: true + +- insert: + - id: directory-picker-native-desktop + name: '@deepseek-ai/dsh-host-directory-picker-native' diff --git a/build/splash.html b/build/splash.html new file mode 100644 index 0000000..136f56e --- /dev/null +++ b/build/splash.html @@ -0,0 +1,42 @@ + + + + + + + + + +
+ +

Starting DSH Desktop

+

First launch may take a moment.

+ +
+ + + diff --git a/package.json b/package.json index 7b0d950..148eea2 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,14 @@ { "from": "build/harness-node-entry.mjs", "to": "harness-node-entry.mjs" + }, + { + "from": "build/dsh-desktop.patch.yml", + "to": "dsh-desktop.patch.yml" + }, + { + "from": "build/splash.html", + "to": "splash.html" } ], "publish": [ diff --git a/src/main/index.ts b/src/main/index.ts index beaa322..adaf3ae 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -121,6 +121,10 @@ function harnessNodeEntryPath(): string { : join(app.getAppPath(), 'build', 'harness-node-entry.mjs') } +function desktopResourcePath(name: string): string { + return app.isPackaged ? join(process.resourcesPath, name) : join(app.getAppPath(), 'build', name) +} + function desktopIconPath(): string { return app.isPackaged ? join(process.resourcesPath, 'icon.png') @@ -179,8 +183,16 @@ async function openHarness(url: string): Promise { window.focus() } +async function showSplash(): Promise { + const window = mainWindow && !mainWindow.isDestroyed() ? mainWindow : createWindow() + await window.loadFile(desktopResourcePath('splash.html')) + if (window.isDestroyed()) return + window.show() + window.focus() +} + async function launchHarness(): Promise { - mainWindow?.hide() + await showSplash() await runtime.start(launchDirectory) } @@ -322,6 +334,7 @@ async function bootstrap(): Promise { dshEntryPath: dshEntryPath(), nodeExecutablePath: bundledNodePath(), nodeEntryPath: harnessNodeEntryPath(), + dshPatchPath: desktopResourcePath('dsh-desktop.patch.yml'), dshHome: join(app.getPath('userData'), 'harness'), logPath: join(app.getPath('logs'), 'harness.log'), launchProcess: (executablePath, args, options) => spawn(executablePath, args, options), diff --git a/src/main/runtime/harness-runtime.ts b/src/main/runtime/harness-runtime.ts index e509a2d..7555bdb 100644 --- a/src/main/runtime/harness-runtime.ts +++ b/src/main/runtime/harness-runtime.ts @@ -9,6 +9,7 @@ export interface HarnessRuntimeOptions { dshEntryPath: string nodeExecutablePath: string nodeEntryPath: string + dshPatchPath: string dshHome: string logPath: string launchProcess( @@ -20,8 +21,15 @@ export interface HarnessRuntimeOptions { onChanged(snapshot: RuntimeSnapshot): void } -export function buildHarnessArguments(port: number): string[] { - return ['web', '--host', '127.0.0.1', '--port', String(port)] +export function buildHarnessArguments(port: number, patchPath?: string): string[] { + return [ + 'web', + ...(patchPath ? ['--patch', patchPath] : []), + '--host', + '127.0.0.1', + '--port', + String(port) + ] } export function buildHarnessSpawnOptions( @@ -49,9 +57,15 @@ export function buildHarnessSpawnOptions( export function buildNodeArguments( nodeEntryPath: string, dshEntryPath: string, - port: number + port: number, + patchPath?: string ): string[] { - return ['--expose-internals', nodeEntryPath, dshEntryPath, ...buildHarnessArguments(port)] + return [ + '--expose-internals', + nodeEntryPath, + dshEntryPath, + ...buildHarnessArguments(port, patchPath) + ] } export class HarnessRuntime { @@ -92,6 +106,10 @@ export class HarnessRuntime { this.setState('failed', `Harness diagnostic entry was not found: ${this.options.nodeEntryPath}`) return } + if (!existsSync(this.options.dshPatchPath)) { + this.setState('failed', `DSH Desktop patch was not found: ${this.options.dshPatchPath}`) + return + } await mkdir(this.options.dshHome, { recursive: true }) await mkdir(dirname(this.options.logPath), { recursive: true }) @@ -99,7 +117,12 @@ export class HarnessRuntime { const port = await reservePort() const url = `http://127.0.0.1:${port}` - const args = buildNodeArguments(this.options.nodeEntryPath, this.options.dshEntryPath, 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) diff --git a/test/release.test.ts b/test/release.test.ts index 1038e92..531993e 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -60,6 +60,14 @@ describe('GitHub release contract', () => { from: 'build/app-icon.png', to: 'icon.png' }) + expect(packageJson.build.extraResources).toContainEqual({ + from: 'build/splash.html', + to: 'splash.html' + }) + expect(packageJson.build.extraResources).toContainEqual({ + from: 'build/dsh-desktop.patch.yml', + to: 'dsh-desktop.patch.yml' + }) expect(packageJson.build.nsis.artifactName).toBe( 'dsh-desktop-windows-${arch}-setup.${ext}' ) @@ -67,6 +75,22 @@ describe('GitHub release contract', () => { expect(packageJson.build.portable).toBeUndefined() }) + it('shows a packaged startup surface and pins the native directory picker', async () => { + const main = await readFile(path.join(projectRoot, 'src', 'main', 'index.ts'), 'utf8') + const splash = await readFile(path.join(projectRoot, 'build', 'splash.html'), 'utf8') + const patch = await readFile( + path.join(projectRoot, 'build', 'dsh-desktop.patch.yml'), + 'utf8' + ) + + expect(main).toContain("desktopResourcePath('splash.html')") + expect(main).toContain('await showSplash()') + expect(splash).toContain('Starting DSH Desktop') + expect(splash).toContain('prefers-reduced-motion') + expect(patch).toContain('id: directory-picker\n disabled: true') + expect(patch).toContain("name: '@deepseek-ai/dsh-host-directory-picker-native'") + }) + it('publishes update metadata for installed desktop builds', async () => { const packageJson = JSON.parse( await readFile(path.join(projectRoot, 'package.json'), 'utf8') diff --git a/test/runtime.test.ts b/test/runtime.test.ts index 561c88d..816548a 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -22,6 +22,18 @@ describe('Harness launch contract', () => { ]) }) + it('applies the desktop composition patch before web arguments', () => { + expect(buildHarnessArguments(43127, 'C:\\app\\dsh-desktop.patch.yml')).toEqual([ + 'web', + '--patch', + 'C:\\app\\dsh-desktop.patch.yml', + '--host', + '127.0.0.1', + '--port', + '43127' + ]) + }) + it('launches Harness with the bundled Node.js runtime', () => { const options = buildHarnessSpawnOptions( 'C:\\Users\\tester\\AppData\\Roaming\\dsh-desktop\\launch-root', @@ -52,13 +64,16 @@ describe('Harness launch contract', () => { buildNodeArguments( 'C:\\app\\harness-node-entry.mjs', 'C:\\app\\dsh\\lib\\bin.js', - 43127 + 43127, + 'C:\\app\\dsh-desktop.patch.yml' ) ).toEqual([ '--expose-internals', 'C:\\app\\harness-node-entry.mjs', 'C:\\app\\dsh\\lib\\bin.js', 'web', + '--patch', + 'C:\\app\\dsh-desktop.patch.yml', '--host', '127.0.0.1', '--port', From 69cbeb9a5b3f503774287c8d69eec57040597bca Mon Sep 17 00:00:00 2001 From: QinRui Date: Sat, 15 Aug 2026 20:59:09 +0800 Subject: [PATCH 09/10] Make release test portable across line endings --- test/release.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/release.test.ts b/test/release.test.ts index 531993e..a6b7bf2 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -87,7 +87,7 @@ describe('GitHub release contract', () => { expect(main).toContain('await showSplash()') expect(splash).toContain('Starting DSH Desktop') expect(splash).toContain('prefers-reduced-motion') - expect(patch).toContain('id: directory-picker\n disabled: true') + expect(patch).toMatch(/id: directory-picker\r?\n disabled: true/) expect(patch).toContain("name: '@deepseek-ai/dsh-host-directory-picker-native'") }) From 5cb6980045924991667a73fa970f87c533053363 Mon Sep 17 00:00:00 2001 From: QinRui Date: Sat, 15 Aug 2026 21:26:13 +0800 Subject: [PATCH 10/10] Restore native workspace picker surfaces --- build/dsh-desktop.patch.yml | 7 +++++++ test/release.test.ts | 1 + 2 files changed, 8 insertions(+) diff --git a/build/dsh-desktop.patch.yml b/build/dsh-desktop.patch.yml index e56a90e..262806f 100644 --- a/build/dsh-desktop.patch.yml +++ b/build/dsh-desktop.patch.yml @@ -6,3 +6,10 @@ - insert: - id: directory-picker-native-desktop name: '@deepseek-ai/dsh-host-directory-picker-native' + + # The native interaction has a second, renderless client row. Without it + # ui-workspace has no directory-flow occupant, so every workspace picker + # entry point becomes inert or disappears even though the Host capability + # itself is running. + - id: directory-picker-native-desktop-surface + name: '@deepseek-ai/dsh-client-ui-directory-picker-native' diff --git a/test/release.test.ts b/test/release.test.ts index a6b7bf2..7b72798 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -89,6 +89,7 @@ describe('GitHub release contract', () => { expect(splash).toContain('prefers-reduced-motion') expect(patch).toMatch(/id: directory-picker\r?\n disabled: true/) expect(patch).toContain("name: '@deepseek-ai/dsh-host-directory-picker-native'") + expect(patch).toContain("name: '@deepseek-ai/dsh-client-ui-directory-picker-native'") }) it('publishes update metadata for installed desktop builds', async () => {