fix(desktop): capture preview screenshots natively

Use Electron WebContentsView capturePage for browser preview screenshots and selection annotations so captured images match the rendered preview.

Tested:

- cd desktop && bun run test -- electron/services/preview.test.ts src/lib/previewEvents.test.ts src/components/browser/BrowserSurface.test.tsx src/preview-agent/screenshot.test.ts src/preview-agent/picker.test.ts src/preview-agent/editBubble.test.ts

- cd desktop && bun run lint

- cd desktop && bun run build

- bun run check:desktop

- bun run check:electron

- bun run check:native

Not-tested:

- Real GUI click-through for Screenshot / Select Element; Electron runtime launch was blocked in this shell and packaged build did not enter the smoke path.

Constraint: GUI click-through smoke was blocked by local Electron launch behavior; package smoke passed but does not launch the app.

Confidence: medium

Scope-risk: narrow
This commit is contained in:
程序员阿江(Relakkes) 2026-06-10 22:49:32 +08:00
parent 0d45439fbe
commit 66306a54bf
6 changed files with 193 additions and 21 deletions

View File

@ -252,7 +252,7 @@ async function handleCommandInvoke(payload: unknown): Promise<unknown> {
function registerIpcHandlers() {
ipcMain.on(ELECTRON_INTERNAL_CHANNELS.previewMessageFromView, (event, raw) => {
getPreviewService().sendMessageToRenderer(event.sender, raw, mainWindow?.webContents)
void getPreviewService().sendMessageToRenderer(event.sender, raw, mainWindow?.webContents)
})
registerHandler(ELECTRON_IPC_CHANNELS.appGetVersion, () => app.getVersion())
registerHandler(ELECTRON_IPC_CHANNELS.runtimeGetServerUrl, () => getServerRuntime().getServerUrl())
@ -337,7 +337,7 @@ function registerIpcHandlers() {
registerHandler(ELECTRON_IPC_CHANNELS.previewSetBounds, (_event, payload) => getPreviewService().setBounds(payload as PreviewBounds))
registerHandler(ELECTRON_IPC_CHANNELS.previewSetVisible, (_event, payload) => getPreviewService().setVisible(Boolean(payload)))
registerHandler(ELECTRON_IPC_CHANNELS.previewClose, () => getPreviewService().close())
registerHandler(ELECTRON_IPC_CHANNELS.previewMessage, (_event, payload) => getPreviewService().message(payload))
registerHandler(ELECTRON_IPC_CHANNELS.previewMessage, (event, payload) => getPreviewService().message(payload, event.sender))
registerHandler(ELECTRON_IPC_CHANNELS.appModeGet, () => getAppMode(app))
registerHandler(ELECTRON_IPC_CHANNELS.appModeSet, (_event, payload) => setAppMode(app, payload as Parameters<typeof setAppMode>[1]))
registerHandler(ELECTRON_IPC_CHANNELS.appModeDetectPortableDir, () => detectPortableDir(app) as PortableDetection)

View File

@ -19,6 +19,7 @@ class FakeWebContents implements PreviewWebContentsLike {
scripts: string[] = []
sent: Array<{ channel: string, payload: unknown }> = []
close = vi.fn()
capturePage = vi.fn(async () => ({ toDataURL: () => 'data:image/png;base64,NATIVE' }))
private loadHandler: (() => void) | null = null
async loadURL(url: string) {
@ -176,7 +177,33 @@ describe('Electron preview service', () => {
expect(renderer.sent).toEqual([])
})
it('forwards host messages into the injected preview bridge', async () => {
it('captures screenshots from the native WebContentsView instead of DOM repainting in the page', async () => {
const view = new FakeView()
const renderer = new FakeWebContents()
const service = new ElectronPreviewService({
createView: () => view,
previewScriptPath: previewScript(),
})
await service.open({ contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } }, 'https://example.com', {
x: 0,
y: 0,
width: 100,
height: 100,
})
await service.message({ v: 1, type: 'capture', kind: 'full' }, renderer)
expect(view.webContents.capturePage).toHaveBeenCalledTimes(1)
expect(view.webContents.scripts.at(-1)).toBe('window.__previewInjected = true')
expect(renderer.sent).toEqual([
{
channel: ELECTRON_EVENT_CHANNELS.previewEvent,
payload: { v: 1, type: 'screenshot', dataUrl: 'data:image/png;base64,NATIVE', kind: 'full' },
},
])
})
it('forwards picker host messages into the injected preview bridge', async () => {
const view = new FakeView()
const service = new ElectronPreviewService({
createView: () => view,
@ -189,13 +216,55 @@ describe('Electron preview service', () => {
height: 100,
})
await service.message({ v: 1, type: 'capture', kind: 'viewport' })
await service.message({ v: 1, type: 'enter-picker' })
expect(view.webContents.scripts.at(-1)).toBe(
'globalThis.__PREVIEW_BRIDGE__?.handleHostRaw("{\\"v\\":1,\\"type\\":\\"capture\\",\\"kind\\":\\"viewport\\"}")',
'globalThis.__PREVIEW_BRIDGE__?.handleHostRaw("{\\"v\\":1,\\"type\\":\\"enter-picker\\"}")',
)
})
it('adds a native screenshot to selection events before forwarding them to the renderer', async () => {
const view = new FakeView()
const renderer = new FakeWebContents()
const service = new ElectronPreviewService({
createView: () => view,
previewScriptPath: previewScript(),
})
await service.open({ contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } }, 'https://example.com', {
x: 0,
y: 0,
width: 100,
height: 100,
})
await service.sendMessageToRenderer(view.webContents, JSON.stringify({
v: 1,
type: 'selection',
payload: {
pageUrl: 'https://example.com',
element: { selector: '#todo', tag: 'input', classes: [] },
screenshot: { kind: 'region' },
},
}), renderer)
expect(view.webContents.capturePage).toHaveBeenCalledTimes(1)
expect(view.webContents.scripts.at(-1)).toBe('globalThis.__PREVIEW_AGENT_CLEAR_SELECTION_OVERLAY__?.()')
expect(renderer.sent).toEqual([
{
channel: ELECTRON_EVENT_CHANNELS.previewEvent,
payload: {
v: 1,
type: 'selection',
payload: {
pageUrl: 'https://example.com',
element: { selector: '#todo', tag: 'input', classes: [] },
screenshot: { kind: 'region', dataUrl: 'data:image/png;base64,NATIVE' },
},
},
},
])
})
it('rejects host messages before a preview view exists', async () => {
const service = new ElectronPreviewService({
createView: () => new FakeView(),

View File

@ -1,6 +1,6 @@
import { existsSync, readFileSync } from 'node:fs'
import { ELECTRON_EVENT_CHANNELS } from '../ipc/channels'
import { parsePreviewAgentMessage } from '../ipc/previewMessage'
import { parsePreviewAgentMessage, type PreviewAgentMessage } from '../ipc/previewMessage'
export { parsePreviewAgentMessage, shouldForwardPreviewMessage } from '../ipc/previewMessage'
export type PreviewBounds = {
@ -16,6 +16,7 @@ export type PreviewWebContentsLike = {
on(event: 'did-finish-load', handler: () => void): unknown
close?(): void
isDestroyed?(): boolean
capturePage?(): Promise<{ toDataURL(): string }>
send(channel: string, payload: unknown): void
}
@ -37,6 +38,23 @@ export type ElectronPreviewServiceOptions = {
previewScriptPath: string
}
type PreviewHostCaptureMessage = {
v: 1
type: 'capture'
kind: 'full' | 'viewport' | 'element'
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isHostCaptureMessage(payload: unknown): payload is PreviewHostCaptureMessage {
return isPlainRecord(payload) &&
payload.v === 1 &&
payload.type === 'capture' &&
(payload.kind === 'full' || payload.kind === 'viewport' || payload.kind === 'element')
}
export function normalizePreviewUrl(input: string): string {
const trimmed = input.trim()
if (!trimmed) throw new Error('empty url')
@ -108,18 +126,26 @@ export class ElectronPreviewService {
this.parent = null
}
async message(payload: unknown): Promise<void> {
async message(payload: unknown, renderer?: PreviewWebContentsLike | null): Promise<void> {
if (isHostCaptureMessage(payload) && renderer) {
await this.captureScreenshotToRenderer(payload.kind, renderer)
return
}
const raw = JSON.stringify(payload)
const script = `globalThis.__PREVIEW_BRIDGE__?.handleHostRaw(${JSON.stringify(raw)})`
await this.requireView().webContents.executeJavaScript(script)
}
sendMessageToRenderer(sender: PreviewWebContentsLike, raw: unknown, renderer: PreviewWebContentsLike | null | undefined): void {
async sendMessageToRenderer(sender: PreviewWebContentsLike, raw: unknown, renderer: PreviewWebContentsLike | null | undefined): Promise<void> {
if (sender !== this.view?.webContents) return
if (typeof raw !== 'string') return
const message = parsePreviewAgentMessage(raw)
if (!message) return
renderer?.send(ELECTRON_EVENT_CHANNELS.previewEvent, message)
const event = message.type === 'selection'
? await this.withNativeSelectionScreenshot(message)
: message
renderer?.send(ELECTRON_EVENT_CHANNELS.previewEvent, event)
}
private ensureView(parent: PreviewParentWindowLike): PreviewViewLike {
@ -144,4 +170,60 @@ export class ElectronPreviewService {
const script = readFileSync(resolvePreviewScriptPath(this.previewScriptPath), 'utf8')
await view.webContents.executeJavaScript(script)
}
private async captureNativeDataUrl(): Promise<string> {
const webContents = this.requireView().webContents
if (!webContents.capturePage) throw new Error('native preview capture unavailable')
const image = await webContents.capturePage()
return image.toDataURL()
}
private async captureScreenshotToRenderer(kind: PreviewHostCaptureMessage['kind'], renderer: PreviewWebContentsLike): Promise<void> {
try {
renderer.send(ELECTRON_EVENT_CHANNELS.previewEvent, {
v: 1,
type: 'screenshot',
dataUrl: await this.captureNativeDataUrl(),
kind,
})
} catch (error) {
renderer.send(ELECTRON_EVENT_CHANNELS.previewEvent, {
v: 1,
type: 'error',
message: String(error),
})
}
}
private async withNativeSelectionScreenshot(message: Extract<PreviewAgentMessage, { type: 'selection' }>): Promise<PreviewAgentMessage> {
try {
const payload = message.payload
const screenshot = isPlainRecord(payload.screenshot) ? payload.screenshot : {}
return {
...message,
payload: {
...payload,
screenshot: {
...screenshot,
kind: screenshot.kind ?? 'region',
dataUrl: await this.captureNativeDataUrl(),
},
},
}
} catch {
return message
} finally {
await this.clearSelectionOverlay()
}
}
private async clearSelectionOverlay(): Promise<void> {
const webContents = this.view?.webContents
if (!webContents || webContents.isDestroyed?.()) return
try {
await webContents.executeJavaScript('globalThis.__PREVIEW_AGENT_CLEAR_SELECTION_OVERLAY__?.()')
} catch {
// The page may navigate while the native capture is in flight.
}
}
}

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
import { createBridge } from './bridge'
import { captureToDataUrl, captureAnnotatedRegion } from './screenshot'
import { captureToDataUrl, createAnnotationOverlay } from './screenshot'
import { createPicker } from './picker'
import { buildElementMetadata } from './metadata'
import { createEditBubble } from './editBubble'
@ -7,15 +7,33 @@ import { createEditBubble } from './editBubble'
;(() => {
;(window as unknown as { __PREVIEW_AGENT__?: boolean }).__PREVIEW_AGENT__ = true
const previewWindow = window as unknown as {
__DESKTOP_PREVIEW_POST__?: (raw: string) => void
__PREVIEW_BRIDGE__?: unknown
__PREVIEW_AGENT_CLEAR_SELECTION_OVERLAY__?: () => void
} & Record<string, unknown>
const postToHost = (raw: string) => {
const post = (window as unknown as { __DESKTOP_PREVIEW_POST__?: (raw: string) => void }).__DESKTOP_PREVIEW_POST__
const post = previewWindow.__DESKTOP_PREVIEW_POST__
if (post) post(raw)
// 回退M1 证伪 IPC 时启用new WebSocket('ws://127.0.0.1:'+PORT+'/preview-agent') ...
}
const bridge = createBridge({ postToHost, location: window.location, title: document.title })
;(window as unknown as { __PREVIEW_BRIDGE__?: unknown }).__PREVIEW_BRIDGE__ = bridge
;(window as unknown as Record<string, unknown>).__PREVIEW_AGENT_CAPTURE__ = captureToDataUrl
previewWindow.__PREVIEW_BRIDGE__ = bridge
previewWindow.__PREVIEW_AGENT_CAPTURE__ = captureToDataUrl
let selectionOverlayCleanup: (() => void) | null = null
let selectionOverlayTimer: number | null = null
const clearSelectionOverlay = () => {
if (selectionOverlayTimer !== null) {
window.clearTimeout(selectionOverlayTimer)
selectionOverlayTimer = null
}
selectionOverlayCleanup?.()
selectionOverlayCleanup = null
}
previewWindow.__PREVIEW_AGENT_CLEAR_SELECTION_OVERLAY__ = clearSelectionOverlay
bridge.on('capture', async (m) => {
try { bridge.send({ type: 'screenshot', dataUrl: await captureToDataUrl(m.kind), kind: m.kind }) }
@ -36,7 +54,10 @@ import { createEditBubble } from './editBubble'
const emitSelection = async (el: Element, change?: unknown) => {
try {
const dataUrl = await captureAnnotatedRegion(el)
clearSelectionOverlay()
const overlay = createAnnotationOverlay(el, 1)
selectionOverlayCleanup = () => { overlay.remove() }
selectionOverlayTimer = window.setTimeout(clearSelectionOverlay, 5000)
bridge.send({
type: 'selection',
payload: {
@ -44,7 +65,7 @@ import { createEditBubble } from './editBubble'
sourceHint: document.title || undefined,
element: buildElementMetadata(el),
change,
screenshot: { dataUrl, kind: 'region' },
screenshot: { kind: 'region' },
},
})
} catch (e) { bridge.reportError(String(e)) }

View File

@ -39,7 +39,7 @@ function computeBadgePosition(rect: DOMRect): { left: number; top: number } {
return { left, top: clamp(topAbove, VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, window.innerHeight - BADGE_SIZE - VIEWPORT_MARGIN)) }
}
function createAnnotationOverlay(el: Element, label: number | string): HTMLElement {
export function createAnnotationOverlay(el: Element, label: number | string): HTMLElement {
const rect = el.getBoundingClientRect()
const root = document.createElement('div')
root.dataset.previewSelectionAnnotationRoot = 'true'