Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/preview.rs
T
lhk229 ec3a187dd7
Project CI / AI game creator shell Rust crates (push) Successful in 1m11s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m34s
Project CI / Backend tests (push) Successful in 4m46s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m31s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m41s
Project CI / Frontend tests (push) Successful in 2m19s
Project CI / Native shell tests (push) Successful in 7m5s
Project CI / AI game creator shell web tests (push) Successful in 1m54s
Project CI / Repository checks (push) Successful in 2m36s
处理编译大量warning问题 (#503)
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/503
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-09-24 13:01:31 +08:00

1981 lines
74 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<()>,
_analytics_lease: Option<crate::analytics::preview::Lease>,
}
impl PreviewRegistry {
#[cfg(test)]
pub(crate) fn set_running(
&self,
preview: LocalPreviewResult,
stop: mpsc::Sender<()>,
) -> (LocalPreviewResult, Option<LocalPreviewResult>) {
self.set_running_with_lease(preview, stop, None)
}
fn set_running_with_lease(
&self,
preview: LocalPreviewResult,
stop: mpsc::Sender<()>,
analytics_lease: Option<crate::analytics::preview::Lease>,
) -> (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,
_analytics_lease: analytics_lease,
});
(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>,
preview_source: Option<crate::analytics::contract::PreviewSource>,
registry: tauri::State<'_, PreviewRegistry>,
) -> Result<LocalPreviewResult, String> {
let root = Path::new(project_path.trim());
let capture = (preview_source == Some(crate::analytics::contract::PreviewSource::User))
.then(crate::analytics::gui::capture_writer_context)
.flatten();
start_local_game_preview_with_capture(root, expected_revision, &registry, capture)
}
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> {
start_local_game_preview_with_capture(root, expected_revision, registry, None)
}
fn start_local_game_preview_with_capture(
root: &Path,
expected_revision: Option<u64>,
registry: &PreviewRegistry,
capture: Option<(
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
)>,
) -> 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 observation = crate::analytics::preview::prepare(
root,
capture,
crate::analytics::contract::Source::Editor,
crate::analytics::contract::PreviewSource::User,
);
let (analytics_lease, observation) = match observation {
Some((lease, observation)) => (Some(lease), Some(observation)),
None => (None, None),
};
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_with_lease(preview, stop, analytics_lease);
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);
}
if let Some(observation) = observation {
observation.schedule(preview.port);
}
Ok(preview)
}
#[cfg(test)]
#[path = "preview_analytics_tests.rs"]
mod analytics_tests;
#[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 {};"));
}
}