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 22c6c82..6beff7b 100644 --- a/src/distrobox_handler.rs +++ b/src/distrobox_handler.rs @@ -684,6 +684,475 @@ 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 + } +} + +/// 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 +/// 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, + 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 + // 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={} cmd={} host={} boxes={}", + name, + command, + 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)) + .replace("@CMD@", &bash_quote(command)) +} + +/// 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@ +CMD=@CMD@ + +TARGETS=() +[ -n "$HOST" ] && TARGETS+=("host") +TARGETS+=("${BOXES[@]}") + +run_target() { + target=$1 + shift + if [ "$target" = "host" ]; then + exec "$HOST" "$@" + fi + exec distrobox enter "$target" -- "$CMD" "$@" +} + +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 + +# With a single target there is nothing to choose, so never prompt for one. +INDEX=1 +if [ ${#TARGETS[@]} -gt 1 ] && [ -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, command: &str, host: Option<&str>, boxes: &[String]) { + if !valid_command_name(name) { + return; + } + 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); + 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 +1405,350 @@ 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", "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 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", "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", "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", "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", "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..c821ab1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,13 @@ use gettextrs::gettext; +use std::cell::Cell; use std::path::Path; +use std::rc::Rc; use std::thread; use adw::{ - prelude::{ActionRowExt, MessageDialogExt, PreferencesGroupExt, PreferencesRowExt}, + prelude::{ + ActionRowExt, EntryRowExt, MessageDialogExt, PreferencesGroupExt, PreferencesRowExt, + }, ActionRow, Application, StyleManager, ToastOverlay, }; use gtk::{ @@ -17,11 +21,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_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; @@ -38,7 +44,7 @@ use utils::{ const APP_ID: &str = "io.github.dvlv.boxbuddyrs"; enum AppsFetchMessage { - AppsFetched(Vec, Vec), + AppsFetched(Vec, Vec, Vec), } enum BoxCreatedMessage { @@ -915,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); @@ -1080,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( + "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); + 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(); @@ -1273,12 +1293,14 @@ 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); 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."); }); @@ -1290,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); @@ -1313,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", @@ -1403,15 +1425,43 @@ 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")); + //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 +1480,57 @@ 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(), + 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); } } @@ -1460,6 +1561,304 @@ 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, + prefill: Option, +) { + 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); + + // 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 + 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(); + 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(); + // 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; + } + 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(&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); + return; + } + + ask_replace_with_dispatcher( + &win_clone, + box_name_clone.clone(), + bins_group_clone.clone(), + host_name, + 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, + command: 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, &command, 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),