1498247e9f
新增 npm + Vite + Phaser 4.2.1 游戏工程脚手架 允许 DirectProject 在项目边界内安装依赖并执行构建 让预览、静态检查、投影和试玩导出统一使用 dist 产物 补充 Phaser Skill、技术方案、项目记忆和定向测试
1783 lines
65 KiB
Rust
1783 lines
65 KiB
Rust
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);
|
||
};
|
||
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, ®istry)
|
||
}
|
||
|
||
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() {
|
||
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, ®istry)
|
||
}
|
||
|
||
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);
|
||
if let Some(root) = root.filter(|_| stopped) {
|
||
record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None)?;
|
||
append_preview_log(root, "stopped", None)?;
|
||
append_preview_stop_trace_step(root)?;
|
||
}
|
||
Ok(status)
|
||
}
|
||
|
||
#[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,
|
||
®istry,
|
||
)
|
||
}
|
||
|
||
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(¤t_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(®istry, 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 {};"));
|
||
}
|
||
}
|