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..f901e2b 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,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/Colony/categories.json` +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/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..0829cca 100644 --- a/src/config.rs +++ b/src/config.rs @@ -12,9 +12,13 @@ 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, `~/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. +/// 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 +30,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 +76,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..39518d5 100644 --- a/src/persistence.rs +++ b/src/persistence.rs @@ -5,98 +5,254 @@ 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. - if let (Some(legacy_root), Ok(current)) = ( - dirs::config_dir(), + let (Ok(config), Ok(cache)) = ( colony_ui::paths::locate::config_dir(PROGRAM), - ) { - relocate( - &legacy_root.join("Colony").join(PROGRAM), - ¤t, - "config directory", + 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), - ) { - relocate(&config.join("cache"), &cache, "cache"); - 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`. -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() ); + 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)? { @@ -105,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)?; } } @@ -670,11 +826,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 +843,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 +864,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 +879,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 +1052,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 +1089,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 +1113,177 @@ mod path_migration_tests { let _ = std::fs::remove_dir_all(&root); } + /// 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)] + 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 (from, to) = (root.path().join("locked/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"); + + 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!( + !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"); + } + + /// 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"); @@ -983,8 +1322,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 +1383,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..755190d 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,58 +276,6 @@ 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 -} - -#[cfg(not(windows))] -fn is_colony_app(path: &Path) -> bool { - colony_application_dirs() - .iter() - .any(|dir| path.starts_with(dir)) -} - fn expand_env_vars(value: &str) -> String { let mut output = String::new(); let mut chars = value.chars().peekable(); @@ -663,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, }) } @@ -766,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, }) } 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");