程序员阿江(Relakkes) 66306a54bf 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
2026-06-10 22:49:32 +08:00

230 lines
7.3 KiB
TypeScript

import { existsSync, readFileSync } from 'node:fs'
import { ELECTRON_EVENT_CHANNELS } from '../ipc/channels'
import { parsePreviewAgentMessage, type PreviewAgentMessage } from '../ipc/previewMessage'
export { parsePreviewAgentMessage, shouldForwardPreviewMessage } from '../ipc/previewMessage'
export type PreviewBounds = {
x: number
y: number
width: number
height: number
}
export type PreviewWebContentsLike = {
loadURL(url: string): Promise<unknown>
executeJavaScript(script: string): Promise<unknown>
on(event: 'did-finish-load', handler: () => void): unknown
close?(): void
isDestroyed?(): boolean
capturePage?(): Promise<{ toDataURL(): string }>
send(channel: string, payload: unknown): void
}
export type PreviewViewLike = {
webContents: PreviewWebContentsLike
setBounds(bounds: PreviewBounds): void
setVisible?(visible: boolean): void
}
export type PreviewParentWindowLike = {
contentView: {
addChildView(view: unknown): void
removeChildView(view: unknown): void
}
}
export type ElectronPreviewServiceOptions = {
createView: () => PreviewViewLike
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')
const parsed = new URL(trimmed)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`unsupported url scheme: ${trimmed}`)
}
return trimmed
}
export function normalizePreviewBounds(bounds: PreviewBounds): PreviewBounds {
for (const [key, value] of Object.entries(bounds)) {
if (!Number.isFinite(value)) throw new Error(`invalid preview bounds ${key}`)
}
return {
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.max(0, Math.round(bounds.width)),
height: Math.max(0, Math.round(bounds.height)),
}
}
export function resolvePreviewScriptPath(previewScriptPath: string): string {
if (existsSync(previewScriptPath)) return previewScriptPath
const unpackedPath = previewScriptPath.replace(/\.asar([/\\])/, '.asar.unpacked$1')
if (unpackedPath !== previewScriptPath && existsSync(unpackedPath)) return unpackedPath
return previewScriptPath
}
export class ElectronPreviewService {
private readonly createView: () => PreviewViewLike
private readonly previewScriptPath: string
private view: PreviewViewLike | null = null
private parent: PreviewParentWindowLike | null = null
constructor(options: ElectronPreviewServiceOptions) {
this.createView = options.createView
this.previewScriptPath = options.previewScriptPath
}
async open(parent: PreviewParentWindowLike, url: string, bounds: PreviewBounds): Promise<void> {
const normalizedUrl = normalizePreviewUrl(url)
const normalizedBounds = normalizePreviewBounds(bounds)
const view = this.ensureView(parent)
view.setBounds(normalizedBounds)
await view.webContents.loadURL(normalizedUrl)
}
async navigate(url: string): Promise<void> {
const view = this.requireView()
await view.webContents.loadURL(normalizePreviewUrl(url))
}
setBounds(bounds: PreviewBounds): void {
this.view?.setBounds(normalizePreviewBounds(bounds))
}
setVisible(visible: boolean): void {
this.view?.setVisible?.(visible)
}
close(): void {
if (!this.view) return
this.parent?.contentView.removeChildView(this.view)
if (!this.view.webContents.isDestroyed?.()) {
this.view.webContents.close?.()
}
this.view = null
this.parent = null
}
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)
}
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
const event = message.type === 'selection'
? await this.withNativeSelectionScreenshot(message)
: message
renderer?.send(ELECTRON_EVENT_CHANNELS.previewEvent, event)
}
private ensureView(parent: PreviewParentWindowLike): PreviewViewLike {
if (this.view) return this.view
const view = this.createView()
parent.contentView.addChildView(view)
view.webContents.on('did-finish-load', () => {
void this.injectPreviewAgent(view)
})
this.view = view
this.parent = parent
return view
}
private requireView(): PreviewViewLike {
if (!this.view) throw new Error('preview not open')
return this.view
}
private async injectPreviewAgent(view: PreviewViewLike): Promise<void> {
if (view.webContents.isDestroyed?.()) return
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.
}
}
}