Skip to content

Commit ffeb6e8

Browse files
committed
fix(tailscale): detect CLI using fallback install paths
1 parent 0a23278 commit ffeb6e8

File tree

3 files changed

+109
-13
lines changed

3 files changed

+109
-13
lines changed

memory/decisions.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,3 +288,10 @@ Type: decision
288288
Event: Smooth, delayed auto-scroll on streaming/appended messages caused visible upward jump then repin in the conversation view.
289289
Action: Updated `Messages` auto-scroll paths to immediate container-bottom pinning (`scrollTop = scrollHeight`) and removed delayed smooth auto-scroll behavior for automatic updates.
290290
Rule: Automatic message pinning should be immediate and non-animated; reserve smooth scrolling for explicit user-initiated navigation only.
291+
292+
## 2026-02-07 21:16
293+
Context: Desktop Tailscale CLI detection in Tauri runtime
294+
Type: decision
295+
Event: Settings reported missing Tailscale CLI even when installed because GUI runtime PATH did not include shell-resolved aliases/paths.
296+
Action: Added Tailscale binary candidate resolution (`PATH` first, then standard install paths including macOS app bundle path) before status checks.
297+
Rule: Desktop CLI integrations must not rely on shell aliases or login-shell PATH alone; include deterministic install-path fallbacks.

memory/mistakes.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,13 @@ Rule: Node-targeted unit tests must not assume browser globals exist; create and
131131
Root cause: The tests were authored assuming `navigator` is always available, but Vitest runs with `environment: node` in CI.
132132
Fix applied: Added a global-scope navigator shim path and descriptor-safe restore logic in `src/utils/platformPaths.test.ts`.
133133
Prevention rule: For tests that patch `navigator`, `window`, or `document`, guard setup with `typeof ... === \"undefined\"` and perform full teardown in `finally`.
134+
135+
## 2026-02-07 21:16
136+
Context: Tailscale CLI detection from GUI app runtime
137+
Type: mistake
138+
Event: Tailscale detection relied on `PATH` only, which can differ from shell aliases and fail in Tauri GUI runtime.
139+
Action: Added binary resolution fallback candidates (including macOS app bundle path) before reporting CLI missing.
140+
Rule: For desktop-integrated CLIs, resolve from PATH plus standard install locations; do not assume shell alias/path propagation.
141+
Root cause: Implementation assumed the app process inherits the same shell PATH/aliases as user terminal sessions.
142+
Fix applied: Updated `src-tauri/src/tailscale/mod.rs` to probe candidate binaries and execute status/version via resolved path.
143+
Prevention rule: Any new CLI integration in Tauri should include explicit path fallback logic and a test for candidate list coverage.

src-tauri/src/tailscale/mod.rs

Lines changed: 92 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
mod core;
22

3+
use std::ffi::{OsStr, OsString};
34
use std::io::ErrorKind;
5+
use std::process::Output;
46

57
use tauri::State;
68

@@ -21,6 +23,70 @@ fn trim_to_non_empty(value: Option<&str>) -> Option<String> {
2123
.map(str::to_string)
2224
}
2325

26+
fn tailscale_binary_candidates() -> Vec<OsString> {
27+
let mut candidates = vec![OsString::from("tailscale")];
28+
29+
#[cfg(target_os = "macos")]
30+
{
31+
candidates.push(OsString::from(
32+
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
33+
));
34+
candidates.push(OsString::from("/opt/homebrew/bin/tailscale"));
35+
candidates.push(OsString::from("/usr/local/bin/tailscale"));
36+
}
37+
38+
#[cfg(target_os = "linux")]
39+
{
40+
candidates.push(OsString::from("/usr/bin/tailscale"));
41+
candidates.push(OsString::from("/usr/sbin/tailscale"));
42+
candidates.push(OsString::from("/snap/bin/tailscale"));
43+
}
44+
45+
#[cfg(target_os = "windows")]
46+
{
47+
candidates.push(OsString::from(
48+
"C:\\Program Files\\Tailscale\\tailscale.exe",
49+
));
50+
candidates.push(OsString::from(
51+
"C:\\Program Files (x86)\\Tailscale\\tailscale.exe",
52+
));
53+
}
54+
55+
candidates
56+
}
57+
58+
fn missing_tailscale_message() -> String {
59+
#[cfg(target_os = "macos")]
60+
{
61+
return "Tailscale CLI not found on PATH or standard install paths (including /Applications/Tailscale.app/Contents/MacOS/Tailscale).".to_string();
62+
}
63+
#[cfg(not(target_os = "macos"))]
64+
{
65+
"Tailscale CLI not found on PATH or standard install paths.".to_string()
66+
}
67+
}
68+
69+
async fn resolve_tailscale_binary() -> Result<Option<(OsString, Output)>, String> {
70+
let mut failures: Vec<String> = Vec::new();
71+
for binary in tailscale_binary_candidates() {
72+
let output = tokio_command(&binary).arg("version").output().await;
73+
match output {
74+
Ok(version_output) => return Ok(Some((binary, version_output))),
75+
Err(err) if err.kind() == ErrorKind::NotFound => continue,
76+
Err(err) => failures.push(format!("{}: {err}", OsStr::new(&binary).to_string_lossy())),
77+
}
78+
}
79+
80+
if failures.is_empty() {
81+
Ok(None)
82+
} else {
83+
Err(format!(
84+
"Failed to run tailscale version from candidate paths: {}",
85+
failures.join(" | ")
86+
))
87+
}
88+
}
89+
2490
#[tauri::command]
2591
pub(crate) async fn tailscale_status() -> Result<TailscaleStatus, String> {
2692
#[cfg(any(target_os = "android", target_os = "ios"))]
@@ -31,24 +97,17 @@ pub(crate) async fn tailscale_status() -> Result<TailscaleStatus, String> {
3197
));
3298
}
3399

34-
let version_output = tokio_command("tailscale").arg("version").output().await;
35-
let version_output = match version_output {
36-
Ok(output) => output,
37-
Err(err) if err.kind() == ErrorKind::NotFound => {
38-
return Ok(tailscale_core::unavailable_status(
39-
None,
40-
"Tailscale CLI not found on PATH.".to_string(),
41-
));
42-
}
43-
Err(err) => {
44-
return Err(format!("Failed to run tailscale version: {err}"));
45-
}
100+
let Some((tailscale_binary, version_output)) = resolve_tailscale_binary().await? else {
101+
return Ok(tailscale_core::unavailable_status(
102+
None,
103+
missing_tailscale_message(),
104+
));
46105
};
47106

48107
let version = trim_to_non_empty(std::str::from_utf8(&version_output.stdout).ok())
49108
.and_then(|raw| raw.lines().next().map(str::trim).map(str::to_string));
50109

51-
let status_output = tokio_command("tailscale")
110+
let status_output = tokio_command(&tailscale_binary)
52111
.arg("status")
53112
.arg("--json")
54113
.output()
@@ -77,6 +136,26 @@ pub(crate) async fn tailscale_status() -> Result<TailscaleStatus, String> {
77136
tailscale_core::status_from_json(version, payload)
78137
}
79138

139+
#[cfg(test)]
140+
mod tests {
141+
use super::tailscale_binary_candidates;
142+
143+
#[test]
144+
fn includes_path_candidate() {
145+
let candidates = tailscale_binary_candidates();
146+
assert!(!candidates.is_empty());
147+
assert_eq!(candidates[0].to_string_lossy(), "tailscale");
148+
149+
#[cfg(target_os = "macos")]
150+
{
151+
assert!(candidates.iter().any(|candidate| {
152+
candidate.to_string_lossy()
153+
== "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
154+
}));
155+
}
156+
}
157+
}
158+
80159
#[tauri::command]
81160
pub(crate) async fn tailscale_daemon_command_preview(
82161
state: State<'_, AppState>,

0 commit comments

Comments
 (0)