// release 构建隐藏控制台:release 的 exe 为 windows 子系统,双击启动不再弹出 // 标题为 exe 路径的命令行窗口(debug 保留控制台便于看 eprintln 诊断)。 #![cfg_attr( all(not(debug_assertions), target_os = "windows"), windows_subsystem = "windows" )] // Deepseek Harness EAC — Tauri ShellHost(ADR 0002 L1;P2 GUI 主链路) // // 运行模式: // dsh-eac-shell → 窗口 + 托盘 + sidecar 常驻 + WS 桥 + boot.start // dsh-eac-shell --bridge-test → 无 GUI,stdio JSON-RPC 驱动 server.js 冒烟 // // 架构对应(docs/adr/0002): // Rust 本体 = L1(窗口/托盘/WS 回环桥/生命周期/导航编排/壳层方法拦截) // Node sidecar = L2(tauri-shell/sidecar/server.js + bridge.js:挂载 // lib/desktop/* 全部模块 + boot-server 服务编排 + 桥方法面) // dsh 内核 = L3(零改动) // // 启动序列: // 1. spawn sidecar(stdio JSON-RPC)+ 绑定 127.0.0.1:19873(WS + HTTP 同端口) // 2. 主窗先加载壳层加载页 /loading(即起即见,initialization_script 注入桥) // 3. boot.start → sidecar 拉起 dsh web(稳定端口 + 受限端口重试 + 探针竞争) // 4. webUrl 回传 → 主窗导航到真实 Web UI // 5. boot.web-ready(原地重启)→ 重新导航;boot.server-died → /died 页 // // WS 桥(127.0.0.1:19873)方法分流: // 壳层本地拦截(本文件 handle_shell_method): // win.minimize / win.toggle-maximize / win.close / win.is-maximized / // win.start-dragging(send)/ win.viewport-beat(send,视口失同步自愈)/ // win.maximized(通知推送) // menu.action 的纯壳动作(reload / devtools / fullscreen / quit / open-browser) // log.page-error(send,壳层记录) // directory.pick(SYNC-004,原生目录选择对话框 → 绝对路径 | null) // 其余 → sidecar(chrome.init / service.restart / boot.* / P3 渐进收编面)。 use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU64, Ordering}; use std::sync::{Arc, OnceLock, RwLock}; use futures_util::{SinkExt, StreamExt}; use serde_json::Value; use std::process::Stdio; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader as ABufReader}; use tokio::net::{TcpListener, TcpStream}; use tokio::process::{Child, ChildStdin, ChildStdout, Command}; use tokio::sync::{broadcast, mpsc, oneshot, Mutex as AMutex}; use tokio_tungstenite::tungstenite::Message; const BRIDGE_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/bridge-bundle.js")); const WS_PORT: u16 = 19873; // 安装环境隔离(ADR 0004):产品数据根名 + 默认发布通道。 const EAC_PRODUCT_NAME: &str = "Deepseek Harness EAC"; const DEFAULT_EAC_CHANNEL: &str = "beta"; // The manager path is the v6 default. DSH_UI_SKIN_MANAGER_ROLLBACK is a // one-release emergency switch for operators; it only selects the embedded fallback // recovery styles and never restores the removed EAC source tree. const SKIN_MANAGER_LOCK: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/skin-manager-artifact.lock.json" )); static CHINESE_UI: OnceLock = OnceLock::new(); // 桥端口回退:19873 被占(他程序占用/异常残留监听)时向上探测 25 个候选, // 全失败退回 OS 分配(端口 0)。实际端口必须先于任何窗口 URL 定稿 //(loading/died 与主窗口都指向这里),故绑定挪到 setup 任务最前面。 // 页面侧注入的 __DSH_BRIDGE_WS__ 恒为 ws_port();ws-jsonrpc-client.js 里的 // 19873 仅为无注入环境的兜底默认值,不与本机制耦合。 static WS_PORT_EFFECTIVE: AtomicU16 = AtomicU16::new(WS_PORT); static PACKAGED_RESOURCE_ROOT: OnceLock = OnceLock::new(); fn ws_port() -> u16 { WS_PORT_EFFECTIVE.load(Ordering::SeqCst) } /// Windows 内核布局为自绘标题栏预留一次空间。内核 frame 的 padding、 /// resize handle 和 overlay 共同读取 `--dsh-windows-titlebar-height`。 /// 钉版皮肤另给 body 加了 36px padding,不能与这套布局同时生效:否则 /// 内容和 resize handle 都从 y=72 开始,而壳栏实际只占 y=0..36。 /// 壳层覆盖自己的 body 补偿,让内核统一管理内容区;不修改钉版皮肤。 const TITLE_BAR_HEIGHT_PX: u32 = 36; /// 壳层自有样式表 id:补偿规则与标记同源,便于排查与幂等。 const TITLE_BAR_STYLE_ID: &str = "__dsh_title_bar_fix__"; /// Windows 自绘标题栏标记 + 布局补偿(内核 preload-windows.ts 的 mark() 同语义)。 /// /// 必须与端口注入写在同一次 document-start 注入里:主窗会从壳层 /loading /// 导航到内核 Web UI,页面上下文重建后属性与样式都不能丢。 fn windows_titlebar_marker_js() -> String { format!( "if(navigator.platform.indexOf('Win')===0){{\ var h='{height}px';\ var d=function(){{var r=document.documentElement;\ if(!r)return false;\ r.setAttribute('data-windows-titlebar','');\ r.style.setProperty('--dsh-windows-titlebar-height',h);\ r.setAttribute('data-dsh-title-bar-height','{height}');\ r.style.setProperty('--dsh-title-bar-height',h);\ if(!document.getElementById('{style_id}')){{var s=document.createElement('style');\ s.id='{style_id}';\ s.textContent='html[data-dsh-title-bar-height][data-windows-titlebar] body{{padding-top:0 !important}}html[data-dsh-title-bar-height][data-windows-titlebar] [data-control-name=\"session-root\"]>[class*=frame]{{height:100% !important;max-height:100% !important}}';\ (document.head||r).appendChild(s)}}\ return true}};\ if(!d()){{if(document.readyState==='loading'){{document.addEventListener('DOMContentLoaded',d,{{once:true}})}}else{{document.addEventListener('readystatechange',function h(){{if(d()){{document.removeEventListener('readystatechange',h)}}}})}}}}}}", height = TITLE_BAR_HEIGHT_PX, style_id = TITLE_BAR_STYLE_ID, ) } /// 生成带实际桥端口的 WebView 初始化脚本。 /// /// 主窗首屏 /loading 会通过页面 HTML 注入端口,但导航到真实 Web UI /// 后页面上下文会重建;仅注入裸 BRIDGE_JS 会让客户端退回固定的 /// 19873,端口发生回退时窗口控制全部失效。标记同样必须在每次导航的 /// document-start 注入,故与端口写在同一段初始化脚本里。 fn bridge_init_script() -> String { let manager_active = ui_skin_manager_snapshot().is_some(); let skin_css = if manager_active { "\"\"".to_string() } else { serde_json::to_string(&ui_skin_css_bundle()).unwrap_or_else(|_| "\"\"".to_string()) }; let manager = ui_skin_manager_bootstrap_json(); format!( "{}\nwindow.__DSH_BRIDGE_WS__='ws://127.0.0.1:{}/ws';\n{}window.__DSH_UI_SKIN_CSS__={};\nwindow.__DSH_UI_SKIN_MANAGER__={};\n{}", windows_titlebar_marker_js(), ws_port(), shell_invoke_bridge_js(), skin_css, manager, BRIDGE_JS, ) } /// 壳层页面专用的 `invoke` 绑定(`window.dshShell.invoke`)。 /// /// 为什么需要它:`/died` 页要调 L1 的 `diagnose_environment` / /// `repair_environment` 做环境自愈,但 Tauri 的 `window.__TAURI__` 只在启用 /// `app.withGlobalTauri` 时才存在 —— 而本壳**刻意不开**它。 /// /// 底层 IPC `window.__TAURI_INTERNALS__.invoke` 是**无条件注入**的 ///(tauri manager/webview.rs 的 main_frame_script,与 withGlobalTauri 无关), /// 所以这里只做一层薄包装。 /// /// **真实边界(勿误读)**:注入发生在同一个 `main` 窗口的 initialization_script, /// 而内核 Web UI 也是在这个窗口里 `win.navigate` 过去的 —— 因此内核页面 /// **同样能看到 `window.dshShell`**。它的实际收益只是: /// 1. 比开 `withGlobalTauri` 少暴露整个 `__TAURI__` 命名空间(面更小); /// 2. 真正拦住内核页面的是 **ACL**:`capabilities/default.json` 的 /// `remote.urls` 只授权壳桥的 origin(`http://127.0.0.1:<壳桥端口>/*`), /// 内核 UI 在**另一个端口**上,origin 不匹配 → 拿不到任何 `allow-*` 权限。 /// 即:**安全边界靠 ACL 的 origin 匹配,不靠「注入给谁」**。若要进一步收紧, /// 应改为按 URL 注入(Tauri 支持按导航注入),而非依赖命名空间大小。 fn shell_invoke_bridge_js() -> &'static str { "window.dshShell=window.dshShell||{};\ window.dshShell.invoke=function(cmd,args){\ var t=window.__TAURI_INTERNALS__;\ if(!t||typeof t.invoke!=='function'){\ return Promise.reject(new Error('shell invoke unavailable: __TAURI_INTERNALS__ missing'));\ }\ return t.invoke(cmd,args||{});\ };\n" } fn locale_tag_is_chinese(tag: &str) -> bool { tag.trim() .split(['-', '_']) .next() .is_some_and(|primary| primary.eq_ignore_ascii_case("zh")) } #[cfg(windows)] fn detect_chinese_ui_language() -> bool { use windows_sys::Win32::Globalization::GetUserDefaultLocaleName; let mut locale = [0u16; 85]; let len = unsafe { GetUserDefaultLocaleName(locale.as_mut_ptr(), locale.len() as i32) }; if len <= 1 { return false; } locale_tag_is_chinese(&String::from_utf16_lossy(&locale[..len as usize - 1])) } #[cfg(not(windows))] fn detect_chinese_ui_language() -> bool { ["LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"] .iter() .filter_map(|name| std::env::var(name).ok()) .flat_map(|value| value.split(':').map(str::to_owned).collect::>()) .any(|tag| locale_tag_is_chinese(tag.split('.').next().unwrap_or(&tag))) } fn use_chinese_ui() -> bool { *CHINESE_UI.get_or_init(detect_chinese_ui_language) } fn ui_text<'a>(zh: &'a str, en: &'a str) -> &'a str { if use_chinese_ui() { zh } else { en } } #[cfg(test)] mod shell_tests { use super::{ is_sidecar_respawn_request, locale_tag_is_chinese, locale_tag_is_well_formed, shell_http_status, ui_skin_asset, ui_skin_manager_enabled, ui_skin_manager_snapshot, verified_resource_root, WS_PORT, }; use std::fs; use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; static SKIN_MANAGER_ENV_LOCK: Mutex<()> = Mutex::new(()); #[test] fn recognizes_chinese_locale_variants_only() { assert!(locale_tag_is_chinese("zh-CN")); assert!(locale_tag_is_chinese("zh_Hant_TW")); assert!(!locale_tag_is_chinese("en-US")); assert!(!locale_tag_is_chinese("ja-JP")); assert!(!locale_tag_is_chinese("")); } #[test] fn locale_tag_gate_matches_official_id_pattern() { // 官方 LOCALE_ID_PATTERN:^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$。 assert!(locale_tag_is_well_formed("zh")); assert!(locale_tag_is_well_formed("en")); assert!(locale_tag_is_well_formed("zh-CN")); assert!(locale_tag_is_well_formed("zh-Hant-TW")); assert!(!locale_tag_is_well_formed("z")); // 主段过短 assert!(!locale_tag_is_well_formed("chineseee")); // 主段 9 > 8 assert!(!locale_tag_is_well_formed("zh_ CN")); // 非法字符 assert!(!locale_tag_is_well_formed("")); assert!(!locale_tag_is_well_formed("zh_CN")); // 下划线不是分隔符 assert!(locale_tag_is_well_formed("zh-C")); // 子段 1-8 位均合法(含 1) assert!(!locale_tag_is_well_formed("zh-CNbbbbbbbbb")); // 子段 >8 } #[test] fn resource_root_is_canonical_and_complete() { let nonce = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system clock before unix epoch") .as_nanos(); let root = std::env::temp_dir().join(format!("dsh-resource-root-{}-{nonce}", std::process::id())); fs::create_dir_all(root.join("sidecar")).expect("create sidecar fixture"); fs::create_dir_all(root.join("dsh-desktop")).expect("create desktop fixture"); fs::write(root.join("sidecar").join("server.js"), "").expect("write sidecar fixture"); let verified = verified_resource_root(&root).expect("valid resource root"); assert!(verified.is_absolute()); assert_eq!(verified.file_name(), root.file_name()); #[cfg(windows)] assert!(!verified.to_string_lossy().starts_with(r"\\?\")); fs::remove_file(root.join("sidecar").join("server.js")).expect("remove sidecar fixture"); assert!(verified_resource_root(&root).is_none()); fs::remove_dir_all(root).expect("remove resource fixture"); } #[test] fn only_boot_start_can_respawn_sidecar() { assert!(is_sidecar_respawn_request("boot.start")); assert!(!is_sidecar_respawn_request("rescue.safe-mode")); assert!(!is_sidecar_respawn_request("chrome.init")); assert!(!is_sidecar_respawn_request("plugins.list")); } #[test] fn manager_is_enabled_by_default_and_rollback_selects_embedded_fallback() { let _env_lock = SKIN_MANAGER_ENV_LOCK.lock().expect("skin manager env lock"); std::env::remove_var("DSH_UI_SKIN_MANAGER"); std::env::remove_var("DSH_UI_SKIN_MANAGER_ROLLBACK"); assert!(ui_skin_manager_enabled()); let snapshot = ui_skin_manager_snapshot().expect("pinned snapshot"); assert_eq!(snapshot.package, "system.default"); assert_eq!(snapshot.generation, 1); assert_eq!(snapshot.assets["control/layout.css"], "control-layout.css"); std::env::set_var("DSH_UI_SKIN_MANAGER_ROLLBACK", "1"); assert!(!ui_skin_manager_enabled()); assert!(ui_skin_asset("style/tokens.css").contains("--eac-shell")); std::env::remove_var("DSH_UI_SKIN_MANAGER_ROLLBACK"); } #[test] fn manager_snapshot_rejects_unknown_or_unsafe_assets() { let _env_lock = SKIN_MANAGER_ENV_LOCK.lock().expect("skin manager env lock"); std::env::set_var("DSH_UI_SKIN_MANAGER", "1"); let snapshot = ui_skin_manager_snapshot().expect("pinned snapshot"); assert!(snapshot.assets.get("../escape.css").is_none()); assert!(snapshot.assets.get("unknown.css").is_none()); std::env::remove_var("DSH_UI_SKIN_MANAGER"); } #[test] fn retired_and_unknown_shell_pages_are_not_found() { let retired_page = format!("/{}-{}", "recovery", "center"); assert_eq!(shell_http_status("/loading"), 200); assert_eq!(shell_http_status("/died?code=1"), 200); assert_eq!(shell_http_status("/inject/bridge.js"), 200); assert_eq!(shell_http_status("/skin/control/layout.css"), 200); assert_eq!(shell_http_status("/skin/style/tokens.css"), 200); assert_eq!(shell_http_status("/skin/style/states.css"), 200); assert_eq!(shell_http_status("/skin/tokens.css"), 404); assert_eq!(shell_http_status("/skin/../skin.json"), 404); assert_eq!(shell_http_status(&retired_page), 404); assert_eq!(shell_http_status("/about"), 404); assert_eq!(shell_http_status("/unknown"), 404); } #[test] fn shell_pages_expose_named_skin_contracts_and_state_changes() { let loading = super::loading_page(); assert!(loading.contains("data-region=\"session\"")); assert!(loading.contains("data-control-name=\"system.default.loading-spinner\"")); assert!(loading.contains("data-state=\"loading animating\"")); let died = super::died_page("/tmp/dsh-web.log", "1"); assert!(died.contains("data-control-name=\"system.default.restart-button\"")); assert!(died.contains("data-state=\"idle\"")); assert!(died.contains("setAttribute('data-state','running')")); assert!(died.contains("setAttribute('data-state','error')")); // 回归锁(启动页 `\` 乱码):script 注入之前的 HTML 段不得含任何反斜杠 // —— 模板行连接写成 `\\` 时,字面 `\` + 换行 + 缩进会原样进入响应体, // 标签之间的 `\` 成为可见文本节点(散落的 "\" 乱码)。 let loading_html = loading.split("。 assert!(died.contains("});}")); } #[test] fn died_page_retries_through_boot_start_only() { let page = super::died_page("/tmp/dsh-web.log", "1"); assert!(page.contains("_call('boot.start'")); assert!(!page.contains("rescue.safe-mode")); assert!(!page.contains(&format!("{}-{}", "recovery", "center"))); } #[test] fn died_page_exposes_environment_diagnosis_and_guarded_purge() { // P1 自愈(形态 1):sidecar 已退场,页面必须能独立给出环境诊断, // 并把「清理重建」做成**受保护**的显式动作。 let page = super::died_page("/tmp/dsh-web.log", "1"); // 诊断面板与命名锚点(与现有 shell-page 契约同一命名风格)。 assert!(page.contains("data-control-name=\"system.default.environment-panel\"")); assert!(page.contains("data-control-name=\"system.default.environment-summary\"")); assert!(page.contains("data-control-name=\"system.default.environment-problems\"")); assert!(page.contains("data-control-name=\"system.default.environment-purge-button\"")); // 走 L1 命令(不依赖 sidecar 存活)。 assert!(page.contains("invoke('diagnose_environment')")); assert!(page.contains("invoke('repair_environment'")); // 删除必须二次确认,且默认隐藏,只有 removable=true 才放出。 assert!(page.contains("window.confirm(")); assert!(page.contains("d.removable?'idle':'hidden'")); // 不得引入任何自动删除路径。 assert!( !page.contains( "invoke('repair_environment',{purge:true}).then(function(){location.reload();});\n" ), "清理必须由用户点击触发,不得在加载时自动执行" ); assert!(page.contains("onclick=\"purgeEnvironment()\"")); // 无法自动清理时必须给出人工出路(注册表损坏场景实测会卡住)。 assert!(page.contains("data-control-name=\"system.default.environment-hint\"")); assert!( page.contains("环境无法自动清理"), "必须给出可执行的人工恢复提示" ); } #[test] fn shell_pages_do_not_leak_line_continuation_backslashes() { // 回归(2026-09-29 实测「一坨黑」):页面模板每行结尾必须用**单**反斜杠 // 做 Rust 续行。写成双反斜杠 `\\` 时,Rust 把它当「字面反斜杠」, // 于是每个 HTML 行尾都吐出一个可见 `\` + 真实换行,页面被游离反斜杠铺满。 // // 这个缺陷在 HEAD 上就已存在(28 处,/loading 页可见 17 处), // 自愈面板插入后扩到 84 处。 // // 注意:只检查 **HTML 模板部分**(`", ui_text("正在启动服务…", "Starting services..."), ws_port(), BRIDGE_JS ) } fn died_page(log_path: &str, code: &str) -> String { let esc = |s: &str| { s.replace('&', "&") .replace('<', "<") .replace('>', ">") }; format!( // 同 loading_page:单反斜杠续行;`\\` 会把字面 `\` 洗进 HTML/JS, // HTML 里成为可见乱码,`", ui_text("zh-CN", "en"), ui_text("服务已停止", "Service stopped"), ui_text("DSH 服务已停止", "The DSH service has stopped"), ui_text("退出码", "Exit code"), esc(code), esc(log_path), ui_text("重新启动", "Restart"), ws_port(), BRIDGE_JS, ui_text("正在重启…", "Restarting..."), ui_text("重启失败,请重试", "Restart failed. Try again."), // P1 自愈文案:环境 fail-closed 时给用户可操作的诊断与清理入口, // 而不是让他对着一个白屏猜原因。 ui_text("安装环境", "Install environment"), ui_text("正在检查隔离环境…", "Checking the isolated environment..."), ui_text("清理并重建环境", "Clean and rebuild environment"), ui_text("已注册", "registered"), ui_text("已登记", "registered"), ui_text("未登记", "not registered"), ui_text("将删除该隔离环境下的全部数据(会话/配置/插件),且不可恢复。继续?", "This deletes all data in this isolated environment (sessions, config, plugins) and cannot be undone. Continue?"), ui_text("正在清理…", "Cleaning..."), ui_text("清理失败,请查看日志", "Cleanup failed; check the log"), // 无法自动清理时的出路(注册表损坏 / 目录被外部占用):dpx 拒绝在 // 不可信的注册表上做删除(正确的 fail-closed),所以这里给手工路径, // 而不是留一个点不动的按钮让人干瞪眼。 ui_text( "环境无法自动清理(注册表不可读或目录被占用)。可手动把上面那条路径整个删掉或改名,然后重启本应用。", "This environment cannot be cleaned automatically (unreadable registry or the directory is in use). Manually delete or rename the path shown above, then restart the app.", ), ) } /// 查询参数百分号编码(encodeURIComponent 语义:RFC 3986 unreserved 之外 /// 全部转 %XX)。5.3.2 及以前只编码反斜杠/冒号/斜杠/空格 —— `&`/`#` 会 /// 截断或污染参数(log 路径含 `&` 时 /died 页丢失后续内容)。页面侧 /// URLSearchParams 对 %XX 与旧 +(空格)均正确解码,行为兼容。 fn encode_query(v: &str) -> String { let mut out = String::with_capacity(v.len() + 8); for b in v.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { out.push(b as char) } _ => out.push_str(&format!("%{:02X}", b)), } } out } #[derive(Clone, Debug, serde::Deserialize)] struct UiSkinManagerSnapshot { package: String, version: String, digest: String, generation: u64, assets: HashMap, #[serde(rename = "slotAssets", default)] slot_assets: HashMap>, fault: Option, } fn ui_skin_manager_enabled() -> bool { !matches!( std::env::var("DSH_UI_SKIN_MANAGER_ROLLBACK").as_deref(), Ok("1") | Ok("true") ) } fn ui_skin_manager_root() -> PathBuf { let root = resource_root(); let packaged = root .join("ui-skin-manager") .join("resolved") .join("system.default"); if packaged.is_dir() { packaged } else { root.join("tauri-shell") .join("artifacts") .join("resolved") .join("system.default") } } fn ui_skin_manager_snapshot() -> Option { if !ui_skin_manager_enabled() { return None; } let path = ui_skin_manager_root().join("snapshot.json"); let source = std::fs::read_to_string(path).ok()?; let lock: Value = serde_json::from_str(SKIN_MANAGER_LOCK).ok()?; let locked_default_digest = lock .pointer("/default/sha256") .and_then(Value::as_str) .map(str::to_owned)?; let snapshot: UiSkinManagerSnapshot = serde_json::from_str(&source).ok()?; (snapshot.package == "system.default" && snapshot.version == "2.0.0" && snapshot.digest == format!("sha256:{locked_default_digest}") && snapshot.fault.is_none() && snapshot .assets .keys() .all(|asset| ui_skin_asset_path_is_safe(asset))) .then_some(snapshot) } fn ui_skin_asset_path_is_safe(file: &str) -> bool { !file.is_empty() && !file.starts_with('/') && !file.contains('\\') && !file .split('/') .any(|segment| segment.is_empty() || segment == "." || segment == "..") } fn ui_skin_manager_asset(file: &str) -> Option { let snapshot = ui_skin_manager_snapshot()?; let relative = snapshot.assets.get(file)?; if !ui_skin_asset_path_is_safe(file) || !ui_skin_asset_path_is_safe(relative) { return None; } std::fs::read_to_string(ui_skin_manager_root().join(relative)).ok() } fn ui_skin_manager_bootstrap_json() -> String { let Some(snapshot) = ui_skin_manager_snapshot() else { return "{}".to_string(); }; let slots = snapshot .slot_assets .iter() .map(|(slot, assets)| { let css = assets .iter() .filter_map(|asset| ui_skin_manager_asset(asset)) .collect::>() .join("\n"); (slot, css) }) .collect::>(); serde_json::to_string(&serde_json::json!({ "enabled": true, "package": snapshot.package, "version": snapshot.version, "digest": snapshot.digest, "generation": snapshot.generation, "slots": slots, })) .unwrap_or_else(|_| "{}".to_string()) } fn ui_skin_asset_is_registered(file: &str) -> bool { ui_skin_manager_asset(file).is_some() || embedded_skin_asset(file).is_some() } fn ui_skin_asset(file: &str) -> String { if let Some(asset) = ui_skin_manager_asset(file) { return asset; } embedded_skin_asset(file).unwrap_or_default().to_string() } fn ui_skin_css_bundle() -> String { ["control/layout.css", "style/tokens.css", "style/states.css"] .iter() .map(|asset| ui_skin_asset(asset)) .filter(|asset| !asset.is_empty()) .collect::>() .join("\n") } fn embedded_skin_asset(file: &str) -> Option<&'static str> { match file { "control/layout.css" => Some( ":root{--eac-shell-surface:#202124;--eac-shell-text:#f1f3f4;}\nbody{margin:0;background:var(--eac-shell-surface);color:var(--eac-shell-text);font:14px sans-serif;}\n", ), "style/tokens.css" => Some( ":root{--eac-shell-surface:#202124;--eac-shell-text:#f1f3f4;--eac-shell-muted:#9aa0a6;}\n", ), "style/states.css" => Some( "[data-state~=loading]{opacity:.8}[data-state~=error]{color:#f28b82}[data-state~=disabled]{opacity:.55}\n", ), _ => None, } } fn shell_http_status(path: &str) -> u16 { let route = path.split('?').next().unwrap_or(""); if ui_skin_manager_snapshot().is_some() { if let Some(asset) = route.strip_prefix("/skin/") { return if ui_skin_manager_asset(asset).is_some() { 200 } else { 404 }; } } // SYNC-006 验证钩子(仿 DSH_HOST_PATHS_STAGE / DSH_DIRECTORY_PICK_STAGE // 先例):仅当 DSH_BROWSER_PROBE=1 时放行 /browser-probe —— 一个不含桥的 // 极小页面,window.open 触发 guest 的 NewWindowRequested,供自动化验证 // onOpenRequested 事件链。默认(未设置)404,生产路径零影响。 if route == "/browser-probe" { return if std::env::var("DSH_BROWSER_PROBE").as_deref() == Ok("1") { 200 } else { 404 }; } if route == "/" || route == "/inject/bridge.js" || route == "/loading" || route == "/died" { 200 } else if route .strip_prefix("/skin/") .is_some_and(ui_skin_asset_is_registered) { 200 } else { 404 } } async fn http_serve(mut stream: TcpStream, path: &str) -> std::io::Result<()> { eprintln!("[http] serve {}", path); // 真正消费请求头(读到空行):未读数据残留会让连接以 RST 而非 FIN 收尾, // WebView2 视为响应中断并反复重试。 { use tokio::io::AsyncReadExt; let mut consumed = Vec::with_capacity(1024); let mut chunk = [0u8; 1024]; loop { let n = stream.read(&mut chunk).await?; if n == 0 { break; } if consumed.len() + n > 64 * 1024 { break; // 头部异常超长,防御性放行 } consumed.extend_from_slice(&chunk[..n]); if consumed.windows(4).any(|w| w == b"\r\n\r\n") { break; } } } let status = shell_http_status(path); let (body, ctype) = if status == 404 { ( "Not Found

404 Not Found

" .to_string(), "text/html; charset=utf-8", ) } else if path.starts_with("/inject/bridge.js") { (BRIDGE_JS.to_string(), "application/javascript") } else if let Some(file) = path.split('?').next().unwrap_or("").strip_prefix("/skin/") { (ui_skin_asset(file), "text/css; charset=utf-8") } else if path.starts_with("/browser-probe") { // SYNC-006 验证钩子页(shell_http_status 已按 DSH_BROWSER_PROBE=1 门控)。 // 不含桥注入 —— 纯触发器:guest 加载本页后 window.open 一个 http(s) // 地址,L1 的 on_new_window 捕获并广播 browser.open-requested(开窗本身 // 恒 Deny,不会真弹出页面)。 ( "browser-probe\

browser-probe

" .to_string(), "text/html; charset=utf-8", ) } else if path.starts_with("/loading") { (loading_page(), "text/html; charset=utf-8") } else if path.starts_with("/died") { // /died?code=..&log=..(查询参数由 boot.server-died 处理方拼好) let mut code = "unknown".to_string(); let mut log = "".to_string(); if let Some(q) = path.split_once('?') { for kv in q.1.split('&') { if let Some((k, v)) = kv.split_once('=') { let v = v .replace("%3A", ":") .replace("%5C", "\\") .replace("%2F", "/") .replace('+', " "); if k == "code" { code = v; } else if k == "log" { log = v; } } } } (died_page(&log, &code), "text/html; charset=utf-8") } else { let page = format!( // 同 loading_page:单反斜杠续行(`\\` 会把字面 `\` 洗进 HTML)。 "DSH EAC Shell{UI_SKIN_LINKS}\ \
\

DSH EAC — Tauri ShellHost

\
connecting…
\ ", ws_port(), BRIDGE_JS ); (page, "text/html; charset=utf-8") }; let status_text = if status == 200 { "200 OK" } else { "404 Not Found" }; let resp = format!( "HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", status_text, ctype, body.len(), body ); stream.write_all(resp.as_bytes()).await?; stream.flush().await?; Ok(()) } fn run_bridge_test() -> i32 { println!("[bridge] node = {}", resolve_node()); println!("[bridge] sidecar = {}", sidecar_script().display()); let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("tokio runtime"); let code = rt.block_on(async move { let sc = match Sidecar::spawn().await { Ok(s) => s, Err(e) => { eprintln!("[bridge] FAIL spawn: {}", e); return 1; } }; let checks: Vec<(&str, Value, Box bool>)> = vec![ ( "ping", serde_json::json!({}), Box::new(|r: &Value| r.get("pong") == Some(&serde_json::json!(true))), ), ( "shell.info", serde_json::json!({}), Box::new(|r: &Value| r.get("sidecar") == Some(&serde_json::json!("server.ts"))), ), ( "profile.name", serde_json::json!({}), Box::new(|r: &Value| r.get("name") == Some(&serde_json::json!("web-desktop"))), ), ]; let mut ok = 0; for (name, params, check) in &checks { match sc.call(name, params.clone()).await { Ok(r) => { println!("[bridge] {:<20} -> {}", name, r); if check(&r) { ok += 1; } else { eprintln!("[bridge] {} CHECK-FAIL", name); } } Err(e) => eprintln!("[bridge] {} FAIL: {}", name, e), } } let _ = sc.call("shutdown", serde_json::json!({})).await; tokio::time::sleep(std::time::Duration::from_millis(300)).await; sc.kill().await; println!("[bridge] {}/{} checks passed", ok, checks.len()); if ok == checks.len() { 0 } else { 1 } }); code } #[tauri::command] fn shell_ping() -> serde_json::Value { serde_json::json!({ "pong": true, "shell": "tauri", "pid": std::process::id() }) } /// 环境诊断/清理通道(P1 自愈,形态 1)。 /// /// 为什么放在 L1:环境 fail-closed 时 sidecar 已经退场(exit(2)),bridge 与 /// 它的 environment.* RPC 一起消失,而 `/died` 页正需要「环境坏了没有、坏在哪、 /// 能不能清理」—— 这条通道必须**不依赖 sidecar 存活**。 /// /// 分层红线(ADR 0002):L1 **不实现**任何环境治理逻辑。这里只做三件事: /// 用随包 node 跑 `dsh-desktop/scripts/environment-diagnose.mjs`、把它的 /// 单行 JSON 原样解析返回、把失败原因如实带出。注册表/清单/锁/删除全部仍由 /// dsh-dpx 经 L2 适配层完成。 fn run_environment_action(action: &str, purge: bool) -> Result { let script = resource_root() .join("dsh-desktop") .join("scripts") .join("environment-diagnose.mjs"); if !script.is_file() { return Err(format!( "环境诊断脚本缺失:{}(安装包不完整,请重新安装)", script.display() )); } let node = resolve_node(); let mut args = vec![ script.to_string_lossy().to_string(), format!("--action={action}"), ]; if purge { args.push("--purge".to_string()); } let output = std::process::Command::new(&node) .args(&args) // 脚本自身需要真实用户环境(产品数据根/注册表位置都由它推导)。 .stdin(std::process::Stdio::null()) // 关键:必须注入资源根。打包态 dpx payload 位于 /dpx/src/index.js, // 适配层只能经 DSH_RESOURCE_ROOT 定位它;不传就会退化成开发态相对路径查找 //(安装树上不存在)→ 诊断/清理在真实安装包里全部失效(实测踩中)。 .env("DSH_RESOURCE_ROOT", resource_root()) // 阻塞式取输出:诊断/清理都是短命的一次性进程。 .output() .map_err(|e| format!("无法启动环境诊断(node={node}):{e}"))?; let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); // 脚本约定:成功与失败都以单行 JSON 输出,便于这里原样透传给页面。 match serde_json::from_str::(&stdout) { Ok(value) => Ok(value), Err(_) => Err(format!( "环境诊断返回了非 JSON 数据(exit={:?}):stdout={} stderr={}", output.status.code(), truncate_for_ui(&stdout, 400), truncate_for_ui(&stderr, 400), )), } } /// 截断长文本用于 UI 展示(避免把整段堆栈塞进 /died 页)。 fn truncate_for_ui(text: &str, max: usize) -> String { if text.chars().count() <= max { return text.to_string(); } let head: String = text.chars().take(max).collect(); format!("{head}…") } #[tauri::command] fn diagnose_environment() -> Result { run_environment_action("status", false) } /// 显式清理/重建当前通道环境。 /// /// 安全:`purge=true` 只在已登记环境上由 dpx 放行(未登记目录一律拒绝); /// 本命令**不会**被任何自动流程调用,只能由用户在 /died 页显式点击触发。 /// 不迁移旧数据、不触碰宿主 `~/.dsh`。 #[tauri::command] fn repair_environment(purge: bool) -> Result { if !purge { // 非 purge 的 repair = 幂等重新确保(不删任何东西)。 return run_environment_action("repair", false); } // 先 dry-run 取计划(不落盘),再执行 —— 让调用方拿到"删了什么"的事实。 let plan = run_environment_action("plan", true)?; let removed = run_environment_action("remove", true)?; Ok(serde_json::json!({ "ok": true, "plan": plan, "removed": removed })) } #[tauri::command] async fn sidecar_call(method: String, params: Value) -> Result { let state = BRIDGE.get_or_init(|| BridgeState { sidecar: Arc::new(AMutex::new(None)), }); let sc = state.sidecar.lock().await.clone(); match sc { Some(sc) => sc.call(&method, params).await, None => Err("sidecar not running".into()), } } static BRIDGE_ONCE: std::sync::Once = std::sync::Once::new(); static BRIDGE: std::sync::OnceLock = std::sync::OnceLock::new(); /// sidecar 通知 → 壳层响应(主线程执行窗口操作)。 /// setup 首生与死后重生共用: /// 重生实例有新的 notify_tx,不重接则 boot.web-ready 等事件永久丢失。 fn wire_sidecar_notifications(app: &tauri::AppHandle, sc: &Sidecar) { let mut notify = sc.notify_tx.subscribe(); let app_notify = app.clone(); tauri::async_runtime::spawn(async move { loop { match notify.recv().await { Ok(v) => handle_sidecar_notify(&app_notify, &v), Err(broadcast::error::RecvError::Lagged(_)) => continue, Err(_) => break, } } }); } fn handle_sidecar_notify(app: &tauri::AppHandle, v: &Value) { let method = v.get("method").and_then(|m| m.as_str()).unwrap_or(""); let params = v.get("params").cloned().unwrap_or(Value::Null); match method { "boot.web-ready" => { if let Some(url) = params.get("webUrl").and_then(|u| u.as_str()) { set_current_web_url(url); println!("[shell] web-ready → navigate: {}", url); let url = url.to_string(); let app2 = app.clone(); let _ = app.run_on_main_thread(move || { use tauri::Manager; if let Some(win) = app2.get_webview_window("main") { if let Ok(parsed) = tauri::Url::parse(&url) { let _ = win.navigate(parsed); } } }); } } "boot.server-died" => { let code = params .get("code") .map(|c| c.to_string()) .unwrap_or_else(|| "unknown".into()); let log = params .get("logPath") .and_then(|l| l.as_str()) .unwrap_or("") .to_string(); println!("[shell] server-died code={} log={}", code, log); let href = format!( "http://127.0.0.1:{}/died?code={}&log={}", ws_port(), encode_query(&code), encode_query(&log) ); let app2 = app.clone(); let _ = app.run_on_main_thread(move || { use tauri::Manager; if let Some(win) = app2.get_webview_window("main") { let _ = win.show(); if let Ok(parsed) = tauri::Url::parse(&href) { let _ = win.navigate(parsed); } } }); } // v6 Task 3.3:sidecar 内部请求打开外链(如更新流程)。 // 与 handle_shell_method 的同名 arm 区分:这里是 notify 帧(无 id)。 "shell.open-external" => { let url = params .get("url") .and_then(|value| value.as_str()) .unwrap_or(""); let url = url.to_string(); tauri::async_runtime::spawn(async move { if let Err(error) = open_external(&url).await { eprintln!("[shell] sidecar open external failed: {}", error); } }); } "shell.system-notification" => { let title = params .get("title") .and_then(|value| value.as_str()) .unwrap_or("Deepseek Harness EAC") .to_string(); let body = params .get("body") .and_then(|value| value.as_str()) .unwrap_or("") .to_string(); tauri::async_runtime::spawn(async move { if let Err(error) = show_system_notification(&title, &body).await { eprintln!("[shell] system notification failed: {}", error); } }); } _ => {} } } fn main() { let args: Vec = std::env::args().collect(); if args.iter().any(|a| a == "--bridge-test") { std::process::exit(run_bridge_test()); } let state = BRIDGE.get_or_init(|| BridgeState { sidecar: Arc::new(AMutex::new(None)), }); tauri::Builder::default() // SYNC-004:dialog 插件(原生目录选择能力源,见 pick_directory 注释)。 // 仅用 Rust 侧 API(app.dialog());WebView 侧 plugin:dialog|* 命令无 // capability 授权,页面不可绕过桥直达 —— 行为面与注册前一致。 .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { // 二次启动:聚焦已有主窗(= Electron second-instance 行为)。 use tauri::Manager; if let Some(win) = app.get_webview_window("main") { let _ = win.show(); let _ = win.unminimize(); let _ = win.set_focus(); } // 主窗导航完成:清理可能残留的退出 overlay if let Some(w) = app.get_webview_window("main") { let _ = w.eval("window.__dshExitOverlay&&window.__dshExitOverlay.dismiss()"); } })) .invoke_handler(tauri::generate_handler![ shell_ping, sidecar_call, diagnose_environment, repair_environment ]) .setup(move |app| { use tauri::Manager; initialize_packaged_resource_root(app); // SYNC-003:剪贴板路径监听(__DSH_HOST_PATHS__ 暂存源)。失败只 // 降级(无帧推送 → 桥侧 pathFor 返 ''),不阻塞壳启动。 #[cfg(windows)] spawn_clipboard_path_listener(); // SYNC-003 验证钩子(L1 事件注入,见函数注释):仅当 // DSH_HOST_PATHS_STAGE 设置时生效。 stage_host_paths_from_env(); BRIDGE_ONCE.call_once(|| { let st = BridgeState { sidecar: state.sidecar.clone(), }; let app_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { // 先绑桥端口(可能回退到非默认端口):下面所有窗口 URL // (loading / died)都指向桥端口,必须先定稿。 let Some(listener) = bind_ws_listener().await else { eprintln!("[ws] bridge listener bind failed; aborting shell startup"); return; }; match Sidecar::spawn().await { Ok(sc) => { println!("[shell] sidecar ready"); // sidecar 通知 → 壳层:首生与死后重生共用接线。 wire_sidecar_notifications(&app_handle, &sc); *st.sidecar.lock().await = Some(Arc::new(sc)); // 主窗:先加载壳层 /loading 页(即起即见)。 let app_win = app_handle.clone(); let app_win_inner = app_win.clone(); let _ = app_win.run_on_main_thread(move || { let app_win = app_win_inner; let loading = format!("http://127.0.0.1:{}/loading", ws_port()); if let Ok(url) = tauri::Url::parse(&loading) { let (sim_w, sim_h, sim_pos, sim_max) = resolved_initial_bounds(&app_win); let (eff_min_w, eff_min_h) = effective_min_inner(&app_win); let mut builder = tauri::webview::WebviewWindowBuilder::new( &app_win, "main", tauri::WebviewUrl::External(url), ) .title("Deepseek Harness EAC") .inner_size(sim_w, sim_h) .min_inner_size(eff_min_w, eff_min_h) .decorations(false) // 关闭窗口级 drag&drop handler,放行页面 HTML5 拖拽 //(否则图片/文件拖不进输入框,页面 dragover/drop 收不到)。 .disable_drag_drop_handler() // dsh-stt 语音识别:WebView2 在无用户手势下 getUserMedia // 可能被拒,放开 autoplay 策略。WebView2 的麦克风权限本身 // 走 Windows 系统隐私设置,无需额外 permission 授权。 // additional_browser_args 是整体替换语义(wry):必须 // 带上 Tauri 默认的 disable-features,否则主窗丢掉 // msWebOOUI/msPdfOOUI/msSmartScreenProtection 禁用项, // 与浮窗(显式拼接该前缀)行为分裂。 .additional_browser_args("--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --autoplay-policy=no-user-gesture-required") .initialization_script(&bridge_init_script()); if let Some((px, py)) = sim_pos { builder = builder.position(px, py); } match builder.build() { Ok(win) => { apply_window_icon(&win, &app_win); if sim_max { let _ = win.maximize(); } } Err(e) => eprintln!("[shell] main window build failed: {}", e), } } }); // boot.start:拉起 dsh web → webUrl(通知处理器负责导航; // 这里显式再导航一次,兜底通知竞态)。 let st2 = BridgeState { sidecar: st.sidecar.clone() }; let app_nav = app_handle.clone(); tauri::async_runtime::spawn(async move { let sc = st2.sidecar.lock().await.clone(); let Some(sc) = sc else { return }; match sc.call("boot.start", serde_json::json!({})).await { Ok(r) => { let url = r.get("webUrl").and_then(|u| u.as_str()).unwrap_or("").to_string(); println!("[shell] boot.start ok: {}", url); if !url.is_empty() { set_current_web_url(&url); let app3 = app_nav.clone(); let _ = app_nav.run_on_main_thread(move || { use tauri::Manager; if let Some(win) = app3.get_webview_window("main") { if let Ok(parsed) = tauri::Url::parse(&url) { let _ = win.navigate(parsed); } } }); } } Err(e) => { eprintln!("[shell] boot.start failed: {}", e); let msg = e.replace('"', "'").replace('\n', " "); let href = format!( "http://127.0.0.1:{}/died?code=boot&log={}", ws_port(), encode_query(&msg) ); let app3 = app_nav.clone(); let _ = app_nav.run_on_main_thread(move || { use tauri::Manager; if let Some(win) = app3.get_webview_window("main") { let _ = win.show(); if let Ok(parsed) = tauri::Url::parse(&href) { let _ = win.navigate(parsed); } } }); } } }); serve_ws(st, app_handle, listener).await; } Err(e) => { // issue #210:resources 装配失败 / node sidecar 缺失时, // 旧实现只 eprintln!,用户窗口永久停在 /loading 白屏、 // 无任何诊断入口。这里与 boot.start 失败同样处理: // 建主窗并导航到 /died 页(参数带失败原因),提示重装。 eprintln!("[shell] sidecar spawn failed: {}", e); let msg = e.to_string().replace('"', "'").replace('\n', " "); let app_died = app_handle.clone(); let app_died_inner = app_died.clone(); let _ = app_died.run_on_main_thread(move || { use tauri::Manager; let app_died = app_died_inner; let died = format!( "http://127.0.0.1:{}/died?code=sidecar-spawn&log={}", ws_port(), encode_query(&msg) ); if let Ok(url) = tauri::Url::parse(&died) { if app_died.get_webview_window("main").is_none() { let (sim_w, sim_h, sim_pos, sim_max) = resolved_initial_bounds(&app_died); let (eff_min_w, eff_min_h) = effective_min_inner(&app_died); let mut builder = tauri::webview::WebviewWindowBuilder::new( &app_died, "main", tauri::WebviewUrl::External(url), ) .title("Deepseek Harness EAC") .inner_size(sim_w, sim_h) .min_inner_size(eff_min_w, eff_min_h) .decorations(false); if let Some((px, py)) = sim_pos { builder = builder.position(px, py); } match builder.build() { Ok(win) => { apply_window_icon(&win, &app_died); if sim_max { let _ = win.maximize(); } } Err(e) => eprintln!("[shell] died window build failed: {}", e), } } else if let Some(win) = app_died.get_webview_window("main") { let _ = win.show(); let _ = win.navigate(url); } } }); // issue #210 修复完整性:/died 页挂在桥端口上, // spawn 失败分支同样必须起 serve_ws,否则 // 诊断页指向无人监听的端口,WebView2 只会显示「无法访问」。 serve_ws(st, app_handle, listener).await; } } }); }); // 托盘(L1):显示/隐藏、重启服务、反馈、退出。 // 文案语言 = 持久化 preference ?? OS 检测(SYNC-002:设置页改语言 // 经 __DSH_LOCALE__.onChange → locale.changed → rebuild_tray_menu // 按 id 取回本托盘重建菜单)。菜单事件挂在托盘上,重建菜单不丢。 let menu = build_tray_menu(app.handle(), shell_prefers_chinese())?; let mut tray = tauri::tray::TrayIconBuilder::with_id(TRAY_ID) .tooltip("Deepseek Harness EAC") .menu(&menu); let app_handle = app.handle().clone(); if let Some(icon) = app.default_window_icon() { tray = tray.icon(icon.clone()); } tray.on_menu_event(move |app, event| match event.id.as_ref() { "show" => { if let Some(win) = app.get_webview_window("main") { if win.is_visible().unwrap_or(true) { let _ = win.hide(); } else { let _ = win.show(); let _ = win.set_focus(); } } } "restart" => { let st = BridgeState { sidecar: BRIDGE.get_or_init(|| BridgeState { sidecar: Arc::new(AMutex::new(None)), }) .sidecar .clone(), }; tauri::async_runtime::spawn(async move { let sc = st.sidecar.lock().await.clone(); if let Some(sc) = sc { match sc.call("boot.restart", serde_json::json!({})).await { Ok(r) => println!("[tray] restart: {}", r), Err(e) => eprintln!("[tray] restart failed: {}", e), } } }); } "feedback" => { tauri::async_runtime::spawn(async move { if let Err(error) = open_external("https://github.com/Ebony-Vinyl/Deepseek-Harness-EAC/issues").await { eprintln!("[tray] open feedback failed: {}", error); } }); } "quit" => app_handle.exit(0), _ => {} }) .on_tray_icon_event(|tray, event| { // 单击切换窗口可见性(= Electron tray.on('click'))。 if let tauri::tray::TrayIconEvent::Click { button: tauri::tray::MouseButton::Left, button_state: tauri::tray::MouseButtonState::Up, .. } = event { let app = tray.app_handle().clone(); if let Some(win) = app.get_webview_window("main") { if win.is_visible().unwrap_or(true) && win.is_focused().unwrap_or(false) { let _ = win.hide(); } else { let _ = win.show(); let _ = win.set_focus(); } } } }) .build(app)?; println!( "[shell] tray ready (locale: {})", if shell_prefers_chinese() { "zh" } else { "en" } ); Ok(()) }) .on_window_event(|window, event| { use tauri::Manager; match event { // 主窗关闭 → exitAction 策略(minimize/quit/ask 选择页);浮窗真关闭。 tauri::WindowEvent::CloseRequested { api, .. } => { if window.label() == "main" { // 退出前先落盘当前尺寸/位置(ExitRequested 还会兜底一次)。 save_window_state(window.app_handle()); api.prevent_close(); let app = window.app_handle().clone(); tauri::async_runtime::spawn(async move { apply_exit_policy(&app, true).await; }); } } // 最大化状态变化 → win.maximized 通知(桥 onMaximizeChange 消费)。 tauri::WindowEvent::Resized(_) => { if window.label() == "main" { let m = window.is_maximized().unwrap_or(false); if LAST_MAXIMIZED.swap(m, Ordering::SeqCst) != m { let _ = shell_notify().send(serde_json::json!({ "method": "win.maximized", "params": { "maximized": m } })); } throttle_save_window_state(window.app_handle()); } } // 拖移后保存位置(节流写盘,最终态由关闭/退出兜底)。 tauri::WindowEvent::Moved(_) => { if window.label() == "main" { throttle_save_window_state(window.app_handle()); } } // DPI/显示器切换:WebView2 视口跟随偶发丢失(视口失同步主诱因), // 延迟一拍显式重申 webview bounds。 tauri::WindowEvent::ScaleFactorChanged { .. } => { if window.label() == "main" { let app = window.app_handle().clone(); tauri::async_runtime::spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(300)).await; reassert_webview_bounds(&app); }); } } _ => {} } }) .build(tauri::generate_context!()) .expect("error while building tauri application") .run(move |app, event| { if let tauri::RunEvent::ExitRequested { .. } = event { // 进程退出前最终落盘一次窗口状态(兜底 CloseRequested 之前的分支)。 save_window_state(app); // 优雅退出(同步有界,事件循环内完成,杜绝「调度后进程先退」的孤儿): // shutdown RPC → sidecar 有界回收 dsh web 进程树 → 兜底 kill。 let state = BRIDGE.get_or_init(|| BridgeState { sidecar: Arc::new(AMutex::new(None)), }); let st = BridgeState { sidecar: state.sidecar.clone() }; let _ = tauri::async_runtime::block_on(async move { let sc = st.sidecar.lock().await.clone(); if let Some(sc) = sc { // 优雅退出意图置位:其后的 sidecar 自退 EOF 不再触发 // boot.server-died 广播(退出瞬间闪现 /died 页)。 SIDECAR_STOPPING.store(true, Ordering::SeqCst); let _ = tokio::time::timeout( std::time::Duration::from_secs(10), sc.call("shutdown", serde_json::json!({})), ) .await; // shutdown 后 sidecar 自行 gracefulExit → stopServer // (grace 1.2s + hard 4s 有界,detached 的 dsh web 树杀 // 在其中)→ process.exit(0)。等它自退再兜底 kill: // 5.3.2 及以前固定睡 500ms 就杀,stopServer 被拦腰 // 砍断,dsh web 变孤儿占着端口。有界轮询 ~9s 覆盖 // stopServer 上界 + 边距。 // // 先清空槽位再轮询:旧实现走 Arc::into_inner(sc),但槽位 // 永远持有同一个 Arc(强计数 ≥2),into_inner 恒 None —— // 9s 轮询 + 兜底 kill 整段从未生效,sidecar 挂死时整棵 // node/dsh web 进程树孤儿化。child 已改为 AMutex, // 直接经共享句柄轮询即可。 *st.sidecar.lock().await = None; let started = std::time::Instant::now(); let mut exited = false; while started.elapsed() < std::time::Duration::from_secs(9) { if let Ok(Some(_)) = sc.child.lock().await.try_wait() { exited = true; break; } tokio::time::sleep(std::time::Duration::from_millis(200)).await; } if !exited { println!("[shell] sidecar did not exit in time; killing"); sc.kill().await; } } }); println!("[shell] sidecar reaped; exiting"); } }); } #[cfg(all(test, target_os = "macos"))] mod tests { use super::escape_apple_script_string; #[test] fn escapes_backslashes_and_quotes() { assert_eq!(escape_apple_script_string("a\\b\"c"), "a\\\\b\\\"c"); } #[test] fn leaves_plain_text_unchanged() { assert_eq!(escape_apple_script_string("hello 世界"), "hello 世界"); } } #[cfg(test)] mod eac_channel_tests { use super::{ eac_data_root, eac_data_root_with, sanitize_eac_channel, DEFAULT_EAC_CHANNEL, EAC_PRODUCT_NAME, }; #[test] fn keeps_valid_channels() { assert_eq!(sanitize_eac_channel("beta"), "beta"); assert_eq!(sanitize_eac_channel(" Beta "), "beta"); assert_eq!(sanitize_eac_channel("rc-1"), "rc-1"); } #[test] fn falls_back_for_invalid_channels_instead_of_mangling() { // 空值、非 ASCII 与非法首字符都必须退回默认通道, // 而不是「清洗」成一个看似合法却指向错误环境的名字。 for invalid in [ "", " ", "中文通道", "1beta", "-beta", "beta!", "a".repeat(64).as_str(), ] { assert_eq!( sanitize_eac_channel(invalid), DEFAULT_EAC_CHANNEL, "input={invalid:?}" ); } } #[test] fn product_data_root_ends_with_product_name() { let root = eac_data_root(); assert!(root.ends_with(EAC_PRODUCT_NAME), "root={}", root.display()); // L1 只给产品数据根:dpx storageRoot / dsh-environments 由 L2 推导, // 这里不得出现,否则两侧路径策略会各说各话。 assert!(!root.to_string_lossy().contains("dsh-environments")); assert!(!root.to_string_lossy().contains("dpx")); } #[test] fn honors_injected_data_root_instead_of_recomputing() { // 回归(2026-09-28 实测):首版实现无视外部注入的 DSH_EAC_DATA_ROOT, // 一律用 LOCALAPPDATA 重算 —— 而 L2 适配层把该变量当**权威值**, // 两侧语义相反,导致「隔离根建在别处」(便携启动器/端到端验证踩中)。 // // 用纯函数核心验证:不碰进程全局环境,因此不会与并行运行的其它测试 // 互相污染(早期版本用 set_var 出现过一次瞬时失败)。 let injected = if cfg!(windows) { r"D:\EAC 数据\产品 Root" } else { "/tmp/eac 数据/产品 Root" }; let temp = std::env::temp_dir(); // 1) 显式注入 → 原样采用(含空格与中文)。 assert_eq!( eac_data_root_with( Some(injected), None, None, Some(r"C:\ignored"), None, temp.clone() ), std::path::PathBuf::from(injected), "显式注入的产品数据根必须原样采用,不得被 LOCALAPPDATA 覆盖" ); // 首尾空白要裁剪(便于 shell/启动器传值时容错)。 assert_eq!( eac_data_root_with( Some(" D:\\Trimmed "), None, None, None, None, temp.clone() ), std::path::PathBuf::from("D:\\Trimmed"), ); // 2) 空白值不算「给定」→ 退回推导。 assert!( eac_data_root_with( Some(" "), Some("C:\\Users\\u"), None, Some("C:\\la"), None, temp.clone(), ) .ends_with(EAC_PRODUCT_NAME), "空白注入值应退回推导" ); // 3) 未注入 → 按平台默认推导(Windows: LOCALAPPDATA;非 Windows: XDG_DATA_HOME)。 let derived = eac_data_root_with( None, Some("/home/u"), None, Some("C:\\la"), Some("/xdg/data"), temp.clone(), ); assert!(derived.ends_with(EAC_PRODUCT_NAME)); if cfg!(windows) { assert!( derived.starts_with("C:\\la"), "Windows 推导应基于 LOCALAPPDATA:{derived:?}" ); } else { assert!( derived.starts_with("/xdg/data"), "非 Windows 推导应基于 XDG_DATA_HOME:{derived:?}" ); } } }