Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/preview.rs
T
suzmii 7ae71441ba
Project CI / Repository checks (pull_request) Successful in 2m49s
Project CI / Frontend tests (pull_request) Successful in 3m46s
Project CI / Backend tests (pull_request) Successful in 6m42s
Project CI / Native shell tests (pull_request) Successful in 17m35s
合并 master:接入 AGC 内置插件宿主与 Cocos 编辑器能力(#338)
合并 origin/master 4a46f89c9 到 chore/agc-acceptance-build(HEAD 55fab1776),本次合并共涉及 117 个文件(113 个自动合并 + 4 个冲突)。

- 自动合并 113 个文件:插件宿主与插件工作区(plugin_host.rs / builtin_plugins.rs / editor_adapter(_s).rs / plugins/agc-cocos-editor / cocos-editor-bridge crate / editor-adapter-api)、构建与打包脚本、locked Cargo.lock 及 master 侧锁与资源链路改动;不逐条列出。
- 冲突 1 apps/ai-game-creator-shell/src/App.tsx:271-278:两侧各自新增一个 import(本分支 ./services/tauriEventSubscription、master ./services/pluginHost),两个 import 都保留,并按 simple-import-sort 口径排在 ./services/platformSession 之后、tauriEventSubscription 之前;四个符号都仍被使用。
- 冲突 2 docs/project-memory/shared-memory/decision-log.md:8338-8469:两侧在同一位置追加记录,逐字保留两侧记录且各自原有顺序不变(先本分支的 2026-09-11 资源卡预览 data URL / loopback HTTP 两条,后 master 的 2026-09-09~2026-09-12 通用插件宿主、Cocos 桥接与内置插件开关条目),不改写、不合并措辞。
- 冲突 3 docs/project-memory/shared-memory/pitfalls.md:79-112:同上,保留本分支的「2026-09-05 资源画本转场与视口分离」与 master 的「2026-09-11 首页自动工作区必须使用 AGC 管理目录」「2026-09-12 Cocos 项目识别不等于编辑器桥就绪」「2026-09-11 Cocos 项目必须走独立导入分支」,内容逐字保留、两侧原顺序不变。
- 冲突 4 apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs:400-435:判据是「两侧不互斥」,解法为 both(语义超集)。
- 冲突 4 保留本分支的有界退避重试循环(DIRECT_PROJECT_HISTORY_CONTENTION_RETRY_BACKOFF_MS + is_direct_project_history_contention_failure + 测试注入点)与单次尝试函数 append_direct_project_history_item_once(含锁外幂等回扫与锁内状态复核)。
- 冲突 4 保留 master 的有界等待取项目写锁:把该函数内原来的零等待 crate::project::acquire_project_write_lock(root, "conversation.write") 换成 acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, "conversation.write"),并原样保留 master 的注释(agc_write_file 占锁 5.5 秒期间,零等待取锁会让流式历史落盘直接失败)。
- 冲突 4 判断依据:master 只替换了同一处的取锁语义(零等待 → 约 10 秒有界等待,并有 append_waits_for_a_same_process_project_writer 用例锁定),不改变函数签名、调用方与错误类型;HEAD 的重试循环因此仍然可达,与有界等待叠加后语义更强——写文件占锁由有界等待吃掉,等待窗口耗尽后的残余争用再由一次 250ms 短退避重试兜底。未删除任何一侧行为,也未放弃任何一侧语义。
- 冲突 4 位置取舍:有界等待取锁仍放在锁外回扫与序列化之后(本分支结构),保住「幂等回扫与序列化留在锁外、不让他人等整份历史读完」的性能语义;master 的注释与调用参数逐字保留。
- 验证:npm run typecheck、npm --prefix apps/ai-game-creator-shell run typecheck、cargo check --locked --all-targets(exit 0,0 error)、npm run check:rustfmt、npm run check:encoding、git diff --check 全部通过;冲突标记扫描为空。
- 定向 Rust 测试(--test-threads=1):direct_project_history 19 passed / 0 failed(含本分支 contention_failure_is_retried_with_bounded_backoff、contention_failure_beyond_the_backoff_budget_fails_closed、shape_failure_is_not_retried 与 master 的 append_waits_for_a_same_process_project_writer);conversation 36 passed / 0 failed;write_lock 21 passed / 0 failed。两侧用例同时绿,证明有界等待取锁与有界退避重试叠加后语义自洽。
- 未跑全量测试(按仓库约定本地只跑定向,全量交给 CI);本次不 push。
2026-09-13 15:33:48 +08:00

1935 lines
72 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
#[derive(Clone, Default)]
pub(crate) struct PreviewRegistry {
current: Arc<Mutex<Option<PreviewServer>>>,
}
struct PreviewServer {
preview: LocalPreviewResult,
stop: mpsc::Sender<()>,
}
impl PreviewRegistry {
pub(crate) fn set_running(
&self,
preview: LocalPreviewResult,
stop: mpsc::Sender<()>,
) -> (LocalPreviewResult, 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;
let _ = previous.stop.send(());
Some(preview)
} else {
None
};
*current = Some(PreviewServer {
preview: preview.clone(),
stop,
});
(preview, previous_preview)
}
pub(crate) fn status(&self) -> LocalPreviewStatus {
let current = self.current.lock().expect("preview registry lock");
if let Some(server) = current.as_ref() {
local_preview_status_from_result(&server.preview)
} else {
stopped_preview_status()
}
}
pub(crate) fn stop(&self) -> LocalPreviewStatus {
let mut current = self.current.lock().expect("preview registry lock");
if let Some(server) = current.take() {
let _ = server.stop.send(());
}
stopped_preview_status()
}
pub(crate) fn stop_for_project(&self, root: Option<&Path>) -> (LocalPreviewStatus, bool) {
let mut current = self.current.lock().expect("preview registry lock");
let Some(server) = current.as_ref() else {
return (stopped_preview_status(), false);
};
if let Some(root) = root {
let status = local_preview_status_from_result(&server.preview);
if ensure_preview_belongs_to_project(&status, root).is_err() {
return (stopped_preview_status(), false);
}
}
let Some(server) = current.take() else {
return (stopped_preview_status(), false);
};
let _ = server.stop.send(());
(stopped_preview_status(), true)
}
pub(crate) fn stop_if_matches(&self, expected: &LocalPreviewResult) -> bool {
let mut current = self.current.lock().expect("preview registry lock");
if current
.as_ref()
.is_none_or(|server| server.preview != *expected)
{
return false;
}
let Some(server) = current.take() else {
return false;
};
let _ = server.stop.send(());
true
}
}
static GAME_CREATOR_PREVIEW_REGISTRY: OnceLock<PreviewRegistry> = OnceLock::new();
const PREVIEW_REQUEST_READ_TIMEOUT: Duration = Duration::from_secs(2);
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 PREVIEW_FIT_BRIDGE_PATH: &str = "/__genarrative/local-preview-fit.js";
const PREVIEW_FIT_BRIDGE_SCRIPT: &str = r#"(() => {
const messageType = 'genarrative.local-preview-size.v1';
const maxContentProbes = 512;
let ready = document.readyState === 'complete';
let frame = 0;
let lastPublishedSize = '';
const elementLayoutSamples = { width: new WeakMap(), height: new WeakMap() };
const previousRootLayoutSample = {};
const previousBodyLayoutSample = {};
const rootIndependentLayout = { width: false, height: false };
const contentResolutionState = { width: { axis: 'width' }, height: { axis: 'height' } };
const resetLayoutCouplingSamples = (axis) => {
elementLayoutSamples[axis] = new WeakMap();
previousRootLayoutSample[axis] = undefined;
previousBodyLayoutSample[axis] = undefined;
};
const resolveViewportCoupling = (
extent, viewport, previousExtent, previousViewport, previousCoupled,
) => {
if (extent === undefined || previousExtent === undefined || previousViewport === undefined) {
return false;
}
const viewportDelta = viewport - previousViewport;
if (Math.abs(viewportDelta) < 1) {
return Math.abs(extent - previousExtent) < 1 ? previousCoupled : false;
}
const extentDelta = extent - previousExtent;
return Math.abs(extentDelta) >= 1 && extentDelta * viewportDelta > 0;
};
const previewStyleExtent = (value) => {
const extent = Number.parseFloat(value || '');
return Number.isFinite(extent) ? extent : undefined;
};
const previewTypedStyleValue = (element, property) => {
try {
return element?.computedStyleMap?.().get(property)?.toString().trim().toLowerCase();
} catch {
return undefined;
}
};
const previewTypedMinimumCreatesExtent = (value) => {
if (value === undefined || value === 'auto' || value === 'none') return false;
const extent = Number.parseFloat(value);
return Number.isFinite(extent) ? extent > 0 : true;
};
const rootAxisMirrorsBody = (axis, root, body, rootExtent, bodyExtent) => {
if (!root || !body) return false;
const sameExtent = Math.abs(rootExtent - bodyExtent) <= 1;
const sizeProperty = axis === 'width' ? 'width' : 'height';
const minimumProperty = axis === 'width' ? 'min-width' : 'min-height';
const typedSize = previewTypedStyleValue(root, sizeProperty);
const typedMinimum = previewTypedStyleValue(root, minimumProperty);
if (typedSize !== undefined || typedMinimum !== undefined) {
rootIndependentLayout[axis] = (typedSize !== undefined && typedSize !== 'auto')
|| previewTypedMinimumCreatesExtent(typedMinimum);
} else if (!sameExtent) {
rootIndependentLayout[axis] = true;
}
return sameExtent && !rootIndependentLayout[axis];
};
const previewStyleUsesViewport = (value) => (
(value || '').includes('%')
|| /\b(?:v[wh]|vmin|vmax|sv[wh]|lv[wh]|dv[wh]|vi|vb|svi|svb|lvi|lvb|dvi|dvb)\b/i.test(value || '')
);
const previewViewportStyleBaseline = (value, viewport) => {
const normalized = (value || '').trim().toLowerCase();
const amount = Number.parseFloat(normalized);
if (!Number.isFinite(amount) || normalized.includes('calc(')) return undefined;
if (normalized.endsWith('%')) return viewport * amount / 100;
if (/^-?\d*\.?\d+(?:d?v[wh]|s?v[wh]|l?v[wh])$/.test(normalized)) {
return viewport * amount / 100;
}
return undefined;
};
const sampleAxisViewportCoupling = (
edge, sizeStyle, minimumStyle, viewport, previous,
) => {
const size = previewStyleExtent(sizeStyle);
const minimum = previewStyleExtent(minimumStyle);
const sizeUsesViewport = previewStyleUsesViewport(sizeStyle);
const minimumUsesViewport = previewStyleUsesViewport(minimumStyle);
const edgeCoupled = resolveViewportCoupling(
edge, viewport, previous?.edge, previous?.viewport, previous?.edgeCoupled || false,
);
const sizeCoupled = sizeUsesViewport || resolveViewportCoupling(
size, viewport, previous?.size, previous?.viewport,
previous?.sizeUsesViewport && !sizeUsesViewport ? false : previous?.sizeCoupled || false,
);
const minimumCoupled = minimumUsesViewport || resolveViewportCoupling(
minimum, viewport, previous?.minimum, previous?.viewport,
previous?.minimumUsesViewport && !minimumUsesViewport
? false
: previous?.minimumCoupled || false,
);
const edgeBaseline = edgeCoupled
? previous?.edgeBaseline ?? previous?.baseline ?? previous?.edge ?? edge
: undefined;
const sizeBaseline = sizeCoupled
? previous?.sizeBaseline
?? previous?.baseline
?? previous?.size
?? previewViewportStyleBaseline(sizeStyle, viewport)
?? size
: undefined;
const minimumBaseline = minimumCoupled
? previous?.minimumBaseline
?? previous?.baseline
?? previous?.minimum
?? previewViewportStyleBaseline(minimumStyle, viewport)
?? minimum
: undefined;
const baseline = Math.max(
0, edgeBaseline || 0, sizeBaseline || 0, minimumBaseline || 0,
) || undefined;
return {
edge,
size,
minimum,
viewport,
edgeCoupled,
sizeCoupled,
minimumCoupled,
edgeBaseline,
sizeBaseline,
minimumBaseline,
sizeUsesViewport,
minimumUsesViewport,
baseline,
coupled: edgeCoupled || sizeCoupled || minimumCoupled,
};
};
const sampleElementViewportCoupling = (
element, widthExtent, heightExtent, viewportWidth, viewportHeight,
) => {
const style = window.getComputedStyle(element);
const width = sampleAxisViewportCoupling(
widthExtent, style.width, style.minWidth, viewportWidth,
elementLayoutSamples.width.get(element),
);
const height = sampleAxisViewportCoupling(
heightExtent, style.height, style.minHeight, viewportHeight,
elementLayoutSamples.height.get(element),
);
elementLayoutSamples.width.set(element, width);
elementLayoutSamples.height.set(element, height);
return {
width: width.coupled,
height: height.coupled,
widthBaseline: width.baseline,
heightBaseline: height.baseline,
};
};
const sampleDocumentViewportCoupling = (
previousLayoutSample, widthExtent, heightExtent, style, viewportWidth, viewportHeight,
) => {
const width = sampleAxisViewportCoupling(
widthExtent, style?.width, style?.minWidth, viewportWidth,
previousLayoutSample.width,
);
const height = sampleAxisViewportCoupling(
heightExtent, style?.height, style?.minHeight, viewportHeight,
previousLayoutSample.height,
);
previousLayoutSample.width = width;
previousLayoutSample.height = height;
return {
width: width.coupled,
height: height.coupled,
widthBaseline: width.baseline,
heightBaseline: height.baseline,
};
};
const measureContentBounds = (body, viewportWidth, viewportHeight) => {
if (!body) return { width: 0, height: 0, truncated: false };
const walker = document.createTreeWalker(body, NodeFilter.SHOW_ELEMENT);
let width = 0;
let height = 0;
let widthBaseline = 0;
let heightBaseline = 0;
let visited = 0;
let element = walker.nextNode();
while (element && visited < maxContentProbes) {
visited += 1;
const rect = element.getBoundingClientRect();
if (rect.width > 0 || rect.height > 0) {
const widthExtent = rect.right + window.scrollX;
const heightExtent = rect.bottom + window.scrollY;
const coupling = sampleElementViewportCoupling(
element, widthExtent, heightExtent, viewportWidth, viewportHeight,
);
if (!coupling.width) width = Math.max(width, widthExtent);
if (!coupling.height) height = Math.max(height, heightExtent);
widthBaseline = Math.max(widthBaseline, coupling.widthBaseline || 0);
heightBaseline = Math.max(heightBaseline, coupling.heightBaseline || 0);
}
element = walker.nextNode();
}
return {
width: Math.ceil(Math.max(0, width)),
height: Math.ceil(Math.max(0, height)),
widthBaseline: Math.ceil(Math.max(0, widthBaseline)),
heightBaseline: Math.ceil(Math.max(0, heightBaseline)),
truncated: element !== null,
};
};
const resolveIntrinsicExtent = (bodyExtent, bodyCoupled, contentExtent, truncated) => (
truncated || !bodyCoupled
? Math.max(bodyExtent, contentExtent)
: contentExtent
);
const resolveContentExtent = (
state, viewportExtent, scrollExtent, intrinsicExtent, probedExtent, truncated,
coupledBaseline,
) => {
const viewportChanged = state.viewport !== undefined
&& Math.abs(viewportExtent - state.viewport) >= 1;
const hostAppliedPrevious = state.resolved !== undefined
&& Math.abs(viewportExtent - state.resolved) <= 1;
const nativeReset = viewportChanged && !hostAppliedPrevious;
if (nativeReset) {
state.responsive = false;
state.responsiveFloor = undefined;
state.responsiveCandidateCount = 0;
state.responsiveCandidateFloor = undefined;
state.responsiveCandidateDirection = 0;
resetLayoutCouplingSamples(state.axis);
}
const viewportDelta = state.viewport === undefined ? 0 : viewportExtent - state.viewport;
const scrollDelta = state.scroll === undefined ? 0 : scrollExtent - state.scroll;
const responsiveScroll = !nativeReset
&& hostAppliedPrevious
&& Math.abs(viewportDelta) >= 1
&& Math.abs(scrollDelta) >= 1
&& viewportDelta * scrollDelta > 0;
const candidateContradicted = !state.responsive
&& (state.responsiveCandidateCount || 0) > 0
&& (
(viewportChanged && hostAppliedPrevious && !responsiveScroll)
|| (
Math.abs(scrollDelta) >= 1
&& (state.responsiveCandidateDirection || 0) * scrollDelta < 0
)
);
if (responsiveScroll && !state.responsive) {
const candidateFloor = truncated
? Math.max(coupledBaseline || 0, state.resolved || 1)
: coupledBaseline || state.resolved;
if (truncated) {
state.responsive = true;
state.responsiveFloor = candidateFloor;
state.responsiveCandidateCount = 0;
state.responsiveCandidateFloor = undefined;
state.responsiveCandidateDirection = 0;
} else {
if ((state.responsiveCandidateCount || 0) === 0) {
state.responsiveCandidateDirection = Math.sign(viewportDelta);
}
state.responsiveCandidateCount = (state.responsiveCandidateCount || 0) + 1;
state.responsiveCandidateFloor = state.responsiveCandidateFloor || candidateFloor;
if (state.responsiveCandidateCount >= 2) {
state.responsive = true;
state.responsiveFloor = state.responsiveCandidateFloor;
state.responsiveCandidateCount = 0;
state.responsiveCandidateFloor = undefined;
state.responsiveCandidateDirection = 0;
}
}
} else if (candidateContradicted) {
state.responsiveCandidateCount = 0;
state.responsiveCandidateFloor = undefined;
state.responsiveCandidateDirection = 0;
}
const resolved = state.responsive
? Math.max(state.responsiveFloor || 1, truncated ? probedExtent : intrinsicExtent)
: (scrollExtent > viewportExtent ? scrollExtent : intrinsicExtent);
if (state.responsive && truncated) {
state.responsiveFloor = Math.max(state.responsiveFloor || 1, resolved);
}
state.viewport = viewportExtent;
state.scroll = scrollExtent;
state.resolved = resolved;
return resolved;
};
const publish = () => {
frame = 0;
if (!ready || window.parent === window) return;
const root = document.documentElement;
const body = document.body;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const rootRect = root?.getBoundingClientRect();
const rootStyle = root ? window.getComputedStyle(root) : null;
const bodyRect = body?.getBoundingClientRect();
const bodyStyle = body ? window.getComputedStyle(body) : null;
const horizontalMargin = (Number.parseFloat(bodyStyle?.marginLeft || '0') || 0)
+ (Number.parseFloat(bodyStyle?.marginRight || '0') || 0);
const verticalMargin = (Number.parseFloat(bodyStyle?.marginTop || '0') || 0)
+ (Number.parseFloat(bodyStyle?.marginBottom || '0') || 0);
const intrinsicBodyWidth = Math.ceil(Math.max(body?.offsetWidth || 0, bodyRect?.width || 0) + horizontalMargin);
const intrinsicBodyHeight = Math.ceil(Math.max(body?.offsetHeight || 0, bodyRect?.height || 0) + verticalMargin);
const intrinsicRootWidth = Math.ceil(Math.max(
root?.offsetWidth || 0, rootRect?.width || 0, (rootRect?.right || 0) + window.scrollX,
));
const intrinsicRootHeight = Math.ceil(Math.max(
root?.offsetHeight || 0, rootRect?.height || 0, (rootRect?.bottom || 0) + window.scrollY,
));
const rootWidthMirrorsBody = rootAxisMirrorsBody(
'width', root, body, intrinsicRootWidth, intrinsicBodyWidth,
);
const rootHeightMirrorsBody = rootAxisMirrorsBody(
'height', root, body, intrinsicRootHeight, intrinsicBodyHeight,
);
const rootCoupling = sampleDocumentViewportCoupling(
previousRootLayoutSample,
intrinsicRootWidth, intrinsicRootHeight, rootStyle, viewportWidth, viewportHeight,
);
const bodyCoupling = sampleDocumentViewportCoupling(
previousBodyLayoutSample,
intrinsicBodyWidth, intrinsicBodyHeight, bodyStyle, viewportWidth, viewportHeight,
);
const contentBounds = measureContentBounds(body, viewportWidth, viewportHeight);
const coupledWidthBaseline = Math.max(
rootWidthMirrorsBody ? 0 : rootCoupling.widthBaseline || 0,
bodyCoupling.widthBaseline || 0,
contentBounds.widthBaseline || 0,
);
const coupledHeightBaseline = Math.max(
rootHeightMirrorsBody ? 0 : rootCoupling.heightBaseline || 0,
bodyCoupling.heightBaseline || 0,
contentBounds.heightBaseline || 0,
);
const intrinsicBodyContentWidth = resolveIntrinsicExtent(
intrinsicBodyWidth, bodyCoupling.width,
contentBounds.width, contentBounds.truncated,
);
const intrinsicBodyContentHeight = resolveIntrinsicExtent(
intrinsicBodyHeight, bodyCoupling.height,
contentBounds.height, contentBounds.truncated,
);
const intrinsicContentWidth = Math.max(
coupledWidthBaseline,
resolveIntrinsicExtent(
intrinsicRootWidth, rootCoupling.width || rootWidthMirrorsBody,
intrinsicBodyContentWidth, contentBounds.truncated,
),
);
const intrinsicContentHeight = Math.max(
coupledHeightBaseline,
resolveIntrinsicExtent(
intrinsicRootHeight, rootCoupling.height || rootHeightMirrorsBody,
intrinsicBodyContentHeight, contentBounds.truncated,
),
);
const scrollWidth = Math.ceil(Math.max(root?.scrollWidth || 0, body?.scrollWidth || 0));
const scrollHeight = Math.ceil(Math.max(root?.scrollHeight || 0, body?.scrollHeight || 0));
const size = {
contentWidth: Math.max(
1, resolveContentExtent(
contentResolutionState.width, viewportWidth, scrollWidth, intrinsicContentWidth,
contentBounds.width, contentBounds.truncated, coupledWidthBaseline,
),
),
contentHeight: Math.max(
1, resolveContentExtent(
contentResolutionState.height, viewportHeight, scrollHeight, intrinsicContentHeight,
contentBounds.height, contentBounds.truncated, coupledHeightBaseline,
),
),
viewportWidth,
viewportHeight,
};
const sizeKey = `${size.contentWidth}:${size.contentHeight}:${size.viewportWidth}:${size.viewportHeight}`;
if (sizeKey === lastPublishedSize) return;
lastPublishedSize = sizeKey;
window.parent.postMessage({ type: messageType, ...size }, '*');
};
const schedule = () => {
if (frame === 0) frame = window.requestAnimationFrame(publish);
};
const activate = () => {
ready = true;
const style = document.createElement('style');
style.dataset.genarrativePreviewFit = 'true';
style.textContent = 'html,body{scrollbar-width:none}html::-webkit-scrollbar,body::-webkit-scrollbar{display:none}';
(document.head || document.documentElement).appendChild(style);
schedule();
if (typeof ResizeObserver === 'function') {
const resizeObserver = new ResizeObserver(schedule);
resizeObserver.observe(document.documentElement);
if (document.body) resizeObserver.observe(document.body);
}
document.addEventListener('load', schedule, true);
document.fonts?.ready.then(schedule);
window.addEventListener('resize', schedule, { passive: true });
window.setInterval(() => {
if (!document.hidden) schedule();
}, 500);
};
const inspectMessageType = 'genarrative.local-preview-inspect.v1';
const inspectState = { enabled: false, overlay: null, current: null };
const inspectOverlay = () => {
if (inspectState.overlay) return inspectState.overlay;
const overlay = document.createElement('div');
overlay.dataset.genarrativePreviewInspect = 'true';
overlay.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;border:2px solid #f97316;background:rgba(249,115,22,0.14);box-shadow:inset 0 0 0 1px rgba(255,255,255,0.85);';
(document.body || document.documentElement).appendChild(overlay);
inspectState.overlay = overlay;
return overlay;
};
const clearInspectOverlay = () => {
inspectState.overlay?.remove();
inspectState.overlay = null;
inspectState.current = null;
};
const inspectElementSummary = (element) => {
const rect = element.getBoundingClientRect();
const tag = element.tagName ? element.tagName.toLowerCase() : 'div';
const role = (element.getAttribute && element.getAttribute('role')) || '';
const text = (element.innerText || element.textContent || '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120);
const resourceIds = [];
if (element.getAttribute) {
for (const name of ['data-resource-id', 'data-asset-id', 'data-genarrative-resource-id']) {
const value = element.getAttribute(name);
if (value && !resourceIds.includes(value)) resourceIds.push(value);
}
}
const media = element.querySelector ? element.querySelector('img,video,audio') : null;
const rawSource =
(element.getAttribute && element.getAttribute('src')) ||
(media && media.getAttribute && media.getAttribute('src')) ||
'';
let sourcePath = '';
if (rawSource) {
try {
sourcePath = new URL(rawSource, window.location.href).pathname;
} catch {
sourcePath = '';
}
}
return {
label: text || tag,
elementTag: tag,
elementRole: role.slice(0, 80),
text,
width: Math.max(1, Math.round(rect.width)),
height: Math.max(1, Math.round(rect.height)),
resourceIds,
sourcePath,
};
};
const postInspect = (action, selection) => {
window.parent.postMessage({ type: inspectMessageType, action, selection }, '*');
};
const stopInspect = (cancelled) => {
if (!inspectState.enabled) return;
inspectState.enabled = false;
document.removeEventListener('mousemove', handleInspectMove, true);
document.removeEventListener('click', handleInspectClick, true);
document.removeEventListener('keydown', handleInspectKeyDown, true);
clearInspectOverlay();
postInspect(cancelled ? 'cancelled' : 'disabled');
};
const handleInspectMove = (event) => {
if (!inspectState.enabled) return;
const target = event.target;
if (!(target instanceof Element)) return;
inspectState.current = target;
const rect = target.getBoundingClientRect();
const overlay = inspectOverlay();
overlay.style.left = `${rect.left}px`;
overlay.style.top = `${rect.top}px`;
overlay.style.width = `${rect.width}px`;
overlay.style.height = `${rect.height}px`;
};
const handleInspectClick = (event) => {
if (!inspectState.enabled) return;
event.preventDefault();
event.stopPropagation();
const target = event.target;
if (target instanceof Element) postInspect('selected', inspectElementSummary(target));
stopInspect(false);
};
const handleInspectKeyDown = (event) => {
if (!inspectState.enabled || event.key !== 'Escape') return;
event.preventDefault();
stopInspect(true);
};
const startInspect = () => {
if (inspectState.enabled) return;
inspectState.enabled = true;
document.addEventListener('mousemove', handleInspectMove, true);
document.addEventListener('click', handleInspectClick, true);
document.addEventListener('keydown', handleInspectKeyDown, true);
postInspect('enabled');
};
window.addEventListener('message', (event) => {
if (event.source !== window.parent) return;
const data = event.data;
if (!data || data.type !== inspectMessageType) return;
if (data.action === 'enable') startInspect();
else if (data.action === 'disable') stopInspect(false);
});
if (ready) activate();
else window.addEventListener('load', activate, { once: true });
})();"#;
const PREVIEW_FIT_BRIDGE_TAG: &str =
r#"<script src="/__genarrative/local-preview-fit.js"></script>"#;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum PreviewListenerAcceptDisposition {
Sleep,
Retry,
Stop,
}
pub(crate) fn classify_preview_listener_accept_error(
error: &std::io::Error,
) -> PreviewListenerAcceptDisposition {
match error.kind() {
std::io::ErrorKind::WouldBlock => PreviewListenerAcceptDisposition::Sleep,
std::io::ErrorKind::ConnectionAborted
| std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::Interrupted
| std::io::ErrorKind::TimedOut => PreviewListenerAcceptDisposition::Retry,
_ => PreviewListenerAcceptDisposition::Stop,
}
}
pub(crate) fn game_creator_preview_registry() -> PreviewRegistry {
GAME_CREATOR_PREVIEW_REGISTRY
.get_or_init(PreviewRegistry::default)
.clone()
}
pub(crate) fn stopped_preview_status() -> LocalPreviewStatus {
LocalPreviewStatus {
status: "stopped".to_string(),
url: None,
port: None,
root: None,
}
}
fn local_preview_status_from_result(preview: &LocalPreviewResult) -> LocalPreviewStatus {
LocalPreviewStatus {
status: "running".to_string(),
url: Some(preview.url.clone()),
port: Some(preview.port),
root: Some(preview.root.clone()),
}
}
pub(crate) fn preview_open_url(status: &LocalPreviewStatus) -> Result<String, String> {
if status.status == "running" {
if let Some(url) = status.url.as_deref() {
if url.starts_with("http://127.0.0.1:") {
return Ok(url.to_string());
}
}
}
Err("preview is not running".to_string())
}
pub(crate) fn validate_preview_open_project(
status: &LocalPreviewStatus,
project_path: Option<&str>,
) -> Result<(), String> {
let Some(project_path) = project_path.map(str::trim).filter(|path| !path.is_empty()) else {
return Ok(());
};
let root = Path::new(project_path);
enforce_project_permission_policy(root, "preview.open")?;
ensure_preview_belongs_to_project(status, root)
}
pub(crate) fn ensure_preview_belongs_to_project(
status: &LocalPreviewStatus,
root: &Path,
) -> Result<(), String> {
if root.as_os_str().is_empty() {
return Err("项目目录不能为空".to_string());
}
if !root.is_absolute() {
return Err("项目目录必须是绝对路径".to_string());
}
let preview_root = status
.root
.as_deref()
.ok_or_else(|| "preview is not running".to_string())?;
let expected_root = root
.canonicalize()
.map_err(|error| format!("读取项目目录失败:{}: {error}", root.display()))?;
let actual_root = Path::new(preview_root)
.canonicalize()
.map_err(|error| format!("读取预览项目目录失败:{preview_root}: {error}"))?;
if actual_root == expected_root {
Ok(())
} else {
Err("当前预览不属于已授权本地项目".to_string())
}
}
pub(crate) fn filter_preview_status_for_project(
status: LocalPreviewStatus,
project_path: Option<&str>,
) -> LocalPreviewStatus {
let Some(project_path) = project_path.map(str::trim).filter(|path| !path.is_empty()) else {
return status;
};
if ensure_preview_belongs_to_project(&status, Path::new(project_path)).is_err() {
stopped_preview_status()
} else {
status
}
}
#[tauri::command]
pub(crate) fn start_local_game_preview(
project_path: String,
expected_revision: Option<u64>,
registry: tauri::State<'_, PreviewRegistry>,
) -> Result<LocalPreviewResult, String> {
let root = Path::new(project_path.trim());
start_local_game_preview_at_revision(root, expected_revision, &registry)
}
pub(crate) fn start_local_game_preview_at(
root: &Path,
registry: &PreviewRegistry,
) -> Result<LocalPreviewResult, String> {
start_local_game_preview_at_revision(root, None, registry)
}
pub(crate) fn start_local_game_preview_at_revision(
root: &Path,
expected_revision: Option<u64>,
registry: &PreviewRegistry,
) -> Result<LocalPreviewResult, String> {
enforce_project_permission_policy(root, "preview.start")?;
let _lock = acquire_project_write_lock(root, "preview.start")?;
if let Some(expected_revision) = expected_revision {
let current_revision = read_game_creator_agent_runtime_project_revision(root)?.revision;
if current_revision != expected_revision {
return Err(format!(
"本地游戏项目已在验证后发生变化(已验证 revision:{expected_revision},当前 revision:{current_revision})"
));
}
}
let (preview, stop) = start_local_game_preview_for_project(root)?;
if let Err(error) = record_preview_state(
root,
GameCreationAppPreviewStatus::Running,
Some(preview.url.clone()),
Some(preview.port),
) {
let _ = stop.send(());
return Err(error);
}
if let Err(error) = append_preview_log(root, "running", Some(&preview.url)) {
let _ = stop.send(());
let _ = record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None);
return Err(error);
}
let (preview, previous_preview) = registry.set_running(preview, stop);
if let Some(previous_preview) = previous_preview.as_ref() {
// 同一个项目重启预览(换监听线程、换端口)时,旧的 registry 身份与新预览共享
// 同一份落盘记录:上面刚写进去的是 running,这里若再用旧预览收尾,就会把它覆盖
// 成 stopped,下次进项目便丢掉"预览在跑"这条事实。只有旧预览属于**另一个项目**
// 时才替它收尾(那才是真的换项目)。
let previous_status = local_preview_status_from_result(previous_preview);
if ensure_preview_belongs_to_project(&previous_status, root).is_err() {
record_replaced_preview_stop(previous_preview);
}
}
if let Err(error) = append_preview_start_trace_step(root, &preview) {
let _ = registry.stop();
let _ = record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None);
return Err(error);
}
Ok(preview)
}
#[tauri::command]
pub(crate) fn stop_local_game_preview(
project_path: Option<String>,
registry: tauri::State<'_, PreviewRegistry>,
) -> Result<LocalPreviewStatus, String> {
let project_path = project_path
.as_deref()
.map(str::trim)
.filter(|path| !path.is_empty());
let root = project_path.map(Path::new);
let _lock = if let Some(root) = root {
enforce_project_permission_policy(root, "preview.stop")?;
Some(acquire_project_write_lock(root, "preview.stop")?)
} else {
None
};
stop_local_game_preview_for_root(root, &registry)
}
pub(crate) fn stop_local_game_preview_for_root(
root: Option<&Path>,
registry: &PreviewRegistry,
) -> Result<LocalPreviewStatus, String> {
let (status, stopped) = registry.stop_for_project(root);
let Some(root) = root else {
return Ok(status);
};
// 没有活体预览可停时,落盘记录仍可能是陈旧的 running:预览服务器是进程内线程、
// 非 Linux 端口还是随机临时端口,客户端退出后记录会一直写着 running。这时把记录
// 对齐成 stopped 才算真的"停"——否则下次进项目会照着陈旧记录渲染打不开的运行
// 界面(进入项目时的活体核对是第二道保险,记录本身也该在自己的生命周期里结束)。
let stale_record = !stopped
&& read_manifest_for_project(root)?
.preview
.is_some_and(|preview| preview.status == GameCreationAppPreviewStatus::Running);
if !stopped && !stale_record {
return Ok(status);
}
record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None)?;
append_preview_log(root, "stopped", None)?;
append_preview_stop_trace_step(root)?;
Ok(status)
}
/// 退出路径:把当前预览真正停掉,并把落盘记录对齐成 stopped。
///
/// 客户端退出是"记录与真相分叉"的主要来源(进程内监听线程随进程消失,记录却留着
/// running),所以退出时统一收尾。进入项目时的活体核对仍然保留:进程被强杀、断电这类
/// 不走退出路径的情况,靠那道核对自愈。
pub(crate) fn stop_local_game_preview_on_exit(registry: &PreviewRegistry) -> Result<(), String> {
let status = registry.status();
if status.status != "running" {
return Ok(());
}
let Some(root) = status.root.as_deref() else {
// running 却没有 root 不该出现:只把 listener 停掉,不猜记录该写到哪个项目。
registry.stop();
return Ok(());
};
stop_local_game_preview_for_root(Some(Path::new(root)), registry).map(|_| ())
}
#[tauri::command]
pub(crate) fn stop_local_game_preview_if_matches(
project_path: String,
expected_preview: LocalPreviewResult,
registry: tauri::State<'_, PreviewRegistry>,
) -> Result<bool, String> {
stop_local_game_preview_if_matches_at(
Path::new(project_path.trim()),
&expected_preview,
&registry,
)
}
pub(crate) fn stop_local_game_preview_if_matches_at(
root: &Path,
expected_preview: &LocalPreviewResult,
registry: &PreviewRegistry,
) -> Result<bool, String> {
let expected_status = local_preview_status_from_result(expected_preview);
ensure_preview_belongs_to_project(&expected_status, root)?;
if !registry.stop_if_matches(expected_preview) {
return Ok(false);
}
// This command is a compensating cleanup for a preview that became stale while an
// asynchronous start was in flight. Stop the exact registry identity before waiting
// for project persistence so a denied stop policy or a busy project lock cannot leak
// the loopback server. A newer preview for the same project owns the durable state.
let _lock = acquire_project_write_lock(root, "preview.stop")?;
let current_status = registry.status();
if current_status.status == "running"
&& ensure_preview_belongs_to_project(&current_status, root).is_ok()
{
return Ok(true);
}
record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None)?;
append_preview_log(root, "stopped", None)?;
append_preview_stop_trace_step(root)?;
Ok(true)
}
#[tauri::command]
pub(crate) fn get_local_game_preview_status(
registry: tauri::State<'_, PreviewRegistry>,
project_path: Option<String>,
) -> Result<LocalPreviewStatus, String> {
get_local_game_preview_status_at(&registry, project_path.as_deref())
}
pub(crate) fn get_local_game_preview_status_at(
registry: &PreviewRegistry,
project_path: Option<&str>,
) -> Result<LocalPreviewStatus, String> {
let project_path = project_path.map(str::trim).filter(|path| !path.is_empty());
if let Some(project_path) = project_path {
enforce_project_permission_policy(Path::new(project_path), "preview.status")?;
}
Ok(filter_preview_status_for_project(
registry.status(),
project_path,
))
}
#[tauri::command]
pub(crate) async fn get_local_game_project_revision(
project_path: String,
) -> Result<LocalGameProjectRevisionStatus, String> {
tauri::async_runtime::spawn_blocking(move || get_local_game_project_revision_sync(project_path))
.await
.map_err(|error| format!("读取项目版本后台任务失败:{error}"))?
}
pub(crate) fn get_local_game_project_revision_sync(
project_path: String,
) -> Result<LocalGameProjectRevisionStatus, String> {
get_local_game_project_revision_at(Path::new(project_path.trim()))
}
pub(crate) fn get_local_game_project_revision_at(
root: &Path,
) -> Result<LocalGameProjectRevisionStatus, String> {
enforce_project_permission_policy(root, "preview.status")?;
let revision = read_game_creator_agent_runtime_project_revision(root)?;
Ok(LocalGameProjectRevisionStatus {
revision: revision.revision,
})
}
#[tauri::command]
pub(crate) fn activate_local_game_preview(
registry: tauri::State<'_, PreviewRegistry>,
project_path: Option<String>,
) -> Result<LocalPreviewStatus, String> {
let status = registry.status();
validate_preview_open_project(&status, project_path.as_deref())?;
preview_open_url(&status)?;
Ok(status)
}
pub(crate) fn start_local_game_preview_for_project(
root: &Path,
) -> Result<(LocalPreviewResult, mpsc::Sender<()>), String> {
if root.as_os_str().is_empty() {
return Err("项目目录不能为空".to_string());
}
if !root.is_absolute() {
return Err("项目目录必须是绝对路径".to_string());
}
let game_root = project_game_root(root);
if !game_root.is_dir() {
return Err(format!("游戏目录不存在:{}", game_root.display()));
}
if !game_root.join("index.html").is_file() {
return Err(format!(
"游戏入口不存在:{}",
game_root.join("index.html").display()
));
}
let listener = bind_loopback_listener_with_linux_fallback(root.to_string_lossy().as_ref())
.map_err(|error| format!("启动预览失败:{error}"))?;
let port = listener
.local_addr()
.map_err(|error| format!("读取预览端口失败:{error}"))?
.port();
listener
.set_nonblocking(true)
.map_err(|error| format!("设置预览监听失败:{error}"))?;
let served_root = root.to_path_buf();
let (stop_sender, stop_receiver) = mpsc::channel();
thread::spawn(move || loop {
if stop_receiver.try_recv().is_ok() {
break;
}
match listener.accept() {
Ok((stream, _)) => handle_preview_stream(stream, &served_root),
// Chromium can abandon a speculative loopback socket before accept() consumes
// it. Keep the listener alive for that connection; only an unrecoverable listener
// error should tear down the preview server.
Err(error) => match classify_preview_listener_accept_error(&error) {
PreviewListenerAcceptDisposition::Sleep => {
thread::sleep(Duration::from_millis(25));
}
PreviewListenerAcceptDisposition::Retry => {
thread::sleep(Duration::from_millis(5));
}
PreviewListenerAcceptDisposition::Stop => break,
},
}
});
Ok((
LocalPreviewResult {
url: format!("http://127.0.0.1:{port}/"),
port,
root: root.to_string_lossy().into_owned(),
},
stop_sender,
))
}
fn handle_preview_stream(mut stream: TcpStream, root: &Path) {
// The listener is nonblocking so its accept loop can observe the stop channel. Windows may
// inherit that mode on accepted sockets; switch each connection back to blocking mode before
// waiting for Chromium's split request headers.
if stream.set_nonblocking(false).is_err() {
return;
}
let request_line = match read_preview_request_line(&mut stream) {
Ok(Some(request_line)) => request_line,
Ok(None) | Err(_) => return,
};
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or_default();
let url_path = parts.next().unwrap_or("/");
let response = build_preview_response(root, method, url_path);
if stream.write_all(&response).is_ok() {
let _ = stream.flush();
// Explicitly half-close after the complete response, then consume the peer's remaining
// request bytes for a short bounded interval. This lets Windows complete a graceful
// FIN/ACK exchange instead of surfacing the close as WSAECONNABORTED to Chromium.
let _ = stream.shutdown(std::net::Shutdown::Write);
drain_preview_request_after_response(&mut stream);
}
}
fn drain_preview_request_after_response(stream: &mut TcpStream) {
let _ = stream.set_read_timeout(Some(PREVIEW_RESPONSE_DRAIN_TIMEOUT));
let mut buffer = [0u8; 4096];
let mut drained_bytes = 0usize;
while drained_bytes < PREVIEW_RESPONSE_DRAIN_MAX_BYTES {
match stream.read(&mut buffer) {
Ok(0) => break,
Ok(bytes_read) => {
drained_bytes = drained_bytes.saturating_add(bytes_read);
}
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
) =>
{
break;
}
Err(_) => break,
}
}
}
/// Read the request line and all headers before closing the connection.
///
/// Chromium can deliver the request line and headers in separate packets. Dropping the
/// stream after only `read_line` leaves unread request bytes on Windows and may make the
/// close look like an abortive RST (`net::ERR_SOCKET_NOT_CONNECTED`). The bounded read keeps
/// slow or malformed clients from occupying a preview thread indefinitely.
fn read_preview_request_line(stream: &mut TcpStream) -> std::io::Result<Option<String>> {
stream.set_read_timeout(Some(PREVIEW_REQUEST_READ_TIMEOUT))?;
let mut reader = BufReader::new(stream);
let mut request_line = Vec::new();
let mut total_bytes = 0usize;
for line_index in 0..PREVIEW_REQUEST_MAX_HEADER_LINES {
let mut line = Vec::new();
loop {
let available = reader.fill_buf()?;
if available.is_empty() {
return Ok(None);
}
let newline_index = available.iter().position(|byte| *byte == b'\n');
let bytes_to_consume = newline_index
.map(|index| index + 1)
.unwrap_or(available.len());
if total_bytes.saturating_add(bytes_to_consume) > PREVIEW_REQUEST_MAX_HEADER_BYTES {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"preview request headers exceed the size limit",
));
}
line.extend_from_slice(&available[..bytes_to_consume]);
total_bytes += bytes_to_consume;
reader.consume(bytes_to_consume);
if newline_index.is_some() {
break;
}
}
let is_blank_line = line == b"\r\n" || line == b"\n";
if line_index == 0 {
request_line = line;
}
if is_blank_line {
return String::from_utf8(request_line).map(Some).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"preview request line is not valid UTF-8",
)
});
}
}
Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"preview request headers exceed the line limit",
))
}
pub(crate) fn build_preview_response(root: &Path, method: &str, url_path: &str) -> Vec<u8> {
let is_head = method == "HEAD";
if method != "GET" && !is_head {
return http_response(
"405 Method Not Allowed",
"text/plain",
b"method not allowed",
b"method not allowed".len(),
);
}
if url_path.split('?').next() == Some(PREVIEW_FIT_BRIDGE_PATH) {
let body = if is_head {
Vec::new()
} else {
PREVIEW_FIT_BRIDGE_SCRIPT.as_bytes().to_vec()
};
return http_response(
"200 OK",
"text/javascript; charset=utf-8",
&body,
PREVIEW_FIT_BRIDGE_SCRIPT.len(),
);
}
let file_path = match resolve_preview_path(root, url_path) {
Ok(path) => path,
Err(_) => {
let body: &[u8] = if is_head { &[] } else { b"not found" };
return http_response("404 Not Found", "text/plain", body, b"not found".len());
}
};
let body = match fs::read(&file_path) {
Ok(body) => body,
Err(_) => {
let body: &[u8] = if is_head { &[] } else { b"not found" };
return http_response("404 Not Found", "text/plain", body, b"not found".len());
}
};
let body = if content_type(&file_path).starts_with("text/html") {
inject_preview_fit_bridge(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_preview_fit_bridge(body: Vec<u8>) -> Vec<u8> {
let html = match String::from_utf8(body) {
Ok(html) => html,
Err(error) => return error.into_bytes(),
};
let scan = scan_preview_fit_bridge_html(&html);
if scan.has_bridge_script {
return html.into_bytes();
}
let insertion = scan.body_end.or(scan.html_end).unwrap_or(scan.safe_append);
let mut output = String::with_capacity(html.len() + PREVIEW_FIT_BRIDGE_TAG.len());
output.push_str(&html[..insertion]);
output.push_str(PREVIEW_FIT_BRIDGE_TAG);
output.push_str(&html[insertion..]);
output.into_bytes()
}
#[derive(Clone, Copy, Debug)]
struct PreviewFitBridgeHtmlScan {
has_bridge_script: bool,
body_end: Option<usize>,
html_end: Option<usize>,
safe_append: usize,
}
#[derive(Clone, Copy, Debug)]
struct PreviewHtmlTag {
start: usize,
name_start: usize,
name_end: usize,
end: usize,
closing: bool,
}
fn scan_preview_fit_bridge_html(html: &str) -> PreviewFitBridgeHtmlScan {
let bytes = html.as_bytes();
let mut scan = PreviewFitBridgeHtmlScan {
has_bridge_script: false,
body_end: None,
html_end: None,
safe_append: bytes.len(),
};
let mut cursor = 0;
let mut template_depth = 0_u32;
let mut outer_template_start = None;
while let Some(relative_start) = bytes[cursor..].iter().position(|byte| *byte == b'<') {
let tag_start = cursor + relative_start;
if bytes[tag_start..].starts_with(b"<!--") {
let Some(end) = preview_html_comment_end(bytes, tag_start + 4) else {
scan.safe_append = outer_template_start.unwrap_or(tag_start);
break;
};
cursor = end;
continue;
}
if bytes[tag_start..].starts_with(b"<!") {
if !preview_html_doctype_at(bytes, tag_start) {
scan.safe_append = outer_template_start.unwrap_or(tag_start);
break;
}
let Some(end) = preview_html_doctype_end(bytes, tag_start + 2) else {
scan.safe_append = outer_template_start.unwrap_or(tag_start);
break;
};
cursor = end;
continue;
}
if bytes[tag_start..].starts_with(b"<?") {
scan.safe_append = outer_template_start.unwrap_or(tag_start);
break;
}
let tag_name_start = tag_start
+ if bytes.get(tag_start + 1) == Some(&b'/') {
2
} else {
1
};
let tag_candidate = bytes
.get(tag_name_start)
.is_some_and(u8::is_ascii_alphabetic);
let Some(tag) = preview_html_tag_at(bytes, tag_start) else {
if tag_candidate {
scan.safe_append = outer_template_start.unwrap_or(tag_start);
break;
}
cursor = tag_start + 1;
continue;
};
let is_template = preview_html_tag_name_is(bytes, tag, b"template");
let is_foreign_root = !tag.closing
&& (preview_html_tag_name_is(bytes, tag, b"svg")
|| preview_html_tag_name_is(bytes, tag, b"math"));
if is_foreign_root {
scan.safe_append = outer_template_start.unwrap_or(tag.start);
break;
}
if template_depth > 0 {
if is_template {
if tag.closing {
template_depth -= 1;
if template_depth == 0 {
outer_template_start = None;
}
} else {
template_depth = template_depth.saturating_add(1);
}
cursor = tag.end;
continue;
}
if !tag.closing {
if preview_html_tag_name_is(bytes, tag, b"plaintext") {
scan.safe_append = outer_template_start.unwrap_or(tag.start);
break;
}
if let Some(raw_text_name) = preview_html_raw_text_name(bytes, tag) {
let Some((close_start, close_end)) =
preview_html_raw_text_close(bytes, tag.end, raw_text_name)
else {
scan.safe_append = outer_template_start.unwrap_or(tag.start);
break;
};
if raw_text_name == b"script"
&& find_bytes(&bytes[tag.end..close_start], b"<!--").is_some()
{
scan.safe_append = outer_template_start.unwrap_or(tag.start);
break;
}
cursor = close_end;
continue;
}
}
cursor = tag.end;
continue;
}
if is_template && !tag.closing {
template_depth = 1;
outer_template_start = Some(tag.start);
cursor = tag.end;
continue;
}
if tag.closing {
if scan.body_end.is_none() && preview_html_tag_name_is(bytes, tag, b"body") {
scan.body_end = Some(tag.start);
} else if scan.html_end.is_none() && preview_html_tag_name_is(bytes, tag, b"html") {
scan.html_end = Some(tag.start);
}
cursor = tag.end;
continue;
}
if preview_html_tag_name_is(bytes, tag, b"script")
&& preview_html_tag_has_bridge_src(bytes, tag)
{
scan.has_bridge_script = true;
}
if preview_html_tag_name_is(bytes, tag, b"plaintext") {
scan.safe_append = tag.start;
break;
}
if let Some(raw_text_name) = preview_html_raw_text_name(bytes, tag) {
let Some((close_start, close_end)) =
preview_html_raw_text_close(bytes, tag.end, raw_text_name)
else {
scan.safe_append = tag.start;
break;
};
if raw_text_name == b"script"
&& find_bytes(&bytes[tag.end..close_start], b"<!--").is_some()
{
scan.safe_append = tag.start;
break;
}
cursor = close_end;
continue;
}
cursor = tag.end;
}
if template_depth > 0 {
scan.safe_append = outer_template_start.unwrap_or(scan.safe_append);
}
scan
}
fn preview_html_comment_end(bytes: &[u8], start: usize) -> Option<usize> {
if bytes.get(start) == Some(&b'>') {
return Some(start + 1);
}
if bytes.get(start..start + 2) == Some(b"->") {
return Some(start + 2);
}
let mut cursor = start;
while cursor < bytes.len() {
if bytes.get(cursor..cursor + 3) == Some(b"-->") {
return Some(cursor + 3);
}
if bytes.get(cursor..cursor + 4) == Some(b"--!>") {
return Some(cursor + 4);
}
cursor += 1;
}
None
}
fn preview_html_doctype_at(bytes: &[u8], start: usize) -> bool {
let name_start = start + 2;
let name_end = name_start + b"doctype".len();
bytes
.get(name_start..name_end)
.is_some_and(|name| name.eq_ignore_ascii_case(b"doctype"))
}
fn preview_html_doctype_end(bytes: &[u8], start: usize) -> Option<usize> {
bytes
.get(start..)?
.iter()
.position(|byte| *byte == b'>')
.map(|relative_end| start + relative_end + 1)
}
fn preview_html_tag_at(bytes: &[u8], start: usize) -> Option<PreviewHtmlTag> {
if bytes.get(start) != Some(&b'<') {
return None;
}
let mut cursor = start + 1;
let closing = bytes.get(cursor) == Some(&b'/');
if closing {
cursor += 1;
}
let name_start = cursor;
if !bytes.get(cursor).is_some_and(u8::is_ascii_alphabetic) {
return None;
}
cursor += 1;
while bytes
.get(cursor)
.is_some_and(|byte| !preview_html_space(*byte) && !matches!(*byte, b'/' | b'>'))
{
cursor += 1;
}
let name_end = cursor;
let end = preview_html_markup_end(bytes, cursor)?;
Some(PreviewHtmlTag {
start,
name_start,
name_end,
end,
closing,
})
}
fn preview_html_markup_end(bytes: &[u8], mut cursor: usize) -> Option<usize> {
let mut quote = None;
while let Some(byte) = bytes.get(cursor).copied() {
if let Some(expected) = quote {
if byte == expected {
quote = None;
}
} else if matches!(byte, b'\'' | b'"') {
quote = Some(byte);
} else if byte == b'>' {
return Some(cursor + 1);
}
cursor += 1;
}
None
}
fn preview_html_tag_name_is(bytes: &[u8], tag: PreviewHtmlTag, expected: &[u8]) -> bool {
bytes[tag.name_start..tag.name_end].eq_ignore_ascii_case(expected)
}
fn preview_html_raw_text_name(bytes: &[u8], tag: PreviewHtmlTag) -> Option<&'static [u8]> {
[
b"script".as_slice(),
b"style".as_slice(),
b"title".as_slice(),
b"textarea".as_slice(),
b"xmp".as_slice(),
b"iframe".as_slice(),
b"noembed".as_slice(),
b"noframes".as_slice(),
b"noscript".as_slice(),
]
.into_iter()
.find(|name| preview_html_tag_name_is(bytes, tag, name))
}
fn preview_html_raw_text_close(
bytes: &[u8],
mut cursor: usize,
name: &[u8],
) -> Option<(usize, usize)> {
while cursor < bytes.len() {
let relative_start = bytes[cursor..].iter().position(|byte| *byte == b'<')?;
let close_start = cursor + relative_start;
let name_start = close_start + 2;
let name_end = name_start.checked_add(name.len())?;
if bytes.get(close_start + 1) == Some(&b'/')
&& bytes
.get(name_start..name_end)
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(name))
&& bytes
.get(name_end)
.is_some_and(|byte| preview_html_space(*byte) || matches!(*byte, b'/' | b'>'))
{
let end = preview_html_markup_end(bytes, name_end)?;
return Some((close_start, end));
}
cursor = close_start + 1;
}
None
}
fn preview_html_tag_has_bridge_src(bytes: &[u8], tag: PreviewHtmlTag) -> bool {
let mut cursor = tag.name_end;
let limit = tag.end.saturating_sub(1);
let mut first_src = None;
let mut has_type = false;
let mut has_nomodule = false;
while cursor < limit {
while cursor < limit && preview_html_space(bytes[cursor]) {
cursor += 1;
}
if cursor >= limit || bytes[cursor] == b'/' {
break;
}
let attribute_start = cursor;
while cursor < limit
&& !preview_html_space(bytes[cursor])
&& !matches!(bytes[cursor], b'=' | b'/' | b'>')
{
cursor += 1;
}
if cursor == attribute_start {
cursor += 1;
continue;
}
let attribute_end = cursor;
let is_src = bytes[attribute_start..attribute_end].eq_ignore_ascii_case(b"src");
let is_type = bytes[attribute_start..attribute_end].eq_ignore_ascii_case(b"type");
let is_nomodule = bytes[attribute_start..attribute_end].eq_ignore_ascii_case(b"nomodule");
if is_type {
has_type = true;
}
if is_nomodule {
has_nomodule = true;
}
while cursor < limit && preview_html_space(bytes[cursor]) {
cursor += 1;
}
if cursor >= limit || bytes[cursor] != b'=' {
if is_src && first_src.is_none() {
first_src = Some(false);
}
continue;
}
cursor += 1;
while cursor < limit && preview_html_space(bytes[cursor]) {
cursor += 1;
}
let (value_start, value_end) = if cursor < limit && matches!(bytes[cursor], b'\'' | b'"') {
let quote = bytes[cursor];
cursor += 1;
let value_start = cursor;
while cursor < limit && bytes[cursor] != quote {
cursor += 1;
}
let value_end = cursor;
if cursor < limit {
cursor += 1;
}
(value_start, value_end)
} else {
let value_start = cursor;
while cursor < limit && !preview_html_space(bytes[cursor]) && bytes[cursor] != b'>' {
cursor += 1;
}
(value_start, cursor)
};
if is_src && first_src.is_none() {
first_src = Some(bytes[value_start..value_end] == *PREVIEW_FIT_BRIDGE_PATH.as_bytes());
}
}
first_src == Some(true) && !has_type && !has_nomodule
}
fn preview_html_space(byte: u8) -> bool {
matches!(byte, b'\t' | b'\n' | 0x0c | b'\r' | b' ')
}
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() {
return Some(0);
}
haystack
.windows(needle.len())
.position(|candidate| candidate == needle)
}
/// npm 工程只预览构建结果;单 HTML 工程保留现有入口布局。
pub(crate) fn project_game_root(root: &Path) -> PathBuf {
if root.join("package.json").is_file() || root.join("dist/index.html").is_file() {
return root.join("dist");
}
if root.join("game/package.json").is_file() || root.join("game/dist/index.html").is_file() {
return root.join("game/dist");
}
if root.join("index.html").is_file() {
root.to_path_buf()
} else {
root.join("game")
}
}
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())?;
let relative = decoded.trim_start_matches('/');
if !relative.is_empty()
&& relative.split('/').any(|part| {
part.is_empty()
|| part == "."
|| part == ".."
|| part.contains('\\')
|| part.chars().any(char::is_control)
})
{
return Err("预览路径非法".to_string());
}
if relative.is_empty() {
return canonical_preview_path(root, &project_game_root(root).join("index.html"));
}
let game_root = project_game_root(root);
if game_root == root.join("dist") || game_root == root.join("game/dist") {
return canonical_preview_path(root, &game_root.join(relative));
}
let mut file_path = root.to_path_buf();
let mut parts = relative.split('/');
let first = parts.next().ok_or_else(|| "预览路径非法".to_string())?;
if first.is_empty() || first == "." || first == ".." || first.contains('\\') {
return Err("预览路径非法".to_string());
}
if first == "game" || first == "assets" || first == "ui" {
file_path.push(first);
} else {
// `/` serves the resolved game entry (project-root index.html or the
// legacy game/index.html), so browser-relative resources such as
// `style.css` and `scripts/game.js` resolve from that same game root.
// Explicit `/assets/...` URLs keep their project-level asset mapping.
file_path.push(project_game_root(root));
file_path.push(first);
}
for part in parts {
if part.is_empty() || part == "." || part == ".." || part.contains('\\') {
return Err("预览路径非法".to_string());
}
file_path.push(part);
}
canonical_preview_path(root, &file_path)
}
fn canonical_preview_path(root: &Path, file_path: &Path) -> Result<PathBuf, String> {
let canonical_root = root
.canonicalize()
.map_err(|error| format!("预览根目录不可用:{}: {error}", root.display()))?;
let canonical_file = file_path
.canonicalize()
.map_err(|error| format!("预览文件不可用:{}: {error}", file_path.display()))?;
let relative_requested = file_path
.strip_prefix(root)
.map_err(|_| "预览路径越过项目目录".to_string())?;
let mut checked = root.to_path_buf();
for component in relative_requested.components() {
if !matches!(component, std::path::Component::Normal(_)) {
return Err("预览路径非法".to_string());
}
if component.as_os_str().to_str().is_some_and(|name| {
[
".agent",
".git",
".codex",
".hermes",
"node_modules",
"memory",
"exports",
"target",
]
.iter()
.any(|protected| name.eq_ignore_ascii_case(protected))
}) {
return Err("预览路径不能访问控制或依赖目录".to_string());
}
checked.push(component);
if fs::symlink_metadata(&checked)
.map_err(|error| error.to_string())?
.file_type()
.is_symlink()
{
return Err("预览路径不能包含符号链接".to_string());
}
}
if !canonical_file.is_file() {
return Err("预览路径必须是文件".to_string());
}
// New DirectProject layouts may use the project root itself as the web
// root. The old allow-list below only considered `game/` and `assets/`,
// which made a valid root `index.html` resolve to a 404 even though
// `project_game_root` had selected it. Permit web files below the root
// while keeping control/data directories out of the preview surface.
if root.join("index.html").is_file() && canonical_file.starts_with(&canonical_root) {
let relative = canonical_file
.strip_prefix(&canonical_root)
.map_err(|_| "预览路径越过项目目录".to_string())?;
let first_component = relative
.components()
.next()
.and_then(|component| match component {
std::path::Component::Normal(value) => value.to_str(),
_ => None,
});
let protected_root_component = first_component.is_some_and(|component| {
[
".agent",
".git",
".codex",
".hermes",
"memory",
"exports",
"node_modules",
"target",
]
.iter()
.any(|protected| component.eq_ignore_ascii_case(protected))
});
if !protected_root_component && content_type(&canonical_file) != "application/octet-stream"
{
return Ok(canonical_file);
}
}
for segment in ["game", "assets", "ui", "dist"] {
let allowed_dir = root.join(segment);
let metadata = match fs::symlink_metadata(&allowed_dir) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => {
return Err(format!(
"预览目录不可用:{}: {error}",
allowed_dir.display()
))
}
};
if metadata.file_type().is_symlink() {
return Err(format!("预览目录不能是符号链接:{}", allowed_dir.display()));
}
let canonical_allowed_dir = allowed_dir
.canonicalize()
.map_err(|error| format!("预览目录不可用:{}: {error}", allowed_dir.display()))?;
if !canonical_allowed_dir.starts_with(&canonical_root) {
return Err("预览目录越过项目目录".to_string());
}
if canonical_file.starts_with(canonical_allowed_dir) {
return Ok(canonical_file);
}
}
Err("预览路径只能访问真实 game/、assets/ 或 ui/ 目录".to_string())
}
fn percent_decode_path(path: &str) -> Option<String> {
let bytes = path.as_bytes();
let mut output = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' {
let high = hex_value(*bytes.get(index + 1)?)?;
let low = hex_value(*bytes.get(index + 2)?)?;
output.push((high << 4) | low);
index += 3;
} else {
output.push(bytes[index]);
index += 1;
}
}
String::from_utf8(output).ok()
}
fn hex_value(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
pub(crate) fn content_type(path: &Path) -> &'static str {
match path.extension().and_then(|extension| extension.to_str()) {
Some("aac") => "audio/aac",
Some("css") => "text/css; charset=utf-8",
Some("flac") => "audio/flac",
Some("gif") => "image/gif",
Some("html") => "text/html; charset=utf-8",
Some("jpeg" | "jpg") => "image/jpeg",
Some("js") => "text/javascript; charset=utf-8",
Some("json") => "application/json; charset=utf-8",
Some("m4a") => "audio/mp4",
Some("mp3") => "audio/mpeg",
Some("mp4") => "video/mp4",
Some("ogg") => "audio/ogg",
Some("png") => "image/png",
Some("svg") => "image/svg+xml",
Some("wasm") => "application/wasm",
Some("wav") => "audio/wav",
Some("webm") => "video/webm",
Some("webp") => "image/webp",
_ => "application/octet-stream",
}
}
fn http_response(status: &str, content_type: &str, body: &[u8], content_length: usize) -> Vec<u8> {
let header = format!(
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\r\nPragma: no-cache\r\nExpires: 0\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
content_length
);
let mut response = header.into_bytes();
response.extend_from_slice(body);
response
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn npm_preview_requires_build_and_prefers_bundled_assets() {
let base = PathBuf::from(std::env::var("HOME").unwrap()).join("data/tmp");
fs::create_dir_all(&base).unwrap();
let root = tempfile::tempdir_in(base).unwrap();
fs::write(root.path().join("package.json"), "{}").unwrap();
fs::write(root.path().join("index.html"), "source").unwrap();
assert!(resolve_preview_path(root.path(), "/").is_err());
fs::create_dir_all(root.path().join("dist/assets")).unwrap();
fs::create_dir_all(root.path().join("assets")).unwrap();
fs::write(root.path().join("dist/index.html"), "<!doctype html>").unwrap();
fs::write(root.path().join("dist/assets/main.js"), "bundled").unwrap();
fs::write(root.path().join("assets/main.js"), "source").unwrap();
fs::write(root.path().join("assets/hero.png"), "image").unwrap();
assert_eq!(
resolve_preview_path(root.path(), "/assets/main.js").unwrap(),
root.path()
.join("dist/assets/main.js")
.canonicalize()
.unwrap()
);
assert!(resolve_preview_path(root.path(), "/assets/hero.png").is_err());
fs::create_dir_all(root.path().join("game")).unwrap();
fs::write(root.path().join("game/index.html"), "source").unwrap();
assert!(resolve_preview_path(root.path(), "/game/index.html").is_err());
assert!(resolve_preview_path(root.path(), "/assets/%2e%2e/index.html").is_err());
#[cfg(unix)]
{
std::os::unix::fs::symlink(
root.path().join("index.html"),
root.path().join("dist/assets/leak.html"),
)
.unwrap();
assert!(resolve_preview_path(root.path(), "/assets/leak.html").is_err());
}
}
#[test]
fn root_layout_serves_root_entry_and_keeps_legacy_paths_available() {
let root = tempfile::tempdir().expect("create preview root");
fs::create_dir_all(root.path().join("game")).expect("create game directory");
fs::create_dir_all(root.path().join("assets")).expect("create assets directory");
fs::write(
root.path().join("index.html"),
"<!doctype html><html lang=\"zh-CN\"><body>根入口</body></html>",
)
.expect("write root entry");
fs::write(
root.path().join("game/index.html"),
"<!doctype html><html lang=\"zh-CN\"><body>游戏入口</body></html>",
)
.expect("write game entry");
fs::write(root.path().join("style.css"), "body { color: red; }")
.expect("write root stylesheet");
fs::write(
root.path().join("assets/icon.png"),
[0x89, 0x50, 0x4e, 0x47],
)
.expect("write asset");
let canonical_root_entry = root
.path()
.join("index.html")
.canonicalize()
.expect("canonical root entry");
let canonical_game_entry = root
.path()
.join("game/index.html")
.canonicalize()
.expect("canonical game entry");
assert_eq!(project_game_root(root.path()), root.path());
assert_eq!(
resolve_preview_path(root.path(), "/").unwrap(),
canonical_root_entry
);
assert_eq!(
resolve_preview_path(root.path(), "/index.html").unwrap(),
canonical_root_entry
);
assert_eq!(
resolve_preview_path(root.path(), "/style.css").unwrap(),
root.path().join("style.css").canonicalize().unwrap()
);
assert_eq!(
resolve_preview_path(root.path(), "/game/index.html").unwrap(),
canonical_game_entry
);
assert_eq!(
resolve_preview_path(root.path(), "/assets/icon.png").unwrap(),
root.path().join("assets/icon.png").canonicalize().unwrap()
);
let response = build_preview_response(root.path(), "GET", "/");
let response_text = String::from_utf8_lossy(&response);
assert!(response_text.starts_with("HTTP/1.1 200 OK\r\n"));
assert!(response_text.contains("根入口"));
}
#[test]
fn root_layout_does_not_expose_control_or_data_directories() {
let root = tempfile::tempdir().expect("create preview root");
fs::create_dir_all(root.path().join(".agent")).expect("create agent directory");
fs::create_dir_all(root.path().join("memory")).expect("create memory directory");
fs::write(root.path().join("index.html"), "<!doctype html>").expect("write root entry");
fs::write(root.path().join(".agent/secret.json"), "{}").expect("write secret");
fs::write(root.path().join("memory/private.md"), "private").expect("write private data");
assert!(resolve_preview_path(root.path(), "/.agent/secret.json").is_err());
assert!(resolve_preview_path(root.path(), "/memory/private.md").is_err());
}
#[test]
fn legacy_layout_serves_root_ui_modules() {
let root = tempfile::tempdir().expect("create preview root");
fs::create_dir_all(root.path().join("game")).expect("create game directory");
fs::create_dir_all(root.path().join("ui")).expect("create ui directory");
fs::write(root.path().join("game/index.html"), "<!doctype html>")
.expect("write game entry");
fs::write(root.path().join("ui/generated-x.js"), "export {};")
.expect("write generated module");
let expected = root
.path()
.join("ui/generated-x.js")
.canonicalize()
.expect("canonical generated module");
assert_eq!(
resolve_preview_path(root.path(), "/ui/generated-x.js").unwrap(),
expected
);
let response = build_preview_response(root.path(), "GET", "/ui/generated-x.js");
let response_text = String::from_utf8_lossy(&response);
assert!(response_text.starts_with("HTTP/1.1 200 OK\r\n"));
assert!(response_text.contains("export {};"));
}
}