cc-haha/desktop/electron/ipc/capabilities.ts
程序员阿江(Relakkes) e64cd80662 fix: add browser preview zoom controls (#952)
Add per-session zoom state, route the zoom factor through the desktop preview bridge, and apply it to the Electron WebContentsView before preview capture so screenshot and annotation coordinates stay on the native preview surface.

Tested:
- cd desktop && bun run test -- src/components/browser/BrowserSurface.test.tsx src/stores/browserPanelStore.test.ts src/components/workbench/WorkbenchPanel.webview.test.tsx src/lib/previewBridge.test.ts src/lib/desktopHost/electronHost.test.ts
- cd desktop && bun test electron/services/preview.test.ts
- cd desktop && bun run lint
- cd desktop && bun run build
- cd desktop && bun run check:electron

Not-tested:
- cd desktop && bun run check:desktop (blocked by existing MessageList relative-time assertion and TabBar AbortSignal errors)

Confidence: medium
Scope-risk: moderate
2026-07-02 20:31:14 +08:00

133 lines
5.6 KiB
TypeScript

import { ELECTRON_IPC_CHANNELS, type ElectronIpcChannel } from './channels'
type Validator = (payload: unknown) => boolean
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
const noPayload: Validator = value => value === undefined
const optionalRecord: Validator = value => value === undefined || isRecord(value)
const stringPayload: Validator = value => typeof value === 'string'
const booleanPayload: Validator = value => typeof value === 'boolean'
const hasOnlyKeys = (value: Record<string, unknown>, allowedKeys: string[]) =>
Object.keys(value).every(key => allowedKeys.includes(key))
const sessionIdPayload: Validator = value =>
typeof value === 'string'
&& value.length > 0
&& value.length <= 200
&& /^[A-Za-z0-9._:-]+$/.test(value)
const commandInvoke: Validator = value =>
isRecord(value)
&& typeof value.command === 'string'
&& value.command.length > 0
&& (value.args === undefined || isRecord(value.args))
const terminalWrite: Validator = value =>
isRecord(value)
&& typeof value.sessionId === 'number'
&& typeof value.data === 'string'
const terminalSpawn: Validator = value =>
value === undefined
|| (
isRecord(value)
&& (value.cols === undefined || typeof value.cols === 'number')
&& (value.rows === undefined || typeof value.rows === 'number')
&& (value.cwd === undefined || typeof value.cwd === 'string')
&& (value.shell === undefined || typeof value.shell === 'string')
)
const terminalResize: Validator = value =>
isRecord(value)
&& typeof value.sessionId === 'number'
&& typeof value.cols === 'number'
&& typeof value.rows === 'number'
const terminalSessionId: Validator = value =>
isRecord(value)
&& typeof value.sessionId === 'number'
const boundsPayload: Validator = value =>
isRecord(value)
&& typeof value.x === 'number'
&& typeof value.y === 'number'
&& typeof value.width === 'number'
&& typeof value.height === 'number'
const urlWithOptionalBounds: Validator = value =>
isRecord(value)
&& typeof value.url === 'string'
&& (value.bounds === undefined || boundsPayload(value.bounds))
const zoomPayload: Validator = value => typeof value === 'number' && Number.isFinite(value)
const updateCheckOptions: Validator = value => {
if (value === undefined) return true
if (!isRecord(value) || !hasOnlyKeys(value, ['proxy'])) return false
return value.proxy === undefined || (typeof value.proxy === 'string' && value.proxy.trim().length > 0)
}
export const ELECTRON_IPC_VALIDATORS = {
[ELECTRON_IPC_CHANNELS.appGetVersion]: noPayload,
[ELECTRON_IPC_CHANNELS.runtimeGetServerUrl]: noPayload,
[ELECTRON_IPC_CHANNELS.commandInvoke]: commandInvoke,
[ELECTRON_IPC_CHANNELS.clipboardReadText]: noPayload,
[ELECTRON_IPC_CHANNELS.clipboardWriteText]: stringPayload,
[ELECTRON_IPC_CHANNELS.shellOpen]: stringPayload,
[ELECTRON_IPC_CHANNELS.shellOpenPath]: stringPayload,
[ELECTRON_IPC_CHANNELS.traceOpenWindow]: sessionIdPayload,
[ELECTRON_IPC_CHANNELS.dialogOpen]: optionalRecord,
[ELECTRON_IPC_CHANNELS.dialogSave]: optionalRecord,
[ELECTRON_IPC_CHANNELS.updateCheck]: updateCheckOptions,
[ELECTRON_IPC_CHANNELS.updateDownload]: noPayload,
[ELECTRON_IPC_CHANNELS.updateInstall]: noPayload,
[ELECTRON_IPC_CHANNELS.updatePrepareInstall]: noPayload,
[ELECTRON_IPC_CHANNELS.updateCancelInstall]: noPayload,
[ELECTRON_IPC_CHANNELS.updateRelaunch]: noPayload,
[ELECTRON_IPC_CHANNELS.notificationPermissionState]: noPayload,
[ELECTRON_IPC_CHANNELS.notificationRequestPermission]: noPayload,
[ELECTRON_IPC_CHANNELS.notificationSend]: optionalRecord,
[ELECTRON_IPC_CHANNELS.notificationActionAck]: optionalRecord,
[ELECTRON_IPC_CHANNELS.windowMinimize]: noPayload,
[ELECTRON_IPC_CHANNELS.windowToggleMaximize]: noPayload,
[ELECTRON_IPC_CHANNELS.windowClose]: noPayload,
[ELECTRON_IPC_CHANNELS.windowStartDragging]: noPayload,
[ELECTRON_IPC_CHANNELS.windowRequestAttention]: noPayload,
[ELECTRON_IPC_CHANNELS.windowFocus]: noPayload,
[ELECTRON_IPC_CHANNELS.windowIsMaximized]: noPayload,
[ELECTRON_IPC_CHANNELS.terminalSpawn]: terminalSpawn,
[ELECTRON_IPC_CHANNELS.terminalWrite]: terminalWrite,
[ELECTRON_IPC_CHANNELS.terminalResize]: terminalResize,
[ELECTRON_IPC_CHANNELS.terminalKill]: terminalSessionId,
[ELECTRON_IPC_CHANNELS.terminalGetBashPath]: noPayload,
[ELECTRON_IPC_CHANNELS.terminalSetBashPath]: value => value === null || stringPayload(value),
[ELECTRON_IPC_CHANNELS.previewOpen]: urlWithOptionalBounds,
[ELECTRON_IPC_CHANNELS.previewNavigate]: stringPayload,
[ELECTRON_IPC_CHANNELS.previewSetBounds]: boundsPayload,
[ELECTRON_IPC_CHANNELS.previewSetVisible]: booleanPayload,
[ELECTRON_IPC_CHANNELS.previewSetZoom]: zoomPayload,
[ELECTRON_IPC_CHANNELS.previewClose]: noPayload,
[ELECTRON_IPC_CHANNELS.previewMessage]: () => true,
[ELECTRON_IPC_CHANNELS.appModeGet]: noPayload,
[ELECTRON_IPC_CHANNELS.appModeSet]: optionalRecord,
[ELECTRON_IPC_CHANNELS.appModeDetectPortableDir]: noPayload,
[ELECTRON_IPC_CHANNELS.appModePrepareRestart]: noPayload,
[ELECTRON_IPC_CHANNELS.appModeRestart]: noPayload,
[ELECTRON_IPC_CHANNELS.adaptersRestartSidecar]: noPayload,
[ELECTRON_IPC_CHANNELS.zoomSet]: zoomPayload,
} satisfies Record<ElectronIpcChannel, Validator>
const allowedChannels = new Set<ElectronIpcChannel>(
Object.values(ELECTRON_IPC_CHANNELS),
)
export function isElectronIpcChannel(channel: string): channel is ElectronIpcChannel {
return allowedChannels.has(channel as ElectronIpcChannel)
}
export function validateElectronIpcPayload(channel: ElectronIpcChannel, payload: unknown): boolean {
return ELECTRON_IPC_VALIDATORS[channel](payload)
}