Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 6 additions & 22 deletions desktop/src-tauri/installer-hooks.nsh
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
!include "FileFunc.nsh"

!macro NSIS_HOOK_POSTINSTALL
; SSH / 企业软件分发可显式指定首装模式;未传参数时仍沿用交互选择。
; 自动更新不会携带这两个参数,因此不会覆盖用户已有配置
; SSH / 企业软件分发可显式指定首装模式;普通交互安装不弹任何窗口——
; 运行模式在应用首启的初始化页选择。自动更新不携带这两个参数,不会覆盖用户配置
${GetParameters} $R0
ClearErrors
${GetOptions} $R0 "/HUGAGENT_LOCAL" $R1
Expand All @@ -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"
Expand Down
82 changes: 66 additions & 16 deletions desktop/src-tauri/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,16 +189,29 @@ 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())
.unwrap_or(false)
{
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(),
}
}

/// 初始化选型落盘:写入运行形态 + 记住云端地址,并按形态设定初始运行目标。
Expand Down Expand Up @@ -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}"))?;
Expand All @@ -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<DeploymentMode> {
/// 值即运行形态:`local`(仅本机)/ `remote`(仅云端)/ `dual`(本机+云端)。
pub fn pending_installer_mode(config_dir: &Path) -> Option<ProvisionMode> {
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) {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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));
}
}
49 changes: 35 additions & 14 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
}
}
Expand Down Expand Up @@ -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();
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand Down
Loading