mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
0d45439fbe
commit
66306a54bf
@ -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)
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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
@ -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)) }
|
||||
|
||||
@ -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'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user