diff --git a/.github/workflows/desktop-ci.yml b/.github/workflows/desktop-ci.yml new file mode 100644 index 00000000..37b6e4b4 --- /dev/null +++ b/.github/workflows/desktop-ci.yml @@ -0,0 +1,91 @@ +# Validate desktop changes before they reach the release-only workflow. +name: Desktop CI + +on: + pull_request: + paths: + - "desktop/**" + - "src/frontend/**" + - ".github/workflows/desktop-ci.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: desktop-ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + scripts: + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install desktop toolchain + working-directory: desktop + run: npm ci --no-audit --no-fund + + - name: Test release and CE payload helpers + working-directory: desktop + run: npm run test:scripts + + platform-tests: + name: ${{ matrix.name }} + env: + NODE_OPTIONS: "--max-old-space-size=4096" + strategy: + fail-fast: false + matrix: + include: + - name: macOS Rust tests + os: macos-latest + prepare_payload: false + - name: Windows CE payload and Rust tests + os: windows-latest + prepare_payload: true + + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust build + uses: Swatinem/rust-cache@v2 + with: + workspaces: desktop/src-tauri -> target + + - name: Install frontend dependencies + working-directory: src/frontend + run: npm install --no-package-lock --no-audit --no-fund + + - name: Install desktop toolchain + working-directory: desktop + run: npm ci --no-audit --no-fund + + - name: Build frontend for macOS tests + if: ${{ !matrix.prepare_payload }} + working-directory: src/frontend + run: npm run build + + - name: Stage the public CE local-server payload on Windows + if: ${{ matrix.prepare_payload }} + working-directory: desktop + run: node scripts/prepare-bundle.mjs + + - name: Run desktop Rust tests + run: cargo test --manifest-path desktop/src-tauri/Cargo.toml diff --git a/desktop/README.md b/desktop/README.md index 0ca48ef6..61486dfc 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -130,7 +130,7 @@ HUGAGENT_SERVER_BASE=https://你的后端 npm run dev | `src-tauri/src/auth.rs` | token 落盘 + handoff 票据 redeem | | `src-tauri/src/config.rs` | server.json / 环境变量 / 默认值;`save_server_base` 写回 | | `src-tauri/src/local_server.rs` | 本机服务安装、版本检测、进程托管、健康检查、进度与日志状态 | -| `src-tauri/src/menu.rs` | 顶部原生菜单栏(文件/编辑/视图/帮助)构建 + 事件分发 | +| `src-tauri/src/menu.rs` | 平台菜单构建 + 事件分发;macOS 使用系统菜单栏,Windows/Linux 使用窗口内菜单 | | `src-tauri/src/notify.rs` | **A1** 后台通知轮询 → 原生系统通知(接后端 `automations/notifications/list`) | | `src-tauri/src/update.rs` | **A3** 一键自动更新:拉后端 manifest → 验签 → 安装 → 重启 | | `src-tauri/tauri.conf.json` | 窗口/打包/deep-link scheme/资源/**updater 配置(pubkey + endpoints)** | @@ -151,14 +151,15 @@ HUGAGENT_SERVER_BASE=https://你的后端 npm run dev `chatStream.ts` 全部对话能力,零重复实现)。未登录时退化为唤起主窗。 - **A3 一键自动更新**(`update.rs`):菜单「帮助 → 检查更新…」或托盘触发,见下方《自动更新》。 - **A4 托盘增强**:托盘菜单新增「新建对话」「检查更新…」。 -- **原生菜单栏**(`menu.rs`):顶部「文件 / 编辑 / 视图 / 帮助」。文件=新建对话·设置服务器地址·退出; - 编辑=撤销/剪切/复制/粘贴/全选(系统预定义,直接作用于焦点输入框);视图=重新加载·全屏; - 帮助=检查更新·访问官网·关于。 +- **平台化菜单与标题栏**(`menu.rs` + `proxy.rs`):Windows/Linux 保留紧凑的一体化窗口菜单;macOS + 使用系统菜单栏和左侧原生交通灯,窗口内只保留半透明工具栏及「新建对话」「服务设置」两个 Mac + 风格图标按钮,不显示 Windows 式右侧最小化、最大化和关闭按钮。 - **设置服务器地址 UI**(菜单「文件 → 设置服务器地址…」):填后端地址→写回 server.json→重启生效, 不再必须手改 JSON。 - **本机服务一键安装**(菜单「文件 → 本机服务…」):后端不可达时也会自动显示。安装过程提供 阶段进度、实时日志、失败重试和健康检查;客户端更新携带新 CE 资源时自动升级服务代码, - `data/` 目录保持不变。 + `data/` 目录保持不变。远程模式下点击安装会先在当前页面完成安装,服务通过健康检查后才切换 + `server.json` 并重启,避免提前重启造成按钮无响应或安装状态不可见。 > 交互全部走「原生菜单/托盘 → Rust」或「导航到 `/__desktop/*` 哨兵 → 导航守卫」,**不依赖 Tauri > IPC**——因为前端跑在本地反代这个「远程源」上,`window.__TAURI__`/invoke 不保证注入。这是本壳 diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 941bbd6d..480c45c0 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -573,13 +573,25 @@ fn build_window(app: &tauri::AppHandle, url: &str) -> tauri::Result<()> { let app_for_nav = app.clone(); let (width, height, min_width, min_height) = main_window_dimensions(app); - let window = WebviewWindowBuilder::new(app, "main", WebviewUrl::External(parsed)) + let builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::External(parsed)) .title(brand::NAME) - // 用反代注入的一体化标题栏替代「系统标题栏 + 原生菜单栏」两行。 - .decorations(false) .additional_browser_args(WEBVIEW_BROWSER_ARGS) .inner_size(width, height) - .min_inner_size(min_width, min_height) + .min_inner_size(min_width, min_height); + + // Windows/Linux keep the compact custom chrome. macOS uses native window + // decorations and traffic lights, with content extending into a translucent + // toolbar in the same visual hierarchy as Codex and other modern Mac apps. + #[cfg(target_os = "macos")] + let builder = builder + .decorations(true) + .title_bar_style(tauri::TitleBarStyle::Overlay) + .hidden_title(true) + .traffic_light_position(tauri::LogicalPosition::new(18.0, 18.0)); + #[cfg(not(target_os = "macos"))] + let builder = builder.decorations(false); + + let window = builder .on_navigation(move |u| { let scheme = u.scheme(); // Tauri 内部 / 数据类源放行。 @@ -733,7 +745,17 @@ fn build_window(app: &tauri::AppHandle, url: &str) -> tauri::Result<()> { apply_display_zoom(&window); - // 主窗口不再挂原生菜单;文件/编辑/视图/帮助与产品名共用一体化标题栏。 + // macOS application menus belong in the system menu bar. Windows/Linux use + // the in-window menu injected by proxy.rs to avoid a second chrome row. + #[cfg(target_os = "macos")] + match menu::build(app) { + Ok(menu) => { + if let Err(error) = window.set_menu(menu) { + eprintln!("[menu] 挂载 macOS 原生菜单失败: {error}"); + } + } + Err(error) => eprintln!("[menu] 构建 macOS 原生菜单失败: {error}"), + } Ok(()) } diff --git a/desktop/src-tauri/src/menu.rs b/desktop/src-tauri/src/menu.rs index 53485345..bebaedf7 100644 --- a/desktop/src-tauri/src/menu.rs +++ b/desktop/src-tauri/src/menu.rs @@ -13,45 +13,103 @@ use tauri_plugin_opener::OpenerExt; use crate::brand; use crate::Shared; -/// 原生菜单保留为平台回退;Windows 主窗口使用与标题同一行的 WebView 标题栏。 +/// macOS 使用系统应用菜单;Windows/Linux 主窗口使用与标题同一行的 WebView 菜单。 #[allow(dead_code)] pub fn build(app: &AppHandle) -> tauri::Result> { - let file = SubmenuBuilder::new(app, "文件") - .text("new_chat", "新建对话") - .text("server_config", "设置服务器地址…") - .text("local_server", "本机服务…") - .separator() - .quit() - .build()?; - - // 编辑:交给系统预定义项,直接作用于焦点输入框。 - let edit = SubmenuBuilder::new(app, "编辑") - .undo() - .redo() - .separator() - .cut() - .copy() - .paste() - .select_all() - .build()?; - - let view = SubmenuBuilder::new(app, "视图") - .text("reload", "重新加载") - .separator() - .fullscreen() - .build()?; - - let about = AboutMetadataBuilder::new() - .name(Some(brand::NAME.to_string())) - .build(); - let help = SubmenuBuilder::new(app, "帮助") - .text("check_update", "检查更新…") - .text("website", "访问官网") - .separator() - .about(Some(about)) - .build()?; - - Menu::with_items(app, &[&file, &edit, &view, &help]) + #[cfg(target_os = "macos")] + { + let about = AboutMetadataBuilder::new() + .name(Some(brand::NAME.to_string())) + .build(); + let application = SubmenuBuilder::new(app, brand::NAME) + .about(Some(about)) + .separator() + .text("server_config", "设置…") + .text("check_update", "检查更新…") + .separator() + .services() + .separator() + .hide() + .hide_others() + .show_all() + .separator() + .quit() + .build()?; + + let file = SubmenuBuilder::new(app, "文件") + .text("new_chat", "新建对话") + .text("local_server", "本机服务…") + .separator() + .close_window() + .build()?; + + let edit = SubmenuBuilder::new(app, "编辑") + .undo() + .redo() + .separator() + .cut() + .copy() + .paste() + .select_all() + .build()?; + + let view = SubmenuBuilder::new(app, "显示") + .text("reload", "重新加载") + .separator() + .fullscreen() + .build()?; + + let window = SubmenuBuilder::new(app, "窗口") + .minimize() + .maximize() + .build()?; + + let help = SubmenuBuilder::new(app, "帮助") + .text("website", "访问官网") + .build()?; + + return Menu::with_items(app, &[&application, &file, &edit, &view, &window, &help]); + } + + #[cfg(not(target_os = "macos"))] + { + let file = SubmenuBuilder::new(app, "文件") + .text("new_chat", "新建对话") + .text("server_config", "设置服务器地址…") + .text("local_server", "本机服务…") + .separator() + .quit() + .build()?; + + // 编辑:交给系统预定义项,直接作用于焦点输入框。 + let edit = SubmenuBuilder::new(app, "编辑") + .undo() + .redo() + .separator() + .cut() + .copy() + .paste() + .select_all() + .build()?; + + let view = SubmenuBuilder::new(app, "视图") + .text("reload", "重新加载") + .separator() + .fullscreen() + .build()?; + + let about = AboutMetadataBuilder::new() + .name(Some(brand::NAME.to_string())) + .build(); + let help = SubmenuBuilder::new(app, "帮助") + .text("check_update", "检查更新…") + .text("website", "访问官网") + .separator() + .about(Some(about)) + .build()?; + + Menu::with_items(app, &[&file, &edit, &view, &help]) + } } /// 菜单事件分发。托盘的同名动作也复用这里(见 `build_tray`)。 diff --git a/desktop/src-tauri/src/proxy.rs b/desktop/src-tauri/src/proxy.rs index cda9cda1..e4a4f25d 100644 --- a/desktop/src-tauri/src/proxy.rs +++ b/desktop/src-tauri/src/proxy.rs @@ -40,9 +40,10 @@ pub struct ProxyState { /// 在 127.0.0.1 随机端口起反代,返回实际端口。axum serve 在后台 task 常驻。 pub async fn serve(state: ProxyState, web_dir: PathBuf) -> std::io::Result { let index = web_dir.join("index.html"); - // SPA 首页注入一体化标题栏;静态资源仍直接读取原 dist。 + // SPA 首页注入平台标题栏;macOS 保留原生菜单与交通灯,只叠加轻量工具栏。 + // Windows/Linux 继续使用一体化自绘标题栏。静态资源仍直接读取原 dist。 let raw_index = std::fs::read_to_string(&index).unwrap_or_default(); - let injected_index = inject_after_body(&raw_index, &titlebar_block(TB_OFFSET_SPA)); + let injected_index = inject_after_body(&raw_index, &platform_titlebar_block(true)); let injected_path = std::env::temp_dir().join(format!("hugagent-shell-index-{}.html", std::process::id())); if let Err(error) = std::fs::write(&injected_path, injected_index.as_bytes()) { @@ -166,7 +167,7 @@ async fn login_page() -> Html { let html = LOGIN_HTML .replace("HugAgentOS", brand::NAME) .replace("/icon.png", brand::LOGIN_LOGO_URL); - Html(inject_after_body(&html, &titlebar_block(TB_OFFSET_PAGE))) + Html(inject_after_body(&html, &platform_titlebar_block(false))) } /// 关闭主窗口时的自定义确认页(带「记住我的选择」勾选框)。按钮整页导航到 @@ -182,7 +183,7 @@ async fn server_config_page(State(state): State) -> Html { let html = SERVER_CONFIG_HTML .replace("__CURRENT_BASE__", &html_escape(&state.server_base)) .replace("HugAgentOS", brand::NAME); - Html(inject_after_body(&html, &titlebar_block(TB_OFFSET_PAGE))) + Html(inject_after_body(&html, &platform_titlebar_block(false))) } /// 后端不可达或用户在安装器选择本机服务时展示的一体化部署页。 @@ -201,8 +202,18 @@ async fn setup_page(State(state): State) -> Html { "false" }, ) + .replace( + "__PLATFORM__", + if cfg!(target_os = "macos") { + "macos" + } else if cfg!(target_os = "windows") { + "windows" + } else { + "linux" + }, + ) .replace("HugAgentOS", brand::NAME); - Html(inject_after_body(&html, &titlebar_block(TB_OFFSET_PAGE))) + Html(inject_after_body(&html, &platform_titlebar_block(false))) } #[derive(serde::Serialize)] @@ -246,6 +257,11 @@ const TB_OFFSET_SPA: &str = "body{box-sizing:border-box!important;padding-top:36px!important}.jx-appLoading{height:100%!important}"; const TB_OFFSET_PAGE: &str = "body{box-sizing:border-box!important;padding-top:36px!important}"; +const MAC_TITLEBAR_HEIGHT: u8 = 52; +const MAC_OFFSET_SPA: &str = + "body{box-sizing:border-box!important;padding-top:52px!important}.jx-appLoading{height:100%!important}"; +const MAC_OFFSET_PAGE: &str = "body{box-sizing:border-box!important;padding-top:52px!important}"; + const TB_CSS: &str = r##" #hugagent-titlebar{position:fixed;inset:0 0 auto 0;height:36px;z-index:2147483647;display:flex;align-items:center;background:#F7F8FA;border-bottom:1px solid #E5E9EF;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif;color:#30343B} #hugagent-titlebar *{box-sizing:border-box} @@ -339,6 +355,42 @@ bar.addEventListener('mousedown',function(event){if(event.button!==0||isControl( bar.addEventListener('dblclick',function(event){if(isControl(event.target))return;sentinel('/__desktop/win?action=toggle-maximize');}); })();"##; +// macOS keeps the application menu in the system menu bar and the native +// traffic-light controls on the left. The in-window strip only exposes app +// actions, matching the compact icon-toolbar pattern used by modern Mac apps. +const MAC_TB_CSS: &str = r##" +#hugagent-mac-titlebar{position:fixed;inset:0 0 auto 0;height:52px;z-index:2147483647;display:flex;align-items:center;padding:0 12px 0 82px;background:rgba(247,247,246,.86);border-bottom:1px solid rgba(28,28,28,.09);backdrop-filter:saturate(180%) blur(22px);-webkit-backdrop-filter:saturate(180%) blur(22px);font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text",sans-serif;color:#252522;-webkit-user-select:none;user-select:none} +#hugagent-mac-titlebar *{box-sizing:border-box} +#hugagent-mac-titlebar .mac-brand{display:flex;align-items:center;min-width:0;gap:8px;font-size:13px;font-weight:590;letter-spacing:-.01em;color:#3a3935} +#hugagent-mac-titlebar .mac-logo{width:18px;height:18px;border-radius:5px;object-fit:cover;box-shadow:0 1px 2px rgba(0,0,0,.08)} +#hugagent-mac-titlebar .mac-spacer{flex:1;height:100%;min-width:24px} +#hugagent-mac-titlebar .mac-actions{display:flex;align-items:center;gap:7px} +#hugagent-mac-titlebar .mac-toolButton{width:30px;height:30px;padding:0;border:1px solid rgba(28,28,28,.1);border-radius:8px;background:rgba(255,255,255,.64);color:#45443f;display:flex;align-items:center;justify-content:center;box-shadow:0 1px 1px rgba(0,0,0,.03);cursor:default;transition:background .12s ease,border-color .12s ease,transform .08s ease} +#hugagent-mac-titlebar .mac-toolButton:hover{background:rgba(255,255,255,.96);border-color:rgba(28,28,28,.16)} +#hugagent-mac-titlebar .mac-toolButton:active{background:rgba(232,231,227,.92);transform:scale(.96)} +@media(prefers-color-scheme:dark){#hugagent-mac-titlebar{background:rgba(38,38,36,.88);border-bottom-color:rgba(255,255,255,.09);color:#f2f2ef}#hugagent-mac-titlebar .mac-brand{color:#e7e6e1}#hugagent-mac-titlebar .mac-toolButton{background:rgba(255,255,255,.07);border-color:rgba(255,255,255,.12);color:#e9e8e3}#hugagent-mac-titlebar .mac-toolButton:hover{background:rgba(255,255,255,.13);border-color:rgba(255,255,255,.18)}} +"##; + +const MAC_TB_JS: &str = r##"(function(){ +var bar=document.getElementById('hugagent-mac-titlebar');if(!bar)return; +if(new URLSearchParams(location.search).get('quickask')==='1'){ + bar.remove();var style=document.getElementById('hugagent-titlebar-style');if(style)style.remove();return; +} +function sentinel(path){window.location.href=path;} +bar.querySelectorAll('[data-act]').forEach(function(item){ + item.addEventListener('mousedown',function(event){event.preventDefault();}); + item.addEventListener('click',function(event){event.stopPropagation();sentinel('/__desktop/menu?action='+encodeURIComponent(item.dataset.act));}); +}); +bar.addEventListener('mousedown',function(event){ + if(event.button!==0||(event.target instanceof Element&&event.target.closest('button')))return; + sentinel('/__desktop/win?action=drag'); +}); +bar.addEventListener('dblclick',function(event){ + if(event.target instanceof Element&&event.target.closest('button'))return; + sentinel('/__desktop/win?action=toggle-maximize'); +}); +})();"##; + fn titlebar_block(offset_css: &str) -> String { format!( "\ @@ -356,6 +408,32 @@ fn titlebar_block(offset_css: &str) -> String { ) } +fn mac_titlebar_block(offset_css: &str) -> String { + format!( + "\ +
\ +
\"\"{name}
\ +
\ +\ +\ +
", + css = MAC_TB_CSS, + offset = offset_css, + height = MAC_TITLEBAR_HEIGHT, + logo = brand::LOGIN_LOGO_URL, + name = brand::NAME, + script = MAC_TB_JS, + ) +} + +fn platform_titlebar_block(spa: bool) -> String { + if cfg!(target_os = "macos") { + mac_titlebar_block(if spa { MAC_OFFSET_SPA } else { MAC_OFFSET_PAGE }) + } else { + titlebar_block(if spa { TB_OFFSET_SPA } else { TB_OFFSET_PAGE }) + } +} + fn inject_after_body(html: &str, block: &str) -> String { match html.find("") { Some(position) => { @@ -514,10 +592,17 @@ const SETUP_HTML: &str = r##" color:#D5DEEC;font:11.5px/1.55 Consolas,"SFMono-Regular",monospace;white-space:pre-wrap;word-break:break-all} .error{display:none;color:var(--danger);font-size:13px;margin-top:12px}.ready{color:var(--ok)} .current{margin-top:17px;color:#94A3B8;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + body.platform-macos{background:#ECECEA;padding-left:22px;padding-right:22px} + body.platform-macos .shell{max-width:720px;border-color:rgba(28,28,28,.1);border-radius:16px;box-shadow:0 22px 55px rgba(0,0,0,.12)} + body.platform-macos .head{padding-top:25px;background:rgba(252,252,250,.92)} + body.platform-macos .body{background:#FAFAF8} + body.platform-macos .choice{border-color:rgba(28,28,28,.1);border-radius:13px;box-shadow:0 1px 1px rgba(0,0,0,.025)} + body.platform-macos .choice.recommended{border-color:rgba(18,109,255,.3);background:#F7FAFF} + body.platform-macos .btn{border-radius:9px;box-shadow:none} @media(max-width:640px){.choices{grid-template-columns:1fr}.desc{min-height:0}.head,.body{padding-left:22px;padding-right:22px}} - +
HugAgentOS 桌面端
@@ -559,6 +644,7 @@ const SETUP_HTML: &str = r##" var activeLocal = __ACTIVE_LOCAL__; var localSupported = __LOCAL_SUPPORTED__; var installing = false; + var pollTimer = null; if(manage){document.getElementById('lead').textContent='查看或切换本机单用户服务,也可以继续连接已部署的团队服务器。';} function sentinel(path){ window.location.href = path; } function connectServer(){ sentinel('/__desktop/connect-server'); } @@ -567,19 +653,25 @@ const SETUP_HTML: &str = r##" function finishReady(){ if(activeLocal){location.replace('/');}else{activateLocal();} } async function installLocal(){ if(!localSupported){showError('当前安装包暂不支持在此系统一键部署本机服务。');return;} - if(!activeLocal){ activateLocal(); return; } installing = true; - document.getElementById('install').disabled = true; + var button=document.getElementById('install'); + button.disabled=true;button.textContent='正在启动安装…'; document.getElementById('progressWrap').style.display = 'block'; - try{ await fetch('/__desktop/setup/install',{method:'POST'}); } - catch(e){ showError('无法启动安装:' + e.message); } - poll(); + document.getElementById('message').textContent='正在准备本机服务…'; + document.getElementById('error').style.display='none'; + try{ + var response=await fetch('/__desktop/setup/install',{method:'POST'}); + if(!response.ok)throw new Error('HTTP '+response.status); + await response.json(); + poll(); + }catch(e){installing=false;showError('无法启动安装:'+e.message);} } function showError(text){ var el=document.getElementById('error');el.textContent=text;el.style.display='block'; - document.getElementById('install').disabled=false; + var button=document.getElementById('install');button.disabled=false;button.textContent='重试安装'; } async function poll(){ + if(pollTimer){clearTimeout(pollTimer);pollTimer=null;} try{ var response=await fetch('/__desktop/setup/status',{cache:'no-store'}); var s=await response.json(); @@ -598,16 +690,24 @@ const SETUP_HTML: &str = r##" } if(s.phase==='error'){showError(s.message||'安装失败,请重试。');installing=false;return;} if(s.ready){ + installing=false; document.getElementById('message').innerHTML='本机服务已就绪'; document.getElementById('install').style.display='none'; if(s.active_local && !manage){ setTimeout(function(){location.replace('/__desktop/login')},450);return; } + if(!s.active_local && !manage){ + document.getElementById('message').textContent='安装完成,正在切换到本机服务…'; + setTimeout(activateLocal,350);return; + } document.getElementById('readyButton').textContent=s.active_local?'返回应用':'切换到本机服务'; document.getElementById('readyActions').style.display='block'; return; } - if(s.phase==='installing'||s.phase==='starting'){installing=true;document.getElementById('install').disabled=true;} + if(s.phase==='installing'||s.phase==='starting'){ + installing=true;var button=document.getElementById('install');button.disabled=true; + button.textContent=s.phase==='starting'?'正在启动服务…':'正在安装…'; + } }catch(e){ if(installing) showError('读取安装状态失败:'+e.message); } - setTimeout(poll,900); + pollTimer=setTimeout(poll,900); } poll(); @@ -730,7 +830,7 @@ mod tests { use super::*; #[test] - fn titlebar_keeps_product_and_four_menus_on_one_row() { + fn windows_titlebar_keeps_product_and_four_menus_on_one_row() { let block = titlebar_block(TB_OFFSET_SPA); assert!(block.contains("tb-name")); for label in ["文件", "编辑", "视图", "帮助"] { @@ -740,6 +840,24 @@ mod tests { assert!(block.contains("data-win=\"close\"")); } + #[test] + fn mac_titlebar_keeps_native_window_controls_and_uses_app_actions() { + let block = mac_titlebar_block(MAC_OFFSET_SPA); + assert!(block.contains("hugagent-mac-titlebar")); + assert!(block.contains("data-act=\"new_chat\"")); + assert!(block.contains("data-act=\"server_config\"")); + assert!(!block.contains("data-win=\"minimize\"")); + assert!(!block.contains("data-win=\"close\"")); + assert!(!block.contains("tb-menuLabel")); + } + + #[test] + fn setup_install_starts_in_place_before_switching_modes() { + assert!(SETUP_HTML.contains("fetch('/__desktop/setup/install'")); + assert!(SETUP_HTML.contains("正在启动安装")); + assert!(!SETUP_HTML.contains("if(!activeLocal){ activateLocal(); return; }")); + } + #[test] fn titlebar_is_injected_before_page_content() { let html = "
content
";