From 93938aa6bd504a1191a52564c4cc7b51c87d8ef7 Mon Sep 17 00:00:00 2001 From: andyhtran Date: Wed, 29 Apr 2026 22:12:53 -0400 Subject: [PATCH] Fix Tailscale peer discovery in notarized builds The bundled Tailscale binary detects whether to run as CLI or GUI based on context. From a notarized app's subprocess (no TTY) it relaunches the GUI instead of returning JSON, so peer discovery silently returned an empty list. Setting TAILSCALE_BE_CLI=1 forces CLI mode. See tailscale/tailscale#16063 and #7140. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/CopyCat/Broadcast.swift | 3 ++- Sources/CopyCat/TailscaleDiscovery.swift | 11 +++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Sources/CopyCat/Broadcast.swift b/Sources/CopyCat/Broadcast.swift index 9118d90..b710faa 100644 --- a/Sources/CopyCat/Broadcast.swift +++ b/Sources/CopyCat/Broadcast.swift @@ -196,10 +196,11 @@ struct ShellResult: Sendable { /// Runs a process with stdin detached, capturing stdout and stderr separately /// so callers can distinguish "no output" from "failed silently". Returns /// `nil` only when the process couldn't be spawned at all (bad path, etc). -func runShell(_ exec: String, args: [String]) -> ShellResult? { +func runShell(_ exec: String, args: [String], env: [String: String]? = nil) -> ShellResult? { let p = Process() p.executableURL = URL(fileURLWithPath: exec) p.arguments = args + if let env { p.environment = env } let outPipe = Pipe() let errPipe = Pipe() p.standardOutput = outPipe diff --git a/Sources/CopyCat/TailscaleDiscovery.swift b/Sources/CopyCat/TailscaleDiscovery.swift index d14ae36..fd80003 100644 --- a/Sources/CopyCat/TailscaleDiscovery.swift +++ b/Sources/CopyCat/TailscaleDiscovery.swift @@ -24,7 +24,12 @@ enum TailscaleDiscovery { // hosts the user can pick from. static func allPeers() -> [TailscalePeer] { guard let bin = executablePath else { return [] } - guard let result = runShell(bin, args: ["status", "--json"]) else { + // TAILSCALE_BE_CLI=1: when the bundled Tailscale binary is invoked from + // a notarized app's subprocess (no TTY), it relaunches the GUI instead + // of running as CLI. The env var forces CLI mode. See + // tailscale/tailscale#16063 and #7140. + let env = ["TAILSCALE_BE_CLI": "1", "PATH": "/usr/bin:/bin"] + guard let result = runShell(bin, args: ["status", "--json"], env: env) else { Log.app.error("tailscale status: spawn failed for \(bin)") return [] } @@ -36,7 +41,9 @@ enum TailscaleDiscovery { Log.app.info("tailscale status exit=\(result.exitCode): \(stderr)") return [] } - return parseTailscalePeers(json: result.stdout) + let peers = parseTailscalePeers(json: result.stdout) + Log.app.info("tailscale status ok: \(peers.count) peers via \(bin)") + return peers } static func onlineHostnames() -> [String] {