From e634c95119854d4a76e6ad94f9f812232d15b840 Mon Sep 17 00:00:00 2001 From: bustesoul <32890006+bustesoul@users.noreply.github.com> Date: Fri, 16 Jan 2026 23:23:08 +0800 Subject: [PATCH 1/6] refactor packswitch --- varManager_backend/src/api/mod.rs | 27 +- varManager_backend/src/infra/fs_util.rs | 21 +- varManager_backend/src/jobs/packswitch.rs | 411 ++++++++++++++++++---- 3 files changed, 388 insertions(+), 71 deletions(-) diff --git a/varManager_backend/src/api/mod.rs b/varManager_backend/src/api/mod.rs index 40945a7..cbff1bf 100644 --- a/varManager_backend/src/api/mod.rs +++ b/varManager_backend/src/api/mod.rs @@ -1892,12 +1892,27 @@ pub async fn list_packswitch( switches.sort_by_key(|a| a.to_ascii_lowercase()); let addon_path = crate::infra::paths::addon_packages_dir(&vampath); - let current = if let Ok(target) = crate::infra::winfs::read_link_target(&addon_path) { - target - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("default") - .to_string() + let link_root = addon_path.join(crate::infra::paths::INSTALL_LINK_DIR); + let current = if let Ok(target) = crate::infra::winfs::read_link_target(&link_root) { + let resolved = if target.is_absolute() { + target + } else { + link_root + .parent() + .unwrap_or(&addon_path) + .join(target) + }; + let switch_root = crate::infra::paths::addon_switch_root(&vampath); + if resolved.starts_with(&switch_root) { + resolved + .parent() + .and_then(|p| p.file_name()) + .and_then(|s| s.to_str()) + .unwrap_or("default") + .to_string() + } else { + "default".to_string() + } } else { "default".to_string() }; diff --git a/varManager_backend/src/infra/fs_util.rs b/varManager_backend/src/infra/fs_util.rs index 9dcba85..858824b 100644 --- a/varManager_backend/src/infra/fs_util.rs +++ b/varManager_backend/src/infra/fs_util.rs @@ -14,8 +14,12 @@ pub fn collect_symlink_vars(root: &Path, recursive: bool) -> Vec { if !root.exists() { return Vec::new(); } + let scan_root = resolve_symlink_root(root); + if !scan_root.exists() { + return Vec::new(); + } let mut files = Vec::new(); - let walker = WalkDir::new(root) + let walker = WalkDir::new(&scan_root) .follow_links(false) .max_depth(if recursive { usize::MAX } else { 1 }) .into_iter(); @@ -35,6 +39,21 @@ pub fn collect_symlink_vars(root: &Path, recursive: bool) -> Vec { files } +fn resolve_symlink_root(root: &Path) -> PathBuf { + if !is_symlink(root) { + return root.to_path_buf(); + } + if let Ok(target) = fs::read_link(root) { + if target.is_absolute() { + return target; + } + if let Some(parent) = root.parent() { + return parent.join(target); + } + } + root.to_path_buf() +} + pub fn collect_installed_links(vampath: &Path) -> HashMap { let mut installed = HashMap::new(); let install_dir = vampath.join("AddonPackages").join(INSTALL_LINK_DIR); diff --git a/varManager_backend/src/jobs/packswitch.rs b/varManager_backend/src/jobs/packswitch.rs index 0d9a933..bf82312 100644 --- a/varManager_backend/src/jobs/packswitch.rs +++ b/varManager_backend/src/jobs/packswitch.rs @@ -9,7 +9,7 @@ use crate::app::AppState; use crate::infra::{system_ops, winfs}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::collections::HashSet; +use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; use sqlx::SqlitePool; @@ -44,6 +44,9 @@ enum PackSwitchSetOutcome { UpdateDbRequired { addon_path: PathBuf }, } +const DEFAULT_SWITCH_NAME: &str = "default"; +const MANAGED_DIRS: [&str; 3] = [INSTALL_LINK_DIR, MISSING_LINK_DIR, TEMP_LINK_DIR]; + pub async fn run_packswitch_add_job( state: AppState, reporter: JobReporter, @@ -145,7 +148,7 @@ fn delete_switch_blocking(state: &AppState, reporter: &JobReporter, args: PackSw if name.is_empty() { return Err("switch name is required".to_string()); } - if name.eq_ignore_ascii_case("default") { + if name.eq_ignore_ascii_case(DEFAULT_SWITCH_NAME) { return Err("cannot delete default switch".to_string()); } let root = addon_switch_root(&vampath); @@ -174,7 +177,7 @@ fn rename_switch_blocking( if old_name.is_empty() || new_name.is_empty() { return Err("old_name and new_name are required".to_string()); } - if old_name.eq_ignore_ascii_case("default") { + if old_name.eq_ignore_ascii_case(DEFAULT_SWITCH_NAME) { return Err("cannot rename default switch".to_string()); } let root = addon_switch_root(&vampath); @@ -209,43 +212,30 @@ fn set_switch_blocking( fs::create_dir_all(&target).map_err(|err| err.to_string())?; let addon_path = addon_packages_dir(&vampath); - if addon_path.exists() { - let meta = fs::symlink_metadata(&addon_path).map_err(|err| err.to_string())?; - if !meta.file_type().is_symlink() { - reporter.log(format!( - "AddonPackages is not a symlink: {}", - addon_path.display() - )); - return Ok(PackSwitchSetOutcome::UpdateDbRequired { - addon_path, - }); - } - if let Ok(current_target) = winfs::read_link_target(&addon_path) { - let cur = current_target.to_string_lossy().to_ascii_lowercase(); - let want = target.to_string_lossy().to_ascii_lowercase(); - if cur == want { - return Ok(PackSwitchSetOutcome::Switched); - } - } + fs::create_dir_all(&addon_path).map_err(|err| err.to_string())?; - if addon_packages_has_real_vars(&addon_path) { - reporter.log(format!( - "AddonPackages has real var files; update DB required: {}", - addon_path.display() - )); - return Ok(PackSwitchSetOutcome::UpdateDbRequired { addon_path }); - } + let managed_dirs = collect_managed_dirs(); + if managed_dirs_have_real_vars(&addon_path, &managed_dirs) { + reporter.log(format!( + "Managed link folders contain real var files; update DB required: {}", + addon_path.display() + )); + return Ok(PackSwitchSetOutcome::UpdateDbRequired { addon_path }); + } - if meta.file_type().is_symlink() { - if fs::remove_file(&addon_path).is_err() { - fs::remove_dir_all(&addon_path).map_err(|err| err.to_string())?; - } - } else { - fs::remove_dir_all(&addon_path).map_err(|err| err.to_string())?; + let default_pack = switch_root.join(DEFAULT_SWITCH_NAME); + fs::create_dir_all(&default_pack).map_err(|err| err.to_string())?; + ensure_pack_dirs(&target, &managed_dirs)?; + + for dir_name in &managed_dirs { + let addon_dir = addon_path.join(dir_name); + if addon_dir.exists() && !fs_util::is_symlink(&addon_dir) { + move_controlled_dir(&addon_dir, &default_pack, dir_name, reporter)?; } + let pack_dir = target.join(dir_name); + ensure_addon_dir_link(&addon_dir, &pack_dir)?; } - winfs::create_symlink_dir(&addon_path, &target)?; let pool = &state.db_pool; let handle = tokio::runtime::Handle::current(); let _ = handle.block_on(refresh_install_status(pool, &vampath)); @@ -254,51 +244,131 @@ fn set_switch_blocking( Ok(PackSwitchSetOutcome::Switched) } -fn addon_packages_has_real_vars(addon_path: &Path) -> bool { - let scan_root = resolve_addon_scan_root(addon_path); - if !scan_root.exists() { - return false; +fn collect_managed_dirs() -> BTreeSet { + let mut dirs = BTreeSet::new(); + for name in MANAGED_DIRS { + dirs.insert(name.to_string()); } - let exclude: HashSet = [INSTALL_LINK_DIR, MISSING_LINK_DIR, TEMP_LINK_DIR] - .iter() - .map(|name| name.to_string()) - .collect(); - let walker = WalkDir::new(&scan_root).follow_links(false).into_iter(); - for entry in walker.filter_entry(|entry| { - if entry.file_type().is_dir() { - if let Some(name) = entry.file_name().to_str() { - return !exclude.contains(name); + dirs +} + +fn managed_dirs_have_real_vars(addon_path: &Path, managed_dirs: &BTreeSet) -> bool { + for dir_name in managed_dirs { + let path = addon_path.join(dir_name); + if !path.exists() { + continue; + } + let scan_root = resolve_link_dir_target(&path); + if contains_real_var_files(&scan_root) { + return true; + } + } + false +} + +fn resolve_link_dir_target(path: &Path) -> PathBuf { + if fs_util::is_symlink(path) { + if let Ok(target) = winfs::read_link_target(path) { + if target.is_absolute() { + return target; + } + if let Some(parent) = path.parent() { + return parent.join(target); } } - true - }) { + } + path.to_path_buf() +} + +fn contains_real_var_files(root: &Path) -> bool { + if !root.exists() { + return false; + } + let walker = WalkDir::new(root).follow_links(false).into_iter(); + for entry in walker { let entry = match entry { Ok(entry) => entry, Err(_) => continue, }; - if entry.file_type().is_file() { - if let Some(ext) = entry.path().extension() { - if ext.eq_ignore_ascii_case("var") { - return true; - } + if !entry.file_type().is_file() { + continue; + } + if let Some(ext) = entry.path().extension() { + if ext.eq_ignore_ascii_case("var") && !fs_util::is_symlink(entry.path()) { + return true; } } } false } -fn resolve_addon_scan_root(addon_path: &Path) -> PathBuf { - if fs_util::is_symlink(addon_path) { - if let Ok(target) = winfs::read_link_target(addon_path) { - if target.is_absolute() { - return target; - } - if let Some(parent) = addon_path.parent() { - return parent.join(target); +fn ensure_pack_dirs(pack_root: &Path, managed_dirs: &BTreeSet) -> Result<(), String> { + for dir_name in managed_dirs { + let dir = pack_root.join(dir_name); + if !dir.exists() { + fs::create_dir_all(&dir).map_err(|err| err.to_string())?; + } + } + Ok(()) +} + +fn move_controlled_dir( + src: &Path, + default_pack: &Path, + dir_name: &str, + reporter: &JobReporter, +) -> Result<(), String> { + if !src.is_dir() { + return Err(format!("controlled path is not a directory: {}", src.display())); + } + if !default_pack.exists() { + fs::create_dir_all(default_pack).map_err(|err| err.to_string())?; + } + let dest = default_pack.join(dir_name); + let dest = if dest.exists() { + unique_pack_dir(default_pack, dir_name) + } else { + dest + }; + fs::rename(src, &dest).map_err(|err| err.to_string())?; + reporter.log(format!( + "moved existing link folder to {}", + dest.display() + )); + Ok(()) +} + +fn unique_pack_dir(pack_root: &Path, dir_name: &str) -> PathBuf { + let mut index = 1; + loop { + let candidate = pack_root.join(format!("{dir_name}__from_addonpackages_{index}")); + if !candidate.exists() { + return candidate; + } + index += 1; + } +} + +fn ensure_addon_dir_link(addon_dir: &Path, pack_dir: &Path) -> Result<(), String> { + if addon_dir.exists() && fs_util::is_symlink(addon_dir) { + if let Ok(current_target) = winfs::read_link_target(addon_dir) { + let cur = current_target.to_string_lossy().to_ascii_lowercase(); + let want = pack_dir.to_string_lossy().to_ascii_lowercase(); + if cur == want { + return Ok(()); } } + if fs::remove_file(addon_dir).is_err() { + fs::remove_dir_all(addon_dir).map_err(|err| err.to_string())?; + } + } else if addon_dir.exists() { + return Err(format!( + "cannot link over non-symlink directory: {}", + addon_dir.display() + )); } - addon_path.to_path_buf() + winfs::create_symlink_dir(addon_dir, pack_dir)?; + Ok(()) } async fn refresh_install_status(pool: &SqlitePool, vampath: &Path) -> Result { @@ -319,3 +389,216 @@ async fn refresh_install_status(pool: &SqlitePool, vampath: &Path) -> Result PathBuf { + let base = std::env::temp_dir(); + let pid = std::process::id(); + for idx in 0..1000 { + let candidate = base.join(format!("{prefix}_{pid}_{idx}")); + if !candidate.exists() { + fs::create_dir_all(&candidate).unwrap(); + return candidate; + } + } + panic!("failed to create temp dir"); + } + + fn write_file(path: &Path) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, b"test").unwrap(); + } + + fn symlink_supported() -> bool { + let root = make_temp_dir("packswitch_symlink_probe"); + let target = root.join("target"); + let link = root.join("link"); + let ok = fs::create_dir_all(&target) + .map_err(|err| err.to_string()) + .and_then(|_| winfs::create_symlink_dir(&link, &target)) + .is_ok(); + let _ = fs::remove_file(&link).or_else(|_| fs::remove_dir_all(&link)); + let _ = fs::remove_dir_all(&root); + ok + } + + fn switch_pack_links(addon_path: &Path, switch_root: &Path, name: &str) -> Result<(), String> { + let target = switch_root.join(name); + fs::create_dir_all(&target).map_err(|err| err.to_string())?; + let managed = collect_managed_dirs(); + ensure_pack_dirs(&target, &managed)?; + for dir_name in &managed { + let addon_dir = addon_path.join(dir_name); + let pack_dir = target.join(dir_name); + ensure_addon_dir_link(&addon_dir, &pack_dir)?; + } + Ok(()) + } + + #[test] + fn managed_dirs_detect_real_var_in_root() { + let root = make_temp_dir("packswitch_root_var"); + let addon_path = root.join("AddonPackages"); + let install_dir = addon_path.join(INSTALL_LINK_DIR); + fs::create_dir_all(&install_dir).unwrap(); + write_file(&install_dir.join("sample.var")); + + let managed = collect_managed_dirs(); + assert!(managed_dirs_have_real_vars(&addon_path, &managed)); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn managed_dirs_detect_real_var_in_nested_dir() { + let root = make_temp_dir("packswitch_nested_var"); + let addon_path = root.join("AddonPackages"); + let install_dir = addon_path.join(INSTALL_LINK_DIR).join("nested"); + fs::create_dir_all(&install_dir).unwrap(); + write_file(&install_dir.join("deep.VAR")); + + let managed = collect_managed_dirs(); + assert!(managed_dirs_have_real_vars(&addon_path, &managed)); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn managed_dirs_ignore_vars_outside_managed_dirs() { + let root = make_temp_dir("packswitch_outside_var"); + let addon_path = root.join("AddonPackages"); + let other_dir = addon_path.join("UserStuff"); + fs::create_dir_all(&other_dir).unwrap(); + write_file(&other_dir.join("loose.var")); + + let managed = collect_managed_dirs(); + assert!(!managed_dirs_have_real_vars(&addon_path, &managed)); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn managed_dirs_ignore_non_var_files() { + let root = make_temp_dir("packswitch_non_var"); + let addon_path = root.join("AddonPackages"); + let install_dir = addon_path.join(INSTALL_LINK_DIR); + fs::create_dir_all(&install_dir).unwrap(); + write_file(&install_dir.join("note.txt")); + + let managed = collect_managed_dirs(); + assert!(!managed_dirs_have_real_vars(&addon_path, &managed)); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn move_controlled_dir_uses_unique_name_on_collision() { + let root = make_temp_dir("packswitch_move_collision"); + let default_pack = root.join("default"); + let existing = default_pack.join(INSTALL_LINK_DIR); + fs::create_dir_all(&existing).unwrap(); + write_file(&existing.join("keep.txt")); + + let src = root.join("AddonPackages").join(INSTALL_LINK_DIR); + fs::create_dir_all(&src).unwrap(); + write_file(&src.join("moved.txt")); + + let (tx, _rx) = create_job_channel(); + let reporter = JobReporter::new(1, tx); + move_controlled_dir(&src, &default_pack, INSTALL_LINK_DIR, &reporter).unwrap(); + + assert!(!src.exists()); + let moved = default_pack.join(format!("{INSTALL_LINK_DIR}__from_addonpackages_1")); + assert!(moved.exists()); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn ensure_addon_dir_link_rejects_real_directory() { + let root = make_temp_dir("packswitch_link_reject"); + let addon_dir = root.join("AddonPackages").join(INSTALL_LINK_DIR); + let pack_dir = root.join("switches").join("default").join(INSTALL_LINK_DIR); + fs::create_dir_all(&addon_dir).unwrap(); + fs::create_dir_all(&pack_dir).unwrap(); + + let result = ensure_addon_dir_link(&addon_dir, &pack_dir); + assert!(result.is_err()); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn switch_pack_creates_links_when_missing() { + if !symlink_supported() { + return; + } + let root = make_temp_dir("packswitch_basic_links"); + let addon_path = root.join("AddonPackages"); + let switch_root = root.join("___AddonPacksSwitch ___"); + fs::create_dir_all(&addon_path).unwrap(); + + switch_pack_links(&addon_path, &switch_root, "default").unwrap(); + for dir_name in collect_managed_dirs() { + let addon_dir = addon_path.join(&dir_name); + assert!(addon_dir.exists()); + assert!(fs_util::is_symlink(&addon_dir)); + } + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn switch_pack_updates_existing_links() { + if !symlink_supported() { + return; + } + let root = make_temp_dir("packswitch_update_links"); + let addon_path = root.join("AddonPackages"); + let switch_root = root.join("___AddonPacksSwitch ___"); + fs::create_dir_all(&addon_path).unwrap(); + + switch_pack_links(&addon_path, &switch_root, "default").unwrap(); + switch_pack_links(&addon_path, &switch_root, "alt").unwrap(); + + for dir_name in collect_managed_dirs() { + let addon_dir = addon_path.join(&dir_name); + let target = winfs::read_link_target(&addon_dir).unwrap(); + assert!(target.to_string_lossy().to_ascii_lowercase().contains("alt")); + } + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn switch_pack_noop_when_target_matches() { + if !symlink_supported() { + return; + } + let root = make_temp_dir("packswitch_noop_links"); + let addon_path = root.join("AddonPackages"); + let switch_root = root.join("___AddonPacksSwitch ___"); + fs::create_dir_all(&addon_path).unwrap(); + + switch_pack_links(&addon_path, &switch_root, "default").unwrap(); + let addon_dir = addon_path.join(INSTALL_LINK_DIR); + let before = winfs::read_link_target(&addon_dir).unwrap(); + switch_pack_links(&addon_path, &switch_root, "default").unwrap(); + let after = winfs::read_link_target(&addon_dir).unwrap(); + assert_eq!( + before.to_string_lossy().to_ascii_lowercase(), + after.to_string_lossy().to_ascii_lowercase() + ); + + let _ = fs::remove_dir_all(&root); + } +} From 87eba55aa3d3a6fb47c6f3ac3bf1d4343b779ded Mon Sep 17 00:00:00 2001 From: bustesoul <32890006+bustesoul@users.noreply.github.com> Date: Fri, 16 Jan 2026 23:32:18 +0800 Subject: [PATCH 2/6] improve config tips --- README.md | 2 +- README_CN.md | 2 +- .../bootstrap/bootstrap_controller.dart | 162 ++++++++++++++---- .../features/bootstrap/bootstrap_gate.dart | 139 ++++++++++++--- .../lib/features/home/home_page.dart | 1 + .../lib/features/settings/settings_page.dart | 122 +++++++++++-- varmanager_flutter/lib/l10n/app_en.arb | 12 +- .../lib/l10n/app_localizations.dart | 42 ++++- .../lib/l10n/app_localizations_en.dart | 27 ++- .../lib/l10n/app_localizations_zh.dart | 24 ++- varmanager_flutter/lib/l10n/app_zh.arb | 12 +- 11 files changed, 454 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index e438e64..997a321 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ The varManager backend has built-in support for downloading var packages directl **Configuration Tips:** - Proxy support: configure HTTP proxy in Settings (system auto-detect or manual) for Hub downloads -- Paths: `varspath` is your VaM `AddonPackages` directory; `vampath` is your main VaM install path +- Paths: `vampath` is your main VaM install path; `varspath` defaults to the same path (only set it separately if your .var files live on another folder/drive) **Known Issues:** - Windows first: macOS and Linux builds are not yet available in this release diff --git a/README_CN.md b/README_CN.md index fe46849..4448329 100644 --- a/README_CN.md +++ b/README_CN.md @@ -118,7 +118,7 @@ varManager 后端内置了从 VaM Hub 下载 var 包的支持: **配置贴士:** - **代理支持:** 在设置中配置 HTTP 代理(系统自动检测或手动设置),以加速 Hub 资源下载 -- **路径说明:** `varspath` 为 VaM `AddonPackages` 目录;`vampath` 为 VaM 主安装目录 +- **路径说明:** `vampath` 为 VaM 主安装目录;`varspath` 默认与 `vampath` 相同(仅当 var 文件与 VaM 分开放置时单独设置) **已知问题:** - **Windows 优先:** 本次发布暂不包含 macOS 和 Linux 版本 diff --git a/varmanager_flutter/lib/features/bootstrap/bootstrap_controller.dart b/varmanager_flutter/lib/features/bootstrap/bootstrap_controller.dart index 93b7a59..1fc3ecf 100644 --- a/varmanager_flutter/lib/features/bootstrap/bootstrap_controller.dart +++ b/varmanager_flutter/lib/features/bootstrap/bootstrap_controller.dart @@ -102,24 +102,46 @@ class BootstrapController extends Notifier { Future runChecks( String backendLabel, String varspathLabel, + String vampathLabel, String downloaderLabel, String fileOpsLabel, String symlinkLabel, String vamExecLabel, { required String varspathHint, + required String vampathHint, required String downloaderHint, required String fileOpsHint, required String symlinkHint, required String vamExecHint, + required String varspathName, + required String vampathName, }) async { state = state.copyWith(checksRunning: true, checksRan: true, errorMessage: null); + final config = state.config; + final varspath = config.varspath.trim(); + final vampath = config.vampath.trim(); + final samePaths = _pathsMatch(varspath, vampath); + final varFileOpsLabel = _withPathSuffix(fileOpsLabel, varspathName); + final vampathFileOpsLabel = _withPathSuffix(fileOpsLabel, vampathName); + final varSymlinkLabel = _withPathSuffix(symlinkLabel, varspathName); + final vampathSymlinkLabel = _withPathSuffix(symlinkLabel, vampathName); + final checks = []; checks.add(_pending('backend', backendLabel)); checks.add(_pending('varspath', varspathLabel)); + if (!samePaths) { + checks.add(_pending('vampath', vampathLabel)); + } checks.add(_pending('downloader', downloaderLabel)); - checks.add(_pending('fileops', fileOpsLabel)); - checks.add(_pending('symlink', symlinkLabel)); + checks.add(_pending('fileops_varspath', varFileOpsLabel)); + if (!samePaths) { + checks.add(_pending('fileops_vampath', vampathFileOpsLabel)); + } + checks.add(_pending('symlink_varspath', varSymlinkLabel)); + if (!samePaths) { + checks.add(_pending('symlink_vampath', vampathSymlinkLabel)); + } checks.add(_pending('vamexec', vamExecLabel)); state = state.copyWith(checks: checks); @@ -128,21 +150,69 @@ class BootstrapController extends Notifier { final backendCheck = await _checkBackend(client, backendLabel); _setCheck(backendCheck); - final config = state.config; - final varspathCheck = await _checkVarspath(config, varspathLabel, varspathHint); + final varspathCheck = await _checkPathExists( + 'varspath', + varspath, + varspathLabel, + varspathHint, + emptyMessage: '$varspathName not set', + ); _setCheck(varspathCheck); + if (!samePaths) { + final vampathCheck = await _checkPathExists( + 'vampath', + vampath, + vampathLabel, + vampathHint, + emptyMessage: '$vampathName not set', + ); + _setCheck(vampathCheck); + } + final downloaderCheck = await _checkDownloaderPath(config, downloaderLabel, downloaderHint); _setCheck(downloaderCheck); - final fileOpsCheck = - await _checkFileOps(config, fileOpsLabel, fileOpsHint); - _setCheck(fileOpsCheck); + final fileOpsVarCheck = await _checkFileOps( + 'fileops_varspath', + varspath, + varFileOpsLabel, + fileOpsHint, + emptyMessage: '$varspathName not set', + ); + _setCheck(fileOpsVarCheck); + + if (!samePaths) { + final fileOpsVamCheck = await _checkFileOps( + 'fileops_vampath', + vampath, + vampathFileOpsLabel, + fileOpsHint, + emptyMessage: '$vampathName not set', + ); + _setCheck(fileOpsVamCheck); + } - final symlinkCheck = - await _checkSymlink(config, symlinkLabel, symlinkHint); - _setCheck(symlinkCheck); + final symlinkVarCheck = await _checkSymlink( + 'symlink_varspath', + varspath, + varSymlinkLabel, + symlinkHint, + emptyMessage: '$varspathName not set', + ); + _setCheck(symlinkVarCheck); + + if (!samePaths) { + final symlinkVamCheck = await _checkSymlink( + 'symlink_vampath', + vampath, + vampathSymlinkLabel, + symlinkHint, + emptyMessage: '$vampathName not set', + ); + _setCheck(symlinkVamCheck); + } final vamExecCheck = await _checkVamExec(config, vamExecLabel, vamExecHint); @@ -181,8 +251,11 @@ class BootstrapController extends Notifier { } BootstrapConfig _resolveBootstrapConfig(AppConfig? config) { - final varspath = config?.varspath ?? ''; + var varspath = config?.varspath ?? ''; final vampath = config?.vampath ?? ''; + if (varspath.trim().isEmpty && vampath.trim().isNotEmpty) { + varspath = vampath; + } final downloader = config?.downloaderSavePath ?? ''; var vamExec = config?.vamExec ?? ''; final proxy = config?.proxy ?? ProxyConfig.empty; @@ -262,6 +335,20 @@ class BootstrapController extends Notifier { } } + String _normalizePath(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) return ''; + return p.normalize(trimmed).toLowerCase(); + } + + bool _pathsMatch(String left, String right) { + return _normalizePath(left) == _normalizePath(right); + } + + String _withPathSuffix(String label, String pathName) { + return '$label ($pathName)'; + } + Future _checkBackend( BackendClient client, String label, @@ -286,25 +373,26 @@ class BootstrapController extends Notifier { } } - Future _checkVarspath( - BootstrapConfig config, + Future _checkPathExists( + String id, + String path, String label, - String hint, - ) async { - final path = config.varspath.trim(); + String hint, { + required String emptyMessage, + }) async { if (path.isEmpty) { return BootstrapCheckItem( - id: 'varspath', + id: id, label: label, status: BootstrapCheckStatus.fail, - message: 'varspath not set', + message: emptyMessage, hints: [hint], ); } final dir = Directory(path); if (!await dir.exists()) { return BootstrapCheckItem( - id: 'varspath', + id: id, label: label, status: BootstrapCheckStatus.fail, message: 'Directory not found', @@ -312,7 +400,7 @@ class BootstrapController extends Notifier { ); } return BootstrapCheckItem( - id: 'varspath', + id: id, label: label, status: BootstrapCheckStatus.pass, message: 'OK', @@ -362,17 +450,18 @@ class BootstrapController extends Notifier { } Future _checkFileOps( - BootstrapConfig config, + String id, + String path, String label, - String hint, - ) async { - final path = config.varspath.trim(); + String hint, { + required String emptyMessage, + }) async { if (path.isEmpty) { return BootstrapCheckItem( - id: 'fileops', + id: id, label: label, status: BootstrapCheckStatus.fail, - message: 'varspath not set', + message: emptyMessage, hints: [hint], ); } @@ -398,7 +487,7 @@ class BootstrapController extends Notifier { await fileA.delete().onError((_, _) => fileA); await fileD.delete().onError((_, _) => fileD); return BootstrapCheckItem( - id: 'fileops', + id: id, label: label, status: BootstrapCheckStatus.pass, message: 'OK', @@ -406,7 +495,7 @@ class BootstrapController extends Notifier { ); } catch (err) { return BootstrapCheckItem( - id: 'fileops', + id: id, label: label, status: BootstrapCheckStatus.fail, message: err.toString(), @@ -418,17 +507,18 @@ class BootstrapController extends Notifier { } Future _checkSymlink( - BootstrapConfig config, + String id, + String path, String label, - String hint, - ) async { - final path = config.varspath.trim(); + String hint, { + required String emptyMessage, + }) async { if (path.isEmpty) { return BootstrapCheckItem( - id: 'symlink', + id: id, label: label, status: BootstrapCheckStatus.fail, - message: 'varspath not set', + message: emptyMessage, hints: [hint], ); } @@ -457,7 +547,7 @@ class BootstrapController extends Notifier { await moved.delete().onError((_, _) => moved); await target.delete().onError((_, _) => target); return BootstrapCheckItem( - id: 'symlink', + id: id, label: label, status: BootstrapCheckStatus.pass, message: 'OK', @@ -465,7 +555,7 @@ class BootstrapController extends Notifier { ); } catch (err) { return BootstrapCheckItem( - id: 'symlink', + id: id, label: label, status: BootstrapCheckStatus.fail, message: err.toString(), diff --git a/varmanager_flutter/lib/features/bootstrap/bootstrap_gate.dart b/varmanager_flutter/lib/features/bootstrap/bootstrap_gate.dart index b7aab26..3162883 100644 --- a/varmanager_flutter/lib/features/bootstrap/bootstrap_gate.dart +++ b/varmanager_flutter/lib/features/bootstrap/bootstrap_gate.dart @@ -680,6 +680,7 @@ class _ConfigStepState extends ConsumerState<_ConfigStep> { final _proxyPassword = TextEditingController(); String _proxyMode = 'system'; bool _loaded = false; + bool _separateVarspath = false; @override void dispose() { @@ -697,8 +698,14 @@ class _ConfigStepState extends ConsumerState<_ConfigStep> { void _loadConfigIfNeeded(BootstrapConfig config) { if (_loaded) return; _loaded = true; - _varspath.text = config.varspath; - _vampath.text = config.vampath; + final vampath = config.vampath; + final varspath = config.varspath; + final samePaths = _pathsMatch(vampath, varspath); + final hasVampath = vampath.trim().isNotEmpty; + final hasVarspath = varspath.trim().isNotEmpty; + _separateVarspath = hasVampath && hasVarspath && !samePaths; + _varspath.text = (!_separateVarspath && hasVampath) ? vampath : varspath; + _vampath.text = vampath; _vamExec.text = config.vamExec; _downloaderSavePath.text = config.downloaderSavePath; _proxyMode = config.proxyMode; @@ -708,7 +715,7 @@ class _ConfigStepState extends ConsumerState<_ConfigStep> { _proxyPassword.text = config.proxyPassword; } - void _applyVarspathDefaults(String path) { + void _applyPathDefaults(String path) { if (path.trim().isEmpty) return; if (_downloaderSavePath.text.trim().isEmpty) { _downloaderSavePath.text = p.join(path.trim(), 'AddonPackages'); @@ -719,11 +726,26 @@ class _ConfigStepState extends ConsumerState<_ConfigStep> { } Future _pickVarspath() async { + if (!_separateVarspath) return; final path = await getDirectoryPath(); if (path == null) return; setState(() { _varspath.text = path; - _applyVarspathDefaults(path); + _applyPathDefaults(path); + }); + } + + Future _pickVampath() async { + final path = await getDirectoryPath(); + if (path == null) return; + setState(() { + _vampath.text = path; + if (!_separateVarspath) { + _varspath.text = path; + } + if (!_separateVarspath) { + _applyPathDefaults(path); + } }); } @@ -744,9 +766,11 @@ class _ConfigStepState extends ConsumerState<_ConfigStep> { } BootstrapConfig _currentConfig() { + final vampath = _vampath.text.trim(); + final varspath = _separateVarspath ? _varspath.text.trim() : vampath; return BootstrapConfig( - varspath: _varspath.text.trim(), - vampath: _vampath.text.trim(), + varspath: varspath, + vampath: vampath, vamExec: _vamExec.text.trim(), downloaderSavePath: _downloaderSavePath.text.trim(), proxyMode: _proxyMode.trim(), @@ -757,6 +781,22 @@ class _ConfigStepState extends ConsumerState<_ConfigStep> { ); } + String _normalizePath(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) return ''; + return p.normalize(trimmed).toLowerCase(); + } + + bool _pathsMatch(String left, String right) { + return _normalizePath(left) == _normalizePath(right); + } + + void _syncVarspathToVampath(String value) { + if (_separateVarspath) return; + _varspath.text = value; + _applyPathDefaults(value); + } + @override Widget build(BuildContext context) { final state = ref.watch(bootstrapProvider); @@ -794,23 +834,72 @@ class _ConfigStepState extends ConsumerState<_ConfigStep> { Text(l10n.bootstrapConfigBody), const SizedBox(height: 12), _pathField( - controller: _varspath, - label: l10n.varspathLabel, + controller: _vampath, + label: l10n.vampathLabel, hint: l10n.chooseVamHint, - onBrowse: _pickVarspath, + onBrowse: _pickVampath, + onChanged: _syncVarspathToVampath, validator: (value) { if (value == null || value.trim().isEmpty) { - return l10n.bootstrapConfigVarspathRequired; + return l10n.bootstrapConfigVampathRequired; } return null; }, - onChanged: (value) => _applyVarspathDefaults(value), + ), + CheckboxListTile( + value: _separateVarspath, + title: Text(l10n.varspathSeparateLabel), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + onChanged: (value) { + if (value == null) return; + setState(() { + _separateVarspath = value; + if (!value) { + _varspath.text = _vampath.text.trim(); + } + }); + }, + ), + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.warning_amber_rounded, + size: 18, + color: Colors.orange.shade700, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + l10n.varspathSeparateTip, + style: TextStyle( + fontSize: 12, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ), + ], + ), ), _pathField( - controller: _vampath, - label: l10n.vampathLabel, - hint: l10n.chooseVamHint, - onBrowse: () => _pickDirectory(_vampath), + controller: _varspath, + label: l10n.varspathLabel, + hint: l10n.varspathSameAsVampathHint, + onBrowse: _separateVarspath ? _pickVarspath : null, + enabled: _separateVarspath, + validator: (value) { + if (_separateVarspath && + (value == null || value.trim().isEmpty)) { + return l10n.bootstrapConfigVarspathRequired; + } + return null; + }, + onChanged: (value) => _applyPathDefaults(value), ), _pathField( controller: _vamExec, @@ -891,9 +980,10 @@ class _ConfigStepState extends ConsumerState<_ConfigStep> { required TextEditingController controller, required String label, required String hint, - required VoidCallback onBrowse, + VoidCallback? onBrowse, String? Function(String?)? validator, ValueChanged? onChanged, + bool enabled = true, }) { final l10n = context.l10n; return Padding( @@ -905,6 +995,7 @@ class _ConfigStepState extends ConsumerState<_ConfigStep> { controller: controller, onChanged: onChanged, validator: validator, + enabled: enabled, decoration: InputDecoration( labelText: label, hintText: hint, @@ -913,11 +1004,13 @@ class _ConfigStepState extends ConsumerState<_ConfigStep> { ), ), ), - const SizedBox(width: 8), - OutlinedButton( - onPressed: onBrowse, - child: Text(l10n.commonBrowse), - ), + if (onBrowse != null) ...[ + const SizedBox(width: 8), + OutlinedButton( + onPressed: enabled ? onBrowse : null, + child: Text(l10n.commonBrowse), + ), + ], ], ), ); @@ -1029,15 +1122,19 @@ class _ChecksStep extends ConsumerWidget { : () => ref.read(bootstrapProvider.notifier).runChecks( l10n.bootstrapCheckBackendLabel, l10n.bootstrapCheckVarspathLabel, + l10n.bootstrapCheckVampathLabel, l10n.bootstrapCheckDownloaderLabel, l10n.bootstrapCheckFileOpsLabel, l10n.bootstrapCheckSymlinkLabel, l10n.bootstrapCheckVamExecLabel, varspathHint: l10n.bootstrapCheckVarspathHint, + vampathHint: l10n.bootstrapCheckVampathHint, downloaderHint: l10n.bootstrapCheckDownloaderHint, fileOpsHint: l10n.bootstrapCheckFileOpsHint, symlinkHint: l10n.bootstrapCheckSymlinkHint, vamExecHint: l10n.bootstrapCheckVamExecHint, + varspathName: l10n.varspathLabel, + vampathName: l10n.vampathLabel, ), icon: state.checksRunning ? const SizedBox( diff --git a/varmanager_flutter/lib/features/home/home_page.dart b/varmanager_flutter/lib/features/home/home_page.dart index 0d2fdae..ac26813 100644 --- a/varmanager_flutter/lib/features/home/home_page.dart +++ b/varmanager_flutter/lib/features/home/home_page.dart @@ -1577,6 +1577,7 @@ class _HomePageState extends ConsumerState { return; } await _loadPackSwitches(); + ref.invalidate(varsListProvider); } void _syncController(TextEditingController controller, String value) { diff --git a/varmanager_flutter/lib/features/settings/settings_page.dart b/varmanager_flutter/lib/features/settings/settings_page.dart index 3b9ecac..979f9f4 100644 --- a/varmanager_flutter/lib/features/settings/settings_page.dart +++ b/varmanager_flutter/lib/features/settings/settings_page.dart @@ -34,6 +34,7 @@ class _SettingsPageState extends ConsumerState { final _proxyUsername = TextEditingController(); final _proxyPassword = TextEditingController(); ProxyMode _proxyMode = ProxyMode.system; + bool _separateVarspath = false; AppConfig? _config; String? _backendVersion; @@ -57,6 +58,13 @@ class _SettingsPageState extends ConsumerState { backendVersion = null; } if (!mounted) return; + final vampath = cfg.vampath ?? ''; + final varspath = cfg.varspath ?? ''; + final samePaths = _pathsMatch(vampath, varspath); + final hasVampath = vampath.trim().isNotEmpty; + final hasVarspath = varspath.trim().isNotEmpty; + final separate = hasVampath && hasVarspath && !samePaths; + final effectiveVarspath = (!separate && hasVampath) ? vampath : varspath; setState(() { _config = cfg; _backendVersion = backendVersion; @@ -65,8 +73,8 @@ class _SettingsPageState extends ConsumerState { _listenPort.text = cfg.listenPort.toString(); _logLevel.text = cfg.logLevel; _jobConcurrency.text = cfg.jobConcurrency.toString(); - _varspath.text = cfg.varspath ?? ''; - _vampath.text = cfg.vampath ?? ''; + _varspath.text = effectiveVarspath; + _vampath.text = vampath; _vamExec.text = cfg.vamExec ?? ''; _downloaderSavePath.text = cfg.downloaderSavePath ?? ''; _proxyHost.text = cfg.proxy.host; @@ -74,6 +82,7 @@ class _SettingsPageState extends ConsumerState { _proxyUsername.text = cfg.proxy.username ?? ''; _proxyPassword.text = cfg.proxy.password ?? ''; _proxyMode = cfg.proxyMode; + _separateVarspath = separate; }); } @@ -96,20 +105,23 @@ class _SettingsPageState extends ConsumerState { Future _save() async { if (!_formKey.currentState!.validate()) return; - if (_downloaderSavePath.text.trim().isEmpty) { - final next = _addonPackagesPath(_varspath.text); - if (next.isNotEmpty) { - _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 varspath = _separateVarspath ? _varspath.text.trim() : vampath; + if (!_separateVarspath && _varspath.text.trim() != varspath) { + _varspath.text = varspath; + } + if (_downloaderSavePath.text.trim().isEmpty) { + final next = _addonPackagesPath(varspath); + if (next.isNotEmpty) { + _downloaderSavePath.text = next; + } + } final vamExec = _vamExec.text.trim(); final downloaderSavePath = _downloaderSavePath.text.trim(); final proxyMode = _proxyMode; @@ -164,7 +176,22 @@ class _SettingsPageState extends ConsumerState { }); } + Future _pickVampathDirectory() async { + final path = await getDirectoryPath(); + if (path == null) return; + setState(() { + _vampath.text = path; + if (!_separateVarspath) { + _varspath.text = path; + } + if (_downloaderSavePath.text.trim().isEmpty && !_separateVarspath) { + _downloaderSavePath.text = _addonPackagesPath(path); + } + }); + } + Future _pickVarspathDirectory() async { + if (!_separateVarspath) return; final path = await getDirectoryPath(); if (path == null) return; setState(() { @@ -189,6 +216,21 @@ class _SettingsPageState extends ConsumerState { return p.join(trimmed, 'AddonPackages'); } + String _normalizePath(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) return ''; + return p.normalize(trimmed).toLowerCase(); + } + + bool _pathsMatch(String left, String right) { + return _normalizePath(left) == _normalizePath(right); + } + + void _syncVarspathToVampath(String value) { + if (_separateVarspath) return; + _varspath.text = value; + } + @override Widget build(BuildContext context) { if (_config == null) { @@ -273,17 +315,59 @@ class _SettingsPageState extends ConsumerState { title: l10n.settingsSectionPaths, child: Column( children: [ - _pathField( - _varspath, - l10n.varspathLabel, - hintText: l10n.chooseVamHint, - onBrowse: _pickVarspathDirectory, - ), _pathField( _vampath, l10n.vampathLabel, hintText: l10n.chooseVamHint, - onBrowse: () => _pickDirectory(_vampath), + onChanged: _syncVarspathToVampath, + onBrowse: _pickVampathDirectory, + ), + CheckboxListTile( + value: _separateVarspath, + title: Text(l10n.varspathSeparateLabel), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + onChanged: (value) { + if (value == null) return; + setState(() { + _separateVarspath = value; + if (!value) { + _varspath.text = _vampath.text.trim(); + } + }); + }, + ), + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.warning_amber_rounded, + size: 18, + color: Colors.orange.shade700, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + l10n.varspathSeparateTip, + style: TextStyle( + fontSize: 12, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ), + ], + ), + ), + _pathField( + _varspath, + l10n.varspathLabel, + hintText: l10n.varspathSameAsVampathHint, + enabled: _separateVarspath, + onBrowse: _separateVarspath ? _pickVarspathDirectory : null, ), _pathField( _vamExec, @@ -369,6 +453,8 @@ class _SettingsPageState extends ConsumerState { TextInputType keyboard = TextInputType.text, VoidCallback? onBrowse, Key? anchorKey, + ValueChanged? onChanged, + bool enabled = true, }) { final l10n = context.l10n; return Padding( @@ -380,6 +466,8 @@ class _SettingsPageState extends ConsumerState { child: TextFormField( controller: controller, keyboardType: keyboard, + onChanged: onChanged, + enabled: enabled, decoration: InputDecoration( labelText: label, hintText: hintText, @@ -393,7 +481,7 @@ class _SettingsPageState extends ConsumerState { if (onBrowse != null) ...[ const SizedBox(width: 8), OutlinedButton( - onPressed: onBrowse, + onPressed: enabled ? onBrowse : null, child: Text(l10n.commonBrowse), ), ], diff --git a/varmanager_flutter/lib/l10n/app_en.arb b/varmanager_flutter/lib/l10n/app_en.arb index e2eac51..bf1de57 100644 --- a/varmanager_flutter/lib/l10n/app_en.arb +++ b/varmanager_flutter/lib/l10n/app_en.arb @@ -97,6 +97,9 @@ "proxyPasswordLabel": "Proxy password", "varspathLabel": "varspath", "vampathLabel": "vampath", + "varspathSameAsVampathHint": "Default: same as vampath", + "varspathSeparateLabel": "I need to set varspath separately", + "varspathSeparateTip": "Use a separate varspath only when .var files are stored in a different folder or drive from VaM.", "vamExecLabel": "vam_exec", "downloaderSavePathLabel": "Downloader save path", "chooseVamHint": "Recommended: choose virt_a_mate directory", @@ -150,7 +153,7 @@ "updateDbLabel": "Update DB", "updateDbTooltip": "Scan vars, extract previews, and update the database.", "updateDbRequiredTitle": "Update DB required", - "updateDbRequiredMessage": "AddonPackages at {path} contains real .var files or is not a symlink. Run Update DB before switching packs.", + "updateDbRequiredMessage": "Managed link folders under AddonPackages at {path} contain real .var files. Run Update DB or move them out before switching packs.", "updateDbSummaryTitle": "Update DB completed", "updateDbSummaryScanned": "Scanned {count} packages.", "updateDbSummaryEmpty": "No packages moved.", @@ -784,7 +787,8 @@ "bootstrapFeaturePacks": "Switch pack profiles fast from the Home sidebar.", "bootstrapConfigTitle": "Basic configuration", "bootstrapConfigBody": "Fill in key paths so varManager can index packages and launch VaM.", - "bootstrapConfigVarspathRequired": "varspath is required.", + "bootstrapConfigVarspathRequired": "varspath is required when separate is enabled.", + "bootstrapConfigVampathRequired": "vampath is required.", "bootstrapConfigVamExecHint": "Recommended: VaM (Desktop Mode).bat", "bootstrapChecksTitle": "Self-check", "bootstrapChecksBody": "We will test write access, file operations, and symlink support.", @@ -793,11 +797,13 @@ "bootstrapChecksSkipBody": "You can continue without checks, but some features may fail later.", "bootstrapCheckBackendLabel": "Backend health", "bootstrapCheckVarspathLabel": "varspath availability", + "bootstrapCheckVampathLabel": "vampath availability", "bootstrapCheckDownloaderLabel": "Download path write access", "bootstrapCheckFileOpsLabel": "File copy/move/rename", "bootstrapCheckSymlinkLabel": "Symlink create/read/move", "bootstrapCheckVamExecLabel": "VaM executable path", - "bootstrapCheckVarspathHint": "Set varspath in configuration.", + "bootstrapCheckVarspathHint": "Set varspath in configuration (defaults to vampath).", + "bootstrapCheckVampathHint": "Set vampath in configuration.", "bootstrapCheckDownloaderHint": "Choose a writable download path.", "bootstrapCheckFileOpsHint": "Possible reasons: read-only folder, missing permissions, or locked files.", "bootstrapCheckSymlinkHint": "Possible reasons: admin/dev mode required, unsupported filesystem, or read-only drive.", diff --git a/varmanager_flutter/lib/l10n/app_localizations.dart b/varmanager_flutter/lib/l10n/app_localizations.dart index e9337fd..45d0d92 100644 --- a/varmanager_flutter/lib/l10n/app_localizations.dart +++ b/varmanager_flutter/lib/l10n/app_localizations.dart @@ -680,6 +680,24 @@ abstract class AppLocalizations { /// **'vampath'** String get vampathLabel; + /// No description provided for @varspathSameAsVampathHint. + /// + /// In en, this message translates to: + /// **'Default: same as vampath'** + String get varspathSameAsVampathHint; + + /// No description provided for @varspathSeparateLabel. + /// + /// In en, this message translates to: + /// **'I need to set varspath separately'** + String get varspathSeparateLabel; + + /// No description provided for @varspathSeparateTip. + /// + /// In en, this message translates to: + /// **'Use a separate varspath only when .var files are stored in a different folder or drive from VaM.'** + String get varspathSeparateTip; + /// No description provided for @vamExecLabel. /// /// In en, this message translates to: @@ -1001,7 +1019,7 @@ abstract class AppLocalizations { /// No description provided for @updateDbRequiredMessage. /// /// In en, this message translates to: - /// **'AddonPackages at {path} contains real .var files or is not a symlink. Run Update DB before switching packs.'** + /// **'Managed link folders under AddonPackages at {path} contain real .var files. Run Update DB or move them out before switching packs.'** String updateDbRequiredMessage(Object path); /// No description provided for @updateDbSummaryTitle. @@ -3009,9 +3027,15 @@ abstract class AppLocalizations { /// No description provided for @bootstrapConfigVarspathRequired. /// /// In en, this message translates to: - /// **'varspath is required.'** + /// **'varspath is required when separate is enabled.'** String get bootstrapConfigVarspathRequired; + /// No description provided for @bootstrapConfigVampathRequired. + /// + /// In en, this message translates to: + /// **'vampath is required.'** + String get bootstrapConfigVampathRequired; + /// No description provided for @bootstrapConfigVamExecHint. /// /// In en, this message translates to: @@ -3060,6 +3084,12 @@ abstract class AppLocalizations { /// **'varspath availability'** String get bootstrapCheckVarspathLabel; + /// No description provided for @bootstrapCheckVampathLabel. + /// + /// In en, this message translates to: + /// **'vampath availability'** + String get bootstrapCheckVampathLabel; + /// No description provided for @bootstrapCheckDownloaderLabel. /// /// In en, this message translates to: @@ -3087,9 +3117,15 @@ abstract class AppLocalizations { /// No description provided for @bootstrapCheckVarspathHint. /// /// In en, this message translates to: - /// **'Set varspath in configuration.'** + /// **'Set varspath in configuration (defaults to vampath).'** String get bootstrapCheckVarspathHint; + /// No description provided for @bootstrapCheckVampathHint. + /// + /// In en, this message translates to: + /// **'Set vampath in configuration.'** + String get bootstrapCheckVampathHint; + /// No description provided for @bootstrapCheckDownloaderHint. /// /// 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 45a7b5c..38df176 100644 --- a/varmanager_flutter/lib/l10n/app_localizations_en.dart +++ b/varmanager_flutter/lib/l10n/app_localizations_en.dart @@ -314,6 +314,16 @@ class AppLocalizationsEn extends AppLocalizations { @override String get vampathLabel => 'vampath'; + @override + String get varspathSameAsVampathHint => 'Default: same as vampath'; + + @override + String get varspathSeparateLabel => 'I need to set varspath separately'; + + @override + String get varspathSeparateTip => + 'Use a separate varspath only when .var files are stored in a different folder or drive from VaM.'; + @override String get vamExecLabel => 'vam_exec'; @@ -481,7 +491,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String updateDbRequiredMessage(Object path) { - return 'AddonPackages at $path contains real .var files or is not a symlink. Run Update DB before switching packs.'; + return 'Managed link folders under AddonPackages at $path contain real .var files. Run Update DB or move them out before switching packs.'; } @override @@ -1623,7 +1633,11 @@ class AppLocalizationsEn extends AppLocalizations { 'Fill in key paths so varManager can index packages and launch VaM.'; @override - String get bootstrapConfigVarspathRequired => 'varspath is required.'; + String get bootstrapConfigVarspathRequired => + 'varspath is required when separate is enabled.'; + + @override + String get bootstrapConfigVampathRequired => 'vampath is required.'; @override String get bootstrapConfigVamExecHint => @@ -1652,6 +1666,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get bootstrapCheckVarspathLabel => 'varspath availability'; + @override + String get bootstrapCheckVampathLabel => 'vampath availability'; + @override String get bootstrapCheckDownloaderLabel => 'Download path write access'; @@ -1665,7 +1682,11 @@ class AppLocalizationsEn extends AppLocalizations { String get bootstrapCheckVamExecLabel => 'VaM executable path'; @override - String get bootstrapCheckVarspathHint => 'Set varspath in configuration.'; + String get bootstrapCheckVarspathHint => + 'Set varspath in configuration (defaults to vampath).'; + + @override + String get bootstrapCheckVampathHint => 'Set vampath in configuration.'; @override String get bootstrapCheckDownloaderHint => 'Choose a writable download path.'; diff --git a/varmanager_flutter/lib/l10n/app_localizations_zh.dart b/varmanager_flutter/lib/l10n/app_localizations_zh.dart index 6ac9a4b..2a66ec2 100644 --- a/varmanager_flutter/lib/l10n/app_localizations_zh.dart +++ b/varmanager_flutter/lib/l10n/app_localizations_zh.dart @@ -311,6 +311,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get vampathLabel => 'vampath'; + @override + String get varspathSameAsVampathHint => '默认与 vampath 相同'; + + @override + String get varspathSeparateLabel => '我确定需要单独设置 varspath'; + + @override + String get varspathSeparateTip => '仅在 var 文件与 VaM 放在不同文件夹或磁盘时才需要单独配置。'; + @override String get vamExecLabel => 'vam_exec'; @@ -476,7 +485,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String updateDbRequiredMessage(Object path) { - return '检测到 AddonPackages 包含实际 .var 文件或不是符号链接($path)。请先执行更新数据库。'; + return '检测到 AddonPackages 受管链接目录中包含实际 .var 文件($path)。请先执行更新数据库或移出这些文件。'; } @override @@ -1588,7 +1597,10 @@ class AppLocalizationsZh extends AppLocalizations { String get bootstrapConfigBody => '填写关键路径,确保能够索引包并启动 VaM。'; @override - String get bootstrapConfigVarspathRequired => '必须填写 varspath。'; + String get bootstrapConfigVarspathRequired => '单独设置时必须填写 varspath。'; + + @override + String get bootstrapConfigVampathRequired => '必须填写 vampath。'; @override String get bootstrapConfigVamExecHint => '推荐:VaM (Desktop Mode).bat'; @@ -1614,6 +1626,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get bootstrapCheckVarspathLabel => 'varspath 可用性'; + @override + String get bootstrapCheckVampathLabel => 'vampath 可用性'; + @override String get bootstrapCheckDownloaderLabel => '下载目录写入权限'; @@ -1627,7 +1642,10 @@ class AppLocalizationsZh extends AppLocalizations { String get bootstrapCheckVamExecLabel => 'VaM 启动脚本路径'; @override - String get bootstrapCheckVarspathHint => '请先在配置中设置 varspath。'; + String get bootstrapCheckVarspathHint => '请先在配置中设置 varspath(默认同 vampath)。'; + + @override + String get bootstrapCheckVampathHint => '请先在配置中设置 vampath。'; @override String get bootstrapCheckDownloaderHint => '请选择可写的下载目录。'; diff --git a/varmanager_flutter/lib/l10n/app_zh.arb b/varmanager_flutter/lib/l10n/app_zh.arb index a8f9e22..2fbda19 100644 --- a/varmanager_flutter/lib/l10n/app_zh.arb +++ b/varmanager_flutter/lib/l10n/app_zh.arb @@ -97,6 +97,9 @@ "proxyPasswordLabel": "代理密码", "varspathLabel": "varspath", "vampathLabel": "vampath", + "varspathSameAsVampathHint": "默认与 vampath 相同", + "varspathSeparateLabel": "我确定需要单独设置 varspath", + "varspathSeparateTip": "仅在 var 文件与 VaM 放在不同文件夹或磁盘时才需要单独配置。", "vamExecLabel": "vam_exec", "downloaderSavePathLabel": "下载保存路径", "chooseVamHint": "推荐: 选择 Virt-A-Mate 目录", @@ -150,7 +153,7 @@ "updateDbLabel": "更新数据库", "updateDbTooltip": "扫描 Vars、提取预览并更新数据库。", "updateDbRequiredTitle": "需要更新数据库", - "updateDbRequiredMessage": "检测到 AddonPackages 包含实际 .var 文件或不是符号链接({path})。请先执行更新数据库。", + "updateDbRequiredMessage": "检测到 AddonPackages 受管链接目录中包含实际 .var 文件({path})。请先执行更新数据库或移出这些文件。", "updateDbSummaryTitle": "更新数据库完成", "updateDbSummaryScanned": "扫描到 {count} 个包。", "updateDbSummaryEmpty": "没有需要移动的包。", @@ -484,7 +487,8 @@ "bootstrapFeaturePacks": "在主页侧栏快速切换 Pack 配置。", "bootstrapConfigTitle": "基础配置", "bootstrapConfigBody": "填写关键路径,确保能够索引包并启动 VaM。", - "bootstrapConfigVarspathRequired": "必须填写 varspath。", + "bootstrapConfigVarspathRequired": "单独设置时必须填写 varspath。", + "bootstrapConfigVampathRequired": "必须填写 vampath。", "bootstrapConfigVamExecHint": "推荐:VaM (Desktop Mode).bat", "bootstrapChecksTitle": "功能自检", "bootstrapChecksBody": "将测试写入权限、文件操作和软链接能力。", @@ -493,11 +497,13 @@ "bootstrapChecksSkipBody": "可以继续,但部分功能可能无法正常工作。", "bootstrapCheckBackendLabel": "后端健康检查", "bootstrapCheckVarspathLabel": "varspath 可用性", + "bootstrapCheckVampathLabel": "vampath 可用性", "bootstrapCheckDownloaderLabel": "下载目录写入权限", "bootstrapCheckFileOpsLabel": "文件复制/移动/重命名", "bootstrapCheckSymlinkLabel": "软链接创建/读取/移动", "bootstrapCheckVamExecLabel": "VaM 启动脚本路径", - "bootstrapCheckVarspathHint": "请先在配置中设置 varspath。", + "bootstrapCheckVarspathHint": "请先在配置中设置 varspath(默认同 vampath)。", + "bootstrapCheckVampathHint": "请先在配置中设置 vampath。", "bootstrapCheckDownloaderHint": "请选择可写的下载目录。", "bootstrapCheckFileOpsHint": "可能原因:目录只读、权限不足或文件被占用。", "bootstrapCheckSymlinkHint": "可能原因:需要管理员/开发者模式、文件系统不支持软链接或磁盘只读。", From 6a89c3b83729cae51945b73811b20a20f9563e2a Mon Sep 17 00:00:00 2001 From: bustesoul <32890006+bustesoul@users.noreply.github.com> Date: Sat, 17 Jan 2026 00:19:56 +0800 Subject: [PATCH 3/6] add home_page refresh after pack switch --- .../lib/features/home/home_page.dart | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/varmanager_flutter/lib/features/home/home_page.dart b/varmanager_flutter/lib/features/home/home_page.dart index ac26813..a212293 100644 --- a/varmanager_flutter/lib/features/home/home_page.dart +++ b/varmanager_flutter/lib/features/home/home_page.dart @@ -840,15 +840,16 @@ class _HomePageState extends ConsumerState { spacing: 8, runSpacing: 8, children: [ - _withTooltip( + _withTooltip( l10n.rebuildLinksTooltip, OutlinedButton.icon( onPressed: isBusy ? null : () async { - await _runJob('rebuild_links', - args: {'include_missing': true}); - }, + await _runJob('rebuild_links', + args: {'include_missing': true}); + ref.invalidate(varsListProvider); + }, icon: const Icon(Icons.link), label: Text(l10n.rebuildLinksLabel), style: OutlinedButton.styleFrom( @@ -873,14 +874,15 @@ class _HomePageState extends ConsumerState { ), ), ), - _withTooltip( + _withTooltip( l10n.staleVarsTooltip, OutlinedButton.icon( onPressed: isBusy ? null : () async { - await _runJob('stale_vars'); - }, + await _runJob('stale_vars'); + ref.invalidate(varsListProvider); + }, icon: const Icon(Icons.inventory_2_outlined), label: Text(l10n.staleVarsLabel), style: OutlinedButton.styleFrom( @@ -889,14 +891,15 @@ class _HomePageState extends ConsumerState { ), ), ), - _withTooltip( + _withTooltip( l10n.oldVersionsTooltip, OutlinedButton.icon( onPressed: isBusy ? null : () async { - await _runJob('old_version_vars'); - }, + await _runJob('old_version_vars'); + ref.invalidate(varsListProvider); + }, icon: const Icon(Icons.layers_clear), label: Text(l10n.oldVersionsLabel), style: OutlinedButton.styleFrom( From c2f0770c90f0628862a39c372ee1ce85e8ee0bab Mon Sep 17 00:00:00 2001 From: bustesoul <32890006+bustesoul@users.noreply.github.com> Date: Sat, 17 Jan 2026 00:20:16 +0800 Subject: [PATCH 4/6] bump version 2.0.2 --- VERSION | 2 +- varManager_backend/Cargo.lock | 2 +- varManager_backend/Cargo.toml | 2 +- .../flutter/generated_plugin_registrant.cc | 8 +-- .../linux/flutter/generated_plugins.cmake | 2 +- .../Flutter/GeneratedPluginRegistrant.swift | 4 +- varmanager_flutter/pubspec.lock | 52 ++++++++++++++++--- varmanager_flutter/pubspec.yaml | 6 +-- .../flutter/generated_plugin_registrant.cc | 6 +-- .../windows/flutter/generated_plugins.cmake | 2 +- 10 files changed, 63 insertions(+), 23 deletions(-) diff --git a/VERSION b/VERSION index 10bf840..f93ea0c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.1 \ No newline at end of file +2.0.2 \ No newline at end of file diff --git a/varManager_backend/Cargo.lock b/varManager_backend/Cargo.lock index f1237d5..7dd3185 100644 --- a/varManager_backend/Cargo.lock +++ b/varManager_backend/Cargo.lock @@ -3522,7 +3522,7 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "varManager_backend" -version = "2.0.1" +version = "2.0.2" dependencies = [ "axum", "bytes", diff --git a/varManager_backend/Cargo.toml b/varManager_backend/Cargo.toml index 40ceb08..bf05ade 100644 --- a/varManager_backend/Cargo.toml +++ b/varManager_backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "varManager_backend" -version = "2.0.1" +version = "2.0.2" edition = "2021" [dependencies] diff --git a/varmanager_flutter/linux/flutter/generated_plugin_registrant.cc b/varmanager_flutter/linux/flutter/generated_plugin_registrant.cc index ae34b3b..d8f1fa2 100644 --- a/varmanager_flutter/linux/flutter/generated_plugin_registrant.cc +++ b/varmanager_flutter/linux/flutter/generated_plugin_registrant.cc @@ -7,16 +7,16 @@ #include "generated_plugin_registrant.h" #include -#include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); - g_autoptr(FlPluginRegistrar) screen_retriever_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverPlugin"); - screen_retriever_plugin_register_with_registrar(screen_retriever_registrar); + g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); + screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); g_autoptr(FlPluginRegistrar) window_manager_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); window_manager_plugin_register_with_registrar(window_manager_registrar); diff --git a/varmanager_flutter/linux/flutter/generated_plugins.cmake b/varmanager_flutter/linux/flutter/generated_plugins.cmake index 081edc4..77d25b4 100644 --- a/varmanager_flutter/linux/flutter/generated_plugins.cmake +++ b/varmanager_flutter/linux/flutter/generated_plugins.cmake @@ -4,7 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_linux - screen_retriever + screen_retriever_linux window_manager ) diff --git a/varmanager_flutter/macos/Flutter/GeneratedPluginRegistrant.swift b/varmanager_flutter/macos/Flutter/GeneratedPluginRegistrant.swift index f7ca5ca..7fa7464 100644 --- a/varmanager_flutter/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/varmanager_flutter/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,11 +6,11 @@ import FlutterMacOS import Foundation import file_selector_macos -import screen_retriever +import screen_retriever_macos import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) - ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin")) + ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/varmanager_flutter/pubspec.lock b/varmanager_flutter/pubspec.lock index 892d93a..0d05961 100644 --- a/varmanager_flutter/pubspec.lock +++ b/varmanager_flutter/pubspec.lock @@ -301,6 +301,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" leak_tracker: dependency: transitive description: @@ -433,10 +441,42 @@ packages: dependency: "direct main" description: name: screen_retriever - sha256: "6ee02c8a1158e6dae7ca430da79436e3b1c9563c8cf02f524af997c201ac2b90" + sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: f7f8120c92ef0784e58491ab664d01efda79a922b025ff286e29aa123ea3dd18 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: "71f956e65c97315dd661d71f828708bd97b6d358e776f1a30d5aa7d22d78a149" url: "https://pub.dev" source: hosted - version: "0.1.9" + version: "0.2.0" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: ee197f4581ff0d5608587819af40490748e1e39e648d7680ecf95c05197240c0 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: "449ee257f03ca98a57288ee526a301a430a344a161f9202b4fcc38576716fe13" + url: "https://pub.dev" + source: hosted + version: "0.2.0" shelf: dependency: transitive description: @@ -598,10 +638,10 @@ packages: dependency: transitive description: name: watcher - sha256: f52385d4f73589977c80797e60fe51014f7f2b957b5e9a62c3f6ada439889249 + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.2.1" web: dependency: transitive description: @@ -638,10 +678,10 @@ packages: dependency: "direct main" description: name: window_manager - sha256: ab8b2a7f97543d3db2b506c9d875e637149d48ee0c6a5cb5f5fd6e0dac463792 + sha256: "7eb6d6c4164ec08e1bf978d6e733f3cebe792e2a23fb07cbca25c2872bfdbdcd" url: "https://pub.dev" source: hosted - version: "0.4.2" + version: "0.5.1" yaml: dependency: transitive description: diff --git a/varmanager_flutter/pubspec.yaml b/varmanager_flutter/pubspec.yaml index b5aad11..dd077bb 100644 --- a/varmanager_flutter/pubspec.yaml +++ b/varmanager_flutter/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 2.0.1+0 +version: 2.0.2+0 environment: sdk: ^3.10.4 @@ -41,8 +41,8 @@ dependencies: path: ^1.9.0 file_selector: ^1.0.3 flutter_staggered_grid_view: ^0.7.0 - screen_retriever: ^0.1.9 - window_manager: ^0.4.2 + screen_retriever: ^0.2.0 + window_manager: ^0.5.1 tutorial_coach_mark: ^1.3.3 dev_dependencies: diff --git a/varmanager_flutter/windows/flutter/generated_plugin_registrant.cc b/varmanager_flutter/windows/flutter/generated_plugin_registrant.cc index fd960fe..e66cab6 100644 --- a/varmanager_flutter/windows/flutter/generated_plugin_registrant.cc +++ b/varmanager_flutter/windows/flutter/generated_plugin_registrant.cc @@ -7,14 +7,14 @@ #include "generated_plugin_registrant.h" #include -#include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); - ScreenRetrieverPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("ScreenRetrieverPlugin")); + ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); WindowManagerPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("WindowManagerPlugin")); } diff --git a/varmanager_flutter/windows/flutter/generated_plugins.cmake b/varmanager_flutter/windows/flutter/generated_plugins.cmake index c69a472..9dc3b2c 100644 --- a/varmanager_flutter/windows/flutter/generated_plugins.cmake +++ b/varmanager_flutter/windows/flutter/generated_plugins.cmake @@ -4,7 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows - screen_retriever + screen_retriever_windows window_manager ) From 20a9d642be1c3ed47f9a08c7fe3647815a5198b5 Mon Sep 17 00:00:00 2001 From: bustesoul <32890006+bustesoul@users.noreply.github.com> Date: Sat, 17 Jan 2026 00:47:30 +0800 Subject: [PATCH 5/6] add release workflow --- .github/workflows/build.yml | 70 +++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2b4fb1c..7682886 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,6 +7,9 @@ on: branches: ["master"] workflow_dispatch: +permissions: + contents: write + jobs: build: runs-on: windows-latest @@ -24,6 +27,46 @@ jobs: $version = (Get-Content VERSION -TotalCount 1).Trim() "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii + - name: Prepare release metadata + id: release_meta + if: github.event_name == 'push' + run: | + $version = "${{ steps.version.outputs.version }}" + $tag = "V$version" + $before = "${{ github.event.before }}" + $after = "${{ github.sha }}" + $files = @() + if ($before -and ($before -notmatch '^[0]+$')) { + $files = git diff --name-only $before $after + } else { + $files = git show --name-only --pretty="" $after + } + $versionChanged = $files -contains "VERSION" + $tagExists = $false + if ($versionChanged) { + git show-ref --tags --verify --quiet "refs/tags/$tag" + if ($LASTEXITCODE -eq 0) { + $tagExists = $true + } + } + "version_changed=$($versionChanged.ToString().ToLower())" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii + "tag_name=$tag" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii + "tag_exists=$($tagExists.ToString().ToLower())" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii + $notes = "" + if ($versionChanged -and -not $tagExists) { + $prevTag = git tag --list "V*" --sort=-v:refname | Select-Object -First 1 + if ([string]::IsNullOrWhiteSpace($prevTag)) { + $log = git log -n 20 --pretty=format:"- %s (%h)" + } else { + $log = git log "$prevTag..HEAD" --pretty=format:"- %s (%h)" + } + $logText = ($log | ForEach-Object { $_ }) -join "`n" + $notes = "Release $tag`n`nChanges:`n$logText" + } + "release_notes< Date: Sat, 17 Jan 2026 00:48:56 +0800 Subject: [PATCH 6/6] add missing_vars_page tooltip --- .../missing_vars/missing_vars_page.dart | 44 ++++++++++++++++--- varmanager_flutter/lib/l10n/app_en.arb | 3 ++ .../lib/l10n/app_localizations.dart | 18 ++++++++ .../lib/l10n/app_localizations_en.dart | 11 +++++ .../lib/l10n/app_localizations_zh.dart | 9 ++++ varmanager_flutter/lib/l10n/app_zh.arb | 3 ++ 6 files changed, 82 insertions(+), 6 deletions(-) diff --git a/varmanager_flutter/lib/features/missing_vars/missing_vars_page.dart b/varmanager_flutter/lib/features/missing_vars/missing_vars_page.dart index 8a090a7..4510b3b 100644 --- a/varmanager_flutter/lib/features/missing_vars/missing_vars_page.dart +++ b/varmanager_flutter/lib/features/missing_vars/missing_vars_page.dart @@ -454,6 +454,18 @@ class _MissingVarsPageState extends ConsumerState { } } + String _downloadStatusTooltip(_DownloadStatus status) { + final l10n = context.l10n; + switch (status) { + case _DownloadStatus.direct: + return l10n.downloadStatusDirectTip; + case _DownloadStatus.noVersion: + return l10n.downloadStatusNoVersionTip; + case _DownloadStatus.none: + return l10n.downloadStatusNoneTip; + } + } + IconData _downloadIcon(String name) { final status = _downloadStatus(name); switch (status) { @@ -1057,8 +1069,20 @@ class _MissingVarsPageState extends ConsumerState { ), SizedBox( width: 32, - child: Icon(_downloadIcon(entry.displayName), - color: _downloadColor(entry.displayName), size: 18), + child: Builder( + builder: (context) { + final status = + _downloadStatus(entry.displayName); + return Tooltip( + message: _downloadStatusTooltip(status), + child: Icon( + _downloadIcon(entry.displayName), + color: _downloadColor(entry.displayName), + size: 18, + ), + ); + }, + ), ), ], ), @@ -1091,10 +1115,18 @@ class _MissingVarsPageState extends ConsumerState { Text(l10n.resolvedLabel( selectedVar == null ? '-' : _resolvedDisplay(selectedVar))), const SizedBox(height: 4), - Text(l10n.downloadLabel( - selectedVar == null - ? '-' - : _downloadStatusLabel(_downloadStatus(selectedVar)))), + if (selectedVar == null) + Text(l10n.downloadLabel('-')) + else + Tooltip( + message: + _downloadStatusTooltip(_downloadStatus(selectedVar)), + child: Text( + l10n.downloadLabel( + _downloadStatusLabel(_downloadStatus(selectedVar)), + ), + ), + ), const SizedBox(height: 4), Text(l10n.linkStatusLabel(linkStatusLabel), style: TextStyle(color: linkStatusColor)), diff --git a/varmanager_flutter/lib/l10n/app_en.arb b/varmanager_flutter/lib/l10n/app_en.arb index bf1de57..60c539c 100644 --- a/varmanager_flutter/lib/l10n/app_en.arb +++ b/varmanager_flutter/lib/l10n/app_en.arb @@ -314,6 +314,9 @@ "downloadStatusDirect": "Direct", "downloadStatusNoVersion": "No Version", "downloadStatusNone": "None", + "downloadStatusDirectTip": "Exact version download link found on Hub.", + "downloadStatusNoVersionTip": "Hub link found, but version differs; download may not match.", + "downloadStatusNoneTip": "No Hub download link found.", "missingSelectFirst": "Please select a missing var first.", "missingFetchHubLinksFirst": "Please click \"Fetch Hub Links\" first to get download URLs.", "missingNoDownloadUrlForSelected": "No download URL available for the selected var.", diff --git a/varmanager_flutter/lib/l10n/app_localizations.dart b/varmanager_flutter/lib/l10n/app_localizations.dart index 45d0d92..78c499f 100644 --- a/varmanager_flutter/lib/l10n/app_localizations.dart +++ b/varmanager_flutter/lib/l10n/app_localizations.dart @@ -1987,6 +1987,24 @@ abstract class AppLocalizations { /// **'None'** String get downloadStatusNone; + /// No description provided for @downloadStatusDirectTip. + /// + /// In en, this message translates to: + /// **'Exact version download link found on Hub.'** + String get downloadStatusDirectTip; + + /// No description provided for @downloadStatusNoVersionTip. + /// + /// In en, this message translates to: + /// **'Hub link found, but version differs; download may not match.'** + String get downloadStatusNoVersionTip; + + /// No description provided for @downloadStatusNoneTip. + /// + /// In en, this message translates to: + /// **'No Hub download link found.'** + String get downloadStatusNoneTip; + /// No description provided for @missingSelectFirst. /// /// 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 38df176..c69bf67 100644 --- a/varmanager_flutter/lib/l10n/app_localizations_en.dart +++ b/varmanager_flutter/lib/l10n/app_localizations_en.dart @@ -1043,6 +1043,17 @@ class AppLocalizationsEn extends AppLocalizations { @override String get downloadStatusNone => 'None'; + @override + String get downloadStatusDirectTip => + 'Exact version download link found on Hub.'; + + @override + String get downloadStatusNoVersionTip => + 'Hub link found, but version differs; download may not match.'; + + @override + String get downloadStatusNoneTip => 'No Hub download link found.'; + @override String get missingSelectFirst => 'Please select a missing var first.'; diff --git a/varmanager_flutter/lib/l10n/app_localizations_zh.dart b/varmanager_flutter/lib/l10n/app_localizations_zh.dart index 2a66ec2..a68472f 100644 --- a/varmanager_flutter/lib/l10n/app_localizations_zh.dart +++ b/varmanager_flutter/lib/l10n/app_localizations_zh.dart @@ -1018,6 +1018,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get downloadStatusNone => '无'; + @override + String get downloadStatusDirectTip => 'Hub 已找到对应版本的下载链接。'; + + @override + String get downloadStatusNoVersionTip => 'Hub 有链接但版本不一致,下载可能不匹配。'; + + @override + String get downloadStatusNoneTip => '未在 Hub 找到下载链接。'; + @override String get missingSelectFirst => '请先选择一个缺失 Var。'; diff --git a/varmanager_flutter/lib/l10n/app_zh.arb b/varmanager_flutter/lib/l10n/app_zh.arb index 2fbda19..ab49dc6 100644 --- a/varmanager_flutter/lib/l10n/app_zh.arb +++ b/varmanager_flutter/lib/l10n/app_zh.arb @@ -314,6 +314,9 @@ "downloadStatusDirect": "直连", "downloadStatusNoVersion": "无版本", "downloadStatusNone": "无", + "downloadStatusDirectTip": "Hub 已找到对应版本的下载链接。", + "downloadStatusNoVersionTip": "Hub 有链接但版本不一致,下载可能不匹配。", + "downloadStatusNoneTip": "未在 Hub 找到下载链接。", "missingSelectFirst": "请先选择一个缺失 Var。", "missingFetchHubLinksFirst": "请先点击“获取 Hub 链接”。", "missingNoDownloadUrlForSelected": "所选 Var 无可用下载链接。",