feat(desktop): add preview bridge protocol and host<->page commands

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-05-29 13:01:19 +08:00
parent d3e7a35e7a
commit 06461a93e9
4 changed files with 70 additions and 2 deletions

View File

@ -2189,7 +2189,9 @@ pub fn run() {
webview_panel::preview_navigate,
webview_panel::preview_set_bounds,
webview_panel::preview_set_visible,
webview_panel::preview_close
webview_panel::preview_close,
webview_panel::preview_message,
webview_panel::preview_eval
]);
// macOS: native menu bar (traffic-light overlay style)

View File

@ -1,5 +1,5 @@
use std::sync::Mutex;
use tauri::{AppHandle, LogicalPosition, LogicalSize, Manager, Runtime, WebviewBuilder, WebviewUrl};
use tauri::{AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, Runtime, WebviewBuilder, WebviewUrl};
const PREVIEW_LABEL: &str = "preview";
@ -128,6 +128,21 @@ pub async fn preview_close<R: Runtime>(
Ok(())
}
/// 页面 → 宿主:注入脚本经 IPC 调此命令Rust 校验后转发给前端。
#[tauri::command]
pub fn preview_message<R: Runtime>(app: AppHandle<R>, raw: String) -> Result<(), String> {
// 仅转发 JSON 字符串前端做强类型解析schema 校验在 TS 侧 protocol
serde_json::from_str::<serde_json::Value>(&raw).map_err(|e| e.to_string())?;
app.emit("preview://event", raw).map_err(|e| e.to_string())
}
/// 宿主 → 页面:在子 webview 内 eval 一段 JS。
#[tauri::command]
pub async fn preview_eval<R: Runtime>(app: AppHandle<R>, js: String) -> Result<(), String> {
let webview = app.get_webview(PREVIEW_LABEL).ok_or_else(|| "preview not open".to_string())?;
webview.eval(&js).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { parseHostMessage, serializeAgentMessage } from './protocol'
describe('preview-agent protocol', () => {
it('serializes agent→host messages to a stable envelope', () => {
expect(JSON.parse(serializeAgentMessage({ type: 'ready' }))).toEqual({ v: 1, type: 'ready' })
expect(JSON.parse(serializeAgentMessage({ type: 'navigated', url: 'http://x/', title: 'T' })))
.toEqual({ v: 1, type: 'navigated', url: 'http://x/', title: 'T' })
})
it('parses host→agent messages and rejects unknown/garbage', () => {
expect(parseHostMessage('{"v":1,"type":"enter-picker"}')).toEqual({ type: 'enter-picker' })
expect(parseHostMessage('not json')).toBeNull()
expect(parseHostMessage('{"v":1,"type":"nope"}')).toBeNull()
})
})

View File

@ -0,0 +1,36 @@
export type AgentMessage =
| { type: 'ready' }
| { type: 'navigated'; url: string; title: string }
| { type: 'error'; message: string }
| { type: 'selection'; payload: unknown } // M5 填充结构
| { type: 'screenshot'; dataUrl: string; kind: 'full' | 'viewport' | 'element' } // M4
export type HostMessage =
| { type: 'enter-picker' }
| { type: 'exit-picker' }
| { type: 'capture'; kind: 'full' | 'viewport' | 'element' }
const HOST_TYPES = new Set(['enter-picker', 'exit-picker', 'capture'])
export function serializeAgentMessage(msg: AgentMessage): string {
return JSON.stringify({ v: 1, ...msg })
}
export function parseHostMessage(raw: string): HostMessage | null {
try {
const obj = JSON.parse(raw) as unknown
if (
typeof obj !== 'object' ||
obj === null ||
(obj as Record<string, unknown>)['v'] !== 1 ||
typeof (obj as Record<string, unknown>)['type'] !== 'string' ||
!HOST_TYPES.has((obj as Record<string, unknown>)['type'] as string)
) {
return null
}
const { v: _v, ...rest } = obj as Record<string, unknown>
return rest as unknown as HostMessage
} catch {
return null
}
}