Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

</td></tr>
</table>
Expand Down Expand Up @@ -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
Expand Down
23 changes: 20 additions & 3 deletions crates/hydra-gui/src/autostart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,26 @@ fn entry_path() -> Option<PathBuf> {
}

/// 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;

Expand Down Expand Up @@ -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,
Expand Down
96 changes: 96 additions & 0 deletions crates/hydra-gui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<I: IntoIterator<Item = OsString>>(args: I) -> Result<Option<PathBuf>, 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<PathBuf> {
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 —
Expand Down Expand Up @@ -449,3 +506,42 @@ fn native_menu_events() -> impl iced::futures::Stream<Item = String> {
}
})
}

#[cfg(test)]
mod tests {
use super::config_dir_arg;
use std::ffi::OsString;
use std::path::PathBuf;

fn parse(argv: &[&str]) -> Result<Option<PathBuf>, 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));
}
}
38 changes: 33 additions & 5 deletions crates/hydra-gui/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf> = 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()
Expand Down
14 changes: 14 additions & 0 deletions crates/hydra-gui/src/nmhost.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(|| {
Expand Down
8 changes: 8 additions & 0 deletions crates/hydra-gui/src/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading