Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/preview.rs
T
kdletters 1498247e9f
Project CI / Repository checks (push) Successful in 2m54s
Project CI / Frontend tests (push) Successful in 3m4s
Project CI / Backend tests (push) Successful in 8m10s
Project CI / Native shell tests (push) Failing after 16m56s
AGC产物迁移npm与Phaser4
新增 npm + Vite + Phaser 4.2.1 游戏工程脚手架

允许 DirectProject 在项目边界内安装依赖并执行构建

让预览、静态检查、投影和试玩导出统一使用 dist 产物

补充 Phaser Skill、技术方案、项目记忆和定向测试
2026-09-08 15:11:36 +08:00

1783 lines
65 KiB
Rust
Raw Blame History

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