From 982fb177541b3ad286ab53d72ec2ce5dae0424a5 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 21 Sep 2026 19:49:04 +0800 Subject: [PATCH 1/7] feat(desktop): open renderer DevTools with F12 in packaged builds --- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/src/main.ts | 7 +++++++ apps/desktop/tests/main-startup.spec.ts | 18 ++++++++++++++++++ 5 files changed, 29 insertions(+), 4 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 2f89559d39..c79f25014d 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 4de8eb539bba42919fd29740bb2741566f460ab1 -README.zh.md: 9e733caae70fc3fe4d0131548a14660cddbd2248 +README.md: 3defe5c4391ec802de5c20675fbb99a597768728 +README.zh.md: e84dbba6a9c9df2c6695313e077fb2aac55c9cb5 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 4de8eb539b..3defe5c439 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -89,7 +89,7 @@ Recovery waits for Host shutdown before changing plugin activation. The native r pnpm run dev:desktop ``` -Development Harness state defaults to `apps/desktop/.desktop-build/development/home`, the disposable npm project lives at `apps/desktop/.desktop-build/development/project`, and Electron browser data lives at `apps/desktop/.desktop-build/development/electron-user-data`. Sessions, settings, credentials, package links, and browser data therefore stay out of the user's normal Harness home. An explicit `DSH_HOME` replaces only the development Harness home. Renderer DevTools opens automatically; Main, Renderer, and dsh Host debugging listen on ports 9229, 9222, and 9230. `DSH_DESKTOP_MAIN_INSPECT_PORT`, `DSH_DESKTOP_RENDERER_DEBUG_PORT`, and `DSH_DESKTOP_HOST_INSPECT_PORT` replace those ports, while `DSH_DESKTOP_OPEN_DEVTOOLS=0` keeps the detached Renderer tools closed. +Press F12 in the main desktop window to open detached Renderer DevTools, including in packaged builds. On keyboards with media keys, use Fn+F12. Development Harness state defaults to `apps/desktop/.desktop-build/development/home`, the disposable npm project lives at `apps/desktop/.desktop-build/development/project`, and Electron browser data lives at `apps/desktop/.desktop-build/development/electron-user-data`. Sessions, settings, credentials, package links, and browser data therefore stay out of the user's normal Harness home. An explicit `DSH_HOME` replaces only the development Harness home. Renderer DevTools opens automatically; Main, Renderer, and dsh Host debugging listen on ports 9229, 9222, and 9230. `DSH_DESKTOP_MAIN_INSPECT_PORT`, `DSH_DESKTOP_RENDERER_DEBUG_PORT`, and `DSH_DESKTOP_HOST_INSPECT_PORT` replace those ports, while `DSH_DESKTOP_OPEN_DEVTOOLS=0` keeps the detached Renderer tools closed. After an explicit build, `start:desktop` reconstructs the disposable project and launches the existing artifacts without building again: diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 9e733caae7..e84dbba6a9 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -89,7 +89,7 @@ macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜 pnpm run dev:desktop ``` -开发 Harness 状态默认写入 `apps/desktop/.desktop-build/development/home`,一次性 npm 项目位于 `apps/desktop/.desktop-build/development/project`,Electron 浏览器数据则位于 `apps/desktop/.desktop-build/development/electron-user-data`。因此,会话、设置、凭据、包链接和浏览器数据都不会进入用户正常使用的 Harness home;显式 `DSH_HOME` 只会替换开发 Harness home。Renderer DevTools 默认自动打开,Main、Renderer 和 dsh Host 调试端口依次为 9229、9222 和 9230。`DSH_DESKTOP_MAIN_INSPECT_PORT`、`DSH_DESKTOP_RENDERER_DEBUG_PORT` 与 `DSH_DESKTOP_HOST_INSPECT_PORT` 可以替换这些端口,`DSH_DESKTOP_OPEN_DEVTOOLS=0` 则保持 Renderer 调试窗口关闭。 +在桌面主窗口按 F12 可打开独立的 Renderer DevTools,打包版本同样支持。使用多媒体功能键的键盘需按 Fn+F12。开发 Harness 状态默认写入 `apps/desktop/.desktop-build/development/home`,一次性 npm 项目位于 `apps/desktop/.desktop-build/development/project`,Electron 浏览器数据则位于 `apps/desktop/.desktop-build/development/electron-user-data`。因此,会话、设置、凭据、包链接和浏览器数据都不会进入用户正常使用的 Harness home;显式 `DSH_HOME` 只会替换开发 Harness home。Renderer DevTools 默认自动打开,Main、Renderer 和 dsh Host 调试端口依次为 9229、9222 和 9230。`DSH_DESKTOP_MAIN_INSPECT_PORT`、`DSH_DESKTOP_RENDERER_DEBUG_PORT` 与 `DSH_DESKTOP_HOST_INSPECT_PORT` 可以替换这些端口,`DSH_DESKTOP_OPEN_DEVTOOLS=0` 则保持 Renderer 调试窗口关闭。 显式构建完成后,`start:desktop` 会重新生成一次性项目,并跳过构建直接启动已有产物: diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 15d7453dcb..29619e85ae 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -153,6 +153,13 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi webviewTag: primary, }, }) + if (primary) { + window.webContents.on('before-input-event', (event, input) => { + if (input.type !== 'keyDown' || input.key !== 'F12' || input.isAutoRepeat) return + event.preventDefault() + window.webContents.openDevTools({ mode: 'detach' }) + }) + } window.webContents.setWindowOpenHandler(({ url }) => { if (['http:', 'https:'].includes(new URL(url).protocol)) void shell.openExternal(url) return { action: 'deny' } diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 329cf5a9b8..6b562283a6 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -368,6 +368,24 @@ describe('desktop main startup', () => { expect((await handler(new Request('dsh-app://unknown/update-dialog.html'))).status).toBe(404) }) + it.each(['darwin', 'win32'])('opens packaged main-window DevTools with F12 on %s', async (platform) => { + vi.stubGlobal('process', { ...process, platform }) + await readyForUpdate() + const contents = harness.windows[0]!.webContents + expect(contents.openDevTools).not.toHaveBeenCalled() + const event = { preventDefault: vi.fn() } + for (const input of [ + { type: 'keyUp', key: 'F12' }, + { type: 'keyDown', key: 'F11' }, + { type: 'keyDown', key: 'F12', isAutoRepeat: true }, + ]) contents.emit('before-input-event', event, input) + expect(contents.openDevTools).not.toHaveBeenCalled() + expect(event.preventDefault).not.toHaveBeenCalled() + contents.emit('before-input-event', event, { type: 'keyDown', key: 'F12', isAutoRepeat: false }) + expect(event.preventDefault).toHaveBeenCalledOnce() + expect(contents.openDevTools).toHaveBeenCalledExactlyOnceWith({ mode: 'detach' }) + }) + it.each([ ['darwin', true, 'en-US'], ['darwin', false, 'zh-CN'], From edae410733eba60d5f89b62e27ce3d8ffdd7be55 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 21 Sep 2026 20:06:43 +0800 Subject: [PATCH 2/7] feat(desktop): support Command Option I for macOS DevTools --- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/src/main.ts | 5 ++++- apps/desktop/tests/main-startup.spec.ts | 23 +++++++++++++++++++++++ 5 files changed, 31 insertions(+), 5 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index c79f25014d..0493921094 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 3defe5c4391ec802de5c20675fbb99a597768728 -README.zh.md: e84dbba6a9c9df2c6695313e077fb2aac55c9cb5 +README.md: 983a3841afadebbb7fb3ae335dd9d5f8fa4472ad +README.zh.md: 67668f5ba310679b66c4e351c680b5238303ebd1 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 3defe5c439..983a3841af 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -89,7 +89,7 @@ Recovery waits for Host shutdown before changing plugin activation. The native r pnpm run dev:desktop ``` -Press F12 in the main desktop window to open detached Renderer DevTools, including in packaged builds. On keyboards with media keys, use Fn+F12. Development Harness state defaults to `apps/desktop/.desktop-build/development/home`, the disposable npm project lives at `apps/desktop/.desktop-build/development/project`, and Electron browser data lives at `apps/desktop/.desktop-build/development/electron-user-data`. Sessions, settings, credentials, package links, and browser data therefore stay out of the user's normal Harness home. An explicit `DSH_HOME` replaces only the development Harness home. Renderer DevTools opens automatically; Main, Renderer, and dsh Host debugging listen on ports 9229, 9222, and 9230. `DSH_DESKTOP_MAIN_INSPECT_PORT`, `DSH_DESKTOP_RENDERER_DEBUG_PORT`, and `DSH_DESKTOP_HOST_INSPECT_PORT` replace those ports, while `DSH_DESKTOP_OPEN_DEVTOOLS=0` keeps the detached Renderer tools closed. +Press F12 in the main desktop window to open detached Renderer DevTools, including in packaged builds. On keyboards with media keys, use Fn+F12. macOS also supports Command+Option+I. Development Harness state defaults to `apps/desktop/.desktop-build/development/home`, the disposable npm project lives at `apps/desktop/.desktop-build/development/project`, and Electron browser data lives at `apps/desktop/.desktop-build/development/electron-user-data`. Sessions, settings, credentials, package links, and browser data therefore stay out of the user's normal Harness home. An explicit `DSH_HOME` replaces only the development Harness home. Renderer DevTools opens automatically; Main, Renderer, and dsh Host debugging listen on ports 9229, 9222, and 9230. `DSH_DESKTOP_MAIN_INSPECT_PORT`, `DSH_DESKTOP_RENDERER_DEBUG_PORT`, and `DSH_DESKTOP_HOST_INSPECT_PORT` replace those ports, while `DSH_DESKTOP_OPEN_DEVTOOLS=0` keeps the detached Renderer tools closed. After an explicit build, `start:desktop` reconstructs the disposable project and launches the existing artifacts without building again: diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index e84dbba6a9..67668f5ba3 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -89,7 +89,7 @@ macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜 pnpm run dev:desktop ``` -在桌面主窗口按 F12 可打开独立的 Renderer DevTools,打包版本同样支持。使用多媒体功能键的键盘需按 Fn+F12。开发 Harness 状态默认写入 `apps/desktop/.desktop-build/development/home`,一次性 npm 项目位于 `apps/desktop/.desktop-build/development/project`,Electron 浏览器数据则位于 `apps/desktop/.desktop-build/development/electron-user-data`。因此,会话、设置、凭据、包链接和浏览器数据都不会进入用户正常使用的 Harness home;显式 `DSH_HOME` 只会替换开发 Harness home。Renderer DevTools 默认自动打开,Main、Renderer 和 dsh Host 调试端口依次为 9229、9222 和 9230。`DSH_DESKTOP_MAIN_INSPECT_PORT`、`DSH_DESKTOP_RENDERER_DEBUG_PORT` 与 `DSH_DESKTOP_HOST_INSPECT_PORT` 可以替换这些端口,`DSH_DESKTOP_OPEN_DEVTOOLS=0` 则保持 Renderer 调试窗口关闭。 +在桌面主窗口按 F12 可打开独立的 Renderer DevTools,打包版本同样支持。使用多媒体功能键的键盘需按 Fn+F12。macOS 也支持 Command+Option+I。开发 Harness 状态默认写入 `apps/desktop/.desktop-build/development/home`,一次性 npm 项目位于 `apps/desktop/.desktop-build/development/project`,Electron 浏览器数据则位于 `apps/desktop/.desktop-build/development/electron-user-data`。因此,会话、设置、凭据、包链接和浏览器数据都不会进入用户正常使用的 Harness home;显式 `DSH_HOME` 只会替换开发 Harness home。Renderer DevTools 默认自动打开,Main、Renderer 和 dsh Host 调试端口依次为 9229、9222 和 9230。`DSH_DESKTOP_MAIN_INSPECT_PORT`、`DSH_DESKTOP_RENDERER_DEBUG_PORT` 与 `DSH_DESKTOP_HOST_INSPECT_PORT` 可以替换这些端口,`DSH_DESKTOP_OPEN_DEVTOOLS=0` 则保持 Renderer 调试窗口关闭。 显式构建完成后,`start:desktop` 会重新生成一次性项目,并跳过构建直接启动已有产物: diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 29619e85ae..2f1f519379 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -155,7 +155,10 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi }) if (primary) { window.webContents.on('before-input-event', (event, input) => { - if (input.type !== 'keyDown' || input.key !== 'F12' || input.isAutoRepeat) return + if (input.type !== 'keyDown' || input.isAutoRepeat) return + const macShortcut = process.platform === 'darwin' && input.code === 'KeyI' + && input.meta && input.alt && !input.control && !input.shift + if (input.key !== 'F12' && !macShortcut) return event.preventDefault() window.webContents.openDevTools({ mode: 'detach' }) }) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 6b562283a6..de4d4405ea 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -386,6 +386,29 @@ describe('desktop main startup', () => { expect(contents.openDevTools).toHaveBeenCalledExactlyOnceWith({ mode: 'detach' }) }) + it.each(['darwin', 'win32'])('handles Command+Option+I only on macOS (platform=%s)', async (platform) => { + vi.stubGlobal('process', { ...process, platform }) + await readyForUpdate() + const contents = harness.windows[0]!.webContents + const event = { preventDefault: vi.fn() } + const input = { type: 'keyDown', key: 'ˆ', code: 'KeyI', meta: true, alt: true, + control: false, shift: false, isAutoRepeat: false } + for (const change of [ + { type: 'keyUp' }, { isAutoRepeat: true }, { code: 'KeyJ' }, + { meta: false }, { alt: false }, { control: true }, { shift: true }, + ]) contents.emit('before-input-event', event, { ...input, ...change }) + expect(contents.openDevTools).not.toHaveBeenCalled() + expect(event.preventDefault).not.toHaveBeenCalled() + contents.emit('before-input-event', event, input) + if (platform === 'darwin') { + expect(contents.openDevTools).toHaveBeenCalledExactlyOnceWith({ mode: 'detach' }) + expect(event.preventDefault).toHaveBeenCalledOnce() + } else { + expect(contents.openDevTools).not.toHaveBeenCalled() + expect(event.preventDefault).not.toHaveBeenCalled() + } + }) + it.each([ ['darwin', true, 'en-US'], ['darwin', false, 'zh-CN'], From ff16cf535a852b40de06adc4435e82a638514acf Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 21 Sep 2026 20:18:40 +0800 Subject: [PATCH 3/7] fix(desktop): suppress DevTools shortcuts behind update overlays --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 4 +- apps/desktop/README.zh.md | 4 +- apps/desktop/src/devtools-shortcut.ts | 22 +++++++++ apps/desktop/src/main.ts | 13 ++--- apps/desktop/src/policy-test-auth.ts | 7 +-- apps/desktop/src/update-overlay.ts | 17 +++++++ apps/desktop/tests/devtools-shortcut.spec.ts | 51 ++++++++++++++++++++ apps/desktop/tests/main-startup.spec.ts | 42 ++-------------- apps/desktop/tests/policy-test-auth.spec.ts | 5 -- apps/desktop/tests/update-overlay.spec.ts | 20 ++++++-- 11 files changed, 123 insertions(+), 66 deletions(-) create mode 100644 apps/desktop/src/devtools-shortcut.ts create mode 100644 apps/desktop/tests/devtools-shortcut.spec.ts diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 0493921094..0687e136e7 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 983a3841afadebbb7fb3ae335dd9d5f8fa4472ad -README.zh.md: 67668f5ba310679b66c4e351c680b5238303ebd1 +README.md: 92a2c3b4b4b85467ce0994accf120be684687ade +README.zh.md: 1b4e0570fd574d40503552c4e1e98a7687e0c3e7 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 983a3841af..92a2c3b4b4 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -12,6 +12,8 @@ Creator and the Web Plugin Manager use Desktop’s bundled pnpm under Electron N Desktop microphone access is restricted to audio requests from the primary `dsh-app://app` frame. macOS uses system microphone authorization and a packaged usage description. +Press F12 (Fn+F12 on media-key keyboards) or, on macOS, Command+Option+I while the main application page has focus to open detached Renderer DevTools, including in packaged builds. These shortcuts are suppressed while an update overlay is attached and do not reach the main window while an embedded browser guest has focus. + ## Key technical decisions The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation. @@ -89,7 +91,7 @@ Recovery waits for Host shutdown before changing plugin activation. The native r pnpm run dev:desktop ``` -Press F12 in the main desktop window to open detached Renderer DevTools, including in packaged builds. On keyboards with media keys, use Fn+F12. macOS also supports Command+Option+I. Development Harness state defaults to `apps/desktop/.desktop-build/development/home`, the disposable npm project lives at `apps/desktop/.desktop-build/development/project`, and Electron browser data lives at `apps/desktop/.desktop-build/development/electron-user-data`. Sessions, settings, credentials, package links, and browser data therefore stay out of the user's normal Harness home. An explicit `DSH_HOME` replaces only the development Harness home. Renderer DevTools opens automatically; Main, Renderer, and dsh Host debugging listen on ports 9229, 9222, and 9230. `DSH_DESKTOP_MAIN_INSPECT_PORT`, `DSH_DESKTOP_RENDERER_DEBUG_PORT`, and `DSH_DESKTOP_HOST_INSPECT_PORT` replace those ports, while `DSH_DESKTOP_OPEN_DEVTOOLS=0` keeps the detached Renderer tools closed. +Development Harness state defaults to `apps/desktop/.desktop-build/development/home`, the disposable npm project lives at `apps/desktop/.desktop-build/development/project`, and Electron browser data lives at `apps/desktop/.desktop-build/development/electron-user-data`. Sessions, settings, credentials, package links, and browser data therefore stay out of the user's normal Harness home. An explicit `DSH_HOME` replaces only the development Harness home. Renderer DevTools opens automatically; Main, Renderer, and dsh Host debugging listen on ports 9229, 9222, and 9230. `DSH_DESKTOP_MAIN_INSPECT_PORT`, `DSH_DESKTOP_RENDERER_DEBUG_PORT`, and `DSH_DESKTOP_HOST_INSPECT_PORT` replace those ports, while `DSH_DESKTOP_OPEN_DEVTOOLS=0` keeps the detached Renderer tools closed. After an explicit build, `start:desktop` reconstructs the disposable project and launches the existing artifacts without building again: diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 67668f5ba3..1b4e0570fd 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -12,6 +12,8 @@ Creator 和 Web Plugin Manager 在 Electron Node 模式下使用 Desktop 内置 桌面麦克风访问仅允许主 `dsh-app://app` 页面发起的音频请求。macOS 使用系统麦克风授权与随包用途说明。 +主应用页面获得焦点时,按 F12(多媒体功能键键盘上为 Fn+F12)或 macOS 的 Command+Option+I 可打开独立的 Renderer DevTools,打包版本同样支持。更新遮罩存在时,这些快捷键不会生效;内嵌浏览器获得焦点时,按键不会传递到主窗口。 + ## 关键技术决策 设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。 @@ -89,7 +91,7 @@ macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜 pnpm run dev:desktop ``` -在桌面主窗口按 F12 可打开独立的 Renderer DevTools,打包版本同样支持。使用多媒体功能键的键盘需按 Fn+F12。macOS 也支持 Command+Option+I。开发 Harness 状态默认写入 `apps/desktop/.desktop-build/development/home`,一次性 npm 项目位于 `apps/desktop/.desktop-build/development/project`,Electron 浏览器数据则位于 `apps/desktop/.desktop-build/development/electron-user-data`。因此,会话、设置、凭据、包链接和浏览器数据都不会进入用户正常使用的 Harness home;显式 `DSH_HOME` 只会替换开发 Harness home。Renderer DevTools 默认自动打开,Main、Renderer 和 dsh Host 调试端口依次为 9229、9222 和 9230。`DSH_DESKTOP_MAIN_INSPECT_PORT`、`DSH_DESKTOP_RENDERER_DEBUG_PORT` 与 `DSH_DESKTOP_HOST_INSPECT_PORT` 可以替换这些端口,`DSH_DESKTOP_OPEN_DEVTOOLS=0` 则保持 Renderer 调试窗口关闭。 +开发 Harness 状态默认写入 `apps/desktop/.desktop-build/development/home`,一次性 npm 项目位于 `apps/desktop/.desktop-build/development/project`,Electron 浏览器数据则位于 `apps/desktop/.desktop-build/development/electron-user-data`。因此,会话、设置、凭据、包链接和浏览器数据都不会进入用户正常使用的 Harness home;显式 `DSH_HOME` 只会替换开发 Harness home。Renderer DevTools 默认自动打开,Main、Renderer 和 dsh Host 调试端口依次为 9229、9222 和 9230。`DSH_DESKTOP_MAIN_INSPECT_PORT`、`DSH_DESKTOP_RENDERER_DEBUG_PORT` 与 `DSH_DESKTOP_HOST_INSPECT_PORT` 可以替换这些端口,`DSH_DESKTOP_OPEN_DEVTOOLS=0` 则保持 Renderer 调试窗口关闭。 显式构建完成后,`start:desktop` 会重新生成一次性项目,并跳过构建直接启动已有产物: diff --git a/apps/desktop/src/devtools-shortcut.ts b/apps/desktop/src/devtools-shortcut.ts new file mode 100644 index 0000000000..417e1fdff6 --- /dev/null +++ b/apps/desktop/src/devtools-shortcut.ts @@ -0,0 +1,22 @@ +/** Shared desktop DevTools shortcuts respect shell-owned update overlays. */ +import type { WebContents } from 'electron' +import { hasUpdateOverlay } from './update-overlay.ts' + +/** + * Install F12 and macOS Command+Option+I on one renderer for its lifetime. + * @param contents - Renderer whose DevTools may open while no update overlay is attached. + */ +export function installDevToolsShortcut(contents: { + on(event: 'before-input-event', listener: (event: Electron.Event, input: Electron.Input) => void): unknown + openDevTools: WebContents['openDevTools'] +}): void { + contents.on('before-input-event', (event, input) => { + if (input.type !== 'keyDown' || input.isAutoRepeat || hasUpdateOverlay(contents)) return + const f12 = input.key === 'F12' && !input.meta && !input.alt && !input.control && !input.shift + const macShortcut = process.platform === 'darwin' && input.code === 'KeyI' + && input.meta && input.alt && !input.control && !input.shift + if (!f12 && !macShortcut) return + event.preventDefault() + contents.openDevTools({ mode: 'detach' }) + }) +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 2f1f519379..cc585d0da3 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,3 +1,4 @@ +import { installDevToolsShortcut } from './devtools-shortcut.ts' import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts' /** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */ @@ -151,18 +152,10 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi sandbox: true, webSecurity: true, webviewTag: primary, + devTools: primary, }, }) - if (primary) { - window.webContents.on('before-input-event', (event, input) => { - if (input.type !== 'keyDown' || input.isAutoRepeat) return - const macShortcut = process.platform === 'darwin' && input.code === 'KeyI' - && input.meta && input.alt && !input.control && !input.shift - if (input.key !== 'F12' && !macShortcut) return - event.preventDefault() - window.webContents.openDevTools({ mode: 'detach' }) - }) - } + if (primary) installDevToolsShortcut(window.webContents) window.webContents.setWindowOpenHandler(({ url }) => { if (['http:', 'https:'].includes(new URL(url).protocol)) void shell.openExternal(url) return { action: 'deny' } diff --git a/apps/desktop/src/policy-test-auth.ts b/apps/desktop/src/policy-test-auth.ts index e7597a5e57..6accf10c12 100644 --- a/apps/desktop/src/policy-test-auth.ts +++ b/apps/desktop/src/policy-test-auth.ts @@ -1,4 +1,5 @@ /** Isolated, process-lifetime Feishu cookies for explicitly configured test policy requests. */ +import { installDevToolsShortcut } from './devtools-shortcut.ts' import { randomUUID } from 'node:crypto' import { BrowserWindow, session } from 'electron' import type { DesktopLocale } from './locale.ts' @@ -92,11 +93,7 @@ export class DesktopPolicyTestAuth { window.on('closed', () => { finish('cancelled') }) window.on('page-title-updated', (event) => { event.preventDefault() }) const contents = window.webContents - contents.on('before-input-event', (event, input) => { - if (input.type !== 'keyDown' || input.key !== 'F12' || input.isAutoRepeat) return - event.preventDefault() - contents.openDevTools({ mode: 'detach' }) - }) + installDevToolsShortcut(contents) contents.setWindowOpenHandler(() => ({ action: 'deny' })) contents.on('will-navigate', (event, url) => { if (!this.allowed(url)) { event.preventDefault(); finish('failed') } diff --git a/apps/desktop/src/update-overlay.ts b/apps/desktop/src/update-overlay.ts index a6e341ecb9..b4ac549871 100644 --- a/apps/desktop/src/update-overlay.ts +++ b/apps/desktop/src/update-overlay.ts @@ -1,6 +1,16 @@ /** Shell-owned modal windows cover the parent's content without replacing its native window controls. */ import { BrowserWindow } from 'electron' +const overlays = new WeakMap>() + +/** + * @param contents - Parent renderer whose input may be blocked by an update overlay. + * @returns Whether at least one update overlay remains attached. + */ +export function hasUpdateOverlay(contents: object): boolean { + return (overlays.get(contents)?.size ?? 0) > 0 +} + /** * @param parent - Product window whose content is blocked while the overlay is open. * @param preload - Isolated shell-only preload. @@ -15,6 +25,13 @@ export function createUpdateOverlay(parent: BrowserWindow, preload: string, titl skipTaskbar: true, hasShadow: false, title, webPreferences: { preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true }, }) + const attached = overlays.get(parent.webContents) ?? new Set() + overlays.set(parent.webContents, attached) + attached.add(window) + window.once('closed', () => { + attached.delete(window) + if (attached.size === 0) overlays.delete(parent.webContents) + }) // macOS native modals animate the entire viewport as a sheet. const focus = (): void => { if (!window.isDestroyed()) window.focus() } const blockInput = (event: Electron.Event): void => { event.preventDefault(); focus() } diff --git a/apps/desktop/tests/devtools-shortcut.spec.ts b/apps/desktop/tests/devtools-shortcut.spec.ts new file mode 100644 index 0000000000..d7c52bf76b --- /dev/null +++ b/apps/desktop/tests/devtools-shortcut.spec.ts @@ -0,0 +1,51 @@ +import { EventEmitter } from 'node:events' +import { afterEach, expect, it, vi } from 'vitest' +import { installDevToolsShortcut } from '../src/devtools-shortcut.ts' + +vi.mock('electron', () => ({ BrowserWindow: vi.fn() })) +afterEach(() => { vi.unstubAllGlobals() }) + +function renderer() { + const contents = Object.assign(new EventEmitter(), { openDevTools: vi.fn() }) + installDevToolsShortcut(contents) + return contents +} + +it('opens DevTools only on an unmodified F12 keydown', () => { + const contents = renderer() + expect(contents.openDevTools).not.toHaveBeenCalled() + const event = { preventDefault: vi.fn() } + for (const input of [ + { type: 'keyUp', key: 'F12' }, + { type: 'keyDown', key: 'F11' }, + { type: 'keyDown', key: 'F12', isAutoRepeat: true }, + ...['meta', 'alt', 'control', 'shift'].map(modifier => ({ type: 'keyDown', key: 'F12', [modifier]: true })), + ]) contents.emit('before-input-event', event, input) + expect(contents.openDevTools).not.toHaveBeenCalled() + expect(event.preventDefault).not.toHaveBeenCalled() + contents.emit('before-input-event', event, { type: 'keyDown', key: 'F12', isAutoRepeat: false }) + expect(event.preventDefault).toHaveBeenCalledOnce() + expect(contents.openDevTools).toHaveBeenCalledExactlyOnceWith({ mode: 'detach' }) +}) + +it.each(['darwin', 'win32'])('handles Command+Option+I only on macOS (platform=%s)', (platform) => { + vi.stubGlobal('process', { ...process, platform }) + const contents = renderer() + const event = { preventDefault: vi.fn() } + const input = { type: 'keyDown', key: 'ˆ', code: 'KeyI', meta: true, alt: true, + control: false, shift: false, isAutoRepeat: false } + for (const change of [ + { type: 'keyUp' }, { isAutoRepeat: true }, { code: 'KeyJ' }, + { meta: false }, { alt: false }, { control: true }, { shift: true }, + ]) contents.emit('before-input-event', event, { ...input, ...change }) + expect(contents.openDevTools).not.toHaveBeenCalled() + expect(event.preventDefault).not.toHaveBeenCalled() + contents.emit('before-input-event', event, input) + if (platform === 'darwin') { + expect(contents.openDevTools).toHaveBeenCalledExactlyOnceWith({ mode: 'detach' }) + expect(event.preventDefault).toHaveBeenCalledOnce() + } else { + expect(contents.openDevTools).not.toHaveBeenCalled() + expect(event.preventDefault).not.toHaveBeenCalled() + } +}) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index de4d4405ea..c8025d4e3c 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -368,45 +368,13 @@ describe('desktop main startup', () => { expect((await handler(new Request('dsh-app://unknown/update-dialog.html'))).status).toBe(404) }) - it.each(['darwin', 'win32'])('opens packaged main-window DevTools with F12 on %s', async (platform) => { - vi.stubGlobal('process', { ...process, platform }) + it('installs DevTools shortcuts on the packaged main window', async () => { await readyForUpdate() - const contents = harness.windows[0]!.webContents - expect(contents.openDevTools).not.toHaveBeenCalled() + const window = harness.windows[0]! + expect(window.options).toMatchObject({ webPreferences: { devTools: true } }) const event = { preventDefault: vi.fn() } - for (const input of [ - { type: 'keyUp', key: 'F12' }, - { type: 'keyDown', key: 'F11' }, - { type: 'keyDown', key: 'F12', isAutoRepeat: true }, - ]) contents.emit('before-input-event', event, input) - expect(contents.openDevTools).not.toHaveBeenCalled() - expect(event.preventDefault).not.toHaveBeenCalled() - contents.emit('before-input-event', event, { type: 'keyDown', key: 'F12', isAutoRepeat: false }) - expect(event.preventDefault).toHaveBeenCalledOnce() - expect(contents.openDevTools).toHaveBeenCalledExactlyOnceWith({ mode: 'detach' }) - }) - - it.each(['darwin', 'win32'])('handles Command+Option+I only on macOS (platform=%s)', async (platform) => { - vi.stubGlobal('process', { ...process, platform }) - await readyForUpdate() - const contents = harness.windows[0]!.webContents - const event = { preventDefault: vi.fn() } - const input = { type: 'keyDown', key: 'ˆ', code: 'KeyI', meta: true, alt: true, - control: false, shift: false, isAutoRepeat: false } - for (const change of [ - { type: 'keyUp' }, { isAutoRepeat: true }, { code: 'KeyJ' }, - { meta: false }, { alt: false }, { control: true }, { shift: true }, - ]) contents.emit('before-input-event', event, { ...input, ...change }) - expect(contents.openDevTools).not.toHaveBeenCalled() - expect(event.preventDefault).not.toHaveBeenCalled() - contents.emit('before-input-event', event, input) - if (platform === 'darwin') { - expect(contents.openDevTools).toHaveBeenCalledExactlyOnceWith({ mode: 'detach' }) - expect(event.preventDefault).toHaveBeenCalledOnce() - } else { - expect(contents.openDevTools).not.toHaveBeenCalled() - expect(event.preventDefault).not.toHaveBeenCalled() - } + window.webContents.emit('before-input-event', event, { type: 'keyDown', key: 'F12' }) + expect(window.webContents.openDevTools).toHaveBeenCalledExactlyOnceWith({ mode: 'detach' }) }) it.each([ diff --git a/apps/desktop/tests/policy-test-auth.spec.ts b/apps/desktop/tests/policy-test-auth.spec.ts index d830dd1d22..3848a9355a 100644 --- a/apps/desktop/tests/policy-test-auth.spec.ts +++ b/apps/desktop/tests/policy-test-auth.spec.ts @@ -78,11 +78,6 @@ it('opens only the Feishu login window DevTools on a single F12 keydown', () => const press = (type: string, key: string, isAutoRepeat = false) => { window.webContents.emit('before-input-event', { preventDefault }, { type, key, isAutoRepeat }) } - press('keyDown', 'F11') - press('keyUp', 'F12') - press('keyDown', 'F12', true) - expect(window.webContents.openDevTools).not.toHaveBeenCalled() - expect(preventDefault).not.toHaveBeenCalled() press('keyDown', 'F12') expect(preventDefault).toHaveBeenCalledOnce() expect(window.webContents.openDevTools).toHaveBeenCalledExactlyOnceWith({ mode: 'detach' }) diff --git a/apps/desktop/tests/update-overlay.spec.ts b/apps/desktop/tests/update-overlay.spec.ts index 9f4a79878d..f21ce47bea 100644 --- a/apps/desktop/tests/update-overlay.spec.ts +++ b/apps/desktop/tests/update-overlay.spec.ts @@ -1,7 +1,8 @@ import { EventEmitter } from 'node:events' import { expect, it, vi } from 'vitest' import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron' -import { createUpdateOverlay } from '../src/update-overlay.ts' +import { installDevToolsShortcut } from '../src/devtools-shortcut.ts' +import { createUpdateOverlay, hasUpdateOverlay } from '../src/update-overlay.ts' const native = vi.hoisted(() => ({ create: vi.fn<(options: BrowserWindowConstructorOptions) => object>() })) vi.mock('electron', () => ({ BrowserWindow: function (options: object) { return native.create(options) } })) @@ -10,7 +11,7 @@ it('keeps the macOS mandatory overlay stationary and blocks parent keyboard inpu const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') const parent = Object.assign(new EventEmitter(), { getContentBounds: () => ({ x: 0, y: 0, width: 1000, height: 700 }), - webContents: Object.assign(new EventEmitter(), { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) }), + webContents: Object.assign(new EventEmitter(), { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}), openDevTools: vi.fn() }), isDestroyed: () => false, }) const window = Object.assign(new EventEmitter(), { @@ -19,14 +20,23 @@ it('keeps the macOS mandatory overlay stationary and blocks parent keyboard inpu }) native.create.mockReturnValue(window) try { + installDevToolsShortcut(parent.webContents) createUpdateOverlay(parent as unknown as BrowserWindow, 'owned', 'Update required', false) expect(native.create).toHaveBeenLastCalledWith(expect.objectContaining({ modal: false, transparent: true, frame: false })) const event = { preventDefault: vi.fn() } - parent.webContents.emit('before-input-event', event) + expect(hasUpdateOverlay(parent.webContents)).toBe(true) + parent.webContents.emit('before-input-event', event, { type: 'keyDown', key: 'F12' }) + expect(parent.webContents.openDevTools).not.toHaveBeenCalled() + parent.webContents.emit('before-input-event', { preventDefault: vi.fn() }, + { type: 'keyDown', code: 'KeyI', meta: true, alt: true }) + expect(parent.webContents.openDevTools).not.toHaveBeenCalled() expect(event.preventDefault).toHaveBeenCalledOnce() - expect(window.focus).toHaveBeenCalledOnce() + expect(window.focus).toHaveBeenCalledTimes(2) window.emit('closed') expect(parent.listenerCount('focus')).toBe(0) - expect(parent.webContents.listenerCount('before-input-event')).toBe(0) + expect(parent.webContents.listenerCount('before-input-event')).toBe(1) + expect(hasUpdateOverlay(parent.webContents)).toBe(false) + parent.webContents.emit('before-input-event', event, { type: 'keyDown', key: 'F12' }) + expect(parent.webContents.openDevTools).toHaveBeenCalledExactlyOnceWith({ mode: 'detach' }) } finally { platform.mockRestore() } }) From 355e43c1f357024d065bcceb4c13551c9004931b Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 21 Sep 2026 20:43:38 +0800 Subject: [PATCH 4/7] refactor(desktop): use hidden native DevTools menu shortcuts --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/src/devtools-shortcut.ts | 22 --------- apps/desktop/src/main.ts | 12 +++-- apps/desktop/src/policy-test-auth.ts | 7 ++- apps/desktop/src/update-overlay.ts | 19 +------- apps/desktop/tests/devtools-shortcut.spec.ts | 51 -------------------- apps/desktop/tests/main-startup.spec.ts | 22 +++++---- apps/desktop/tests/policy-test-auth.spec.ts | 5 ++ apps/desktop/tests/update-overlay.spec.ts | 23 +++------ 11 files changed, 42 insertions(+), 127 deletions(-) delete mode 100644 apps/desktop/src/devtools-shortcut.ts delete mode 100644 apps/desktop/tests/devtools-shortcut.spec.ts diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 0687e136e7..ec024ea7bf 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 92a2c3b4b4b85467ce0994accf120be684687ade -README.zh.md: 1b4e0570fd574d40503552c4e1e98a7687e0c3e7 +README.md: 16bf5fd64fc4ea8bfe03e6e84e37cb87115bd74b +README.zh.md: 500240148cc8fb0c90f6029caaa4fdf57881c83d diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 92a2c3b4b4..16bf5fd64f 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -12,7 +12,7 @@ Creator and the Web Plugin Manager use Desktop’s bundled pnpm under Electron N Desktop microphone access is restricted to audio requests from the primary `dsh-app://app` frame. macOS uses system microphone authorization and a packaged usage description. -Press F12 (Fn+F12 on media-key keyboards) or, on macOS, Command+Option+I while the main application page has focus to open detached Renderer DevTools, including in packaged builds. These shortcuts are suppressed while an update overlay is attached and do not reach the main window while an embedded browser guest has focus. +Press F12 (Fn+F12 on media-key keyboards), Command+Option+I on macOS, or Ctrl+Shift+I on Windows to toggle DevTools for the focused application page, including in packaged builds. These native shortcuts use hidden application-menu items. Update overlays and packaged embedded browser guests disable DevTools. ## Key technical decisions diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 1b4e0570fd..500240148c 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -12,7 +12,7 @@ Creator 和 Web Plugin Manager 在 Electron Node 模式下使用 Desktop 内置 桌面麦克风访问仅允许主 `dsh-app://app` 页面发起的音频请求。macOS 使用系统麦克风授权与随包用途说明。 -主应用页面获得焦点时,按 F12(多媒体功能键键盘上为 Fn+F12)或 macOS 的 Command+Option+I 可打开独立的 Renderer DevTools,打包版本同样支持。更新遮罩存在时,这些快捷键不会生效;内嵌浏览器获得焦点时,按键不会传递到主窗口。 +按 F12(多媒体功能键键盘上为 Fn+F12)、macOS 的 Command+Option+I 或 Windows 的 Ctrl+Shift+I,可切换当前获得焦点的应用页面的 DevTools,打包版本同样支持。这些原生快捷键通过隐藏的应用菜单项注册。更新遮罩和打包版本的内嵌浏览器禁用 DevTools。 ## 关键技术决策 diff --git a/apps/desktop/src/devtools-shortcut.ts b/apps/desktop/src/devtools-shortcut.ts deleted file mode 100644 index 417e1fdff6..0000000000 --- a/apps/desktop/src/devtools-shortcut.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** Shared desktop DevTools shortcuts respect shell-owned update overlays. */ -import type { WebContents } from 'electron' -import { hasUpdateOverlay } from './update-overlay.ts' - -/** - * Install F12 and macOS Command+Option+I on one renderer for its lifetime. - * @param contents - Renderer whose DevTools may open while no update overlay is attached. - */ -export function installDevToolsShortcut(contents: { - on(event: 'before-input-event', listener: (event: Electron.Event, input: Electron.Input) => void): unknown - openDevTools: WebContents['openDevTools'] -}): void { - contents.on('before-input-event', (event, input) => { - if (input.type !== 'keyDown' || input.isAutoRepeat || hasUpdateOverlay(contents)) return - const f12 = input.key === 'F12' && !input.meta && !input.alt && !input.control && !input.shift - const macShortcut = process.platform === 'darwin' && input.code === 'KeyI' - && input.meta && input.alt && !input.control && !input.shift - if (!f12 && !macShortcut) return - event.preventDefault() - contents.openDevTools({ mode: 'detach' }) - }) -} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index cc585d0da3..1c0a5ae985 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,4 +1,3 @@ -import { installDevToolsShortcut } from './devtools-shortcut.ts' import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts' /** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */ @@ -152,10 +151,9 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi sandbox: true, webSecurity: true, webviewTag: primary, - devTools: primary, + devTools: true, }, }) - if (primary) installDevToolsShortcut(window.webContents) window.webContents.setWindowOpenHandler(({ url }) => { if (['http:', 'https:'].includes(new URL(url).protocol)) void shell.openExternal(url) return { action: 'deny' } @@ -675,9 +673,13 @@ async function main(): Promise { { role: 'quit', ...(darwin ? { label: currentDesktopLocale().messages.quitApplication } : process.platform === 'win32' ? { label: currentDesktopLocale().messages.exitApplication } : {}) }, ] - Menu.setApplicationMenu(process.platform === 'win32' ? null : Menu.buildFromTemplate([{ + const devToolsItems: MenuItemConstructorOptions[] = [ + { role: 'toggleDevTools', visible: false }, + { role: 'toggleDevTools', visible: false, accelerator: 'F12' }, + ] + Menu.setApplicationMenu(Menu.buildFromTemplate(process.platform === 'win32' ? devToolsItems : [{ label: darwin ? app.name : currentDesktopLocale().messages.application, - submenu: applicationItems(), + submenu: [...applicationItems(), ...devToolsItems], }, ...platformMenus])) if (process.platform === 'win32') { diff --git a/apps/desktop/src/policy-test-auth.ts b/apps/desktop/src/policy-test-auth.ts index 6accf10c12..e7597a5e57 100644 --- a/apps/desktop/src/policy-test-auth.ts +++ b/apps/desktop/src/policy-test-auth.ts @@ -1,5 +1,4 @@ /** Isolated, process-lifetime Feishu cookies for explicitly configured test policy requests. */ -import { installDevToolsShortcut } from './devtools-shortcut.ts' import { randomUUID } from 'node:crypto' import { BrowserWindow, session } from 'electron' import type { DesktopLocale } from './locale.ts' @@ -93,7 +92,11 @@ export class DesktopPolicyTestAuth { window.on('closed', () => { finish('cancelled') }) window.on('page-title-updated', (event) => { event.preventDefault() }) const contents = window.webContents - installDevToolsShortcut(contents) + contents.on('before-input-event', (event, input) => { + if (input.type !== 'keyDown' || input.key !== 'F12' || input.isAutoRepeat) return + event.preventDefault() + contents.openDevTools({ mode: 'detach' }) + }) contents.setWindowOpenHandler(() => ({ action: 'deny' })) contents.on('will-navigate', (event, url) => { if (!this.allowed(url)) { event.preventDefault(); finish('failed') } diff --git a/apps/desktop/src/update-overlay.ts b/apps/desktop/src/update-overlay.ts index b4ac549871..2dff332f19 100644 --- a/apps/desktop/src/update-overlay.ts +++ b/apps/desktop/src/update-overlay.ts @@ -1,16 +1,6 @@ /** Shell-owned modal windows cover the parent's content without replacing its native window controls. */ import { BrowserWindow } from 'electron' -const overlays = new WeakMap>() - -/** - * @param contents - Parent renderer whose input may be blocked by an update overlay. - * @returns Whether at least one update overlay remains attached. - */ -export function hasUpdateOverlay(contents: object): boolean { - return (overlays.get(contents)?.size ?? 0) > 0 -} - /** * @param parent - Product window whose content is blocked while the overlay is open. * @param preload - Isolated shell-only preload. @@ -23,14 +13,7 @@ export function createUpdateOverlay(parent: BrowserWindow, preload: string, titl parent, modal: nativeModal || process.platform !== 'darwin', show: false, frame: false, transparent: true, ...parent.getContentBounds(), resizable: false, minimizable: false, maximizable: false, skipTaskbar: true, hasShadow: false, title, - webPreferences: { preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true }, - }) - const attached = overlays.get(parent.webContents) ?? new Set() - overlays.set(parent.webContents, attached) - attached.add(window) - window.once('closed', () => { - attached.delete(window) - if (attached.size === 0) overlays.delete(parent.webContents) + webPreferences: { preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true, devTools: false }, }) // macOS native modals animate the entire viewport as a sheet. const focus = (): void => { if (!window.isDestroyed()) window.focus() } diff --git a/apps/desktop/tests/devtools-shortcut.spec.ts b/apps/desktop/tests/devtools-shortcut.spec.ts deleted file mode 100644 index d7c52bf76b..0000000000 --- a/apps/desktop/tests/devtools-shortcut.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { EventEmitter } from 'node:events' -import { afterEach, expect, it, vi } from 'vitest' -import { installDevToolsShortcut } from '../src/devtools-shortcut.ts' - -vi.mock('electron', () => ({ BrowserWindow: vi.fn() })) -afterEach(() => { vi.unstubAllGlobals() }) - -function renderer() { - const contents = Object.assign(new EventEmitter(), { openDevTools: vi.fn() }) - installDevToolsShortcut(contents) - return contents -} - -it('opens DevTools only on an unmodified F12 keydown', () => { - const contents = renderer() - expect(contents.openDevTools).not.toHaveBeenCalled() - const event = { preventDefault: vi.fn() } - for (const input of [ - { type: 'keyUp', key: 'F12' }, - { type: 'keyDown', key: 'F11' }, - { type: 'keyDown', key: 'F12', isAutoRepeat: true }, - ...['meta', 'alt', 'control', 'shift'].map(modifier => ({ type: 'keyDown', key: 'F12', [modifier]: true })), - ]) contents.emit('before-input-event', event, input) - expect(contents.openDevTools).not.toHaveBeenCalled() - expect(event.preventDefault).not.toHaveBeenCalled() - contents.emit('before-input-event', event, { type: 'keyDown', key: 'F12', isAutoRepeat: false }) - expect(event.preventDefault).toHaveBeenCalledOnce() - expect(contents.openDevTools).toHaveBeenCalledExactlyOnceWith({ mode: 'detach' }) -}) - -it.each(['darwin', 'win32'])('handles Command+Option+I only on macOS (platform=%s)', (platform) => { - vi.stubGlobal('process', { ...process, platform }) - const contents = renderer() - const event = { preventDefault: vi.fn() } - const input = { type: 'keyDown', key: 'ˆ', code: 'KeyI', meta: true, alt: true, - control: false, shift: false, isAutoRepeat: false } - for (const change of [ - { type: 'keyUp' }, { isAutoRepeat: true }, { code: 'KeyJ' }, - { meta: false }, { alt: false }, { control: true }, { shift: true }, - ]) contents.emit('before-input-event', event, { ...input, ...change }) - expect(contents.openDevTools).not.toHaveBeenCalled() - expect(event.preventDefault).not.toHaveBeenCalled() - contents.emit('before-input-event', event, input) - if (platform === 'darwin') { - expect(contents.openDevTools).toHaveBeenCalledExactlyOnceWith({ mode: 'detach' }) - expect(event.preventDefault).toHaveBeenCalledOnce() - } else { - expect(contents.openDevTools).not.toHaveBeenCalled() - expect(event.preventDefault).not.toHaveBeenCalled() - } -}) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index c8025d4e3c..53e42a94a3 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -368,13 +368,14 @@ describe('desktop main startup', () => { expect((await handler(new Request('dsh-app://unknown/update-dialog.html'))).status).toBe(404) }) - it('installs DevTools shortcuts on the packaged main window', async () => { + it('installs hidden native DevTools shortcuts in the macOS application menu', async () => { + vi.stubGlobal('process', { ...process, platform: 'darwin' }) await readyForUpdate() - const window = harness.windows[0]! - expect(window.options).toMatchObject({ webPreferences: { devTools: true } }) - const event = { preventDefault: vi.fn() } - window.webContents.emit('before-input-event', event, { type: 'keyDown', key: 'F12' }) - expect(window.webContents.openDevTools).toHaveBeenCalledExactlyOnceWith({ mode: 'detach' }) + expect(applicationMenuItems().slice(-2)).toEqual([ + { role: 'toggleDevTools', visible: false }, + { role: 'toggleDevTools', visible: false, accelerator: 'F12' }, + ]) + expect(harness.windows[0]!.options).toMatchObject({ webPreferences: { devTools: true } }) }) it.each([ @@ -566,7 +567,10 @@ describe('desktop main startup', () => { } else if (platform === 'win32') { expect(window.options).toMatchObject({ titleBarStyle: 'hidden', titleBarOverlay: { height: WINDOWS_TITLEBAR_HEIGHT } }) expect(window.options).not.toHaveProperty('vibrancy') - expect(harness.menu.setApplicationMenu).toHaveBeenCalledWith(null) + expect(harness.menu.mock.calls[0]![0]).toEqual([ + { role: 'toggleDevTools', visible: false }, + { role: 'toggleDevTools', visible: false, accelerator: 'F12' }, + ]) } else { expect(window.options).not.toHaveProperty('titleBarStyle') expect(window.options).not.toHaveProperty('vibrancy') @@ -674,7 +678,7 @@ describe('desktop main startup', () => { window.webContents.mainFrame.url = 'dsh-app://unowned/index.html' listener(event, 'zh-CN', '#fff', '#000') expect(window.setTitleBarOverlay).not.toHaveBeenCalled() - expect(harness.menu.setApplicationMenu).toHaveBeenCalledExactlyOnceWith(null) + expect(harness.menu.setApplicationMenu).toHaveBeenCalledOnce() }) it('maps Windows caption menus to localized native commands and rejects foreign popup requests', async () => { @@ -728,7 +732,7 @@ describe('desktop main startup', () => { ? ['Desktop test', 'fileMenu', 'editMenu', 'windowMenu'] : ['Application', 'editMenu']) const application = template[0]!.submenu as MenuItemConstructorOptions[] - expect(application.map(describeItem)).toEqual(platform === 'darwin' + expect(application.filter(item => item.visible !== false).map(describeItem)).toEqual(platform === 'darwin' ? ['about', 'separator', en.checkUpdatesMenu, 'separator', 'hide', 'hideOthers', 'unhide', 'separator', 'quit'] : ['about', 'separator', en.checkUpdatesMenu, 'separator', 'quit']) expect(harness.menu.setApplicationMenu).toHaveBeenCalledOnce() diff --git a/apps/desktop/tests/policy-test-auth.spec.ts b/apps/desktop/tests/policy-test-auth.spec.ts index 3848a9355a..d830dd1d22 100644 --- a/apps/desktop/tests/policy-test-auth.spec.ts +++ b/apps/desktop/tests/policy-test-auth.spec.ts @@ -78,6 +78,11 @@ it('opens only the Feishu login window DevTools on a single F12 keydown', () => const press = (type: string, key: string, isAutoRepeat = false) => { window.webContents.emit('before-input-event', { preventDefault }, { type, key, isAutoRepeat }) } + press('keyDown', 'F11') + press('keyUp', 'F12') + press('keyDown', 'F12', true) + expect(window.webContents.openDevTools).not.toHaveBeenCalled() + expect(preventDefault).not.toHaveBeenCalled() press('keyDown', 'F12') expect(preventDefault).toHaveBeenCalledOnce() expect(window.webContents.openDevTools).toHaveBeenCalledExactlyOnceWith({ mode: 'detach' }) diff --git a/apps/desktop/tests/update-overlay.spec.ts b/apps/desktop/tests/update-overlay.spec.ts index f21ce47bea..879aaefe7d 100644 --- a/apps/desktop/tests/update-overlay.spec.ts +++ b/apps/desktop/tests/update-overlay.spec.ts @@ -1,8 +1,7 @@ import { EventEmitter } from 'node:events' import { expect, it, vi } from 'vitest' import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron' -import { installDevToolsShortcut } from '../src/devtools-shortcut.ts' -import { createUpdateOverlay, hasUpdateOverlay } from '../src/update-overlay.ts' +import { createUpdateOverlay } from '../src/update-overlay.ts' const native = vi.hoisted(() => ({ create: vi.fn<(options: BrowserWindowConstructorOptions) => object>() })) vi.mock('electron', () => ({ BrowserWindow: function (options: object) { return native.create(options) } })) @@ -11,7 +10,7 @@ it('keeps the macOS mandatory overlay stationary and blocks parent keyboard inpu const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') const parent = Object.assign(new EventEmitter(), { getContentBounds: () => ({ x: 0, y: 0, width: 1000, height: 700 }), - webContents: Object.assign(new EventEmitter(), { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}), openDevTools: vi.fn() }), + webContents: Object.assign(new EventEmitter(), { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) }), isDestroyed: () => false, }) const window = Object.assign(new EventEmitter(), { @@ -20,23 +19,15 @@ it('keeps the macOS mandatory overlay stationary and blocks parent keyboard inpu }) native.create.mockReturnValue(window) try { - installDevToolsShortcut(parent.webContents) createUpdateOverlay(parent as unknown as BrowserWindow, 'owned', 'Update required', false) - expect(native.create).toHaveBeenLastCalledWith(expect.objectContaining({ modal: false, transparent: true, frame: false })) + expect(native.create).toHaveBeenLastCalledWith(expect.objectContaining({ modal: false, transparent: true, frame: false, + webPreferences: expect.objectContaining({ devTools: false }) })) const event = { preventDefault: vi.fn() } - expect(hasUpdateOverlay(parent.webContents)).toBe(true) - parent.webContents.emit('before-input-event', event, { type: 'keyDown', key: 'F12' }) - expect(parent.webContents.openDevTools).not.toHaveBeenCalled() - parent.webContents.emit('before-input-event', { preventDefault: vi.fn() }, - { type: 'keyDown', code: 'KeyI', meta: true, alt: true }) - expect(parent.webContents.openDevTools).not.toHaveBeenCalled() + parent.webContents.emit('before-input-event', event) expect(event.preventDefault).toHaveBeenCalledOnce() - expect(window.focus).toHaveBeenCalledTimes(2) + expect(window.focus).toHaveBeenCalledOnce() window.emit('closed') expect(parent.listenerCount('focus')).toBe(0) - expect(parent.webContents.listenerCount('before-input-event')).toBe(1) - expect(hasUpdateOverlay(parent.webContents)).toBe(false) - parent.webContents.emit('before-input-event', event, { type: 'keyDown', key: 'F12' }) - expect(parent.webContents.openDevTools).toHaveBeenCalledExactlyOnceWith({ mode: 'detach' }) + expect(parent.webContents.listenerCount('before-input-event')).toBe(0) } finally { platform.mockRestore() } }) From 54f64333573424ebe2c9c07ed7f86caa9bd5021d Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 21 Sep 2026 20:47:26 +0800 Subject: [PATCH 5/7] test(desktop): avoid untyped nested overlay matcher --- apps/desktop/tests/update-overlay.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/tests/update-overlay.spec.ts b/apps/desktop/tests/update-overlay.spec.ts index 879aaefe7d..c65f2afcb0 100644 --- a/apps/desktop/tests/update-overlay.spec.ts +++ b/apps/desktop/tests/update-overlay.spec.ts @@ -20,8 +20,8 @@ it('keeps the macOS mandatory overlay stationary and blocks parent keyboard inpu native.create.mockReturnValue(window) try { createUpdateOverlay(parent as unknown as BrowserWindow, 'owned', 'Update required', false) - expect(native.create).toHaveBeenLastCalledWith(expect.objectContaining({ modal: false, transparent: true, frame: false, - webPreferences: expect.objectContaining({ devTools: false }) })) + expect(native.create).toHaveBeenLastCalledWith(expect.objectContaining({ modal: false, transparent: true, frame: false })) + expect(native.create.mock.lastCall?.[0].webPreferences?.devTools).toBe(false) const event = { preventDefault: vi.fn() } parent.webContents.emit('before-input-event', event) expect(event.preventDefault).toHaveBeenCalledOnce() From fc7c9c2e2fcdf33805cb3c8d6bce2fa8db44dde4 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 21 Sep 2026 20:57:01 +0800 Subject: [PATCH 6/7] fix(desktop): retain update overlay DevTools availability --- apps/desktop/src/update-overlay.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/update-overlay.ts b/apps/desktop/src/update-overlay.ts index 2dff332f19..a6e341ecb9 100644 --- a/apps/desktop/src/update-overlay.ts +++ b/apps/desktop/src/update-overlay.ts @@ -13,7 +13,7 @@ export function createUpdateOverlay(parent: BrowserWindow, preload: string, titl parent, modal: nativeModal || process.platform !== 'darwin', show: false, frame: false, transparent: true, ...parent.getContentBounds(), resizable: false, minimizable: false, maximizable: false, skipTaskbar: true, hasShadow: false, title, - webPreferences: { preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true, devTools: false }, + webPreferences: { preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true }, }) // macOS native modals animate the entire viewport as a sheet. const focus = (): void => { if (!window.isDestroyed()) window.focus() } From e47e85cce6df421339056bae66515cb5ee03825a Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 21 Sep 2026 20:58:15 +0800 Subject: [PATCH 7/7] test(desktop): remove obsolete overlay DevTools assertion --- apps/desktop/tests/update-overlay.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/desktop/tests/update-overlay.spec.ts b/apps/desktop/tests/update-overlay.spec.ts index c65f2afcb0..9f4a79878d 100644 --- a/apps/desktop/tests/update-overlay.spec.ts +++ b/apps/desktop/tests/update-overlay.spec.ts @@ -21,7 +21,6 @@ it('keeps the macOS mandatory overlay stationary and blocks parent keyboard inpu try { createUpdateOverlay(parent as unknown as BrowserWindow, 'owned', 'Update required', false) expect(native.create).toHaveBeenLastCalledWith(expect.objectContaining({ modal: false, transparent: true, frame: false })) - expect(native.create.mock.lastCall?.[0].webPreferences?.devTools).toBe(false) const event = { preventDefault: vi.fn() } parent.webContents.emit('before-input-event', event) expect(event.preventDefault).toHaveBeenCalledOnce()