7afec12958
拆出 CLI、命令、配置、Agent、素材、项目、预览和窗口模块 拆出 Rust 单测并保留 main.rs 作为 Tauri 薄入口 扩展壳配置与原生壳门禁的 Rust 源码扫描范围 同步 AI 游戏创作 App 技术方案和决策记录
477 lines
16 KiB
Rust
477 lines
16 KiB
Rust
use super::*;
|
|
|
|
#[derive(Default)]
|
|
pub(crate) struct PreviewRegistry {
|
|
current: 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 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,
|
|
registry: tauri::State<'_, PreviewRegistry>,
|
|
) -> Result<LocalPreviewResult, String> {
|
|
let root = Path::new(project_path.trim());
|
|
enforce_project_permission_policy(root, "preview.start")?;
|
|
let _lock = acquire_project_write_lock(root, "preview.start")?;
|
|
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 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 open_local_game_preview(
|
|
app: tauri::AppHandle,
|
|
registry: tauri::State<'_, PreviewRegistry>,
|
|
project_path: Option<String>,
|
|
) -> Result<LocalPreviewStatus, String> {
|
|
let status = registry.status();
|
|
validate_preview_open_project(&status, project_path.as_deref())?;
|
|
let url = preview_open_url(&status)?;
|
|
app.opener()
|
|
.open_url(&url, None::<&str>)
|
|
.map_err(|error| format!("preview open failed: {error}"))?;
|
|
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 = root.join("game");
|
|
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 =
|
|
TcpListener::bind(("127.0.0.1", 0)).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),
|
|
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
|
thread::sleep(Duration::from_millis(25));
|
|
}
|
|
Err(_) => 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) {
|
|
let mut request_line = String::new();
|
|
{
|
|
let mut reader = BufReader::new(&mut stream);
|
|
if reader.read_line(&mut request_line).is_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);
|
|
let _ = stream.write_all(&response);
|
|
}
|
|
|
|
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(),
|
|
);
|
|
}
|
|
|
|
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 content_length = body.len();
|
|
let body = if is_head { Vec::new() } else { body };
|
|
http_response("200 OK", content_type(&file_path), &body, content_length)
|
|
}
|
|
|
|
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() {
|
|
return canonical_preview_path(root, &root.join("game/index.html"));
|
|
}
|
|
|
|
let mut file_path = root.to_path_buf();
|
|
let mut parts = relative.split('/');
|
|
let first = parts.next().ok_or_else(|| "预览路径非法".to_string())?;
|
|
if first != "game" && first != "assets" {
|
|
return Err("预览路径只能访问 game/ 或 assets/".to_string());
|
|
}
|
|
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()))?;
|
|
for segment in ["game", "assets"] {
|
|
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/ 目录".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\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
|
content_length
|
|
);
|
|
let mut response = header.into_bytes();
|
|
response.extend_from_slice(body);
|
|
response
|
|
}
|