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) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-06-03 14:00:19 +08:00
parent 85cf04e305
commit ef87589923
5 changed files with 218 additions and 8 deletions

View File

@ -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)

View File

@ -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)
})
})

View File

@ -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
}

View File

@ -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')
})
})

View File

@ -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()
})
}