diff --git a/varManager_backend/src/api/mod.rs b/varManager_backend/src/api/mod.rs index ad2d461..40945a7 100644 --- a/varManager_backend/src/api/mod.rs +++ b/varManager_backend/src/api/mod.rs @@ -654,6 +654,23 @@ fn normalize_optional(value: Option) -> Option { }) } +/// Normalize vam_exec: if user provides a full path, extract just the filename. +/// The system always combines vampath + vam_exec, so only the filename is needed. +fn normalize_vam_exec(value: Option) -> Option { + normalize_optional(value).map(|s| { + let path = std::path::Path::new(&s); + if path.is_absolute() || s.contains('\\') || s.contains('/') { + // Extract filename from path + path.file_name() + .and_then(|n| n.to_str()) + .map(|n| n.to_string()) + .unwrap_or(s) + } else { + s + } + }) +} + fn normalize_proxy(mut proxy: crate::app::ProxyConfig) -> crate::app::ProxyConfig { proxy.host = proxy.host.trim().to_string(); proxy.username = normalize_optional(proxy.username); @@ -702,7 +719,7 @@ fn apply_config_update(current: &Config, req: UpdateConfigRequest) -> Result Result<(), String> { @@ -31,19 +39,7 @@ pub fn start_vam(state: &AppState) -> Result<(), String> { return Err(format!("vam executable not found: {}", exec_path.display())); } - if is_cmd_script(&exec_path) { - let exec = format!("\"{}\"", exec_path.to_string_lossy()); - Command::new("cmd") - .args(["/C", exec.as_str()]) - .current_dir(&vampath) - .spawn() - .map_err(|err| err.to_string())?; - } else { - Command::new(&exec_path) - .current_dir(&vampath) - .spawn() - .map_err(|err| err.to_string())?; - } + spawn_detached(&exec_path, &vampath)?; Ok(()) } @@ -108,3 +104,48 @@ fn is_cmd_script(path: &Path) -> bool { .map(|ext| ext.eq_ignore_ascii_case("bat") || ext.eq_ignore_ascii_case("cmd")) .unwrap_or(false) } + +/// Spawn a process fully detached from the parent. +/// This prevents the child from blocking on inherited stdin/stdout/stderr handles. +#[cfg(windows)] +fn spawn_detached(exec_path: &Path, working_dir: &Path) -> Result<(), String> { + if is_cmd_script(exec_path) { + // For bat/cmd files: use "start" to launch in a new console window. + // Use /B and CREATE_NO_WINDOW to keep it hidden while preserving START behavior. + let exec = exec_path.to_string_lossy(); + let workdir = working_dir.to_string_lossy(); + Command::new("cmd") + .args(["/C", "start", "", "/B", "/D", &workdir, &*exec]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP) + .spawn() + .map_err(|err| err.to_string())?; + } else { + // For exe files: launch directly with detached flags + Command::new(exec_path) + .current_dir(working_dir) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP) + .spawn() + .map_err(|err| err.to_string())?; + } + + Ok(()) +} + +#[cfg(not(windows))] +fn spawn_detached(exec_path: &Path, working_dir: &Path) -> Result<(), String> { + Command::new(exec_path) + .current_dir(working_dir) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|err| err.to_string())?; + + Ok(()) +} diff --git a/varManager_backend/src/jobs/update_db.rs b/varManager_backend/src/jobs/update_db.rs index 3a2cdd8..5c370cd 100644 --- a/varManager_backend/src/jobs/update_db.rs +++ b/varManager_backend/src/jobs/update_db.rs @@ -41,8 +41,9 @@ pub async fn run_update_db_job(state: AppState, reporter: JobReporter) -> Result } fn update_db_blocking(state: &AppState, reporter: &JobReporter) -> Result<(), String> { + let overall_start = std::time::Instant::now(); let (varspath, vampath) = config_paths(state)?; - reporter.log(format!("UpdateDB start: varspath={}", varspath.display())); + reporter.log(format!("UpdateDB started: varspath={}", varspath.display())); reporter.progress(1); let addon_vars = match vampath.as_ref() { @@ -64,6 +65,7 @@ fn update_db_blocking(state: &AppState, reporter: &JobReporter) -> Result<(), St vars_for_install = dedup_strings(vars_for_install); save_vars_for_install(&vars_for_install)?; + reporter.log("Phase 1/5: Tidying VAR files...".to_string()); tidy_vars( &varspath, if vampath.is_some() { Some(&addon_vars) } else { None }, @@ -94,6 +96,8 @@ fn update_db_blocking(state: &AppState, reporter: &JobReporter) -> Result<(), St return Ok(()); } + reporter.log(format!("Phase 2/5: Processing {} VAR files into database...", var_files.len())); + let dependency_regex = Regex::new( r#"\x22(([^\r\n\x22\x3A\x2E]{1,60})\x2E([^\r\n\x22\x3A\x2E]{1,80})\x2E(\d+|latest))(\x22?\s*)\x3A"#, ) @@ -108,6 +112,9 @@ fn update_db_blocking(state: &AppState, reporter: &JobReporter) -> Result<(), St handle.block_on(async move { let mut exist_vars: HashSet = HashSet::new(); let mut tx = pool_for_tx.begin().await.map_err(|err| err.to_string())?; + let total_vars = var_files.len(); + let start_time = std::time::Instant::now(); + for (idx, var_file) in var_files.iter().enumerate() { let basename = match var_file.file_stem() { Some(stem) => stem.to_string_lossy().to_string(), @@ -150,13 +157,27 @@ fn update_db_blocking(state: &AppState, reporter: &JobReporter) -> Result<(), St Err(ProcessError::Io(err)) => return Err(err), } - let progress = 10 + ((idx + 1) * 80 / var_files.len()) as u8; - if idx % 200 == 0 || idx + 1 == var_files.len() { + let progress = 10 + ((idx + 1) * 80 / total_vars) as u8; + // Report progress every 50 VARs or at completion, with detailed info + if idx % 50 == 0 || idx + 1 == total_vars { + let elapsed = start_time.elapsed().as_secs_f64(); + let speed = if elapsed > 0.0 { (idx + 1) as f64 / elapsed } else { 0.0 }; + let remaining = if speed > 0.0 { (total_vars - idx - 1) as f64 / speed } else { 0.0 }; + reporter_async.progress(progress.min(90)); + reporter_async.log(format!( + "Processing VARs: {}/{} ({:.1}%) | Speed: {:.1} VAR/s | ETA: {:.0}s | Current: {}", + idx + 1, total_vars, + (idx + 1) as f64 / total_vars as f64 * 100.0, + speed, + remaining, + basename + )); } } cleanup_missing_vars(&mut tx, &exist_vars, &varspath_async, &reporter_async).await?; + reporter_async.log("Phase 3/5: Committing database changes...".to_string()); tx.commit().await.map_err(|err| err.to_string())?; Ok::<(), String>(()) })?; @@ -165,12 +186,15 @@ fn update_db_blocking(state: &AppState, reporter: &JobReporter) -> Result<(), St if !vars_for_install.is_empty() { if let Some(vampath) = vampath.as_ref() { + reporter.log("Phase 4/5: Installing pending VARs...".to_string()); reporter.log(format!( "Install pending vars (varsForInstall): {}", vars_for_install.len() )); let pending = handle.block_on(vars_dependencies(&pool, vars_for_install))?; let total = pending.len(); + let start_time = std::time::Instant::now(); + for (idx, var_name) in pending.iter().enumerate() { match handle.block_on(install_var(&pool, &varspath, vampath, var_name)) { Ok(InstallOutcome::Installed) => { @@ -181,9 +205,20 @@ fn update_db_blocking(state: &AppState, reporter: &JobReporter) -> Result<(), St reporter.log(format!("install pending failed {} ({})", var_name, err)); } } - if total > 0 && (idx % 50 == 0 || idx + 1 == total) { + if total > 0 && (idx % 20 == 0 || idx + 1 == total) { let progress = 90 + ((idx + 1) * 5 / total) as u8; + let elapsed = start_time.elapsed().as_secs_f64(); + let speed = if elapsed > 0.0 { (idx + 1) as f64 / elapsed } else { 0.0 }; + let remaining = if speed > 0.0 { (total - idx - 1) as f64 / speed } else { 0.0 }; + reporter.progress(progress.min(95)); + reporter.log(format!( + "Installing VARs: {}/{} ({:.1}%) | Speed: {:.1} VAR/s | ETA: {:.0}s", + idx + 1, total, + (idx + 1) as f64 / total as f64 * 100.0, + speed, + remaining + )); } } let _ = clear_vars_for_install(); @@ -195,6 +230,7 @@ fn update_db_blocking(state: &AppState, reporter: &JobReporter) -> Result<(), St reporter.progress(95); if let Some(vampath) = vampath.as_ref() { + reporter.log("Phase 5/5: Refreshing installation status...".to_string()); handle.block_on(refresh_install_status(&pool, vampath, reporter))?; reporter.progress(97); match system_ops::rescan_packages(state) { @@ -207,7 +243,13 @@ fn update_db_blocking(state: &AppState, reporter: &JobReporter) -> Result<(), St } reporter.progress(100); - reporter.log("UpdateDB completed".to_string()); + let total_elapsed = overall_start.elapsed(); + reporter.log(format!( + "UpdateDB completed in {:.1}s ({:.0}m {:.0}s)", + total_elapsed.as_secs_f64(), + total_elapsed.as_secs() / 60, + total_elapsed.as_secs() % 60 + )); Ok(()) } @@ -305,6 +347,8 @@ fn tidy_vars( } let total = vars.len(); + let start_time = std::time::Instant::now(); + for (idx, varfile) in vars.into_iter().enumerate() { if !varfile.exists() { continue; @@ -345,11 +389,20 @@ fn tidy_vars( move_file(&varfile, &dest)?; } - if idx % 500 == 0 && total > 0 { - reporter.log(format!("TidyVars progress: {}/{}", idx + 1, total)); + if idx % 200 == 0 && total > 0 { + let elapsed = start_time.elapsed().as_secs_f64(); + let speed = if elapsed > 0.0 { (idx + 1) as f64 / elapsed } else { 0.0 }; + let remaining = if speed > 0.0 { (total - idx - 1) as f64 / speed } else { 0.0 }; + reporter.log(format!( + "TidyVars: {}/{} ({:.1}%) | Speed: {:.1} VAR/s | ETA: {:.0}s", + idx + 1, total, + (idx + 1) as f64 / total as f64 * 100.0, + speed, + remaining + )); } } - reporter.log("TidyVars completed".to_string()); + reporter.log(format!("TidyVars completed: {} files processed", total)); Ok(()) } diff --git a/varmanager_flutter/lib/features/bootstrap/bootstrap_controller.dart b/varmanager_flutter/lib/features/bootstrap/bootstrap_controller.dart index 7e6cee0..93b7a59 100644 --- a/varmanager_flutter/lib/features/bootstrap/bootstrap_controller.dart +++ b/varmanager_flutter/lib/features/bootstrap/bootstrap_controller.dart @@ -188,6 +188,10 @@ class BootstrapController extends Notifier { final proxy = config?.proxy ?? ProxyConfig.empty; final proxyMode = config?.proxyMode ?? ProxyMode.system; + // Normalize vam_exec: extract filename if full path provided + if (vamExec.trim().isNotEmpty) { + vamExec = p.basename(vamExec.trim()); + } if (vamExec.trim().isEmpty) { vamExec = _vamDesktopBat; } diff --git a/varmanager_flutter/lib/features/settings/settings_page.dart b/varmanager_flutter/lib/features/settings/settings_page.dart index ee6cb3d..3b9ecac 100644 --- a/varmanager_flutter/lib/features/settings/settings_page.dart +++ b/varmanager_flutter/lib/features/settings/settings_page.dart @@ -102,22 +102,44 @@ class _SettingsPageState extends ConsumerState { _downloaderSavePath.text = next; } } + final previous = _config; + if (previous == null) return; + final listenHost = _listenHost.text.trim(); + final listenPort = int.tryParse(_listenPort.text.trim()) ?? 57123; + final logLevel = _logLevel.text.trim(); + final jobConcurrency = int.tryParse(_jobConcurrency.text.trim()) ?? 10; + final varspath = _varspath.text.trim(); + final vampath = _vampath.text.trim(); + final vamExec = _vamExec.text.trim(); + final downloaderSavePath = _downloaderSavePath.text.trim(); + final proxyMode = _proxyMode; + final proxyHost = _proxyHost.text.trim(); + final proxyPort = int.tryParse(_proxyPort.text.trim()) ?? 0; + final proxyUsername = _proxyUsername.text.trim(); + final proxyPassword = _proxyPassword.text.trim(); + final needsRestartHint = previous.listenHost != listenHost || + previous.listenPort != listenPort || + previous.proxyMode != proxyMode || + previous.proxy.host != proxyHost || + previous.proxy.port != proxyPort || + (previous.proxy.username ?? '') != proxyUsername || + (previous.proxy.password ?? '') != proxyPassword; final client = ref.read(backendClientProvider); final update = { - 'listen_host': _listenHost.text.trim(), - 'listen_port': int.tryParse(_listenPort.text.trim()) ?? 57123, - 'log_level': _logLevel.text.trim(), - 'job_concurrency': int.tryParse(_jobConcurrency.text.trim()) ?? 10, - 'varspath': _varspath.text.trim(), - 'vampath': _vampath.text.trim(), - 'vam_exec': _vamExec.text.trim(), - 'downloader_save_path': _downloaderSavePath.text.trim(), - 'proxy_mode': _proxyMode.name, + 'listen_host': listenHost, + 'listen_port': listenPort, + 'log_level': logLevel, + 'job_concurrency': jobConcurrency, + 'varspath': varspath, + 'vampath': vampath, + 'vam_exec': vamExec, + 'downloader_save_path': downloaderSavePath, + 'proxy_mode': proxyMode.name, 'proxy': { - 'host': _proxyHost.text.trim(), - 'port': int.tryParse(_proxyPort.text.trim()) ?? 0, - 'username': _proxyUsername.text.trim(), - 'password': _proxyPassword.text.trim(), + 'host': proxyHost, + 'port': proxyPort, + 'username': proxyUsername, + 'password': proxyPassword, }, }; final cfg = await client.updateConfig(update); @@ -126,7 +148,11 @@ class _SettingsPageState extends ConsumerState { _config = cfg; }); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.configSavedRestartHint)), + SnackBar( + content: Text(needsRestartHint + ? context.l10n.configSavedRestartHint + : context.l10n.configSaved), + ), ); } diff --git a/varmanager_flutter/lib/l10n/app_en.arb b/varmanager_flutter/lib/l10n/app_en.arb index f746138..15eb44b 100644 --- a/varmanager_flutter/lib/l10n/app_en.arb +++ b/varmanager_flutter/lib/l10n/app_en.arb @@ -103,6 +103,7 @@ "chooseAddonPackagesHint": "Recommended: choose AddonPackages", "appVersionLabel": "App version", "backendVersionLabel": "Backend version", + "configSaved": "Config saved", "configSavedRestartHint": "Config saved; listen_host/port and proxy apply after restart.", "searchVarPackageLabel": "Search var/package", "creatorLabel": "Creator", diff --git a/varmanager_flutter/lib/l10n/app_localizations.dart b/varmanager_flutter/lib/l10n/app_localizations.dart index 3aa734a..4ae9026 100644 --- a/varmanager_flutter/lib/l10n/app_localizations.dart +++ b/varmanager_flutter/lib/l10n/app_localizations.dart @@ -716,6 +716,12 @@ abstract class AppLocalizations { /// **'Backend version'** String get backendVersionLabel; + /// No description provided for @configSaved. + /// + /// In en, this message translates to: + /// **'Config saved'** + String get configSaved; + /// No description provided for @configSavedRestartHint. /// /// In en, this message translates to: diff --git a/varmanager_flutter/lib/l10n/app_localizations_en.dart b/varmanager_flutter/lib/l10n/app_localizations_en.dart index 2bb7b4f..d1763e7 100644 --- a/varmanager_flutter/lib/l10n/app_localizations_en.dart +++ b/varmanager_flutter/lib/l10n/app_localizations_en.dart @@ -332,6 +332,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get backendVersionLabel => 'Backend version'; + @override + String get configSaved => 'Config saved'; + @override String get configSavedRestartHint => 'Config saved; listen_host/port and proxy apply after restart.'; diff --git a/varmanager_flutter/lib/l10n/app_localizations_zh.dart b/varmanager_flutter/lib/l10n/app_localizations_zh.dart index 1a85728..453e76f 100644 --- a/varmanager_flutter/lib/l10n/app_localizations_zh.dart +++ b/varmanager_flutter/lib/l10n/app_localizations_zh.dart @@ -329,6 +329,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get backendVersionLabel => '后端版本'; + @override + String get configSaved => '配置已保存'; + @override String get configSavedRestartHint => '配置已保存;listen_host/port 与代理需重启生效。'; diff --git a/varmanager_flutter/lib/l10n/app_zh.arb b/varmanager_flutter/lib/l10n/app_zh.arb index 51bfc2f..44f2c73 100644 --- a/varmanager_flutter/lib/l10n/app_zh.arb +++ b/varmanager_flutter/lib/l10n/app_zh.arb @@ -103,6 +103,7 @@ "chooseAddonPackagesHint": "推荐: 选择 AddonPackages 目录", "appVersionLabel": "应用版本", "backendVersionLabel": "后端版本", + "configSaved": "配置已保存", "configSavedRestartHint": "配置已保存;listen_host/port 与代理需重启生效。", "searchVarPackageLabel": "搜索 Var/包", "creatorLabel": "作者",