diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1700aa63..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 @@ -349,11 +352,33 @@ 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') - if ($LASTEXITCODE -ne 0) { throw 'Packaged koffi native binding failed.' } + # 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' + $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 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/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 0d0863f77..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 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. 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/docs/development.md b/docs/development.md index 5ea0f9ef4..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 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: `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/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/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 469ca5882..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": [ @@ -463,6 +459,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/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/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/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 +}) 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/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 }) 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/src/main/index.ts b/src/main/index.ts index 83020acba..403296b82 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') @@ -631,8 +549,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') } /** @@ -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/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/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..22cb8bf28 --- /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', '重启', 'Restart', '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/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-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 2f5b345be..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 ? '重启' : 'Restart', 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:36px; 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[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 e4e11f9e1..ee625afcd 100644 --- a/src/preload/windows-titlebar.ts +++ b/src/preload/windows-titlebar.ts @@ -1,28 +1,53 @@ 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 } +/** Harness reads this marker while creating its first layout frame. */ +export function markWindowsTitlebar(document: Document): void { + 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 }) +} + +/** + * 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)) @@ -44,7 +69,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; @@ -53,27 +77,12 @@ function installLayout(document: Document): void { height: 100% !important; min-height: 0 !important; } - :root { - --dsh-titlebar-safe-inset-top: max(36px, env(titlebar-area-height, 36px)); - --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; - } - 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; + /* 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, @@ -90,8 +99,8 @@ function installLayout(document: Document): void { z-index: 10; top: 0; left: 0; - right: calc(var(${CAPTION_WIDTH_PROPERTY}, 140px) + 44px); - height: 36px; + 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; user-select: none; @@ -101,27 +110,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 top - // 36px, 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. @@ -131,10 +139,9 @@ function installDragRegion(document: Document): void { scheduled = true requestAnimationFrame(() => { scheduled = false - updateDragRegionVisibility() + update() }) } - const observer = new MutationObserver(scheduleUpdate) observer.observe(document.documentElement, { childList: true, @@ -142,37 +149,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/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/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 }) + } + }) +}) 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..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]+)") @@ -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..872e6d66b 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', () => { @@ -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-menu-bar.test.ts b/test/windows-menu-bar.test.ts new file mode 100644 index 000000000..7794423ff --- /dev/null +++ b/test/windows-menu-bar.test.ts @@ -0,0 +1,75 @@ +// @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() +}) + +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') +}) 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 }) +}) 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 6dc726949..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: 36 }) - 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: 36 - }) + 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() }) })