diff --git a/desktop/src-tauri/installer-hooks.nsh b/desktop/src-tauri/installer-hooks.nsh index 359c575..6633dc1 100644 --- a/desktop/src-tauri/installer-hooks.nsh +++ b/desktop/src-tauri/installer-hooks.nsh @@ -1,8 +1,8 @@ !include "FileFunc.nsh" !macro NSIS_HOOK_POSTINSTALL - ; SSH / 企业软件分发可显式指定首装模式;未传参数时仍沿用交互选择。 - ; 自动更新不会携带这两个参数,因此不会覆盖用户已有配置。 + ; SSH / 企业软件分发可显式指定首装模式;普通交互安装不弹任何窗口—— + ; 运行模式在应用首启的初始化页选择。自动更新不携带这两个参数,不会覆盖用户配置。 ${GetParameters} $R0 ClearErrors ${GetOptions} $R0 "/HUGAGENT_LOCAL" $R1 @@ -18,26 +18,10 @@ Goto hugagent_install_mode_write hugagent_install_mode_check_silent: - ; Tauri 的 NSIS 自动更新使用 /P(passive)或 /S(silent),不能再次弹首装选择。 - IfSilent hugagent_install_mode_done - ClearErrors - ${GetOptions} $R0 "/P" $R1 - IfErrors hugagent_install_mode_check_long_passive hugagent_install_mode_done - - hugagent_install_mode_check_long_passive: - ClearErrors - ${GetOptions} $R0 "/passive" $R1 - IfErrors hugagent_install_mode_interactive hugagent_install_mode_done - - hugagent_install_mode_interactive: - ; 已选择过部署方式时保持原值,升级安装不覆盖用户配置。 - IfFileExists "$APPDATA\com.hugagent.desktop\install-mode" hugagent_install_mode_done - MessageBox MB_YESNO|MB_ICONQUESTION "是否同时安装无 Docker 的本机服务?选择“是”后,首次启动会联网下载 Python 依赖;选择“否”则连接已有服务器。" IDNO hugagent_install_mode_remote - StrCpy $R2 "local" - Goto hugagent_install_mode_write - - hugagent_install_mode_remote: - StrCpy $R2 "remote" + ; 安装器不做任何交互弹窗——运行模式在应用首启的初始化页里选(本机 / 云端 / + ; 本机+云端三选一)。这里只处理软件分发系统显式传入的 /HUGAGENT_LOCAL、 + ; /HUGAGENT_REMOTE 参数;未传参数时什么都不写,首启必出初始化页。 + Goto hugagent_install_mode_done hugagent_install_mode_write: CreateDirectory "$APPDATA\com.hugagent.desktop" diff --git a/desktop/src-tauri/src/config.rs b/desktop/src-tauri/src/config.rs index e2cf40b..30270cd 100644 --- a/desktop/src-tauri/src/config.rs +++ b/desktop/src-tauri/src/config.rs @@ -189,8 +189,10 @@ pub fn save_server_base(config_dir: &Path, server_base: &str) -> Result<(), Stri save(config_dir, &cfg) } -/// 客户端是否已完成初始化选型。首启(无 server.json)时展示运行模式选择页; -/// 由环境变量强制服务器地址时视作已配置,直接跳过选择页。 +/// 客户端是否已完成初始化选型。必须在 server.json 里**显式选过**运行形态,且 +/// 云端 / 双模式还要有云端地址——安装器预选(只写形态)与旧版遗留配置(只有 +/// deployment_mode)都不算已完成,首启会补一次初始化页(模式 / 地址已预填, +/// 确认后不再询问)。由环境变量强制服务器地址时视作已配置,直接跳过选择页。 pub fn is_provisioned(config_dir: &Path) -> bool { if std::env::var("HUGAGENT_SERVER_BASE") .map(|v| !v.trim().is_empty()) @@ -198,7 +200,18 @@ pub fn is_provisioned(config_dir: &Path) -> bool { { return true; } - config_dir.join("server.json").is_file() + if !config_dir.join("server.json").is_file() { + return false; + } + let cfg = load(config_dir); + match cfg.provision_mode { + None => false, + Some(ProvisionMode::LocalOnly) => true, + // 云端 / 双模式:没有云端地址就还没配完(安装器预选即此状态)。 + // 只认 provision() 显式记下的 cloud_server_base——server_base 有编译期 + // 默认值,用它判断会把「预选了云端但还没填地址」误判成已配置。 + Some(_) => !cfg.cloud_server_base.trim().is_empty(), + } } /// 初始化选型落盘:写入运行形态 + 记住云端地址,并按形态设定初始运行目标。 @@ -236,14 +249,6 @@ pub fn provision( save(config_dir, &cfg) } -/// 切换为客户端托管的本机服务。端口固定,便于桌面壳重启后恢复连接。 -pub fn save_local_server(config_dir: &Path, server_base: &str) -> Result<(), String> { - let mut cfg = load(config_dir); - cfg.deployment_mode = DeploymentMode::Local; - cfg.server_base = server_base.trim().trim_end_matches('/').to_string(); - save(config_dir, &cfg) -} - fn save(config_dir: &Path, cfg: &AppConfig) -> Result<(), String> { let path = config_dir.join("server.json"); let text = serde_json::to_string_pretty(cfg).map_err(|e| format!("序列化失败: {e}"))?; @@ -253,15 +258,26 @@ fn save(config_dir: &Path, cfg: &AppConfig) -> Result<(), String> { /// NSIS 交互安装写入的一次性选择。`install-mode` 由安装器保留,用于避免升级时 /// 重复询问;应用只消费 `.pending`,因此用户日后在菜单切换模式不会被旧选择覆盖。 -pub fn pending_installer_mode(config_dir: &Path) -> Option { +/// 值即运行形态:`local`(仅本机)/ `remote`(仅云端)/ `dual`(本机+云端)。 +pub fn pending_installer_mode(config_dir: &Path) -> Option { let value = std::fs::read_to_string(config_dir.join("install-mode.pending")).ok()?; match value.trim().to_ascii_lowercase().as_str() { - "local" => Some(DeploymentMode::Local), - "remote" => Some(DeploymentMode::Remote), + "local" => Some(ProvisionMode::LocalOnly), + "remote" => Some(ProvisionMode::CloudOnly), + "dual" => Some(ProvisionMode::Dual), _ => None, } } +/// 安装器选择的「预选」落盘:只记运行形态,不写地址。云端 / 双模式还差服务器 +/// 地址,`is_provisioned` 因此仍为 false —— 首启初始化页会按此预选,用户只需 +/// 补地址;仅本机不需要地址,直接走完整 `provision`(不经这里)。 +pub fn preselect_provision_mode(config_dir: &Path, mode: ProvisionMode) -> Result<(), String> { + let mut cfg = load(config_dir); + cfg.provision_mode = Some(mode); + save(config_dir, &cfg) +} + pub fn clear_pending_installer_mode(config_dir: &Path) -> Result<(), String> { let path = config_dir.join("install-mode.pending"); match std::fs::remove_file(path) { @@ -302,7 +318,7 @@ mod tests { #[test] fn switching_modes_is_persisted() { let dir = temp_dir("modes"); - save_local_server(&dir, "http://127.0.0.1:32101/").unwrap(); + provision(&dir, ProvisionMode::LocalOnly, "http://127.0.0.1:32101/", "").unwrap(); let local = load(&dir); assert!(local.uses_local_server()); assert_eq!(local.server_base, "http://127.0.0.1:32101"); @@ -389,8 +405,42 @@ mod tests { fn pending_installer_choice_is_one_time() { let dir = temp_dir("installer-choice"); std::fs::write(dir.join("install-mode.pending"), "local").unwrap(); - assert_eq!(pending_installer_mode(&dir), Some(DeploymentMode::Local)); + assert_eq!(pending_installer_mode(&dir), Some(ProvisionMode::LocalOnly)); clear_pending_installer_mode(&dir).unwrap(); assert_eq!(pending_installer_mode(&dir), None); + + std::fs::write(dir.join("install-mode.pending"), "dual").unwrap(); + assert_eq!(pending_installer_mode(&dir), Some(ProvisionMode::Dual)); + std::fs::write(dir.join("install-mode.pending"), "remote").unwrap(); + assert_eq!(pending_installer_mode(&dir), Some(ProvisionMode::CloudOnly)); + } + + #[test] + fn legacy_server_json_requires_reprovision() { + // 旧版遗留配置只有 deployment_mode:升级后要补一次初始化页(预填确认)。 + let dir = temp_dir("legacy-reprovision"); + std::fs::write( + dir.join("server.json"), + r#"{"deployment_mode":"local","server_base":"http://127.0.0.1:32101"}"#, + ) + .unwrap(); + assert!(!is_provisioned(&dir)); + } + + #[test] + fn preselected_cloud_mode_waits_for_address() { + // 安装器预选云端/双模式:形态已定但地址未填 → 仍未完成初始化; + // 初始化页补完地址(provision)后才算配好。 + let dir = temp_dir("preselect-dual"); + preselect_provision_mode(&dir, ProvisionMode::Dual).unwrap(); + assert!(!is_provisioned(&dir)); + provision( + &dir, + ProvisionMode::Dual, + "http://127.0.0.1:32101", + "https://team.example.test", + ) + .unwrap(); + assert!(is_provisioned(&dir)); } } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index f1134dd..aa25c40 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -249,17 +249,23 @@ pub fn run() { let mut cfg = config::load(&config_dir); - // NSIS 交互安装可选择「同时安装本机服务」。选择通过一次性 pending - // 文件交给应用消费;自动更新不写 pending,用户日后的菜单切换也不会 - // 被旧安装选择覆盖。该机制同时兼容已有 server.json 的旧版客户端升级。 + // NSIS 交互安装的「运行模式三选一」通过一次性 pending 文件交给应用消费; + // 自动更新不写 pending,用户日后的菜单切换也不会被旧安装选择覆盖。 + // 仅本机不需要地址 → 直接完成初始化;云端 / 双模式还差服务器地址 → + // 只预选形态(is_provisioned 仍为 false),首启初始化页按预选补地址。 if let Some(installer_mode) = config::pending_installer_mode(&config_dir) { match installer_mode { - config::DeploymentMode::Local => { - config::save_local_server(&config_dir, local_server::LOCAL_SERVER_BASE) - .map_err(std::io::Error::other)?; + config::ProvisionMode::LocalOnly => { + config::provision( + &config_dir, + config::ProvisionMode::LocalOnly, + local_server::LOCAL_SERVER_BASE, + "", + ) + .map_err(std::io::Error::other)?; } - config::DeploymentMode::Remote => { - config::save_server_base(&config_dir, cfg.server_base_trimmed()) + other => { + config::preselect_provision_mode(&config_dir, other) .map_err(std::io::Error::other)?; } } @@ -314,8 +320,8 @@ pub fn run() { } // 本机模式下,安装包版本变化会自动升级服务资源;已安装且同版本则直接 - // 拉起服务。安装/启动都在后台进行,主窗口显示可观察、可重试的进度页。 - // Dual 同样确保本机服务安装并常驻(后台,不阻塞云端使用)。 + // 拉起服务。安装/启动任务在后台跑,但主窗口与本机模式一致地停在可观察、 + // 可重试的进度页(Dual 也一样:本机执行面装好才进云端,见下方 start 路由)。 if cfg.uses_local_server() || hybrid_local { if local_server.needs_install() { local_server.install_in_background(); @@ -324,8 +330,15 @@ pub fn run() { } } - let backend_ready = if cfg.uses_local_server() { + // Dual 的本机就绪状态单独留一份:主后端(云端)可达性与本机执行面 + // 就绪与否是两件事,start 路由两个都要看。 + let local_ready_now = if cfg.uses_local_server() || hybrid_local { tauri::async_runtime::block_on(local_server.is_ready()) + } else { + true + }; + let backend_ready = if cfg.uses_local_server() { + local_ready_now } else { tauri::async_runtime::block_on(local_server::LocalServerManager::probe_base( &http, @@ -424,6 +437,9 @@ pub fn run() { format!("http://127.0.0.1:{}/__desktop/init", port) } else if !backend_ready { format!("http://127.0.0.1:{}/__desktop/setup", port) + } else if hybrid_local && !local_ready_now { + // Dual:本机执行面和本机模式一样在前台装完(进度页),就绪后自动回云端。 + format!("http://127.0.0.1:{}/__desktop/setup", port) } else if token0.is_some() { format!("http://127.0.0.1:{}/", port) } else { @@ -824,9 +840,14 @@ fn build_window(app: &tauri::AppHandle, url: &str) -> tauri::Result<()> { eprintln!("[config] 双模式下忽略 activate-local(云端为主)"); return; } - if let Err(error) = - config::save_local_server(&dir, local_server::LOCAL_SERVER_BASE) - { + // 整体切到本机 = LocalOnly 形态:连 provision_mode 一起写全, + // 重启后不会再被初始化页拦一道(云端地址仍被记住,可切回)。 + if let Err(error) = config::provision( + &dir, + config::ProvisionMode::LocalOnly, + local_server::LOCAL_SERVER_BASE, + "", + ) { eprintln!("[config] 切换本机服务失败: {error}"); return; }