Skip to content

Commit b91c039

Browse files
authored
fix: macOS Tailscale detection (#374)
1 parent c54558b commit b91c039

File tree

2 files changed

+88
-8
lines changed

2 files changed

+88
-8
lines changed

src-tauri/src/tailscale/mod.rs

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,19 @@ use self::core as tailscale_core;
2626
#[cfg(any(target_os = "android", target_os = "ios"))]
2727
const UNSUPPORTED_MESSAGE: &str = "Tailscale integration is only available on desktop.";
2828

29+
#[cfg(target_os = "macos")]
30+
fn tailscale_command(binary: &OsStr) -> tokio::process::Command {
31+
let mut command = tokio_command("/bin/launchctl");
32+
let uid = unsafe { libc::geteuid() };
33+
command.arg("asuser").arg(uid.to_string()).arg(binary);
34+
command
35+
}
36+
37+
#[cfg(not(target_os = "macos"))]
38+
fn tailscale_command(binary: &OsStr) -> tokio::process::Command {
39+
tokio_command(binary)
40+
}
41+
2942
fn trim_to_non_empty(value: Option<&str>) -> Option<String> {
3043
value
3144
.map(str::trim)
@@ -79,9 +92,28 @@ fn missing_tailscale_message() -> String {
7992
async fn resolve_tailscale_binary() -> Result<Option<(OsString, Output)>, String> {
8093
let mut failures: Vec<String> = Vec::new();
8194
for binary in tailscale_binary_candidates() {
82-
let output = tokio_command(&binary).arg("version").output().await;
95+
let output = tailscale_command(binary.as_os_str())
96+
.arg("version")
97+
.output()
98+
.await;
8399
match output {
84-
Ok(version_output) => return Ok(Some((binary, version_output))),
100+
Ok(version_output) => {
101+
if version_output.status.success() {
102+
return Ok(Some((binary, version_output)));
103+
}
104+
let stdout = trim_to_non_empty(std::str::from_utf8(&version_output.stdout).ok());
105+
let stderr = trim_to_non_empty(std::str::from_utf8(&version_output.stderr).ok());
106+
let detail = match (stdout, stderr) {
107+
(Some(out), Some(err)) => format!("stdout: {out}; stderr: {err}"),
108+
(Some(out), None) => format!("stdout: {out}"),
109+
(None, Some(err)) => format!("stderr: {err}"),
110+
(None, None) => "no output".to_string(),
111+
};
112+
failures.push(format!(
113+
"{}: tailscale version failed ({detail})",
114+
OsStr::new(&binary).to_string_lossy()
115+
));
116+
}
85117
Err(err) if err.kind() == ErrorKind::NotFound => continue,
86118
Err(err) => failures.push(format!("{}: {err}", OsStr::new(&binary).to_string_lossy())),
87119
}
@@ -311,7 +343,7 @@ pub(crate) async fn tailscale_status() -> Result<TailscaleStatus, String> {
311343
let version = trim_to_non_empty(std::str::from_utf8(&version_output.stdout).ok())
312344
.and_then(|raw| raw.lines().next().map(str::trim).map(str::to_string));
313345

314-
let status_output = tokio_command(&tailscale_binary)
346+
let status_output = tailscale_command(tailscale_binary.as_os_str())
315347
.arg("status")
316348
.arg("--json")
317349
.output()
@@ -337,7 +369,41 @@ pub(crate) async fn tailscale_status() -> Result<TailscaleStatus, String> {
337369

338370
let payload = std::str::from_utf8(&status_output.stdout)
339371
.map_err(|err| format!("Invalid UTF-8 from tailscale status: {err}"))?;
340-
tailscale_core::status_from_json(version, payload)
372+
let stderr_text = trim_to_non_empty(std::str::from_utf8(&status_output.stderr).ok());
373+
if payload.trim().is_empty() {
374+
let suffix = stderr_text
375+
.as_deref()
376+
.map(|value| format!(" stderr: {value}"))
377+
.unwrap_or_default();
378+
return Err(format!(
379+
"tailscale status --json returned empty output.{suffix}"
380+
));
381+
}
382+
match tailscale_core::status_from_json(version, payload) {
383+
Ok(status) => Ok(status),
384+
Err(err) => {
385+
let trimmed_payload = payload.trim();
386+
let payload_preview = if trimmed_payload.is_empty() {
387+
None
388+
} else if trimmed_payload.len() > 200 {
389+
Some(format!("{}…", &trimmed_payload[..200]))
390+
} else {
391+
Some(trimmed_payload.to_string())
392+
};
393+
let mut details = Vec::new();
394+
if let Some(stderr) = stderr_text {
395+
details.push(format!("stderr: {stderr}"));
396+
}
397+
if let Some(preview) = payload_preview {
398+
details.push(format!("stdout: {preview}"));
399+
}
400+
if details.is_empty() {
401+
Err(err)
402+
} else {
403+
Err(format!("{err} ({})", details.join("; ")))
404+
}
405+
}
406+
}
341407
}
342408

343409
#[cfg(test)]

src/features/settings/components/SettingsView.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,22 @@ import {
7676
type OrbitActionResult,
7777
} from "./settingsViewHelpers";
7878

79+
const formatErrorMessage = (error: unknown, fallback: string) => {
80+
if (error instanceof Error) {
81+
return error.message;
82+
}
83+
if (typeof error === "string") {
84+
return error;
85+
}
86+
if (error && typeof error === "object" && "message" in error) {
87+
const message = (error as { message?: unknown }).message;
88+
if (typeof message === "string") {
89+
return message;
90+
}
91+
}
92+
return fallback;
93+
};
94+
7995
export type SettingsViewProps = {
8096
workspaceGroups: WorkspaceGroup[];
8197
groupedWorkspaces: Array<{
@@ -673,7 +689,7 @@ export function SettingsView({
673689
setTailscaleStatus(status);
674690
} catch (error) {
675691
setTailscaleStatusError(
676-
error instanceof Error ? error.message : "Unable to load Tailscale status.",
692+
formatErrorMessage(error, "Unable to load Tailscale status."),
677693
);
678694
} finally {
679695
setTailscaleStatusBusy(false);
@@ -690,9 +706,7 @@ export function SettingsView({
690706
setTailscaleCommandPreview(preview);
691707
} catch (error) {
692708
setTailscaleCommandError(
693-
error instanceof Error
694-
? error.message
695-
: "Unable to build Tailscale daemon command.",
709+
formatErrorMessage(error, "Unable to build Tailscale daemon command."),
696710
);
697711
} finally {
698712
setTailscaleCommandBusy(false);

0 commit comments

Comments
 (0)