From aa4d6fcd9eac0d0fe051d6307ed6009adefbb2e1 Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Tue, 20 Oct 2026 15:01:18 +0300 Subject: fix clicks from the menu bar app: find tmux without homebrew on PATH launchd starts the menu bar app with PATH=/usr/bin:/bin:/usr/sbin:/sbin, so the `dingger focus` it runs for every click (panel rows, menu rows, the jump shortcut) could not find homebrew's tmux and failed with a misleading "no tmux client attached"; the app discarded the error. dingger now finds tmux on PATH or in the usual install locations (DINGGER_TMUX overrides), reports a missing tmux as such, and the app logs failed jumps. also: panel rows own every click inside them (labels took the clicks and counted as window-drag background). e2e step 14 repeats the menu bar row click and dingger focus under launchd's environment; both fail with the previous binaries. --- crates/dinggerd/src/main.rs | 2 +- crates/dinggerd/src/tmux.rs | 36 ++++++++++++++++++++++++++++-------- menubar/main.swift | 5 +++-- menubar/model.swift | 31 +++++++++++++++++++++++++++++-- menubar/panel.swift | 10 ++++++++++ tests/e2e.sh | 24 ++++++++++++++++++++++++ 6 files changed, 95 insertions(+), 13 deletions(-) diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs index 285f83a..3707d51 100644 --- a/crates/dinggerd/src/main.rs +++ b/crates/dinggerd/src/main.rs @@ -125,7 +125,7 @@ fn main() { /// by the watcher on its next tick). This is the single code path behind /// every click: the tmux status line and the menu bar rows. fn focus(client: Option, pane: Option) -> Result<(), String> { - let clients = tmux::clients(); + let clients = tmux::clients()?; // The named client if it is attached; otherwise the most recently active // one (a click on the status line makes the clicking client the most // recent, so this is right even if the name did not expand). diff --git a/crates/dinggerd/src/tmux.rs b/crates/dinggerd/src/tmux.rs index 6189c4b..48d9544 100644 --- a/crates/dinggerd/src/tmux.rs +++ b/crates/dinggerd/src/tmux.rs @@ -37,8 +37,30 @@ impl Pane { } } +/// The tmux binary. launchd starts dingger, and the menu bar app that runs +/// `dingger focus`, with PATH=/usr/bin:/bin:/usr/sbin:/sbin, where Homebrew's +/// tmux is not found: fall back to the usual install locations. +fn tmux_binary() -> &'static std::path::Path { + static TMUX: std::sync::OnceLock = std::sync::OnceLock::new(); + TMUX.get_or_init(|| { + if let Some(path) = std::env::var_os("DINGGER_TMUX") { + return path.into(); + } + let on_path: Vec = std::env::var_os("PATH") + .map(|path| std::env::split_paths(&path).map(|dir| dir.join("tmux")).collect()) + .unwrap_or_default(); + let known = ["/opt/homebrew/bin/tmux", "/usr/local/bin/tmux", "/usr/bin/tmux"] + .map(std::path::PathBuf::from); + on_path + .into_iter() + .chain(known) + .find(|candidate| candidate.is_file()) + .unwrap_or_else(|| "tmux".into()) + }) +} + fn run_tmux(args: &[&str]) -> Result { - let mut command = Command::new("tmux"); + let mut command = Command::new(tmux_binary()); // -u forces UTF-8 mode: without it, tmux mangles format output in // non-UTF-8 locales (launchd/cron default to the C locale, turning the // tab separators in our -F formats into underscores). @@ -51,7 +73,7 @@ fn run_tmux(args: &[&str]) -> Result { let output = command .args(args) .output() - .map_err(|err| format!("failed to run tmux: {err}"))?; + .map_err(|err| format!("failed to run tmux ({}): {err}", tmux_binary().display()))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!("tmux {} failed: {}", args.join(" "), stderr.trim())); @@ -227,14 +249,12 @@ pub fn client_format(client: &str, format: &str) -> Option { } /// Attached clients as (name, pid), most recently active first. -pub fn clients() -> Vec<(String, u32)> { - let Ok(out) = run_tmux(&[ +pub fn clients() -> Result, String> { + let out = run_tmux(&[ "list-clients", "-F", "#{client_activity}\t#{client_name}\t#{client_pid}", - ]) else { - return Vec::new(); - }; + ])?; let mut clients: Vec<(u64, String, u32)> = out .lines() .filter_map(|line| { @@ -246,7 +266,7 @@ pub fn clients() -> Vec<(String, u32)> { }) .collect(); clients.sort_by_key(|client| std::cmp::Reverse(client.0)); - clients.into_iter().map(|(_, name, pid)| (name, pid)).collect() + Ok(clients.into_iter().map(|(_, name, pid)| (name, pid)).collect()) } /// Show a pane in a client: switch the client to the pane's window and make diff --git a/menubar/main.swift b/menubar/main.swift index e9294e8..c1891ab 100644 --- a/menubar/main.swift +++ b/menubar/main.swift @@ -136,8 +136,8 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { /// The jump shortcut: same as clicking the tmux status line. @objc private func jumpToTopWaiting() { DispatchQueue.global().async { - let output = run(dinggerBinary, ["focus"]) ?? "failed" - log("jump: dingger focus \(output.isEmpty ? "ok" : output)") + let result = runReporting(dinggerBinary, ["focus"]) + log("jump: dingger focus \(result.ok ? "ok" : "failed: \(result.output)")") DispatchQueue.main.async { self.refresh() } } } @@ -155,6 +155,7 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { } private func jump(to paneId: String) { + log("jump: to \(paneId)") DispatchQueue.global().async { focus(paneId) DispatchQueue.main.async { self.refresh() } diff --git a/menubar/model.swift b/menubar/model.swift index e60cb84..67a693a 100644 --- a/menubar/model.swift +++ b/menubar/model.swift @@ -126,12 +126,38 @@ func run(_ path: String, _ args: [String]) -> String? { return String(data: data, encoding: .utf8) } +/// Like run(), but reports why a command failed: exit status and stderr. +func runReporting(_ path: String, _ args: [String]) -> (ok: Bool, output: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: path) + process.arguments = args + let out = Pipe(), err = Pipe() + process.standardOutput = out + process.standardError = err + do { + try process.run() + } catch { + return (false, "cannot run \(path): \(error.localizedDescription)") + } + let stdout = String(data: out.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let stderr = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + process.waitUntilExit() + let text = (stdout + stderr).trimmingCharacters(in: .whitespacesAndNewlines) + return (process.terminationStatus == 0, text) +} + +/// The tmux binary: launchd gives apps a PATH without Homebrew, so look in +/// the usual places. +let tmuxBinary: String = environment["DINGGER_TMUX"] + ?? ["/opt/homebrew/bin/tmux", "/usr/local/bin/tmux", "/usr/bin/tmux"] + .first { FileManager.default.isExecutableFile(atPath: $0) } ?? "/opt/homebrew/bin/tmux" + func tmux(_ args: [String]) -> String? { var all = ["-u"] if let socket = environment["DINGGER_TMUX_SOCKET"] { all += ["-L", socket] } - return run(environment["DINGGER_TMUX"] ?? "/opt/homebrew/bin/tmux", all + args) + return run(tmuxBinary, all + args) } let dinggerBinary = environment["DINGGER_BIN"] ?? (NSHomeDirectory() + "/.local/bin/dingger") @@ -162,7 +188,8 @@ func fetchRows() -> [Row] { /// Show the pane and mark it attended; the same code path as the tmux /// status-line click. func focus(_ paneId: String) { - _ = run(dinggerBinary, ["focus", "--pane", paneId]) + let result = runReporting(dinggerBinary, ["focus", "--pane", paneId]) + if !result.ok { log("jump: dingger focus --pane \(paneId) failed: \(result.output)") } } // MARK: rendering diff --git a/menubar/panel.swift b/menubar/panel.swift index 357ff91..e6ff569 100644 --- a/menubar/panel.swift +++ b/menubar/panel.swift @@ -151,6 +151,15 @@ private class ClickView: NSView { override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + // Own every click inside, including over labels: a text field would + // otherwise take the click (and count as a window-drag background). + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(convert(point, from: superview)) ? self : nil + } + + // Dragging is done here (performDrag), not by the window server. + override var mouseDownCanMoveWindow: Bool { false } + override func updateTrackingAreas() { super.updateTrackingAreas() trackingAreas.forEach(removeTrackingArea) @@ -170,6 +179,7 @@ private class ClickView: NSView { } override func mouseUp(with event: NSEvent) { + log("panel: mouse up (dragged: \(dragged))") if !dragged { onClick() } } } diff --git a/tests/e2e.sh b/tests/e2e.sh index 50d7fa7..1a5cba0 100755 --- a/tests/e2e.sh +++ b/tests/e2e.sh @@ -303,6 +303,30 @@ else fail "menu bar binary not found at $MENUBAR" fi +# --- 14. clicks work with launchd's environment ------------------------------ +# launchd starts the menu bar app with PATH=/usr/bin:/bin:/usr/sbin:/sbin, +# which has no Homebrew tmux; every click runs `dingger focus` from there. +echo "14. launchd environment" +launchd_env() { + env -i HOME="$HOME" PATH=/usr/bin:/bin:/usr/sbin:/sbin DINGGER_TMUX_SOCKET="$SOCK" \ + DINGGER_AGENT_SOCKET="$HOOK" DINGGER_BIN="$DINGGER" "$@" +} +t switch-client -c "$CLIENT" -t "$B1" +expect "client back on wb1" "client_window" wb1 +hook "$A1" working; hook "$A1" idle +expect "A1 done" "opt $A1 @agent_state" done +if [ -x "$MENUBAR" ]; then + launchd_env "$MENUBAR" --click "$A1" 2>"$DIR/click.err" + expect "menu bar row click jumps, under launchd's PATH" "client_window" wa1 + expect "A1 idle after that click" "opt $A1 @agent_state" idle +fi +t switch-client -c "$CLIENT" -t "$B1" +expect "client back on wb1" "client_window" wb1 +hook "$A2" working; hook "$A2" idle +expect "A2 done" "opt $A2 @agent_state" done +launchd_env "$DINGGER" focus +expect "dingger focus (jump shortcut) jumps, under launchd's PATH" "client_window" wa2 + echo echo "$PASSES passed, $FAILS failed" [ "$FAILS" -eq 0 ] || { echo "--- daemon log tail"; tail -30 "$DIR/daemon.log"; exit 1; } -- cgit v1.2.3