实现游戏运行会话与桥接协议
Project CI / Frontend tests (pull_request) Failing after 25s
Project CI / Repository checks (pull_request) Failing after 54s
Project CI / Backend tests (pull_request) Successful in 3m49s
Project CI / Native shell tests (pull_request) Failing after 8m26s

冻结 Host/Game Bridge 双向消息合同与安全校验
为 PreviewRegistry 增加预览身份并注入宿主 bootstrap
接入 iframe 一次性会话、状态投影和暂停继续控制
补充协议、AppSurface、Rust 生命周期与共享合同测试
同步更新工作台 PRD、技术方案和共享决策记录
This commit is contained in:
2026-08-04 16:16:04 +08:00
parent d1a47c0fe7
commit 203ce5b9e7
14 changed files with 1624 additions and 45 deletions
@@ -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)]
@@ -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<LocalPreviewResult>) {
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<LocalPreviewResult>,
) {
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#"<script src="/.genarrative/game-bridge.v1.js" data-genarrative-game-bridge="v1"></script>"#;
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<u8>) -> Vec<u8> {
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("<head")
.and_then(|start| {
lowercase[start..]
.find('>')
.map(|offset| start + offset + 1)
})
.or_else(|| lowercase.find("</body>"))
.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<PathBuf, String> {
let path = url_path.split('?').next().unwrap_or("/");
let decoded = percent_decode_path(path).ok_or_else(|| "预览路径非法".to_string())?;
@@ -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 = "<!doctype html><html><head><title>Bridge</title></head><body></body></html>";
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();
@@ -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<HTMLIFrameElement>(null);
const activeRef = useRef<ActiveBridgeSession | null>(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<unknown>) {
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 (
<iframe
ref={iframeRef}
className={className}
title={title}
src={embeddedUrl}
sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock"
allow="autoplay; fullscreen; gamepad"
onLoad={handleLoad}
/>
);
}
});
@@ -0,0 +1,467 @@
import type {
GameHostMessage,
GameHostMessageType,
GameRunSession,
GameRuntimeInspectionMetric,
GameRuntimeMessage,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
GAME_HOST_MESSAGE_SCHEMA_VERSION,
GAME_RUN_BRIDGE_MAX_MESSAGE_BYTES,
GAME_RUN_BRIDGE_PROTOCOL_VERSION,
GAME_RUNTIME_MESSAGE_SCHEMA_VERSION,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
export type GameRunBridgeIdentity = {
projectId: string;
versionId: string;
projectRevision: number;
previewId: string;
previewOrigin: string;
};
const RUNTIME_MESSAGE_TYPES = new Set([
'runtime.ready',
'runtime.state',
'runtime.error',
'runtime.slice',
'runtime.inspection',
]);
const RUNTIME_CAPABILITIES = new Set(['state', 'slice', 'inspection']);
const RUNTIME_STATES = new Set([
'starting',
'playing',
'paused',
'completed',
'stopped',
]);
const SLICE_STATES = new Set([
'idle',
'starting',
'playing',
'paused',
'completed',
'failed',
]);
const DANGEROUS_FIELD =
/(?:^|[-_])(path|file|url|href|src|html|script|command|shell|tauri|invoke)(?:$|[-_])/iu;
const HTML_OR_ACTIVE_URL =
/<\/?[a-z][^>]*>|(?:https?|file|javascript|data):|\bhost\.[a-z]/iu;
const WINDOWS_ABSOLUTE_PATH = /^[a-z]:[\\/]/iu;
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function hasExactKeys(
value: Record<string, unknown>,
required: readonly string[],
optional: readonly string[] = [],
) {
const keys = Object.keys(value);
return (
required.every((key) => Object.hasOwn(value, key)) &&
keys.every((key) => required.includes(key) || optional.includes(key))
);
}
function hasForbiddenControlCharacter(value: string, allowLineBreaks: boolean) {
for (const character of value) {
const code = character.charCodeAt(0);
if (
code === 0x7f ||
(code < 0x20 &&
(!allowLineBreaks || (code !== 0x09 && code !== 0x0a && code !== 0x0d)))
) {
return true;
}
}
return false;
}
function isSafeIdentifier(value: unknown, maxChars = 256): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
value.length <= maxChars &&
value.trim() === value &&
!hasForbiddenControlCharacter(value, false)
);
}
function isSafeText(value: unknown, maxChars: number): value is string {
return (
typeof value === 'string' &&
value.length <= maxChars &&
!hasForbiddenControlCharacter(value, true) &&
!HTML_OR_ACTIVE_URL.test(value) &&
!value.startsWith('/') &&
!value.startsWith('\\') &&
!WINDOWS_ABSOLUTE_PATH.test(value)
);
}
function safeSerializedSize(value: unknown) {
try {
const serialized = JSON.stringify(value);
if (serialized === undefined) {
return null;
}
return new TextEncoder().encode(serialized).byteLength;
} catch {
return null;
}
}
function validateReadyPayload(payload: unknown) {
if (
!isPlainObject(payload) ||
!hasExactKeys(payload, ['capabilities']) ||
!Array.isArray(payload.capabilities) ||
payload.capabilities.length > RUNTIME_CAPABILITIES.size
) {
return false;
}
const capabilities = payload.capabilities;
return (
new Set(capabilities).size === capabilities.length &&
capabilities.every(
(capability) =>
typeof capability === 'string' && RUNTIME_CAPABILITIES.has(capability),
)
);
}
function validateStatePayload(payload: unknown) {
return (
isPlainObject(payload) &&
hasExactKeys(payload, ['status'], ['summary']) &&
typeof payload.status === 'string' &&
RUNTIME_STATES.has(payload.status) &&
(payload.summary === undefined || isSafeText(payload.summary, 512))
);
}
function validateErrorPayload(payload: unknown) {
return (
isPlainObject(payload) &&
hasExactKeys(payload, ['code', 'message', 'recoverable']) &&
isSafeIdentifier(payload.code, 96) &&
isSafeText(payload.message, 512) &&
typeof payload.recoverable === 'boolean'
);
}
function validateSlicePayload(payload: unknown) {
return (
isPlainObject(payload) &&
hasExactKeys(payload, ['sliceId', 'title', 'status'], ['summary']) &&
isSafeIdentifier(payload.sliceId, 128) &&
isSafeText(payload.title, 128) &&
typeof payload.status === 'string' &&
SLICE_STATES.has(payload.status) &&
(payload.summary === undefined || isSafeText(payload.summary, 512))
);
}
function isInspectionMetric(
value: unknown,
): value is GameRuntimeInspectionMetric {
return (
value === null ||
typeof value === 'boolean' ||
(typeof value === 'number' && Number.isFinite(value)) ||
isSafeText(value, 256)
);
}
function validateInspectionPayload(payload: unknown) {
if (
!isPlainObject(payload) ||
!hasExactKeys(payload, ['inspectionId', 'label', 'metrics'], ['summary']) ||
!isSafeIdentifier(payload.inspectionId, 128) ||
!isSafeText(payload.label, 128) ||
(payload.summary !== undefined && !isSafeText(payload.summary, 512)) ||
!isPlainObject(payload.metrics)
) {
return false;
}
const metrics = Object.entries(payload.metrics);
return (
metrics.length <= 32 &&
metrics.every(
([key, value]) =>
isSafeIdentifier(key, 64) &&
!DANGEROUS_FIELD.test(key) &&
isInspectionMetric(value),
)
);
}
function validatePayload(type: string, payload: unknown) {
switch (type) {
case 'runtime.ready':
return validateReadyPayload(payload);
case 'runtime.state':
return validateStatePayload(payload);
case 'runtime.error':
return validateErrorPayload(payload);
case 'runtime.slice':
return validateSlicePayload(payload);
case 'runtime.inspection':
return validateInspectionPayload(payload);
default:
return false;
}
}
function normalizePreviewOrigin(value: string) {
try {
const url = new URL(value);
if (
url.protocol !== 'http:' ||
url.hostname !== '127.0.0.1' ||
!url.port ||
url.pathname !== '/' ||
url.search ||
url.hash
) {
return null;
}
return url.origin;
} catch {
return null;
}
}
export function validateGameRunBridgeIdentity(identity: GameRunBridgeIdentity) {
const previewOrigin = normalizePreviewOrigin(identity.previewOrigin);
if (
!isSafeIdentifier(identity.projectId) ||
!isSafeIdentifier(identity.versionId) ||
!isSafeIdentifier(identity.previewId) ||
!Number.isSafeInteger(identity.projectRevision) ||
identity.projectRevision <= 0 ||
previewOrigin === null
) {
return null;
}
return { ...identity, previewOrigin };
}
function randomHex(bytesLength: number) {
if (!globalThis.crypto?.getRandomValues) {
return null;
}
const bytes = new Uint8Array(bytesLength);
globalThis.crypto.getRandomValues(bytes);
return Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join(
'',
);
}
export function newGameRunSessionId() {
const value = randomHex(16);
return value ? `game-run-session-${value}` : null;
}
export function newGameRunBridgeRequestId(direction: 'host' | 'runtime') {
const value = randomHex(16);
return value ? `${direction}-request-${value}` : null;
}
export function createGameRunSession(
identity: GameRunBridgeIdentity,
sessionId: string,
now: number,
): GameRunSession {
return {
schemaVersion: 'game-creator-run-session.v1',
protocolVersion: GAME_RUN_BRIDGE_PROTOCOL_VERSION,
sessionId,
previewId: identity.previewId,
previewOrigin: identity.previewOrigin,
projectId: identity.projectId,
versionId: identity.versionId,
projectRevision: identity.projectRevision,
status: 'starting',
stale: false,
staleReason: null,
startedAt: now,
updatedAt: now,
};
}
export function createGameHostMessage({
identity,
sessionId,
requestId,
sequence,
type,
}: {
identity: GameRunBridgeIdentity;
sessionId: string;
requestId: string;
sequence: number;
type: GameHostMessageType;
}): GameHostMessage {
return {
schemaVersion: GAME_HOST_MESSAGE_SCHEMA_VERSION,
protocolVersion: GAME_RUN_BRIDGE_PROTOCOL_VERSION,
requestId,
sessionId,
previewId: identity.previewId,
projectId: identity.projectId,
sequence,
type,
versionId: identity.versionId,
projectRevision: identity.projectRevision,
};
}
function responseTypeMatches(
runtimeType: string,
hostType: GameHostMessageType,
) {
if (runtimeType === 'runtime.error') {
return true;
}
if (runtimeType === 'runtime.ready') {
return hostType === 'host.start';
}
if (runtimeType === 'runtime.state') {
return [
'host.pause',
'host.resume',
'host.stop',
'host.state.request',
].includes(hostType);
}
return (
runtimeType === 'runtime.inspection' &&
hostType === 'host.inspection.request'
);
}
export class GameRunBridgeRuntimeGuard {
readonly #identity: GameRunBridgeIdentity;
readonly #sessionId: string;
readonly #pendingRequests = new Map<string, GameHostMessageType>();
readonly #runtimeRequestIds = new Set<string>();
#lastRuntimeSequence = 0;
#ready = false;
constructor(identity: GameRunBridgeIdentity, sessionId: string) {
this.#identity = identity;
this.#sessionId = sessionId;
}
registerHostRequest(requestId: string, type: GameHostMessageType) {
if (
!isSafeIdentifier(requestId, 128) ||
this.#pendingRequests.has(requestId)
) {
return false;
}
this.#pendingRequests.set(requestId, type);
return true;
}
accept(data: unknown): GameRuntimeMessage | null {
const size = safeSerializedSize(data);
if (
size === null ||
size > GAME_RUN_BRIDGE_MAX_MESSAGE_BYTES ||
!isPlainObject(data) ||
!hasExactKeys(
data,
[
'schemaVersion',
'protocolVersion',
'requestId',
'sessionId',
'previewId',
'projectId',
'sequence',
'type',
'versionId',
'projectRevision',
'payload',
],
['responseTo'],
)
) {
return null;
}
if (
data.schemaVersion !== GAME_RUNTIME_MESSAGE_SCHEMA_VERSION ||
data.protocolVersion !== GAME_RUN_BRIDGE_PROTOCOL_VERSION ||
data.sessionId !== this.#sessionId ||
data.previewId !== this.#identity.previewId ||
data.projectId !== this.#identity.projectId ||
data.versionId !== this.#identity.versionId ||
data.projectRevision !== this.#identity.projectRevision ||
!isSafeIdentifier(data.requestId, 128) ||
this.#runtimeRequestIds.has(data.requestId) ||
!Number.isSafeInteger(data.sequence) ||
data.sequence !== this.#lastRuntimeSequence + 1 ||
typeof data.type !== 'string' ||
!RUNTIME_MESSAGE_TYPES.has(data.type) ||
!validatePayload(data.type, data.payload)
) {
return null;
}
const responseTo = data.responseTo;
if (responseTo !== undefined) {
if (!isSafeIdentifier(responseTo, 128)) {
return null;
}
const hostType = this.#pendingRequests.get(responseTo);
if (!hostType || !responseTypeMatches(data.type, hostType)) {
return null;
}
} else if (data.type === 'runtime.ready' || !this.#ready) {
return null;
}
if (data.type === 'runtime.ready' && this.#ready) {
return null;
}
this.#lastRuntimeSequence = data.sequence;
this.#runtimeRequestIds.add(data.requestId);
if (responseTo !== undefined) {
this.#pendingRequests.delete(responseTo);
}
if (data.type === 'runtime.ready') {
this.#ready = true;
}
return data as unknown as GameRuntimeMessage;
}
}
export function gameRunSessionFromRuntimeMessage(
session: GameRunSession,
message: GameRuntimeMessage,
now: number,
): GameRunSession {
if (message.type === 'runtime.error') {
return { ...session, status: 'failed', updatedAt: now };
}
if (message.type === 'runtime.ready') {
return { ...session, status: 'playing', updatedAt: now };
}
if (message.type !== 'runtime.state') {
return { ...session, updatedAt: now };
}
const status =
message.payload.status === 'completed'
? 'paused'
: message.payload.status === 'starting'
? 'starting'
: message.payload.status;
return { ...session, status, updatedAt: now };
}
+18 -1
View File
@@ -4524,7 +4524,7 @@ iframe.preview-frame {
.game-run-panels {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
@@ -4602,6 +4602,23 @@ iframe.preview-frame {
font-size: 10px;
}
.game-run-panels button {
justify-self: start;
min-height: 28px;
padding: 0 12px;
border: 1px solid #dfb59f;
border-radius: 999px;
background: #fff8f4;
color: #855c4c;
font-size: 10px;
cursor: pointer;
}
.game-run-panels button:disabled {
cursor: default;
opacity: 0.5;
}
.game-workbench-chat {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
@@ -29,12 +29,15 @@ import type {
GameCreationAppAgentGroup,
GameCreationAppManifest,
GameCreationAppPreviewState,
GameRunSession,
GameRuntimeMessage,
ProjectResourceCanvasLayoutMode,
RunnableGameVersion,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { resolveTauriInvoke } from '../../app/tauri';
import {
LocalGamePreviewFrame,
type LocalGamePreviewFrameHandle,
resolveEmbeddedPreviewUrl,
} from '../../features/project-workspace/LocalGamePreviewFrame';
import { resourceCanvasSectionExtent } from './resourceCanvasLayoutModel';
@@ -159,6 +162,10 @@ type RunnableGameVersionLaunchResult = {
port: number;
root: string;
};
previewIdentity: {
previewId: string;
origin: string;
};
};
const categoryOrder: ResourceCategory[] = [
@@ -445,6 +452,14 @@ export default function ProjectDevelopmentView({
);
const [activeRunnablePreviewVersionId, setActiveRunnablePreviewVersionId] =
useState<string | null>(null);
const [activePreviewIdentity, setActivePreviewIdentity] = useState<{
previewId: string;
origin: string;
} | null>(null);
const [gameRunSession, setGameRunSession] =
useState<GameRunSession | null>(null);
const [latestRuntimeMessage, setLatestRuntimeMessage] =
useState<GameRuntimeMessage | null>(null);
const [imagePreview, setImagePreview] = useState<ImagePreviewState>({
status: 'idle',
resourceId: null,
@@ -459,6 +474,7 @@ export default function ProjectDevelopmentView({
});
const [mediaDuration, setMediaDuration] = useState<number | null>(null);
const resourceCanvasRef = useRef<HTMLDivElement>(null);
const gamePreviewFrameRef = useRef<LocalGamePreviewFrameHandle>(null);
const resourceFocusRef = useRef<HTMLElement>(null);
const resourceListScrollRef = useRef({ left: 0, top: 0 });
const restoreResourceListScrollRef = useRef(false);
@@ -478,6 +494,16 @@ export default function ProjectDevelopmentView({
activeRunnablePreviewVersionId === currentRunnableVersionId
? resolveEmbeddedPreviewUrl(preview)
: null;
const runBridgeIdentity =
embeddedPreviewUrl && currentRunnableVersion && activePreviewIdentity
? {
projectId: manifest.projectId,
versionId: currentRunnableVersion.versionId,
projectRevision: currentRunnableVersion.projectRevision,
previewId: activePreviewIdentity.previewId,
previewOrigin: activePreviewIdentity.origin,
}
: null;
const runAvailable = runnableVersions.length > 0 && currentRunnableVersion !== null;
const projectedResources = useMemo(
() => projectResourcesFromReadModels(manifest, attachments, agentResults),
@@ -772,6 +798,9 @@ export default function ProjectDevelopmentView({
useEffect(() => {
setActiveRunnablePreviewVersionId(null);
setActivePreviewIdentity(null);
setGameRunSession(null);
setLatestRuntimeMessage(null);
}, [manifest.projectId]);
useEffect(() => {
@@ -1001,6 +1030,9 @@ export default function ProjectDevelopmentView({
setRunStatus('正在启动可运行版本');
setSelectedRunnableVersionId(versionId);
setActiveRunnablePreviewVersionId(null);
setActivePreviewIdentity(null);
setGameRunSession(null);
setLatestRuntimeMessage(null);
onPreviewChange?.(null);
try {
const result = await invoke<RunnableGameVersionLaunchResult>(
@@ -1019,12 +1051,16 @@ export default function ProjectDevelopmentView({
});
setSelectedRunnableVersionId(result.version.versionId);
setActiveRunnablePreviewVersionId(result.version.versionId);
setRunStatus(`正在运行版本 ${result.version.versionId}`);
setActivePreviewIdentity(result.previewIdentity ?? null);
setRunStatus(`正在建立版本 ${result.version.versionId} 的运行会话`);
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setSelectedRunnableVersionId(manifest.currentRunnableVersionId ?? '');
setActiveRunnablePreviewVersionId(null);
setActivePreviewIdentity(null);
setGameRunSession(null);
setLatestRuntimeMessage(null);
setRunStatus(message);
onPreviewChange?.({ status: 'failed' });
return false;
@@ -1047,6 +1083,53 @@ export default function ProjectDevelopmentView({
void launchRunnableVersion(currentRunnableVersion.versionId);
}
function showResourcesView() {
gamePreviewFrameRef.current?.stop();
setMode('resources');
}
function handleGameRunSessionChange(session: GameRunSession) {
setGameRunSession(session);
if (session.status === 'starting') {
setLatestRuntimeMessage(null);
}
setRunStatus(
{
starting: '等待游戏运行时就绪',
playing: `正在运行版本 ${session.versionId}`,
paused: `版本 ${session.versionId} 已暂停`,
stopped: '运行会话已停止',
failed: '游戏运行会话失败',
}[session.status],
);
}
function handleGameRuntimeMessage(message: GameRuntimeMessage) {
setLatestRuntimeMessage(message);
if (message.type === 'runtime.error') {
setRunStatus(message.payload.message || '游戏运行时报告错误');
} else if (message.type === 'runtime.slice') {
setRunStatus(`测试切片:${message.payload.title} · ${message.payload.status}`);
} else if (message.type === 'runtime.inspection') {
setRunStatus(`已接收检查结果:${message.payload.label}`);
}
}
function toggleGameRunPause() {
if (gameRunSession?.status === 'paused') {
if (gamePreviewFrameRef.current?.resume()) {
setRunStatus('正在继续运行会话');
}
return;
}
if (
gameRunSession?.status === 'playing' &&
gamePreviewFrameRef.current?.pause()
) {
setRunStatus('正在暂停运行会话');
}
}
return (
<section
className="launcher-page launcher-project-development game-project-workbench"
@@ -1071,7 +1154,7 @@ export default function ProjectDevelopmentView({
role="tab"
aria-selected={mode === 'resources'}
className={mode === 'resources' ? 'is-active' : ''}
onClick={() => setMode('resources')}
onClick={showResourcesView}
>
资源管理
</button>
@@ -1519,8 +1602,13 @@ export default function ProjectDevelopmentView({
<div className="game-run-preview">
{embeddedPreviewUrl ? (
<LocalGamePreviewFrame
ref={gamePreviewFrameRef}
title={`${projectName} 游戏运行画面`}
preview={preview}
runIdentity={runBridgeIdentity}
onSessionChange={handleGameRunSessionChange}
onRuntimeMessage={handleGameRuntimeMessage}
onBridgeError={setRunStatus}
/>
) : (
<div className="game-run-preview-empty">
@@ -1557,6 +1645,40 @@ export default function ProjectDevelopmentView({
<p>当前无可运行版本</p>
)}
</section>
<section aria-label="运行会话信息">
<header>
<Gamepad2 size={16} aria-hidden="true" />
运行会话
</header>
{gameRunSession ? (
<dl>
<div>
<dt>状态</dt>
<dd>{gameRunSession.status}</dd>
</div>
<div>
<dt>协议</dt>
<dd>{gameRunSession.protocolVersion}</dd>
</div>
<div>
<dt>消息</dt>
<dd>{latestRuntimeMessage?.type ?? '等待 ready'}</dd>
</div>
</dl>
) : (
<p>等待 iframe 建立会话</p>
)}
<button
type="button"
disabled={
gameRunSession?.status !== 'playing' &&
gameRunSession?.status !== 'paused'
}
onClick={toggleGameRunPause}
>
{gameRunSession?.status === 'paused' ? '继续' : '暂停'}
</button>
</section>
</div>
{runStatus ? (
<p className="game-run-status" role="status">
@@ -2109,6 +2109,155 @@ export function registerProjectWorkbenchFoundationTests() {
});
});
it('binds P3 runtime messages to the current iframe, origin, preview and reload session', async () => {
const manifest = createGameCreationAppManifest(
'workbench-run-bridge',
'运行桥测试',
);
const version = runnableVersionFixture(
manifest.projectId,
'runnable-r9',
9,
);
manifest.runnableVersions = [version];
manifest.currentRunnableVersionId = version.versionId;
const previewIdentity = {
previewId: 'preview-run-bridge-1',
origin: 'http://127.0.0.1:4199',
};
manifest.preview = {
status: 'running',
url: `${previewIdentity.origin}/`,
port: 4199,
};
const invoke = vi.fn(async () => ({
manifest,
version,
preview: {
url: `${previewIdentity.origin}/`,
port: 4199,
root: '/tmp/workbench-run-bridge',
},
previewIdentity,
}));
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-run-bridge',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
const iframe = (await screen.findByTitle(
'运行桥测试 游戏运行画面',
)) as HTMLIFrameElement;
const frameWindow = iframe.contentWindow;
if (!frameWindow) {
throw new Error('missing iframe contentWindow');
}
const postMessage = vi.spyOn(frameWindow, 'postMessage');
fireEvent.load(iframe);
expect(postMessage).toHaveBeenCalledTimes(1);
const start = postMessage.mock.calls[0]?.[0] as Record<string, unknown>;
expect(start).toMatchObject({
protocolVersion: 'game-creator-run-bridge.v1',
projectId: manifest.projectId,
previewId: previewIdentity.previewId,
sequence: 1,
type: 'host.start',
versionId: version.versionId,
projectRevision: version.projectRevision,
});
expect(postMessage.mock.calls[0]?.[1]).toBe(previewIdentity.origin);
const ready = {
schemaVersion: 'game-creator-runtime-message.v1',
protocolVersion: 'game-creator-run-bridge.v1',
requestId: 'runtime-request-ready',
responseTo: start.requestId,
sessionId: start.sessionId,
previewId: start.previewId,
projectId: start.projectId,
sequence: 1,
type: 'runtime.ready',
versionId: start.versionId,
projectRevision: start.projectRevision,
payload: { capabilities: ['state', 'slice', 'inspection'] },
};
window.dispatchEvent(
new MessageEvent('message', {
data: ready,
origin: 'http://127.0.0.1:4200',
source: frameWindow,
}),
);
expect(screen.getByText('等待游戏运行时就绪')).not.toBeNull();
await act(async () => {
window.dispatchEvent(
new MessageEvent('message', {
data: ready,
origin: previewIdentity.origin,
source: frameWindow,
}),
);
await Promise.resolve();
});
expect(await screen.findByText('playing')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '暂停' }));
const pause = postMessage.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(pause).toMatchObject({ sequence: 2, type: 'host.pause' });
await act(async () => {
window.dispatchEvent(
new MessageEvent('message', {
data: {
...ready,
requestId: 'runtime-request-pause',
responseTo: pause.requestId,
sequence: 2,
type: 'runtime.state',
payload: { status: 'paused' },
},
origin: previewIdentity.origin,
source: frameWindow,
}),
);
await Promise.resolve();
});
expect(await screen.findByText('paused')).not.toBeNull();
fireEvent.load(iframe);
const reloadedStart = postMessage.mock.calls.at(-1)?.[0] as Record<
string,
unknown
>;
expect(reloadedStart).toMatchObject({ sequence: 1, type: 'host.start' });
expect(reloadedStart.sessionId).not.toBe(start.sessionId);
window.dispatchEvent(
new MessageEvent('message', {
data: {
...ready,
requestId: 'runtime-request-old-session',
responseTo: undefined,
sequence: 3,
type: 'runtime.state',
payload: { status: 'playing' },
},
origin: previewIdentity.origin,
source: frameWindow,
}),
);
expect(screen.getByText('starting')).not.toBeNull();
});
it('shows a precise runnable version launch failure without embedding a preview', async () => {
const manifest = createGameCreationAppManifest(
'workbench-version-failure',
@@ -0,0 +1,215 @@
import { describe, expect, it } from 'vitest';
import type { GameRuntimeMessage } from '../../../../packages/shared/src/contracts/gameCreationApp';
import {
createGameHostMessage,
createGameRunSession,
type GameRunBridgeIdentity,
GameRunBridgeRuntimeGuard,
gameRunSessionFromRuntimeMessage,
validateGameRunBridgeIdentity,
} from '../src/features/project-workspace/gameRunBridge';
const identity: GameRunBridgeIdentity = {
projectId: 'project-1',
versionId: 'runnable-r7',
projectRevision: 7,
previewId: 'preview-1',
previewOrigin: 'http://127.0.0.1:4173',
};
function runtimeMessage(
overrides: Partial<GameRuntimeMessage> = {},
): GameRuntimeMessage {
return {
schemaVersion: 'game-creator-runtime-message.v1',
protocolVersion: 'game-creator-run-bridge.v1',
requestId: 'runtime-request-1',
responseTo: 'host-request-1',
sessionId: 'session-1',
previewId: identity.previewId,
projectId: identity.projectId,
sequence: 1,
type: 'runtime.ready',
versionId: identity.versionId,
projectRevision: identity.projectRevision,
payload: { capabilities: ['state', 'slice', 'inspection'] },
...overrides,
} as GameRuntimeMessage;
}
describe('P3 Game Run Bridge', () => {
it('creates a fully bound host request and accepts its exact ready response', () => {
expect(validateGameRunBridgeIdentity(identity)).toEqual(identity);
const host = createGameHostMessage({
identity,
sessionId: 'session-1',
requestId: 'host-request-1',
sequence: 1,
type: 'host.start',
});
expect(host).toMatchObject({
protocolVersion: 'game-creator-run-bridge.v1',
projectId: identity.projectId,
previewId: identity.previewId,
type: 'host.start',
});
const guard = new GameRunBridgeRuntimeGuard(identity, 'session-1');
expect(guard.registerHostRequest(host.requestId, host.type)).toBe(true);
expect(guard.accept(runtimeMessage())).toEqual(runtimeMessage());
expect(
guard.accept(
runtimeMessage({
requestId: 'runtime-request-2',
responseTo: undefined,
sequence: 2,
type: 'runtime.state',
payload: { status: 'playing' },
}),
),
).toMatchObject({ type: 'runtime.state' });
});
it('rejects stale identities, bad ordering and uncorrelated ready messages', () => {
const guard = new GameRunBridgeRuntimeGuard(identity, 'session-1');
guard.registerHostRequest('host-request-1', 'host.start');
expect(
guard.accept(runtimeMessage({ responseTo: 'unknown-request' })),
).toBeNull();
expect(
guard.accept(runtimeMessage({ projectId: 'other-project' })),
).toBeNull();
expect(guard.accept(runtimeMessage({ sequence: 2 }))).toBeNull();
expect(guard.accept(runtimeMessage())).not.toBeNull();
expect(
guard.accept(
runtimeMessage({
requestId: 'runtime-request-2',
responseTo: undefined,
sequence: 2,
previewId: 'old-preview',
type: 'runtime.state',
payload: { status: 'paused' },
}),
),
).toBeNull();
});
it('enforces message and payload whitelists without consuming a rejected sequence', () => {
const guard = new GameRunBridgeRuntimeGuard(identity, 'session-1');
guard.registerHostRequest('host-request-1', 'host.start');
expect(guard.accept(runtimeMessage())).not.toBeNull();
const rejectedPayloads = [
{
type: 'runtime.state',
payload: { status: 'playing', summary: '/tmp/secret' },
},
{
type: 'runtime.state',
payload: { status: 'playing', summary: 'https://untrusted.example' },
},
{
type: 'runtime.state',
payload: { status: 'playing', summary: '<script>run()</script>' },
},
{
type: 'runtime.inspection',
payload: {
inspectionId: 'inspection-1',
label: '运行画面',
metrics: { filePath: '/tmp/secret' },
},
},
{
type: 'runtime.inspection',
payload: {
inspectionId: 'inspection-1',
label: '运行画面',
metrics: { nested: { command: 'host.stop' } },
},
},
] as const;
for (const [index, rejected] of rejectedPayloads.entries()) {
expect(
guard.accept(
runtimeMessage({
requestId: `runtime-request-rejected-${index}`,
responseTo: undefined,
sequence: 2,
...rejected,
} as Partial<GameRuntimeMessage>),
),
).toBeNull();
}
expect(
guard.accept({
...runtimeMessage({
requestId: 'runtime-request-extra-field',
responseTo: undefined,
sequence: 2,
type: 'runtime.state',
payload: { status: 'playing' },
}),
command: 'host.stop',
}),
).toBeNull();
expect(
guard.accept(
runtimeMessage({
requestId: 'runtime-request-2',
responseTo: undefined,
sequence: 2,
type: 'runtime.inspection',
payload: {
inspectionId: 'inspection-1',
label: '运行画面',
metrics: { fps: 60, visible: true },
},
}),
),
).toMatchObject({ type: 'runtime.inspection' });
const oversized = runtimeMessage({
requestId: 'runtime-request-3',
responseTo: undefined,
sequence: 3,
type: 'runtime.state',
payload: { status: 'playing', summary: 'x'.repeat(70 * 1024) },
});
expect(guard.accept(oversized)).toBeNull();
});
it('projects only validated runtime states into the ephemeral session', () => {
const session = createGameRunSession(identity, 'session-1', 100);
expect(session.status).toBe('starting');
const ready = runtimeMessage();
const playing = gameRunSessionFromRuntimeMessage(session, ready, 101);
expect(playing).toMatchObject({ status: 'playing', updatedAt: 101 });
const failed = gameRunSessionFromRuntimeMessage(
playing,
runtimeMessage({
type: 'runtime.error',
payload: { code: 'game-error', message: '运行失败', recoverable: true },
}),
102,
);
expect(failed).toMatchObject({ status: 'failed', updatedAt: 102 });
});
it('accepts only exact 127.0.0.1 origins with explicit ports', () => {
expect(
validateGameRunBridgeIdentity({
...identity,
previewOrigin: 'http://localhost:4173',
}),
).toBeNull();
expect(
validateGameRunBridgeIdentity({
...identity,
previewOrigin: 'http://127.0.0.1:4173/path',
}),
).toBeNull();
});
});
@@ -1,6 +1,6 @@
# AI 游戏创作项目开发工作台 PRD
更新时间:`2026-08-04`(可运行版本 P0/P1)
更新时间:`2026-08-04`(可运行版本 P0/P1,运行桥 P3)
## 1. 产品定位
@@ -348,7 +348,7 @@ type GameCreationAppManifest = {
- 运行入口只读取当前可运行版本。切换版本时先停止当前项目旧 preview、立即卸载 iframe,再复核并启动目标快照;启动成功后回写当前选择和 loopback preview。无版本、版本损坏、版本描述 revision 不一致、权限拒绝或预览启动失败分别显示明确原因。
- 资源管理只从 `runnableVersions` 投影“可运行版本”卡。当前选择版本的全部现存绑定资产始终高亮;点击其它版本可只读查看其父子关系和历史绑定,不改变当前运行选择。
### 5.5 测试切片与数值参数(P2)
### 5.5 运行会话与 Host/Game Bridge(P3)
```ts
type GameTestSlice = {
@@ -374,7 +374,11 @@ type GameTunableParameterDefinition = {
};
type GameRunSession = {
schemaVersion: 'game-creator-run-session.v1';
protocolVersion: 'game-creator-run-bridge.v1';
sessionId: string;
previewId: string;
previewOrigin: `http://127.0.0.1:${number}`;
projectId: string;
versionId: string;
projectRevision: number;
@@ -387,25 +391,51 @@ type GameRunSession = {
type GameHostMessage = {
schemaVersion: 'game-creator-host-message.v1';
protocolVersion: 'game-creator-run-bridge.v1';
requestId: string;
sessionId: string;
previewId: string;
projectId: string;
sequence: number;
type: 'host.start' | 'host.pause' | 'host.resume' | 'host.stop';
type:
| 'host.start'
| 'host.pause'
| 'host.resume'
| 'host.stop'
| 'host.state.request'
| 'host.inspection.request';
versionId: string;
projectRevision: number;
};
type GameRuntimeMessage = {
schemaVersion: 'game-creator-runtime-message.v1';
protocolVersion: 'game-creator-run-bridge.v1';
requestId: string;
responseTo?: string;
sessionId: string;
previewId: string;
projectId: string;
sequence: number;
type: 'runtime.ready' | 'runtime.state' | 'runtime.completed' | 'runtime.failed';
type:
| 'runtime.ready'
| 'runtime.state'
| 'runtime.error'
| 'runtime.slice'
| 'runtime.inspection';
versionId: string;
projectRevision: number;
detail?: Record<string, unknown>;
payload: RuntimeReadyPayload | RuntimeStatePayload | RuntimeErrorPayload
| RuntimeSlicePayload | RuntimeInspectionPayload;
};
```
revision 不一致统一表示编辑工作树已经晚于当前 Session 绑定版本;Session 和切片标记 `stale`,但不热更新、不改写旧版本,也不把 stale 当失败。参数写入立即增加编辑态 revision;当前 preview/slice 保持旧 revision,并显示“需要重新拉起”。P1 尚不实现切片、参数和 Runtime message bridge。
- P3 只在 P1 已启动的正式可运行版本 loopback preview 上建立会话,不接受普通工作树 preview、远程 URL 或其它项目的预览。`PreviewRegistry` 为每次服务启动生成新的 `previewId` 并返回精确 `http://127.0.0.1:<port>` origin;iframe 每次 load 生成新的 `sessionId`,旧 iframe、旧 preview、旧 session 与组件卸载后的消息全部失效。
- 宿主发送 `host.start` 后,游戏必须用 `runtime.ready.responseTo` 关联该 request;其后 `runtime.state / runtime.error / runtime.slice / runtime.inspection` 可以作为受控主动上报,带 `responseTo` 时必须精确命中仍在等待且类型相容的 Host request。Host 与 Runtime 各自的 `sequence` 从 `1` 开始严格连续递增,重复、倒序或跳号消息丢弃。
- 宿主只接受 `event.source === 当前 iframe.contentWindow` 且 `event.origin === PreviewRegistry 返回 origin` 的消息;随后逐项校验 protocol、schema、projectId、versionId、projectRevision、previewId 与 sessionId。iframe 重载、版本切换、preview 替换和会话停止时,先发送当前 session 的 `host.stop`,再清空请求表、序列与 Runtime 投影。
- 单条消息结构化序列化后最多 `64 KiB`,顶层和 payload 都使用字段白名单。`runtime.ready` 只声明 `state / slice / inspection` 能力;`runtime.state` 只上报运行状态和短摘要;`runtime.error` 只上报受限错误码、文本和是否可恢复;`runtime.slice` 只上报切片身份、标题、状态和摘要;`runtime.inspection` 只上报受限 label/summary 与最多 32 个标量 metric。路径、URL、HTML、脚本、宿主命令、嵌套对象与未知字段一律拒绝,游戏消息不能触发 Tauri、文件、命令、Agent 或生成能力。
- 预览服务在响应 HTML 时只注入宿主拥有的同源 `game-creator-run-bridge.v1` bootstrap,不修改不可变版本快照字节或摘要。bootstrap 为旧可运行版本提供 ready/state/pause/resume/stop 基线,并向游戏暴露仅能上报上述五类安全消息的 API;权限和安全结论始终由宿主校验,不信任游戏脚本自报。
- revision 不一致继续表示编辑工作树已经晚于当前 Session 绑定版本;Session 标记 `stale`,但不热更新、不改写旧版本,也不把 stale 当失败。本次不实现 P2 资源替换与下一版本,也不实现测试切片编排、参数写入或数值面板;`runtime.slice` 只是 P3 传输层的安全上报能力。
### 5.6 Agent 泥点归因(P2)
@@ -438,13 +468,16 @@ type ProjectAgentMudPointAttribution = {
- 当前版本绑定资源在 dependency / type 两种资源管理布局中高亮。
- 无版本、损坏、revision 不一致和 preview 启动失败提供独立错误状态。
### P2
### P2(本次跳过)
- 测试切片正式协议与恢复。
- 参数注册表、立即写编辑态和预览重新拉起。
- 泥点归因 read model。
- Agent.md/Skill 安全合同。
- 高风险审批 Rank 与无需审批运行合同。
- 资源替换与下一不可变版本按独立方案后续实现;P3 不得提前写入 `resource-replacement` 版本。
### P3
- 在现有 `PreviewRegistry` 生命周期中增加 preview 身份和精确 loopback origin,不重建预览服务。
- 工作台 iframe 建立一次性 `GameRunSession`,完成 Host/Game 双向桥、状态投影、request 关联和严格失效处理。
- 接入 host-owned 同源 bootstrap,使当前正式可运行版本可以完成 ready/state 基线握手,并为游戏提供受限上报 API。
- 覆盖来源窗口、origin、全身份、协议、消息类型、payload、容量、顺序、重载、切换和停止负向测试。
## 7. 验收
@@ -497,9 +530,18 @@ type ProjectAgentMudPointAttribution = {
4. 根目录全量 Vitest、前后端 typecheck / lint / build、Rust workspace test / check、SpacetimeDB schema、原生壳、内容 / 编码、生产运维与部署门禁全部通过后,阶段七才允许提交。
5. 本地 `.env`、`.env.local`、密钥、缓存、日志和构建产物不进入阶段七提交;提交前再次执行编码检查和 `git diff --check`。
### 7.6 P3 运行会话与桥验收
1. 启动正式可运行版本后,PreviewRegistry 返回新的 previewId 与精确 loopback origin;iframe load 创建新 session,Host 发出的第一条消息固定为 `host.start / sequence=1`。
2. 只有当前 iframe contentWindow、精确 origin、完整身份匹配、schema/protocol 正确、sequence 连续且 request 关联合法的消息能推进会话;旧 iframe、旧端口、旧 preview/session 和其它项目消息均不改变 UI。
3. ready/state/error/slice/inspection 五类消息按各自 payload 白名单通过;未知类型、超过 64 KiB、额外字段、嵌套数据、路径、URL、HTML、脚本或宿主命令内容被拒绝。
4. iframe reload、切换版本、切回资源管理或组件卸载都会停止并失效旧 session;迟到消息不能复活旧状态,新 load 使用新 sessionId 和从 1 重新开始的双向 sequence。
5. 预览响应注入的 bootstrap 不修改快照文件或摘要;旧版本至少完成 ready/playing 基线握手,pause/resume/stop 与 state/inspection request 均按 requestId 回应。
6. 本阶段不出现资源替换、新版本写入、测试切片控件、数值微调面板或任何游戏触发的 Tauri/文件/命令能力。
## 8. 非目标
- 当前收口不实现资源卡手动拖动,也不实现资源聚焦工具栏、资源聚焦工具侧边栏、美术编辑、音频编辑 / 替换、资源重新生成、资源替换、手动创建版本或版本回滚。P1 实现运行版本登记、选择、切换和运行态消费;测试切片、数值参数、Host/Runtime 消息桥与泥点归因仍属于 P2。
- 当前收口不实现资源卡手动拖动,也不实现资源聚焦工具栏、资源聚焦工具侧边栏、美术编辑、音频编辑 / 替换、资源重新生成、资源替换、手动创建版本或版本回滚。P1 实现运行版本登记、选择、切换和运行态消费;P3 只实现运行会话与 Host/Game Bridge,测试切片编排、数值参数和泥点归因仍不在本次范围。
- 本切片不持久化资源聚焦状态、画布缩放 / 平移、搜索条件、筛选条件或当前 mode;聚焦退出时的列表上下文恢复只限当前前端会话,这些状态如需跨重启保存必须另行扩展合同,不能塞入 `game-creator-resource-layout.v1`。
- 不修改 SpacetimeDB schema。
- 不开放普通用户 Agent.md/Skill。
@@ -1,12 +1,22 @@
# 决策记录
## 2026-08-04 工作台 P3 在正式可运行预览上建立一次性 Host/Game Bridge
- 背景:P0/P1 已用不可变快照和专用启动命令建立正式可运行版本,但运行 iframe 仍没有可验证的会话身份、消息顺序、请求关联和 reload 后失效语义;飞书需求同时明确本次跳过 P2 资源替换,只实现 P3 底层运行桥。
- 决策:协议固定为 `game-creator-run-bridge.v1`。每次正式可运行 preview 启动由 `PreviewRegistry` 生成新的 `previewId` 和精确 `http://127.0.0.1:<port>` origin,每次 iframe load 用 Web Crypto 生成新的 `sessionId`;Host/Runtime 消息完整绑定 project/version/revision/preview/session,双向 sequence 从 1 严格连续,ready 必须关联 start,其它响应只能命中仍在等待且类型相容的 request。
- 安全边界:Host 只接受当前 iframe `contentWindow` 和 Registry origin 的消息,再执行 64 KiB、精确字段、类型白名单与各 payload 白名单校验;文本、inspection 标量及数量均有界,并拒绝路径、URL、HTML、脚本、宿主命令和嵌套数据。合法消息只投影前端临时会话状态,不得触发 Tauri、文件、命令、Agent 或生成能力。iframe reload、版本/preview 切换、停止、返回资源管理和卸载都会先 best-effort stop,再失效旧会话。
- 兼容策略:loopback preview 在 HTML 响应中注入宿主拥有的同源 bootstrap,不修改不可变快照文件或摘要;旧游戏至少具备 ready/state/pause/resume/stop 和 state/inspection request 基线。游戏暴露 API 只能上报 ready/state/error/slice/inspection 合同内数据,最终信任判断仍只在 Host 守卫完成。
- 范围:本次不实现 P2 资源替换或下一版本,不实现测试切片编排、数值参数写入、微调面板或泥点归因;`runtime.slice` 仅是 P3 安全传输类型。
- 验证方式:前端纯协议测试覆盖身份、顺序、关联、白名单、危险字段和大小限制;AppSurface 覆盖 iframe/source/origin、暂停响应、reload 新 session 与旧消息拒绝;Rust 覆盖 Registry 身份轮换、bootstrap 注入和快照字节不变,并运行 shell typecheck、编码检查与 `git diff --check`。
- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。
## 2026-08-04 工作台 P0/P1 以不可变快照建立唯一可运行版本事实源
- 背景:2026-08-03 资源管理阶段新增的 `manifest.versions` 只用于只读项目迭代卡,当时明确不承担运行版本切换;本轮飞书需求正式进入运行工作台 P0/P1,不能再以 `code-prototype=completed`、普通 preview、checkpoint 或前端状态推断游戏已经可运行。
- 决策:保留 `manifest.versions` 作为通用迭代历史,新增由可信 Rust / Runtime 独占追加的 `runnableVersions` 与唯一可变选择指针 `currentRunnableVersionId`。每条记录必须绑定 project/revision、父版本、三项通过凭证、固定 entry、资源槽位和不可变 SHA-256;首版为 `initial`,后续为 `agent-revision`,已有记录在 manifest 存储边界禁止修改、删除或重排。
- 产物边界:Runtime 只有在当前 revision 的 `preview-playtest` 传递前置任务闭包、`game.static_smoke`、`preview.validate` 与真实试玩证据全部通过后才自动登记;试玩后的发布节点不阻断登记。`game/` 与可选 `assets/` 被有界复制到 `.agent/runnable-versions/<versionId>/artifact`,版本描述与文件摘要在每次启动前复核。旧版本始终运行该快照,不运行当前工作树;快照已安装但 manifest 写入中断时,重试复用原描述时间与摘要,身份或内容冲突则失败关闭。
- 工作台消费:运行入口只读取 `runnableVersions/currentRunnableVersionId`。切换时先停止旧 preview 并卸载 iframe,复核并启动目标快照,成功后才更新选择指针和 loopback preview;无版本、项目身份不符、快照损坏、revision 不一致和启动失败必须原样展示明确错误。资源管理只投影正式可运行版本,并持续高亮当前版本仍存在的绑定资产。
- P0/P2 边界:同时冻结测试切片、运行会话、数值参数、资源描述及 Host/Runtime 消息的 TypeScript/Rust 合同;数值参数的 `codeMutationAllowed` 固定为 `false`,消息类型固定为 `host.* / runtime.*`。本轮不实现测试切片、数值微调、消息桥、泥点归因或资源替换,这些仍属于 P2。
- 当时的实现边界:同时冻结测试切片、运行会话、数值参数、资源描述及 Host/Runtime 消息的 TypeScript/Rust 合同;数值参数的 `codeMutationAllowed` 固定为 `false`,消息类型固定为 `host.* / runtime.*`。该 P0/P1 变更当时不实现测试切片、数值微调、消息桥、泥点归因或资源替换;其中消息桥随后按本文件 P3 决策实现,资源替换仍属于 P2。
- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。
## 2026-08-03 资源管理阶段七以完整 CI 与可重复界面合同收口
@@ -358,13 +358,13 @@ game-project/
2026-07-18 起,项目开发页按《陶泥儿GameAgent-V1.0 项目开发界面需求》先落一版可交互工作台。该切片替代“正式用户项目页只有聊天与只读 Agent 列表”的旧视觉口径,但不改变 Runtime、manifest、预览和计费事实源。
2026-07-20 起,产品状态机、P0/P1/P2 范围与后续数据合同以 [`【AI游戏创作】项目开发工作台PRD-2026-07-20.md`](../prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md) 为准;本节只保留当前实现边界。
2026-07-20 起,产品状态机、P0/P1/P2/P3 范围与后续数据合同以 [`【AI游戏创作】项目开发工作台PRD-2026-07-20.md`](../prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md) 为准;本节只保留当前实现边界。
- 页面骨架固定为左侧现有全局导航、中间主视窗、右侧陶泥儿对话和底部子 Agent 状态栏;不新建第二套客户端或平行项目页。
- 中间主视窗提供 `资源管理 / 运行` 切换。2026-08-04 起运行入口不再读取 `code-prototype` 任务状态,只读取 manifest 的正式 `runnableVersions + currentRunnableVersionId`;无版本时保持视觉不可用但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled` 或 `aria-disabled`。切回资源管理只修改前端展示态,不伪造后端预览暂停结果。
- 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执和已导入附件派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,任务声明中的未登记音频也不冒充正式音频。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
- 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar;2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源卡拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供工具栏、工具侧边栏或可拖动标题栏。阶段四已补齐安全本地文档、扩展美术媒体与音频聚焦,正文独立滚动,视频 / 音频使用内置媒体控件,失败显示空态;美术编辑、音频编辑 / 替换、版本替换或运行模块仍不在本阶段。
- 运行表现层嵌入当前项目的 loopback 游戏画面。P1 由专用可运行版本命令停止旧 preview、卸载 iframe、复核版本快照并启动目标版本;普通 `preview.start` 继续服务 Agent 当前工作树验证,不能作为工作台运行授权或旧版本切换入口。测试切片、参数调整和 Host/Runtime message bridge 已冻结为正式 P2 合同,不再称为本地 UI 草稿,本轮不接线。
- 运行表现层嵌入当前项目的 loopback 游戏画面。P1 由专用可运行版本命令停止旧 preview、卸载 iframe、复核版本快照并启动目标版本;普通 `preview.start` 继续服务 Agent 当前工作树验证,不能作为工作台运行授权或旧版本切换入口。2026-08-04 起 P3 在该生命周期上接入运行会话与 Host/Game Bridge;P2 资源替换与下一版本继续跳过,测试切片编排和参数调整也不在 P3 内接线。
- 右侧继续复用现有 Project Supervisor 会话、Runtime 澄清和确认链路;输入区展示 `严格审批 / 风险审批 / 无需审批` 独立面板。P0 只有严格审批可选;风险审批和无需审批保持视觉不可用但允许点击查看原因,不替代 Runtime 的逐动作权限、确认、sandbox 或 reconciliation 门禁。风险 Rank 算法记录在 `docs/project-memory/todos/【待解决】AI游戏创作高风险审批Rank-2026-07-20.md`,前端不得自行计算。
- 底部状态栏默认展示策划、美术、程序 3 组,并允许在同一栏展开数值、音频、发布组;状态来自 manifest 与当前 Supervisor run 的 Runtime,悬停显示当前任务与进度。累计泥点必须等待后端计费归因投影;Agent.md 编辑和自定义 Skill 在来源审核、版本、权限、sandbox 与回滚合同完备前不向普通用户开放。
- 当前 run 专业状态与项目历史成果分离:状态继续严格匹配当前 `parentRunId`;已有文本成果从专业 Agent 持久对话中合法的 `agent-finalization-<32 lower hex>` assistant 恢复,并以“历史成果”来源投影到资源管理文档区。新 run 失败、待确认、候选为空或持久对话瞬时读取失败不得清除已恢复的旧成功回执,普通失败 assistant 也不得被当作成果。
@@ -424,6 +424,16 @@ game-project/
- `currentRunnableVersionId` 是唯一可变选择指针。专用 Tauri 命令在项目锁内复核项目身份、版本结构、快照描述、revision 与摘要;切换时先停止当前项目 registry server,再启动目标快照。启动失败不得回退当前工作树或继续展示旧 iframe。
- 普通 Agent `preview.start / preview.validate` 仍对当前编辑工作树执行,Runner registry 与 Tauri 用户可见 registry 的既有隔离不变;工作台运行专用命令只能服务已登记快照。
### 运行会话与 Host/Game Bridge P3
- `PreviewRegistry` 继续是唯一进程内预览所有者,只在当前 `PreviewServer` 上增加 UUID `previewId` 与由监听端口确定的精确 `http://127.0.0.1:<port>` origin。专用可运行版本启动结果返回该身份;普通 preview API 不获得工作台运行授权。
- 前端 `LocalGamePreviewFrame` 只在同时拿到 P1 版本身份和 Registry preview 身份时启用桥。每次 iframe load 使用 Web Crypto 生成新的 128-bit sessionId,建立独立 Host/Runtime sequence、pending request 和已消费 runtime request 集合;reload、unmount、版本/preview key 变化先 best-effort 发送 `host.stop`,再原子失效旧会话。
- wire protocol 固定为 `game-creator-run-bridge.v1`,Host/Runtime 分别使用 `game-creator-host-message.v1` 与 `game-creator-runtime-message.v1`。所有消息携带 request/session/preview/project/version/revision 身份;Host 类型只开放 start/pause/resume/stop/state.request/inspection.request,Runtime 类型只开放 ready/state/error/slice/inspection。
- `message` listener 先检查当前 iframe `contentWindow` 与 Registry origin,再执行 64 KiB、plain object、精确字段、JavaScript 安全正整数、连续 sequence、request/response 类型相容和 payload 专用校验。inspection metrics 只允许最多 32 个标量;全部文本有界并拒绝路径、URL、HTML/脚本和宿主命令痕迹。通过的消息只更新 React 运行会话投影,不调用 Tauri invoke,也不进入 Agent/文件/命令/生成链路。
- loopback server 对 HTML 响应注入固定同源外部 bootstrap 标签,并在保留原文件不变的前提下提供 `/.genarrative/game-bridge.v1.js`。bootstrap 只接受 parent 发来的合法 Host envelope,锁定首个 start 的 origin 与完整身份,基线响应 ready/state 并处理 pause/resume/stop/state/inspection;同时暴露受限 reportState/reportError/reportSlice/reportInspection API,业务游戏不能指定宿主命令、文件路径或 envelope 身份。
- 工作台显示真实 session 状态并提供 pause/resume;ready 之前保持 starting,error 进入 failed,runtime state 只按白名单状态推进。`runtime.slice` 与 `runtime.inspection` 作为安全传输与展示投影存在,不等于已实现 P2 测试切片流程、检查工具或数值编辑。
- 定向门禁覆盖 Rust Registry 身份轮换和 HTML/bootstrap 响应;TypeScript 纯协议覆盖白名单、容量、身份、sequence、request 关联与危险 payload;AppSurface 覆盖真实 iframe source/origin、reload/session 失效、版本切换卸载和 UI 状态。最后运行 shared-contracts、Tauri project/preview、AGC typecheck、定向 Vitest、编码检查与 `git diff --check`。
历史命令式 drag preview 句柄与局部连接索引可以保留,但项目工作台不再向资源卡传入该入口。拖动热路径、4096 张真实卡片拖动重渲染和 Chromium p95 门槛统一暂缓;当前回归只要求 Pointer Move 不改变卡片坐标、SVG path 或布局 revision。`ResizeObserver` 仍保持单图层单实例,任何实时 DOM 几何都不得通过 Tauri IPC 往返 Rust。
## 分阶段实施
@@ -604,7 +604,11 @@ export interface GameTestSlice {
}
export interface GameRunSession {
schemaVersion: 'game-creator-run-session.v1';
protocolVersion: 'game-creator-run-bridge.v1';
sessionId: string;
previewId: string;
previewOrigin: string;
projectId: string;
versionId: string;
projectRevision: number;
@@ -637,25 +641,90 @@ export interface GameResourceDescriptor {
format: string;
}
export const GAME_RUN_BRIDGE_PROTOCOL_VERSION =
'game-creator-run-bridge.v1' as const;
export const GAME_HOST_MESSAGE_SCHEMA_VERSION =
'game-creator-host-message.v1' as const;
export const GAME_RUNTIME_MESSAGE_SCHEMA_VERSION =
'game-creator-runtime-message.v1' as const;
export const GAME_RUN_BRIDGE_MAX_MESSAGE_BYTES = 64 * 1024;
export type GameHostMessageType =
| 'host.start'
| 'host.pause'
| 'host.resume'
| 'host.stop'
| 'host.state.request'
| 'host.inspection.request';
export interface GameHostMessage {
schemaVersion: 'game-creator-host-message.v1';
protocolVersion: 'game-creator-run-bridge.v1';
requestId: string;
sessionId: string;
previewId: string;
projectId: string;
sequence: number;
type: 'host.start' | 'host.pause' | 'host.resume' | 'host.stop';
type: GameHostMessageType;
versionId: string;
projectRevision: number;
}
export interface GameRuntimeMessage {
export type GameRuntimeCapability = 'state' | 'slice' | 'inspection';
export interface GameRuntimeReadyPayload {
capabilities: GameRuntimeCapability[];
}
export interface GameRuntimeStatePayload {
status: 'starting' | 'playing' | 'paused' | 'completed' | 'stopped';
summary?: string;
}
export interface GameRuntimeErrorPayload {
code: string;
message: string;
recoverable: boolean;
}
export interface GameRuntimeSlicePayload {
sliceId: string;
title: string;
status: GameTestSliceStatus;
summary?: string;
}
export type GameRuntimeInspectionMetric = string | number | boolean | null;
export interface GameRuntimeInspectionPayload {
inspectionId: string;
label: string;
summary?: string;
metrics: Record<string, GameRuntimeInspectionMetric>;
}
interface GameRuntimeMessageBase {
schemaVersion: 'game-creator-runtime-message.v1';
protocolVersion: 'game-creator-run-bridge.v1';
requestId: string;
responseTo?: string;
sessionId: string;
previewId: string;
projectId: string;
sequence: number;
type: 'runtime.ready' | 'runtime.state' | 'runtime.completed' | 'runtime.failed';
versionId: string;
projectRevision: number;
detail?: Record<string, unknown>;
}
export type GameRuntimeMessage = GameRuntimeMessageBase &
(
| { type: 'runtime.ready'; payload: GameRuntimeReadyPayload }
| { type: 'runtime.state'; payload: GameRuntimeStatePayload }
| { type: 'runtime.error'; payload: GameRuntimeErrorPayload }
| { type: 'runtime.slice'; payload: GameRuntimeSlicePayload }
| { type: 'runtime.inspection'; payload: GameRuntimeInspectionPayload }
);
export interface GameCreationAppManifest {
schemaVersion: string;
projectId: string;
@@ -721,7 +721,11 @@ pub enum GameRunSessionStaleReason {
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameRunSession {
pub schema_version: String,
pub protocol_version: String,
pub session_id: String,
pub preview_id: String,
pub preview_origin: String,
pub project_id: String,
pub version_id: String,
pub project_revision: u64,
@@ -820,13 +824,21 @@ pub enum GameHostMessageType {
HostResume,
#[serde(rename = "host.stop")]
HostStop,
#[serde(rename = "host.state.request")]
HostStateRequest,
#[serde(rename = "host.inspection.request")]
HostInspectionRequest,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameHostMessage {
pub schema_version: String,
pub protocol_version: String,
pub request_id: String,
pub session_id: String,
pub preview_id: String,
pub project_id: String,
pub sequence: u64,
#[serde(rename = "type")]
pub message_type: GameHostMessageType,
@@ -840,24 +852,31 @@ pub enum GameRuntimeMessageType {
RuntimeReady,
#[serde(rename = "runtime.state")]
RuntimeState,
#[serde(rename = "runtime.completed")]
RuntimeCompleted,
#[serde(rename = "runtime.failed")]
RuntimeFailed,
#[serde(rename = "runtime.error")]
RuntimeError,
#[serde(rename = "runtime.slice")]
RuntimeSlice,
#[serde(rename = "runtime.inspection")]
RuntimeInspection,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameRuntimeMessage {
pub schema_version: String,
pub protocol_version: String,
pub request_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_to: Option<String>,
pub session_id: String,
pub preview_id: String,
pub project_id: String,
pub sequence: u64,
#[serde(rename = "type")]
pub message_type: GameRuntimeMessageType,
pub version_id: String,
pub project_revision: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<serde_json::Value>,
pub payload: serde_json::Value,
}
fn validate_iteration_version_id(value: &str, label: &str, max_chars: usize) -> Result<(), String> {
@@ -2392,10 +2411,14 @@ mod tests {
}
#[test]
fn workbench_p2_message_types_keep_the_frozen_wire_values() {
fn workbench_p3_message_types_keep_the_frozen_wire_values() {
let host = GameHostMessage {
schema_version: "game-creator-host-message.v1".to_string(),
protocol_version: "game-creator-run-bridge.v1".to_string(),
request_id: "host-request-1".to_string(),
session_id: "session-1".to_string(),
preview_id: "preview-1".to_string(),
project_id: "project-1".to_string(),
sequence: 1,
message_type: GameHostMessageType::HostStart,
version_id: "runnable-r7".to_string(),
@@ -2405,7 +2428,11 @@ mod tests {
serde_json::to_value(host).expect("serialize host message"),
json!({
"schemaVersion": "game-creator-host-message.v1",
"protocolVersion": "game-creator-run-bridge.v1",
"requestId": "host-request-1",
"sessionId": "session-1",
"previewId": "preview-1",
"projectId": "project-1",
"sequence": 1,
"type": "host.start",
"versionId": "runnable-r7",
@@ -2415,16 +2442,22 @@ mod tests {
let runtime = GameRuntimeMessage {
schema_version: "game-creator-runtime-message.v1".to_string(),
protocol_version: "game-creator-run-bridge.v1".to_string(),
request_id: "runtime-request-1".to_string(),
response_to: Some("host-request-1".to_string()),
session_id: "session-1".to_string(),
preview_id: "preview-1".to_string(),
project_id: "project-1".to_string(),
sequence: 2,
message_type: GameRuntimeMessageType::RuntimeReady,
version_id: "runnable-r7".to_string(),
project_revision: 7,
detail: None,
payload: json!({ "capabilities": ["state", "slice", "inspection"] }),
};
let value = serde_json::to_value(runtime).expect("serialize runtime message");
assert_eq!(value["type"], "runtime.ready");
assert!(value.get("detail").is_none());
assert_eq!(value["responseTo"], "host-request-1");
assert_eq!(value["payload"]["capabilities"][0], "state");
}
#[test]