diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index f068b3b24..cb2bacc74 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -141,6 +141,13 @@ struct LocalPreviewResult { root: String, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalPreviewIdentity { + preview_id: String, + origin: String, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct LocalPreviewStatus { @@ -156,6 +163,7 @@ struct RunnableGameVersionLaunchResult { manifest: GameCreationAppManifest, version: RunnableGameVersion, preview: LocalPreviewResult, + preview_identity: LocalPreviewIdentity, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index 717a5c3cf..8ed4abde0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -7,6 +7,7 @@ pub(crate) struct PreviewRegistry { struct PreviewServer { preview: LocalPreviewResult, + identity: LocalPreviewIdentity, stop: mpsc::Sender<()>, } @@ -16,6 +17,19 @@ impl PreviewRegistry { preview: LocalPreviewResult, stop: mpsc::Sender<()>, ) -> (LocalPreviewResult, Option) { + let (preview, _, previous_preview) = self.set_running_with_identity(preview, stop); + (preview, previous_preview) + } + + pub(crate) fn set_running_with_identity( + &self, + preview: LocalPreviewResult, + stop: mpsc::Sender<()>, + ) -> ( + LocalPreviewResult, + LocalPreviewIdentity, + Option, + ) { let mut current = self.current.lock().expect("preview registry lock"); let previous_preview = if let Some(previous) = current.take() { let preview = previous.preview; @@ -24,11 +38,21 @@ impl PreviewRegistry { } else { None }; + let identity = LocalPreviewIdentity { + preview_id: format!("preview-{}", uuid::Uuid::new_v4().simple()), + origin: format!("http://127.0.0.1:{}", preview.port), + }; *current = Some(PreviewServer { preview: preview.clone(), + identity: identity.clone(), stop, }); - (preview, previous_preview) + let registry_identity = current + .as_ref() + .expect("preview registry current server") + .identity + .clone(); + (preview, registry_identity, previous_preview) } pub(crate) fn status(&self) -> LocalPreviewStatus { @@ -89,6 +113,125 @@ const PREVIEW_REQUEST_MAX_HEADER_BYTES: usize = 32 * 1024; const PREVIEW_REQUEST_MAX_HEADER_LINES: usize = 100; const PREVIEW_RESPONSE_DRAIN_TIMEOUT: Duration = Duration::from_millis(250); const PREVIEW_RESPONSE_DRAIN_MAX_BYTES: usize = 32 * 1024; +const GAME_RUN_BRIDGE_SCRIPT_PATH: &str = "/.genarrative/game-bridge.v1.js"; +const GAME_RUN_BRIDGE_SCRIPT_TAG: &str = + r#""#; +const GAME_RUN_BRIDGE_BOOTSTRAP: &str = r#"(() => { + 'use strict'; + const protocolVersion = 'game-creator-run-bridge.v1'; + const hostSchema = 'game-creator-host-message.v1'; + const runtimeSchema = 'game-creator-runtime-message.v1'; + const hostTypes = new Set(['host.start', 'host.pause', 'host.resume', 'host.stop', 'host.state.request', 'host.inspection.request']); + let session = null; + let hostOrigin = null; + let hostSequence = 0; + let runtimeSequence = 0; + let state = 'starting'; + const hostRequestIds = new Set(); + + const randomId = () => { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return `runtime-request-${Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('')}`; + }; + const sameIdentity = message => session && + message.sessionId === session.sessionId && + message.previewId === session.previewId && + message.projectId === session.projectId && + message.versionId === session.versionId && + message.projectRevision === session.projectRevision; + const send = (type, payload, responseTo) => { + if (!session || !hostOrigin) return false; + const message = { + schemaVersion: runtimeSchema, + protocolVersion, + requestId: randomId(), + sessionId: session.sessionId, + previewId: session.previewId, + projectId: session.projectId, + sequence: ++runtimeSequence, + type, + versionId: session.versionId, + projectRevision: session.projectRevision, + payload, + }; + if (responseTo) message.responseTo = responseTo; + parent.postMessage(message, hostOrigin); + return true; + }; + const reportState = (nextState, summary) => { + state = nextState; + return send('runtime.state', summary === undefined ? { status: nextState } : { status: nextState, summary }); + }; + const reportError = (code, message, recoverable = false) => + send('runtime.error', { code, message, recoverable }); + const reportSlice = (sliceId, title, status, summary) => + send('runtime.slice', summary === undefined ? { sliceId, title, status } : { sliceId, title, status, summary }); + const reportInspection = (inspectionId, label, metrics, summary) => + send('runtime.inspection', summary === undefined ? { inspectionId, label, metrics } : { inspectionId, label, metrics, summary }); + + Object.defineProperty(window, 'genarrativeGameBridge', { + configurable: false, + enumerable: false, + writable: false, + value: Object.freeze({ protocolVersion, reportState, reportError, reportSlice, reportInspection }), + }); + + addEventListener('message', event => { + const message = event.data; + if (event.source !== parent || !message || typeof message !== 'object' || + message.schemaVersion !== hostSchema || message.protocolVersion !== protocolVersion || + !hostTypes.has(message.type) || !Number.isSafeInteger(message.sequence) || + typeof message.requestId !== 'string' || hostRequestIds.has(message.requestId)) return; + if (message.type === 'host.start') { + if (session || message.sequence !== 1 || typeof message.sessionId !== 'string' || + typeof message.previewId !== 'string' || typeof message.projectId !== 'string' || + typeof message.versionId !== 'string' || !Number.isSafeInteger(message.projectRevision)) return; + hostOrigin = event.origin; + hostSequence = 1; + hostRequestIds.add(message.requestId); + session = { + sessionId: message.sessionId, + previewId: message.previewId, + projectId: message.projectId, + versionId: message.versionId, + projectRevision: message.projectRevision, + }; + send('runtime.ready', { capabilities: ['state', 'slice', 'inspection'] }, message.requestId); + state = 'playing'; + send('runtime.state', { status: state }); + dispatchEvent(new CustomEvent('genarrative:host-message', { detail: message })); + return; + } + if (event.origin !== hostOrigin || !sameIdentity(message) || message.sequence !== hostSequence + 1) return; + hostSequence = message.sequence; + hostRequestIds.add(message.requestId); + if (message.type === 'host.pause') { + state = 'paused'; + send('runtime.state', { status: state }, message.requestId); + } else if (message.type === 'host.resume') { + state = 'playing'; + send('runtime.state', { status: state }, message.requestId); + } else if (message.type === 'host.state.request') { + send('runtime.state', { status: state }, message.requestId); + } else if (message.type === 'host.inspection.request') { + send('runtime.inspection', { + inspectionId: 'host-baseline', + label: '运行画面', + metrics: { state, viewportWidth: innerWidth, viewportHeight: innerHeight }, + }, message.requestId); + } else if (message.type === 'host.stop') { + state = 'stopped'; + send('runtime.state', { status: state }, message.requestId); + } + dispatchEvent(new CustomEvent('genarrative:host-message', { detail: message })); + if (message.type === 'host.stop') { + session = null; + hostOrigin = null; + } + }); +})(); +"#; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum PreviewListenerAcceptDisposition { @@ -500,7 +643,8 @@ pub(crate) fn launch_local_game_runnable_version( let _ = record_preview_state(root, GameCreationAppPreviewStatus::Failed, None, None); return Err(error); } - let (preview, previous_preview) = registry.set_running(preview, stop); + let (preview, preview_identity, previous_preview) = + registry.set_running_with_identity(preview, stop); if let Some(previous_preview) = previous_preview.as_ref() { record_replaced_preview_stop(previous_preview); } @@ -525,6 +669,7 @@ pub(crate) fn launch_local_game_runnable_version( manifest, version: selected_version, preview, + preview_identity, }) } @@ -645,6 +790,21 @@ pub(crate) fn build_preview_response(root: &Path, method: &str, url_path: &str) ); } + if url_path.split('?').next() == Some(GAME_RUN_BRIDGE_SCRIPT_PATH) { + let content_length = GAME_RUN_BRIDGE_BOOTSTRAP.len(); + let body = if is_head { + Vec::new() + } else { + GAME_RUN_BRIDGE_BOOTSTRAP.as_bytes().to_vec() + }; + return http_response( + "200 OK", + "text/javascript; charset=utf-8", + &body, + content_length, + ); + } + let file_path = match resolve_preview_path(root, url_path) { Ok(path) => path, Err(_) => { @@ -659,11 +819,41 @@ pub(crate) fn build_preview_response(root: &Path, method: &str, url_path: &str) return http_response("404 Not Found", "text/plain", body, b"not found".len()); } }; + let body = if file_path.extension().and_then(|value| value.to_str()) == Some("html") { + inject_game_run_bridge_tag(body) + } else { + body + }; let content_length = body.len(); let body = if is_head { Vec::new() } else { body }; http_response("200 OK", content_type(&file_path), &body, content_length) } +fn inject_game_run_bridge_tag(body: Vec) -> Vec { + let html = match String::from_utf8(body) { + Ok(html) => html, + Err(error) => return error.into_bytes(), + }; + if html.contains("data-genarrative-game-bridge=") { + return html.into_bytes(); + } + let lowercase = html.to_ascii_lowercase(); + let insertion_index = lowercase + .find("') + .map(|offset| start + offset + 1) + }) + .or_else(|| lowercase.find("")) + .unwrap_or(html.len()); + let mut injected = String::with_capacity(html.len() + GAME_RUN_BRIDGE_SCRIPT_TAG.len()); + injected.push_str(&html[..insertion_index]); + injected.push_str(GAME_RUN_BRIDGE_SCRIPT_TAG); + injected.push_str(&html[insertion_index..]); + injected.into_bytes() +} + pub(crate) fn resolve_preview_path(root: &Path, url_path: &str) -> Result { let path = url_path.split('?').next().unwrap_or("/"); let decoded = percent_decode_path(path).ok_or_else(|| "预览路径非法".to_string())?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 2f638d6bb..cd5b82ef9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -2414,6 +2414,41 @@ fn preview_registry_reports_status_and_stops_previous_server() { assert_eq!(registry.status().status, "stopped"); } +#[test] +fn preview_registry_rotates_p3_identity_with_the_owned_server() { + let registry = PreviewRegistry::default(); + let (first_stop, first_receiver) = mpsc::channel(); + let (_, first_identity, previous) = registry.set_running_with_identity( + LocalPreviewResult { + url: "http://127.0.0.1:4101/".to_string(), + port: 4101, + root: "/tmp/game-one".to_string(), + }, + first_stop, + ); + assert!(previous.is_none()); + assert_eq!(first_identity.origin, "http://127.0.0.1:4101"); + assert!(first_identity.preview_id.starts_with("preview-")); + + let (second_stop, _) = mpsc::channel(); + let (_, second_identity, previous) = registry.set_running_with_identity( + LocalPreviewResult { + url: "http://127.0.0.1:4102/".to_string(), + port: 4102, + root: "/tmp/game-two".to_string(), + }, + second_stop, + ); + assert!(first_receiver.try_recv().is_ok()); + assert_eq!( + previous.expect("replaced preview").url, + "http://127.0.0.1:4101/" + ); + assert_ne!(first_identity.preview_id, second_identity.preview_id); + assert_eq!(second_identity.origin, "http://127.0.0.1:4102"); + registry.stop(); +} + #[test] fn replaced_preview_records_stopped_state() { let root = unique_project_path(); @@ -3254,6 +3289,36 @@ fn local_preview_can_serve_an_immutable_version_snapshot_instead_of_the_working_ fs::remove_dir_all(root).ok(); } +#[test] +fn local_preview_injects_the_host_owned_p3_bridge_without_mutating_html() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-bridge", "运行桥预览测试").expect("project init"); + let original = "Bridge"; + fs::write(root.join("game/index.html"), original).expect("write bridge fixture"); + + let html_response = + String::from_utf8(build_preview_response(&root, "GET", "/")).expect("html response"); + assert!(html_response.contains("data-genarrative-game-bridge=\"v1\"")); + assert!(html_response.contains("/.genarrative/game-bridge.v1.js")); + assert_eq!( + fs::read_to_string(root.join("game/index.html")).expect("read original html"), + original + ); + + let script_response = String::from_utf8(build_preview_response( + &root, + "GET", + "/.genarrative/game-bridge.v1.js", + )) + .expect("bridge response"); + assert!(script_response.contains("Content-Type: text/javascript; charset=utf-8")); + assert!(script_response.contains("game-creator-run-bridge.v1")); + assert!(script_response.contains("runtime.ready")); + assert!(script_response.contains("event.source !== parent")); + + fs::remove_dir_all(root).ok(); +} + #[test] fn local_preview_server_drains_split_browser_headers_before_response() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx index 401757601..c706366bb 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx @@ -1,10 +1,49 @@ -/* eslint-disable react-refresh/only-export-components -- The URL guard is exported with its small rendering adapter for focused tests. */ +/* eslint-disable react-refresh/only-export-components -- The URL guard is exported with its rendering adapter for focused tests. */ + +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, +} from 'react'; + +import type { + GameHostMessageType, + GameRunSession, + GameRuntimeMessage, +} from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { + createGameHostMessage, + createGameRunSession, + type GameRunBridgeIdentity, + GameRunBridgeRuntimeGuard, + gameRunSessionFromRuntimeMessage, + newGameRunBridgeRequestId, + newGameRunSessionId, + validateGameRunBridgeIdentity, +} from './gameRunBridge'; export type LocalGamePreviewLike = { status?: string | null; url?: string | null; }; +export type LocalGamePreviewFrameHandle = { + pause: () => boolean; + resume: () => boolean; + stop: () => boolean; + requestState: () => boolean; + requestInspection: () => boolean; +}; + +type ActiveBridgeSession = { + guard: GameRunBridgeRuntimeGuard; + identity: GameRunBridgeIdentity; + nextHostSequence: number; + session: GameRunSession; +}; + export function resolveEmbeddedPreviewUrl( preview: LocalGamePreviewLike | null | undefined, ) { @@ -25,26 +64,169 @@ export function resolveEmbeddedPreviewUrl( } } -export function LocalGamePreviewFrame({ - preview, - title, - className, -}: { - preview: LocalGamePreviewLike | null | undefined; - title: string; - className?: string; -}) { +export const LocalGamePreviewFrame = forwardRef< + LocalGamePreviewFrameHandle, + { + preview: LocalGamePreviewLike | null | undefined; + title: string; + className?: string; + runIdentity?: GameRunBridgeIdentity | null; + onSessionChange?: (session: GameRunSession) => void; + onRuntimeMessage?: (message: GameRuntimeMessage) => void; + onBridgeError?: (message: string) => void; + } +>(function LocalGamePreviewFrame( + { + preview, + title, + className, + runIdentity, + onSessionChange, + onRuntimeMessage, + onBridgeError, + }, + forwardedRef, +) { const embeddedUrl = resolveEmbeddedPreviewUrl(preview); + const iframeRef = useRef(null); + const activeRef = useRef(null); + const callbacksRef = useRef({ + onBridgeError, + onRuntimeMessage, + onSessionChange, + }); + callbacksRef.current = { + onBridgeError, + onRuntimeMessage, + onSessionChange, + }; + + const sendHostMessage = useCallback((type: GameHostMessageType) => { + const active = activeRef.current; + const targetWindow = iframeRef.current?.contentWindow; + if (!active || !targetWindow) { + return false; + } + const requestId = newGameRunBridgeRequestId('host'); + if (!requestId || !active.guard.registerHostRequest(requestId, type)) { + callbacksRef.current.onBridgeError?.('运行会话无法创建安全请求身份'); + return false; + } + const message = createGameHostMessage({ + identity: active.identity, + sessionId: active.session.sessionId, + requestId, + sequence: active.nextHostSequence, + type, + }); + active.nextHostSequence += 1; + targetWindow.postMessage(message, active.identity.previewOrigin); + return true; + }, []); + + const stopActiveSession = useCallback( + (notify: boolean) => { + const active = activeRef.current; + if (!active) { + return false; + } + sendHostMessage('host.stop'); + activeRef.current = null; + if (notify) { + callbacksRef.current.onSessionChange?.({ + ...active.session, + status: 'stopped', + updatedAt: Date.now(), + }); + } + return true; + }, + [sendHostMessage], + ); + + useImperativeHandle( + forwardedRef, + () => ({ + pause: () => sendHostMessage('host.pause'), + resume: () => sendHostMessage('host.resume'), + stop: () => stopActiveSession(true), + requestState: () => sendHostMessage('host.state.request'), + requestInspection: () => sendHostMessage('host.inspection.request'), + }), + [sendHostMessage, stopActiveSession], + ); + + useEffect(() => { + function receiveRuntimeMessage(event: MessageEvent) { + const active = activeRef.current; + if ( + !active || + event.source !== iframeRef.current?.contentWindow || + event.origin !== active.identity.previewOrigin + ) { + return; + } + const message = active.guard.accept(event.data); + if (!message) { + return; + } + active.session = gameRunSessionFromRuntimeMessage( + active.session, + message, + Date.now(), + ); + callbacksRef.current.onSessionChange?.(active.session); + callbacksRef.current.onRuntimeMessage?.(message); + } + window.addEventListener('message', receiveRuntimeMessage); + return () => { + window.removeEventListener('message', receiveRuntimeMessage); + stopActiveSession(false); + }; + }, [stopActiveSession]); + + function handleLoad() { + stopActiveSession(true); + if (!runIdentity || !embeddedUrl) { + return; + } + const identity = validateGameRunBridgeIdentity(runIdentity); + if (!identity || new URL(embeddedUrl).origin !== identity.previewOrigin) { + callbacksRef.current.onBridgeError?.( + '运行预览身份与 loopback origin 不一致', + ); + return; + } + const sessionId = newGameRunSessionId(); + if (!sessionId) { + callbacksRef.current.onBridgeError?.('当前 WebView 不支持安全运行会话'); + return; + } + const session = createGameRunSession(identity, sessionId, Date.now()); + activeRef.current = { + guard: new GameRunBridgeRuntimeGuard(identity, sessionId), + identity, + nextHostSequence: 1, + session, + }; + callbacksRef.current.onSessionChange?.(session); + if (!sendHostMessage('host.start')) { + activeRef.current = null; + } + } + if (!embeddedUrl) { return null; } return (