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', () => {