From ef875899234d994c217cec0bd3cd014e81db9065 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 3 Jun 2026 14:00:19 +0800 Subject: [PATCH] fix(desktop): set Windows AppUserModelID and guard window navigation - applyWindowsAppUserModelId() so Windows attributes toast notifications to the app (kept in sync with build.appId via a test); no-op on macOS/Linux - main window: setWindowOpenHandler denies uncontrolled popups, routes http(s) links to the system browser; intentionally no will-navigate guard so dev HMR reloads keep working - preview view: denies popups + blocks non-http(s) navigation (file:/custom schemes) while allowing in-page http(s) browsing Co-Authored-By: Claude Opus 4.8 (1M context) --- desktop/electron/main.ts | 25 +++-- desktop/electron/services/appIdentity.test.ts | 28 ++++++ desktop/electron/services/appIdentity.ts | 18 ++++ .../services/navigationGuards.test.ts | 97 +++++++++++++++++++ desktop/electron/services/navigationGuards.ts | 58 +++++++++++ 5 files changed, 218 insertions(+), 8 deletions(-) create mode 100644 desktop/electron/services/appIdentity.test.ts create mode 100644 desktop/electron/services/appIdentity.ts create mode 100644 desktop/electron/services/navigationGuards.test.ts create mode 100644 desktop/electron/services/navigationGuards.ts diff --git a/desktop/electron/main.ts b/desktop/electron/main.ts index 352fa19b..34b41d3e 100644 --- a/desktop/electron/main.ts +++ b/desktop/electron/main.ts @@ -26,6 +26,8 @@ import { type PortableDetection, } from './services/appMode' import { installMacOsChromiumKeychainPromptGuard } from './services/keychain' +import { applyWindowsAppUserModelId } from './services/appIdentity' +import { installMainWindowNavigationGuards, installPreviewNavigationGuards } from './services/navigationGuards' import { logNotificationSmokeRendererAck, scheduleNotificationSmoke } from './services/notificationSmoke' import { normalizeZoomFactor } from './services/zoom' import { resolveRendererEntry } from './services/rendererEntry' @@ -126,14 +128,18 @@ function getTerminalService() { function getPreviewService() { previewService ??= new ElectronPreviewService({ previewScriptPath: previewAgentPath(), - createView: () => new WebContentsView({ - webPreferences: { - preload: previewPreloadPath(), - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - }, - }), + createView: () => { + const view = new WebContentsView({ + webPreferences: { + preload: previewPreloadPath(), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }) + installPreviewNavigationGuards(view.webContents, { openExternal: openExternalUrl }) + return view + }, }) return previewService } @@ -297,6 +303,8 @@ async function createMainWindow() { }, }) + installMainWindowNavigationGuards(mainWindow.webContents, { openExternal: openExternalUrl }) + installWindowLifecycle({ app, window: mainWindow, @@ -334,6 +342,7 @@ if (!acquireSingleInstanceLock(app, () => mainWindow)) { registerIpcHandlers() app.whenReady().then(async () => { + applyWindowsAppUserModelId(app) applyStartupPortableMode(app) await getServerRuntime().startServer().catch(error => { console.error('[desktop] failed to start Electron server sidecar', error) diff --git a/desktop/electron/services/appIdentity.test.ts b/desktop/electron/services/appIdentity.test.ts new file mode 100644 index 00000000..e0383065 --- /dev/null +++ b/desktop/electron/services/appIdentity.test.ts @@ -0,0 +1,28 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it, vi } from 'vitest' +import { applyWindowsAppUserModelId, WINDOWS_APP_USER_MODEL_ID } from './appIdentity' + +describe('applyWindowsAppUserModelId', () => { + it('sets the AppUserModelID on Windows so toast notifications are attributed to the app', () => { + const setAppUserModelId = vi.fn() + const result = applyWindowsAppUserModelId({ setAppUserModelId }, 'win32') + expect(result).toBe(true) + expect(setAppUserModelId).toHaveBeenCalledWith(WINDOWS_APP_USER_MODEL_ID) + }) + + it('is a no-op on macOS and Linux', () => { + for (const platform of ['darwin', 'linux'] as const) { + const setAppUserModelId = vi.fn() + expect(applyWindowsAppUserModelId({ setAppUserModelId }, platform)).toBe(false) + expect(setAppUserModelId).not.toHaveBeenCalled() + } + }) + + it('keeps the AppUserModelID in sync with build.appId in package.json', () => { + const packageJsonPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json') + const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { build?: { appId?: string } } + expect(WINDOWS_APP_USER_MODEL_ID).toBe(pkg.build?.appId) + }) +}) diff --git a/desktop/electron/services/appIdentity.ts b/desktop/electron/services/appIdentity.ts new file mode 100644 index 00000000..23790a0f --- /dev/null +++ b/desktop/electron/services/appIdentity.ts @@ -0,0 +1,18 @@ +export type AppUserModelIdHost = { + setAppUserModelId(id: string): void +} + +// Must stay in sync with build.appId in desktop/package.json. Windows attributes +// toast notifications (and taskbar pinning) to this AppUserModelID; without an +// explicit call, notifications from a dev/unpackaged run can silently fail to show. +export const WINDOWS_APP_USER_MODEL_ID = 'com.claude-code-haha.desktop' + +export function applyWindowsAppUserModelId( + app: AppUserModelIdHost, + platform: NodeJS.Platform = process.platform, + appUserModelId: string = WINDOWS_APP_USER_MODEL_ID, +): boolean { + if (platform !== 'win32') return false + app.setAppUserModelId(appUserModelId) + return true +} diff --git a/desktop/electron/services/navigationGuards.test.ts b/desktop/electron/services/navigationGuards.test.ts new file mode 100644 index 00000000..b2663437 --- /dev/null +++ b/desktop/electron/services/navigationGuards.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest' +import { + installMainWindowNavigationGuards, + installPreviewNavigationGuards, + isHttpUrl, +} from './navigationGuards' + +function fakeWebContents() { + let windowOpenHandler: ((details: { url: string }) => { action: 'deny' } | { action: 'allow' }) | null = null + let willNavigateHandler: ((event: { preventDefault: () => void }, url: string) => void) | null = null + return { + contents: { + setWindowOpenHandler(handler: (details: { url: string }) => { action: 'deny' } | { action: 'allow' }) { + windowOpenHandler = handler + }, + on(event: 'will-navigate', handler: (event: { preventDefault: () => void }, url: string) => void) { + if (event === 'will-navigate') willNavigateHandler = handler + return this + }, + }, + openWindow(url: string) { + if (!windowOpenHandler) throw new Error('window open handler not installed') + return windowOpenHandler({ url }) + }, + navigate(url: string) { + const event = { preventDefault: vi.fn() } + willNavigateHandler?.(event, url) + return event.preventDefault + }, + hasWillNavigate() { + return willNavigateHandler !== null + }, + } +} + +describe('isHttpUrl', () => { + it('accepts only http(s) URLs', () => { + expect(isHttpUrl('https://example.com')).toBe(true) + expect(isHttpUrl('http://127.0.0.1:8080')).toBe(true) + expect(isHttpUrl('file:///etc/passwd')).toBe(false) + expect(isHttpUrl('javascript:alert(1)')).toBe(false) + expect(isHttpUrl('not a url')).toBe(false) + }) +}) + +describe('installMainWindowNavigationGuards', () => { + it('denies popups and routes http(s) ones to the system browser', () => { + const openExternal = vi.fn() + const wc = fakeWebContents() + installMainWindowNavigationGuards(wc.contents, { openExternal }) + + expect(wc.openWindow('https://example.com')).toEqual({ action: 'deny' }) + expect(openExternal).toHaveBeenCalledWith('https://example.com') + }) + + it('denies non-http popups without opening anything', () => { + const openExternal = vi.fn() + const wc = fakeWebContents() + installMainWindowNavigationGuards(wc.contents, { openExternal }) + + expect(wc.openWindow('file:///etc/passwd')).toEqual({ action: 'deny' }) + expect(openExternal).not.toHaveBeenCalled() + }) + + it('does not install a will-navigate guard so dev reloads keep working', () => { + const wc = fakeWebContents() + installMainWindowNavigationGuards(wc.contents, { openExternal: vi.fn() }) + expect(wc.hasWillNavigate()).toBe(false) + }) +}) + +describe('installPreviewNavigationGuards', () => { + it('allows in-page http(s) navigation so the preview keeps working as a browser', () => { + const wc = fakeWebContents() + installPreviewNavigationGuards(wc.contents, { openExternal: vi.fn() }) + + const preventDefault = wc.navigate('https://example.com/page') + expect(preventDefault).not.toHaveBeenCalled() + }) + + it('blocks navigation to non-http(s) schemes', () => { + const wc = fakeWebContents() + installPreviewNavigationGuards(wc.contents, { openExternal: vi.fn() }) + + const preventDefault = wc.navigate('file:///etc/passwd') + expect(preventDefault).toHaveBeenCalledTimes(1) + }) + + it('denies popups and routes http(s) ones to the system browser', () => { + const openExternal = vi.fn() + const wc = fakeWebContents() + installPreviewNavigationGuards(wc.contents, { openExternal }) + + expect(wc.openWindow('https://example.com')).toEqual({ action: 'deny' }) + expect(openExternal).toHaveBeenCalledWith('https://example.com') + }) +}) diff --git a/desktop/electron/services/navigationGuards.ts b/desktop/electron/services/navigationGuards.ts new file mode 100644 index 00000000..77c8de1e --- /dev/null +++ b/desktop/electron/services/navigationGuards.ts @@ -0,0 +1,58 @@ +export type WindowOpenHandlerResult = { action: 'deny' } | { action: 'allow' } + +export type NavigationGuardWebContents = { + setWindowOpenHandler(handler: (details: { url: string }) => WindowOpenHandlerResult): void + on( + event: 'will-navigate', + handler: (event: { preventDefault: () => void }, url: string) => void, + ): unknown +} + +export type NavigationGuardOptions = { + openExternal: (url: string) => void +} + +export function isHttpUrl(url: string): boolean { + try { + const { protocol } = new URL(url) + return protocol === 'http:' || protocol === 'https:' + } catch { + return false + } +} + +/** + * Main app window guard. The renderer is a single-page app loaded from a fixed + * entry; it should never spawn an uncontrolled child window. Any window.open / + * target=_blank with an http(s) URL is routed to the system browser and the + * Electron popup is denied. We intentionally do NOT install a `will-navigate` + * guard here so that dev HMR reloads and in-app navigations keep working. + */ +export function installMainWindowNavigationGuards( + webContents: NavigationGuardWebContents, + { openExternal }: NavigationGuardOptions, +): void { + webContents.setWindowOpenHandler(({ url }) => { + if (isHttpUrl(url)) openExternal(url) + return { action: 'deny' } + }) +} + +/** + * Preview (WebContentsView) guard. The preview renders untrusted remote pages, + * so it must keep working as a browser: in-page http(s) navigation is allowed. + * Popups are denied (http(s) ones handed to the system browser), and navigation + * to any non-http(s) scheme (file:, custom schemes) is blocked outright. + */ +export function installPreviewNavigationGuards( + webContents: NavigationGuardWebContents, + { openExternal }: NavigationGuardOptions, +): void { + webContents.setWindowOpenHandler(({ url }) => { + if (isHttpUrl(url)) openExternal(url) + return { action: 'deny' } + }) + webContents.on('will-navigate', (event, url) => { + if (!isHttpUrl(url)) event.preventDefault() + }) +}