aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-20 15:01:18 +0300
committerIgor Soarez <igor@soarez.org>2026-10-20 15:01:18 +0300
commitaa4d6fcd9eac0d0fe051d6307ed6009adefbb2e1 (patch)
treec20523f3753f7c04b34f163135d1419094a9838f
parenteed9c9c1141e118e43b0055f935dd891d89bebd6 (diff)
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.
-rw-r--r--crates/dinggerd/src/main.rs2
-rw-r--r--crates/dinggerd/src/tmux.rs36
-rw-r--r--menubar/main.swift5
-rw-r--r--menubar/model.swift31
-rw-r--r--menubar/panel.swift10
-rwxr-xr-xtests/e2e.sh24
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<String>, pane: Option<String>) -> 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::path::PathBuf> = 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::path::PathBuf> = 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<String, String> {
- 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<String, String> {
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<String> {
}
/// Attached clients as (name, pid), most recently active first.
-pub fn clients() -> Vec<(String, u32)> {
- let Ok(out) = run_tmux(&[
+pub fn clients() -> Result<Vec<(String, u32)>, 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; }