From 5a9bafc0f6b044f235c014b1e2ff1d26d43915ca Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Fri, 9 Oct 2026 13:24:56 +0200 Subject: [PATCH 1/2] fix(paths): keep the old profile when migration fails and prune update staging from the cache When both the rename and the copy fallback of a legacy directory failed, migrate_legacy_paths removed the half copy and let Colony continue on an empty new directory. relocate() now returns the directory to use, and a failure records the legacy path in a OnceLock that colony_data_dir() and colony_cache_dir() honour for the rest of the session; the next start retries the move. The migration now runs before the log file is created. On Windows the log's data directory is the config directory the first move targets, and creating it first made the move skip for good. Its log lines are captured in memory and written to the log once it is open. The boot sweep looked for update-staging in the config root while the launcher download writes it to the cache. Both are swept now, and the whole directory is emptied: a staged update that was never applied is unreachable after a restart. colony.log moves from the cache root to the data directory. External config overrides are read from the Local config directory first, with the Roaming one kept as a fallback on Windows. The dead ~/.local/share/colony/applications entry is gone; origin classification uses the shared install root instead. --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- CONTRIBUTING.md | 2 +- config/colony.toml | 9 +- docs/architecture.md | 4 +- docs/faq.md | 5 +- docs/tutorial.md | 2 +- src/config.rs | 54 ++++++- src/main.rs | 77 +++++++--- src/persistence.rs | 213 +++++++++++++++++++++----- src/scan.rs | 67 +------- src/update/mod.rs | 5 +- 11 files changed, 295 insertions(+), 145 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 2e55c92..48fbb7e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -82,7 +82,7 @@ body: attributes: label: Logs description: | - Relevant lines from `colony 2>&1` or `~/.cache/colony/`. Redact any + Relevant lines from `colony 2>&1` or `~/.local/share/Colony/Colony/colony.log`. Redact any personal paths or tokens before pasting. render: shell diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 877222f..ec5bbf0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -145,7 +145,7 @@ See [docs/architecture.md](docs/architecture.md) for the full layout. Short vers ### Reporting bugs / feature requests -Use the templates in `.github/ISSUE_TEMPLATE/` when filing. Include Colony version (`colony --version`), OS, and relevant log excerpts from `~/.cache/colony/colony.log`. +Use the templates in `.github/ISSUE_TEMPLATE/` when filing. Include Colony version (`colony --version`), OS, and relevant log excerpts from `~/.local/share/Colony/Colony/colony.log`. --- diff --git a/config/colony.toml b/config/colony.toml index 31a0773..2e96c72 100644 --- a/config/colony.toml +++ b/config/colony.toml @@ -1,11 +1,11 @@ [scan] -# Dossiers scannés sous Windows (variables d'environnement supportées) +# Directories scanned on Windows (environment variables are expanded) windows = [ "${ProgramData}\\Microsoft\\Windows\\Start Menu\\Programs", "${APPDATA}\\Microsoft\\Windows\\Start Menu\\Programs", ] -# Dossiers scannés sous Linux/macOS (variables d'environnement supportées) +# Directories scanned on Linux/macOS (environment variables are expanded) unix = [ "${HOME}/.local/share/applications", "/usr/share/applications", @@ -14,8 +14,3 @@ unix = [ "${HOME}/.local/share/flatpak/exports/share/applications", "/var/lib/snapd/desktop/applications", ] - -# Dossiers qui contiennent uniquement des applications Colony (pour classer l'origine) -colony = [ - "${HOME}/.local/share/colony/applications", -] diff --git a/docs/architecture.md b/docs/architecture.md index a0bffd6..0eb38aa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -93,11 +93,11 @@ All async operations (API calls, downloads, scanning) return a `Task` t | Preferences | `~/.config/Colony/Colony/preferences/preferences.json` | Permanent | | Favorites | `~/.config/Colony/Colony/preferences/favorites.json` | Permanent | | OAuth token | OS Keychain / `~/.config/Colony/Colony/auth/github_token.json` | Permanent | -| Diagnostics log | `~/.cache/Colony/Colony/colony.log` | Truncated per run | +| Diagnostics log | `~/.local/share/Colony/Colony/colony.log` | Truncated per run | | Installed versions | `~/.local/share/Colony/apps//.colony_version` | Permanent | | Resolved asset | `~/.local/share/Colony/apps//.colony_asset` | Permanent | | Colony binaries | `~/.local/share/Colony/apps//` | Permanent | -| Self-update staging | `~/.cache/Colony/Colony/update-staging/` | Temporary | +| Self-update staging | `~/.cache/Colony/Colony/update-staging/` | Emptied at every start | ## GitHub API diff --git a/docs/faq.md b/docs/faq.md index 37f344f..4e1119e 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -133,7 +133,7 @@ If your app is installed but not in any of these (portable binary without `.desk | System app scan cache | `~/.cache/Colony/Colony/scan_cache.json` | | Self-update staging | `~/.cache/Colony/Colony/update-staging/` | | HTTP ETag cache | `~/.cache/Colony/Colony/http_etags.json` | -| Diagnostics log | `~/.cache/Colony/Colony/colony.log` | +| Diagnostics log | `~/.local/share/Colony/Colony/colony.log` | On Windows all three roots are `%LOCALAPPDATA%\Colony\`, and on macOS config and data share `~/Library/Application Support/Colony/` while the cache sits in @@ -162,7 +162,8 @@ Themes apply immediately, no restart. If a palette looks off, verify Settings Yes. Colony looks for a `categories.json` override before falling back to the copy compiled into the binary, in this order: -1. `~/.config/Colony/Colony/categories.json` +1. `~/.config/colony/categories.json` (`%LOCALAPPDATA%\colony\categories.json` + on Windows, where `%APPDATA%\colony\` is still read after it) 2. next to the `colony` binary 3. `./config/categories.json` diff --git a/docs/tutorial.md b/docs/tutorial.md index 2541785..d678334 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -152,5 +152,5 @@ upgraded alongside the rest of your system. [colony-spec.md](colony-spec.md) for the full manifest reference. - **Rate limit hit?** Connect a GitHub account (section 6). - **Download fails?** Check your network. Colony retries up to three times and resumes where it stopped; logs - go to `~/.cache/Colony/Colony/colony.log` if you need to dig in. + go to `~/.local/share/Colony/Colony/colony.log` if you need to dig in. - **More questions?** See the [FAQ](faq.md). diff --git a/src/config.rs b/src/config.rs index 57046b4..4d7cc4c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -12,9 +12,12 @@ use std::path::PathBuf; /// Resolve an external config file named `file_name` (e.g. `"categories.json"`). /// /// Search order, first existing file wins: -/// 1. `~/.config/colony/` — per-user override (XDG config dir). -/// 2. `/config/` — config shipped next to the binary. -/// 3. `./config/` — current working dir (dev: `cargo run`). +/// 1. `/colony/`: per-user override, `~/.config/colony/` +/// on Linux and `%LOCALAPPDATA%\colony\` on Windows. +/// 2. `/colony/`: Windows only, the Roaming folder that +/// earlier versions read, so an override placed there keeps working. +/// 3. `/config/`: config shipped next to the binary. +/// 4. `./config/`: current working dir (dev: `cargo run`). /// /// Returns `None` when no candidate exists; callers should then use their embedded /// or built-in defaults. @@ -26,19 +29,27 @@ pub fn resolve_config_path(file_name: &str) -> Option { fn candidate_paths(file_name: &str) -> Vec { let mut candidates = Vec::new(); - // 1. ~/.config/colony/ - if let Some(config_dir) = dirs::config_dir() { - candidates.push(config_dir.join("colony").join(file_name)); + // 1. Local, never Roaming: the shared layout keeps Colony out of Roaming. + // 2. Roaming, only where it differs (Windows), for overrides placed there + // before. On Linux and macOS the two resolve to the same directory. + for root in [dirs::config_local_dir(), dirs::config_dir()] + .into_iter() + .flatten() + { + let path = root.join("colony").join(file_name); + if !candidates.contains(&path) { + candidates.push(path); + } } - // 2. /config/ + // 3. /config/ if let Ok(exe) = std::env::current_exe() { if let Some(exe_dir) = exe.parent() { candidates.push(exe_dir.join("config").join(file_name)); } } - // 3. ./config/ (dev convenience) + // 4. ./config/ (dev convenience) candidates.push(PathBuf::from("config").join(file_name)); candidates @@ -64,6 +75,33 @@ mod tests { ); } + #[test] + fn the_local_override_wins_over_roaming() { + // Both sides read XDG_CONFIG_HOME, which other tests redirect. + let _guard = crate::persistence::XDG_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let candidates = candidate_paths("colony.toml"); + let local = dirs::config_local_dir() + .expect("config_local_dir") + .join("colony") + .join("colony.toml"); + let roaming = dirs::config_dir() + .expect("config_dir") + .join("colony") + .join("colony.toml"); + + assert_eq!(candidates[0], local, "Local is searched first"); + // Roaming is still read, right after Local, and listed once: on Linux + // and macOS it is the same path as Local. + let at = candidates.iter().position(|p| p == &roaming); + assert!(matches!(at, Some(0) | Some(1)), "{candidates:?}"); + assert_eq!(candidates.iter().filter(|p| *p == &roaming).count(), 1); + if local != roaming { + assert_eq!(at, Some(1)); + } + } + #[test] fn first_existing_returns_first_present_file() { let dir = std::env::temp_dir(); diff --git a/src/main.rs b/src/main.rs index dfde465..cc672be 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,14 +21,33 @@ mod update; use state::{default_font, App}; -/// Where diagnostics land. A plain truncate-on-start file, not a rolling -/// appender: one run's worth of log is what a bug report needs, and it keeps -/// the dependency set unchanged. -fn log_file_path() -> Option { - // The shared layout, same as every other cache: /Colony/Colony/. - colony_ui::paths::cache_dir("Colony") - .ok() - .map(|dir| dir.join("colony.log")) +/// Run `f` with its log lines held in memory, and return them. +/// +/// The startup migration must run before anything creates a directory, the +/// log file included, yet what it reports belongs in that file. +fn capture_log(f: impl FnOnce()) -> Vec { + #[derive(Clone, Default)] + struct Buffer(std::sync::Arc>>); + impl std::io::Write for Buffer { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + let mut buffer = self.0.lock().unwrap_or_else(|e| e.into_inner()); + buffer.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + let buffer = Buffer::default(); + let writer = buffer.clone(); + let subscriber = tracing_subscriber::fmt() + .with_writer(move || writer.clone()) + .with_ansi(false) + .finish(); + tracing::subscriber::with_default(subscriber, f); + let mut lines = buffer.0.lock().unwrap_or_else(|e| e.into_inner()); + std::mem::take(&mut *lines) } /// Answer `--version` / `--help` without opening a window. The bug template @@ -75,9 +94,11 @@ fn handle_cli_flags() -> bool { Diagnostics are written to {} and to stderr.\n\ Set RUST_LOG=debug for more detail.", env!("CARGO_PKG_VERSION"), - log_file_path() - .map(|p| p.display().to_string()) - .unwrap_or_else(|| "(no cache directory)".into()), + // `locate`, not the creating helper: printing help must not + // bring a directory into existence before the migration runs. + colony_ui::paths::locate::data_dir("Colony") + .map(|dir| dir.join("colony.log").display().to_string()) + .unwrap_or_else(|_| "(no data directory)".into()), ); true } @@ -190,6 +211,13 @@ pub fn main() -> iced::Result { return Ok(()); } + // Earlier versions wrote to different directories; move them before + // anything creates a path. That includes the log file: on Windows its data + // directory is the very config directory the first move targets, and a + // move skips a target that already exists. No-op once done, and on a fresh + // install. + let migration_log = capture_log(crate::persistence::migrate_legacy_paths); + // `EnvFilter::from_default_env().add_directive(INFO)` looked like "default // to info", but a bare `RUST_LOG=debug` parses to a directive that compares // Equal to the added one, so add_directive REPLACED it and the user's @@ -206,20 +234,27 @@ pub fn main() -> iced::Result { // Log to a file as well as stderr: a .desktop launch has no terminal, and // on Windows there is no console at all, so stderr-only meant that every // warning in the codebase reached nobody in any shipped configuration. + // A plain truncate-on-start file in the data directory, not a rolling + // appender: one run's worth of log is what a bug report needs, and it keeps + // the dependency set unchanged. + use std::io::Write as _; use tracing_subscriber::fmt::writer::MakeWriterExt; - match log_file_path().and_then(|p| std::fs::File::create(p).ok()) { - Some(file) => tracing_subscriber::fmt() - .with_env_filter(filter) - .with_writer(std::sync::Mutex::new(file).and(std::io::stderr)) - .with_ansi(false) - .init(), + let _ = std::io::stderr().write_all(&migration_log); + let log_file = crate::persistence::colony_log_path() + .ok() + .and_then(|path| std::fs::File::create(path).ok()); + match log_file { + Some(mut file) => { + let _ = file.write_all(&migration_log); + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(std::sync::Mutex::new(file).and(std::io::stderr)) + .with_ansi(false) + .init() + } None => tracing_subscriber::fmt().with_env_filter(filter).init(), } - // Earlier versions wrote to different directories; move them before - // anything reads a path. No-op once done, and on a fresh install. - crate::persistence::migrate_legacy_paths(); - // Honor the saved language preference over environment locale detection, // and reopen at the last persisted window size (clamped to sanity). let prefs = crate::persistence::load_preferences(); diff --git a/src/persistence.rs b/src/persistence.rs index 72746ed..2df2209 100644 --- a/src/persistence.rs +++ b/src/persistence.rs @@ -5,34 +5,77 @@ use anyhow::Result; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; +use std::sync::OnceLock; use crate::github::{current_platform_key, ColonyRepo}; /// Colony's own name in the shared `Colony//` tree. const PROGRAM: &str = "Colony"; +/// Legacy directories this session keeps using because [`migrate_legacy_paths`] +/// could not move them. Set at most once, before anything reads a path. +static CONFIG_FALLBACK: OnceLock = OnceLock::new(); +static CACHE_FALLBACK: OnceLock = OnceLock::new(); + +/// Serializes the tests that redirect the XDG variables. They are process-wide, +/// so every such test must hold this one lock rather than a lock of its own. +#[cfg(test)] +pub(crate) static XDG_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// Use the migration fallback when there is one, else the shared layout. +fn resolve( + fallback: &OnceLock, + layout: fn(&str) -> std::io::Result, +) -> Result { + match fallback.get() { + Some(legacy) => { + std::fs::create_dir_all(legacy)?; + Ok(legacy.clone()) + } + None => Ok(layout(PROGRAM)?), + } +} + /// Central config directory for Colony's own files. /// -/// The layout — which root on which platform — is defined once in colony-ui; +/// The layout (which root on which platform) is defined once in colony-ui; /// see `design/filesystem.md` in Project-Colony-Resources. On Linux this is -/// `~/.config/Colony/Colony/`. +/// `~/.config/Colony/Colony/`. After a failed migration it is the legacy +/// directory instead, for the rest of the session. pub fn colony_data_dir() -> Result { - Ok(colony_ui::paths::config_dir(PROGRAM)?) + resolve(&CONFIG_FALLBACK, colony_ui::paths::config_dir) } /// Regenerable state: the repo listing and the scan results. pub(crate) fn colony_cache_dir() -> Result { - Ok(colony_ui::paths::cache_dir(PROGRAM)?) + resolve(&CACHE_FALLBACK, colony_ui::paths::cache_dir) +} + +/// The diagnostics log, `/Colony/Colony/colony.log`. Data rather than +/// cache: it is something the program produced and cannot fetch again. +/// +/// While a failed migration keeps the session on the legacy config directory, +/// the log stays beside it. On Windows the data directory *is* the new config +/// directory, and creating it would make the next start take the move as done. +pub fn colony_log_path() -> Result { + let dir = match CONFIG_FALLBACK.get() { + Some(legacy) => legacy.clone(), + None => colony_ui::paths::data_dir(PROGRAM)?, + }; + Ok(dir.join("colony.log")) } /// Move state written by earlier versions to where the shared layout puts it. /// -/// Must run before anything reads a path — `main` calls it first. +/// Must run before anything creates a path, the log file included: `main` +/// calls it first. /// /// Deliberately conservative: it only moves when the old location exists and /// the new one does not, and it **never deletes the source**. A user who ends /// up with a copy in both places has lost nothing; a user whose preferences -/// were deleted by a half-finished migration has. +/// were deleted by a half-finished migration has. When a move fails, the +/// session keeps using the old location rather than an empty new one, and the +/// next start tries again. pub fn migrate_legacy_paths() { // Windows used to resolve config to Roaming (dirs::config_dir), and the // layout is Local. Identical on Linux and macOS, so this is a no-op there. @@ -40,11 +83,19 @@ pub fn migrate_legacy_paths() { dirs::config_dir(), colony_ui::paths::locate::config_dir(PROGRAM), ) { - relocate( - &legacy_root.join("Colony").join(PROGRAM), - ¤t, - "config directory", - ); + let legacy = legacy_root.join("Colony").join(PROGRAM); + if relocate(&legacy, ¤t, "config directory") != current { + // The cache used to live inside the config directory, and on + // Windows, the only platform this move runs on, the new cache root + // sits inside the new config directory. Keep it in the old place + // too: creating it would create the new config directory with it, + // and the next start would take the move as done. + let _ = CACHE_FALLBACK.set(legacy.join("cache")); + let _ = CONFIG_FALLBACK.set(legacy); + // The cache moves below read from the new config directory, which + // this failure left empty. + return; + } } // Everything regenerable used to live inside the config directory. All of @@ -54,44 +105,63 @@ pub fn migrate_legacy_paths() { colony_ui::paths::locate::config_dir(PROGRAM), colony_ui::paths::locate::cache_dir(PROGRAM), ) { - relocate(&config.join("cache"), &cache, "cache"); + let legacy = config.join("cache"); + if relocate(&legacy, &cache, "cache") != cache { + // Moving the per-repo caches now would fill a cache root this + // session does not read; they follow at the start that succeeds. + let _ = CACHE_FALLBACK.set(legacy); + return; + } for sub in ["repo-docs", "repo-icons", "update-staging"] { relocate(&config.join(sub), &cache.join(sub), sub); } } } -/// Move `from` to `to`, once, without ever destroying `from`. -fn relocate(from: &Path, to: &Path, what: &str) { +/// Move `from` to `to`, once, without ever destroying `from`, and return the +/// directory to use from now on: `to`, unless `from` still holds data that +/// could not be moved. +fn relocate<'a>(from: &'a Path, to: &'a Path, what: &str) -> &'a Path { if from == to || !from.is_dir() || to.exists() { - return; + return to; } - let Some(parent) = to.parent() else { return }; + let Some(parent) = to.parent() else { return to }; if let Err(e) = std::fs::create_dir_all(parent) { - tracing::warn!("cannot prepare {} for the {what}: {e}", parent.display()); - return; + tracing::warn!( + "cannot prepare {} for the {what}: {e}; using the old location at {} for now", + parent.display(), + from.display() + ); + return from; } - // A rename is atomic and cheap, but fails across filesystems (EXDEV) — and + // A rename is atomic and cheap, but fails across filesystems (EXDEV), and // ~/.config and ~/.cache are not guaranteed to be on the same one. if std::fs::rename(from, to).is_ok() { tracing::info!("moved the {what} to {}", to.display()); - return; + return to; } match copy_tree(from, to) { - Ok(()) => tracing::info!( - "copied the {what} to {}; the old copy at {} is left in place and can be deleted by hand", - to.display(), - from.display() - ), + Ok(()) => { + tracing::info!( + "copied the {what} to {}; the old copy at {} is left in place and can be deleted by hand", + to.display(), + from.display() + ); + to + } Err(e) => { // Leave no half-copied directory behind: the next start would see // `to` existing and skip the migration, stranding the real data. let _ = std::fs::remove_dir_all(to); tracing::error!( - "could not move the {what} from {}: {e}. Nothing was lost — the old location still holds it — but Colony will start with an empty one.", - from.display() + "could not move the {what} from {} to {}: {e}. Nothing was lost: \ + Colony keeps using the old location for this session and tries \ + again at the next start.", + from.display(), + to.display() ); + from } } } @@ -670,11 +740,15 @@ pub fn remove_app_dir(app_dir: &std::path::Path) -> std::io::Result<()> { /// accumulates. Run once at boot: any staging file present then is by /// definition orphaned, because nothing is in flight yet. /// -/// The `update-staging` directory gets the same treatment, plus the `.old` -/// backup a completed self-update leaves next to the executable - two full -/// copies of the launcher could otherwise sit on disk indefinitely. +/// The `update-staging` directory is emptied outright: the path of a +/// downloaded launcher update lives only in memory, so one that was never +/// applied is unreachable after a restart, binary and signatures alike. So is +/// the `.old` backup a completed self-update leaves next to the executable - +/// two full copies of the launcher could otherwise sit on disk indefinitely. pub fn prune_staging() -> u64 { - fn sweep(dir: &std::path::Path, reclaimed: &mut u64) { + /// `everything` is for directories that hold nothing but staging; the + /// per-app directories hold the installed binaries too. + fn sweep(dir: &std::path::Path, reclaimed: &mut u64, everything: bool) { let Ok(entries) = std::fs::read_dir(dir) else { return; }; @@ -683,7 +757,8 @@ pub fn prune_staging() -> u64 { let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue; }; - if !(name.ends_with(".part") + if !(everything + || name.ends_with(".part") || name.ends_with(".part.id") || name.ends_with(".new") || name.ends_with(".old")) @@ -703,7 +778,7 @@ pub fn prune_staging() -> u64 { if let Ok(entries) = std::fs::read_dir(&apps) { for entry in entries.flatten() { if entry.path().is_dir() { - sweep(&entry.path(), &mut reclaimed); + sweep(&entry.path(), &mut reclaimed, false); // An uninstall that could not delete a running binary // renames it aside and leaves the shell of the directory; // once the sweep above has taken the `.old` file, finish @@ -718,8 +793,14 @@ pub fn prune_staging() -> u64 { } } } - if let Ok(base) = colony_data_dir() { - sweep(&base.join("update-staging"), &mut reclaimed); + // Self-update staging lives in the cache. The config root is swept as well, + // for what versions before the shared layout left there and the migration + // could not move. + for base in [colony_cache_dir(), colony_data_dir()] + .into_iter() + .flatten() + { + sweep(&base.join("update-staging"), &mut reclaimed, true); } // Only our OWN backup, by exact path - never a directory sweep here. The // executable may well live in /usr/bin, where an extension-based sweep @@ -885,7 +966,7 @@ mod path_migration_tests { ); seed(&from, "auth/github_token.json", "token"); - relocate(&from, &to, "config directory"); + assert_eq!(relocate(&from, &to, "config directory"), to.as_path()); assert!(!from.exists(), "the source should have been renamed away"); assert_eq!( @@ -922,7 +1003,8 @@ mod path_migration_tests { let root = scratch("noop"); let (from, to) = (root.join("absent"), root.join("new")); - relocate(&from, &to, "config directory"); + // Nothing to move is not a failure: the new location is the one to use. + assert_eq!(relocate(&from, &to, "config directory"), to.as_path()); assert!(!to.exists(), "nothing to migrate should create nothing"); let _ = std::fs::remove_dir_all(&root); @@ -945,6 +1027,50 @@ mod path_migration_tests { let _ = std::fs::remove_dir_all(&root); } + /// A move that fails must leave the session on the old directory, never on + /// an empty new one. The rename fails because the old directory's parent is + /// read-only, and the copy fallback fails on a file it cannot read. + #[cfg(unix)] + #[test] + fn a_failed_move_keeps_using_the_legacy_directory() { + use std::os::unix::fs::PermissionsExt; + let set_mode = |path: &Path, mode| { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap() + }; + + let root = tempfile::tempdir().expect("tempdir"); + let locked = root.path().join("locked"); + let (from, to) = (locked.join("old"), root.path().join("new")); + seed( + &from, + "preferences/preferences.json", + "{\"theme\":\"gruvbox\"}", + ); + seed(&from, "auth/github_token.json", "token"); + let unreadable = from.join("auth/github_token.json"); + + set_mode(&unreadable, 0o000); + set_mode(&locked, 0o555); + // Root ignores permission bits, and then neither failure can be staged. + let enforced = std::fs::File::open(&unreadable).is_err(); + let used = enforced.then(|| relocate(&from, &to, "config directory").to_path_buf()); + // Restore before asserting, so the temp dir can always be removed. + set_mode(&locked, 0o755); + set_mode(&unreadable, 0o600); + let Some(used) = used else { return }; + + assert_eq!(used, from, "the session must stay on the legacy directory"); + assert!( + !to.exists(), + "the half copy is removed so the next start retries" + ); + assert_eq!( + std::fs::read_to_string(from.join("preferences/preferences.json")).unwrap(), + "{\"theme\":\"gruvbox\"}" + ); + assert_eq!(std::fs::read_to_string(&unreadable).unwrap(), "token"); + } + #[test] fn copy_tree_reproduces_nested_contents() { let root = scratch("copy"); @@ -983,8 +1109,7 @@ mod path_migration_tests { fn migrating_a_real_legacy_layout_moves_the_caches_and_leaves_config_alone() { // Driving process-wide environment variables; must not race the other // tests that read a path. - static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = XDG_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let root = scratch("e2e"); let (old_config, old_data, old_cache) = ( @@ -1045,6 +1170,16 @@ mod path_migration_tests { migrate_legacy_paths(); assert!(cache.join("repos_cache.json").exists()); + // Self-update staging is downloaded to the cache, so that is where the + // boot sweep has to look; the config root is still swept for what + // older versions left there. A staged update that was never applied is + // unreachable after a restart and goes too. + seed(&legacy, "update-staging/colony-linux.part", "partial"); + let reclaimed = prune_staging(); + assert!(!cache.join("update-staging/colony-linux").exists()); + assert!(!legacy.join("update-staging/colony-linux.part").exists()); + assert_eq!(reclaimed, ("binary".len() + "partial".len()) as u64); + for (k, v) in [ ("XDG_CONFIG_HOME", old_config), ("XDG_DATA_HOME", old_data), diff --git a/src/scan.rs b/src/scan.rs index 97f0e3b..6b0f0a2 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -1,8 +1,6 @@ use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; -#[cfg(not(windows))] -use std::sync::OnceLock; use anyhow::Result; use serde::Deserialize; @@ -115,7 +113,6 @@ struct ColonyConfig { struct ScanConfig { windows: Option>, unix: Option>, - colony: Option>, } pub fn scan_applications() -> Result> { @@ -198,16 +195,7 @@ fn default_windows_dirs() -> Vec { #[cfg(not(windows))] fn get_application_dirs() -> Vec { - // Scan Colony-managed directories first: with first-seen-wins dedup this - // ensures a Colony-installed app is not shadowed (and mis-classified) by a - // same-named system app. - let mut dirs = colony_application_dirs(); - for dir in load_scan_dirs_from_config().unwrap_or_else(default_unix_dirs) { - if !dirs.contains(&dir) { - dirs.push(dir); - } - } - dirs + load_scan_dirs_from_config().unwrap_or_else(default_unix_dirs) } #[cfg(not(windows))] @@ -288,56 +276,13 @@ fn default_unix_dirs() -> Vec { dirs } -#[cfg(not(windows))] -fn colony_application_dirs() -> Vec { - static COLONY_DIRS: OnceLock> = OnceLock::new(); - COLONY_DIRS - .get_or_init(|| load_colony_dirs_from_config().unwrap_or_else(default_colony_dirs)) - .clone() -} - -#[cfg(not(windows))] -fn load_colony_dirs_from_config() -> Option> { - let path = crate::config::resolve_config_path("colony.toml")?; - let content = fs::read_to_string(&path).ok()?; - let config: ColonyConfig = match toml::from_str(&content) { - Ok(config) => config, - Err(error) => { - tracing::warn!("Invalid config {}: {}", path.display(), error); - return None; - } - }; - let dirs = config.scan?.colony?; - let expanded: Vec = dirs - .into_iter() - .map(|dir| PathBuf::from(expand_env_vars(&dir))) - .collect(); - if expanded.is_empty() { - None - } else { - Some(expanded) - } -} - -#[cfg(not(windows))] -fn default_colony_dirs() -> Vec { - let mut dirs = Vec::new(); - - if let Ok(home) = std::env::var("HOME") { - dirs.push(PathBuf::from(format!( - "{}/.local/share/colony/applications", - home - ))); - } - - dirs -} - +/// Whether `path` sits under the shared install root, where every program +/// Colony installs lives. The installed-apps state is the only record of what +/// Colony put on disk; a hard-coded list of directories went stale the moment +/// the install root moved. #[cfg(not(windows))] fn is_colony_app(path: &Path) -> bool { - colony_application_dirs() - .iter() - .any(|dir| path.starts_with(dir)) + crate::persistence::colony_apps_dir().is_ok_and(|apps| path.starts_with(apps)) } fn expand_env_vars(value: &str) -> String { diff --git a/src/update/mod.rs b/src/update/mod.rs index 96173e2..655be6f 100644 --- a/src/update/mod.rs +++ b/src/update/mod.rs @@ -588,8 +588,9 @@ mod tests { /// honors XDG on Linux, so callers gate on cfg(target_os = "linux"). #[cfg(target_os = "linux")] fn with_temp_dirs(f: impl FnOnce()) { - static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let _guard = crate::persistence::XDG_TEST_LOCK + .lock() + .unwrap_or_else(|p| p.into_inner()); let tmp = tempfile::tempdir().expect("tempdir"); let old_config = std::env::var_os("XDG_CONFIG_HOME"); let old_data = std::env::var_os("XDG_DATA_HOME"); From 2913d99d39a6896bb65499e47cb5d8e5991e5655 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Fri, 9 Oct 2026 14:16:58 +0200 Subject: [PATCH 2/2] fix(paths): finish a config move an earlier version skipped v0.10.2 to v0.10.4 created the new config directory for the log file before the migration ran, so on Windows the Roaming to Local move was skipped and the profile stayed behind. The config move is now tracked by a .migrated marker: when the target exists without it, the files it lacks are copied over, never overwriting what was written there since. A half copy that cannot be removed after a failed move is renamed aside, so the next start retries instead of running on it. The migration decisions are factored out of the OnceLock setters and tested on explicit paths, scanned apps are classified as external directly, and the FAQ lists the macOS override path. --- docs/faq.md | 6 +- src/config.rs | 3 +- src/persistence.rs | 317 +++++++++++++++++++++++++++++++++++++-------- src/scan.rs | 26 +--- 4 files changed, 274 insertions(+), 78 deletions(-) diff --git a/docs/faq.md b/docs/faq.md index 4e1119e..f901e2b 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -162,8 +162,10 @@ Themes apply immediately, no restart. If a palette looks off, verify Settings Yes. Colony looks for a `categories.json` override before falling back to the copy compiled into the binary, in this order: -1. `~/.config/colony/categories.json` (`%LOCALAPPDATA%\colony\categories.json` - on Windows, where `%APPDATA%\colony\` is still read after it) +1. `~/.config/colony/categories.json` on Linux, + `~/Library/Application Support/colony/categories.json` on macOS, and + `%LOCALAPPDATA%\colony\categories.json` on Windows, where + `%APPDATA%\colony\` is still read after it 2. next to the `colony` binary 3. `./config/categories.json` diff --git a/src/config.rs b/src/config.rs index 4d7cc4c..0829cca 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,7 +13,8 @@ use std::path::PathBuf; /// /// Search order, first existing file wins: /// 1. `/colony/`: per-user override, `~/.config/colony/` -/// on Linux and `%LOCALAPPDATA%\colony\` on Windows. +/// on Linux, `~/Library/Application Support/colony/` on macOS and +/// `%LOCALAPPDATA%\colony\` on Windows. /// 2. `/colony/`: Windows only, the Roaming folder that /// earlier versions read, so an override placed there keeps working. /// 3. `/config/`: config shipped next to the binary. diff --git a/src/persistence.rs b/src/persistence.rs index 2df2209..39518d5 100644 --- a/src/persistence.rs +++ b/src/persistence.rs @@ -77,45 +77,102 @@ pub fn colony_log_path() -> Result { /// session keeps using the old location rather than an empty new one, and the /// next start tries again. pub fn migrate_legacy_paths() { - // Windows used to resolve config to Roaming (dirs::config_dir), and the - // layout is Local. Identical on Linux and macOS, so this is a no-op there. - if let (Some(legacy_root), Ok(current)) = ( - dirs::config_dir(), + let (Ok(config), Ok(cache)) = ( colony_ui::paths::locate::config_dir(PROGRAM), - ) { - let legacy = legacy_root.join("Colony").join(PROGRAM); - if relocate(&legacy, ¤t, "config directory") != current { - // The cache used to live inside the config directory, and on - // Windows, the only platform this move runs on, the new cache root - // sits inside the new config directory. Keep it in the old place - // too: creating it would create the new config directory with it, - // and the next start would take the move as done. - let _ = CACHE_FALLBACK.set(legacy.join("cache")); - let _ = CONFIG_FALLBACK.set(legacy); - // The cache moves below read from the new config directory, which - // this failure left empty. - return; - } + colony_ui::paths::locate::cache_dir(PROGRAM), + ) else { + return; + }; + // Windows used to resolve config to Roaming (dirs::config_dir), and the + // layout is Local. Identical on Linux and macOS, so that move is a no-op + // there. + let legacy_config = + dirs::config_dir().map_or_else(|| config.clone(), |root| root.join("Colony").join(PROGRAM)); + let (config_fallback, cache_fallback) = migrate(&legacy_config, &config, &cache); + if let Some(dir) = config_fallback { + let _ = CONFIG_FALLBACK.set(dir); + } + if let Some(dir) = cache_fallback { + let _ = CACHE_FALLBACK.set(dir); + } +} + +/// The moves behind [`migrate_legacy_paths`], on explicit paths. Returns the +/// legacy config and cache directories this session has to keep using because +/// their move failed, if any. +fn migrate( + legacy_config: &Path, + config: &Path, + cache: &Path, +) -> (Option, Option) { + if move_config_dir(legacy_config, config) != config { + // The cache used to live inside the config directory, and on Windows, + // the only platform this move runs on, the new cache root sits inside + // the new config directory. Keep it in the old place too: creating it + // would create the new config directory with it, and the next start + // would have to merge into that directory instead of moving the old + // one. The cache moves below read from the new config directory, which + // this failure left empty, so they are skipped. + return ( + Some(legacy_config.to_path_buf()), + Some(legacy_config.join("cache")), + ); } // Everything regenerable used to live inside the config directory. All of // it is re-fetched when missing, so a failure here costs a round trip to // GitHub and nothing more. - if let (Ok(config), Ok(cache)) = ( - colony_ui::paths::locate::config_dir(PROGRAM), - colony_ui::paths::locate::cache_dir(PROGRAM), - ) { - let legacy = config.join("cache"); - if relocate(&legacy, &cache, "cache") != cache { - // Moving the per-repo caches now would fill a cache root this - // session does not read; they follow at the start that succeeds. - let _ = CACHE_FALLBACK.set(legacy); - return; - } - for sub in ["repo-docs", "repo-icons", "update-staging"] { - relocate(&config.join(sub), &cache.join(sub), sub); + let legacy_cache = config.join("cache"); + if relocate(&legacy_cache, cache, "cache") != cache { + // Moving the per-repo caches now would fill a cache root this session + // does not read; they follow at the start that succeeds. + return (None, Some(legacy_cache)); + } + for sub in ["repo-docs", "repo-icons", "update-staging"] { + relocate(&config.join(sub), &cache.join(sub), sub); + } + (None, None) +} + +/// Written in the new config directory once the move from the legacy one is +/// done. "The new directory exists" used to stand for "moved", but v0.10.2 to +/// v0.10.4 created that directory, for the log file, before the move ran: on +/// Windows the whole profile stayed behind in Roaming and Colony started on an +/// empty one. +const MIGRATED_MARKER: &str = ".migrated"; + +/// The config directory move, tracked by [`MIGRATED_MARKER`]. Returns the +/// directory to use from now on, as [`relocate`] does. +fn move_config_dir<'a>(legacy: &'a Path, current: &'a Path) -> &'a Path { + let marker = current.join(MIGRATED_MARKER); + if legacy == current || !legacy.is_dir() || marker.exists() { + return current; + } + if current.exists() { + // An earlier version created the new directory and skipped the move. + // Bring over what it lacks; whatever was written there since wins. + if let Err(e) = copy_tree(legacy, current) { + tracing::warn!( + "could not finish moving the config directory from {} to {}: {e}; \ + trying again at the next start", + legacy.display(), + current.display() + ); + return current; } + tracing::info!( + "copied what {} lacked from {}; files already there were kept, and \ + the old directory can be deleted by hand", + current.display(), + legacy.display() + ); + } else if relocate(legacy, current, "config directory") != current { + return legacy; + } + if let Err(e) = std::fs::write(&marker, b"") { + tracing::warn!("could not write {}: {e}", marker.display()); } + current } /// Move `from` to `to`, once, without ever destroying `from`, and return the @@ -151,9 +208,6 @@ fn relocate<'a>(from: &'a Path, to: &'a Path, what: &str) -> &'a Path { to } Err(e) => { - // Leave no half-copied directory behind: the next start would see - // `to` existing and skip the migration, stranding the real data. - let _ = std::fs::remove_dir_all(to); tracing::error!( "could not move the {what} from {} to {}: {e}. Nothing was lost: \ Colony keeps using the old location for this session and tries \ @@ -161,12 +215,44 @@ fn relocate<'a>(from: &'a Path, to: &'a Path, what: &str) -> &'a Path { from.display(), to.display() ); + discard_half_copy(to); from } } } -/// Recursive copy. Files and directories only; anything else is skipped. +/// Remove what a failed copy left at `to`. Left in place, it would pass for a +/// finished move at the next start, which would then skip the move and run on +/// half the data. When it cannot be removed, for example because a scanner +/// holds a file open, it is renamed aside instead. +fn discard_half_copy(to: &Path) { + let Err(e) = std::fs::remove_dir_all(to) else { + return; + }; + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let mut aside = to.as_os_str().to_owned(); + aside.push(format!(".incomplete-{stamp}")); + let aside = PathBuf::from(aside); + match std::fs::rename(to, &aside) { + Ok(()) => tracing::warn!( + "could not remove the incomplete copy at {}: {e}; moved it to {}, \ + which can be deleted by hand", + to.display(), + aside.display() + ), + Err(_) => tracing::error!( + "could not remove the incomplete copy at {}: {e}. Delete it by hand, \ + or the next start will skip the move and use it", + to.display() + ), + } +} + +/// Recursive copy that never overwrites: a file already at the target is +/// kept. Files and directories only; anything else is skipped. fn copy_tree(from: &Path, to: &Path) -> std::io::Result<()> { std::fs::create_dir_all(to)?; for entry in std::fs::read_dir(from)? { @@ -175,7 +261,7 @@ fn copy_tree(from: &Path, to: &Path) -> std::io::Result<()> { let kind = entry.file_type()?; if kind.is_dir() { copy_tree(&entry.path(), &target)?; - } else if kind.is_file() { + } else if kind.is_file() && std::fs::symlink_metadata(&target).is_err() { std::fs::copy(entry.path(), &target)?; } } @@ -1027,20 +1113,34 @@ mod path_migration_tests { let _ = std::fs::remove_dir_all(&root); } - /// A move that fails must leave the session on the old directory, never on - /// an empty new one. The rename fails because the old directory's parent is - /// read-only, and the copy fallback fails on a file it cannot read. + /// Run `f` while `dir` cannot be moved: its parent is read-only, which + /// fails the rename, and `file` inside it is unreadable, which fails the + /// copy fallback. `None` when running as root, which ignores permission + /// bits, so neither failure can be staged. #[cfg(unix)] - #[test] - fn a_failed_move_keeps_using_the_legacy_directory() { + fn while_unmovable(dir: &Path, file: &Path, f: impl FnOnce() -> T) -> Option { use std::os::unix::fs::PermissionsExt; let set_mode = |path: &Path, mode| { std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap() }; + let parent = dir.parent().expect("parent"); + set_mode(file, 0o000); + set_mode(parent, 0o555); + let out = std::fs::File::open(file).is_err().then(f); + // Restore before the caller asserts, so the temp dir can always be + // removed. + set_mode(parent, 0o755); + set_mode(file, 0o600); + out + } + /// A move that fails must leave the session on the old directory, never on + /// an empty new one. + #[cfg(unix)] + #[test] + fn a_failed_move_keeps_using_the_legacy_directory() { let root = tempfile::tempdir().expect("tempdir"); - let locked = root.path().join("locked"); - let (from, to) = (locked.join("old"), root.path().join("new")); + let (from, to) = (root.path().join("locked/old"), root.path().join("new")); seed( &from, "preferences/preferences.json", @@ -1049,15 +1149,11 @@ mod path_migration_tests { seed(&from, "auth/github_token.json", "token"); let unreadable = from.join("auth/github_token.json"); - set_mode(&unreadable, 0o000); - set_mode(&locked, 0o555); - // Root ignores permission bits, and then neither failure can be staged. - let enforced = std::fs::File::open(&unreadable).is_err(); - let used = enforced.then(|| relocate(&from, &to, "config directory").to_path_buf()); - // Restore before asserting, so the temp dir can always be removed. - set_mode(&locked, 0o755); - set_mode(&unreadable, 0o600); - let Some(used) = used else { return }; + let Some(used) = while_unmovable(&from, &unreadable, || { + relocate(&from, &to, "config directory").to_path_buf() + }) else { + return; + }; assert_eq!(used, from, "the session must stay on the legacy directory"); assert!( @@ -1071,6 +1167,123 @@ mod path_migration_tests { assert_eq!(std::fs::read_to_string(&unreadable).unwrap(), "token"); } + /// The Windows shape: Roaming to Local, with the new cache root inside the + /// new config directory. A failed move puts both on the legacy tree, and + /// that is what `colony_data_dir` and `colony_cache_dir` then resolve to. + #[cfg(unix)] + #[test] + fn a_failed_config_move_resolves_both_dirs_to_the_legacy_tree() { + let root = tempfile::tempdir().expect("tempdir"); + let legacy = root.path().join("roaming/Colony/Colony"); + let config = root.path().join("local/Colony/Colony"); + let cache = config.join("cache"); + seed(&legacy, "preferences/favorites.json", "[\"Grape\"]"); + seed(&legacy, "auth/github_token.json", "token"); + + let Some((config_fallback, cache_fallback)) = + while_unmovable(&legacy, &legacy.join("auth/github_token.json"), || { + migrate(&legacy, &config, &cache) + }) + else { + return; + }; + + assert_eq!(config_fallback.as_deref(), Some(legacy.as_path())); + assert_eq!(cache_fallback, Some(legacy.join("cache"))); + assert!(!config.exists(), "the new config directory must not exist"); + + let unused: fn(&str) -> std::io::Result = + |_| panic!("the layout must not be consulted while a fallback is set"); + let (config_lock, cache_lock) = (OnceLock::new(), OnceLock::new()); + config_lock.set(config_fallback.unwrap()).unwrap(); + cache_lock.set(cache_fallback.unwrap()).unwrap(); + let data_dir = resolve(&config_lock, unused).unwrap(); + assert_eq!(data_dir, legacy); + assert_eq!(resolve(&cache_lock, unused).unwrap(), legacy.join("cache")); + assert_eq!( + std::fs::read_to_string(data_dir.join("preferences/favorites.json")).unwrap(), + "[\"Grape\"]" + ); + } + + /// A failed cache move keeps the session on the old cache and leaves the + /// per-repo caches where they are, for the start where the move succeeds. + #[cfg(unix)] + #[test] + fn a_failed_cache_move_defers_the_per_repo_moves() { + let root = tempfile::tempdir().expect("tempdir"); + let config = root.path().join("config/Colony/Colony"); + let cache = root.path().join("cache/Colony/Colony"); + seed(&config, "cache/repos_cache.json", "[]"); + seed(&config, "repo-docs/Eidos/README.md", "# Eidos"); + let legacy_cache = config.join("cache"); + + let Some(fallbacks) = while_unmovable( + &legacy_cache, + &legacy_cache.join("repos_cache.json"), + || migrate(&config, &config, &cache), + ) else { + return; + }; + + assert_eq!(fallbacks, (None, Some(legacy_cache))); + assert!(!cache.join("repo-docs").exists()); + assert!(config.join("repo-docs/Eidos/README.md").exists()); + } + + /// v0.10.2 to v0.10.4 created the new config directory before the move ran, + /// so on Windows the move was skipped and the profile stayed in Roaming. + /// The missing files arrive now, and nothing written since is overwritten. + #[test] + fn a_move_an_earlier_version_skipped_is_finished_without_overwriting() { + let root = tempfile::tempdir().expect("tempdir"); + let (legacy, current) = (root.path().join("roaming"), root.path().join("local")); + seed( + &legacy, + "preferences/preferences.json", + "{\"theme\":\"gruvbox\"}", + ); + seed(&legacy, "preferences/favorites.json", "[\"Grape\"]"); + seed(&legacy, "auth/github_token.json", "token"); + seed(¤t, "cache/colony.log", "log"); + seed(¤t, "preferences/preferences.json", "{\"width\":900}"); + + assert_eq!(move_config_dir(&legacy, ¤t), current.as_path()); + + let read = |rel: &str| std::fs::read_to_string(current.join(rel)).unwrap(); + assert_eq!(read("preferences/favorites.json"), "[\"Grape\"]"); + assert_eq!(read("auth/github_token.json"), "token"); + assert_eq!(read("preferences/preferences.json"), "{\"width\":900}"); + assert_eq!(read("cache/colony.log"), "log"); + assert!( + legacy.join("preferences/preferences.json").exists(), + "the source is never removed" + ); + assert!(current.join(MIGRATED_MARKER).exists()); + + // Done once: a file removed since is not brought back. + std::fs::remove_file(current.join("auth/github_token.json")).unwrap(); + assert_eq!(move_config_dir(&legacy, ¤t), current.as_path()); + assert!(!current.join("auth/github_token.json").exists()); + } + + /// A completed move is marked as well, so a later start never merges the + /// old directory back in. + #[test] + fn a_completed_config_move_is_marked() { + let root = tempfile::tempdir().expect("tempdir"); + let (legacy, current) = (root.path().join("roaming"), root.path().join("local")); + seed(&legacy, "auth/github_token.json", "token"); + + assert_eq!(move_config_dir(&legacy, ¤t), current.as_path()); + + assert_eq!( + std::fs::read_to_string(current.join("auth/github_token.json")).unwrap(), + "token" + ); + assert!(current.join(MIGRATED_MARKER).exists()); + } + #[test] fn copy_tree_reproduces_nested_contents() { let root = scratch("copy"); diff --git a/src/scan.rs b/src/scan.rs index 6b0f0a2..755190d 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -276,15 +276,6 @@ fn default_unix_dirs() -> Vec { dirs } -/// Whether `path` sits under the shared install root, where every program -/// Colony installs lives. The installed-apps state is the only record of what -/// Colony put on disk; a hard-coded list of directories went stale the moment -/// the install root moved. -#[cfg(not(windows))] -fn is_colony_app(path: &Path) -> bool { - crate::persistence::colony_apps_dir().is_ok_and(|apps| path.starts_with(apps)) -} - fn expand_env_vars(value: &str) -> String { let mut output = String::new(); let mut chars = value.chars().peekable(); @@ -608,18 +599,13 @@ fn parse_desktop_file(path: &Path) -> Result { let name = name.ok_or_else(|| anyhow::anyhow!("No name found"))?; let exec = exec.ok_or_else(|| anyhow::anyhow!("No exec found"))?; - let origin = if is_colony_app(path) { - AppOrigin::Colony - } else { - AppOrigin::External - }; - Ok(Application { name, exec, icon, category: categorize_linux_app(&categories), - origin, + // The entries Colony writes for its own apps were skipped above. + origin: AppOrigin::External, }) } @@ -711,18 +697,12 @@ fn parse_macos_app(path: &Path) -> Option { let category = categorize_macos_app(&name); - let origin = if is_colony_app(path) { - AppOrigin::Colony - } else { - AppOrigin::External - }; - Some(Application { name, exec, icon: None, category, - origin, + origin: AppOrigin::External, }) }