mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
85cf04e305
commit
ef87589923
@ -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)
|
||||
|
||||
28
desktop/electron/services/appIdentity.test.ts
Normal file
28
desktop/electron/services/appIdentity.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
18
desktop/electron/services/appIdentity.ts
Normal file
18
desktop/electron/services/appIdentity.ts
Normal 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
|
||||
}
|
||||
97
desktop/electron/services/navigationGuards.test.ts
Normal file
97
desktop/electron/services/navigationGuards.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
58
desktop/electron/services/navigationGuards.ts
Normal file
58
desktop/electron/services/navigationGuards.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user