From a63308a5adce7d8d43e19cb9b8f189e8cdb5a37c Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Sun, 27 Sep 2026 20:49:13 -0700 Subject: [PATCH 01/10] refactor(windows): run Harness through Electron Node mode (cherry picked from commit edb439819a18a6157234e3dbe5bda5768bc1cebb) --- .github/workflows/release.yml | 9 +++++++-- build/harness-node-entry.mjs | 3 ++- docs/development.md | 2 +- docs/release-runbook.md | 2 +- package.json | 1 + .../generations/installer.mjs | 4 ++++ src/main/index.ts | 4 ++-- src/main/runtime/harness-runtime.ts | 7 ++++--- src/main/runtime/profile-plugin-command.ts | 5 +++-- test/profile-plugin-command.test.ts | 4 ++-- test/release.test.ts | 2 +- test/runtime.test.ts | 2 +- 12 files changed, 29 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1700aa63..84704f08d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -349,11 +349,16 @@ jobs: $desktopStdout = Join-Path $env:RUNNER_TEMP 'dsh-packaged-desktop.stdout.log' $desktopStderr = Join-Path $env:RUNNER_TEMP 'dsh-packaged-desktop.stderr.log' $packagedNode = Join-Path $isolatedApp 'resources\app.asar.unpacked\node_modules\node\bin\node.exe' - if (-not (Test-Path $packagedNode)) { throw 'Packaged Windows node.exe is missing.' } + if (Test-Path $packagedNode) { throw 'Standalone Windows node.exe must not be packaged.' } # Exercise the actual native binding too; the best-effort fallback # must not hide a missing or broken packaged koffi dependency. - & $packagedNode --input-type=module -e "import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const require = createRequire(pathToFileURL(process.argv[1])); const resolved = require.resolve('koffi'); if (!resolved.startsWith(process.argv[2])) throw new Error('koffi resolved outside packaged app: ' + resolved); const koffi = require('koffi'); const getCurrentProcessId = koffi.load('kernel32.dll').func('GetCurrentProcessId', 'uint32', []); if (getCurrentProcessId() !== process.pid) throw new Error('Native koffi smoke failed'); console.log('Packaged koffi native binding passed');" (Join-Path $isolatedApp 'resources\app.asar.unpacked\node_modules\koffi\package.json') (Join-Path $isolatedApp 'resources\app.asar.unpacked\node_modules') + $env:ELECTRON_RUN_AS_NODE = '1' + & $executable --input-type=module -e "import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const require = createRequire(pathToFileURL(process.argv[1])); const resolved = require.resolve('koffi'); if (!resolved.startsWith(process.argv[2])) throw new Error('koffi resolved outside packaged app: ' + resolved); const koffi = require('koffi'); const getCurrentProcessId = koffi.load('kernel32.dll').func('GetCurrentProcessId', 'uint32', []); if (getCurrentProcessId() !== process.pid) throw new Error('Native koffi smoke failed'); console.log('Packaged koffi native binding passed');" (Join-Path $isolatedApp 'resources\app.asar.unpacked\node_modules\koffi\package.json') (Join-Path $isolatedApp 'resources\app.asar.unpacked\node_modules') if ($LASTEXITCODE -ne 0) { throw 'Packaged koffi native binding failed.' } + $pnpmEntry = Join-Path $isolatedApp 'resources\app.asar.unpacked\node_modules\pnpm\bin\pnpm.cjs' + & $executable $pnpmEntry --version + if ($LASTEXITCODE -ne 0) { throw 'Packaged pnpm failed under Electron Node mode.' } + Remove-Item Env:ELECTRON_RUN_AS_NODE if (Test-Path $userData) { Remove-Item -Recurse -Force $userData } $desktop = Start-Process -FilePath $executable -WorkingDirectory $isolatedApp -PassThru ` -RedirectStandardOutput $desktopStdout -RedirectStandardError $desktopStderr diff --git a/build/harness-node-entry.mjs b/build/harness-node-entry.mjs index aaa369e8d..e893a0ecd 100644 --- a/build/harness-node-entry.mjs +++ b/build/harness-node-entry.mjs @@ -12,7 +12,8 @@ import { enforceWindowsChildProcessHide } from './windows-child-process-hide.mjs // `--expose-internals` shifts argv and the CLI answers "--profile is // required" instead of installing. Declaring it here, after this process has // already parsed the Chromium switches it was launched with, marks only the -// children as Node processes. Windows and Linux use standalone Node runtimes. +// children as Node processes. Windows declares Node mode before launching this +// entry; Linux uses a standalone Node runtime. if (process.versions.electron !== undefined) { process.env.ELECTRON_RUN_AS_NODE = '1' } diff --git a/docs/development.md b/docs/development.md index 5ea0f9ef4..0697780dc 100644 --- a/docs/development.md +++ b/docs/development.md @@ -93,7 +93,7 @@ npm run package:win Do not invoke `electron-builder --win` from macOS or Linux for a distributable Windows package. The target verification scripts intentionally reject host/target mismatches. -For local unsigned development packages, use the corresponding `package:dev:*` command. Windows packages retain a bundled Node runtime because the Harness native loader does not yet support Electron 43.4.0 in Node mode. Verify that `resources/app.asar.unpacked/node_modules/node/bin/node.exe` is present, the packaged native-module and Harness smokes pass, and the final signed installer passes the separate installed-app smoke before handoff. +For local unsigned development packages, use the corresponding `package:dev:*` command. Windows packages run Harness and package commands through the packaged Electron executable in Node mode; the standalone `resources/app.asar.unpacked/node_modules/node/bin/node.exe` must be absent. Verify the packaged native-module, pnpm and Harness smokes, then the final signed installer's separate installed-app smoke before handoff. The locked Electron 43.0.0 must remain compatible with the native loader; changing Electron requires a new Windows package qualification. Formal release artifacts are built, signed, and published by the tag workflow. A local build or pull-request check is not formal release evidence. diff --git a/docs/release-runbook.md b/docs/release-runbook.md index 650624f51..640dde7ef 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -28,7 +28,7 @@ Windows packaging and signing run as separate jobs. The GitHub-hosted Windows ru The pinned Windows NSIS template stages the application in a sibling directory before closing the old app, then renames the old directory to a backup and promotes the staged directory. A failed extraction or rename restores the previous installation. The signed installer smoke also locks the old executable to verify that a failed upgrade leaves it runnable. A user-selected different directory is an independent installation: the installer does not automatically uninstall the previous directory, which remains available until the user removes it. Keep the user-selected installation directory when changing this template; the build adapter rejects unexpected upstream template changes. -Runtime dependencies are unpacked beside `app.asar` because Harness, pnpm, native addons, and the standalone Windows Node process need physical paths. The macOS ARM64 test package contains a 6.2 MB `app.asar` and about 588 MB of unpacked dependencies; enabling asar alone did not reduce its 256 MB DMG. Do not remove `node.exe` from Windows yet: the pinned `node-addon-require-builtin@0.1.6` rejects Electron 43.4.0 in RunAsNode mode during Harness boot, even though the Koffi native probe passes ([Windows CI evidence](https://github.com/dataelement/dsh-desktop/actions/runs/35972303467)). Repeat the packaged Windows Harness and final signed-installer gates after a loader version explicitly supports the target Electron fingerprint before revisiting that removal. +Runtime dependencies remain unpacked beside `app.asar` because Harness, pnpm, native addons, and plugin generation installation need physical paths. The macOS ARM64 test package contains a 6.2 MB `app.asar` and about 588 MB of unpacked dependencies; enabling asar alone did not reduce its 256 MB DMG. Windows uses the packaged Electron executable in Node mode instead of an independent `node.exe`. The locked Electron 43.0.0 is a native-loader supported fingerprint; an earlier Electron 43.4.0 attempt failed during Harness boot even though the Koffi probe passed ([Windows CI evidence](https://github.com/dataelement/dsh-desktop/actions/runs/35972303467)). Qualify any Electron or native-loader change with packaged Windows Harness and final signed-installer gates. Prepare the local runner once: diff --git a/package.json b/package.json index 469ca5882..aaf9e68bd 100644 --- a/package.json +++ b/package.json @@ -463,6 +463,7 @@ }, "win": { "icon": "build/icon.ico", + "files": ["!node_modules/node/**/*"], "verifyUpdateCodeSignature": true, "signtoolOptions": { "publisherName": "Beijing Shuju Xiangsu Intelligence Technology Co., Ltd.", diff --git a/packages/dsh-desktop-market-installer/generations/installer.mjs b/packages/dsh-desktop-market-installer/generations/installer.mjs index cd8cfdec8..e2cc5cbc0 100644 --- a/packages/dsh-desktop-market-installer/generations/installer.mjs +++ b/packages/dsh-desktop-market-installer/generations/installer.mjs @@ -145,6 +145,10 @@ async function defaultRunInstall(options, stagingDir) { cwd: stagingDir, env: { ...(options.environment ?? process.env), + ...(process.platform === 'win32' && process.versions.electron && + options.nodeExecutablePath === process.execPath + ? { ELECTRON_RUN_AS_NODE: '1' } + : {}), CI: 'true', NO_COLOR: '1', npm_config_side_effects_cache: 'false' diff --git a/src/main/index.ts b/src/main/index.ts index 2e2b8f788..f4af5b197 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -631,8 +631,8 @@ function dshEntryPath(): string { } function bundledNodePath(): string { - const executable = process.platform === 'win32' ? 'node.exe' : 'node' - return join(bundledRuntimeRoot(), 'node_modules', 'node', 'bin', executable) + if (process.platform === 'win32') return process.execPath + return join(bundledRuntimeRoot(), 'node_modules', 'node', 'bin', 'node') } /** diff --git a/src/main/runtime/harness-runtime.ts b/src/main/runtime/harness-runtime.ts index 059b20fbd..beec6de85 100644 --- a/src/main/runtime/harness-runtime.ts +++ b/src/main/runtime/harness-runtime.ts @@ -303,9 +303,9 @@ export function buildHarnessSpawnOptions( const pathKey = platform === 'win32' ? 'Path' : 'PATH' const pathApi = platform === 'win32' ? win32 : posix - // ELECTRON_RUN_AS_NODE must not reach the Harness process itself: the macOS - // utility process starts with Chromium switches that Node rejects. The - // Harness entry declares Node mode only for its Electron children. + // The Windows Harness uses the packaged Electron executable as Node. macOS + // uses a utility process, which must not receive this flag before Chromium + // parses its switches. Its entry declares Node mode only for children. // // On Windows, `detached: true` puts the Harness in its own process group // and console. Without it, a child process that calls `os.kill(pid, 0)` @@ -318,6 +318,7 @@ export function buildHarnessSpawnOptions( cwd: launchDirectory, env: { ...parentEnvironment, + ...(platform === 'win32' && { ELECTRON_RUN_AS_NODE: '1' }), DSH_HOME: dshHome, NO_COLOR: '1', // package-import-method/child-concurrency are left at pnpm's defaults diff --git a/src/main/runtime/profile-plugin-command.ts b/src/main/runtime/profile-plugin-command.ts index c6df2cf5a..79d92e62b 100644 --- a/src/main/runtime/profile-plugin-command.ts +++ b/src/main/runtime/profile-plugin-command.ts @@ -112,14 +112,14 @@ export async function ensureProfilePnpmShim(options: ProfilePluginCommandOptions if (process.platform === 'win32') { await writeFile( join(directory, 'pnpm.cmd'), - `@chcp 65001 >nul\r\n@echo off\r\n"${options.nodeExecutablePath}" ${command + `@chcp 65001 >nul\r\n@echo off\r\n@set ELECTRON_RUN_AS_NODE=1\r\n"${options.nodeExecutablePath}" ${command .map((part) => `"${part}"`) .join(' ')} %*\r\n`, 'utf8' ) await writeFile( join(directory, 'node.cmd'), - `@chcp 65001 >nul\r\n@echo off\r\n"${options.nodeExecutablePath}" %*\r\n`, + `@chcp 65001 >nul\r\n@echo off\r\n@set ELECTRON_RUN_AS_NODE=1\r\n"${options.nodeExecutablePath}" %*\r\n`, 'utf8' ) } else { @@ -166,6 +166,7 @@ export function buildProfilePluginCommandEnvironment( ): NodeJS.ProcessEnv { const result = { ...environment } delete result.ELECTRON_RUN_AS_NODE + if (platform === 'win32') result.ELECTRON_RUN_AS_NODE = '1' // The spread above keeps only the casing the OS block actually stores — // even for `process.env`, whose case-insensitivity does not survive a diff --git a/test/profile-plugin-command.test.ts b/test/profile-plugin-command.test.ts index 0e75a1aea..6ad5ee98b 100644 --- a/test/profile-plugin-command.test.ts +++ b/test/profile-plugin-command.test.ts @@ -176,14 +176,14 @@ describe('profile pnpm shim and failure reporting', () => { }) describe('buildProfilePluginCommandEnvironment', () => { - it('keeps Windows plugin repair on bundled Node without inheriting Electron mode', () => { + it('runs Windows plugin repair through Electron Node mode', () => { const result = buildProfilePluginCommandEnvironment( { Path: 'C:\\Windows\\System32', ELECTRON_RUN_AS_NODE: '1' }, 'C:\\shim', 'C:\\DSH Desktop\\node.exe', 'win32' ) - expect(result.ELECTRON_RUN_AS_NODE).toBeUndefined() + expect(result.ELECTRON_RUN_AS_NODE).toBe('1') }) it('keeps the user PATH when the environment block stores it lowercase', () => { // Spreading `process.env` keeps only the casing the OS block stores, so diff --git a/test/release.test.ts b/test/release.test.ts index 9b8c45ff8..a0a104c8c 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -467,7 +467,7 @@ describe('GitHub release contract', () => { expect(workflow).toContain('smoke-signed-windows:') expect(workflow).toContain('smoke-signed-windows-installer.ps1') expect(workflow).toContain("needs.smoke-signed-windows.result == 'success'") - expect(workflow).toContain("if (-not (Test-Path $packagedNode)) { throw 'Packaged Windows node.exe is missing.' }") + expect(workflow).toContain("if (Test-Path $packagedNode) { throw 'Standalone Windows node.exe must not be packaged.' }") expect(workflow).toContain('version="${PRERELEASE_TAG#v}"') expect(workflow).toContain('version="${SIGNED_VERSION#v}"') expect(workflow).not.toContain('version="${PRERELEASE_TAG:-${GITHUB_REF_NAME#v}}"') diff --git a/test/runtime.test.ts b/test/runtime.test.ts index 973ca3b6c..c37cb8990 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -198,7 +198,7 @@ describe('Harness launch contract', () => { Path: 'windows-path' } }) - expect(options.env).not.toHaveProperty('ELECTRON_RUN_AS_NODE') + expect(options.env).toHaveProperty('ELECTRON_RUN_AS_NODE', '1') }) it('asks the patched Harness to resolve Safe Mode plugins from its installation only', () => { From f80d29e9967d5d8582cdf7bf599cb526811ce32b Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Sun, 27 Sep 2026 20:58:14 -0700 Subject: [PATCH 02/10] fix(windows): verify PPT with packaged Electron Node (cherry picked from commit 916704418a603c0ed63ac2b8d8d6c7f356694a4a) --- scripts/verify-packaged-ppt-runtime.cjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/verify-packaged-ppt-runtime.cjs b/scripts/verify-packaged-ppt-runtime.cjs index 3c8e120ca..5445ab165 100644 --- a/scripts/verify-packaged-ppt-runtime.cjs +++ b/scripts/verify-packaged-ppt-runtime.cjs @@ -71,12 +71,17 @@ module.exports = async function verifyPackagedPptRuntime(context) { ] const appRoot = candidates.find(candidate => require('node:fs').existsSync(candidate)) if (!appRoot) throw new Error('Cannot locate the packaged physical runtime') - const executable = path.join(appRoot, 'node_modules', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node') + const executable = process.platform === 'win32' + ? path.join(context.appOutDir, product + '.exe') + : path.join(appRoot, 'node_modules', 'node', 'bin', 'node') if (!require('node:fs').existsSync(executable)) throw new Error('Cannot locate the packaged Node runtime') // Harness needs physical package paths. Electron's ASAR-aware filesystem // would incorrectly accept generated plugins left entirely in app.asar. await execFileAsync(executable, [__filename, '--verify-runtime', appRoot], { cwd: appRoot, + env: process.platform === 'win32' + ? { ...process.env, ELECTRON_RUN_AS_NODE: '1' } + : process.env, timeout: 120_000, maxBuffer: 1024 * 1024 }) From 9e6787a6f00d3854e7bc554ac4de6060b80669b7 Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Sun, 27 Sep 2026 21:10:02 -0700 Subject: [PATCH 03/10] test(windows): preserve explicit Koffi probe exit (cherry picked from commit e92473c448278d555233ba2da80882e4eed68e43) --- .github/workflows/release.yml | 6 ++++-- test/release.test.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 84704f08d..f1c9b631f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -353,8 +353,10 @@ jobs: # Exercise the actual native binding too; the best-effort fallback # must not hide a missing or broken packaged koffi dependency. $env:ELECTRON_RUN_AS_NODE = '1' - & $executable --input-type=module -e "import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const require = createRequire(pathToFileURL(process.argv[1])); const resolved = require.resolve('koffi'); if (!resolved.startsWith(process.argv[2])) throw new Error('koffi resolved outside packaged app: ' + resolved); const koffi = require('koffi'); const getCurrentProcessId = koffi.load('kernel32.dll').func('GetCurrentProcessId', 'uint32', []); if (getCurrentProcessId() !== process.pid) throw new Error('Native koffi smoke failed'); console.log('Packaged koffi native binding passed');" (Join-Path $isolatedApp 'resources\app.asar.unpacked\node_modules\koffi\package.json') (Join-Path $isolatedApp 'resources\app.asar.unpacked\node_modules') - if ($LASTEXITCODE -ne 0) { throw 'Packaged koffi native binding failed.' } + & $executable --input-type=module -e "import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const require = createRequire(pathToFileURL(process.argv[1])); const resolved = require.resolve('koffi'); if (!resolved.startsWith(process.argv[2])) throw new Error('koffi resolved outside packaged app: ' + resolved); const koffi = require('koffi'); const getCurrentProcessId = koffi.load('kernel32.dll').func('GetCurrentProcessId', 'uint32', []); if (getCurrentProcessId() !== process.pid) throw new Error('Native koffi smoke failed'); console.log('Packaged koffi native binding passed'); process.exit(0);" (Join-Path $isolatedApp 'resources\app.asar.unpacked\node_modules\koffi\package.json') (Join-Path $isolatedApp 'resources\app.asar.unpacked\node_modules') + $koffiExitCode = $LASTEXITCODE + Write-Host "Electron Node Koffi probe exit code: $koffiExitCode" + if ($koffiExitCode -ne 0) { throw "Packaged koffi native binding failed (exit code $koffiExitCode)." } $pnpmEntry = Join-Path $isolatedApp 'resources\app.asar.unpacked\node_modules\pnpm\bin\pnpm.cjs' & $executable $pnpmEntry --version if ($LASTEXITCODE -ne 0) { throw 'Packaged pnpm failed under Electron Node mode.' } diff --git a/test/release.test.ts b/test/release.test.ts index a0a104c8c..fc198856c 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -354,7 +354,7 @@ describe('GitHub release contract', () => { expect(workflow).toContain("$isolatedApp = Join-Path $env:RUNNER_TEMP") expect(workflow).toContain('$executable = Join-Path $isolatedApp $sourceExecutable.Name') expect(workflow).toContain('-WorkingDirectory $isolatedApp') - expect(workflow).toContain('Packaged koffi native binding failed.') + expect(workflow).toContain('Packaged koffi native binding failed (exit code $koffiExitCode).') expect(workflow).toContain("'dist-dev\\win-unpacked\\DSH Desktop Dev.exe'") expect(workflow).toContain('if (-not [string]::IsNullOrEmpty($log))') expect(workflow).toContain("dsh web: (http://127\\.0\\.0\\.1:\\d+/\\?token=[^\\s]+)") From 119b9a9e5b21319259ae0f3f99c11ffd5ef20bd1 Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Sun, 27 Sep 2026 21:22:17 -0700 Subject: [PATCH 04/10] test(windows): wait for Electron Node probe exit (cherry picked from commit 6a7b2cab2dea0e5426151fa55621676171a57852) --- .github/workflows/release.yml | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f1c9b631f..414c608de 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -352,15 +352,30 @@ jobs: if (Test-Path $packagedNode) { throw 'Standalone Windows node.exe must not be packaged.' } # Exercise the actual native binding too; the best-effort fallback # must not hide a missing or broken packaged koffi dependency. - $env:ELECTRON_RUN_AS_NODE = '1' - & $executable --input-type=module -e "import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const require = createRequire(pathToFileURL(process.argv[1])); const resolved = require.resolve('koffi'); if (!resolved.startsWith(process.argv[2])) throw new Error('koffi resolved outside packaged app: ' + resolved); const koffi = require('koffi'); const getCurrentProcessId = koffi.load('kernel32.dll').func('GetCurrentProcessId', 'uint32', []); if (getCurrentProcessId() !== process.pid) throw new Error('Native koffi smoke failed'); console.log('Packaged koffi native binding passed'); process.exit(0);" (Join-Path $isolatedApp 'resources\app.asar.unpacked\node_modules\koffi\package.json') (Join-Path $isolatedApp 'resources\app.asar.unpacked\node_modules') - $koffiExitCode = $LASTEXITCODE - Write-Host "Electron Node Koffi probe exit code: $koffiExitCode" + # A Windows GUI-subsystem executable makes PowerShell's '&' return + # before the Node-mode child exits, leaving $LASTEXITCODE unset. + function Invoke-ElectronNode([string[]]$nodeArguments) { + $start = [System.Diagnostics.ProcessStartInfo]::new($executable) + $start.WorkingDirectory = $isolatedApp + $start.UseShellExecute = $false + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.Environment['ELECTRON_RUN_AS_NODE'] = '1' + foreach ($argument in $nodeArguments) { [void]$start.ArgumentList.Add($argument) } + $child = [System.Diagnostics.Process]::Start($start) + $stdout = $child.StandardOutput.ReadToEnd() + $stderr = $child.StandardError.ReadToEnd() + $child.WaitForExit() + if ($stdout) { Write-Host $stdout } + if ($stderr) { Write-Host $stderr } + return $child.ExitCode + } + $koffiProbe = "import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const require = createRequire(pathToFileURL(process.argv[1])); const resolved = require.resolve('koffi'); if (!resolved.startsWith(process.argv[2])) throw new Error('koffi resolved outside packaged app: ' + resolved); const koffi = require('koffi'); const getCurrentProcessId = koffi.load('kernel32.dll').func('GetCurrentProcessId', 'uint32', []); if (getCurrentProcessId() !== process.pid) throw new Error('Native koffi smoke failed'); console.log('Packaged koffi native binding passed'); process.exit(0);" + $koffiExitCode = Invoke-ElectronNode @('--input-type=module', '-e', $koffiProbe, (Join-Path $isolatedApp 'resources\app.asar.unpacked\node_modules\koffi\package.json'), (Join-Path $isolatedApp 'resources\app.asar.unpacked\node_modules')) if ($koffiExitCode -ne 0) { throw "Packaged koffi native binding failed (exit code $koffiExitCode)." } $pnpmEntry = Join-Path $isolatedApp 'resources\app.asar.unpacked\node_modules\pnpm\bin\pnpm.cjs' - & $executable $pnpmEntry --version - if ($LASTEXITCODE -ne 0) { throw 'Packaged pnpm failed under Electron Node mode.' } - Remove-Item Env:ELECTRON_RUN_AS_NODE + $pnpmExitCode = Invoke-ElectronNode @($pnpmEntry, '--version') + if ($pnpmExitCode -ne 0) { throw "Packaged pnpm failed under Electron Node mode (exit code $pnpmExitCode)." } if (Test-Path $userData) { Remove-Item -Recurse -Force $userData } $desktop = Start-Process -FilePath $executable -WorkingDirectory $isolatedApp -PassThru ` -RedirectStandardOutput $desktopStdout -RedirectStandardError $desktopStderr From e45a39e61a7b9bbb442a247d2b9e57bcb2affc4a Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Mon, 28 Sep 2026 02:27:46 -0700 Subject: [PATCH 05/10] build: fail packaging when Electron Node mode rejects the Harness loader Harness 0.1.7 reaches Node internals through node-addon-require-builtin, whose native binary accepts only the Electron builds it was compiled for (0.1.6: 43.0.0, 44.0.0, 45.0.0-alpha.6). An unsupported Electron passed every packaging step and failed only at Harness boot, as Electron 43.4.0 did on Windows CI. verify-target now runs the same five internal-module loads under the packaging Electron in Node mode and stops the build with the loader's own message. Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 2 +- docs/development.md | 2 +- scripts/electron-node-loader.d.mts | 19 +++++++ scripts/electron-node-loader.mjs | 79 ++++++++++++++++++++++++++++++ scripts/verify-target.mjs | 19 ++++++- test/electron-node-loader.test.ts | 76 ++++++++++++++++++++++++++++ 6 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 scripts/electron-node-loader.d.mts create mode 100644 scripts/electron-node-loader.mjs create mode 100644 test/electron-node-loader.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index 0d0863f77..e5e0c5dec 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,7 +19,7 @@ flowchart TD MAIN --> UPDATE["Installed-build update manager"] ``` -On macOS, Harness runs in an Electron UtilityProcess with Node capabilities. On Windows, it is launched with the bundled target-native Node.js executable. Cordis HMR's `--expose-internals` permission is granted to that isolated process and never to the web renderer. +On macOS, Harness runs in an Electron UtilityProcess with Node capabilities. On Windows, it is launched from the packaged Electron executable in Node mode (`ELECTRON_RUN_AS_NODE=1`); Windows packages carry no standalone `node.exe`. Both paths reach Node internals through Harness's `node-addon-require-builtin`, which accepts only the Electron builds it was compiled for, so `scripts/verify-target.mjs` runs the same loader calls under the packaging Electron before every package build. Cordis HMR's `--expose-internals` permission is granted to that isolated process and never to the web renderer. ## Startup flow diff --git a/docs/development.md b/docs/development.md index 0697780dc..9864d1949 100644 --- a/docs/development.md +++ b/docs/development.md @@ -93,7 +93,7 @@ npm run package:win Do not invoke `electron-builder --win` from macOS or Linux for a distributable Windows package. The target verification scripts intentionally reject host/target mismatches. -For local unsigned development packages, use the corresponding `package:dev:*` command. Windows packages run Harness and package commands through the packaged Electron executable in Node mode; the standalone `resources/app.asar.unpacked/node_modules/node/bin/node.exe` must be absent. Verify the packaged native-module, pnpm and Harness smokes, then the final signed installer's separate installed-app smoke before handoff. The locked Electron 43.0.0 must remain compatible with the native loader; changing Electron requires a new Windows package qualification. +For local unsigned development packages, use the corresponding `package:dev:*` command. Windows packages run Harness and package commands through the packaged Electron executable in Node mode; the standalone `resources/app.asar.unpacked/node_modules/node/bin/node.exe` must be absent. Verify the packaged native-module, pnpm and Harness smokes, then the final signed installer's separate installed-app smoke before handoff. The locked Electron 43.0.0 must remain compatible with the native loader: `scripts/verify-target.mjs` fails packaging when Electron in Node mode cannot load it, and changing Electron still requires a new Windows package qualification. Formal release artifacts are built, signed, and published by the tag workflow. A local build or pull-request check is not formal release evidence. diff --git a/scripts/electron-node-loader.d.mts b/scripts/electron-node-loader.d.mts new file mode 100644 index 000000000..bb661e547 --- /dev/null +++ b/scripts/electron-node-loader.d.mts @@ -0,0 +1,19 @@ +export const HARNESS_INTERNAL_MODULES: readonly string[] + +export interface ElectronNodeRuntime { + electron: string | null + node: string + v8: string +} + +export type ElectronNodeLoaderProbe = + | { ok: true; runtime: ElectronNodeRuntime } + | { ok: false; detail: string } + +export function harnessLoaderAnchor(projectRoot: string): string +export function electronExecutable(projectRoot: string): string +export function probeElectronNodeLoader(options: { + executable: string + anchor: string + timeoutMs?: number +}): ElectronNodeLoaderProbe diff --git a/scripts/electron-node-loader.mjs b/scripts/electron-node-loader.mjs new file mode 100644 index 000000000..9ab68e003 --- /dev/null +++ b/scripts/electron-node-loader.mjs @@ -0,0 +1,79 @@ +import { spawnSync } from 'node:child_process' +import { createRequire } from 'node:module' +import { dirname } from 'node:path' + +/** + * Harness 0.1.7 resolves Profile packages through Node internals reached by + * `node-addon-require-builtin`. Its native binary accepts only the Electron + * runtime fingerprints it was built for (exact Node and V8), and it reports an + * unsupported one only when Harness boots. Electron 43.4.0 passed every + * packaging step and failed there. This probe runs the same calls under the + * Electron executable in Node mode so an unsupported pairing stops packaging. + */ +export const HARNESS_INTERNAL_MODULES = [ + 'internal/modules/esm/loader', + 'internal/modules/cjs/loader', + 'internal/modules/helpers', + 'internal/modules/esm/utils', + 'internal/modules/esm/resolve' +] + +const PROBE_SOURCE = ` +const { createRequire } = require('node:module') +const [anchor, ...ids] = process.argv.slice(1) +const addon = createRequire(anchor)('node-addon-require-builtin') +for (const id of ids) addon.requireBuiltin(id) +process.stdout.write(JSON.stringify({ + electron: process.versions.electron ?? null, + node: process.versions.node, + v8: process.versions.v8 +})) +` + +/** The package manifest Harness loads the addon from, so the probe resolves the same copy. */ +export function harnessLoaderAnchor(projectRoot) { + const projectRequire = createRequire(`${projectRoot}/package.json`) + return projectRequire.resolve('@deepseek-ai/dsh-app-boot/package.json') +} + +/** The Electron executable the build packages, as the `electron` package reports it. */ +export function electronExecutable(projectRoot) { + const projectRequire = createRequire(`${projectRoot}/package.json`) + const executable = projectRequire('electron') + if (typeof executable !== 'string') { + throw new Error('The electron package did not report an executable path.') + } + return executable +} + +/** + * Run the Harness loader calls under `executable` in Electron Node mode. + * @returns the runtime versions on success, or the loader's own error text. + */ +export function probeElectronNodeLoader({ executable, anchor, timeoutMs = 60_000 }) { + const result = spawnSync( + executable, + ['-e', PROBE_SOURCE, anchor, ...HARNESS_INTERNAL_MODULES], + { + cwd: dirname(anchor), + encoding: 'utf8', + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + timeout: timeoutMs, + windowsHide: true + } + ) + if (result.error) { + return { ok: false, detail: `could not start ${executable}: ${result.error.message}` } + } + if (result.status !== 0) { + const output = `${result.stderr ?? ''}${result.stdout ?? ''}`.trim() + const reason = output.split(/\r?\n/u).find((line) => /unsupported|Error/u.test(line)) ?? output + return { ok: false, detail: reason || `exited with ${result.status ?? result.signal}` } + } + try { + const runtime = JSON.parse(result.stdout.trim()) + return { ok: true, runtime } + } catch { + return { ok: false, detail: `returned an invalid probe result: ${result.stdout.trim()}` } + } +} diff --git a/scripts/verify-target.mjs b/scripts/verify-target.mjs index 054e5d3f4..06cad94ce 100644 --- a/scripts/verify-target.mjs +++ b/scripts/verify-target.mjs @@ -1,6 +1,7 @@ import { constants, accessSync } from 'node:fs' import { resolve } from 'node:path' import { spawnSync } from 'node:child_process' +import { electronExecutable, harnessLoaderAnchor, probeElectronNodeLoader } from './electron-node-loader.mjs' const [expectedPlatform, expectedArch] = process.argv.slice(2) @@ -55,4 +56,20 @@ if (runtime.platform !== expectedPlatform || runtime.arch !== expectedArch) { process.exit(1) } -console.log(`Packaging target verified: ${process.platform}/${process.arch}; bundled Node.js ${runtime.version}`) +// Windows runs Harness through Electron Node mode and macOS through a utility +// process; both reach Node internals through the Harness native loader, which +// accepts only the Electron builds it was compiled for. +const loader = probeElectronNodeLoader({ + executable: electronExecutable(process.cwd()), + anchor: harnessLoaderAnchor(process.cwd()) +}) +if (!loader.ok) { + console.error(`Electron Node mode cannot load the Harness native loader: ${loader.detail}`) + console.error('Pin an Electron version the installed node-addon-require-builtin supports, or update the loader, before packaging.') + process.exit(1) +} + +console.log( + `Packaging target verified: ${process.platform}/${process.arch}; bundled Node.js ${runtime.version}; ` + + `Electron ${loader.runtime.electron} (Node ${loader.runtime.node}) loads the Harness native loader` +) diff --git a/test/electron-node-loader.test.ts b/test/electron-node-loader.test.ts new file mode 100644 index 000000000..8e89f9592 --- /dev/null +++ b/test/electron-node-loader.test.ts @@ -0,0 +1,76 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { + HARNESS_INTERNAL_MODULES, + electronExecutable, + harnessLoaderAnchor, + probeElectronNodeLoader +} from '../scripts/electron-node-loader.mjs' + +/** A project whose only package is a stand-in for the Harness native loader. */ +async function fixtureProject(loaderSource: string): Promise<{ root: string; anchor: string }> { + const root = await mkdtemp(path.join(tmpdir(), 'dsh-electron-node-loader-')) + const loader = path.join(root, 'node_modules', 'node-addon-require-builtin') + await mkdir(loader, { recursive: true }) + await writeFile(path.join(loader, 'package.json'), JSON.stringify({ name: 'node-addon-require-builtin', main: 'index.js' })) + await writeFile(path.join(loader, 'index.js'), loaderSource) + const anchor = path.join(root, 'package.json') + await writeFile(anchor, JSON.stringify({ name: 'fixture' })) + return { root, anchor } +} + +describe('Electron Node mode Harness loader probe', () => { + it('loads every internal module Harness profile resolution needs under the pinned Electron', () => { + const result = probeElectronNodeLoader({ + executable: electronExecutable(process.cwd()), + anchor: harnessLoaderAnchor(process.cwd()) + }) + + expect(result).toMatchObject({ ok: true }) + if (result.ok) expect(result.runtime.electron).toMatch(/^\d+\.\d+\.\d+/u) + }) + + it('requests each internal module from the loader Harness resolves', async () => { + const { root, anchor } = await fixtureProject(` + const { appendFileSync } = require('node:fs') + const { join } = require('node:path') + exports.requireBuiltin = (id) => { appendFileSync(join(__dirname, 'requested.txt'), id + '\\n'); return {} } + `) + try { + const result = probeElectronNodeLoader({ executable: process.execPath, anchor }) + expect(result).toMatchObject({ ok: true, runtime: { electron: null, node: process.versions.node } }) + const requested = await readFile(path.join(root, 'node_modules', 'node-addon-require-builtin', 'requested.txt'), 'utf8') + expect(requested.trim().split('\n')).toEqual([...HARNESS_INTERNAL_MODULES]) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('reports the loader rejection instead of passing the build', async () => { + const { root, anchor } = await fixtureProject(` + exports.requireBuiltin = () => { + throw new Error('node-addon-require-builtin unsupported: Unsupported/no-context (unsupported Electron runtime fingerprint: Node 24.18.1)') + } + `) + try { + const result = probeElectronNodeLoader({ executable: process.execPath, anchor }) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.detail).toContain('unsupported Electron runtime fingerprint') + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('reports an executable that cannot start', async () => { + const { root, anchor } = await fixtureProject('exports.requireBuiltin = () => ({})') + try { + const result = probeElectronNodeLoader({ executable: path.join(root, 'missing-electron'), anchor }) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.detail).toContain('could not start') + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) From 3a1f4dd5d8b2971b6121faeb6857c93d4c04cb82 Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Mon, 28 Sep 2026 03:33:29 -0700 Subject: [PATCH 06/10] fix(windows): use upstream zero-width sidebar collapse (cherry picked from commit 4f023e87bcb02e31a0d07219697d95a252535673) --- src/preload/index.ts | 4 ++- src/preload/windows-titlebar.ts | 26 +++++++------- test/windows-sidebar-collapse.test.ts | 49 +++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 14 deletions(-) create mode 100644 test/windows-sidebar-collapse.test.ts diff --git a/src/preload/index.ts b/src/preload/index.ts index ca6e1b4d7..8de868951 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,7 +9,7 @@ import { } from './update-view' import { isPluginLoadError } from './plugin-error-view' import { findBootFailureText } from './boot-failure' -import { mountWindowsTitlebarLayout } from './windows-titlebar' +import { markWindowsTitlebar, mountWindowsTitlebarLayout } from './windows-titlebar' import { mountMacosWindowChrome } from './macos-window-chrome' if (process.platform === 'darwin') { @@ -23,6 +23,8 @@ if (process.platform === 'darwin') { window.addEventListener('unload', dispose, { once: true }) } +if (process.platform === 'win32') markWindowsTitlebar(document) + // Intercept and persist localStorage to disk storage before any page script executes setupDesktopStoragePersistence() diff --git a/src/preload/windows-titlebar.ts b/src/preload/windows-titlebar.ts index e4e11f9e1..4230283c7 100644 --- a/src/preload/windows-titlebar.ts +++ b/src/preload/windows-titlebar.ts @@ -10,8 +10,17 @@ interface TitlebarLayoutMountOptions { ipcRenderer: Pick } +/** Harness reads this marker while creating its first layout frame. */ +export function markWindowsTitlebar(document: Document): void { + const root = document.documentElement + if (!root) return + root.dataset.windowsTitlebar = '' + root.style.setProperty('--dsh-windows-titlebar-height', `${WINDOWS_TITLEBAR_HEIGHT}px`) +} + export function mountWindowsTitlebarLayout(options: TitlebarLayoutMountOptions): void { const { document, ipcRenderer } = options + markWindowsTitlebar(document) if (!document.body) return installLayout(document) @@ -60,19 +69,10 @@ function installLayout(document: Document): void { body.dsh-desktop-windows-titlebar-layout [data-dsh-sidebar-root][data-dsh-sidebar-wide="true"] { padding-top: 6px !important; } - body.dsh-desktop-windows-titlebar-layout [data-sidebar-right-panel], - body.dsh-desktop-windows-titlebar-layout [data-sidebar-right-panel="fullscreen"], - body.dsh-desktop-windows-titlebar-layout [data-rightbar-col] > div, - body.dsh-desktop-windows-titlebar-layout [data-side="rightbar"] { - top: var(--dsh-titlebar-safe-inset-top, 36px) !important; - height: calc(100% - var(--dsh-titlebar-safe-inset-top, 36px)) !important; - } - /* rc.2 owns the header outside the session slot. Keep all header - contributions in normal flow below the native caption/menu strip. */ - body.dsh-desktop-windows-titlebar-layout [data-dsh-conversation-header] { - min-height: 76px !important; - padding-top: calc(var(--dsh-titlebar-safe-inset-top, 36px) + 6px) !important; - box-sizing: border-box !important; + /* Harness now reserves the caption row in AppFrame. Keep its drag area + clear of the native controls and our separate application menu view. */ + html[data-windows-titlebar] :has(> [data-shell-overlay])::before { + right: calc(var(${CAPTION_WIDTH_PROPERTY}, 140px) + 44px); } body.dsh-desktop-windows-titlebar-layout button, body.dsh-desktop-windows-titlebar-layout a, diff --git a/test/windows-sidebar-collapse.test.ts b/test/windows-sidebar-collapse.test.ts new file mode 100644 index 000000000..1d6c02772 --- /dev/null +++ b/test/windows-sidebar-collapse.test.ts @@ -0,0 +1,49 @@ +// @vitest-environment jsdom +import { readFileSync } from 'node:fs' +import ts from 'typescript' +import { expect, it } from 'vitest' +import { WINDOWS_TITLEBAR_HEIGHT } from '../src/shared/desktop-menu' +import { markWindowsTitlebar } from '../src/preload/windows-titlebar' + +const client = readFileSync('node_modules/@deepseek-ai/dsh-client-ui-layout/lib/client.js', 'utf8') +const ast = ts.createSourceFile('client.js', client, ts.ScriptTarget.Latest, true) + +function functionSource(name: string): string { + let source: string | undefined + const visit = (node: ts.Node): void => { + if (ts.isFunctionDeclaration(node) && node.name?.text === name) source = node.getText(ast) + ts.forEachChild(node, visit) + } + visit(ast) + if (!source) throw new Error(`Harness layout function ${name} is unavailable`) + return source +} + +let collapsedWidthSource: string | undefined +const findCollapsedWidth = (node: ts.Node): void => { + if (ts.isVariableStatement(node) && node.declarationList.declarations.some(declaration => + ts.isIdentifier(declaration.name) && declaration.name.text === 'collapsedWidth' + )) collapsedWidthSource = node.getText(ast) + ts.forEachChild(node, findCollapsedWidth) +} +findCollapsedWidth(ast) + +it('uses the upstream zero-width column after the Windows preload marks the page', () => { + const doc = document.implementation.createHTMLDocument() + markWindowsTitlebar(doc) + expect(doc.documentElement.hasAttribute('data-windows-titlebar')).toBe(true) + expect(doc.documentElement.style.getPropertyValue('--dsh-windows-titlebar-height')).toBe(`${WINDOWS_TITLEBAR_HEIGHT}px`) + if (!collapsedWidthSource) throw new Error('Harness collapsed sidebar width is unavailable') + + const createColumns = new Function('document', ` + ${functionSource('clampWidth')} + ${functionSource('computeColumns')} + const darwin = document.documentElement.dataset.platform === 'darwin' + ${collapsedWidthSource} + return () => computeColumns(1200, 0, 0, collapsedWidth) + `) as (doc: Document) => () => { sidebar: number; center: number } + + expect(createColumns(doc)()).toMatchObject({ sidebar: 0, center: 1200 }) + const webDoc = document.implementation.createHTMLDocument() + expect(createColumns(webDoc)()).toMatchObject({ sidebar: 56, center: 1144 }) +}) From 5f4ab7248b54110ed03a9924decac970795eb3fe Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Mon, 28 Sep 2026 23:01:33 -0700 Subject: [PATCH 07/10] test(windows): boot Harness through Electron Node mode in CI The loader gate proves the native resolver loads; it does not prove Harness boots. Add the probe from the RunAsNode assessment: it starts Harness from the repository through Electron Node mode with a disposable DSH_HOME, exchanges the launch token and requires an authenticated HTTP 200. The Windows CI job runs it before packaging. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 3 + docs/development.md | 2 +- scripts/probe-electron-node-runtime.mjs | 111 ++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 scripts/probe-electron-node-runtime.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 414c608de..82355130a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -314,6 +314,9 @@ jobs: - name: Verify PPT generation leaves tracked sources clean run: git diff --exit-code -- packages/ppt-runtime scripts/ppt - run: npm run typecheck + - name: Boot Harness through Electron Node mode on Windows + run: node scripts/probe-electron-node-runtime.mjs + timeout-minutes: 5 - name: Check recovery UI on native Windows at three display scales run: node scripts/check-recovery-ui.mjs - uses: actions/upload-artifact@v4 diff --git a/docs/development.md b/docs/development.md index 9864d1949..0a44c4b16 100644 --- a/docs/development.md +++ b/docs/development.md @@ -93,7 +93,7 @@ npm run package:win Do not invoke `electron-builder --win` from macOS or Linux for a distributable Windows package. The target verification scripts intentionally reject host/target mismatches. -For local unsigned development packages, use the corresponding `package:dev:*` command. Windows packages run Harness and package commands through the packaged Electron executable in Node mode; the standalone `resources/app.asar.unpacked/node_modules/node/bin/node.exe` must be absent. Verify the packaged native-module, pnpm and Harness smokes, then the final signed installer's separate installed-app smoke before handoff. The locked Electron 43.0.0 must remain compatible with the native loader: `scripts/verify-target.mjs` fails packaging when Electron in Node mode cannot load it, and changing Electron still requires a new Windows package qualification. +For local unsigned development packages, use the corresponding `package:dev:*` command. Windows packages run Harness and package commands through the packaged Electron executable in Node mode; the standalone `resources/app.asar.unpacked/node_modules/node/bin/node.exe` must be absent. Verify the packaged native-module, pnpm and Harness smokes, then the final signed installer's separate installed-app smoke before handoff. The locked Electron 43.0.0 must remain compatible with the native loader: `scripts/verify-target.mjs` fails packaging when Electron in Node mode cannot load it, and changing Electron still requires a new Windows package qualification. Before packaging, `node scripts/probe-electron-node-runtime.mjs` boots Harness from the repository through Electron Node mode with a disposable `DSH_HOME` and checks an authenticated HTTP response; the Windows CI job runs it on every build. Formal release artifacts are built, signed, and published by the tag workflow. A local build or pull-request check is not formal release evidence. diff --git a/scripts/probe-electron-node-runtime.mjs b/scripts/probe-electron-node-runtime.mjs new file mode 100644 index 000000000..339a37161 --- /dev/null +++ b/scripts/probe-electron-node-runtime.mjs @@ -0,0 +1,111 @@ +/** Probe the Electron Node runtime with a disposable Harness Profile. + * + * This must pass on the target platform before replacing the bundled Node + * executable: loading the native resolver alone does not prove Harness boots. + */ +import { spawn, execFileSync } from 'node:child_process' +import { createServer } from 'node:net' +import { mkdtemp, mkdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' + +const require = createRequire(import.meta.url) +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const electron = require('electron') +const entry = require.resolve('@deepseek-ai/dsh/lib/bin.js') +const startupTimeoutMs = 90_000 + +async function freePort() { + const server = createServer() + await new Promise((resolveReady, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolveReady) + }) + const address = server.address() + if (typeof address === 'string' || address === null) throw new Error('No TCP port was allocated') + await new Promise((resolveClosed) => server.close(resolveClosed)) + return address.port +} + +async function stop(child) { + if (child.exitCode !== null) return + const exited = new Promise((resolveExit) => { + child.once('exit', resolveExit) + child.once('error', resolveExit) + setTimeout(resolveExit, 3_000).unref() + }) + if (process.platform === 'win32') { + try { + if (child.pid !== undefined) { + execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }) + } + } catch { + child.kill() + } + } else { + child.kill('SIGTERM') + } + await exited +} + +async function main() { + const home = await mkdtemp(join(tmpdir(), 'dsh-electron-node-probe-')) + const launchRoot = join(home, 'launch-root') + await mkdir(launchRoot) + const port = await freePort() + const child = spawn(electron, [ + '--expose-internals', + join(root, 'build', 'harness-node-entry.mjs'), + entry, + 'web', '--no-open', '--host', '127.0.0.1', '--port', String(port) + ], { + cwd: launchRoot, + env: { ...process.env, DSH_HOME: join(home, 'harness'), ELECTRON_RUN_AS_NODE: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + detached: process.platform === 'win32' + }) + + let output = '' + let launchError + const append = (chunk) => { output = `${output}${chunk.toString('utf8')}`.slice(-16_384) } + child.stdout.on('data', append) + child.stderr.on('data', append) + child.once('error', (error) => { launchError = error; append(error.message) }) + + try { + const deadline = Date.now() + startupTimeoutMs + let url + while (Date.now() < deadline && child.exitCode === null && launchError === undefined) { + url = /dsh web: (http:\/\/127\.0\.0\.1:\d+\/\?token=[^\s]+)/u.exec(output)?.[1] + if (url) break + await new Promise((resolveDelay) => setTimeout(resolveDelay, 200)) + } + if (!url) throw new Error('Harness did not report a ready endpoint') + const exchange = await fetch(url, { + redirect: 'manual', + signal: AbortSignal.timeout(10_000) + }) + const cookie = exchange.headers.getSetCookie().map((value) => value.split(';')[0]).join('; ') + if (!cookie) throw new Error(`Harness token exchange returned HTTP ${exchange.status} without a session cookie`) + const response = await fetch(new URL('/', url), { + headers: { Cookie: cookie }, + signal: AbortSignal.timeout(10_000) + }) + if (!response.ok) throw new Error(`Harness returned HTTP ${response.status}`) + console.log(`Electron RunAsNode Harness probe passed on ${process.platform}/${process.arch} (HTTP ${response.status})`) + } catch (error) { + const diagnostic = output.replace(/token=[^\s]+/gu, 'token=[redacted]') + throw new Error(`${error instanceof Error ? error.message : String(error)}\n${diagnostic}`, { cause: error }) + } finally { + await stop(child) + await rm(home, { recursive: true, force: true }) + } +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) From c6e0510b5d7a2d45e458e4ff6e7ecba7665951ec Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Mon, 28 Sep 2026 23:45:24 -0700 Subject: [PATCH 08/10] fix(windows): match the upstream caption height and sidebar offset Two Desktop overrides still moved the Windows sidebar away from the upstream Desktop layout after switching to the Harness titlebar marker: - The caption row was 36px; upstream reserves 40 DIP, and Harness sizes the frame, sidebar toggle and right panels from that value. - The sidebar patch pads the expanded sidebar for non-macOS captions, and preload reduced that to 6px. With the marker Harness already starts every column below the caption, so the padding is now 0. The menu view, drag region and titlebar inset follow the shared height. Co-Authored-By: Claude Opus 5.5 --- src/preload/windows-menu.ts | 4 ++-- src/preload/windows-titlebar.ts | 16 +++++++++------ src/shared/desktop-menu.ts | 5 ++++- test/runtime.test.ts | 4 ++-- test/windows-sidebar-padding.test.ts | 29 ++++++++++++++++++++++++++++ test/windows-titlebar.test.ts | 4 ++-- 6 files changed, 49 insertions(+), 13 deletions(-) create mode 100644 test/windows-sidebar-padding.test.ts diff --git a/src/preload/windows-menu.ts b/src/preload/windows-menu.ts index 2d6080766..ff89f11a3 100644 --- a/src/preload/windows-menu.ts +++ b/src/preload/windows-menu.ts @@ -229,10 +229,10 @@ const menuStyles = ` html, body { width:100%; height:100%; margin:0; overflow:hidden; background:transparent; } body { color:var(--label-primary); font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; user-select:none; } .bar { position:relative; width:100%; height:100%; display:flex; justify-content:flex-end; align-items:flex-start; } - .menuButton { appearance:none; flex:none; width:44px; height:36px; display:grid; place-items:center; padding:0; color:var(--label-secondary); background:transparent; border:0; cursor:pointer; } + .menuButton { appearance:none; flex:none; width:44px; height:100%; display:grid; place-items:center; padding:0; color:var(--label-secondary); background:transparent; border:0; cursor:pointer; } .menuButton:hover, .menuButton.isOpen { color:var(--label-primary); background:var(--hover); } .menuButton:focus-visible { outline:2px solid #4d6bfe; outline-offset:-3px; } - .menu { position:absolute; top:43px; right:0; width:304px; max-height:calc(100vh - 56px); overflow:auto; padding:7px; color:var(--label-primary); background:var(--surface); border:1px solid var(--border); border-radius:12px; box-shadow:0 14px 36px rgba(0,0,0,.2); scrollbar-width:thin; } + .menu { position:absolute; top:47px; right:0; width:304px; max-height:calc(100vh - 56px); overflow:auto; padding:7px; color:var(--label-primary); background:var(--surface); border:1px solid var(--border); border-radius:12px; box-shadow:0 14px 36px rgba(0,0,0,.2); scrollbar-width:thin; } .menu[hidden] { display:none; } .sectionLabel { padding:7px 10px 4px; color:var(--label-tertiary); font-size:10px; font-weight:600; line-height:14px; letter-spacing:.08em; text-transform:uppercase; } .item { appearance:none; width:100%; min-height:33px; display:flex; align-items:center; justify-content:space-between; gap:20px; padding:6px 10px; color:inherit; background:transparent; border:0; border-radius:7px; font:inherit; font-size:13px; line-height:20px; text-align:left; cursor:pointer; } diff --git a/src/preload/windows-titlebar.ts b/src/preload/windows-titlebar.ts index 4230283c7..fa1eeba1a 100644 --- a/src/preload/windows-titlebar.ts +++ b/src/preload/windows-titlebar.ts @@ -63,11 +63,15 @@ function installLayout(document: Document): void { min-height: 0 !important; } :root { - --dsh-titlebar-safe-inset-top: max(36px, env(titlebar-area-height, 36px)); + --dsh-titlebar-safe-inset-top: max(${WINDOWS_TITLEBAR_HEIGHT}px, env(titlebar-area-height, ${WINDOWS_TITLEBAR_HEIGHT}px)); --dsh-titlebar-safe-inset-right: calc(var(${CAPTION_WIDTH_PROPERTY}, 140px) + 44px); } - body.dsh-desktop-windows-titlebar-layout [data-dsh-sidebar-root][data-dsh-sidebar-wide="true"] { - padding-top: 6px !important; + /* The sidebar patch pads the expanded sidebar for non-macOS captions. + With the Windows marker, Harness already starts every column below the + caption row, so any extra padding pushes the brand and New Session + below where upstream places them. */ + html[data-windows-titlebar] [data-dsh-sidebar-root][data-dsh-sidebar-wide="true"] { + padding-top: 0 !important; } /* Harness now reserves the caption row in AppFrame. Keep its drag area clear of the native controls and our separate application menu view. */ @@ -91,7 +95,7 @@ function installLayout(document: Document): void { top: 0; left: 0; right: calc(var(${CAPTION_WIDTH_PROPERTY}, 140px) + 44px); - height: 36px; + height: ${WINDOWS_TITLEBAR_HEIGHT}px; background: transparent; pointer-events: none; user-select: none; @@ -108,8 +112,8 @@ function installDragRegion(document: Document): void { dragRegion.setAttribute('aria-hidden', 'true') document.body.appendChild(dragRegion) - // The native drag region still wins over a modal's buttons in the top - // 36px, so hide it while a real dialog is open. Only semantic dialog + // The native drag region still wins over a modal's buttons in the caption + // row, so hide it while a real dialog is open. Only semantic dialog // markers count: class-name guesses match permanent elements and would // hide the region for good. const modalSelector = 'dialog[open], [role="dialog"], [role="alertdialog"], [aria-modal="true"]' diff --git a/src/shared/desktop-menu.ts b/src/shared/desktop-menu.ts index 1dce66b25..45a847661 100644 --- a/src/shared/desktop-menu.ts +++ b/src/shared/desktop-menu.ts @@ -1,4 +1,7 @@ -export const WINDOWS_TITLEBAR_HEIGHT = 36 +// Matches the 40 DIP caption row of the upstream Desktop (apps/desktop +// windows-layout.ts). Harness 0.1.7 sizes its layout, sidebar toggle and +// right panels from this value through --dsh-windows-titlebar-height. +export const WINDOWS_TITLEBAR_HEIGHT = 40 export const desktopMenuCommands = [ 'connect-phone', diff --git a/test/runtime.test.ts b/test/runtime.test.ts index c37cb8990..872e6d66b 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -729,10 +729,10 @@ describe('Harness window activation', () => { it('stamps Windows renderer URLs so plugins can avoid the native titlebar overlay', () => { expect(desktopHarnessUrl('http://127.0.0.1:43127', 'win32')).toBe( - 'http://127.0.0.1:43127/?dsh-desktop-mode=advanced&dsh-desktop-platform=win32&dsh-desktop-titlebar-inset=36' + 'http://127.0.0.1:43127/?dsh-desktop-mode=advanced&dsh-desktop-platform=win32&dsh-desktop-titlebar-inset=40' ) expect(desktopHarnessUrl('http://127.0.0.1:43127/?workspace=demo', 'win32')).toBe( - 'http://127.0.0.1:43127/?workspace=demo&dsh-desktop-mode=advanced&dsh-desktop-platform=win32&dsh-desktop-titlebar-inset=36' + 'http://127.0.0.1:43127/?workspace=demo&dsh-desktop-mode=advanced&dsh-desktop-platform=win32&dsh-desktop-titlebar-inset=40' ) expect(desktopHarnessUrl('http://127.0.0.1:43127', 'darwin')).toBe( 'http://127.0.0.1:43127' diff --git a/test/windows-sidebar-padding.test.ts b/test/windows-sidebar-padding.test.ts new file mode 100644 index 000000000..cc540b406 --- /dev/null +++ b/test/windows-sidebar-padding.test.ts @@ -0,0 +1,29 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest' +import { mountWindowsTitlebarLayout } from '../src/preload/windows-titlebar' + +afterEach(() => { + vi.unstubAllGlobals() + document.head.replaceChildren() + document.body.replaceChildren() + document.documentElement.removeAttribute('data-windows-titlebar') +}) + +it('drops the sidebar patch padding once Harness reserves the Windows caption row', () => { + vi.stubGlobal('matchMedia', () => ({ matches: false, addEventListener: () => undefined })) + vi.stubGlobal('ResizeObserver', class { observe(): void {} unobserve(): void {} }) + // The dsh-client-ui-sidebar patch adds this padding for non-macOS captions. + const patch = document.createElement('style') + patch.textContent = 'html:not([data-platform=darwin]) [data-dsh-sidebar-root][data-dsh-sidebar-wide="true"]{padding-top:32px}' + document.head.append(patch) + const sidebar = document.createElement('div') + sidebar.setAttribute('data-dsh-sidebar-root', '') + sidebar.setAttribute('data-dsh-sidebar-wide', 'true') + document.body.append(sidebar) + expect(getComputedStyle(sidebar).paddingTop).toBe('32px') + + mountWindowsTitlebarLayout({ document, ipcRenderer: { invoke: async () => undefined } }) + + expect(document.documentElement.hasAttribute('data-windows-titlebar')).toBe(true) + expect(getComputedStyle(sidebar).paddingTop).toBe('0px') +}) diff --git a/test/windows-titlebar.test.ts b/test/windows-titlebar.test.ts index 6dc726949..204c77fa5 100644 --- a/test/windows-titlebar.test.ts +++ b/test/windows-titlebar.test.ts @@ -31,7 +31,7 @@ describe('Windows titlebar menu', () => { const closedAt100Percent = windowsMenuViewBounds({ width: 1380, height: 900 }, false) const closedAt69Percent = windowsMenuViewBounds({ width: 1380, height: 900 }, false) - expect(closedAt100Percent).toEqual({ x: 1196, y: 0, width: 44, height: 36 }) + expect(closedAt100Percent).toEqual({ x: 1196, y: 0, width: 44, height: WINDOWS_TITLEBAR_HEIGHT }) expect(closedAt69Percent).toEqual(closedAt100Percent) expect(windowsMenuViewBounds({ width: 1380, height: 900 }, true)).toEqual({ @@ -44,7 +44,7 @@ describe('Windows titlebar menu', () => { x: 856, y: 0, width: 44, - height: 36 + height: WINDOWS_TITLEBAR_HEIGHT }) }) }) From 0c2832c8bc867b7501e3c29e4367d9678aa1a1f1 Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Tue, 29 Sep 2026 00:08:59 -0700 Subject: [PATCH 09/10] feat(windows): move the caption menu to the upstream seat with native popups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the separate WebContentsView dropdown at the top right with the upstream Desktop layout: an in-page "应用" / "编辑" menubar beside the sidebar toggle (following --dsh-windows-menu-start when the sidebar collapses) that opens native popup menus from the main process. - The application menu keeps every command the dropdown offered: phone pairing, Harness restart, Safe Mode, Harness log, session export, a View submenu (reload, developer tools, zoom with the current value, full screen), updates, about and exit. Popup accelerators are display only; the hidden application menu still owns the shortcuts. - Edit commands are sent as key events, as upstream does, so editors that keep their own history receive them. - Harness pages now rely on AppFrame's caption strip for dragging; Desktop's local pages keep their own strip. An open dialog releases both drag areas. - Drop patch hunks that only served the old caption: the conversation header marker, and the sidebar's non-macOS padding and brand spacing. - Remove the menu view, its preload, HTML resource and IPC channels. Co-Authored-By: Claude Opus 5.5 --- build/windows-menu.html | 14 - docs/architecture.md | 2 +- docs/baseline-runtime-compatibility.md | 38 +-- electron.vite.config.ts | 3 +- package.json | 4 - ...sh-client-ui-conversation+0.1.7-rc.2.patch | 32 +-- ...-ai+dsh-client-ui-sidebar+0.1.7-rc.2.patch | 21 +- scripts/recovery-ui-smoke.ts | 12 +- src/main/index.ts | 180 +++---------- src/main/windows-menu-view.ts | 37 --- src/main/windows-menu.ts | 114 ++++++++ src/preload/windows-menu-bar.ts | 153 +++++++++++ src/preload/windows-menu.ts | 254 ------------------ src/preload/windows-titlebar.ts | 95 ++----- test/windows-menu-bar.test.ts | 64 +++++ test/windows-sidebar-padding.test.ts | 29 -- test/windows-titlebar-header.test.mjs | 32 --- test/windows-titlebar.test.ts | 111 +++++--- 18 files changed, 510 insertions(+), 685 deletions(-) delete mode 100644 build/windows-menu.html delete mode 100644 src/main/windows-menu-view.ts create mode 100644 src/main/windows-menu.ts create mode 100644 src/preload/windows-menu-bar.ts delete mode 100644 src/preload/windows-menu.ts create mode 100644 test/windows-menu-bar.test.ts delete mode 100644 test/windows-sidebar-padding.test.ts delete mode 100644 test/windows-titlebar-header.test.mjs diff --git a/build/windows-menu.html b/build/windows-menu.html deleted file mode 100644 index 5b3fee66e..000000000 --- a/build/windows-menu.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - DSH Desktop application menu - - - - diff --git a/docs/architecture.md b/docs/architecture.md index e5e0c5dec..2060234e0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,7 +19,7 @@ flowchart TD MAIN --> UPDATE["Installed-build update manager"] ``` -On macOS, Harness runs in an Electron UtilityProcess with Node capabilities. On Windows, it is launched from the packaged Electron executable in Node mode (`ELECTRON_RUN_AS_NODE=1`); Windows packages carry no standalone `node.exe`. Both paths reach Node internals through Harness's `node-addon-require-builtin`, which accepts only the Electron builds it was compiled for, so `scripts/verify-target.mjs` runs the same loader calls under the packaging Electron before every package build. Cordis HMR's `--expose-internals` permission is granted to that isolated process and never to the web renderer. +On macOS, Harness runs in an Electron UtilityProcess with Node capabilities. On Windows, it is launched from the packaged Electron executable in Node mode (`ELECTRON_RUN_AS_NODE=1`); Windows packages carry no standalone `node.exe`. Both paths reach Node internals through Harness's `node-addon-require-builtin`, which accepts only the Electron builds it was compiled for, so `scripts/verify-target.mjs` runs the same loader calls under the packaging Electron before every package build. Cordis HMR's `--expose-internals` permission is granted to that isolated process and never to the web renderer. On Windows the window uses a hidden title bar: Harness draws the 40 DIP caption row, and Desktop adds the "应用" / "编辑" caption menubar beside the sidebar toggle, whose native popups carry the Desktop commands. ## Startup flow diff --git a/docs/baseline-runtime-compatibility.md b/docs/baseline-runtime-compatibility.md index 22e845615..b9d889ab3 100644 --- a/docs/baseline-runtime-compatibility.md +++ b/docs/baseline-runtime-compatibility.md @@ -24,22 +24,28 @@ Harness 0.1.7 可以通过运行时 resolver 将宿主依赖解析到当前安 验证:内置 Fetch 的 gzip JSON、对象/数组响应头、重复 Set-Cookie、分块 SSE、HTTP/1.1 数组保持原样、trailers;在 Electron Node 24.17.0 中通过 Harness 代理向 Google OAuth token 端点发送无凭据诊断请求,修复前得到 gzip 字节且无响应头,修复后能解析预期的 HTTP 400 JSON。该检查不代表真实账号认证或模型推理验收。 -### Windows header clearance after rc.2 - -`ConversationHeader` now owns the `
` outside `conversation.session.header`. -The old preload selector (`[data-slot="conversation.session.header"] > header`) -therefore no longer reserves space for native caption controls. The conversation -patch adds only `data-dsh-conversation-header` to the resident header; preload -uses that marker to reserve the native caption height and keeps all header slots -in normal flow below it. This covers sessionless and active views without cloning -contributions or relying on generated class names. This is window chrome, so the -layout stays in preload rather than a business plugin. Remove the marker patch -when upstream exposes an equivalent stable header/Windows clearance contract. - -Regression: `test/windows-titlebar-header.test.mjs` executes the installed rc.2 -header in both session states and verifies slot ownership/count. Local Chromium -geometry checks cover narrow/wide, light/dark and blank/active layouts; they do -not replace final Windows installer checks (DPI, zoom, maximize and clicks). +### Windows caption after rc.2 + +Harness 0.1.7 owns the Windows caption row once preload marks +`` and sets `--dsh-windows-titlebar-height` (40 +DIP, matching the upstream Desktop): AppFrame reserves, paints and drags the +row, the sidebar collapses to zero width and its toggle sits in the caption. +Desktop no longer patches the conversation header or pads the expanded sidebar +for the caption; the former `data-dsh-conversation-header` marker and the +sidebar's non-macOS padding were removed from their patches. + +The caption menubar ("应用" / "编辑") sits in the upstream seat beside the +sidebar toggle (`--dsh-windows-menu-start`) and opens native popup menus from +the main process. The application menu carries every Desktop command the former +dropdown offered (phone pairing, Harness restart, Safe Mode, log, session +export, view and zoom, updates, about, exit); edit commands are delivered as key +events so editor-owned history receives them. Desktop's own local pages, which +have no AppFrame, get the menubar at the caption's left edge and a drag strip. + +Regression: `test/windows-titlebar.test.ts` (menu contents, commands, key +events, request validation) and `test/windows-menu-bar.test.ts` (seat, +labels, popup request). Visual checks on Windows (DPI, zoom, maximize, +collapsed sidebar, caption buttons) are still required. ## 插件 Harness 版本声明采用告警策略 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 4e00c1fa2..c53d2d3db 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -11,8 +11,7 @@ export default defineConfig({ build: { rollupOptions: { input: { - index: resolve('src/preload/index.ts'), - 'windows-menu': resolve('src/preload/windows-menu.ts') + index: resolve('src/preload/index.ts') }, output: { format: 'cjs', diff --git a/package.json b/package.json index aaf9e68bd..d24e959d1 100644 --- a/package.json +++ b/package.json @@ -434,10 +434,6 @@ { "from": "build/community-wechat-qr.png", "to": "community-wechat-qr.png" - }, - { - "from": "build/windows-menu.html", - "to": "windows-menu.html" } ], "publish": [ diff --git a/patches/@deepseek-ai+dsh-client-ui-conversation+0.1.7-rc.2.patch b/patches/@deepseek-ai+dsh-client-ui-conversation+0.1.7-rc.2.patch index fa90e00b2..7239927df 100644 --- a/patches/@deepseek-ai+dsh-client-ui-conversation+0.1.7-rc.2.patch +++ b/patches/@deepseek-ai+dsh-client-ui-conversation+0.1.7-rc.2.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@deepseek-ai/dsh-client-ui-conversation/lib/client.js b/node_modules/@deepseek-ai/dsh-client-ui-conversation/lib/client.js -index 2107bbb..6b4defc 100644 +index 2107bbb..8f4eeb5 100644 --- a/node_modules/@deepseek-ai/dsh-client-ui-conversation/lib/client.js +++ b/node_modules/@deepseek-ai/dsh-client-ui-conversation/lib/client.js @@ -15719,12 +15719,13 @@ window.__ModuleLoader__.load({ @@ -54,15 +54,7 @@ index 2107bbb..6b4defc 100644 ] }); const composer = renderSlotChain("conversation.composer", { -@@ -16290,6 +16298,7 @@ window.__ModuleLoader__.load({ - const blank = session === void 0 || conversation === void 0 || session.blank && conversationPhase(session, conversation) === "blank"; - return (0, react_jsx_runtime.jsxs)("header", { - className: clsx(ConversationRoot_module_css_default.header, blank && ConversationRoot_module_css_default.headerBlank, sessionId === void 0 && ConversationRoot_module_css_default.headerSessionless), -+ "data-dsh-conversation-header": "", - "data-window-drag": true, - children: [(0, react_jsx_runtime.jsx)("div", { - className: ConversationRoot_module_css_default.headerLeading, -@@ -17092,13 +17101,14 @@ window.__ModuleLoader__.load({ +@@ -17092,13 +17100,14 @@ window.__ModuleLoader__.load({ } //#endregion //#region \0dsh-css:/home/runner/work/deepseek-harness/deepseek-harness/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css.mjs @@ -78,7 +70,7 @@ index 2107bbb..6b4defc 100644 document.head.appendChild(tag); } var InputBar_module_css_default = { -@@ -17120,6 +17130,7 @@ window.__ModuleLoader__.load({ +@@ -17120,6 +17129,7 @@ window.__ModuleLoader__.load({ "pending": "uV2eYG_pending", "placeholder": "uV2eYG_placeholder", "primary": "uV2eYG_primary", @@ -86,7 +78,7 @@ index 2107bbb..6b4defc 100644 "retry": "uV2eYG_retry", "root": "uV2eYG_root", "row": "uV2eYG_row", -@@ -17146,7 +17157,7 @@ window.__ModuleLoader__.load({ +@@ -17146,7 +17156,7 @@ window.__ModuleLoader__.load({ * The no-session state renders the SAME div inert as the Workspace-picker * trigger instead of a parallel tree. */ @@ -95,7 +87,7 @@ index 2107bbb..6b4defc 100644 const input = useInput((s) => s); const notice = useNotices((s) => s); const busyEnter = useBusyEnter((s) => s); -@@ -17374,10 +17385,6 @@ window.__ModuleLoader__.load({ +@@ -17374,10 +17384,6 @@ window.__ModuleLoader__.load({ className: InputBar_module_css_default.overlayAnchor, children: renderSlot("conversation.input.overlay", {}) }), @@ -106,7 +98,7 @@ index 2107bbb..6b4defc 100644 renderSlot("conversation.input.attachments", { attachments, canAcceptDrop, -@@ -17389,25 +17396,35 @@ window.__ModuleLoader__.load({ +@@ -17389,25 +17395,35 @@ window.__ModuleLoader__.load({ onRetryFile: (id) => { retryFileUpload?.(id); }, @@ -156,7 +148,7 @@ index 2107bbb..6b4defc 100644 }), (0, react_jsx_runtime.jsxs)("div", { ref: rowRef, -@@ -17535,7 +17552,7 @@ window.__ModuleLoader__.load({ +@@ -17535,7 +17551,7 @@ window.__ModuleLoader__.load({ }), (0, react_jsx_runtime.jsxs)("div", { className: InputBar_module_css_default.dock, @@ -165,7 +157,7 @@ index 2107bbb..6b4defc 100644 useProjection, t })] -@@ -18102,6 +18119,14 @@ window.__ModuleLoader__.load({ +@@ -18102,6 +18118,14 @@ window.__ModuleLoader__.load({ "conversation.hero.agentPreset": { kind: "single", scope: "session-maybe" @@ -180,7 +172,7 @@ index 2107bbb..6b4defc 100644 } }, slots: { -@@ -18225,6 +18250,10 @@ window.__ModuleLoader__.load({ +@@ -18225,6 +18249,10 @@ window.__ModuleLoader__.load({ kind: "list", scope: "session" }, @@ -191,7 +183,7 @@ index 2107bbb..6b4defc 100644 "conversation.input.permission": { kind: "single", scope: "session" -@@ -18261,6 +18290,7 @@ window.__ModuleLoader__.load({ +@@ -18261,6 +18289,7 @@ window.__ModuleLoader__.load({ removeAttachment: void 0, resolveDraftAttachments: void 0, retryFileUpload: void 0, @@ -199,7 +191,7 @@ index 2107bbb..6b4defc 100644 toggleCommandMenu: void 0, stop: void 0, hooks: { -@@ -18327,6 +18357,11 @@ window.__ModuleLoader__.load({ +@@ -18327,6 +18356,11 @@ window.__ModuleLoader__.load({ retryFileUpload: (id) => { if (sessions.binding(sessionId) !== void 0) conversation.retryFileUpload(sessionId, id); }, @@ -211,7 +203,7 @@ index 2107bbb..6b4defc 100644 toggleCommandMenu: inputTriggers === void 0 ? void 0 : (selection) => { shell.dismissPopup(); const snapshot = shell.snapshot; -@@ -18359,10 +18394,7 @@ window.__ModuleLoader__.load({ +@@ -18359,10 +18393,7 @@ window.__ModuleLoader__.load({ yield slots.register({ name: "main", key: "conversation", diff --git a/patches/@deepseek-ai+dsh-client-ui-sidebar+0.1.7-rc.2.patch b/patches/@deepseek-ai+dsh-client-ui-sidebar+0.1.7-rc.2.patch index 89e70c66e..f5100778a 100644 --- a/patches/@deepseek-ai+dsh-client-ui-sidebar+0.1.7-rc.2.patch +++ b/patches/@deepseek-ai+dsh-client-ui-sidebar+0.1.7-rc.2.patch @@ -1,16 +1,7 @@ diff --git a/node_modules/@deepseek-ai/dsh-client-ui-sidebar/lib/client.js b/node_modules/@deepseek-ai/dsh-client-ui-sidebar/lib/client.js -index 1c2bc08..c5122d3 100644 +index 1c2bc08..4ecebcd 100644 --- a/node_modules/@deepseek-ai/dsh-client-ui-sidebar/lib/client.js +++ b/node_modules/@deepseek-ai/dsh-client-ui-sidebar/lib/client.js -@@ -94,7 +94,7 @@ window.__ModuleLoader__.load({ - const tag = document.createElement("style"); - tag.dataset.plugin = "@deepseek-ai/dsh-client-ui-sidebar"; - tag.dataset.pluginCss = tagId; -- tag.textContent = css; -+ tag.textContent = css + "html:not([data-platform=darwin]) [data-dsh-sidebar-root][data-dsh-sidebar-wide=\"true\"]{padding-top:32px}[data-dsh-sidebar-brand-identity]{gap:4px}"; - document.head.appendChild(tag); - } - var SidebarRoot_module_css_default = { @@ -288,6 +288,8 @@ window.__ModuleLoader__.load({ }); return (0, react_jsx_runtime.jsxs)("div", { @@ -20,15 +11,7 @@ index 1c2bc08..c5122d3 100644 className: clsx(SidebarRoot_module_css_default.root, !wide && SidebarRoot_module_css_default.collapsed, !wide && everWide.current && SidebarRoot_module_css_default.railIn, collapsed && wide && SidebarRoot_module_css_default.fading, !pointerInside && SidebarRoot_module_css_default.quietBars), style: wide ? { width: collapsed ? lastWideWidth.current : width } : void 0, onPointerEnter: () => { -@@ -309,6 +311,7 @@ window.__ModuleLoader__.load({ - children: [wide && (() => { - const identity = (0, react_jsx_runtime.jsxs)("span", { - className: SidebarRoot_module_css_default.brandIdentity, -+ "data-dsh-sidebar-brand-identity": "", - "aria-hidden": "true", - children: [(0, react_jsx_runtime.jsx)("span", { - className: SidebarRoot_module_css_default.brandMark, -@@ -407,6 +410,7 @@ window.__ModuleLoader__.load({ +@@ -407,6 +409,7 @@ window.__ModuleLoader__.load({ className: SidebarRoot_module_css_default.footerActions, children: renderSlot("sidebar.footer.action", { wide }) }), (0, react_jsx_runtime.jsx)("div", { diff --git a/scripts/recovery-ui-smoke.ts b/scripts/recovery-ui-smoke.ts index 251b2f777..d7eb6133e 100644 --- a/scripts/recovery-ui-smoke.ts +++ b/scripts/recovery-ui-smoke.ts @@ -1,11 +1,11 @@ -import { app, BrowserWindow, WebContentsView, ipcMain, shell, desktopCapturer } from 'electron' +import { app, BrowserWindow, ipcMain, shell, desktopCapturer } from 'electron' import { strict as assert } from 'node:assert' import { mkdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { SAFE_MODE_FRAME_CHANNEL, SAFE_MODE_FRAME_UPDATE_CHANNEL, SafeModeFrame } from '../src/main/safe-mode-frame' import { buildSafeModeViewModel } from '../src/main/safe-mode' import { buildPluginRecoveryViewModel } from '../src/main/plugin-recovery-view' -import { windowsMenuViewBounds } from '../src/main/windows-menu-view' +import { WINDOWS_TITLEBAR_HEIGHT } from '../src/shared/desktop-menu' import { secureWindow } from '../src/main/security' const scale = process.env.RECOVERY_UI_SCALE || '1' @@ -44,7 +44,7 @@ async function main(): Promise { show: true, frame: process.platform !== 'darwin', ...(process.platform === 'win32' ? { titleBarStyle: 'hidden' as const, - titleBarOverlay: { color: '#00000000', symbolColor: '#fafafa', height: 36 }, + titleBarOverlay: { color: '#00000000', symbolColor: '#fafafa', height: WINDOWS_TITLEBAR_HEIGHT }, autoHideMenuBar: true } : {}), backgroundColor: '#18181b', @@ -52,9 +52,6 @@ async function main(): Promise { }) parent.setMenuBarVisibility(false) secureWindow(parent) - const menu = new WebContentsView({ webPreferences: { sandbox: true } }) - menu.setBackgroundColor('#00000000') - if (process.platform === 'win32') parent.contentView.addChildView(menu) const names = ['calendar-plugin', 'search-plugin', 'notes-plugin', '@community/billing-plugin', '@community/longer-agent-memory-plugin', 'mobile-plugin'] let closed = 0 for (const scenario of ['safe-mode', 'plugin-recovery', 'multiple-plugins', 'market-offline', 'unidentified-plugin']) { @@ -69,8 +66,7 @@ async function main(): Promise { for (const locale of ['zh', 'en'] as const) for (const theme of ['light', 'dark']) for (const [width, height] of [[1280,800], [900,640]]) { parent.setSize(width!, height!) if (process.platform === 'win32') { - parent.setTitleBarOverlay({ color: '#00000000', symbolColor: theme === 'dark' ? '#fafafa' : '#18181b', height: 36 }) - menu.setBounds(windowsMenuViewBounds({ width: width!, height: height! }, false)) + parent.setTitleBarOverlay({ color: '#00000000', symbolColor: theme === 'dark' ? '#fafafa' : '#18181b', height: WINDOWS_TITLEBAR_HEIGHT }) } const model = page === 'safe-mode' ? buildSafeModeViewModel({ locale, plugins: names }) : buildPluginRecoveryViewModel({ locale, plugins: scenario === 'unidentified-plugin' ? [] : (scenario === 'multiple-plugins' || scenario === 'market-offline') ? names.slice(0, 3) : [names[0]!], removedPlugins: [], diff --git a/src/main/index.ts b/src/main/index.ts index f4af5b197..b8f14a5b1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -19,7 +19,6 @@ import { shell, Tray, utilityProcess, - WebContentsView, type IpcMainInvokeEvent, type MessageBoxOptions } from 'electron' @@ -157,7 +156,6 @@ import { resolveHarnessLocale } from './application-locale' import { installContextMenu } from './context-menu' import { WINDOWS_TITLEBAR_HEIGHT, - isDesktopMenuCommand, isZoomMenuCommand, type DesktopMenuCommand } from '../shared/desktop-menu' @@ -181,7 +179,7 @@ import { } from './state/plugin-market-check' import { upgradeMarketInSharedTree, upgradePluginToGeneration } from './state/plugin-upgrade' import { aboutDetail, bundledHarnessVersion } from './version-info' -import { windowsMenuViewBounds } from './windows-menu-view' +import { parseWindowsMenuRequest, windowsMenuTemplate, type EditingKey } from './windows-menu' import { shouldKeepRunningInBackground } from './close-to-tray' import { MAIN_WINDOW_RECOVERY_RELOAD_COOLDOWN_MS, @@ -216,9 +214,6 @@ const PLUGIN_RECOVERY_ACTIONS = new Set([ ]) let mainWindow: BrowserWindow | undefined -let windowsMenuView: WebContentsView | undefined -let windowsMenuOpen = false -let windowsMenuDark = false let mobileWindow: BrowserWindow | undefined let tray: Tray | undefined let runtime: HarnessRuntime @@ -505,87 +500,10 @@ function applyWindowChromeTheme(window: BrowserWindow, isDark: boolean): void { if (process.platform === 'darwin') applyMacosWindowBackdrop(window, isDark) else window.setBackgroundColor(isDark ? '#141416' : '#ffffff') if (process.platform === 'win32') { - windowsMenuDark = isDark window.setTitleBarOverlay(windowsTitleBarOverlay(isDark)) - if (windowsMenuView && !windowsMenuView.webContents.isDestroyed()) { - windowsMenuView.webContents.send('desktop-titlebar:theme-changed', isDark) - } - } -} - -function updateWindowsMenuViewBounds(window: BrowserWindow): void { - if (!windowsMenuView || windowsMenuView.webContents.isDestroyed() || window.isDestroyed()) return - const contentSize = window.getContentSize() - const width = contentSize[0] ?? 0 - const height = contentSize[1] ?? 0 - windowsMenuView.setBounds( - windowsMenuViewBounds({ width, height }, windowsMenuOpen, window.isFullScreen()) - ) -} - -function setWindowsMenuOpen(window: BrowserWindow, open: boolean, notifyRenderer = false): void { - windowsMenuOpen = open - updateWindowsMenuViewBounds(window) - if (notifyRenderer && windowsMenuView && !windowsMenuView.webContents.isDestroyed()) { - windowsMenuView.webContents.send('desktop-titlebar:close-menu') } } -function attachWindowsMenuView(window: BrowserWindow): void { - const menuView = new WebContentsView({ - webPreferences: { - contextIsolation: true, - nodeIntegration: false, - preload: join(import.meta.dirname, '../preload/windows-menu.cjs'), - sandbox: true, - webSecurity: true - } - }) - windowsMenuView = menuView - windowsMenuOpen = false - windowsMenuDark = nativeTheme.shouldUseDarkColors - menuView.setBackgroundColor('#00000000') - menuView.webContents.setZoomFactor(1) - menuView.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) - menuView.webContents.on('did-finish-load', () => { - if (!menuView.webContents.isDestroyed()) { - menuView.webContents.send('desktop-titlebar:theme-changed', windowsMenuDark) - } - }) - // This view is not the main window webContents, so it sits outside - // installMainWindowRendererRecovery's reload/GPU-fallback path — without - // its own recovery a lost renderer here just leaves a dead, invisible menu - // until the user restarts the whole app. - menuView.webContents.on('render-process-gone', (_event, details) => { - if (['clean-exit', 'killed'].includes(details.reason)) return - runtime?.note( - `[desktop] windows menu view render-process-gone: reason=${details.reason} exitCode=${details.exitCode}` - ) - if (menuView.webContents.isDestroyed()) return - void loadDesktopResource(menuView.webContents, desktopResourcePath('windows-menu.html'), { - query: { - locale: harnessLocale(), - theme: windowsMenuDark ? 'dark' : 'light' - } - }).catch(showUnexpectedError) - }) - window.contentView.addChildView(menuView) - updateWindowsMenuViewBounds(window) - - const updateBounds = (): void => updateWindowsMenuViewBounds(window) - window.on('resize', updateBounds) - window.on('enter-full-screen', updateBounds) - window.on('leave-full-screen', updateBounds) - window.on('blur', () => setWindowsMenuOpen(window, false, true)) - - void loadDesktopResource(menuView.webContents, desktopResourcePath('windows-menu.html'), { - query: { - locale: harnessLocale(), - theme: windowsMenuDark ? 'dark' : 'light' - } - }).catch(showUnexpectedError) -} - function configureAppIdentity(): void { if (developmentBuild) { app.setName('DSH Desktop Dev') @@ -1106,17 +1024,11 @@ function createWindow(): BrowserWindow { installMainWindowRendererRecovery(window) window.on('closed', () => { if (mainWindow === window) mainWindow = undefined - if (windowsMenuView && !windowsMenuView.webContents.isDestroyed()) { - windowsMenuView.webContents.close() - } - windowsMenuView = undefined - windowsMenuOpen = false resolvePluginRecoveryAction('quit') resolveWebImportAction('skip') resolveSafeModeAction({ type: 'quit' }) }) mainWindow = window - if (isWindows) attachWindowsMenuView(window) return window } @@ -1805,37 +1717,27 @@ function registerHarnessHandlers(): void { return { ok: true, enabled, restartRequired: true } }) - ipcMain.removeHandler('desktop-menu:execute') - ipcMain.handle('desktop-menu:execute', async (event, command: unknown) => { - assertTrustedDesktopMenuEvent(event) - if (!isDesktopMenuCommand(command)) { - throw new Error('Unknown DSH Desktop menu command.') - } - const zoomFactor = await executeDesktopMenuCommand(command) - return zoomFactor === undefined ? { ok: true } : { ok: true, zoomFactor } - }) - - ipcMain.removeHandler('desktop-menu:get-zoom-factor') - ipcMain.handle('desktop-menu:get-zoom-factor', (event) => { - assertTrustedDesktopMenuEvent(event) - return { zoomFactor: mainWindow?.webContents.getZoomFactor() ?? 1 } - }) - - ipcMain.removeHandler('desktop-titlebar:set-menu-open') - ipcMain.handle('desktop-titlebar:set-menu-open', (event, open: unknown) => { - assertTrustedWindowsMenuEvent(event) - if (typeof open !== 'boolean') { - throw new Error('The application menu state must be a boolean.') - } - if (mainWindow && !mainWindow.isDestroyed()) setWindowsMenuOpen(mainWindow, open) - return { ok: true } - }) - - ipcMain.removeHandler('desktop-titlebar:close-menu') - ipcMain.handle('desktop-titlebar:close-menu', (event) => { + ipcMain.removeHandler('desktop-titlebar:popup-menu') + ipcMain.handle('desktop-titlebar:popup-menu', (event, name: unknown, x: unknown, y: unknown) => { assertTrustedMainWindowEvent(event) - if (mainWindow && !mainWindow.isDestroyed()) setWindowsMenuOpen(mainWindow, false, true) - return { ok: true } + if (process.platform !== 'win32') throw new Error('The caption menu is only available on Windows.') + const request = parseWindowsMenuRequest(name, x, y) + const window = mainWindow + if (!window || window.isDestroyed()) return + const zoomFactor = window.webContents.getZoomFactor() + const template = windowsMenuTemplate(request.name, harnessLocale(), zoomFactor, { + run: (command) => void executeDesktopMenuCommand(command).catch(showUnexpectedError), + sendEditingKey: (key) => sendEditingKey(window, key) + }) + // The page reports CSS pixels; the popup is placed in window DIPs. + return new Promise((resolve) => { + Menu.buildFromTemplate(template).popup({ + window, + x: Math.round(request.x * zoomFactor), + y: Math.round(request.y * zoomFactor), + callback: () => resolve() + }) + }) }) ipcMain.removeHandler('desktop-titlebar:set-theme') @@ -1863,33 +1765,6 @@ function registerHarnessHandlers(): void { }) } -function assertTrustedDesktopMenuEvent(event: IpcMainInvokeEvent): void { - const fromMainWindow = - mainWindow && - !mainWindow.isDestroyed() && - event.sender === mainWindow.webContents && - event.senderFrame === mainWindow.webContents.mainFrame - const fromWindowsMenu = - windowsMenuView && - !windowsMenuView.webContents.isDestroyed() && - event.sender === windowsMenuView.webContents && - event.senderFrame === windowsMenuView.webContents.mainFrame - if (!fromMainWindow && !fromWindowsMenu) { - throw new Error('This action is only available from the DSH Desktop window.') - } -} - -function assertTrustedWindowsMenuEvent(event: IpcMainInvokeEvent): void { - if ( - !windowsMenuView || - windowsMenuView.webContents.isDestroyed() || - event.sender !== windowsMenuView.webContents || - event.senderFrame !== windowsMenuView.webContents.mainFrame - ) { - throw new Error('This action is only available from the Windows application menu.') - } -} - function assertTrustedMainWindowEvent(event: IpcMainInvokeEvent): void { if ( !mainWindow || @@ -1949,6 +1824,19 @@ async function showAbout(window: BrowserWindow): Promise { if (result.response === 0) await checkForUpdates(true) } +/** + * Deliver an editing shortcut as key events. The composer and other editors + * keep their own history and listen for the keys, so Chromium's native undo + * stack (webContents.undo) would skip them. + */ +function sendEditingKey(window: BrowserWindow, key: EditingKey): void { + if (window.isDestroyed()) return + const contents = window.webContents + contents.focus() + contents.sendInputEvent({ type: 'keyDown', keyCode: key.keyCode, modifiers: key.modifiers }) + contents.sendInputEvent({ type: 'keyUp', keyCode: key.keyCode, modifiers: key.modifiers }) +} + async function executeDesktopMenuCommand(command: DesktopMenuCommand): Promise { const window = mainWindow if (!window || window.isDestroyed()) return diff --git a/src/main/windows-menu-view.ts b/src/main/windows-menu-view.ts deleted file mode 100644 index 5d1a60ef2..000000000 --- a/src/main/windows-menu-view.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Rectangle } from 'electron' -import { WINDOWS_TITLEBAR_HEIGHT } from '../shared/desktop-menu' - -export const WINDOWS_CAPTION_CONTROLS_WIDTH = 140 -export const WINDOWS_MENU_BUTTON_WIDTH = 44 -export const WINDOWS_MENU_PANEL_WIDTH = 304 -export const WINDOWS_MENU_PANEL_MAX_HEIGHT = 760 - -interface ContentSize { - width: number - height: number -} - -export function windowsMenuViewBounds( - contentSize: ContentSize, - menuOpen: boolean, - fullscreen = false -): Rectangle { - const contentWidth = Math.max(0, Math.floor(contentSize.width)) - const contentHeight = Math.max(0, Math.floor(contentSize.height)) - const captionWidth = fullscreen - ? 0 - : Math.min(WINDOWS_CAPTION_CONTROLS_WIDTH, contentWidth) - const availableWidth = Math.max(0, contentWidth - captionWidth) - const requestedWidth = menuOpen ? WINDOWS_MENU_PANEL_WIDTH : WINDOWS_MENU_BUTTON_WIDTH - const width = Math.min(requestedWidth, availableWidth) - const height = menuOpen - ? Math.min(WINDOWS_MENU_PANEL_MAX_HEIGHT, contentHeight) - : Math.min(WINDOWS_TITLEBAR_HEIGHT, contentHeight) - - return { - x: Math.max(0, contentWidth - captionWidth - width), - y: 0, - width, - height - } -} diff --git a/src/main/windows-menu.ts b/src/main/windows-menu.ts new file mode 100644 index 000000000..b491b67b8 --- /dev/null +++ b/src/main/windows-menu.ts @@ -0,0 +1,114 @@ +import type { MenuItemConstructorOptions } from 'electron' +import { formatZoomPercentage, type DesktopMenuCommand } from '../shared/desktop-menu' + +/** + * The two caption menus the Windows page shows beside the sidebar toggle, in + * the seat the upstream Desktop uses. The popups are native so they paint over + * the page, the webviews and the caption controls without a separate view. + */ +export const windowsMenuNames = ['application', 'edit'] as const +export type WindowsMenuName = (typeof windowsMenuNames)[number] + +/** Editing keys go to the focused editor, whose own history ignores Chromium's native undo stack. */ +export type EditingKey = { keyCode: string; modifiers: Array<'control'> } + +export interface WindowsMenuActions { + run(command: DesktopMenuCommand): void + sendEditingKey(key: EditingKey): void +} + +export interface WindowsMenuRequest { + name: WindowsMenuName + x: number + y: number +} + +/** Validate a popup request from the page: a known menu anchored inside the window. */ +export function parseWindowsMenuRequest(name: unknown, x: unknown, y: unknown): WindowsMenuRequest { + const coordinate = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 100_000 + if (!windowsMenuNames.includes(name as WindowsMenuName) || !coordinate(x) || !coordinate(y)) { + throw new Error('The Windows caption menu request is invalid.') + } + return { name: name as WindowsMenuName, x, y } +} + +/** + * Build one caption menu. The application menu carries every Desktop command + * the former dropdown offered; the edit menu mirrors upstream. + * @param zoomFactor - current page zoom, shown on the reset item. + */ +export function windowsMenuTemplate( + name: WindowsMenuName, + locale: 'zh' | 'en', + zoomFactor: number, + actions: WindowsMenuActions +): MenuItemConstructorOptions[] { + const zh = locale === 'zh' + const command = ( + id: DesktopMenuCommand, + zhLabel: string, + enLabel: string, + accelerator?: string + ): MenuItemConstructorOptions => ({ + label: zh ? zhLabel : enLabel, + ...(accelerator === undefined ? {} : { accelerator, registerAccelerator: false }), + click: () => actions.run(id) + }) + const editing = ( + zhLabel: string, + enLabel: string, + key: EditingKey, + accelerator?: string + ): MenuItemConstructorOptions => ({ + label: zh ? zhLabel : enLabel, + ...(accelerator === undefined ? {} : { accelerator, registerAccelerator: false }), + click: () => actions.sendEditingKey(key) + }) + + if (name === 'edit') { + return [ + editing('撤销', 'Undo', { keyCode: 'Z', modifiers: ['control'] }, 'Ctrl+Z'), + editing('重做', 'Redo', { keyCode: 'Y', modifiers: ['control'] }, 'Ctrl+Y'), + { type: 'separator' }, + editing('剪切', 'Cut', { keyCode: 'X', modifiers: ['control'] }, 'Ctrl+X'), + editing('复制', 'Copy', { keyCode: 'C', modifiers: ['control'] }, 'Ctrl+C'), + editing('粘贴', 'Paste', { keyCode: 'V', modifiers: ['control'] }, 'Ctrl+V'), + editing('删除', 'Delete', { keyCode: 'Delete', modifiers: [] }), + { type: 'separator' }, + editing('全选', 'Select All', { keyCode: 'A', modifiers: ['control'] }, 'Ctrl+A') + ] + } + + return [ + command('connect-phone', '连接手机…', 'Connect Phone…', 'Ctrl+Shift+M'), + command('restart-harness', '重启 Harness', 'Restart Harness', 'Ctrl+Shift+R'), + command('safe-mode', '以安全模式重启…', 'Restart as Safe Mode…'), + command('show-harness-log', '显示 Harness 日志', 'Show Harness Log'), + command('export-session', '导出 Session 日志…', 'Export Session Log…'), + { type: 'separator' }, + { + label: zh ? '视图' : 'View', + submenu: [ + command('reload', '重新加载', 'Reload', 'Ctrl+R'), + command('toggle-devtools', '开发者工具', 'Developer Tools', 'Ctrl+Shift+I'), + { type: 'separator' }, + command('zoom-in', '放大', 'Zoom In', 'Ctrl+='), + command('zoom-out', '缩小', 'Zoom Out', 'Ctrl+-'), + command( + 'zoom-reset', + `实际大小(当前 ${formatZoomPercentage(zoomFactor)})`, + `Actual Size (now ${formatZoomPercentage(zoomFactor)})`, + 'Ctrl+0' + ), + { type: 'separator' }, + command('toggle-fullscreen', '切换全屏', 'Toggle Full Screen', 'F11') + ] + }, + { type: 'separator' }, + command('check-for-updates', '检查更新…', 'Check for Updates…', 'Ctrl+U'), + command('about', '关于 DSH Desktop', 'About DSH Desktop'), + { type: 'separator' }, + command('quit', '退出', 'Exit') + ] +} diff --git a/src/preload/windows-menu-bar.ts b/src/preload/windows-menu-bar.ts new file mode 100644 index 000000000..0ba26bd08 --- /dev/null +++ b/src/preload/windows-menu-bar.ts @@ -0,0 +1,153 @@ +import type { IpcRenderer } from 'electron' + +type MenuName = 'application' | 'edit' + +interface MenuBarOptions { + document: Document + ipcRenderer: Pick +} + +const HOST_ATTRIBUTE = 'data-dsh-windows-menu' + +/** + * Mount the Windows caption menubar ("应用" / "编辑") where the upstream + * Desktop puts it: in the caption row, right of the sidebar toggle. Harness + * 0.1.7 moves that seat with `--dsh-windows-menu-start` when the sidebar + * collapses. The popups are native menus opened by the main process. + * + * Harness pages wait for AppFrame's shell overlay so the menubar appears with + * the rendered application; Desktop's own pages (plugin recovery, splash) have + * no sidebar and mount immediately at the caption's left edge. + */ +export function mountWindowsMenuBar(options: MenuBarOptions): { dispose(): void } { + const { document, ipcRenderer } = options + const existing = document.querySelector(`[${HOST_ATTRIBUTE}]`) + if (existing) return { dispose: () => existing.remove() } + + const host = document.createElement('div') + host.setAttribute(HOST_ATTRIBUTE, '') + const shadow = host.attachShadow({ mode: 'open' }) + const style = document.createElement('style') + style.textContent = ` + :host { position: fixed; top: 0; left: var(--dsh-windows-menu-start, 48px); z-index: 1100; + height: var(--dsh-windows-titlebar-height, 40px); display: flex; align-items: center; + font-family: var(--dsw-font-family, "Segoe UI", sans-serif); -webkit-app-region: no-drag; } + :host([data-standalone]) { left: 12px; } + [role=menubar] { display: flex; gap: 2px; } + button { height: 28px; padding: 0 10px; border: 0; border-radius: 6px; + background: transparent; color: var(--dsw-alias-label-secondary, #5f6368); + font: inherit; font-size: 14px; cursor: default; } + button:hover, button[aria-expanded=true] { + background: var(--dsw-alias-interactive-bg-hover, rgba(32, 33, 36, .08)); + color: var(--dsw-alias-label-primary, #202124); } + button:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #4d6bfe); outline-offset: -2px; } + ` + const bar = document.createElement('div') + bar.setAttribute('role', 'menubar') + + // Opening a menu must not take focus from the composer: the edit commands + // act on whatever editor held it. + let restoreEditor = (): void => {} + const rememberEditor = (event: FocusEvent): void => { + const target = event.composedPath()[0] + if (!(target instanceof HTMLElement) || target === host || shadow.contains(target)) return + if (!(target instanceof HTMLInputElement) && !(target instanceof HTMLTextAreaElement) + && !target.matches('[contenteditable="true"]')) return + const input = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement ? target : undefined + const start = input?.selectionStart + const end = input?.selectionEnd + const selection = document.getSelection() + const ranges = selection === null + ? [] + : Array.from({ length: selection.rangeCount }, (_, index) => selection.getRangeAt(index).cloneRange()) + restoreEditor = () => { + if (!target.isConnected) return + target.focus({ preventScroll: true }) + if (input !== undefined && start != null && end != null) input.setSelectionRange(start, end) + else if (selection !== null && ranges.length > 0) { + selection.removeAllRanges() + for (const range of ranges) selection.addRange(range) + } + } + } + document.addEventListener('focusout', rememberEditor, true) + + const buttons: HTMLButtonElement[] = [] + const createButton = (name: MenuName, index: number): HTMLButtonElement => { + const button = document.createElement('button') + button.type = 'button' + button.dataset.menu = name + button.setAttribute('role', 'menuitem') + button.setAttribute('aria-haspopup', 'menu') + button.setAttribute('aria-expanded', 'false') + button.tabIndex = index === 0 ? 0 : -1 + button.addEventListener('pointerdown', (event) => event.preventDefault()) + button.addEventListener('mousedown', (event) => event.preventDefault()) + const open = async (): Promise => { + if (button.getAttribute('aria-expanded') === 'true') return + const rect = button.getBoundingClientRect() + button.setAttribute('aria-expanded', 'true') + if (document.activeElement === host) restoreEditor() + try { + await ipcRenderer.invoke('desktop-titlebar:popup-menu', name, rect.left, rect.bottom) + } catch (error) { + console.error('[desktop-titlebar] caption menu failed', error) + } finally { + button.setAttribute('aria-expanded', 'false') + } + } + button.addEventListener('click', () => void open()) + button.addEventListener('keydown', (event) => { + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + event.preventDefault() + const next = buttons[index === 0 ? 1 : 0] + if (!next) return + button.tabIndex = -1 + next.tabIndex = 0 + next.focus() + } else if (event.key === 'ArrowDown') { + event.preventDefault() + void open() + } + }) + bar.append(button) + return button + } + buttons.push(createButton('application', 0), createButton('edit', 1)) + shadow.append(style, bar) + + const relabel = (): void => { + const lang = document.documentElement.lang || navigator.language + const zh = lang.toLowerCase().startsWith('zh') + bar.setAttribute('aria-label', zh ? '应用菜单' : 'Application menu') + const [application, edit] = buttons + if (application) application.textContent = zh ? '应用' : 'Application' + if (edit) edit.textContent = zh ? '编辑' : 'Edit' + } + relabel() + const languageObserver = new MutationObserver(relabel) + languageObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] }) + + const standalone = document.location.protocol === 'file:' + if (standalone) host.dataset.standalone = '' + const mount = (): void => { + if (!document.body) return + // AppFrame owns this seat on Harness pages; before it renders, the + // caption row belongs to the loading surface. + if (!standalone && document.querySelector('[data-shell-overlay]') === null) return + document.body.append(host) + mountObserver.disconnect() + } + const mountObserver = new MutationObserver(mount) + mountObserver.observe(document.documentElement, { childList: true, subtree: true }) + mount() + + return { + dispose: () => { + mountObserver.disconnect() + languageObserver.disconnect() + document.removeEventListener('focusout', rememberEditor, true) + host.remove() + } + } +} diff --git a/src/preload/windows-menu.ts b/src/preload/windows-menu.ts deleted file mode 100644 index ff89f11a3..000000000 --- a/src/preload/windows-menu.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { ipcRenderer } from 'electron' -import { formatZoomPercentage, type DesktopMenuCommand } from '../shared/desktop-menu' - -type MenuEntry = - | { kind: 'command'; command: DesktopMenuCommand; label: string; shortcut?: string } - | { kind: 'separator' } - | { kind: 'label'; label: string } - | { kind: 'zoom'; label: string } - -function mountWindowsMenu(): void { - if (!document.body || document.getElementById('application-menu-button')) return - const params = new URLSearchParams(location.search) - const locale = params.get('locale') === 'zh' ? 'zh' : 'en' - applyTheme(params.get('theme') === 'dark') - - const bar = document.createElement('div') - bar.className = 'bar' - const menuButton = document.createElement('button') - menuButton.id = 'application-menu-button' - menuButton.className = 'menuButton' - menuButton.type = 'button' - menuButton.setAttribute('aria-haspopup', 'menu') - menuButton.setAttribute('aria-expanded', 'false') - menuButton.setAttribute('aria-label', locale === 'zh' ? '打开应用菜单' : 'Open application menu') - menuButton.title = locale === 'zh' ? '应用菜单' : 'Application menu' - menuButton.innerHTML = chevronIcon - - const menu = document.createElement('div') - menu.className = 'menu' - menu.hidden = true - menu.setAttribute('role', 'menu') - menu.setAttribute('aria-label', locale === 'zh' ? '应用菜单' : 'Application menu') - let zoomDisplay: HTMLButtonElement | null = null - - const applyZoomState = (result: unknown): void => { - const zoomFactor = readZoomFactor(result) - if (zoomFactor !== undefined && zoomDisplay) { - zoomDisplay.textContent = formatZoomPercentage(zoomFactor) - } - } - const refreshZoomState = (): void => { - void ipcRenderer.invoke('desktop-menu:get-zoom-factor').then(applyZoomState).catch((error: unknown) => { - console.warn('[desktop-menu] unable to read zoom factor', error) - }) - } - - function openMenu(): void { - void ipcRenderer.invoke('desktop-titlebar:set-menu-open', true) - refreshZoomState() - menu.hidden = false - menuButton.classList.add('isOpen') - menuButton.setAttribute('aria-expanded', 'true') - window.requestAnimationFrame(() => menu.querySelector('button:not(:disabled)')?.focus()) - } - - function closeMenu(restoreFocus = true): void { - if (menu.hidden) return - menu.hidden = true - menuButton.classList.remove('isOpen') - menuButton.setAttribute('aria-expanded', 'false') - void ipcRenderer.invoke('desktop-titlebar:set-menu-open', false) - if (restoreFocus) menuButton.focus() - } - - zoomDisplay = renderMenu(menu, menuEntries(locale), () => closeMenu(false), applyZoomState) - menuButton.addEventListener('pointerdown', (event) => event.preventDefault()) - menuButton.addEventListener('click', () => (menu.hidden ? openMenu() : closeMenu())) - menu.addEventListener('keydown', (event) => { - const buttons = [...menu.querySelectorAll('button:not(:disabled)')] - const current = buttons.indexOf(document.activeElement as HTMLButtonElement) - if (event.key === 'Escape') { - event.preventDefault() - closeMenu() - } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { - event.preventDefault() - const direction = event.key === 'ArrowDown' ? 1 : -1 - const next = current < 0 ? 0 : (current + direction + buttons.length) % buttons.length - buttons[next]?.focus() - } - }) - ipcRenderer.on('desktop-titlebar:close-menu', () => closeMenu(false)) - ipcRenderer.on('desktop-titlebar:theme-changed', (_event, isDark: unknown) => { - if (typeof isDark === 'boolean') applyTheme(isDark) - }) - window.addEventListener('blur', () => closeMenu(false)) - - const style = document.createElement('style') - style.textContent = menuStyles - document.head.appendChild(style) - bar.append(menuButton, menu) - document.body.appendChild(bar) - refreshZoomState() -} - -function renderMenu( - menu: HTMLElement, - entries: MenuEntry[], - close: () => void, - applyZoomState: (result: unknown) => void -): HTMLButtonElement | null { - let zoomDisplay: HTMLButtonElement | null = null - for (const entry of entries) { - if (entry.kind === 'separator') { - const separator = document.createElement('div') - separator.className = 'separator' - separator.setAttribute('role', 'separator') - menu.appendChild(separator) - continue - } - if (entry.kind === 'label') { - const label = document.createElement('div') - label.className = 'sectionLabel' - label.textContent = entry.label - menu.appendChild(label) - continue - } - if (entry.kind === 'zoom') { - const row = document.createElement('div') - row.className = 'zoomRow' - const label = document.createElement('span') - label.textContent = entry.label - row.append(label) - for (const [command, text, title] of [ - ['zoom-out', '−', 'Zoom out'], - ['zoom-reset', '100%', 'Reset zoom'], - ['zoom-in', '+', 'Zoom in'] - ] as const) { - const zoom = document.createElement('button') - zoom.type = 'button' - zoom.className = command === 'zoom-reset' ? 'zoomReset' : 'zoomButton' - zoom.textContent = text - zoom.title = title - zoom.setAttribute('aria-label', title) - zoom.addEventListener('pointerdown', (event) => event.preventDefault()) - zoom.addEventListener('click', () => { - void ipcRenderer.invoke('desktop-menu:execute', command).then(applyZoomState).catch((error: unknown) => { - console.error(`[desktop-menu] unable to execute ${command}`, error) - }) - }) - if (command === 'zoom-reset') zoomDisplay = zoom - row.appendChild(zoom) - } - menu.appendChild(row) - continue - } - - const item = document.createElement('button') - item.type = 'button' - item.className = entry.command === 'quit' ? 'item danger' : 'item' - item.setAttribute('role', 'menuitem') - const label = document.createElement('span') - label.textContent = entry.label - item.appendChild(label) - if (entry.shortcut) { - const shortcut = document.createElement('kbd') - shortcut.textContent = entry.shortcut - item.appendChild(shortcut) - } - item.addEventListener('pointerdown', (event) => event.preventDefault()) - item.addEventListener('click', () => { - close() - void ipcRenderer.invoke('desktop-menu:execute', entry.command).catch((error: unknown) => { - console.error(`[desktop-menu] unable to execute ${entry.command}`, error) - }) - }) - menu.appendChild(item) - } - return zoomDisplay -} - -function readZoomFactor(result: unknown): number | undefined { - if (typeof result !== 'object' || result === null || !('zoomFactor' in result)) return undefined - const zoomFactor = result.zoomFactor - return typeof zoomFactor === 'number' && Number.isFinite(zoomFactor) && zoomFactor > 0 - ? zoomFactor - : undefined -} - -function applyTheme(isDark: boolean): void { - document.documentElement.dataset.theme = isDark ? 'dark' : 'light' -} - -function menuEntries(locale: 'en' | 'zh'): MenuEntry[] { - const zh = locale === 'zh' - return [ - { kind: 'label', label: 'HARNESS' }, - { kind: 'command', command: 'connect-phone', label: zh ? '连接手机…' : 'Connect Phone…', shortcut: 'Ctrl+Shift+M' }, - { kind: 'command', command: 'restart-harness', label: zh ? '重启 Harness' : 'Restart Harness', shortcut: 'Ctrl+Shift+R' }, - { kind: 'command', command: 'safe-mode', label: zh ? '以安全模式重启…' : 'Restart as Safe Mode…' }, - { kind: 'command', command: 'show-harness-log', label: zh ? '显示 Harness 日志' : 'Show Harness Log' }, - { kind: 'command', command: 'check-for-updates', label: zh ? '检查更新…' : 'Check for Updates…', shortcut: 'Ctrl+U' }, - { kind: 'command', command: 'export-session', label: zh ? '导出 Session 日志…' : 'Export Session Log…' }, - { kind: 'separator' }, - { kind: 'label', label: zh ? '编辑' : 'EDIT' }, - { kind: 'command', command: 'undo', label: zh ? '撤销' : 'Undo', shortcut: 'Ctrl+Z' }, - { kind: 'command', command: 'redo', label: zh ? '重做' : 'Redo', shortcut: 'Ctrl+Y' }, - { kind: 'command', command: 'cut', label: zh ? '剪切' : 'Cut', shortcut: 'Ctrl+X' }, - { kind: 'command', command: 'copy', label: zh ? '复制' : 'Copy', shortcut: 'Ctrl+C' }, - { kind: 'command', command: 'paste', label: zh ? '粘贴' : 'Paste', shortcut: 'Ctrl+V' }, - { kind: 'command', command: 'select-all', label: zh ? '全选' : 'Select All', shortcut: 'Ctrl+A' }, - { kind: 'separator' }, - { kind: 'label', label: zh ? '视图' : 'VIEW' }, - { kind: 'command', command: 'reload', label: zh ? '重新加载' : 'Reload', shortcut: 'Ctrl+R' }, - { kind: 'command', command: 'toggle-devtools', label: zh ? '开发者工具' : 'Developer Tools', shortcut: 'Ctrl+Shift+I' }, - { kind: 'zoom', label: zh ? '界面缩放' : 'Interface scale' }, - { kind: 'command', command: 'toggle-fullscreen', label: zh ? '切换全屏' : 'Toggle Full Screen', shortcut: 'F11' }, - { kind: 'separator' }, - { kind: 'command', command: 'about', label: zh ? '关于 DSH Desktop' : 'About DSH Desktop' }, - { kind: 'command', command: 'quit', label: zh ? '退出' : 'Exit' } - ] -} - -const chevronIcon = `` - -const menuStyles = ` - :root { - color-scheme: light; - --label-primary: #202124; --label-secondary: #61666b; --label-tertiary: #81858c; - --hover: rgba(32,33,36,.08); --surface: #fff; --border: rgba(32,33,36,.13); - --separator: rgba(32,33,36,.09); --layer: rgba(32,33,36,.06); --danger: #d93025; - } - :root[data-theme="dark"] { - color-scheme: dark; - --label-primary: #f3f4f6; --label-secondary: #b5b7bd; --label-tertiary: #92959b; - --hover: rgba(255,255,255,.09); --surface: #28282b; --border: rgba(255,255,255,.12); - --separator: rgba(255,255,255,.09); --layer: rgba(255,255,255,.07); --danger: #ee7772; - } - * { box-sizing: border-box; } - html, body { width:100%; height:100%; margin:0; overflow:hidden; background:transparent; } - body { color:var(--label-primary); font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; user-select:none; } - .bar { position:relative; width:100%; height:100%; display:flex; justify-content:flex-end; align-items:flex-start; } - .menuButton { appearance:none; flex:none; width:44px; height:100%; display:grid; place-items:center; padding:0; color:var(--label-secondary); background:transparent; border:0; cursor:pointer; } - .menuButton:hover, .menuButton.isOpen { color:var(--label-primary); background:var(--hover); } - .menuButton:focus-visible { outline:2px solid #4d6bfe; outline-offset:-3px; } - .menu { position:absolute; top:47px; right:0; width:304px; max-height:calc(100vh - 56px); overflow:auto; padding:7px; color:var(--label-primary); background:var(--surface); border:1px solid var(--border); border-radius:12px; box-shadow:0 14px 36px rgba(0,0,0,.2); scrollbar-width:thin; } - .menu[hidden] { display:none; } - .sectionLabel { padding:7px 10px 4px; color:var(--label-tertiary); font-size:10px; font-weight:600; line-height:14px; letter-spacing:.08em; text-transform:uppercase; } - .item { appearance:none; width:100%; min-height:33px; display:flex; align-items:center; justify-content:space-between; gap:20px; padding:6px 10px; color:inherit; background:transparent; border:0; border-radius:7px; font:inherit; font-size:13px; line-height:20px; text-align:left; cursor:pointer; } - .item:hover, .item:focus-visible, .zoomButton:hover, .zoomButton:focus-visible, .zoomReset:hover, .zoomReset:focus-visible { outline:none; background:var(--hover); } - .item.danger { color:var(--danger); } - kbd { flex:none; color:var(--label-tertiary); font:11px/16px ui-monospace,"SFMono-Regular",Consolas,monospace; } - .separator { height:1px; margin:6px 3px; background:var(--separator); } - .zoomRow { min-height:37px; display:grid; grid-template-columns:1fr 30px 54px 30px; align-items:center; gap:3px; padding:3px 7px 3px 10px; font-size:13px; } - .zoomButton, .zoomReset { appearance:none; height:27px; padding:0; color:inherit; background:var(--layer); border:0; border-radius:6px; font:inherit; cursor:pointer; } - .zoomReset { font-size:11px; } - :root[data-theme="dark"] .menu { box-shadow:0 18px 42px rgba(0,0,0,.46); } - @media (prefers-reduced-motion:reduce) { * { scroll-behavior:auto !important; } } -` - -if (document.readyState === 'loading') { - window.addEventListener('DOMContentLoaded', mountWindowsMenu, { once: true }) -} else { - mountWindowsMenu() -} diff --git a/src/preload/windows-titlebar.ts b/src/preload/windows-titlebar.ts index fa1eeba1a..a5cf2d40c 100644 --- a/src/preload/windows-titlebar.ts +++ b/src/preload/windows-titlebar.ts @@ -1,10 +1,11 @@ import type { IpcRenderer } from 'electron' import { WINDOWS_TITLEBAR_HEIGHT } from '../shared/desktop-menu' +import { mountWindowsMenuBar } from './windows-menu-bar' const LAYOUT_STYLE_ID = 'dsh-desktop-windows-titlebar-layout-style' const DRAG_REGION_ID = 'dsh-desktop-windows-drag-region' -const SIDEBAR_WIDTH_PROPERTY = '--dsh-desktop-windows-sidebar-width' -const CAPTION_WIDTH_PROPERTY = '--dsh-desktop-windows-caption-width' +const MODAL_OPEN_ATTRIBUTE = 'data-dsh-modal-open' + interface TitlebarLayoutMountOptions { document: Document ipcRenderer: Pick @@ -18,20 +19,21 @@ export function markWindowsTitlebar(document: Document): void { root.style.setProperty('--dsh-windows-titlebar-height', `${WINDOWS_TITLEBAR_HEIGHT}px`) } +/** + * Windows caption layout. Harness 0.1.7 owns the caption row on its pages + * (AppFrame reserves it, paints it and makes it draggable) once the marker is + * set; Desktop adds the caption menubar in the upstream seat, a drag strip for + * its own local pages, and releases the drag area while a dialog is open. + */ export function mountWindowsTitlebarLayout(options: TitlebarLayoutMountOptions): void { const { document, ipcRenderer } = options markWindowsTitlebar(document) if (!document.body) return installLayout(document) - installDragRegion(document) - trackSidebarLayout(document) - - document.addEventListener('pointerdown', () => { - void ipcRenderer.invoke('desktop-titlebar:close-menu').catch((error: unknown) => { - console.warn('[desktop-titlebar] unable to close the application menu', error) - }) - }) + if (document.location.protocol === 'file:') installDragRegion(document) + trackModals(document) + mountWindowsMenuBar({ document, ipcRenderer }) syncTheme(document, ipcRenderer) const themeObserver = new MutationObserver(() => syncTheme(document, ipcRenderer)) @@ -53,7 +55,6 @@ function installLayout(document: Document): void { style.textContent = ` html, body { height: 100% !important; } body.dsh-desktop-windows-titlebar-layout { - ${CAPTION_WIDTH_PROPERTY}: calc(100vw - env(titlebar-area-x, 0px) - env(titlebar-area-width, calc(100vw - 140px))); box-sizing: border-box !important; height: 100% !important; padding-top: 0 !important; @@ -62,22 +63,12 @@ function installLayout(document: Document): void { height: 100% !important; min-height: 0 !important; } - :root { - --dsh-titlebar-safe-inset-top: max(${WINDOWS_TITLEBAR_HEIGHT}px, env(titlebar-area-height, ${WINDOWS_TITLEBAR_HEIGHT}px)); - --dsh-titlebar-safe-inset-right: calc(var(${CAPTION_WIDTH_PROPERTY}, 140px) + 44px); - } - /* The sidebar patch pads the expanded sidebar for non-macOS captions. - With the Windows marker, Harness already starts every column below the - caption row, so any extra padding pushes the brand and New Session - below where upstream places them. */ - html[data-windows-titlebar] [data-dsh-sidebar-root][data-dsh-sidebar-wide="true"] { - padding-top: 0 !important; - } - /* Harness now reserves the caption row in AppFrame. Keep its drag area - clear of the native controls and our separate application menu view. */ - html[data-windows-titlebar] :has(> [data-shell-overlay])::before { - right: calc(var(${CAPTION_WIDTH_PROPERTY}, 140px) + 44px); + /* A drag region wins over a dialog's buttons in the caption row, so both + Harness's caption strip and Desktop's own give way while one is open. */ + html[${MODAL_OPEN_ATTRIBUTE}] :has(> [data-shell-overlay])::before { + -webkit-app-region: no-drag !important; } + html[${MODAL_OPEN_ATTRIBUTE}] #${DRAG_REGION_ID} { display: none !important; } body.dsh-desktop-windows-titlebar-layout button, body.dsh-desktop-windows-titlebar-layout a, body.dsh-desktop-windows-titlebar-layout input, @@ -94,7 +85,7 @@ function installLayout(document: Document): void { z-index: 10; top: 0; left: 0; - right: calc(var(${CAPTION_WIDTH_PROPERTY}, 140px) + 44px); + right: calc(100vw - env(titlebar-area-x, 0px) - env(titlebar-area-width, calc(100vw - 140px))); height: ${WINDOWS_TITLEBAR_HEIGHT}px; background: transparent; pointer-events: none; @@ -105,27 +96,26 @@ function installLayout(document: Document): void { document.head.appendChild(style) } +/** Desktop's local pages have no Harness caption strip, so they get their own. */ function installDragRegion(document: Document): void { if (document.getElementById(DRAG_REGION_ID)) return const dragRegion = document.createElement('div') dragRegion.id = DRAG_REGION_ID dragRegion.setAttribute('aria-hidden', 'true') document.body.appendChild(dragRegion) +} - // The native drag region still wins over a modal's buttons in the caption - // row, so hide it while a real dialog is open. Only semantic dialog - // markers count: class-name guesses match permanent elements and would - // hide the region for good. +function trackModals(document: Document): void { + // Only semantic dialog markers count: class-name guesses match permanent + // elements and would release the drag area for good. const modalSelector = 'dialog[open], [role="dialog"], [role="alertdialog"], [aria-modal="true"]' - - const updateDragRegionVisibility = (): void => { + const update = (): void => { const hasModal = Array.from(document.querySelectorAll(modalSelector)).some((el) => { if (el.offsetWidth === 0 || el.offsetHeight === 0) return false const style = window.getComputedStyle(el) return style.visibility !== 'hidden' && style.opacity !== '0' }) - const display = hasModal ? 'none' : 'block' - if (dragRegion.style.display !== display) dragRegion.style.display = display + document.documentElement.toggleAttribute(MODAL_OPEN_ATTRIBUTE, hasModal) } // Streaming output mutates the DOM continuously; check at most once a frame. @@ -135,10 +125,9 @@ function installDragRegion(document: Document): void { scheduled = true requestAnimationFrame(() => { scheduled = false - updateDragRegionVisibility() + update() }) } - const observer = new MutationObserver(scheduleUpdate) observer.observe(document.documentElement, { childList: true, @@ -146,37 +135,7 @@ function installDragRegion(document: Document): void { attributes: true, attributeFilter: ['open', 'style', 'class', 'hidden', 'aria-hidden', 'aria-modal', 'role'] }) - updateDragRegionVisibility() -} - -function trackSidebarLayout(document: Document): void { - let observedSidebarColumn: HTMLElement | null = null - const resizeObserver = new ResizeObserver(() => updateSidebarWidth()) - - const updateSidebarWidth = (): void => { - if (!observedSidebarColumn) { - document.documentElement.style.setProperty(SIDEBAR_WIDTH_PROPERTY, '0px') - return - } - const width = observedSidebarColumn.getBoundingClientRect().width - document.documentElement.style.setProperty(SIDEBAR_WIDTH_PROPERTY, `${Math.max(0, width)}px`) - } - - const sync = (): void => { - const sidebarRoot = document.querySelector('[data-dsh-sidebar-root]') - const sidebarColumn = sidebarRoot?.parentElement ?? null - - if (sidebarColumn !== observedSidebarColumn) { - if (observedSidebarColumn) resizeObserver.unobserve(observedSidebarColumn) - observedSidebarColumn = sidebarColumn - if (sidebarColumn) resizeObserver.observe(sidebarColumn) - } - updateSidebarWidth() - } - - const observer = new MutationObserver(sync) - observer.observe(document.documentElement, { childList: true, subtree: true }) - sync() + update() } function syncTheme(document: Document, ipcRenderer: Pick): void { diff --git a/test/windows-menu-bar.test.ts b/test/windows-menu-bar.test.ts new file mode 100644 index 000000000..e5e4e4f81 --- /dev/null +++ b/test/windows-menu-bar.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest' +import { mountWindowsMenuBar } from '../src/preload/windows-menu-bar' + +afterEach(() => { + document.body.replaceChildren() + document.documentElement.removeAttribute('lang') +}) + +const flush = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)) +const host = (): HTMLElement | null => document.querySelector('[data-dsh-windows-menu]') +const button = (name: string): HTMLButtonElement | null => + host()?.shadowRoot?.querySelector(`button[data-menu="${name}"]`) ?? null + +it('waits for the Harness shell overlay before taking the caption seat', async () => { + const invoke = vi.fn(async () => undefined) + const menu = mountWindowsMenuBar({ document, ipcRenderer: { invoke } }) + expect(host()).toBeNull() + + const overlay = document.createElement('div') + overlay.setAttribute('data-shell-overlay', '') + document.body.append(overlay) + await flush() + + expect(host()).not.toBeNull() + expect(host()?.hasAttribute('data-standalone')).toBe(false) + menu.dispose() + expect(host()).toBeNull() +}) + +it('opens the native popup under the pressed button and labels it in the page language', async () => { + document.documentElement.lang = 'zh-CN' + document.body.append(document.createElement('div')) + document.body.lastElementChild?.setAttribute('data-shell-overlay', '') + const invoke = vi.fn(async () => undefined) + const menu = mountWindowsMenuBar({ document, ipcRenderer: { invoke } }) + + expect(button('application')?.textContent).toBe('应用') + expect(button('edit')?.textContent).toBe('编辑') + + const edit = button('edit') + if (!edit) throw new Error('edit menu button was not mounted') + edit.getBoundingClientRect = () => ({ left: 120, bottom: 34 } as DOMRect) + edit.click() + expect(edit.getAttribute('aria-expanded')).toBe('true') + await flush() + expect(invoke).toHaveBeenCalledWith('desktop-titlebar:popup-menu', 'edit', 120, 34) + expect(edit.getAttribute('aria-expanded')).toBe('false') + + document.documentElement.lang = 'en' + await flush() + expect(button('application')?.textContent).toBe('Application') + menu.dispose() +}) + +it('mounts only once per document', () => { + document.body.append(document.createElement('div')) + document.body.lastElementChild?.setAttribute('data-shell-overlay', '') + const invoke = vi.fn(async () => undefined) + const first = mountWindowsMenuBar({ document, ipcRenderer: { invoke } }) + mountWindowsMenuBar({ document, ipcRenderer: { invoke } }) + expect(document.querySelectorAll('[data-dsh-windows-menu]')).toHaveLength(1) + first.dispose() +}) diff --git a/test/windows-sidebar-padding.test.ts b/test/windows-sidebar-padding.test.ts deleted file mode 100644 index cc540b406..000000000 --- a/test/windows-sidebar-padding.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, expect, it, vi } from 'vitest' -import { mountWindowsTitlebarLayout } from '../src/preload/windows-titlebar' - -afterEach(() => { - vi.unstubAllGlobals() - document.head.replaceChildren() - document.body.replaceChildren() - document.documentElement.removeAttribute('data-windows-titlebar') -}) - -it('drops the sidebar patch padding once Harness reserves the Windows caption row', () => { - vi.stubGlobal('matchMedia', () => ({ matches: false, addEventListener: () => undefined })) - vi.stubGlobal('ResizeObserver', class { observe(): void {} unobserve(): void {} }) - // The dsh-client-ui-sidebar patch adds this padding for non-macOS captions. - const patch = document.createElement('style') - patch.textContent = 'html:not([data-platform=darwin]) [data-dsh-sidebar-root][data-dsh-sidebar-wide="true"]{padding-top:32px}' - document.head.append(patch) - const sidebar = document.createElement('div') - sidebar.setAttribute('data-dsh-sidebar-root', '') - sidebar.setAttribute('data-dsh-sidebar-wide', 'true') - document.body.append(sidebar) - expect(getComputedStyle(sidebar).paddingTop).toBe('32px') - - mountWindowsTitlebarLayout({ document, ipcRenderer: { invoke: async () => undefined } }) - - expect(document.documentElement.hasAttribute('data-windows-titlebar')).toBe(true) - expect(getComputedStyle(sidebar).paddingTop).toBe('0px') -}) diff --git a/test/windows-titlebar-header.test.mjs b/test/windows-titlebar-header.test.mjs deleted file mode 100644 index 0315de9cd..000000000 --- a/test/windows-titlebar-header.test.mjs +++ /dev/null @@ -1,32 +0,0 @@ -import { readFileSync } from 'node:fs' -import ts from 'typescript' -import { expect, it } from 'vitest' - -const source = readFileSync('node_modules/@deepseek-ai/dsh-client-ui-conversation/lib/client.js', 'utf8') -const ast = ts.createSourceFile('client.js', source, ts.ScriptTarget.Latest, true) -let headerSource -function visit(node) { - if (ts.isFunctionDeclaration(node) && node.name?.text === 'ConversationHeader') headerSource = node.getText(ast) - ts.forEachChild(node, visit) -} -visit(ast) -const jsx = (type, props) => ({ type, props }) -const Header = new Function('react_jsx_runtime', 'clsx', 'ConversationRoot_module_css_default', 'conversationPhase', `${headerSource}; return ConversationHeader`)( - { jsx, jsxs: jsx }, (...names) => names.filter(Boolean).join(' '), - { header: 'header', headerBlank: 'blank', headerSessionless: 'sessionless', headerLeading: 'leading', titleRow: 'title' }, () => 'active' -) - -it.each([undefined, 'session-1'])('marks the resident header without moving or duplicating slot contributions (%s)', sessionId => { - const calls = [] - const tree = Header({ sessionId, useSession: select => select(sessionId ? { blank: false } : undefined), - useConversation: select => select(sessionId ? {} : undefined), - renderSlot(name, props) { calls.push(name); return jsx('slot', { name, ...props }) } - }) - expect(tree.type).toBe('header') - expect(tree.props['data-dsh-conversation-header']).toBe('') - expect(tree.props['data-window-drag']).toBe(true) - expect(calls).toEqual(sessionId ? ['conversation.header.leading', 'conversation.session.header'] : ['conversation.header.leading']) - // rc.2 renders the session slot INSIDE the header. Styling a header inside - // that slot silently stopped working after the upgrade. - if (sessionId) expect(tree.props.children[1].props.name).toBe('conversation.session.header') -}) diff --git a/test/windows-titlebar.test.ts b/test/windows-titlebar.test.ts index 204c77fa5..30cb9d6c3 100644 --- a/test/windows-titlebar.test.ts +++ b/test/windows-titlebar.test.ts @@ -1,50 +1,91 @@ +import type { MenuItemConstructorOptions } from 'electron' import { describe, expect, it } from 'vitest' import { - WINDOWS_TITLEBAR_HEIGHT, desktopMenuCommands, - formatZoomPercentage, - isDesktopMenuCommand + isDesktopMenuCommand, + type DesktopMenuCommand } from '../src/shared/desktop-menu' import { - WINDOWS_CAPTION_CONTROLS_WIDTH, - WINDOWS_MENU_BUTTON_WIDTH, - WINDOWS_MENU_PANEL_WIDTH, - windowsMenuViewBounds -} from '../src/main/windows-menu-view' + parseWindowsMenuRequest, + windowsMenuTemplate, + type EditingKey +} from '../src/main/windows-menu' -describe('Windows titlebar menu', () => { +function record(): { run: DesktopMenuCommand[]; keys: EditingKey[]; actions: Parameters[3] } { + const run: DesktopMenuCommand[] = [] + const keys: EditingKey[] = [] + return { run, keys, actions: { run: (command) => run.push(command), sendEditingKey: (key) => keys.push(key) } } +} +function clickAll(items: MenuItemConstructorOptions[]): void { + for (const item of items) { + if (Array.isArray(item.submenu)) clickAll(item.submenu) + // The template's handlers ignore Electron's arguments. + else (item.click as (() => void) | undefined)?.() + } +} + +function labels(items: MenuItemConstructorOptions[]): string[] { + return items.flatMap((item) => [ + ...(item.label === undefined ? [] : [item.label]), + ...(Array.isArray(item.submenu) ? labels(item.submenu) : []) + ]) +} + +describe('Windows caption menus', () => { it('accepts only the fixed menu command allowlist', () => { - expect(desktopMenuCommands).toContain('connect-phone') - expect(desktopMenuCommands).toContain('safe-mode') - expect(desktopMenuCommands).toContain('check-for-updates') - expect(desktopMenuCommands).toContain('toggle-fullscreen') expect(isDesktopMenuCommand('copy')).toBe(true) expect(isDesktopMenuCommand('run-shell-command')).toBe(false) expect(isDesktopMenuCommand({ command: 'quit' })).toBe(false) }) - it('keeps the closed menu button aligned beside native caption controls at every page zoom', () => { - expect(WINDOWS_CAPTION_CONTROLS_WIDTH).toBe(140) - expect(WINDOWS_MENU_BUTTON_WIDTH).toBe(44) - expect(WINDOWS_MENU_PANEL_WIDTH).toBe(304) - - const closedAt100Percent = windowsMenuViewBounds({ width: 1380, height: 900 }, false) - const closedAt69Percent = windowsMenuViewBounds({ width: 1380, height: 900 }, false) - expect(closedAt100Percent).toEqual({ x: 1196, y: 0, width: 44, height: WINDOWS_TITLEBAR_HEIGHT }) - expect(closedAt69Percent).toEqual(closedAt100Percent) - - expect(windowsMenuViewBounds({ width: 1380, height: 900 }, true)).toEqual({ - x: 936, - y: 0, - width: 304, - height: 760 - }) - expect(windowsMenuViewBounds({ width: 900, height: 640 }, false, true)).toEqual({ - x: 856, - y: 0, - width: 44, - height: WINDOWS_TITLEBAR_HEIGHT - }) + it('keeps every Desktop command in the application menu, including the View group', () => { + const { run, actions } = record() + clickAll(windowsMenuTemplate('application', 'zh', 1, actions)) + const editing = new Set(['undo', 'redo', 'cut', 'copy', 'paste', 'select-all']) + expect(new Set(run)).toEqual(new Set(desktopMenuCommands.filter((command) => !editing.has(command)))) + }) + + it('sends editing shortcuts as key events so editor-owned history receives them', () => { + const { run, keys, actions } = record() + clickAll(windowsMenuTemplate('edit', 'en', 1, actions)) + expect(run).toEqual([]) + expect(keys).toEqual([ + { keyCode: 'Z', modifiers: ['control'] }, + { keyCode: 'Y', modifiers: ['control'] }, + { keyCode: 'X', modifiers: ['control'] }, + { keyCode: 'C', modifiers: ['control'] }, + { keyCode: 'V', modifiers: ['control'] }, + { keyCode: 'Delete', modifiers: [] }, + { keyCode: 'A', modifiers: ['control'] } + ]) + }) + + it('labels the menus in the app language and shows the current zoom', () => { + const { actions } = record() + expect(labels(windowsMenuTemplate('application', 'zh', 1.25, actions))).toContain('实际大小(当前 125%)') + expect(labels(windowsMenuTemplate('application', 'en', 0.9, actions))).toContain('Actual Size (now 90%)') + expect(labels(windowsMenuTemplate('edit', 'zh', 1, actions))).toEqual( + ['撤销', '重做', '剪切', '复制', '粘贴', '删除', '全选'] + ) + }) + + it('does not register popup accelerators over the application menu shortcuts', () => { + const { actions } = record() + const flatten = (items: MenuItemConstructorOptions[]): MenuItemConstructorOptions[] => + items.flatMap((item) => (Array.isArray(item.submenu) ? flatten(item.submenu) : [item])) + for (const name of ['application', 'edit'] as const) { + for (const item of flatten(windowsMenuTemplate(name, 'en', 1, actions))) { + if (item.accelerator !== undefined) expect(item.registerAccelerator).toBe(false) + } + } + }) + + it('rejects popup requests outside the two menus or the window', () => { + expect(parseWindowsMenuRequest('application', 48, 34)).toEqual({ name: 'application', x: 48, y: 34 }) + expect(() => parseWindowsMenuRequest('view', 48, 34)).toThrow() + expect(() => parseWindowsMenuRequest('edit', -1, 34)).toThrow() + expect(() => parseWindowsMenuRequest('edit', 48, Number.NaN)).toThrow() + expect(() => parseWindowsMenuRequest('edit', '48', 34)).toThrow() }) }) From a398ece9d67c21eba50716109975e6f561aaca41 Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Tue, 29 Sep 2026 01:12:28 -0700 Subject: [PATCH 10/10] fix(windows): mark the caption before any page script runs Preload can execute before the parser creates , which left the Windows marker to DOMContentLoaded, after Harness scripts had run. Observe the document and mark the moment it exists. Co-Authored-By: Claude Opus 5.5 --- src/preload/windows-titlebar.ts | 22 ++++++++++++++++++---- test/windows-menu-bar.test.ts | 11 +++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/preload/windows-titlebar.ts b/src/preload/windows-titlebar.ts index a5cf2d40c..ee625afcd 100644 --- a/src/preload/windows-titlebar.ts +++ b/src/preload/windows-titlebar.ts @@ -13,10 +13,24 @@ interface TitlebarLayoutMountOptions { /** Harness reads this marker while creating its first layout frame. */ export function markWindowsTitlebar(document: Document): void { - const root = document.documentElement - if (!root) return - root.dataset.windowsTitlebar = '' - root.style.setProperty('--dsh-windows-titlebar-height', `${WINDOWS_TITLEBAR_HEIGHT}px`) + const apply = (root: HTMLElement): void => { + root.dataset.windowsTitlebar = '' + root.style.setProperty('--dsh-windows-titlebar-height', `${WINDOWS_TITLEBAR_HEIGHT}px`) + } + const root = document.documentElement as HTMLElement | null + if (root) { + apply(root) + return + } + // Preload can run before the parser creates . Mark it the moment it + // exists so every Harness script, including its first layout, sees it. + const observer = new MutationObserver(() => { + const created = document.documentElement as HTMLElement | null + if (!created) return + observer.disconnect() + apply(created) + }) + observer.observe(document, { childList: true }) } /** diff --git a/test/windows-menu-bar.test.ts b/test/windows-menu-bar.test.ts index e5e4e4f81..7794423ff 100644 --- a/test/windows-menu-bar.test.ts +++ b/test/windows-menu-bar.test.ts @@ -62,3 +62,14 @@ it('mounts only once per document', () => { expect(document.querySelectorAll('[data-dsh-windows-menu]')).toHaveLength(1) first.dispose() }) + +it('marks as soon as the parser creates it when preload runs first', async () => { + const { markWindowsTitlebar } = await import('../src/preload/windows-titlebar') + const doc = document.implementation.createDocument(null, null) + markWindowsTitlebar(doc) + const html = doc.createElementNS('http://www.w3.org/1999/xhtml', 'html') as HTMLElement + doc.append(html) + await flush() + expect(html.hasAttribute('data-windows-titlebar')).toBe(true) + expect(html.style.getPropertyValue('--dsh-windows-titlebar-height')).toBe('40px') +})