From 0eb2b2b09a99b644da27393d864d0186e2b5a4d0 Mon Sep 17 00:00:00 2001 From: Kacper Paczos Date: Tue, 25 Aug 2026 18:35:19 +0200 Subject: [PATCH 1/3] feat: add a box command to the host terminal, with a host/box chooser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BoxBuddy could export an application to the host menu, but not a command to the host terminal, and a command that exists both on the host and in a box could only shadow one with the other. The Exported Binaries section gains an "Add Command…" button. The command is resolved inside the box first; if nothing on the host answers to that name it is a plain `distrobox-export --bin`, exactly as before. When the host already has one - its own binary, an earlier distrobox export, or a chooser BoxBuddy wrote before - the dialog offers to replace it with a chooser: a small self-contained bash script in ~/.local/bin that asks, at invocation time, which one to run. The chooser prints a numbered menu on stderr and reads the answer from /dev/tty, so it never eats the program's own stdin. Scripts and pipes must not block on a prompt, so with no terminal it runs the first target silently, and BOXBUDDY_DISPATCH=host| picks one outright. Adding the same command from another box folds that box into the existing chooser rather than replacing it. The file carries distrobox's own `# distrobox_binary` and `# name: ` markers next to BoxBuddy's, so `distrobox-export --list-binaries` still finds the command and `--delete` still removes it; without them distrobox would quietly lose sight of a command it had exported. The binaries list skips entries that are choosers so they are not listed twice. Nothing else knows about the chooser: the file is the whole state, and it keeps working if BoxBuddy is uninstalled. --- src/distrobox_handler.rs | 703 +++++++++++++++++++++++++++++++++++++++ src/main.rs | 300 ++++++++++++++++- 2 files changed, 997 insertions(+), 6 deletions(-) diff --git a/src/distrobox_handler.rs b/src/distrobox_handler.rs index 22c6c82..634a1c2 100644 --- a/src/distrobox_handler.rs +++ b/src/distrobox_handler.rs @@ -684,6 +684,420 @@ pub fn remove_exported_binary_from_box(box_name: &str, binary: &str) { ); } +/// Exports `bin_path` from inside `box_name` to the host terminal's +/// `~/.local/bin` via `distrobox-export --bin`. Without an explicit +/// `--export-path`, distrobox defaults to +/// `${DISTROBOX_EXPORT_PATH:-${host_home}/.local/bin}` which is what the +/// rest of BoxBuddy assumes. Passing the literal string `~/.local/bin` +/// would not work because no shell expands it. +pub fn export_binary_from_box(box_name: &str, bin_path: &str) { + let _ = run_command( + "distrobox", + Some(&[ + "enter", + box_name, + "--", + "distrobox-export", + "--bin", + bin_path, + ]), + ); +} + +/// Resolves `name` inside `box_name` via `distrobox enter … bash -lc 'command -v +/// -- '`. Returns the trimmed absolute path when the box has it, None +/// otherwise. Used by the "Add Command to Terminal" UI to confirm the box +/// really has the command before asking anything about the host side. +pub fn box_command_path(box_name: &str, name: &str) -> Option { + if !valid_command_name(name) { + return None; + } + let script = format!("command -v -- {}", name); + let out = get_command_output( + "distrobox", + Some(&["enter", box_name, "--", "bash", "-lc", &script]), + ); + let trimmed = out.trim(); + if trimmed.starts_with('/') { + Some(trimmed.to_string()) + } else { + None + } +} + +/// Description of what the host already has on the path for a given command +/// name. The "Add Command to Terminal" dialog needs to know whether to plain- +/// export (nothing in the way), warn the user (a host binary with the same +/// name) or fold the new box into an existing dispatcher. +pub struct HostCommandState { + /// Other host-side paths matching the name. Does NOT include + /// `$HOME/.local/bin/` when that file is a dispatcher or a + /// distrobox-export wrapper, because the chooser is about to replace it. + pub host_paths: Vec, + /// Box name extracted from a distrobox-export wrapper sitting at + /// `$HOME/.local/bin/`, if any. + pub wrapper_box: Option, + /// `(host, boxes)` parsed from a BoxBuddy dispatcher at + /// `$HOME/.local/bin/`, if any. + pub dispatcher: Option<(Option, Vec)>, +} + +/// Looks at the host side for a command called `name`. Sees whether the host +/// has its own `name` binary, a distrobox wrapper sitting in +/// `$HOME/.local/bin/` or an existing BoxBuddy dispatcher. Parsing the +/// wrapper is defensive: if no box name can be extracted the file is treated +/// as a plain host binary (wrapper_box stays None and the local path is +/// included in host_paths). +pub fn host_command_conflicts(name: &str) -> HostCommandState { + let mut state = HostCommandState { + host_paths: vec![], + wrapper_box: None, + dispatcher: None, + }; + if !valid_command_name(name) { + return state; + } + + // One login shell answers everything: where $HOME is, what the user's PATH + // resolves the name to, whether ~/.local/bin already holds a file of that + // name, and what is in it. Sections are split by a form-feed line, which + // no path or script line contains. + // Each separator carries its own leading newline: a section that produces + // no output (no match on PATH, no local file) would otherwise not end in a + // newline and the sections would shift by one. + let probe = format!( + "printf '%s' \"$HOME\"; printf '\\n\\f\\n'; \ + type -aP -- {name} 2>/dev/null; printf '\\n\\f\\n'; \ + test -e \"$HOME/.local/bin/{name}\" && echo yes; printf '\\n\\f\\n'; \ + cat -- \"$HOME/.local/bin/{name}\" 2>/dev/null" + ); + let out = get_command_output("bash", Some(&["-lc", &probe])); + let mut parts = out.split("\n\u{c}\n"); + let home = parts.next().unwrap_or_default().trim().to_string(); + let paths = parts.next().unwrap_or_default(); + let local_present = !parts.next().unwrap_or_default().trim().is_empty(); + let local_file = parts.next().unwrap_or_default(); + + let local_path = format!("{home}/.local/bin/{name}"); + let is_dispatcher = local_file + .lines() + .any(|l| l.starts_with("# boxbuddy-dispatcher:")); + let is_wrapper = !is_dispatcher && local_file.contains("# distrobox_binary"); + + if is_dispatcher { + state.dispatcher = parse_dispatcher_marker(local_file); + } else if is_wrapper { + state.wrapper_box = parse_distrobox_wrapper_box(local_file); + } + + // What the chooser is about to replace is described by the dispatcher / + // wrapper_box fields, so it must not also be listed as a rival host + // binary. Everything else the shell resolves is a real clash. + let replacing_local = is_dispatcher || state.wrapper_box.is_some(); + for p in paths.lines().map(str::trim).filter(|l| !l.is_empty()) { + if replacing_local && p == local_path { + continue; + } + state.host_paths.push(p.to_string()); + } + + // `type -aP` only sees what is on PATH. With ~/.local/bin missing from it, + // an existing file there would go unnoticed and a plain export would + // silently overwrite it. + if local_present && !replacing_local && !state.host_paths.contains(&local_path) { + state.host_paths.push(local_path); + } + + state +} + +/// Pulls the box name out of a `distrobox-export --bin` wrapper script. +/// Tries, in order: (a) the `# name: ` comment distrobox itself +/// writes, then (b) the bare `-n ` argument on the +/// `distrobox-enter` exec line. Returns None if neither matches; the caller +/// then treats the file as a plain host binary. +fn parse_distrobox_wrapper_box(content: &str) -> Option { + // (a) The `# name: ` comment distrobox itself writes is the + // authoritative source. + for line in content.lines() { + if let Some(rest) = line.trim_start().strip_prefix("# name:") { + let name = rest.trim(); + if !name.is_empty() { + return Some(name.to_string()); + } + } + } + + // (b) Failing that, the bare `-n ` argument on the + // distrobox-enter exec line (space-separated, NOT `--name`). + for line in content.lines() { + if !line.contains("distrobox-enter") { + continue; + } + let mut tokens = line.split_whitespace(); + while let Some(tok) = tokens.next() { + if tok == "-n" { + if let Some(name) = tokens.next() { + if !name.is_empty() { + return Some(name.to_string()); + } + } + } + } + } + + None +} + +/// Validates a command name the user typed in the "Add Command" entry. Names +/// must be non-empty, contain only `[A-Za-z0-9._+-]` and not start with `-` +/// or `.`. Used by the UI before anything else to keep shell-escaping +/// corners closed. +pub fn valid_command_name(name: &str) -> bool { + if name.is_empty() { + return false; + } + let mut chars = name.chars(); + let first = chars.next().unwrap(); + if first == '-' || first == '.' { + return false; + } + name.chars() + .all(|c| matches!(c, 'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '_' | '+' | '-')) +} + +/// Builds the bash source for a dispatcher. Pure - no I/O - so the same body +/// can be parsed by `parse_dispatcher_marker`, written to disk by +/// `write_dispatcher` and inspected by tests. +/// +/// Behaviour: +/// * `BOXBUDDY_DISPATCH=host` runs the host binary (errors if no host). +/// * `BOXBUDDY_DISPATCH=` runs that box via `distrobox enter`. +/// * Any other env value errors out without prompting. +/// * When stderr is a tty and `/dev/tty` is readable, the user gets a +/// numbered menu on stderr and `read` happens from `/dev/tty`. +/// * Otherwise the first target (host when present, else first box) runs +/// without asking. +/// +/// A host path containing whitespace cannot round-trip through the marker +/// line (tokens are whitespace-separated); runtime quoting of the +/// dispatched command is unaffected. +pub fn dispatcher_script(name: &str, host: Option<&str>, boxes: &[String]) -> String { + // One `# name:` line per box, so `distrobox-export --list-binaries` run in + // any of the target boxes still finds this command, and + // `distrobox-export --bin … --delete` still recognises the file. Without + // them distrobox quietly loses sight of a command it had exported. + let mut markers = String::from("# distrobox_binary\n"); + for b in boxes { + markers.push_str(&format!("# name: {b}\n")); + } + markers.push_str(&format!( + "# boxbuddy-dispatcher: command={} host={} boxes={}", + name, + host.unwrap_or(""), + boxes.join(",") + )); + + let boxes_arr = boxes + .iter() + .map(|b| bash_quote(b)) + .collect::>() + .join(" "); + + DISPATCHER_TEMPLATE + .replace("@MARKERS@", &markers) + .replace( + "@HOST@", + &host.map(bash_quote).unwrap_or_else(|| "''".into()), + ) + .replace("@BOXES@", &boxes_arr) + .replace("@NAME@", &bash_quote(name)) +} + +/// The dispatcher itself. Kept as one template rather than assembled line by +/// line: every generated file then has the same shape whatever the targets +/// are, so reading one tells you how all of them behave. `host` is the first +/// entry when it is set; a box literally named `host` would be shadowed by it. +const DISPATCHER_TEMPLATE: &str = r#"#!/usr/bin/env bash +@MARKERS@ +# BoxBuddy regenerates this file; manual edits will be lost. +HOST=@HOST@ +BOXES=(@BOXES@) +NAME=@NAME@ + +TARGETS=() +[ -n "$HOST" ] && TARGETS+=("host") +TARGETS+=("${BOXES[@]}") + +run_target() { + target=$1 + shift + if [ "$target" = "host" ]; then + exec "$HOST" "$@" + fi + exec distrobox enter "$target" -- "$NAME" "$@" +} + +if [ ${#TARGETS[@]} -eq 0 ]; then + echo "$NAME: no targets configured" >&2 + exit 2 +fi + +# Scripts and pipes must never block on a prompt: BOXBUDDY_DISPATCH picks a +# target outright, and with no terminal the first target is used silently. +if [ -n "${BOXBUDDY_DISPATCH:-}" ]; then + for t in "${TARGETS[@]}"; do + [ "$BOXBUDDY_DISPATCH" = "$t" ] && run_target "$t" "$@" + done + echo "$NAME: unknown BOXBUDDY_DISPATCH target: $BOXBUDDY_DISPATCH" >&2 + exit 2 +fi + +INDEX=1 +if [ -t 2 ] && [ -r /dev/tty ]; then + echo "Run $NAME with:" >&2 + i=1 + for t in "${TARGETS[@]}"; do + if [ "$t" = "host" ]; then + echo " $i: host ($HOST)" >&2 + else + echo " $i: $t" >&2 + fi + i=$((i + 1)) + done + printf 'Run %s from [1]: ' "$NAME" >&2 + read -r CHOICE < /dev/tty || CHOICE=1 + [ -n "$CHOICE" ] || CHOICE=1 + case $CHOICE in + '' | *[!0-9]*) + echo "$NAME: invalid choice: $CHOICE" >&2 + exit 2 + ;; + esac + INDEX=$CHOICE +fi + +if [ "$INDEX" -lt 1 ] || [ "$INDEX" -gt ${#TARGETS[@]} ]; then + echo "$NAME: choice out of range" >&2 + exit 2 +fi +run_target "${TARGETS[$((INDEX - 1))]}" "$@" +"#; + +/// Single-quote-safe bash quoting. A name like `it's` becomes `'it'\''s'`, +/// which is safe to embed between `()` in `BOXES=(...)` and between `=` in +/// `HOST=...`. +fn bash_quote(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('\''); + for c in s.chars() { + if c == '\'' { + out.push_str("'\\''"); + } else { + out.push(c); + } + } + out.push('\''); + out +} + +/// Parses the marker line emitted by `dispatcher_script`. Returns `(host, +/// boxes)` where `host` is None when the marker says `host=` (no host +/// target). Returns None when the content has no marker. +/// +/// A host path containing whitespace cannot round-trip through the marker +/// line (tokens are whitespace-separated); runtime quoting of the +/// dispatched command is unaffected. +pub fn parse_dispatcher_marker(content: &str) -> Option<(Option, Vec)> { + for line in content.lines() { + let Some(rest) = line.strip_prefix("# boxbuddy-dispatcher:") else { + continue; + }; + let mut host: Option = None; + let mut boxes: Vec = Vec::new(); + for token in rest.split_whitespace() { + if let Some(h) = token.strip_prefix("host=") { + host = if h.is_empty() { + None + } else { + Some(h.to_string()) + }; + } else if let Some(b) = token.strip_prefix("boxes=") { + if !b.is_empty() { + boxes = b.split(',').map(String::from).collect(); + } + } + } + return Some((host, boxes)); + } + None +} + +/// Writes the dispatcher to `$HOME/.local/bin/` on the host via +/// `run_command`. The body is passed through a quoted heredoc so bash does +/// not touch it; the heredoc tag (`BBDISPATCH`) cannot appear inside a +/// validated command name nor inside any of the lines `dispatcher_script` +/// emits. +pub fn write_dispatcher(name: &str, host: Option<&str>, boxes: &[String]) { + if !valid_command_name(name) { + return; + } + let content = dispatcher_script(name, host, boxes); + let mut script = String::new(); + script.push_str("mkdir -p \"$HOME/.local/bin\" && cat > \"$HOME/.local/bin/"); + script.push_str(name); + script.push_str("\" <<'BBDISPATCH'\n"); + script.push_str(&content); + script.push_str("BBDISPATCH\n"); + script.push_str("chmod +x \"$HOME/.local/bin/"); + script.push_str(name); + script.push('"'); + let _ = run_command("bash", Some(&["-c", &script])); +} + +/// Lists BoxBuddy dispatchers under `$HOME/.local/bin` whose marker points +/// at `box_name`. Returns `(command_name, host, boxes)` triples; the command +/// name is the file name, since that is what the shell resolves on PATH. +pub fn list_dispatchers_for_box(box_name: &str) -> Vec<(String, Option, Vec)> { + let script = "grep -sl '^# boxbuddy-dispatcher:' \"$HOME/.local/bin/\"* 2>/dev/null"; + let out = get_command_output("bash", Some(&["-c", script])); + let mut result = Vec::new(); + for line in out.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let content = get_command_output( + "bash", + Some(&["-c", &format!("cat -- {}", bash_quote(line))]), + ); + let Some((host, boxes)) = parse_dispatcher_marker(&content) else { + continue; + }; + if !boxes.iter().any(|b| b == box_name) { + continue; + } + let Some(cmd_name) = std::path::Path::new(line) + .file_name() + .and_then(|s| s.to_str()) + else { + continue; + }; + result.push((cmd_name.to_string(), host, boxes)); + } + result +} + +/// Deletes the dispatcher at `$HOME/.local/bin/`. A no-op when the +/// name fails validation; otherwise `rm -f` so the call is idempotent. +pub fn remove_dispatcher(name: &str) { + if !valid_command_name(name) { + return; + } + let script = format!("rm -f -- \"$HOME/.local/bin/{}\"", name); + let _ = run_command("bash", Some(&["-c", &script])); +} + pub fn stop_box(box_name: &str) { let _ = run_command("distrobox", Some(&["stop", box_name, "--yes"])); } @@ -936,4 +1350,293 @@ mod stream_tests { let _ = delete_box(name); assert!(exists, "streaming create did not produce a listable box"); } + + #[test] + fn dispatcher_script_round_trips_with_host_and_boxes() { + use super::{dispatcher_script, parse_dispatcher_marker}; + + let cases: Vec<(Option<&str>, Vec)> = vec![ + (Some("/usr/bin/claude"), vec![]), + (None, vec!["bx1".to_string()]), + ( + Some("/usr/bin/x"), + vec!["a".to_string(), "b".to_string(), "c".to_string()], + ), + ( + None, + vec!["a".to_string(), "b".to_string(), "c".to_string()], + ), + ]; + for (host, boxes) in cases { + let script = dispatcher_script("name", host, &boxes); + let parsed = parse_dispatcher_marker(&script); + assert!(parsed.is_some(), "parse failed for {:?}", (host, &boxes)); + let (h, b) = parsed.unwrap(); + assert_eq!(h.as_deref(), host, "host mismatch for {:?}", (host, &boxes)); + assert_eq!(b, boxes, "boxes mismatch for {:?}", (host, &boxes)); + } + } + + #[test] + fn dispatcher_script_round_trips_no_boxes() { + use super::{dispatcher_script, parse_dispatcher_marker}; + + let script = dispatcher_script("solo", Some("/usr/bin/solo"), &[]); + let (h, b) = parse_dispatcher_marker(&script).unwrap(); + assert_eq!(h, Some("/usr/bin/solo".to_string())); + assert!(b.is_empty()); + } + + #[test] + fn parse_dispatcher_marker_missing_returns_none() { + use super::parse_dispatcher_marker; + assert!(parse_dispatcher_marker("not a dispatcher\n").is_none()); + assert!(parse_dispatcher_marker("").is_none()); + assert!(parse_dispatcher_marker("# something else\n").is_none()); + } + + #[test] + fn parse_dispatcher_marker_empty_host_token_yields_none_host() { + use super::parse_dispatcher_marker; + let s = "# boxbuddy-dispatcher: command=x host= boxes=a,b\n"; + let (h, b) = parse_dispatcher_marker(s).unwrap(); + assert!(h.is_none()); + assert_eq!(b, vec!["a".to_string(), "b".to_string()]); + } + + #[test] + fn parse_dispatcher_marker_preserves_box_order() { + use super::parse_dispatcher_marker; + let s = "# boxbuddy-dispatcher: command=x host= boxes=c,a,b\n"; + let (_, b) = parse_dispatcher_marker(s).unwrap(); + assert_eq!(b, vec!["c".to_string(), "a".to_string(), "b".to_string()]); + } + + #[test] + fn valid_command_name_accepts_valid_names() { + use super::valid_command_name; + assert!(valid_command_name("claude")); + assert!(valid_command_name("my-tool2")); + assert!(valid_command_name("a.b+c")); + assert!(valid_command_name("x")); + assert!(valid_command_name("a_b")); + } + + #[test] + fn valid_command_name_rejects_invalid_names() { + use super::valid_command_name; + assert!(!valid_command_name("")); + assert!(!valid_command_name("has space")); + assert!(!valid_command_name("../x")); + assert!(!valid_command_name("-flag")); + assert!(!valid_command_name("a/b")); + assert!(!valid_command_name("a'b")); + assert!(!valid_command_name("a$b")); + assert!(!valid_command_name(".hidden")); + assert!(!valid_command_name("-")); + } + + /// Generates a dispatcher script, writes it to a temp file and asks + /// `bash -n` to confirm it parses. The dispatcher runs without any + /// external binaries in the no-op "no targets" branch, but the + /// richer scripts with host and boxes also have to be clean. + #[test] + fn dispatcher_script_is_bash_n_clean() { + use super::dispatcher_script; + use std::fs; + use std::process::Command; + + let variants: Vec<(Option<&str>, Vec)> = vec![ + (Some("/usr/bin/x"), vec!["a".to_string(), "b".to_string()]), + (None, vec!["a".to_string()]), + (Some("/usr/bin/x"), vec![]), + (None, vec![]), + ]; + for (host, boxes) in variants { + let script = dispatcher_script("name", host, &boxes); + let mut path = std::env::temp_dir(); + path.push(format!("boxbuddy_disp_{p}.sh", p = std::process::id())); + fs::write(&path, &script).unwrap(); + let status = Command::new("bash") + .args(["-n", path.to_str().unwrap()]) + .status() + .unwrap(); + assert!( + status.success(), + "bash -n failed for host={:?} boxes={:?}", + host, + boxes + ); + let _ = fs::remove_file(&path); + } + } + + /// Writes a dispatcher for host=None, boxes=["bx"] plus a fake + /// `distrobox` executable (a tiny sh script that logs "$@" to a file) + /// into a fresh temp dir, then runs the dispatcher with PATH + /// prepended by that temp dir and `BOXBUDDY_DISPATCH=bx`, stdin from + /// /dev/null. The fake distrobox log should record the exec args. + #[test] + fn dispatcher_runs_box_target_via_env() { + use super::dispatcher_script; + use std::fs; + use std::os::unix::fs::PermissionsExt; + use std::process::{Command, Stdio}; + + let tmp = + std::env::temp_dir().join(format!("boxbuddy_dispatch_box_{}", std::process::id())); + fs::create_dir_all(&tmp).unwrap(); + + let log_path = tmp.join("log"); + let fake_dx = tmp.join("distrobox"); + let fake_dx_content = format!( + "#!/bin/sh\necho \"$@\" >> '{}'\n", + log_path.to_str().unwrap() + ); + fs::write(&fake_dx, fake_dx_content).unwrap(); + let mut perm = fs::metadata(&fake_dx).unwrap().permissions(); + perm.set_mode(0o755); + fs::set_permissions(&fake_dx, perm).unwrap(); + + let dispatcher = tmp.join("dispatcher"); + let script = dispatcher_script("mycmd", None, &["bx".to_string()]); + fs::write(&dispatcher, &script).unwrap(); + let mut perm = fs::metadata(&dispatcher).unwrap().permissions(); + perm.set_mode(0o755); + fs::set_permissions(&dispatcher, perm).unwrap(); + + // Prepend the temp dir so the fake `distrobox` is found first; the + // system PATH must still be reachable because the dispatcher's + // `#!/usr/bin/env bash` shebang resolves bash through it. + let mut path_env = tmp.to_str().unwrap().to_string(); + if let Ok(existing) = std::env::var("PATH") { + path_env.push(':'); + path_env.push_str(&existing); + } + + let status = Command::new(&dispatcher) + .env("PATH", &path_env) + .env("BOXBUDDY_DISPATCH", "bx") + .stdin(Stdio::null()) + .status() + .unwrap(); + assert!( + status.success(), + "dispatcher exited with status: {:?}", + status.code() + ); + let log_content = fs::read_to_string(&log_path).unwrap(); + assert!( + log_content.contains("enter bx -- mycmd"), + "log was: {:?}", + log_content + ); + + let _ = fs::remove_dir_all(&tmp); + } + + /// Same shape as `dispatcher_runs_box_target_via_env`, but with a + /// host target instead of a box. The fake `hostbin` sh script logs + /// its args; the dispatcher should exec it directly when + /// `BOXBUDDY_DISPATCH=host`. We pass an extra arg through so the log + /// has something to assert against (with $@ empty the host binary + /// would log a blank line and prove nothing). + #[test] + fn dispatcher_runs_host_target_via_env() { + use super::dispatcher_script; + use std::fs; + use std::os::unix::fs::PermissionsExt; + use std::process::{Command, Stdio}; + + let tmp = + std::env::temp_dir().join(format!("boxbuddy_dispatch_host_{}", std::process::id())); + fs::create_dir_all(&tmp).unwrap(); + + let log_path = tmp.join("log"); + let host_bin = tmp.join("hostbin"); + let host_bin_content = format!( + "#!/bin/sh\necho \"$@\" >> '{}'\n", + log_path.to_str().unwrap() + ); + fs::write(&host_bin, host_bin_content).unwrap(); + let mut perm = fs::metadata(&host_bin).unwrap().permissions(); + perm.set_mode(0o755); + fs::set_permissions(&host_bin, perm).unwrap(); + + let host_path = host_bin.to_str().unwrap().to_string(); + let dispatcher = tmp.join("dispatcher"); + let script = dispatcher_script("mycmd", Some(&host_path), &[]); + fs::write(&dispatcher, &script).unwrap(); + let mut perm = fs::metadata(&dispatcher).unwrap().permissions(); + perm.set_mode(0o755); + fs::set_permissions(&dispatcher, perm).unwrap(); + + let status = Command::new(&dispatcher) + .env("BOXBUDDY_DISPATCH", "host") + .arg("caller-arg") + .stdin(Stdio::null()) + .status() + .unwrap(); + assert!( + status.success(), + "dispatcher exited with status: {:?}", + status.code() + ); + let log_content = fs::read_to_string(&log_path).unwrap(); + assert!( + log_content.contains("caller-arg"), + "log was: {:?}", + log_content + ); + + let _ = fs::remove_dir_all(&tmp); + } + + /// The real wrapper text `distrobox-export --bin` writes (as produced + /// from the template in `/usr/bin/distrobox-export`). The parser should + /// pull `mybox` out of the `# name:` comment. + #[test] + fn parse_distrobox_wrapper_box_reads_real_distrobox_export_wrapper() { + use super::parse_distrobox_wrapper_box; + let wrapper = r#"#!/bin/sh +# distrobox_binary +# name: mybox +if [ -z "${CONTAINER_ID}" ]; then + exec "/usr/bin/distrobox-enter" -n mybox -- '/usr/bin/tool' "$@" +elif [ -n "${CONTAINER_ID}" ] && [ "${CONTAINER_ID}" != "mybox" ]; then + exec distrobox-host-exec '/home/user/.local/bin/tool' "$@" +else + exec '/usr/bin/tool' "$@" +fi +"#; + assert_eq!( + parse_distrobox_wrapper_box(wrapper), + Some("mybox".to_string()) + ); + } + + /// Older wrappers don't carry the `# name:` comment; the parser should + /// fall back to the `-n ` argument on the distrobox-enter exec + /// line. + #[test] + fn parse_distrobox_wrapper_box_falls_back_to_dash_n_exec_line() { + use super::parse_distrobox_wrapper_box; + let wrapper = r#"#!/bin/sh +# distrobox_binary +exec "/usr/bin/distrobox-enter" -n mybox -- '/usr/bin/tool' "$@" +"#; + assert_eq!( + parse_distrobox_wrapper_box(wrapper), + Some("mybox".to_string()) + ); + } + + /// A script with neither marker is not a distrobox wrapper; the parser + /// must return None so the caller treats it as a plain host binary. + #[test] + fn parse_distrobox_wrapper_box_returns_none_for_plain_script() { + use super::parse_distrobox_wrapper_box; + let script = "#!/bin/sh\necho hi\n"; + assert_eq!(parse_distrobox_wrapper_box(script), None); + } } diff --git a/src/main.rs b/src/main.rs index a20908a..bdc71ef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,9 @@ use std::path::Path; use std::thread; use adw::{ - prelude::{ActionRowExt, MessageDialogExt, PreferencesGroupExt, PreferencesRowExt}, + prelude::{ + ActionRowExt, EntryRowExt, MessageDialogExt, PreferencesGroupExt, PreferencesRowExt, + }, ActionRow, Application, StyleManager, ToastOverlay, }; use gtk::{ @@ -17,11 +19,13 @@ use gtk::{ mod distrobox_handler; use distrobox_handler::{ - assemble_box, clone_box, create_box, create_box_streaming, delete_box, export_app_from_box, get_all_distroboxes, - get_apps_in_box, get_available_images_with_distro_name, get_binaries_exported_from_box, - get_number_of_boxes, install_deb_in_box, install_rpm_in_box, open_terminal_in_box, - remove_app_from_host, remove_exported_binary_from_box, run_command_in_box, stop_box, - upgrade_all_boxes, upgrade_box, DBox, DBoxApp, + assemble_box, box_command_path, clone_box, create_box, create_box_streaming, delete_box, + export_app_from_box, export_binary_from_box, get_all_distroboxes, get_apps_in_box, + get_available_images_with_distro_name, get_binaries_exported_from_box, get_number_of_boxes, + host_command_conflicts, install_deb_in_box, install_rpm_in_box, list_dispatchers_for_box, + open_terminal_in_box, remove_app_from_host, remove_dispatcher, remove_exported_binary_from_box, + run_command_in_box, stop_box, upgrade_all_boxes, upgrade_box, valid_command_name, + write_dispatcher, DBox, DBoxApp, HostCommandState, }; mod utils; @@ -1273,6 +1277,7 @@ fn on_show_applications_clicked(window: &ApplicationWindow, box_name: String) { let (sender, receiver) = async_channel::bounded(1); let box_name_clone = box_name.clone(); + let win_for_async = window.clone(); gio::spawn_blocking(move || { let apps = get_apps_in_box(&box_name_clone); @@ -1406,12 +1411,28 @@ fn on_show_applications_clicked(window: &ApplicationWindow, box_name: String) { let bins_group = adw::PreferencesGroup::new(); bins_group.set_title(&gettext("Exported Binaries")); + //TRANSLATORS: Button Label + let add_cmd_btn = gtk::Button::with_label(&gettext("Add Command…")); + bins_group.set_header_suffix(Some(&add_cmd_btn)); + if binaries.is_empty() { //TRANSLATORS: Error Message bins_group.set_description(Some(&gettext("No Binaries Exported"))); } + // A chooser carries distrobox's own export + // markers so distrobox keeps recognising the + // command, which also means `--list-binaries` + // reports it. It gets its own row below, so skip + // it here rather than listing it twice. + let choosers = list_dispatchers_for_box(&box_name); for binary in binaries { + if choosers.iter().any(|(name, _, _)| { + std::path::Path::new(&binary).file_name() + == Some(std::ffi::OsStr::new(name)) + }) { + continue; + } let row = adw::ActionRow::new(); row.set_title(&markup_escape_text(&binary.to_string())); @@ -1430,6 +1451,25 @@ fn on_show_applications_clicked(window: &ApplicationWindow, box_name: String) { bins_group.add(&row); } + // BoxBuddy-managed dispatchers for this box. They + // sit in the same section as `distrobox-export` + // binaries because to the user they're just more + // commands available in the host terminal. + for (name, host, boxes) in choosers { + add_chooser_row(&bins_group, name, host, boxes); + } + + let bins_group_for_add = bins_group.clone(); + let box_name_for_add = box_name.clone(); + let win_for_add = win_for_async.clone(); + add_cmd_btn.connect_clicked(move |_btn| { + on_add_command_clicked( + &win_for_add, + box_name_for_add.clone(), + bins_group_for_add.clone(), + ); + }); + scroll_area.append(&bins_group); } } @@ -1460,6 +1500,254 @@ fn run_app_in_box(app: &DBoxApp, box_name: &str) { run_command_in_box(&app.exec_name, box_name); } +/// Appends one row representing a BoxBuddy dispatcher to `bins_group`. +/// Subtitle lists the targets (host first as the literal "host" when +/// present, then the box names). The Remove button deletes the dispatcher +/// for ALL its targets at once - that consequence goes in the tooltip, +/// not the subtitle, so the row stays short. +fn add_chooser_row( + bins_group: &adw::PreferencesGroup, + name: String, + host: Option, + boxes: Vec, +) { + let row = adw::ActionRow::new(); + row.set_title(&markup_escape_text(&name)); + + let mut targets: Vec = Vec::new(); + if host.is_some() { + targets.push("host".to_string()); + } + targets.extend(boxes.iter().cloned()); + //TRANSLATORS: Subtitle for chooser row - {} replaced with comma-separated targets + let subtitle = gettext(&format!("Chooser: {}", targets.join(", "))); + row.set_subtitle(&subtitle); + //TRANSLATORS: Tooltip for chooser row explaining removing affects all targets + row.set_tooltip_text(Some(&gettext( + "Removing deletes this chooser for all its targets.", + ))); + + //TRANSLATORS: Button Label + let remove_btn = gtk::Button::with_label(&gettext("Remove")); + remove_btn.set_valign(Align::Center); + + let row_clone = row.clone(); + remove_btn.connect_clicked(move |btn| { + remove_dispatcher(&name); + row_clone.set_title("Removed!"); + btn.set_sensitive(false); + }); + row.add_suffix(&remove_btn); + bins_group.add(&row); +} + +/// "Add Command to Terminal" flow. Opens the name-entry dialog, asks +/// the box what the path is, looks at the host for clashes, and either +/// plain-exports the binary or asks whether to overwrite the host side +/// with a BoxBuddy dispatcher. +fn on_add_command_clicked( + window: &ApplicationWindow, + box_name: String, + bins_group: adw::PreferencesGroup, +) { + let name_dialog = adw::MessageDialog::new( + Some(window), + //TRANSLATORS: Popup Heading + Some(&gettext("Add Command to Terminal")), + //TRANSLATORS: Popup Body + Some(&gettext( + "Make a command from this box available in the host terminal.", + )), + ); + name_dialog.set_transient_for(Some(window)); + + let entry_row = adw::EntryRow::new(); + //TRANSLATORS: Entry Label - command name to export + entry_row.set_title(&gettext("Command")); + entry_row.set_activates_default(true); + + let prefs_group = adw::PreferencesGroup::new(); + prefs_group.add(&entry_row); + name_dialog.set_extra_child(Some(&prefs_group)); + + //TRANSLATORS: Button Label + name_dialog.add_response("cancel", &gettext("Cancel")); + //TRANSLATORS: Button Label + name_dialog.add_response("add", &gettext("Add")); + name_dialog.set_response_appearance("add", adw::ResponseAppearance::Suggested); + name_dialog.set_default_response(Some("add")); + name_dialog.set_close_response("cancel"); + + let win_clone = window.clone(); + let box_name_clone = box_name.clone(); + let bins_group_clone = bins_group.clone(); + let entry_row_clone = entry_row.clone(); + name_dialog.connect_response(None, move |_d, res| { + if res != "add" { + return; + } + let name = entry_row_clone.text().to_string(); + if !valid_command_name(&name) { + // Silent no-op for invalid input; the entry is right there for + // the user to fix. + return; + } + let bin_path = match box_command_path(&box_name_clone, &name) { + Some(p) => p, + None => { + let nf = adw::MessageDialog::new( + Some(&win_clone), + //TRANSLATORS: Popup Heading + Some(&gettext("Not Found")), + //TRANSLATORS: Popup Body - {} replaced with command name and box name + Some(&gettext(&format!( + "{} was not found in {}", + name, box_name_clone + ))), + ); + nf.set_transient_for(Some(&win_clone)); + //TRANSLATORS: Button Label + nf.add_response("ok", &gettext("Ok")); + nf.set_default_response(Some("ok")); + nf.set_close_response("ok"); + nf.present(); + return; + } + }; + + let host_state = host_command_conflicts(&name); + let has_clash = !host_state.host_paths.is_empty() + || host_state.wrapper_box.is_some() + || host_state.dispatcher.is_some(); + + if !has_clash { + export_binary_from_box(&box_name_clone, &bin_path); + append_binary_row(&bins_group_clone, &box_name_clone, &name, &bin_path); + return; + } + + ask_replace_with_dispatcher( + &win_clone, + box_name_clone.clone(), + bins_group_clone.clone(), + name, + host_state, + ); + }); + + name_dialog.present(); +} + +/// Builds the row shown for a freshly-exported binary in the "Exported +/// Binaries" section. Mirrors `remove_exported_binary` style. `name` is +/// the command name the user typed, used as the row title; `bin_path` is +/// the original in-box path (`--bin` value) we passed to `distrobox-export` +/// at creation time and must keep targeting `remove_exported_binary_from_box` +/// so removal hits the same file. +fn append_binary_row( + bins_group: &adw::PreferencesGroup, + box_name: &str, + name: &str, + bin_path: &str, +) { + let row = adw::ActionRow::new(); + row.set_title(&markup_escape_text(name)); + + //TRANSLATORS: Button Label + let remove_btn = gtk::Button::with_label(&gettext("Remove")); + remove_btn.set_valign(Align::Center); + + let box_name_clone = box_name.to_string(); + let row_clone = row.clone(); + let bin_path_clone = bin_path.to_string(); + remove_btn.connect_clicked(move |btn| { + // delete targets the original `--bin` value passed to distrobox-export + remove_exported_binary_from_box(&box_name_clone, &bin_path_clone); + row_clone.set_title("Removed!"); + btn.set_sensitive(false); + }); + row.add_suffix(&remove_btn); + bins_group.add(&row); +} + +/// Second dialog in the Add Command flow: lists what already exists on +/// the host side (plain host paths, a wrapper box, or an existing +/// dispatcher's boxes/host) and asks the user whether to overwrite with +/// a dispatcher. Confirmation merges the targets and writes the file. +fn ask_replace_with_dispatcher( + window: &ApplicationWindow, + box_name: String, + bins_group: adw::PreferencesGroup, + name: String, + host_state: HostCommandState, +) { + let mut body_lines: Vec = Vec::new(); + for p in &host_state.host_paths { + body_lines.push(p.clone()); + } + if let Some(wb) = &host_state.wrapper_box { + //TRANSLATORS: Conflict entry - {} replaced with a box name + body_lines.push(gettext(&format!("in box {}", wb))); + } + if let Some((Some(h), _)) = &host_state.dispatcher { + //TRANSLATORS: Conflict entry - {} replaced with an existing dispatcher's host + body_lines.push(gettext(&format!("in dispatcher (host: {})", h))); + } else if let Some((None, boxes)) = &host_state.dispatcher { + //TRANSLATORS: Conflict entry - {} replaced with an existing dispatcher's box list + body_lines.push(gettext(&format!( + "in dispatcher (boxes: {})", + boxes.join(", ") + ))); + } + let body = body_lines.join("\n"); + + let d = adw::MessageDialog::new( + Some(window), + //TRANSLATORS: Popup Heading - {} replaced with the command name + Some(&gettext(&format!("{} Already Exists", name))), + Some(&body), + ); + d.set_transient_for(Some(window)); + //TRANSLATORS: Button Label + d.add_response("cancel", &gettext("Cancel")); + //TRANSLATORS: Button Label + d.add_response("dispatcher", &gettext("Replace With Chooser")); + d.set_response_appearance("dispatcher", adw::ResponseAppearance::Suggested); + d.set_default_response(Some("dispatcher")); + d.set_close_response("cancel"); + + let box_name_clone = box_name.clone(); + let bins_group_clone = bins_group.clone(); + d.connect_response(None, move |_dlg, res| { + if res != "dispatcher" { + return; + } + let mut boxes_vec: Vec = Vec::new(); + if let Some((_, existing_boxes)) = &host_state.dispatcher { + boxes_vec.extend(existing_boxes.iter().cloned()); + } + if let Some(wb) = &host_state.wrapper_box { + if !boxes_vec.contains(wb) { + boxes_vec.push(wb.clone()); + } + } + if !boxes_vec.contains(&box_name_clone) { + boxes_vec.push(box_name_clone.clone()); + } + + let host: Option = if let Some((Some(h), _)) = &host_state.dispatcher { + Some(h.clone()) + } else { + host_state.host_paths.first().cloned() + }; + + write_dispatcher(&name, host.as_deref(), &boxes_vec); + add_chooser_row(&bins_group_clone, name.clone(), host, boxes_vec); + }); + + d.present(); +} + fn on_delete_clicked(window: &ApplicationWindow, box_name: String) { let d = adw::MessageDialog::new( Some(window), From 0f8390e24ab4a7cb577cf75dbf7fe8cdcd2adec9 Mon Sep 17 00:00:00 2001 From: Kacper Paczos Date: Tue, 25 Aug 2026 20:24:04 +0200 Subject: [PATCH 2/3] feat: list a box's own commands, and name them per box on the host A tool installed inside a box was invisible in BoxBuddy: the applications list only ever enumerates `.desktop` files, and a command line tool has none. The Applications page now has a Commands section listing what the box itself holds, each with an "Add to Terminal" button that opens the existing add-command dialog with the name filled in. The scan covers `/usr/local/bin` and `/opt/*/bin`, and `$HOME/.local/bin` only when the box has a home of its own - distrobox shares the host's home by default, so scanning it there would list the host's own tools as though they lived in the box. `/usr/bin` is left out: between the base image and distrobox's first-run setup it holds hundreds of entries with nothing to tell a user install apart from them. The add-command dialog gains a "Name on host" field. `distrobox-export` always keeps the command's own name, so a different one can only be a chooser, and a chooser is now written even with nothing in the way - with a single target it runs it without asking. This is what lets the same tool from several boxes sit on the host as separate profiles: `claude` from two boxes becomes `claude-work` and `claude-personal`, each entering its own box. The generated script therefore separates the host-facing name from the command run inside the box, and the marker records both. docs/tips.md explains using a box's own home directory to keep an application's settings and logins separate while the host's files stay reachable, and the new-box form now says the same thing where the choice is actually made. --- docs/tips.md | 35 ++++++++++ src/distrobox_handler.rs | 134 ++++++++++++++++++++++++++++++++++---- src/main.rs | 137 +++++++++++++++++++++++++++++++++++---- 3 files changed, 282 insertions(+), 24 deletions(-) diff --git a/docs/tips.md b/docs/tips.md index a4767a9..9520c85 100644 --- a/docs/tips.md +++ b/docs/tips.md @@ -76,3 +76,38 @@ sudo flatpak override --reset io.github.dvlv.boxbuddyrs ``` ------ + +## Running the same application under separate profiles + +Some applications keep their settings and logins in your home directory, which +means every box sees the same ones - the box changes the system underneath the +application, not who it is logged in as. If you want the same application under +several identities (say a work account and two personal ones), give each box its +own home directory. + +Fill in **Home Directory** when creating the box, for example +`~/boxes/work`. The box then has a home of its own, so anything the application +writes there - its configuration, its credentials, its history - belongs to that +box alone. + +Three things stay the way you would want them: + +- **Your files on the host are still reachable.** Only the home directory is + swapped; the rest of the filesystem is mounted as usual, so a project at + `/home/you/Documents/project` is available inside the box under that same + path. Note that it is no longer under `~`, so use the full path. +- **Exports still land on the host.** Distrobox knows your real home, so + applications you add to the menu and commands you add to the terminal appear + in the host's menu and on the host's `PATH`, not inside the box's private + home. +- **Each box updates independently.** The application is installed per box, so + one profile can stay on an older version while another moves on. The flip + side is that updating means updating each box. + +Give the boxes names you will recognise (`work`, `personal`): exported +applications carry the box name in the menu, so the entries stay apart. For +commands, the host name you choose when adding one to the terminal is what you +will type, so `claude-work` and `claude-personal` can live side by side. + +If BoxBuddy is installed as a Flatpak, choosing a custom home directory needs +`home` filesystem access - see the section above. diff --git a/src/distrobox_handler.rs b/src/distrobox_handler.rs index 634a1c2..6beff7b 100644 --- a/src/distrobox_handler.rs +++ b/src/distrobox_handler.rs @@ -725,6 +725,52 @@ pub fn box_command_path(box_name: &str, name: &str) -> Option { } } +/// Commands installed inside a box, as absolute paths within it. +/// +/// Scans `/usr/local/bin` and `/opt/*/bin`, and `$HOME/.local/bin` only when +/// the box has a home of its own: distrobox shares the host's home by default, +/// so scanning it there would list the host's own tools as though they lived in +/// the box. `/usr/bin` is deliberately left out - between the base image and +/// distrobox's own first-run setup it holds hundreds of entries with nothing to +/// tell user installs apart from them. +pub fn get_commands_in_box(box_name: &str) -> Vec { + let script = "dirs=/usr/local/bin; \ + for d in /opt/*/bin; do [ -d \"$d\" ] && dirs=\"$dirs $d\"; done; \ + if [ \"$HOME\" != \"${DISTROBOX_HOST_HOME:-$HOME}\" ]; then dirs=\"$dirs $HOME/.local/bin\"; fi; \ + for d in $dirs; do [ -d \"$d\" ] && find \"$d\" -maxdepth 1 -type f -perm -u+x -print; done 2>/dev/null; \ + true"; + let out = get_command_output( + "distrobox", + Some(&["enter", box_name, "--", "bash", "-lc", script]), + ); + parse_command_paths(&out) +} + +/// Turns the scan's output into usable absolute paths: keeps only lines that +/// look like a path to a command whose name the rest of this module can handle, +/// drops duplicates by command name, and sorts by that name. +pub fn parse_command_paths(output: &str) -> Vec { + let mut found: Vec<(String, String)> = Vec::new(); + for line in output.lines() { + let line = line.trim(); + if !line.starts_with('/') { + continue; + } + let Some(name) = std::path::Path::new(line) + .file_name() + .and_then(|n| n.to_str()) + else { + continue; + }; + if !valid_command_name(name) || found.iter().any(|(n, _)| n == name) { + continue; + } + found.push((name.to_string(), line.to_string())); + } + found.sort_by(|a, b| a.0.cmp(&b.0)); + found.into_iter().map(|(_, path)| path).collect() +} + /// Description of what the host already has on the path for a given command /// name. The "Add Command to Terminal" dialog needs to know whether to plain- /// export (nothing in the way), warn the user (a host binary with the same @@ -882,7 +928,12 @@ pub fn valid_command_name(name: &str) -> bool { /// A host path containing whitespace cannot round-trip through the marker /// line (tokens are whitespace-separated); runtime quoting of the /// dispatched command is unaffected. -pub fn dispatcher_script(name: &str, host: Option<&str>, boxes: &[String]) -> String { +pub fn dispatcher_script( + name: &str, + command: &str, + host: Option<&str>, + boxes: &[String], +) -> String { // One `# name:` line per box, so `distrobox-export --list-binaries` run in // any of the target boxes still finds this command, and // `distrobox-export --bin … --delete` still recognises the file. Without @@ -892,8 +943,9 @@ pub fn dispatcher_script(name: &str, host: Option<&str>, boxes: &[String]) -> St markers.push_str(&format!("# name: {b}\n")); } markers.push_str(&format!( - "# boxbuddy-dispatcher: command={} host={} boxes={}", + "# boxbuddy-dispatcher: command={} cmd={} host={} boxes={}", name, + command, host.unwrap_or(""), boxes.join(",") )); @@ -912,6 +964,7 @@ pub fn dispatcher_script(name: &str, host: Option<&str>, boxes: &[String]) -> St ) .replace("@BOXES@", &boxes_arr) .replace("@NAME@", &bash_quote(name)) + .replace("@CMD@", &bash_quote(command)) } /// The dispatcher itself. Kept as one template rather than assembled line by @@ -924,6 +977,7 @@ const DISPATCHER_TEMPLATE: &str = r#"#!/usr/bin/env bash HOST=@HOST@ BOXES=(@BOXES@) NAME=@NAME@ +CMD=@CMD@ TARGETS=() [ -n "$HOST" ] && TARGETS+=("host") @@ -935,7 +989,7 @@ run_target() { if [ "$target" = "host" ]; then exec "$HOST" "$@" fi - exec distrobox enter "$target" -- "$NAME" "$@" + exec distrobox enter "$target" -- "$CMD" "$@" } if [ ${#TARGETS[@]} -eq 0 ]; then @@ -953,8 +1007,9 @@ if [ -n "${BOXBUDDY_DISPATCH:-}" ]; then exit 2 fi +# With a single target there is nothing to choose, so never prompt for one. INDEX=1 -if [ -t 2 ] && [ -r /dev/tty ]; then +if [ ${#TARGETS[@]} -gt 1 ] && [ -t 2 ] && [ -r /dev/tty ]; then echo "Run $NAME with:" >&2 i=1 for t in "${TARGETS[@]}"; do @@ -1038,11 +1093,11 @@ pub fn parse_dispatcher_marker(content: &str) -> Option<(Option, Vec, boxes: &[String]) { +pub fn write_dispatcher(name: &str, command: &str, host: Option<&str>, boxes: &[String]) { if !valid_command_name(name) { return; } - let content = dispatcher_script(name, host, boxes); + let content = dispatcher_script(name, command, host, boxes); let mut script = String::new(); script.push_str("mkdir -p \"$HOME/.local/bin\" && cat > \"$HOME/.local/bin/"); script.push_str(name); @@ -1368,7 +1423,7 @@ mod stream_tests { ), ]; for (host, boxes) in cases { - let script = dispatcher_script("name", host, &boxes); + let script = dispatcher_script("name", "name", host, &boxes); let parsed = parse_dispatcher_marker(&script); assert!(parsed.is_some(), "parse failed for {:?}", (host, &boxes)); let (h, b) = parsed.unwrap(); @@ -1377,11 +1432,68 @@ mod stream_tests { } } + #[test] + fn command_scan_keeps_one_entry_per_name_sorted() { + use super::parse_command_paths; + + let out = "/usr/local/bin/zzz\n/usr/local/bin/aaa\n/opt/tool/bin/aaa\n"; + assert_eq!( + parse_command_paths(out), + vec![ + "/usr/local/bin/aaa".to_string(), + "/usr/local/bin/zzz".to_string() + ] + ); + } + + #[test] + fn command_scan_ignores_noise_and_unusable_names() { + use super::parse_command_paths; + + // find's own errors, relative junk and a name the shell-quoting rules + // of this module refuse must all be dropped. + let out = "find: '/opt/x/bin': No such file or directory\n\nnot-a-path\n/usr/local/bin/we ird\n/usr/local/bin/ok\n"; + assert_eq!( + parse_command_paths(out), + vec!["/usr/local/bin/ok".to_string()] + ); + } + + #[test] + fn dispatcher_runs_the_in_box_command_under_its_host_name() { + use super::{dispatcher_script, parse_dispatcher_marker}; + + // `claude` from box `work`, called `claude-work` on the host. + let script = dispatcher_script("claude-work", "claude", None, &["work".to_string()]); + assert!(script.contains("NAME='claude-work'"), "host name missing"); + assert!(script.contains("CMD='claude'"), "in-box command missing"); + assert!( + script.contains("cmd=claude "), + "marker does not record the in-box command" + ); + // The host-facing name must never be what gets run inside the box. + assert!(script.contains("exec distrobox enter \"$target\" -- \"$CMD\"")); + let (host, boxes) = parse_dispatcher_marker(&script).unwrap(); + assert_eq!(host, None); + assert_eq!(boxes, vec!["work".to_string()]); + } + + #[test] + fn single_target_dispatcher_never_prompts() { + use super::dispatcher_script; + + let one = dispatcher_script("claude-work", "claude", None, &["work".to_string()]); + assert!( + one.contains("if [ ${#TARGETS[@]} -gt 1 ] && [ -t 2 ]"), + "the menu is not guarded by the target count" + ); + } + #[test] fn dispatcher_script_round_trips_no_boxes() { use super::{dispatcher_script, parse_dispatcher_marker}; - let script = dispatcher_script("solo", Some("/usr/bin/solo"), &[]); + let script = dispatcher_script("solo", "solo", Some("/usr/bin/solo"), &[]); let (h, b) = parse_dispatcher_marker(&script).unwrap(); assert_eq!(h, Some("/usr/bin/solo".to_string())); assert!(b.is_empty()); @@ -1453,7 +1565,7 @@ mod stream_tests { (None, vec![]), ]; for (host, boxes) in variants { - let script = dispatcher_script("name", host, &boxes); + let script = dispatcher_script("name", "name", host, &boxes); let mut path = std::env::temp_dir(); path.push(format!("boxbuddy_disp_{p}.sh", p = std::process::id())); fs::write(&path, &script).unwrap(); @@ -1499,7 +1611,7 @@ mod stream_tests { fs::set_permissions(&fake_dx, perm).unwrap(); let dispatcher = tmp.join("dispatcher"); - let script = dispatcher_script("mycmd", None, &["bx".to_string()]); + let script = dispatcher_script("mycmd", "mycmd", None, &["bx".to_string()]); fs::write(&dispatcher, &script).unwrap(); let mut perm = fs::metadata(&dispatcher).unwrap().permissions(); perm.set_mode(0o755); @@ -1565,7 +1677,7 @@ mod stream_tests { let host_path = host_bin.to_str().unwrap().to_string(); let dispatcher = tmp.join("dispatcher"); - let script = dispatcher_script("mycmd", Some(&host_path), &[]); + let script = dispatcher_script("mycmd", "mycmd", Some(&host_path), &[]); fs::write(&dispatcher, &script).unwrap(); let mut perm = fs::metadata(&dispatcher).unwrap().permissions(); perm.set_mode(0o755); diff --git a/src/main.rs b/src/main.rs index bdc71ef..2857865 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,7 @@ use gettextrs::gettext; +use std::cell::Cell; use std::path::Path; +use std::rc::Rc; use std::thread; use adw::{ @@ -21,11 +23,11 @@ mod distrobox_handler; use distrobox_handler::{ assemble_box, box_command_path, clone_box, create_box, create_box_streaming, delete_box, export_app_from_box, export_binary_from_box, get_all_distroboxes, get_apps_in_box, - get_available_images_with_distro_name, get_binaries_exported_from_box, get_number_of_boxes, - host_command_conflicts, install_deb_in_box, install_rpm_in_box, list_dispatchers_for_box, - open_terminal_in_box, remove_app_from_host, remove_dispatcher, remove_exported_binary_from_box, - run_command_in_box, stop_box, upgrade_all_boxes, upgrade_box, valid_command_name, - write_dispatcher, DBox, DBoxApp, HostCommandState, + get_available_images_with_distro_name, get_binaries_exported_from_box, get_commands_in_box, + get_number_of_boxes, host_command_conflicts, install_deb_in_box, install_rpm_in_box, + list_dispatchers_for_box, open_terminal_in_box, remove_app_from_host, remove_dispatcher, + remove_exported_binary_from_box, run_command_in_box, stop_box, upgrade_all_boxes, upgrade_box, + valid_command_name, write_dispatcher, DBox, DBoxApp, HostCommandState, }; mod utils; @@ -42,7 +44,7 @@ use utils::{ const APP_ID: &str = "io.github.dvlv.boxbuddyrs"; enum AppsFetchMessage { - AppsFetched(Vec, Vec), + AppsFetched(Vec, Vec, Vec), } enum BoxCreatedMessage { @@ -919,6 +921,7 @@ fn create_new_distrobox(window: &ApplicationWindow) { volume_box_list.set_visible(false); // TRANSLATORS: Entry Label - Select home directory for new distrobox + // TRANSLATORS: Entry Label - custom home directory for the new box home_entry_row.set_title(&gettext("Home Directory (Leave blank for default)")); home_entry_row.set_width_request(600); home_select_row.add_prefix(&home_entry_row); @@ -1084,6 +1087,19 @@ fn create_new_distrobox(window: &ApplicationWindow) { main_box.append(&boxed_list); + // The home directory field is the one option whose consequences are not + // obvious: it is what makes a box a separate profile of an application + // rather than another way of running the host's copy. + // TRANSLATORS: Explanation shown under the new-box form, about the Home Directory field + let home_hint = gtk::Label::new(Some(&gettext( + "With its own home directory, a box keeps its own application settings and logins, so the same application can run under separate profiles. The rest of your files on the host stay accessible, and applications or commands you export from the box still appear in the host's menu and terminal.", + ))); + home_hint.set_wrap(true); + home_hint.set_xalign(0.0); + home_hint.set_margin_top(6); + home_hint.add_css_class("dim-label"); + main_box.append(&home_hint); + //Volumes if has_host_access() { let volume_box_list_clone = volume_box_list.clone(); @@ -1282,8 +1298,9 @@ fn on_show_applications_clicked(window: &ApplicationWindow, box_name: String) { gio::spawn_blocking(move || { let apps = get_apps_in_box(&box_name_clone); let binaries = get_binaries_exported_from_box(&box_name_clone); + let commands = get_commands_in_box(&box_name_clone); sender - .send_blocking(AppsFetchMessage::AppsFetched(apps, binaries)) + .send_blocking(AppsFetchMessage::AppsFetched(apps, binaries, commands)) .expect("The channel needs to be open."); }); @@ -1295,7 +1312,7 @@ fn on_show_applications_clicked(window: &ApplicationWindow, box_name: String) { async move { while let Ok(msg) = receiver.recv().await { match msg { - AppsFetchMessage::AppsFetched(apps, binaries) => { + AppsFetchMessage::AppsFetched(apps, binaries, commands) => { loading_spinner.stop(); scroll_area.remove(&loading_box); @@ -1318,7 +1335,7 @@ fn on_show_applications_clicked(window: &ApplicationWindow, box_name: String) { // sections, and two empty headings would just split the // window between them. One centred message says the same // thing, the way the rest of the app does it. - if apps.is_empty() && binaries.is_empty() { + if apps.is_empty() && binaries.is_empty() && commands.is_empty() { //TRANSLATORS: Error Message scroll_area.append(&build_empty_state_page(&gettext( "No Applications Installed", @@ -1408,6 +1425,18 @@ fn on_show_applications_clicked(window: &ApplicationWindow, box_name: String) { scroll_area.append(&apps_group); + // Commands the user installed in this box. They + // have no .desktop file, so the applications list + // above cannot show them, which is why a tool + // installed in a box used to be invisible here. + let cmds_group = adw::PreferencesGroup::new(); + //TRANSLATORS: Section heading - commands installed inside the box + cmds_group.set_title(&gettext("Commands")); + if commands.is_empty() { + //TRANSLATORS: Shown when a box has no commands of its own + cmds_group.set_description(Some(&gettext("No Commands Found"))); + } + let bins_group = adw::PreferencesGroup::new(); bins_group.set_title(&gettext("Exported Binaries")); @@ -1467,9 +1496,41 @@ fn on_show_applications_clicked(window: &ApplicationWindow, box_name: String) { &win_for_add, box_name_for_add.clone(), bins_group_for_add.clone(), + None, ); }); + for path in commands { + let Some(cmd_name) = std::path::Path::new(&path) + .file_name() + .and_then(|n| n.to_str()) + .map(str::to_string) + else { + continue; + }; + let row = adw::ActionRow::new(); + row.set_title(&markup_escape_text(&cmd_name)); + row.set_subtitle(&markup_escape_text(&path)); + + //TRANSLATORS: Button Label + let add_btn = gtk::Button::with_label(&gettext("Add to Terminal")); + add_btn.set_valign(Align::Center); + let win_for_cmd = win_for_async.clone(); + let box_for_cmd = box_name.clone(); + let bins_for_cmd = bins_group.clone(); + add_btn.connect_clicked(move |_btn| { + on_add_command_clicked( + &win_for_cmd, + box_for_cmd.clone(), + bins_for_cmd.clone(), + Some(cmd_name.clone()), + ); + }); + row.add_suffix(&add_btn); + cmds_group.add(&row); + } + scroll_area.append(&cmds_group); + scroll_area.append(&bins_group); } } @@ -1549,6 +1610,7 @@ fn on_add_command_clicked( window: &ApplicationWindow, box_name: String, bins_group: adw::PreferencesGroup, + prefill: Option, ) { let name_dialog = adw::MessageDialog::new( Some(window), @@ -1566,8 +1628,35 @@ fn on_add_command_clicked( entry_row.set_title(&gettext("Command")); entry_row.set_activates_default(true); + // The host-side name is what makes several boxes usable as profiles of the + // same tool: the command keeps its name inside the box, while the host gets + // one entry per box. It follows the command until the user edits it. + if let Some(cmd) = &prefill { + entry_row.set_text(cmd); + } + + let host_name_row = adw::EntryRow::new(); + //TRANSLATORS: Entry Label - the name the command gets on the host + host_name_row.set_title(&gettext("Name on host")); + host_name_row.set_activates_default(true); + let host_name_edited = Rc::new(Cell::new(false)); + let edited_clone = host_name_edited.clone(); + host_name_row.connect_changed(move |_row| edited_clone.set(true)); + let host_name_follow = host_name_row.clone(); + let edited_for_cmd = host_name_edited.clone(); + entry_row.connect_changed(move |row| { + if !edited_for_cmd.get() { + let mirrored = row.text(); + host_name_follow.set_text(&mirrored); + // set_text fires "changed" on the host row; that echo is ours, not + // the user's, so it must not count as an edit. + edited_for_cmd.set(false); + } + }); + let prefs_group = adw::PreferencesGroup::new(); prefs_group.add(&entry_row); + prefs_group.add(&host_name_row); name_dialog.set_extra_child(Some(&prefs_group)); //TRANSLATORS: Button Label @@ -1582,13 +1671,19 @@ fn on_add_command_clicked( let box_name_clone = box_name.clone(); let bins_group_clone = bins_group.clone(); let entry_row_clone = entry_row.clone(); + let host_name_clone = host_name_row.clone(); name_dialog.connect_response(None, move |_d, res| { if res != "add" { return; } let name = entry_row_clone.text().to_string(); - if !valid_command_name(&name) { - // Silent no-op for invalid input; the entry is right there for + // An empty host name means "same as the command". + let host_name = match host_name_clone.text().to_string() { + t if t.is_empty() => name.clone(), + t => t, + }; + if !valid_command_name(&name) || !valid_command_name(&host_name) { + // Silent no-op for invalid input; the entries are right there for // the user to fix. return; } @@ -1615,11 +1710,25 @@ fn on_add_command_clicked( } }; - let host_state = host_command_conflicts(&name); + let host_state = host_command_conflicts(&host_name); let has_clash = !host_state.host_paths.is_empty() || host_state.wrapper_box.is_some() || host_state.dispatcher.is_some(); + // `distrobox-export --bin` always keeps the command's own name, so a + // different host-side name can only be a chooser - even with nothing + // in the way. A chooser with one target runs it without asking. + if !has_clash && host_name != name { + write_dispatcher(&host_name, &name, None, &[box_name_clone.clone()]); + add_chooser_row( + &bins_group_clone, + host_name.clone(), + None, + vec![box_name_clone.clone()], + ); + return; + } + if !has_clash { export_binary_from_box(&box_name_clone, &bin_path); append_binary_row(&bins_group_clone, &box_name_clone, &name, &bin_path); @@ -1630,6 +1739,7 @@ fn on_add_command_clicked( &win_clone, box_name_clone.clone(), bins_group_clone.clone(), + host_name, name, host_state, ); @@ -1679,6 +1789,7 @@ fn ask_replace_with_dispatcher( box_name: String, bins_group: adw::PreferencesGroup, name: String, + command: String, host_state: HostCommandState, ) { let mut body_lines: Vec = Vec::new(); @@ -1741,7 +1852,7 @@ fn ask_replace_with_dispatcher( host_state.host_paths.first().cloned() }; - write_dispatcher(&name, host.as_deref(), &boxes_vec); + write_dispatcher(&name, &command, host.as_deref(), &boxes_vec); add_chooser_row(&bins_group_clone, name.clone(), host, boxes_vec); }); From ccaf3ec5bebfa8d61f8b8755d23d6326cbd1a13a Mon Sep 17 00:00:00 2001 From: Kacper Paczos Date: Wed, 26 Aug 2026 16:45:36 +0200 Subject: [PATCH 3/3] docs: shorten the home directory hint Four lines of explanation under a form is more than anyone reads. Same three facts - own settings and logins, host files still reachable, exports still land on the host - in one sentence. --- src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 2857865..c821ab1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1092,7 +1092,7 @@ fn create_new_distrobox(window: &ApplicationWindow) { // rather than another way of running the host's copy. // TRANSLATORS: Explanation shown under the new-box form, about the Home Directory field let home_hint = gtk::Label::new(Some(&gettext( - "With its own home directory, a box keeps its own application settings and logins, so the same application can run under separate profiles. The rest of your files on the host stay accessible, and applications or commands you export from the box still appear in the host's menu and terminal.", + "A home of its own gives the box its own settings and logins. Your files on the host stay reachable, and anything you export still appears on the host.", ))); home_hint.set_wrap(true); home_hint.set_xalign(0.0);