From 36a3ffff34727baff26e1cb038f12af440a2dd9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 29 May 2026 13:04:08 +0800 Subject: [PATCH] feat(desktop): preview-agent bridge with ready/navigated reporting Co-Authored-By: Claude Sonnet 4.6 --- desktop/src-tauri/resources/preview-agent.js | 2 +- desktop/src/preview-agent/bridge.test.ts | 21 +++++++++++++++++++ desktop/src/preview-agent/bridge.ts | 22 ++++++++++++++++++++ desktop/src/preview-agent/index.ts | 18 ++++++++++++++-- 4 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 desktop/src/preview-agent/bridge.test.ts create mode 100644 desktop/src/preview-agent/bridge.ts diff --git a/desktop/src-tauri/resources/preview-agent.js b/desktop/src-tauri/resources/preview-agent.js index 5fa73bcc..82adb00a 100644 --- a/desktop/src-tauri/resources/preview-agent.js +++ b/desktop/src-tauri/resources/preview-agent.js @@ -1 +1 @@ -(()=>{(()=>{window.__PREVIEW_AGENT__=!0})();})(); +(()=>{var g=new Set(["enter-picker","exit-picker","capture"]);function a(s){return JSON.stringify({v:1,...s})}function p(s){try{let e=JSON.parse(s);if(typeof e!=="object"||e===null||e.v!==1||typeof e.type!=="string"||!g.has(e.type))return null;let{v:n,...t}=e;return t}catch{return null}}function i(s){let e=new Map,n=(t)=>s.postToHost(a(t));return{reportReady:()=>n({type:"ready"}),reportNavigated:()=>n({type:"navigated",url:s.location.href,title:s.title}),reportError:(t)=>n({type:"error",message:t}),send:n,on(t,r){let o=e.get(t)??[];o.push(r),e.set(t,o)},handleHostRaw(t){let r=p(t);if(!r)return;for(let o of e.get(r.type)??[])o(r)}}}(()=>{window.__PREVIEW_AGENT__=!0;let e=i({postToHost:(t)=>{let r=window.__TAURI_INTERNALS__;if(r?.invoke)r.invoke("preview_message",{raw:t})},location:window.location,title:document.title});window.__PREVIEW_BRIDGE__=e;let n=()=>{e.reportReady(),e.reportNavigated()};if(document.readyState!=="loading")n();else document.addEventListener("DOMContentLoaded",n);window.addEventListener("popstate",()=>e.reportNavigated())})();})(); diff --git a/desktop/src/preview-agent/bridge.test.ts b/desktop/src/preview-agent/bridge.test.ts new file mode 100644 index 00000000..c60d30e6 --- /dev/null +++ b/desktop/src/preview-agent/bridge.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from 'vitest' +import { createBridge } from './bridge' + +describe('createBridge', () => { + it('reports ready and navigated via postToHost', () => { + const postToHost = vi.fn() + const bridge = createBridge({ postToHost, location: { href: 'http://x/a' } as Location, title: 'T' }) + bridge.reportReady() + bridge.reportNavigated() + expect(JSON.parse(postToHost.mock.calls[0]![0]!)).toMatchObject({ type: 'ready' }) + expect(JSON.parse(postToHost.mock.calls[1]![0]!)).toMatchObject({ type: 'navigated', url: 'http://x/a', title: 'T' }) + }) + + it('dispatches host messages to registered handlers', () => { + const postToHost = vi.fn(); const onEnter = vi.fn() + const bridge = createBridge({ postToHost, location: {} as Location, title: '' }) + bridge.on('enter-picker', onEnter) + bridge.handleHostRaw('{"v":1,"type":"enter-picker"}') + expect(onEnter).toHaveBeenCalled() + }) +}) diff --git a/desktop/src/preview-agent/bridge.ts b/desktop/src/preview-agent/bridge.ts new file mode 100644 index 00000000..5f25e2ad --- /dev/null +++ b/desktop/src/preview-agent/bridge.ts @@ -0,0 +1,22 @@ +import { parseHostMessage, serializeAgentMessage, type AgentMessage, type HostMessage } from './protocol' + +type Deps = { postToHost: (raw: string) => void; location: Location; title: string } + +export function createBridge(deps: Deps) { + const handlers = new Map void>>() + const send = (m: AgentMessage) => deps.postToHost(serializeAgentMessage(m)) + return { + reportReady: () => send({ type: 'ready' }), + reportNavigated: () => send({ type: 'navigated', url: deps.location.href, title: deps.title }), + reportError: (message: string) => send({ type: 'error', message }), + send, + on(type: HostMessage['type'], fn: (m: HostMessage) => void) { + const arr = handlers.get(type) ?? []; arr.push(fn); handlers.set(type, arr) + }, + handleHostRaw(raw: string) { + const msg = parseHostMessage(raw) + if (!msg) return + for (const fn of handlers.get(msg.type) ?? []) fn(msg) + }, + } +} diff --git a/desktop/src/preview-agent/index.ts b/desktop/src/preview-agent/index.ts index 46ed1d3e..899925c5 100644 --- a/desktop/src/preview-agent/index.ts +++ b/desktop/src/preview-agent/index.ts @@ -1,5 +1,19 @@ -// 注入到被预览页面的 IIFE 入口(M3 逐步填实) +import { createBridge } from './bridge' + ;(() => { - // 标记注入成功,供宿主探测 ;(window as unknown as { __PREVIEW_AGENT__?: boolean }).__PREVIEW_AGENT__ = true + + const postToHost = (raw: string) => { + const internals = (window as unknown as { __TAURI_INTERNALS__?: { invoke?: (c: string, a: unknown) => void } }).__TAURI_INTERNALS__ + if (internals?.invoke) internals.invoke('preview_message', { 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 + + const onReady = () => { bridge.reportReady(); bridge.reportNavigated() } + if (document.readyState !== 'loading') onReady() + else document.addEventListener('DOMContentLoaded', onReady) + window.addEventListener('popstate', () => bridge.reportNavigated()) })()