Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/preview.rs
T
suzmii 531392e506 退出路径统一收尾本地预览,陈旧预览记录不再留给下次进入
- preview.rs:新增 stop_local_game_preview_on_exit——退出时若 registry 里确有 running 预览,就用 stop_local_game_preview_for_root 把它真正停掉并写 stopped(顺带落 preview.log 与 trace 收尾)。预览服务器是进程内线程,进程一退 URL 就永久失效;不在这里收尾,.agent/manifest.json 会一直写着 running。
- main.rs:handle_game_creator_gui_run_event 的 RunEvent::Exit 分支先做预览收尾,再做既有的 Agent server / runner 关闭;失败按 `preview.gui_exit.stop_failed` 记日志,不阻断退出。
- 语义变更(用户已确认接受):关窗不再保留预览——退出即停止预览并把记录写成 stopped,下次进项目一律落到资源管理。与"进入项目时按 registry 活体核对"配套:退出路径覆盖正常关闭,强杀 / 断电那类不走退出路径的情况由进入时的核对自愈。不引入 stale DTO 字段(判定仍要活体探测,且会把状态扩到跨端契约与全部消费方)。
- tests/project.rs:新增 gui_exit_stops_the_preview_and_clears_the_stale_record——退出收尾后 registry 归零且 manifest 记录为 stopped。
- tests/sessionPreview.test.ts:补两条 Rust 侧结构性守卫(本仓既有做法:直接解析源码)。① RunEvent::Exit 分支必须出现 preview::stop_local_game_preview_on_exit——这条接线没有别的行为测试覆盖(注册全局 registry 的用例会互相打架);② 同项目重启预览的替换分支里,收尾必须排在"旧预览是否属于另一个项目"的判定之后。变异验证:把 main.rs 的退出收尾摘掉 → 守卫①立即红灯(已实测)。
- decision-log.md:新增 2026-09-11 决策条目,逐条写下背景(偶发进运行界面的成因链:进程内预览线程 + 随机临时端口 + 退出零清理 + 进门不核对 + 同项目重启覆盖刚写的 running)、A/B/C 三项决策、关窗不再保留预览这条语义变更及"为什么两者都做而不加 stale 字段"、不做什么与验证方式。同文件被 prettier 补了几处标题前空行(格式规范化,无内容变化)。
- 验证:cargo check --locked --all-targets exit 0;Rust 定向用例 restarting_preview_for_the_same_project / replacing_preview_for_another_project / stopping_without_a_live_preview / gui_exit_stops_the_preview 各 1 passed;AGC 子集 1198 passed / 4 skipped / 0 failed;共享组件 1385 passed;typecheck exit 0;编码 4378 文件;git diff --check 干净。
2026-09-11 12:36:08 +08:00

1927 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) fn get_local_game_project_revision(
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 {};"));
}
}