diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index af12c91..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
@@ -268,8 +273,76 @@ 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
+ - 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) {
+ Start-Sleep -Seconds 5
+ $stableLog = Get-Content -Raw $logPath
+ 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))."
+ }
+ $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 }
+ }
+ - 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:
name: windows-x64
path: |
@@ -277,6 +350,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/build/dsh-desktop.patch.yml b/build/dsh-desktop.patch.yml
new file mode 100644
index 0000000..262806f
--- /dev/null
+++ b/build/dsh-desktop.patch.yml
@@ -0,0 +1,15 @@
+# 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'
+
+ # 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/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/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/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-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 5e60183..148eea2 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",
@@ -59,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",
@@ -89,6 +91,18 @@
{
"from": "build/app-icon.png",
"to": "icon.png"
+ },
+ {
+ "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 e9a3f18..adaf3ae 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 {
@@ -109,6 +110,21 @@ function dshEntryPath(): string {
return join(app.getAppPath(), 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js')
}
+function bundledNodePath(): string {
+ const executable = process.platform === 'win32' ? 'node.exe' : 'node'
+ 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 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')
@@ -167,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)
}
@@ -308,9 +332,12 @@ async function bootstrap(): Promise {
createWindow()
runtime = new HarnessRuntime({
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'),
- nodeExecutable: process.execPath,
+ 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 9bb7c4f..7555bdb 100644
--- a/src/main/runtime/harness-runtime.ts
+++ b/src/main/runtime/harness-runtime.ts
@@ -1,4 +1,4 @@
-import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
+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'
@@ -7,21 +7,65 @@ import type { RuntimePhase, RuntimeSnapshot } from '../../shared/contracts'
export interface HarnessRuntimeOptions {
dshEntryPath: string
+ nodeExecutablePath: string
+ nodeEntryPath: string
+ dshPatchPath: string
dshHome: string
logPath: string
- nodeExecutable: string
+ launchProcess(
+ executablePath: string,
+ args: string[],
+ options: SpawnOptionsWithoutStdio
+ ): ChildProcessWithoutNullStreams
startupTimeoutMs?: number
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 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 buildHarnessSpawnOptions(
+ launchDirectory: string,
+ dshHome: string,
+ platform: NodeJS.Platform = process.platform,
+ environment: NodeJS.ProcessEnv = process.env
+): SpawnOptionsWithoutStdio {
+ 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 ?? ''
+ },
+ stdio: ['pipe', 'pipe', 'pipe'],
+ windowsHide: true
+ }
+}
+
+export function buildNodeArguments(
+ nodeEntryPath: string,
+ dshEntryPath: string,
+ port: number,
+ patchPath?: string
+): string[] {
+ return [
+ '--expose-internals',
+ nodeEntryPath,
+ dshEntryPath,
+ ...buildHarnessArguments(port, patchPath)
+ ]
}
export class HarnessRuntime {
@@ -54,6 +98,18 @@ export class HarnessRuntime {
this.setState('failed', `Harness entry was not found: ${this.options.dshEntryPath}`)
return
}
+ if (!existsSync(this.options.nodeExecutablePath)) {
+ 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
+ }
+ 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 })
@@ -61,52 +117,70 @@ 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 = 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…')
- 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: 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
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
- const detail = signal ? `signal ${signal}` : `exit code ${code ?? 'unknown'}`
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
}
@@ -132,9 +206,12 @@ export class HarnessRuntime {
private async stopChild(child: ChildProcessWithoutNullStreams): Promise {
if (child.exitCode !== null) return
+ const exitPromise = new Promise((resolve) =>
+ child.once('exit', () => resolve(true))
+ )
child.kill('SIGTERM')
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')
@@ -164,6 +241,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/release.test.ts b/test/release.test.ts
index b1a50a5..7b72798 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,23 @@ 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).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 () => {
const packageJson = JSON.parse(
await readFile(path.join(projectRoot, 'package.json'), 'utf8')
@@ -128,10 +153,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 +177,17 @@ 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('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 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)
expect(
workflow.match(
diff --git a/test/runtime.test.ts b/test/runtime.test.ts
index 3060334..816548a 100644
--- a/test/runtime.test.ts
+++ b/test/runtime.test.ts
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'
-import { buildHarnessArguments, buildNodeArguments } from '../src/main/runtime/harness-runtime'
+import {
+ buildHarnessArguments,
+ buildHarnessSpawnOptions,
+ buildNodeArguments,
+ formatExitCode
+} from '../src/main/runtime/harness-runtime'
import { canGrantWindowPermission, isTrustedAppUrl } from '../src/main/security-policy'
import {
isAbortedNavigationError,
@@ -17,17 +22,70 @@ describe('Harness launch contract', () => {
])
})
- it('grants Node internals only to the Harness child process', () => {
- expect(buildNodeArguments('/runtime/dsh.js', 43127)).toEqual([
+ 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',
+ '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',
+ stdio: ['pipe', 'pipe', 'pipe'],
+ windowsHide: true,
+ 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('passes the internal-loader flag directly to bundled Node.js', () => {
+ expect(
+ buildNodeArguments(
+ 'C:\\app\\harness-node-entry.mjs',
+ 'C:\\app\\dsh\\lib\\bin.js',
+ 43127,
+ 'C:\\app\\dsh-desktop.patch.yml'
+ )
+ ).toEqual([
'--expose-internals',
- '/runtime/dsh.js',
+ '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',
'43127'
])
})
+
+ it('makes native Windows termination codes diagnosable', () => {
+ expect(formatExitCode(4294930435)).toContain(
+ '0xFFFF7003, Crashpad handler unavailable'
+ )
+ })
})
describe('navigation trust boundary', () => {