diff --git a/src-tauri/src/tailscale/mod.rs b/src-tauri/src/tailscale/mod.rs index a3848f83c..7e82d80ce 100644 --- a/src-tauri/src/tailscale/mod.rs +++ b/src-tauri/src/tailscale/mod.rs @@ -26,6 +26,19 @@ use self::core as tailscale_core; #[cfg(any(target_os = "android", target_os = "ios"))] const UNSUPPORTED_MESSAGE: &str = "Tailscale integration is only available on desktop."; +#[cfg(target_os = "macos")] +fn tailscale_command(binary: &OsStr) -> tokio::process::Command { + let mut command = tokio_command("/bin/launchctl"); + let uid = unsafe { libc::geteuid() }; + command.arg("asuser").arg(uid.to_string()).arg(binary); + command +} + +#[cfg(not(target_os = "macos"))] +fn tailscale_command(binary: &OsStr) -> tokio::process::Command { + tokio_command(binary) +} + fn trim_to_non_empty(value: Option<&str>) -> Option { value .map(str::trim) @@ -79,9 +92,28 @@ fn missing_tailscale_message() -> String { async fn resolve_tailscale_binary() -> Result, String> { let mut failures: Vec = Vec::new(); for binary in tailscale_binary_candidates() { - let output = tokio_command(&binary).arg("version").output().await; + let output = tailscale_command(binary.as_os_str()) + .arg("version") + .output() + .await; match output { - Ok(version_output) => return Ok(Some((binary, version_output))), + Ok(version_output) => { + if version_output.status.success() { + return Ok(Some((binary, version_output))); + } + let stdout = trim_to_non_empty(std::str::from_utf8(&version_output.stdout).ok()); + let stderr = trim_to_non_empty(std::str::from_utf8(&version_output.stderr).ok()); + let detail = match (stdout, stderr) { + (Some(out), Some(err)) => format!("stdout: {out}; stderr: {err}"), + (Some(out), None) => format!("stdout: {out}"), + (None, Some(err)) => format!("stderr: {err}"), + (None, None) => "no output".to_string(), + }; + failures.push(format!( + "{}: tailscale version failed ({detail})", + OsStr::new(&binary).to_string_lossy() + )); + } Err(err) if err.kind() == ErrorKind::NotFound => continue, Err(err) => failures.push(format!("{}: {err}", OsStr::new(&binary).to_string_lossy())), } @@ -311,7 +343,7 @@ pub(crate) async fn tailscale_status() -> Result { let version = trim_to_non_empty(std::str::from_utf8(&version_output.stdout).ok()) .and_then(|raw| raw.lines().next().map(str::trim).map(str::to_string)); - let status_output = tokio_command(&tailscale_binary) + let status_output = tailscale_command(tailscale_binary.as_os_str()) .arg("status") .arg("--json") .output() @@ -337,7 +369,41 @@ pub(crate) async fn tailscale_status() -> Result { let payload = std::str::from_utf8(&status_output.stdout) .map_err(|err| format!("Invalid UTF-8 from tailscale status: {err}"))?; - tailscale_core::status_from_json(version, payload) + let stderr_text = trim_to_non_empty(std::str::from_utf8(&status_output.stderr).ok()); + if payload.trim().is_empty() { + let suffix = stderr_text + .as_deref() + .map(|value| format!(" stderr: {value}")) + .unwrap_or_default(); + return Err(format!( + "tailscale status --json returned empty output.{suffix}" + )); + } + match tailscale_core::status_from_json(version, payload) { + Ok(status) => Ok(status), + Err(err) => { + let trimmed_payload = payload.trim(); + let payload_preview = if trimmed_payload.is_empty() { + None + } else if trimmed_payload.len() > 200 { + Some(format!("{}…", &trimmed_payload[..200])) + } else { + Some(trimmed_payload.to_string()) + }; + let mut details = Vec::new(); + if let Some(stderr) = stderr_text { + details.push(format!("stderr: {stderr}")); + } + if let Some(preview) = payload_preview { + details.push(format!("stdout: {preview}")); + } + if details.is_empty() { + Err(err) + } else { + Err(format!("{err} ({})", details.join("; "))) + } + } + } } #[cfg(test)] diff --git a/src/features/settings/components/SettingsView.tsx b/src/features/settings/components/SettingsView.tsx index 57ad12c97..c808e62e5 100644 --- a/src/features/settings/components/SettingsView.tsx +++ b/src/features/settings/components/SettingsView.tsx @@ -76,6 +76,22 @@ import { type OrbitActionResult, } from "./settingsViewHelpers"; +const formatErrorMessage = (error: unknown, fallback: string) => { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "string") { + return error; + } + if (error && typeof error === "object" && "message" in error) { + const message = (error as { message?: unknown }).message; + if (typeof message === "string") { + return message; + } + } + return fallback; +}; + export type SettingsViewProps = { workspaceGroups: WorkspaceGroup[]; groupedWorkspaces: Array<{ @@ -673,7 +689,7 @@ export function SettingsView({ setTailscaleStatus(status); } catch (error) { setTailscaleStatusError( - error instanceof Error ? error.message : "Unable to load Tailscale status.", + formatErrorMessage(error, "Unable to load Tailscale status."), ); } finally { setTailscaleStatusBusy(false); @@ -690,9 +706,7 @@ export function SettingsView({ setTailscaleCommandPreview(preview); } catch (error) { setTailscaleCommandError( - error instanceof Error - ? error.message - : "Unable to build Tailscale daemon command.", + formatErrorMessage(error, "Unable to build Tailscale daemon command."), ); } finally { setTailscaleCommandBusy(false);