From bf0c34df7a086195552ef7f25a8ad944405a03a9 Mon Sep 17 00:00:00 2001 From: Masiha Date: Sun, 6 Sep 2026 22:42:29 +0330 Subject: [PATCH] feat(gui): run from a custom application directory with --config --- README.md | 19 ++++++ crates/hydra-gui/src/autostart.rs | 23 +++++++- crates/hydra-gui/src/main.rs | 96 +++++++++++++++++++++++++++++++ crates/hydra-gui/src/model.rs | 38 ++++++++++-- crates/hydra-gui/src/nmhost.rs | 14 +++++ crates/hydra-gui/src/update.rs | 8 +++ 6 files changed, 190 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 99c2d2c3..23525835 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ - [CLI Compatibility (`wget` / `curl` Mode)](#cli-compatibility-wget--curl-mode) - [Interactive Queue Manager (TUI)](#interactive-queue-manager-tui) - [Remote Checksum Lookup & Verification](#remote-checksum-lookup--verification) + - [Portable GUI Profile](#portable-gui-profile) - [Benchmark](#benchmark) - [A fair 100 ms path](#a-fair-100-ms-path) - [Four public mirrors](#four-public-mirrors) @@ -94,6 +95,7 @@ - **Browser Integration** — Chrome, Edge, Firefox, and Safari extensions hand off downloads - **Queue & Scheduler** — scheduled start/stop times with retry tracking - **Desktop Niceties** — tray icon, sounds, launch-on-startup, localized UI +- **Portable Profile** — `hydra-gui --config ./here` keeps settings, downloads list and logs in that directory @@ -536,6 +538,23 @@ hydra checksum https://example.com/release.tar.gz hydra --checksum sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 https://example.com/file.tar.gz ``` +### Portable GUI Profile + +The desktop app keeps `config.toml`, its download list (`state.redb`), `logs/` and `locales/` in +`~/.config/hydra` (Linux/macOS) or `%APPDATA%\hydra` (Windows). `--config` points all of it +somewhere else — a USB stick, a project folder, a second profile: + +```bash +hydra-gui --config ./here +``` + +The path may be relative (it is resolved against the working directory at launch) and is created +if missing. Such an instance is fully independent: it has its own download list and its own +single-instance lock, so it runs alongside an ordinary Hydra. Two per-user registrations stay +with the ordinary install and are left untouched — the login item ("launch on startup") and the +browser native-messaging host, neither of which can carry the flag. The browser extension still +reaches a running portable instance over its WebSocket port. + --- ## Benchmark diff --git a/crates/hydra-gui/src/autostart.rs b/crates/hydra-gui/src/autostart.rs index b178824d..59ef5c64 100644 --- a/crates/hydra-gui/src/autostart.rs +++ b/crates/hydra-gui/src/autostart.rs @@ -84,8 +84,26 @@ fn entry_path() -> Option { } /// Sync the login item with the settings. Safe to call every save. -#[cfg(target_os = "windows")] +/// +/// A `--config DIR` instance leaves the login item alone. There is exactly +/// one per user and the ordinary install owns it, while a fresh profile's +/// settings have "launch on startup" ON — so a portable copy would seize it +/// on its very first run, and every logon after that would start the +/// portable copy, on the portable download list, instead of the installed +/// one. Same reasoning as the browser registration in `nmhost`. pub fn apply(enabled: bool, minimized: bool) { + if let Some(dir) = crate::model::app_dir_override() { + crate::log::info(&format!( + "login item: --config {} — left to the default profile", + dir.display() + )); + return; + } + apply_platform(enabled, minimized); +} + +#[cfg(target_os = "windows")] +fn apply_platform(enabled: bool, minimized: bool) { use winreg::enums::HKEY_CURRENT_USER; use winreg::RegKey; @@ -135,9 +153,8 @@ pub fn apply(enabled: bool, minimized: bool) { } } -/// Sync the login item with the settings. Safe to call every save. #[cfg(not(target_os = "windows"))] -pub fn apply(enabled: bool, minimized: bool) { +fn apply_platform(enabled: bool, minimized: bool) { let Some(path) = entry_path() else { return }; if !enabled { // On Linux the deb/rpm packages ship /etc/xdg/autostart/hydra.desktop, diff --git a/crates/hydra-gui/src/main.rs b/crates/hydra-gui/src/main.rs index 14145e96..53dfebea 100644 --- a/crates/hydra-gui/src/main.rs +++ b/crates/hydra-gui/src/main.rs @@ -39,8 +39,65 @@ mod windows; use app::{App, Message, WinKind}; use iced::{window, Subscription, Task, Theme}; +use std::ffi::OsString; +use std::path::PathBuf; + +/// The directory named by `--config DIR` (or `--config=DIR`), as written on +/// the command line. `Err` carries the line to print before exiting: a +/// misspelt profile path must not fall back to the default one and silently +/// run against the wrong download list. +fn config_dir_arg>(args: I) -> Result, String> { + // skip(1): argv[0] is the executable, and a portable install may well + // have put the word "--config" in its path. + let mut args = args.into_iter().skip(1); + while let Some(arg) = args.next() { + let value = if arg == *"--config" { + args.next() + .ok_or_else(|| "--config needs a directory".to_string())? + } else if let Some(rest) = arg.to_str().and_then(|a| a.strip_prefix("--config=")) { + OsString::from(rest) + } else { + continue; + }; + if value.is_empty() { + return Err("--config needs a directory".into()); + } + return Ok(Some(PathBuf::from(value))); + } + Ok(None) +} + +/// Make `dir` usable as the application directory: absolute (the login item +/// and the update finisher relaunch this process from an unrelated working +/// directory, so a relative `./profile` has to be pinned down now) and +/// present on disk. +fn prepare_app_dir(dir: PathBuf) -> std::io::Result { + let dir = std::path::absolute(dir)?; + std::fs::create_dir_all(&dir)?; + Ok(dir) +} fn main() -> iced::Result { + // `--config DIR` moves config.toml, the state db, logs and locales into + // DIR, so a portable install keeps its profile beside itself. It is + // resolved before anything else: the single-instance probe below already + // reads ipc.json out of the application directory, and two profiles are + // two independent instances. + match config_dir_arg(std::env::args_os()) { + Ok(Some(dir)) => match prepare_app_dir(dir) { + Ok(dir) => model::set_app_dir(dir), + Err(e) => { + eprintln!("hydra-gui: --config: {e}"); + std::process::exit(1); + } + }, + Ok(None) => {} + Err(msg) => { + eprintln!("hydra-gui: {msg}"); + std::process::exit(2); + } + } + // Single instance: if a running instance answers on the extbus // socket, hand it the spotlight (it opens its main window) and leave. // Two instances would fight over state.redb, the tray, and ipc.json — @@ -449,3 +506,42 @@ fn native_menu_events() -> impl iced::futures::Stream { } }) } + +#[cfg(test)] +mod tests { + use super::config_dir_arg; + use std::ffi::OsString; + use std::path::PathBuf; + + fn parse(argv: &[&str]) -> Result, String> { + config_dir_arg(argv.iter().map(OsString::from)) + } + + #[test] + fn no_flag_means_the_platform_directory() { + assert_eq!(parse(&["hydra-gui", "--minimized"]), Ok(None)); + } + + #[test] + fn both_spellings_are_accepted() { + let want = Ok(Some(PathBuf::from("./here"))); + assert_eq!(parse(&["hydra-gui", "--config", "./here"]), want); + assert_eq!(parse(&["hydra-gui", "--config=./here"]), want); + assert_eq!( + parse(&["hydra-gui", "--minimized", "--config", "./here"]), + want + ); + } + + #[test] + fn a_missing_or_empty_directory_is_an_error() { + assert!(parse(&["hydra-gui", "--config"]).is_err()); + assert!(parse(&["hydra-gui", "--config", ""]).is_err()); + assert!(parse(&["hydra-gui", "--config="]).is_err()); + } + + #[test] + fn the_executable_path_is_never_read_as_a_flag() { + assert_eq!(parse(&["/opt/--config=oops/hydra-gui"]), Ok(None)); + } +} diff --git a/crates/hydra-gui/src/model.rs b/crates/hydra-gui/src/model.rs index 1385cc56..9035e33e 100644 --- a/crates/hydra-gui/src/model.rs +++ b/crates/hydra-gui/src/model.rs @@ -766,14 +766,42 @@ pub fn pick_queue_color(existing: &[QueueDef]) -> u32 { pool[seed % pool.len()] } -/// The application directory holding `config.toml`, `gui-state.json`, +/// The `--config DIR` the app was started with, absolute, when one was +/// given. Written once in `main` before anything reads [`app_dir`]. +static APP_DIR_OVERRIDE: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Run out of `dir` instead of the platform application directory. Called +/// from `main` for `--config DIR`, before the first [`app_dir`] read; a +/// second call is ignored, since half the process would already be pointing +/// at the first answer. +pub fn set_app_dir(dir: PathBuf) { + let _ = APP_DIR_OVERRIDE.set(dir); +} + +/// The `--config DIR` in force, if any. +/// +/// Callers that hand this instance's identity to some OTHER process — the +/// login item that relaunches it, the native-messaging host the browser +/// spawns — have to know the directory is not the one that process would +/// otherwise assume. +pub fn app_dir_override() -> Option<&'static std::path::Path> { + APP_DIR_OVERRIDE.get().map(|p| p.as_path()) +} + +/// The application directory holding `config.toml`, `state.redb`, /// `locales/` and `logs/`. /// -/// Deliberately NOT `dirs::config_dir()` everywhere: on macOS that resolves to -/// `~/Library/Application Support`, and hydra's convention (shared with the -/// CLI) is `~/.config/hydra` on both Linux and macOS. Windows uses -/// `%APPDATA%\hydra` (`Users\{user}\AppData\Roaming\hydra`). +/// `--config DIR` moves all of it, so a portable install (a USB stick, a +/// second profile) keeps its settings and download list beside itself. +/// Without the flag: deliberately NOT `dirs::config_dir()` everywhere — on +/// macOS that resolves to `~/Library/Application Support`, and hydra's +/// convention (shared with the CLI) is `~/.config/hydra` on both Linux and +/// macOS. Windows uses `%APPDATA%\hydra` +/// (`Users\{user}\AppData\Roaming\hydra`). pub fn app_dir() -> PathBuf { + if let Some(dir) = APP_DIR_OVERRIDE.get() { + return dir.clone(); + } #[cfg(target_os = "windows")] { dirs::config_dir() diff --git a/crates/hydra-gui/src/nmhost.rs b/crates/hydra-gui/src/nmhost.rs index 36224805..a50f67b3 100644 --- a/crates/hydra-gui/src/nmhost.rs +++ b/crates/hydra-gui/src/nmhost.rs @@ -282,6 +282,20 @@ fn register_windows(host: &Path) { /// Runs off the UI thread; failures are logged and otherwise ignored, since /// the WebSocket transport still works whenever the app is already running. pub fn ensure_registered() { + // A `--config DIR` instance registers nothing. The manifest is + // machine-wide per user and carries no arguments, so `hydra-host` always + // reads ipc.json from the DEFAULT application directory: registering + // here would point every browser at a host that talks to the ordinary + // install (or to nothing at all), and overwrite that install's + // registration on the way. The WebSocket transport still reaches this + // instance while it is running. + if let Some(dir) = crate::model::app_dir_override() { + crate::log::info(&format!( + "nmhost: --config {} — browser registration left to the default profile", + dir.display() + )); + return; + } std::thread::Builder::new() .name("nmhost-register".into()) .spawn(|| { diff --git a/crates/hydra-gui/src/update.rs b/crates/hydra-gui/src/update.rs index 0553e1dc..596ac47d 100644 --- a/crates/hydra-gui/src/update.rs +++ b/crates/hydra-gui/src/update.rs @@ -293,6 +293,14 @@ async fn drive( } (None, None) => return Err(std::io::Error::other("nothing was extracted to install")), } + // The finisher restarts us; a `--config DIR` instance has to come back + // on the same profile rather than on the default one. + if let Some(dir) = crate::model::app_dir_override() { + cmd.arg("--relaunch-arg") + .arg("--config") + .arg("--relaunch-arg") + .arg(dir); + } #[cfg(target_os = "windows")] { use std::os::windows::process::CommandExt;