diff --git a/CONTEXT.md b/CONTEXT.md index 724d95de3..59acab2ff 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -194,6 +194,34 @@ _Avoid_: 进度通知、快照轮询、第二套历史 把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。 _Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer +**引用候选**: +输入区可以命中的对象集合(`@` 素材、`$` Skill),由宿主按种类注入;输入区不判断候选属于哪一类。 +_Avoid_: 输入区自己读项目清单或应用目录、把候选取值写死在组件里 + +**引用 provider**: +一种引用种类向输入区提供的全部能力:触发符、候选、身份解析与正文文本形态;每种引用各一份,宿主按需选择性注入。 +_Avoid_: 一个总装对象决定所有种类、输入区按种类分叉、provider 之间互相知道对方 + +**静默 provider**: +不提供候选、只负责已有引用身份与文本形态的 provider;附件与运行画面区域属于这一类,只能由外部插入或草稿回填进入正文。 +_Avoid_: 给附件或运行画面区域造候选、为它们保留输入区内的专门分支 + +**引用文本语法**: +引用在正文文本里的形态(`@显示名` / `$名称` / `@附件名`)及其反解析;出站与解析必须同一口径,token 前后各留一个空白。 +_Avoid_: 出站与解析各写一套、在空白边界之外再补兼容别名、让解析依赖具体种类的字段 + +**引用输入区**: +只负责编辑与渲染引用的共享输入组件;候选、身份解析与文本语法都来自注入的 provider,它不持有项目清单、不访问后端。 +_Avoid_: 输入区自己拉 Skill 目录、把选择器面板塞在输入区内部 + +**引用选择器**: +宿主渲染的独立面板,自己拿数据与筛选状态,确认后把选中的引用交给输入区的插入缝。 +_Avoid_: 输入区自带面板、每个宿主各画一个、绕开插入缝另开第二条通道 + +**附件芯片**: +聊天附件在正文里的唯一表示;附件导入成功即以芯片进入正文,正文之外不存在第二份附件状态。 +_Avoid_: 待发送附件列表与正文芯片并存、提交时再拼一遍附件 + ## Relationships - 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**。 @@ -209,6 +237,10 @@ _Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer - **个人历史成绩** 由最近记录列表和个人最佳摘要组成,只允许本人查看;排行榜只公开入榜胜利成绩。 - **正式作品入口闭环** 必须覆盖创作入口、作品详情 CTA、广场/作品卡片、我的作品/个人作品架、稳定作品 ID runtime 路由和 `work_play_start` 埋点。 - **Phase 2 实施顺序** 固定为:契约与领域规则 → SpacetimeDB 表/reducer 与 api-server BFF → 最小前端纵切 → 投影与列表体验 → 收口验证。 +- **引用输入区** 由宿主注入的若干 **引用 provider** 组成;**引用候选** 与 **引用文本语法** 都来自 provider,输入区不判断引用种类。 +- **引用选择器** 不属于 **引用输入区**:它自己拿数据,确认后只通过输入区的插入缝交付引用。 +- 只有带触发符的 **引用 provider** 会产生候选;**静默 provider** 没有触发符,只能由外部插入或草稿回填进入正文。 +- **附件芯片** 是本轮附件的唯一事实源;附件导入失败时不产生芯片。 ## Example dialogue diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser.rs b/apps/ai-game-creator-shell/src-tauri/src/browser.rs index 919fb7467..16b4ed4bd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser.rs @@ -6,6 +6,7 @@ mod model; mod network_policy; mod playtest; mod process; +mod sweep; pub use discovery::discover_chrome_or_edge; #[allow(unused_imports)] @@ -22,6 +23,7 @@ pub(crate) use process::validate_local_preview_in_browser_with_cancellation; pub use process::{ validate_local_preview_in_browser, validate_local_preview_in_browser_with_interaction, }; +pub(crate) use sweep::sweep_stale_browser_processes; pub(crate) use model::required_viewport_playtests_passed; pub(crate) use playtest::browser_playtest_scenario_fingerprint; diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs index 3330832ff..098f43841 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs @@ -1,9 +1,14 @@ use std::fs; use std::path::PathBuf; use std::time::Duration; +#[cfg(windows)] +use std::time::Instant; +use chromiumoxide::async_process::{Child as BrowserChild, ChildStderr as BrowserChildStderr}; use chromiumoxide::browser::{Browser, BrowserConfig}; -use futures::StreamExt; +use chromiumoxide::handler::viewport::Viewport; +use chromiumoxide::handler::HandlerConfig; +use futures::{AsyncBufReadExt, FutureExt, StreamExt}; use tempfile::{Builder as TempDirBuilder, TempDir}; use super::cdp::run_browser_validation; @@ -15,8 +20,12 @@ use super::model::{ BrowserIdentity, BrowserValidationInput, BrowserValidationResult, BROWSER_TIMEOUT, }; use super::network_policy::{preview_proxy_bypass_list, validate_input}; +#[cfg(windows)] +use crate::process_session::WindowsProcessJob; -fn browser_process_temp_root() -> PathBuf { +pub(super) const BROWSER_TEMP_PREFIX: &str = "ga-browser-"; + +pub(super) fn browser_process_temp_root() -> PathBuf { #[cfg(unix)] { PathBuf::from("/tmp") @@ -29,7 +38,7 @@ fn browser_process_temp_root() -> PathBuf { pub(super) fn create_browser_process_temp_dir() -> Result { TempDirBuilder::new() - .prefix("ga-browser-") + .prefix(BROWSER_TEMP_PREFIX) .tempdir_in(browser_process_temp_root()) .map_err(|error| format!("创建浏览器临时目录失败:{error}")) } @@ -63,21 +72,306 @@ fn browser_config( .map_err(|error| format!("构建浏览器配置失败:{error}")) } -/// 仅验证浏览器启动和真实 CDP,不加载项目、不生成试玩凭证。 -/// 每次独立 profile;既不串行化其它工具,也不继承 Codex 的临时 HOME。 -pub(crate) async fn check_browser_health() -> Result { - let discovered = discover_chrome_or_edge().map_err(|_| "browser-not-found")?; - let temporary = create_browser_process_temp_dir().map_err(|_| "browser-temp-unavailable")?; - let config = browser_config(&discovered.executable_path, &temporary, "<-loopback>") - .map_err(|_| "browser-config-invalid")?; - // 外层超时给 Chromiumoxide 内部启动超时后的 kill/wait 留出清理窗口。 - let (mut browser, mut handler) = tokio::time::timeout( - BROWSER_TIMEOUT + Duration::from_secs(5), - Browser::launch(config), +const BROWSER_CLOSE_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_LAUNCH_STDERR_BYTES: usize = 64 * 1024; +const MAX_WS_URL_LEN: usize = 512; + +/// 必须与 browser_config 的 builder 调用保持一致:request_timeout / +/// enable_request_intercept / disable_cache 在这里逐项镜像;viewport 镜像 +/// BrowserConfigBuilder 的默认值 Some(Viewport::default())(800x600,旧 +/// Browser::launch 会把它带入 HandlerConfig);其余字段 +/// (ignore_https_errors / ignore_invalid_messages)沿用 +/// HandlerConfig::default(),与 builder 默认值相同。 +fn handler_config() -> HandlerConfig { + let mut config = HandlerConfig::default(); + config.request_timeout = BROWSER_TIMEOUT; + config.request_intercept = true; + config.cache_enabled = false; + config.viewport = Some(Viewport::default()); + config +} + +/// 从浏览器 stderr 行提取 DevTools ws 地址;只接受 loopback 端点, +/// 避免伪造输出把 CDP 连接引导到任意地址。 +fn parse_devtools_ws_url(line: &str) -> Option { + let (_, ws) = line.rsplit_once("listening on ")?; + let ws = ws.trim(); + if ws.is_empty() || ws.len() > MAX_WS_URL_LEN || !ws.contains("devtools/browser") { + return None; + } + let authority = ws.strip_prefix("ws://")?.split('/').next()?; + let (host, port) = authority.rsplit_once(':')?; + if !matches!(host, "127.0.0.1" | "localhost" | "[::1]") + || port.is_empty() + || !port.bytes().all(|byte| byte.is_ascii_digit()) + { + return None; + } + Some(ws.to_string()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OwnedBrowserLaunchError { + SpawnFailed, + WsTimeout, + WsFailed, + ConnectTimeout, + ConnectFailed, +} + +impl OwnedBrowserLaunchError { + fn is_timeout(self) -> bool { + matches!(self, Self::WsTimeout | Self::ConnectTimeout) + } + + fn code(self) -> &'static str { + match self { + Self::SpawnFailed => "browser-spawn-failed", + Self::WsTimeout => "browser-ws-timeout", + Self::WsFailed => "browser-ws-failed", + Self::ConnectTimeout => "browser-cdp-connect-timeout", + Self::ConnectFailed => "browser-cdp-connect-failed", + } + } +} + +type BrowserStderrReader = futures::io::BufReader; + +/// 复刻 chromiumoxide 私有 ws_url_from_output 的语义(读 stderr 直到 +/// DevTools listening 行、提前退出即失败、整体有界),但 root 进程始终 +/// 留在调用方手里,并把 stderr reader 交还给调用方持续排空。 +async fn devtools_ws_url_from_stderr( + child: &mut BrowserChild, + timeout: Duration, +) -> Result<(String, BrowserStderrReader), OwnedBrowserLaunchError> { + let stderr = child + .stderr + .take() + .ok_or(OwnedBrowserLaunchError::SpawnFailed)?; + let mut reader = futures::io::BufReader::new(stderr); + let mut captured: Vec = Vec::new(); + let mut exited = Box::pin(child.wait()).fuse(); + let read = async { + loop { + let mut line = Vec::new(); + futures::select! { + _status = exited => return Err(OwnedBrowserLaunchError::WsFailed), + result = reader.read_until(b'\n', &mut line).fuse() => { + let count = result.map_err(|_| OwnedBrowserLaunchError::WsFailed)?; + if count == 0 { + return Err(OwnedBrowserLaunchError::WsFailed); + } + captured.extend_from_slice(&line); + if captured.len() > MAX_LAUNCH_STDERR_BYTES { + return Err(OwnedBrowserLaunchError::WsFailed); + } + let Ok(text) = std::str::from_utf8(&line) else { + return Err(OwnedBrowserLaunchError::WsFailed); + }; + if let Some(url) = parse_devtools_ws_url(text) { + return Ok(url); + } + } + } + } + }; + match tokio::time::timeout(timeout, read).await { + Ok(Ok(url)) => Ok((url, reader)), + Ok(Err(error)) => Err(error), + Err(_) => Err(OwnedBrowserLaunchError::WsTimeout), + } +} + +fn spawn_stderr_drain(mut reader: BrowserStderrReader) -> tokio::task::JoinHandle<()> { + // 持续排空 stderr:浏览器日志写满管道会整体 stall。内容有界、不留存。 + tokio::spawn(async move { + let mut buffer = Vec::with_capacity(4096); + loop { + buffer.clear(); + match reader.read_until(b'\n', &mut buffer).await { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + } + }) +} + +/// 自持浏览器进程:root 从 spawn 起归本结构所有;Windows 下整树挂进 +/// KILL_ON_JOB_CLOSE 的 Job——失败路径、遗忘 shutdown、甚至宿主进程 +/// 被杀,整棵树都会被回收,不再留下无父进程的无头浏览器。 +struct BrowserProcessGuard { + child: BrowserChild, + #[cfg(windows)] + job: Option, +} + +impl BrowserProcessGuard { + /// 整树收割:Windows 由 Job 终止全树,再 kill+wait root 兜底; + /// Unix 只终止 root,子进程依赖 root 死亡后的级联退出。 + /// 返回是否已确认 root 进程退出。 + async fn reap(&mut self) -> bool { + #[cfg(windows)] + if let Some(job) = &self.job { + let _ = job.terminate(); + } + let _ = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.kill()).await; + matches!( + tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.wait()).await, + Ok(Ok(_)) + ) + } + + async fn confirm_reaped(&mut self) -> Result<(), String> { + #[cfg(windows)] + { + if let Some(job) = &self.job { + let deadline = Instant::now() + BROWSER_CLOSE_TIMEOUT; + loop { + if job + .is_empty() + .map_err(|_| "browser-tree-reap-unconfirmed".to_string())? + { + return Ok(()); + } + if Instant::now() >= deadline { + return Err("browser-tree-reap-unconfirmed".into()); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + } + Ok(()) + } + #[cfg(not(windows))] + { + // 兜底确认 root 确已退出:否则调用方会在浏览器仍存活时删除 + // 配置目录,且 browser-cleanup-unconfirmed 永远不会暴露。 + match self.child.inner.try_wait() { + Ok(Some(_)) => Ok(()), + _ => Err("browser-tree-reap-unconfirmed".into()), + } + } + } +} + +impl Drop for BrowserProcessGuard { + fn drop(&mut self) { + // Windows:Job 句柄关闭触发 KILL_ON_JOB_CLOSE 收掉整树; + // Unix:tokio kill_on_drop 终止 root,子进程随 IPC 断开级联退出。 + } +} + +struct OwnedBrowser { + browser: Browser, + process: BrowserProcessGuard, + handler_task: tokio::task::JoinHandle<()>, + drain_task: tokio::task::JoinHandle<()>, + // Option 以便在收割未确认时取出并保留目录(OwnedBrowser 有 Drop, + // 不能直接移动字段)。 + temp: Option, +} + +impl OwnedBrowser { + fn browser(&self) -> &Browser { + &self.browser + } + + /// 优雅收束:CDP close → wait;失败则整树终止并确认收割。 + async fn shutdown(mut self) -> Result<(), String> { + let close = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.browser.close()).await; + let waited = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.process.child.wait()).await; + self.handler_task.abort(); + self.drain_task.abort(); + if matches!(close, Ok(Ok(_))) && matches!(waited, Ok(Ok(_))) { + return Ok(()); + } + if !self.process.reap().await { + // 进程退出未确认:保留目录与 owner.json,留待下次启动或 + // 预检时由跨会话清扫收割,而不是删掉证据让清扫失明。 + if let Some(temp) = self.temp.take() { + std::mem::forget(temp); + } + return Err("browser-cleanup-unconfirmed".into()); + } + let confirmed = self.process.confirm_reaped().await; + if confirmed.is_err() { + if let Some(temp) = self.temp.take() { + std::mem::forget(temp); + } + } + confirmed + } +} + +impl Drop for OwnedBrowser { + fn drop(&mut self) { + self.handler_task.abort(); + self.drain_task.abort(); + // 进程侧由 BrowserProcessGuard 的 Drop 兜底。 + } +} + +/// 自拉浏览器并完成 CDP 连接。spawn 与 Job 绑定之间存在极小的逸出 +/// 窗口:绑定前产生的子进程未入 Job——Unix 上随 root 死亡级联退出; +/// Windows 没有这种级联,但窗口只有毫秒级(Chrome 此时尚未拉起子 +/// 进程),真发生泄漏时临时目录因被占用而保留,留待系统或用户清理。 +/// 绑定之后的一切子进程由 Job 全覆盖。 +async fn launch_owned_browser( + config: BrowserConfig, + executable: &std::path::Path, + temp: TempDir, +) -> Result { + let child = config + .launch() + .map_err(|_| OwnedBrowserLaunchError::SpawnFailed)?; + // 无法证明整树可收割时拒绝放行浏览器;child 随 drop 由 kill_on_drop 收尾。 + #[cfg(windows)] + let job = match WindowsProcessJob::assign_tokio(&child.inner) { + Ok(job) => Some(job), + Err(_) => return Err(OwnedBrowserLaunchError::SpawnFailed), + }; + let mut process = BrowserProcessGuard { + child, + #[cfg(windows)] + job, + }; + // 跨会话清扫的身份锚点:写入失败时该目录之后按旧残留只删不杀。 + if let Some(pid) = process.child.inner.id() { + super::sweep::write_browser_process_owner(temp.path(), pid, executable); + } + let (url, reader) = match devtools_ws_url_from_stderr(&mut process.child, BROWSER_TIMEOUT).await + { + Ok(pair) => pair, + Err(error) => { + // 收割未确认时保留目录与 owner.json,交给跨会话清扫。 + if !process.reap().await { + std::mem::forget(temp); + } + return Err(error); + } + }; + let drain_task = spawn_stderr_drain(reader); + let connected = tokio::time::timeout( + BROWSER_TIMEOUT, + Browser::connect_with_config(url, handler_config()), ) - .await - .map_err(|_| "browser-start-timeout")? - .map_err(|_| "browser-start-failed")?; + .await; + let (browser, mut handler) = match connected { + Ok(Ok(pair)) => pair, + Ok(Err(_)) => { + drain_task.abort(); + if !process.reap().await { + std::mem::forget(temp); + } + return Err(OwnedBrowserLaunchError::ConnectFailed); + } + Err(_) => { + drain_task.abort(); + if !process.reap().await { + std::mem::forget(temp); + } + return Err(OwnedBrowserLaunchError::ConnectTimeout); + } + }; let handler_task = tokio::spawn(async move { while let Some(message) = handler.next().await { if message.is_err() { @@ -85,14 +379,33 @@ pub(crate) async fn check_browser_health() -> Result { } } }); - let version = tokio::time::timeout(Duration::from_secs(5), browser.version()).await; - let close = tokio::time::timeout(Duration::from_secs(5), browser.close()).await; - let waited = tokio::time::timeout(Duration::from_secs(5), browser.wait()).await; - handler_task.abort(); - let _ = handler_task.await; - if !matches!(close, Ok(Ok(_))) || !matches!(waited, Ok(Ok(_))) { - let _ = tokio::time::timeout(Duration::from_secs(5), browser.kill()).await; - let _ = tokio::time::timeout(Duration::from_secs(5), browser.wait()).await; + Ok(OwnedBrowser { + browser, + process, + handler_task, + drain_task, + temp: Some(temp), + }) +} + +/// 仅验证浏览器启动和真实 CDP,不加载项目、不生成试玩凭证。 +/// 每次独立 profile;既不串行化其它工具,也不继承 Codex 的临时 HOME。 +pub(crate) async fn check_browser_health() -> Result { + let discovered = discover_chrome_or_edge().map_err(|_| "browser-not-found")?; + let temporary = create_browser_process_temp_dir().map_err(|_| "browser-temp-unavailable")?; + let config = browser_config(&discovered.executable_path, &temporary, "<-loopback>") + .map_err(|_| "browser-config-invalid")?; + let owned = launch_owned_browser(config, &discovered.executable_path, temporary) + .await + .map_err(|error| { + if error.is_timeout() { + "browser-start-timeout" + } else { + "browser-start-failed" + } + })?; + let version = tokio::time::timeout(Duration::from_secs(5), owned.browser().version()).await; + if owned.shutdown().await.is_err() { return Err("browser-cleanup-failed".into()); } let version = version @@ -155,23 +468,18 @@ pub(crate) async fn validate_local_preview_in_browser_with_cancellation( &proxy_bypass_list, )?; - let (mut browser, mut handler) = tokio::time::timeout( - BROWSER_TIMEOUT + Duration::from_secs(5), - Browser::launch(config), - ) - .await - .map_err(|_| "启动浏览器超时".to_string())? - .map_err(|error| format!("启动浏览器失败:{error}"))?; - let handler_task = tokio::spawn(async move { - while let Some(message) = handler.next().await { - if message.is_err() { - break; + let owned = launch_owned_browser(config, &browser_executable.executable_path, browser_temp) + .await + .map_err(|error| { + if error.is_timeout() { + "启动浏览器超时".to_string() + } else { + format!("启动浏览器失败:{}", error.code()) } - } - }); + })?; let work = run_browser_validation( - &browser, + owned.browser(), &browser_executable, &preview_url, &input, @@ -191,22 +499,11 @@ pub(crate) async fn validate_local_preview_in_browser_with_cancellation( _=cancelled => Err("宿主已停止本轮浏览器验证".to_string()), }; - let close_result = tokio::time::timeout(Duration::from_secs(5), browser.close()).await; - let wait_result = tokio::time::timeout(Duration::from_secs(5), browser.wait()).await; - handler_task.abort(); - let _ = handler_task.await; - if !matches!(close_result, Ok(Ok(_))) || !matches!(wait_result, Ok(Ok(_))) { - let _ = tokio::time::timeout(Duration::from_secs(5), browser.kill()).await; - if !matches!( - tokio::time::timeout(Duration::from_secs(5), browser.wait()).await, - Ok(Ok(_)) - ) { - return Err( - "browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程".into(), - ); - } + if owned.shutdown().await.is_err() { + return Err( + "browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程".into(), + ); } - drop(browser_temp); let mut result = validation?; result.completed_at_unix_ms = unix_time_ms(); let persisted_result = browser_validation_result_for_report(&result)?; @@ -231,6 +528,61 @@ mod health_tests { assert!(safe_version_label("HeadlessChrome/140.0.1.2")); } + #[test] + fn devtools_ws_url_only_accepts_loopback_browser_endpoints() { + assert_eq!( + parse_devtools_ws_url( + "DevTools listening on ws://127.0.0.1:53317/devtools/browser/abc-123\r\n" + ) + .as_deref(), + Some("ws://127.0.0.1:53317/devtools/browser/abc-123") + ); + assert!(parse_devtools_ws_url( + "DevTools listening on ws://localhost:9222/devtools/browser/x" + ) + .is_some()); + assert!( + parse_devtools_ws_url("DevTools listening on ws://[::1]:9222/devtools/browser/x") + .is_some() + ); + // 非 loopback、非 ws 协议、非 browser 端点、非法端口一律拒绝。 + assert!(parse_devtools_ws_url( + "DevTools listening on ws://evil.example.com:9222/devtools/browser/x" + ) + .is_none()); + assert!(parse_devtools_ws_url( + "DevTools listening on http://127.0.0.1:9222/devtools/browser/x" + ) + .is_none()); + assert!( + parse_devtools_ws_url("DevTools listening on ws://127.0.0.1:9222/devtools/page/x") + .is_none() + ); + assert!(parse_devtools_ws_url( + "DevTools listening on ws://127.0.0.1:notaport/devtools/browser/x" + ) + .is_none()); + assert!(parse_devtools_ws_url("random browser log line").is_none()); + let overlong = format!( + "DevTools listening on ws://127.0.0.1:9222/devtools/browser/{}", + "a".repeat(MAX_WS_URL_LEN) + ); + assert!(parse_devtools_ws_url(&overlong).is_none()); + } + + #[test] + fn handler_config_mirrors_browser_config_builder() { + let config = handler_config(); + assert_eq!(config.request_timeout, BROWSER_TIMEOUT); + assert!(config.request_intercept); + assert!(!config.cache_enabled); + // 其余字段必须与 BrowserConfigBuilder 默认值保持一致 + // (builder 默认 viewport 为 Some(Viewport::default()),即 800x600)。 + assert!(config.ignore_https_errors); + assert!(config.ignore_invalid_messages); + assert_eq!(config.viewport, Some(Viewport::default())); + } + #[tokio::test] #[ignore = "requires an installed Chrome/Chromium/Edge; local CDP only"] async fn real_browser_health_checks_can_run_concurrently() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs new file mode 100644 index 000000000..21c681161 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs @@ -0,0 +1,613 @@ +//! 跨会话清扫陈旧的无头浏览器进程与临时目录。 +//! +//! 运行期 launch 会把 owner.json 写进 ga-browser-* 目录;启动与每次预检前 +//! 按「进程身份 + 可信浏览器路径」双重校验收割遗留进程树并删除目录。 +//! 没有 owner.json 的旧版本残留只删目录,绝不按猜测杀进程。 + +use std::fs; +use std::path::Path; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use super::discovery::system_browser_candidates; +use super::process::{browser_process_temp_root, BROWSER_TEMP_PREFIX}; + +const OWNER_FILE: &str = "owner.json"; +const OWNER_SCHEMA: &str = "agc-browser-process.v1"; +/// 过新的目录可能属于正在进行中的预检(含 owner.json 写失败的极端情况),跳过。 +const SWEEP_MIN_AGE: Duration = Duration::from_secs(5 * 60); +const TREE_KILL_MAX_PASSES: u32 = 20; +const TREE_KILL_PASS_INTERVAL: Duration = Duration::from_millis(50); + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct BrowserProcessOwner { + schema_version: String, + browser_pid: u32, + browser_start_identity: String, + executable: String, + owner_pid: u32, + owner_start_identity: String, + created_unix_ms: u64, +} + +/// 运行期 launch 的身份锚点;任何一步拿不到身份证明都不写, +/// 该目录之后按旧残留只删不杀。 +pub(super) fn write_browser_process_owner(temp_root: &Path, browser_pid: u32, executable: &Path) { + let identity = crate::runner::external_agent_runner_process_start_identity(browser_pid); + let owner_identity = + crate::runner::external_agent_runner_process_start_identity(std::process::id()); + let (Ok(Some(identity)), Ok(Some(owner_identity))) = (identity, owner_identity) else { + return; + }; + let owner = BrowserProcessOwner { + schema_version: OWNER_SCHEMA.into(), + browser_pid, + browser_start_identity: identity, + executable: executable.to_string_lossy().into_owned(), + owner_pid: std::process::id(), + owner_start_identity: owner_identity, + created_unix_ms: super::evidence::unix_time_ms(), + }; + let Ok(bytes) = serde_json::to_vec_pretty(&owner) else { + return; + }; + let _ = fs::write(temp_root.join(OWNER_FILE), bytes); +} + +fn read_browser_process_owner(dir: &Path) -> Option { + let bytes = fs::read(dir.join(OWNER_FILE)).ok()?; + if bytes.len() > 16 * 1024 { + return None; + } + let owner: BrowserProcessOwner = serde_json::from_slice(&bytes).ok()?; + if owner.schema_version != OWNER_SCHEMA + || owner.browser_pid == 0 + || owner.owner_pid == 0 + || owner.browser_start_identity.is_empty() + || owner.browser_start_identity.len() > 128 + || owner.owner_start_identity.is_empty() + || owner.owner_start_identity.len() > 128 + || owner.executable.is_empty() + || owner.executable.len() > 1024 + { + return None; + } + Some(owner) +} + +/// PID 存活且启动身份一致(创建时间相同),排除 PID 复用。 +fn process_identity_matches(pid: u32, identity: &str) -> bool { + crate::runner::external_agent_runner_process_start_identity(pid) + .ok() + .flatten() + .is_some_and(|live| live == identity) +} + +/// 可执行文件路径等价性:双方规范化后比较,Windows 下忽略大小写。 +fn same_executable_path(actual: &Path, expected: &Path) -> bool { + let canonical = |path: &Path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + let actual = canonical(actual); + let expected = canonical(expected); + #[cfg(windows)] + { + return actual + .to_string_lossy() + .eq_ignore_ascii_case(&expected.to_string_lossy()); + } + #[cfg(not(windows))] + { + actual == expected + } +} + +/// exe 必须仍是系统标准路径下的可信浏览器,防止伪造 owner.json 借刀杀进程。 +fn trusted_browser_executable(path: &Path) -> bool { + let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + if !canonical.is_file() { + return false; + } + system_browser_candidates() + .into_iter() + .any(|(candidate, _)| same_executable_path(&candidate, path)) +} + +/// 杀前复核:PID 对应的活进程镜像必须就是 owner.json 声明的那个可执行 +/// 文件。仅核对字符串不够——/tmp 全局可写时,同机其他用户可以伪造 +/// owner.json 把 browser_pid 指到本用户的任意进程借清扫杀之。 +/// Linux 进一步要求命令行声明本目录的 profile,把 PID 绑到这份配置 +/// 目录,挡住同用户伪造 owner.json 指向正在使用的浏览器。 +#[cfg(windows)] +fn live_process_executable_matches(pid: u32, expected: &Path, _profile_dir: &Path) -> bool { + // 已知残余风险:Windows 读他进程命令行成本高(PEB/WMI),此处只核对 + // 镜像;同用户伪造 owner.json 指向正在使用的浏览器时可误杀它。 + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Threading::{ + OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION, + }; + // SAFETY: 句柄非空时由 CloseHandle 释放;打开失败按不匹配处理(fail-closed)。 + let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if handle.is_null() { + return false; + } + let mut buffer = [0u16; 1024]; + let mut length = buffer.len() as u32; + // SAFETY: buffer 可写,length 先传入容量、返回实际长度。 + let okay = unsafe { QueryFullProcessImageNameW(handle, 0, buffer.as_mut_ptr(), &mut length) }; + // SAFETY: handle 是本函数持有的合法句柄。 + unsafe { CloseHandle(handle) }; + if okay == 0 || length == 0 { + return false; + } + let actual = std::path::PathBuf::from(String::from_utf16_lossy(&buffer[..length as usize])); + same_executable_path(&actual, expected) +} + +#[cfg(target_os = "linux")] +fn live_process_executable_matches(pid: u32, expected: &Path, profile_dir: &Path) -> bool { + let Ok(actual) = fs::read_link(format!("/proc/{pid}/exe")) else { + return false; + }; + if !same_executable_path(&actual, expected) { + return false; + } + // 命令行必须携带 --user-data-dir=<本目录>/profile:镜像一致只证明是 + // 浏览器,不能证明是“这轮预检的”浏览器。 + let Ok(cmdline) = fs::read(format!("/proc/{pid}/cmdline")) else { + return false; + }; + let marker = format!("--user-data-dir={}", profile_dir.to_string_lossy()); + cmdline + .split(|byte| *byte == 0) + .any(|arg| arg == marker.as_bytes()) +} + +// macOS 等无 /proc 的平台没有廉价的镜像与命令行核对手段,由目录属主检查 +// 兜底;已知残余风险:同用户伪造 owner.json 时可终止任意存活 PID。 +#[cfg(all(unix, not(target_os = "linux")))] +fn live_process_executable_matches(_pid: u32, expected: &Path, _profile_dir: &Path) -> bool { + expected.is_file() +} + +/// /tmp 是全局可写目录:属主目录必须由本用户创建,否则视为伪造并拒绝处置。 +#[cfg(unix)] +fn directory_owned_by_current_user(dir: &Path) -> bool { + use std::os::unix::fs::MetadataExt; + dir.metadata() + // SAFETY: geteuid 无前置条件,总是安全的。 + .map(|metadata| metadata.uid() == unsafe { libc::geteuid() }) + .unwrap_or(false) +} + +fn kill_browser_process_tree(root_pid: u32, expected_identity: &str) -> Result<(), String> { + #[cfg(windows)] + { + // 追踪所有经确认属于这棵树的 PID;只有它们全部从快照中消失才判 + // 成功——root 先死而子进程残留时不再误报收割完成。 + let mut tracked: Vec<(u32, String)> = Vec::new(); + for _ in 0..TREE_KILL_MAX_PASSES { + let snapshot = windows_process_snapshot()?; + let root_present = snapshot.iter().any(|(pid, _)| *pid == root_pid); + if root_present && process_identity_matches(root_pid, expected_identity) { + // root 仍是目标浏览器:发现并追踪当前整棵子树。 + for pid in windows_process_tree_pids_from(&snapshot, root_pid)? { + track_process(&mut tracked, pid); + } + } else if !root_present { + // root 已退出且 PID 未被复用:发现临终前才拉起、仍挂在旧父 + // PID 上的孤儿子进程。PID 已被复用时不做发现,避免误认 + // 复用者的子进程。 + for (pid, ppid) in &snapshot { + if *ppid == root_pid { + track_process(&mut tracked, *pid); + } + } + } + // 只统计仍存活且启动身份未变的成员(复核排除 PID 复用); + // 全部消失才算收割完成。 + tracked.retain(|(pid, identity)| { + snapshot.iter().any(|(live, _)| live == pid) + && process_identity_matches(*pid, identity) + }); + if tracked.is_empty() { + return Ok(()); + } + for (pid, _) in &tracked { + // 终止结果以“下一轮快照中是否消失”验证;对正在退出的进程 + // OpenProcess 的瞬时失败会在下一轮自然消解。 + windows_terminate_process(*pid); + } + std::thread::sleep(TREE_KILL_PASS_INTERVAL); + } + return Err("browser-sweep-tree-not-reaped".into()); + } + #[cfg(not(windows))] + { + // 只杀 root:子进程随 IPC 断开级联退出(与运行期收割语义一致)。 + if !process_identity_matches(root_pid, expected_identity) { + return Ok(()); + } + // SAFETY: 目标 PID 与启动身份均已复核。 + if unsafe { libc::kill(root_pid as i32, libc::SIGKILL) } != 0 { + return Err("browser-sweep-kill-failed".into()); + } + // SIGKILL 后必须确认进程真正消失再报成功。 + for _ in 0..TREE_KILL_MAX_PASSES { + // SAFETY: signal 0 仅做存活探测,无副作用。 + if unsafe { libc::kill(root_pid as i32, 0) } != 0 { + return Ok(()); + } + std::thread::sleep(TREE_KILL_PASS_INTERVAL); + } + Err("browser-sweep-tree-not-reaped".into()) + } +} + +#[cfg(windows)] +/// 记录待收割成员及其启动身份:之后每轮终止前复核身份,排除 PID 复用 +/// 后误杀无关进程。拿不到身份的进程不追踪、不触碰(fail-closed)。 +#[cfg(windows)] +fn track_process(tracked: &mut Vec<(u32, String)>, pid: u32) { + if tracked.iter().any(|(known, _)| *known == pid) { + return; + } + if let Ok(Some(identity)) = crate::runner::external_agent_runner_process_start_identity(pid) { + tracked.push((pid, identity)); + } +} + +#[cfg(windows)] +fn windows_process_snapshot() -> Result, String> { + use windows_sys::Win32::Foundation::{ + CloseHandle, GetLastError, ERROR_NO_MORE_FILES, INVALID_HANDLE_VALUE, + }; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }; + // SAFETY: 快照句柄非 INVALID_HANDLE_VALUE 时由 CloseHandle 释放。 + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; + if snapshot == INVALID_HANDLE_VALUE { + return Err("browser-sweep-snapshot-failed".into()); + } + let mut entry = PROCESSENTRY32W::default(); + entry.dwSize = std::mem::size_of::() as u32; + let mut processes = Vec::new(); + // SAFETY: entry 指向可写的 PROCESSENTRY32W,dwSize 已初始化。返回 0 + // 只在 ERROR_NO_MORE_FILES 时是正常结束,其余错误按截断处理 + // (fail-closed:残缺快照会导致误报收割完成)。 + let mut available = unsafe { Process32FirstW(snapshot, &mut entry) }; + loop { + if available == 0 { + // SAFETY: 紧跟失败的枚举调用读取错误码。 + let error = unsafe { GetLastError() }; + // SAFETY: snapshot 是本函数持有的合法句柄。 + unsafe { CloseHandle(snapshot) }; + if error == ERROR_NO_MORE_FILES { + return Ok(processes); + } + return Err("browser-sweep-snapshot-truncated".into()); + } + processes.push((entry.th32ProcessID, entry.th32ParentProcessID)); + // SAFETY: 同上。 + available = unsafe { Process32NextW(snapshot, &mut entry) }; + } +} + +#[cfg(windows)] +fn windows_process_tree_pids_from( + processes: &[(u32, u32)], + root_pid: u32, +) -> Result, String> { + if !processes.iter().any(|(pid, _)| *pid == root_pid) { + return Ok(Vec::new()); + } + let mut tree = vec![root_pid]; + let mut index = 0; + while index < tree.len() { + let parent = tree[index]; + for (pid, ppid) in processes { + if *ppid == parent && !tree.contains(pid) { + tree.push(*pid); + } + } + index += 1; + if tree.len() > 512 { + return Err("browser-sweep-tree-too-large".into()); + } + } + Ok(tree) +} + +#[cfg(windows)] +fn windows_terminate_process(pid: u32) { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess}; + const PROCESS_TERMINATE: u32 = 0x0001; + // SAFETY: 句柄非空时由 CloseHandle 释放;空句柄直接返回。 + let handle = unsafe { OpenProcess(PROCESS_TERMINATE, 0, pid) }; + if handle.is_null() { + return; + } + // SAFETY: handle 是刚打开的合法进程句柄,退出码仅作占位。 + unsafe { TerminateProcess(handle, 1) }; + // SAFETY: 同上。 + unsafe { CloseHandle(handle) }; +} + +/// 清扫陈旧的无头浏览器进程与临时目录,返回逐条处置记录供启动日志留痕。 +pub(crate) fn sweep_stale_browser_processes() -> Vec { + sweep_stale_browser_processes_at(&browser_process_temp_root(), SWEEP_MIN_AGE) +} + +fn sweep_stale_browser_processes_at(root: &Path, min_age: Duration) -> Vec { + let mut notes = Vec::new(); + let Ok(entries) = fs::read_dir(root) else { + return notes; + }; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if !name.starts_with(BROWSER_TEMP_PREFIX) { + continue; + } + let dir = entry.path(); + let Ok(metadata) = entry.metadata() else { + continue; + }; + if !metadata.is_dir() { + continue; + } + let too_young = metadata + .created() + .or_else(|_| metadata.modified()) + .ok() + .and_then(|time| time.elapsed().ok()) + .is_some_and(|age| age < min_age); + if too_young { + continue; + } + match read_browser_process_owner(&dir) { + Some(owner) => sweep_owned_directory(&dir, &name, &owner, &mut notes), + None => match fs::remove_dir_all(&dir) { + Ok(()) => notes.push(format!("removed-legacy-dir:{name}")), + Err(_) => notes.push(format!("kept-legacy-dir-in-use:{name}")), + }, + } + } + notes +} + +fn sweep_owned_directory( + dir: &Path, + name: &str, + owner: &BrowserProcessOwner, + notes: &mut Vec, +) { + // /tmp 全局可写:属主目录必须是本用户创建,否则视为伪造,拒绝处置。 + #[cfg(unix)] + if !directory_owned_by_current_user(dir) { + notes.push(format!("kept-foreign-dir:{name}")); + return; + } + // 另一个活着的 App 实例仍持有这轮浏览器:整目录跳过。 + if process_identity_matches(owner.owner_pid, &owner.owner_start_identity) { + return; + } + if !process_identity_matches(owner.browser_pid, &owner.browser_start_identity) { + // 进程已退出或 PID 已被复用:不动任何进程,仅删目录。 + if fs::remove_dir_all(dir).is_ok() { + notes.push(format!("removed-exited-dir:{name}")); + } + return; + } + if !trusted_browser_executable(Path::new(&owner.executable)) { + notes.push(format!("kept-untrusted-executable:{name}")); + return; + } + // 活进程镜像必须与声明的浏览器一致:防止伪造 owner.json 借清扫 + // 终止本用户的无关进程。profile 子目录名与 process.rs 的 + // browser_config 保持一致;Linux 上借此把 PID 绑到本目录。 + if !live_process_executable_matches( + owner.browser_pid, + Path::new(&owner.executable), + &dir.join("profile"), + ) { + notes.push(format!("kept-image-mismatch:{name}")); + return; + } + match kill_browser_process_tree(owner.browser_pid, &owner.browser_start_identity) { + Ok(()) => { + let _ = fs::remove_dir_all(dir); + notes.push(format!( + "killed-stale-tree:{name}:pid={}", + owner.browser_pid + )); + } + Err(_) => notes.push(format!("kill-unconfirmed:{name}:pid={}", owner.browser_pid)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn exited_child_pid() -> u32 { + #[cfg(windows)] + let mut command = std::process::Command::new("cmd.exe"); + #[cfg(windows)] + command.args(["/c", "exit", "0"]); + #[cfg(not(windows))] + let mut command = std::process::Command::new("/bin/true"); + let mut child = command.spawn().expect("spawn exit fixture"); + let pid = child.id(); + child.wait().expect("reap exit fixture"); + pid + } + + fn write_owner(dir: &Path, owner: &BrowserProcessOwner) { + fs::write( + dir.join(OWNER_FILE), + serde_json::to_vec_pretty(owner).unwrap(), + ) + .unwrap(); + } + + fn owner_fixture(browser_pid: u32, owner_pid: u32) -> BrowserProcessOwner { + BrowserProcessOwner { + schema_version: OWNER_SCHEMA.into(), + browser_pid, + browser_start_identity: "fixture-identity".into(), + executable: "C:\\fixture\\chrome.exe".into(), + owner_pid, + owner_start_identity: "fixture-owner-identity".into(), + created_unix_ms: super::super::evidence::unix_time_ms(), + } + } + + #[test] + fn legacy_directory_without_owner_file_is_removed_only_when_old_enough() { + let root = tempfile::tempdir().unwrap(); + let stale = root.path().join(format!("{BROWSER_TEMP_PREFIX}stale")); + fs::create_dir(&stale).unwrap(); + // 默认年龄门禁:新建的目录视为可能属于进行中的预检,跳过。 + assert!(sweep_stale_browser_processes_at(root.path(), SWEEP_MIN_AGE).is_empty()); + assert!(stale.is_dir()); + // 零门禁等价于“足够旧”:无 owner.json 的旧残留只删目录。 + let notes = sweep_stale_browser_processes_at(root.path(), Duration::ZERO); + assert!(notes + .iter() + .any(|note| note.starts_with("removed-legacy-dir:"))); + assert!(!stale.exists()); + } + + #[test] + fn directory_owned_by_a_live_app_instance_is_skipped() { + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join(format!("{BROWSER_TEMP_PREFIX}owned")); + fs::create_dir(&dir).unwrap(); + let owner_identity = + crate::runner::external_agent_runner_process_start_identity(std::process::id()) + .expect("self identity") + .expect("self identity present"); + let mut owner = owner_fixture(exited_child_pid(), std::process::id()); + owner.owner_start_identity = owner_identity; + write_owner(&dir, &owner); + assert!(sweep_stale_browser_processes_at(root.path(), Duration::ZERO).is_empty()); + assert!(dir.is_dir()); + } + + #[test] + fn exited_or_reused_browser_pid_deletes_directory_without_touching_processes() { + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join(format!("{BROWSER_TEMP_PREFIX}exited")); + fs::create_dir(&dir).unwrap(); + let owner = owner_fixture(exited_child_pid(), exited_child_pid()); + write_owner(&dir, &owner); + let notes = sweep_stale_browser_processes_at(root.path(), Duration::ZERO); + assert!(notes + .iter() + .any(|note| note.starts_with("removed-exited-dir:"))); + assert!(!dir.exists()); + } + + #[test] + fn owner_file_with_bad_schema_or_unknown_fields_is_treated_as_legacy() { + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join(format!("{BROWSER_TEMP_PREFIX}bad-owner")); + fs::create_dir(&dir).unwrap(); + fs::write( + dir.join(OWNER_FILE), + br#"{"schemaVersion":"other","extra":true}"#, + ) + .unwrap(); + assert!(read_browser_process_owner(&dir).is_none()); + let notes = sweep_stale_browser_processes_at(root.path(), Duration::ZERO); + assert!(notes + .iter() + .any(|note| note.starts_with("removed-legacy-dir:"))); + assert!(!dir.exists()); + } + + #[test] + fn trusted_browser_executable_matches_system_candidates_only() { + assert!(!trusted_browser_executable(Path::new( + "C:\\fixture\\chrome.exe" + ))); + let Some((installed, _)) = system_browser_candidates() + .into_iter() + .find(|(candidate, _)| candidate.is_file()) + else { + // 没有安装浏览器的机器上无可信目标可断言。 + return; + }; + assert!(trusted_browser_executable(&installed)); + } + + #[cfg(windows)] + #[test] + fn kill_browser_process_tree_reaps_fixture_tree() { + use std::process::{Command, Stdio}; + + // timeout.exe 在 stdio 被重定向时会立即退出,ping 才能在 null stdio 下存活。 + let mut child = Command::new("cmd.exe") + .args([ + "/c", + "ping", + "127.0.0.1", + "-n", + "60", + "&", + "ping", + "127.0.0.1", + "-n", + "60", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn fixture"); + let pid = child.id(); + std::thread::sleep(Duration::from_millis(500)); + let identity = crate::runner::external_agent_runner_process_start_identity(pid) + .expect("fixture identity") + .expect("fixture identity present"); + let before = + windows_process_tree_pids_from(&windows_process_snapshot().expect("snapshot"), pid) + .expect("tree snapshot"); + assert!(before.len() >= 2, "fixture 必须包含子进程: {before:?}"); + kill_browser_process_tree(pid, &identity).expect("kill tree"); + // 不仅 root 消失,快照里记录到的每个树成员(含 ping 子进程)都必须 + // 真正被收割,不能只凭 root 不在就判成功。 + let alive = windows_process_snapshot().expect("snapshot after kill"); + for member in &before { + assert!( + !alive.iter().any(|(live, _)| live == member), + "fixture 进程 {member} 必须被收割: {before:?}" + ); + } + let _ = child.kill(); + let _ = child.wait(); + } + + #[cfg(any(windows, target_os = "linux"))] + #[test] + fn live_process_executable_matches_current_process_image() { + let exe = std::env::current_exe().unwrap(); + let profile = Path::new("C:\\fixture\\ga-browser-x\\profile"); + // Windows 只核对镜像;Linux 还要求命令行绑定 profile,本测试进程 + // 不具备该标记,正例只在 Windows 断言。 + #[cfg(windows)] + assert!(live_process_executable_matches( + std::process::id(), + &exe, + profile + )); + assert!(!live_process_executable_matches( + std::process::id(), + Path::new("C:\\fixture\\chrome.exe"), + profile + )); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs b/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs index 532503c75..4378f32d7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs @@ -304,6 +304,8 @@ async fn host_npm( pub(crate) async fn host_web_creation_preflight() -> Value { let started = Instant::now(); + // 预检前顺手清扫陈旧的无头浏览器,避免残留进程放大本轮超时。 + let _ = tokio::task::spawn_blocking(crate::browser::sweep_stale_browser_processes).await; let run = async { let fixture = tempfile::tempdir().map_err(|_| "web-preflight-temp-unavailable")?; let root = fixture.path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 5f3cfe2e6..263f74d52 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2467,6 +2467,17 @@ fn main() { if let Some(directory) = game_creator_runtime_config_dir() { setup_log.set(directory.join("diagnostics/startup.log")); } + // 跨会话清扫陈旧的无头浏览器(上次异常退出/被杀留下的 ga-browser-*)。 + // 后台执行,不阻塞启动;杀树前按进程身份与可信浏览器路径双重校验。 + tauri::async_runtime::spawn(async move { + let notes = + tokio::task::spawn_blocking(crate::browser::sweep_stale_browser_processes) + .await + .unwrap_or_default(); + if !notes.is_empty() { + app_log!("startup.browser-sweep: {}", notes.join("; ")); + } + }); setup_log.append("startup.appdata.configure.complete"); let config_dir = game_creator_runtime_config_dir().ok_or_else(|| { let error = std::io::Error::new( diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs index d963ea811..c96ef974c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs @@ -1810,3 +1810,112 @@ setInterval(() => {}, 1000); thread::sleep(Duration::from_millis(25)); } } + +#[cfg(windows)] +#[test] +fn windows_process_job_terminate_reaps_process_tree() { + use std::process::{Command, Stdio}; + + // 系统进程快照(pid, ppid),用于证明 ping 子进程真实存在并被收割。 + fn windows_process_snapshot_for_test() -> Vec<(u32, u32)> { + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }; + // SAFETY: 快照句柄非 INVALID_HANDLE_VALUE 时由 CloseHandle 释放。 + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; + if snapshot == INVALID_HANDLE_VALUE { + return Vec::new(); + } + let mut entry = PROCESSENTRY32W::default(); + entry.dwSize = std::mem::size_of::() as u32; + let mut processes = Vec::new(); + // SAFETY: entry 指向可写的 PROCESSENTRY32W,dwSize 已初始化。 + let mut available = unsafe { Process32FirstW(snapshot, &mut entry) }; + while available != 0 { + processes.push((entry.th32ProcessID, entry.th32ParentProcessID)); + // SAFETY: 同上。 + available = unsafe { Process32NextW(snapshot, &mut entry) }; + } + // SAFETY: snapshot 是本函数持有的合法句柄。 + unsafe { CloseHandle(snapshot) }; + processes + } + + // cmd 启动第一个 ping 子进程后整树存活;terminate 必须连子进程一起收割。 + // timeout.exe 在 stdio 被重定向时会立即退出,ping 才能在 null stdio 下存活。 + let mut child = Command::new("cmd.exe") + .args([ + "/c", + "ping", + "127.0.0.1", + "-n", + "60", + "&", + "ping", + "127.0.0.1", + "-n", + "60", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn cmd fixture"); + let job = WindowsProcessJob::assign_std(&child).expect("assign job"); + thread::sleep(Duration::from_millis(500)); + // 前置检查不能只凭 Job 非空:快照里必须真的看到 cmd 拉起了 ping 子进程。 + let tree_before: Vec = { + let snapshot = windows_process_snapshot_for_test(); + let mut tree = vec![child.id()]; + let mut index = 0; + while index < tree.len() { + let parent = tree[index]; + for (pid, ppid) in &snapshot { + if *ppid == parent && !tree.contains(pid) { + tree.push(*pid); + } + } + index += 1; + } + tree + }; + assert!( + tree_before.len() >= 2, + "fixture 必须包含 ping 子进程,否则收割断言是空转: {tree_before:?}" + ); + assert!( + !job.is_empty().expect("query job"), + "fixture 进程树必须先存活,否则收割断言是空转" + ); + job.terminate().expect("terminate job"); + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while !job.is_empty().expect("query job") { + assert!( + std::time::Instant::now() < deadline, + "Windows Job 进程树未被收割" + ); + thread::sleep(Duration::from_millis(50)); + } + // Job 为空之外,快照里的整棵树(含 ping 子进程)也必须真实消失。 + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + let snapshot = windows_process_snapshot_for_test(); + let survivors: Vec = tree_before + .iter() + .copied() + .filter(|pid| snapshot.iter().any(|(live, _)| live == pid)) + .collect(); + if survivors.is_empty() { + break; + } + assert!( + std::time::Instant::now() < deadline, + "Job terminate 后 fixture 子进程仍存活: {survivors:?}" + ); + thread::sleep(Duration::from_millis(50)); + } + let _ = child.kill(); + let _ = child.wait(); +} diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 7884f201f..493f3ba80 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -170,7 +170,6 @@ type AppProps = { initialProjectManifest?: GameCreationAppManifest; initialProjectKind?: LocalProjectKind; planningStartMode?: boolean; - activeVersionId?: ProjectChatComponentProps['activeVersionId']; initialPlanningPrompt?: string; initialPlanningPromptClaimScope?: string; initialCreationType?: HomeCreationType | null; @@ -207,7 +206,6 @@ export function App({ initialProjectManifest, initialProjectKind = 'web', planningStartMode = false, - activeVersionId = null, initialPlanningPrompt = '', initialPlanningPromptClaimScope = '', initialCreationType = null, @@ -2199,11 +2197,6 @@ export function App({ const chatProjectAssets = manifest.assets.filter( (asset) => asset.localPath && !asset.localPath.startsWith('.agent/'), ); - // `@` 面板「当前版本素材」的版本来源:版本列表来自 manifest, - // 当前版本由工作台壳(`WorkspaceLauncherShell`)持有的那一份状态给出, - // 传 `null` 表示回退到 manifest 中最新的版本。 - const chatProjectVersions = manifest.versions ?? []; - const chatActiveVersionId = activeVersionId; const visibleMessages = latestVisibleItems( messages, @@ -2332,7 +2325,6 @@ export function App({ <> void handleDesignComposerUploadFiles(files)} - versions={chatProjectVersions} /> {runtimeConfigOpen ? ( void; planningStartMode?: boolean; - /** - * C7 当前游戏版本:由工作台壳持有,策划聊天里的 `@` 面板按它切「当前版本素材」。 - * `null` 表示回退到 manifest 中最新的版本。 - */ - activeVersionId?: string | null; playRequest?: { projectPath: string; requestId: number; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceChip.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceChip.tsx index f86fbc6b4..d1cbf408b 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceChip.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceChip.tsx @@ -2,13 +2,13 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext import { $getNodeByKey, type NodeKey } from 'lexical'; import { Paperclip, X } from 'lucide-react'; -import type { DirectCodexUserAttachmentReferencePart } from '../../view/project-development/chat/generated/DirectCodexUserAttachmentReferencePart'; +import type { AttachmentReference } from './resourceReferences'; export function AttachmentReferenceChip({ attachment, nodeKey, }: { - attachment: DirectCodexUserAttachmentReferencePart; + attachment: AttachmentReference; nodeKey: NodeKey; }) { const [editor] = useLexicalComposerContext(); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceNode.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceNode.tsx deleted file mode 100644 index 5e4225c38..000000000 --- a/apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceNode.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { - $applyNodeReplacement, - DecoratorNode, - type EditorConfig, - type LexicalNode, - type NodeKey, - type SerializedLexicalNode, - type Spread, -} from 'lexical'; -import type { ReactNode } from 'react'; - -import type { DirectCodexUserAttachmentReferencePart } from '../../view/project-development/chat/generated/DirectCodexUserAttachmentReferencePart'; -import { AttachmentReferenceChip } from './AttachmentReferenceChip'; - -export type SerializedAttachmentReferenceNode = Spread< - { - attachment: DirectCodexUserAttachmentReferencePart; - type: 'attachment-reference'; - version: 1; - }, - SerializedLexicalNode ->; - -/** 附件在纯文本里的唯一占位字符:一个附件 chip 就当一个字符。 */ -const OBJECT_REPLACEMENT = '\uFFFC'; - -/** - * Inline canonical attachment part rendered inside the Lexical composer. - * The node stores the complete part so read-back preserves the project path and status. - */ -export class AttachmentReferenceNode extends DecoratorNode { - __attachment: DirectCodexUserAttachmentReferencePart; - - static getType() { - return 'attachment-reference'; - } - - static clone(node: AttachmentReferenceNode) { - return new AttachmentReferenceNode(node.__attachment, node.__key); - } - - static importJSON(serializedNode: SerializedAttachmentReferenceNode) { - return $createAttachmentReferenceNode(serializedNode.attachment); - } - - constructor( - attachment: DirectCodexUserAttachmentReferencePart, - key?: NodeKey, - ) { - super(key); - this.__attachment = attachment; - } - - exportJSON(): SerializedAttachmentReferenceNode { - return { - ...super.exportJSON(), - attachment: this.__attachment, - type: 'attachment-reference', - version: 1, - }; - } - - createDOM(_config: EditorConfig) { - return document.createElement('span'); - } - - updateDOM() { - return false; - } - - getTextContent() { - return OBJECT_REPLACEMENT; - } - - getTextContentSize() { - return OBJECT_REPLACEMENT.length; - } - - isInline() { - return true; - } - - isKeyboardSelectable() { - return true; - } - - decorate() { - return ( - - ); - } -} - -export function $createAttachmentReferenceNode( - attachment: DirectCodexUserAttachmentReferencePart, -) { - return $applyNodeReplacement(new AttachmentReferenceNode(attachment)); -} - -export function $isAttachmentReferenceNode( - node: LexicalNode | null | undefined, -): node is AttachmentReferenceNode { - return node instanceof AttachmentReferenceNode; -} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceChip.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceChip.tsx index 80a5d81c3..c67090dc2 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceChip.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceChip.tsx @@ -11,9 +11,20 @@ function chipTitle(reference: ChatReference) { if (reference.type === 'skill') { return `${reference.name} · Skill`; } + if (reference.type === 'attachment') { + return `${reference.name} · 附件`; + } return `${reference.label} · 运行区域`; } +/** chip 显示的引用名:附件有自己的芯片(`AttachmentReferenceChip`),这里只是把联合收窄。 */ +function chipLabel(reference: ChatReference) { + if (reference.type === 'skill' || reference.type === 'attachment') { + return reference.name; + } + return reference.label; +} + export function ResourceReferenceChip({ reference, nodeKey, @@ -39,11 +50,11 @@ export function ResourceReferenceChip({ > - {reference.type === 'skill' ? reference.name : reference.label} + {chipLabel(reference)} + {polishedOriginalText !== null ? ( + + ) : null} + + ) : null} + + {/* 提醒面板打开时错误提示只在面板里出现,输入区不重复显示。 */} + {/* 「与原文相同 / 已截断」这类提示也要可见:只报失败会让「润色没变化」看起来像按钮坏了。 */} + {showPolishAction && + !reminderOpen && + (polishing || polishError || polishNotice) ? ( + + {polishing ? '润色中…' : (polishError ?? polishNotice)} + + ) : null} + {providers.map((provider) => + provider.trigger ? ( + + ) : null, + )} + {reminderOpen ? ( + void polishAndSubmitFromReminder()} + onUseOriginalAndSubmit={useOriginalAndSubmit} + onClose={closeReminder} + onReminderDisabledChange={setPromptPolishReminderDisabled} + /> + ) : null} + { + const nextDraft = readResourceReferenceDraft(editorState); + liveDraftRef.current = nextDraft; + setDraftText( + promptTextFromContent(providersRef.current, nextDraft.content), + ); + if (nextDraft.content.length === 0) { + resetPromptPolish(); + acknowledgedDraftKeyRef.current = null; + } + if (skipInitialDraftChangeRef.current) { + skipInitialDraftChangeRef.current = false; + return; + } + onEditorStateChange?.(editorState); + onChange?.(nextDraft); + }} + /> + + ); +} + +/** + * 一种引用来源的候选菜单:触发符、候选与插入全按 provider 走。 + * + * 输入区不再写死 `@` / `$` 两条链,而是「有几个带触发符的 provider 就有几个菜单」。 + */ +function ProviderMentionMenu({ + provider, + rootRef, + onOpenChange, +}: { + provider: ReferenceProvider; + rootRef: RefObject; + onOpenChange: (trigger: string, open: boolean) => void; +}) { + const trigger = provider.trigger ?? ''; + const [query, setQuery] = useState(null); + const triggerFn = useBasicTypeaheadTriggerMatch(trigger, { + minLength: 0, + maxLength: 64, + allowWhitespace: false, + }); + + const handleQueryChange = useCallback( + (nextQuery: string | null) => { + setQuery(nextQuery); + onOpenChange(trigger, nextQuery !== null); + }, + [onOpenChange, trigger], + ); + + // 懒加载的唯一入口:菜单开合/查询变化经 effect 回调给 provider,`match` 始终保持纯函数, + // 渲染阶段(下面的 useMemo)不会替 provider 发起请求、写 ref 或读清单。 + useEffect(() => { + provider.onMenuQueryChange?.(query); + }, [provider, query]); + + const options = useMemo(() => { + if (query === null || !provider.match) return []; + return provider + .match(query) + .map((reference) => new ReferenceMentionOption(reference)); + }, [provider, query]); + + const renderMenu: MenuRenderFn = useCallback( (_anchorElementRef, itemProps) => { const inputRect = rootRef.current?.getBoundingClientRect(); if (!inputRect || itemProps.options.length === 0) { @@ -1071,16 +867,8 @@ function ResourceReferenceEditor({ onMouseDown={(event) => event.preventDefault()} onClick={() => itemProps.selectOptionAndCleanUp(option)} > - - {'reference' in option - ? `@${option.reference.label}` - : `$${option.skill.name}`} - - - {'reference' in option - ? option.reference.kind - : (option.skill.description ?? 'Skill')} - + {option.label} + {option.hint} ))} , @@ -1090,425 +878,57 @@ function ResourceReferenceEditor({ [rootRef], ); - const pickerReferences = useMemo(() => { - return scopeReferences.filter((reference) => { - if (!resourceReferenceMatchesQuery(reference, pickerQuery)) return false; - if (!resourceReferenceMatchesCategoryFilter(reference, pickerFilter)) { - return false; + const handleSelectOption = useCallback( + ( + option: ReferenceMentionOption, + textNodeContainingQuery: TextNode | null, + closeMenu: () => void, + ) => { + textNodeContainingQuery?.remove(); + const selection = $getSelection(); + const node = $createResourceReferenceNode(option.reference); + if ($isRangeSelection(selection)) { + selection.insertNodes([node, $createTextNode(' ')]); + } else { + const paragraph = $createParagraphNode(); + paragraph.append(node, $createTextNode(' ')); + $getRoot().append(paragraph); } - return resourceReferenceMatchesTagSelection(reference, selectedTags); - }); - }, [pickerFilter, pickerQuery, scopeReferences, selectedTags]); - - const visiblePickerReferences = pickerReferences.slice(0, 120); - - const updatePickerPosition = useCallback(() => { - const rect = rootRef.current?.getBoundingClientRect(); - if (!rect) return; - const viewportPadding = 12; - const gap = 8; - const width = Math.min( - Math.max(rect.width, 360), - Math.max(280, window.innerWidth - viewportPadding * 2), - ); - const left = Math.min( - Math.max(viewportPadding, rect.left), - Math.max(viewportPadding, window.innerWidth - width - viewportPadding), - ); - /** - * 上边界钳制:面板高度**必须**由输入框上下实际可用的空间决定。 - * - * 之前只把底边钉在输入框上方(`bottom: 视口高 - rect.top + 8`)却让高度自由取到 480px, - * 输入框靠上时(居中弹层里的提示词输入、窄屏)整块面板的顶边会被顶出视口——顶部那一排 - * 搜索与筛选既看不见也点不到。这里与 `@` 候选菜单同一套口径:先算上下各有多少空间, - * 空间不足就翻到下方,并把高度收在该侧可用空间内,再对 `top` 兜一次底。 - */ - const availableAbove = Math.max(0, rect.top - viewportPadding - gap); - const availableBelow = Math.max( - 0, - window.innerHeight - rect.bottom - viewportPadding - gap, - ); - const openAbove = availableAbove >= 200 || availableAbove >= availableBelow; - const maxHeight = Math.max( - 160, - Math.min(480, openAbove ? availableAbove : availableBelow), - ); - const top = openAbove - ? Math.max(viewportPadding, rect.top - gap - maxHeight) - : Math.min( - Math.max( - viewportPadding, - window.innerHeight - viewportPadding - maxHeight, - ), - rect.bottom + gap, - ); - setPickerPosition({ - left, - top, - width, - maxHeight, - }); - }, [rootRef]); - - useEffect(() => { - if (!pickerOpen) { - setPickerPosition(null); - return; - } - updatePickerPosition(); - window.addEventListener('resize', updatePickerPosition); - window.addEventListener('scroll', updatePickerPosition, true); - return () => { - window.removeEventListener('resize', updatePickerPosition); - window.removeEventListener('scroll', updatePickerPosition, true); - }; - }, [pickerOpen, updatePickerPosition]); + closeMenu(); + }, + [], + ); return ( - <> -
- {showTriggerButton ? ( - - ) : null} - {showPolishAction ? ( - <> - - {polishedOriginalText !== null ? ( - - ) : null} - - ) : null} -
- {/* 提醒面板打开时错误提示只在面板里出现,输入区不重复显示。 */} - {/* 「与原文相同 / 已截断」这类提示也要可见:只报失败会让「润色没变化」看起来像按钮坏了。 */} - {showPolishAction && - !reminderOpen && - (polishing || polishError || polishNotice) ? ( - - {polishing ? '润色中…' : (polishError ?? polishNotice)} - - ) : null} - - options={mentionOptions} - triggerFn={triggerFn} - onQueryChange={setQuery} - onSelectOption={(option, textNode, closeMenu) => - handleSelectMention(option, textNode, closeMenu) - } - menuRenderFn={ - renderMentionMenu as unknown as MenuRenderFn - } - anchorClassName="resource-reference-menu-anchor" - preselectFirstItem - /> - - options={skillOptions} - triggerFn={skillTriggerFn} - onQueryChange={setSkillQuery} - onSelectOption={(option, textNode, closeMenu) => - handleSelectSkill(option, textNode, closeMenu) - } - menuRenderFn={ - renderMentionMenu as unknown as MenuRenderFn - } - anchorClassName="resource-reference-menu-anchor" - preselectFirstItem - /> - {pickerOpen && pickerPosition - ? createPortal( -
-
- 选择素材 - -
- { - setPickerScopeOverride(nextScope); - }} - gap="sm" - frame="bare" - surface="transparent" - size="sm" - semantics="tabs" - ariaLabel="素材范围" - className="platform-theme platform-theme--light resource-reference-picker-scopes" - /> - {/* 搜索 + 功能分类 + 标签用共享筛选条:与资源画布、参考图弹窗同一套筛选 UI, - 标签口径也来自共享标签库;两个页签各自持有一份独立状态。 */} - updatePickerScopeState({ query: value }), - }} - categoryItems={RESOURCE_REFERENCE_FILTERS} - activeCategoryId={pickerFilter} - onCategoryChange={(nextFilter) => - updatePickerScopeState({ filter: nextFilter }) - } - tagItems={scopeTagItems} - activeTags={selectedTags} - onToggleTag={(tag) => - updatePickerScopeState({ - selectedTags: selectedTags.includes(tag) - ? selectedTags.filter((item) => item !== tag) - : [...selectedTags, tag], - }) - } - className="platform-theme platform-theme--light resource-reference-picker-filters" - /> -
- {visiblePickerReferences.length === 0 ? ( -

- {pickerEmptyMessage(scopeReferences.length, pickerScope)} -

- ) : ( - visiblePickerReferences.map((reference) => { - const selected = selectedResourceIds.includes( - reference.resourceId, - ); - return ( - - ); - }) - )} -
-
- - 已选择 {selectedResourceIds.length} 个 - {pickerReferences.length > visiblePickerReferences.length - ? `,当前显示前 ${visiblePickerReferences.length} 个` - : ''} - - -
-
, - document.body, - ) - : null} - {reminderOpen ? ( - void polishAndSubmitFromReminder()} - onUseOriginalAndSubmit={useOriginalAndSubmit} - onClose={closeReminder} - onReminderDisabledChange={setPromptPolishReminderDisabled} - /> - ) : null} - { - const nextDraft = readResourceReferenceDraft(editorState); - liveDraftRef.current = nextDraft; - setDraftText( - directCodexContentToPromptText(nextDraft.content, assets), - ); - if (nextDraft.content.length === 0) { - resetPromptPolish(); - acknowledgedDraftKeyRef.current = null; - } - if (skipInitialDraftChangeRef.current) { - skipInitialDraftChangeRef.current = false; - return; - } - onEditorStateChange?.(editorState); - onChange?.(nextDraft); - }} - /> - + + options={options} + triggerFn={triggerFn} + onQueryChange={handleQueryChange} + onSelectOption={handleSelectOption} + menuRenderFn={renderMenu} + anchorClassName="resource-reference-menu-anchor" + preselectFirstItem + /> ); } -function ResourcePickerThumbnail({ - asset, - projectPath, -}: { - asset?: GameCreationAppAssetManifestEntry; - projectPath: string; -}) { - const previewScopeIdRef = useRef(createProjectResourcePreviewScopeId()); - const [previewUrl, setPreviewUrl] = useState(null); - const [failed, setFailed] = useState(false); - const mediaType = asset?.mediaType.toLowerCase() ?? ''; - - useEffect(() => { - setPreviewUrl(null); - setFailed(false); - if (!asset || !projectPath || !mediaType.startsWith('image/')) { - return; - } - const invoke = resolveTauriInvoke(); - if (!invoke) return; - let cancelled = false; - void invoke<{ dataUrl: string }>('read_local_project_image_preview', { - projectPath, - relativePath: asset.localPath, - scopeId: previewScopeIdRef.current, - requestId: createProjectResourcePreviewRequestId(), - }) - .then((result) => { - if (!cancelled) setPreviewUrl(result.dataUrl); - }) - .catch(() => { - if (!cancelled) setFailed(true); - }); - return () => { - cancelled = true; - // 只靠 cancelled 标记只是忽略迟到的结果,请求本身还在跑;换素材 / 卸载时 - // 主动作废上一个 scope,避免快速切换素材叠出一串在飞预览请求。 - cancelLocalProjectResourcePreviewScope(previewScopeIdRef.current); - previewScopeIdRef.current = createProjectResourcePreviewScopeId(); - }; - }, [asset, mediaType, projectPath]); - - if (previewUrl) { - return ; - } - if (failed) { - return