mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 12:51:34 +00:00
fix: macOS reports CLI-only Tailscale as not installed (#114179)
* fix(macos): detect CLI-only Tailscale * fix(macos): distinguish Tailscale app and CLI installs Co-authored-by: Nick <18488428+nickxma@users.noreply.github.com> * chore(macos): refresh native i18n inventory * test(pr): provide rg in artifact fixture * fix(macos): gate Tailscale interface evidence * chore(pr): drop superseded fixture shim * chore(macos): realign native i18n inventory * refactor(macos): keep Tailscale status guidance generic --------- Co-authored-by: Nick <18488428+nickxma@users.noreply.github.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -237,7 +237,7 @@ struct TailscaleIntegrationSection: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if self.effectiveService.isInstalled, !self.effectiveService.isRunning {
|
||||
if self.effectiveService.isAppInstalled, !self.effectiveService.isRunning {
|
||||
Button("Start Tailscale") { self.effectiveService.openTailscaleApp() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
|
||||
@@ -18,9 +18,12 @@ final class TailscaleService {
|
||||
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "tailscale")
|
||||
|
||||
/// Indicates if the Tailscale app is installed on the system.
|
||||
/// Indicates if a Tailscale installation or active daemon was detected.
|
||||
private(set) var isInstalled = false
|
||||
|
||||
/// Indicates if the GUI app is available for app-specific actions.
|
||||
private(set) var isAppInstalled = false
|
||||
|
||||
/// Indicates if Tailscale is currently running.
|
||||
private(set) var isRunning = false
|
||||
|
||||
@@ -40,12 +43,14 @@ final class TailscaleService {
|
||||
#if DEBUG
|
||||
init(
|
||||
isInstalled: Bool,
|
||||
isAppInstalled: Bool = false,
|
||||
isRunning: Bool,
|
||||
tailscaleHostname: String? = nil,
|
||||
tailscaleIP: String? = nil,
|
||||
statusError: String? = nil)
|
||||
{
|
||||
self.isInstalled = isInstalled
|
||||
self.isAppInstalled = isAppInstalled
|
||||
self.isRunning = isRunning
|
||||
self.tailscaleHostname = tailscaleHostname
|
||||
self.tailscaleIP = tailscaleIP
|
||||
@@ -59,7 +64,26 @@ final class TailscaleService {
|
||||
return installed
|
||||
}
|
||||
|
||||
private struct TailscaleAPIResponse: Codable {
|
||||
func checkCLIInstallation() -> Bool {
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||
let candidates = [
|
||||
"/usr/local/bin/tailscale",
|
||||
"/usr/local/bin/tailscaled",
|
||||
"/opt/homebrew/bin/tailscale",
|
||||
"/opt/homebrew/bin/tailscaled",
|
||||
"\(home)/go/bin/tailscale",
|
||||
"\(home)/go/bin/tailscaled",
|
||||
]
|
||||
let installed = Self.hasExecutableCLI(at: candidates)
|
||||
self.logger.info("Tailscale CLI installed: \(installed)")
|
||||
return installed
|
||||
}
|
||||
|
||||
static func hasExecutableCLI(at candidates: [String]) -> Bool {
|
||||
candidates.contains { FileManager.default.isExecutableFile(atPath: $0) }
|
||||
}
|
||||
|
||||
struct TailscaleAPIResponse: Codable {
|
||||
let status: String
|
||||
let deviceName: String
|
||||
let tailnetName: String
|
||||
@@ -102,13 +126,33 @@ final class TailscaleService {
|
||||
|
||||
func checkTailscaleStatus() async {
|
||||
let previousIP = self.tailscaleIP
|
||||
self.isInstalled = self.checkAppInstallation()
|
||||
if !self.isInstalled {
|
||||
self.isRunning = false
|
||||
self.tailscaleHostname = nil
|
||||
self.tailscaleIP = nil
|
||||
self.statusError = "Tailscale is not installed"
|
||||
} else if let apiResponse = await fetchTailscaleStatus() {
|
||||
let appInstalled = self.checkAppInstallation()
|
||||
let cliInstalled = self.checkCLIInstallation()
|
||||
let apiResponse = await self.fetchTailscaleStatus()
|
||||
self.applyStatusEvidence(
|
||||
appInstalled: appInstalled,
|
||||
cliInstalled: cliInstalled,
|
||||
apiResponse: apiResponse,
|
||||
fallbackIP: TailscaleNetwork.detectTailnetIPv4())
|
||||
|
||||
if previousIP != self.tailscaleIP {
|
||||
await GatewayEndpointStore.shared.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
func applyStatusEvidence(
|
||||
appInstalled: Bool,
|
||||
cliInstalled: Bool,
|
||||
apiResponse: TailscaleAPIResponse?,
|
||||
fallbackIP: String?)
|
||||
{
|
||||
self.isAppInstalled = appInstalled
|
||||
self.isInstalled = appInstalled || cliInstalled || apiResponse != nil
|
||||
// RFC 6598 space is not Tailscale-exclusive. Only trust an interface
|
||||
// fallback after the app, CLI, or local API proves this installation.
|
||||
let trustedFallbackIP = self.isInstalled ? fallbackIP : nil
|
||||
|
||||
if let apiResponse {
|
||||
self.isRunning = apiResponse.status.lowercased() == "running"
|
||||
|
||||
if self.isRunning {
|
||||
@@ -120,7 +164,7 @@ final class TailscaleService {
|
||||
.replacingOccurrences(of: ".tailscale.net", with: "")
|
||||
|
||||
self.tailscaleHostname = "\(deviceName).\(tailnetName).ts.net"
|
||||
self.tailscaleIP = apiResponse.iPv4
|
||||
self.tailscaleIP = apiResponse.iPv4 ?? trustedFallbackIP
|
||||
self.statusError = nil
|
||||
|
||||
self.logger.info(
|
||||
@@ -130,25 +174,28 @@ final class TailscaleService {
|
||||
self.tailscaleIP = nil
|
||||
self.statusError = "Tailscale is not running"
|
||||
}
|
||||
} else if let trustedFallbackIP {
|
||||
self.isRunning = true
|
||||
self.tailscaleHostname = nil
|
||||
self.tailscaleIP = trustedFallbackIP
|
||||
self.statusError = nil
|
||||
self.logger.info("Tailscale interface IP detected (fallback) ip=\(trustedFallbackIP, privacy: .public)")
|
||||
} else {
|
||||
self.isRunning = false
|
||||
self.tailscaleHostname = nil
|
||||
self.tailscaleIP = nil
|
||||
self.statusError = "Please start the Tailscale app"
|
||||
self.logger.info("Tailscale API not responding; app likely not running")
|
||||
}
|
||||
|
||||
if self.tailscaleIP == nil, let fallback = TailscaleNetwork.detectTailnetIPv4() {
|
||||
self.tailscaleIP = fallback
|
||||
if !self.isRunning {
|
||||
self.isRunning = true
|
||||
self.statusError = if appInstalled {
|
||||
"Please start the Tailscale app"
|
||||
} else if cliInstalled {
|
||||
"Please start the Tailscale daemon"
|
||||
} else {
|
||||
"Tailscale is not installed"
|
||||
}
|
||||
if appInstalled {
|
||||
self.logger.info("Tailscale API not responding; app likely not running")
|
||||
} else if cliInstalled {
|
||||
self.logger.info("Tailscale API not responding; CLI daemon likely not running")
|
||||
}
|
||||
self.statusError = nil
|
||||
self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)")
|
||||
}
|
||||
|
||||
if previousIP != self.tailscaleIP {
|
||||
await GatewayEndpointStore.shared.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,89 @@ import Testing
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct TailscaleIntegrationSectionTests {
|
||||
@Test func `cli installation requires an executable candidate`() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
let executable = tempDir.appendingPathComponent("tailscale")
|
||||
let nonExecutable = tempDir.appendingPathComponent("tailscaled")
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
try Data().write(to: executable)
|
||||
try Data().write(to: nonExecutable)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
|
||||
|
||||
#expect(TailscaleService.hasExecutableCLI(at: [executable.path]))
|
||||
#expect(!TailscaleService.hasExecutableCLI(at: [nonExecutable.path]))
|
||||
}
|
||||
|
||||
@Test func `cli-only tailscale status is detected as installed and running`() {
|
||||
let service = TailscaleService(isInstalled: false, isRunning: false)
|
||||
service.applyStatusEvidence(
|
||||
appInstalled: false,
|
||||
cliInstalled: true,
|
||||
apiResponse: TailscaleService.TailscaleAPIResponse(
|
||||
status: "Running",
|
||||
deviceName: "april",
|
||||
tailnetName: "tail7a0b9.ts.net",
|
||||
iPv4: "100.66.5.88"),
|
||||
fallbackIP: "100.66.5.88")
|
||||
|
||||
#expect(service.isInstalled)
|
||||
#expect(service.isRunning)
|
||||
#expect(service.tailscaleHostname == "april.tail7a0b9.ts.net")
|
||||
#expect(service.tailscaleIP == "100.66.5.88")
|
||||
#expect(service.statusError == nil)
|
||||
}
|
||||
|
||||
@Test func `installed cli-only tailscale without a running daemon is detected`() {
|
||||
let service = TailscaleService(isInstalled: false, isRunning: false)
|
||||
service.applyStatusEvidence(
|
||||
appInstalled: false,
|
||||
cliInstalled: true,
|
||||
apiResponse: nil,
|
||||
fallbackIP: nil)
|
||||
|
||||
#expect(service.isInstalled)
|
||||
#expect(!service.isAppInstalled)
|
||||
#expect(!service.isRunning)
|
||||
#expect(service.tailscaleHostname == nil)
|
||||
#expect(service.tailscaleIP == nil)
|
||||
#expect(service.statusError == "Please start the Tailscale daemon")
|
||||
}
|
||||
|
||||
@Test func `shared cgnat address alone is not treated as a tailscale installation`() {
|
||||
let service = TailscaleService(isInstalled: false, isRunning: false)
|
||||
service.applyStatusEvidence(
|
||||
appInstalled: false,
|
||||
cliInstalled: false,
|
||||
apiResponse: nil,
|
||||
fallbackIP: "100.66.5.88")
|
||||
|
||||
#expect(!service.isInstalled)
|
||||
#expect(!service.isAppInstalled)
|
||||
#expect(!service.isRunning)
|
||||
#expect(service.tailscaleHostname == nil)
|
||||
#expect(service.tailscaleIP == nil)
|
||||
#expect(service.statusError == "Tailscale is not installed")
|
||||
}
|
||||
|
||||
@Test func `known cli installation can use interface fallback`() {
|
||||
let service = TailscaleService(isInstalled: false, isRunning: false)
|
||||
service.applyStatusEvidence(
|
||||
appInstalled: false,
|
||||
cliInstalled: true,
|
||||
apiResponse: nil,
|
||||
fallbackIP: "100.66.5.88")
|
||||
|
||||
#expect(service.isInstalled)
|
||||
#expect(!service.isAppInstalled)
|
||||
#expect(service.isRunning)
|
||||
#expect(service.tailscaleHostname == nil)
|
||||
#expect(service.tailscaleIP == "100.66.5.88")
|
||||
#expect(service.statusError == nil)
|
||||
}
|
||||
|
||||
@Test func `tailscale section builds body when not installed`() {
|
||||
let service = TailscaleService(isInstalled: false, isRunning: false, statusError: "not installed")
|
||||
var view = TailscaleIntegrationSection(connectionMode: .local, isPaused: false)
|
||||
@@ -32,6 +115,7 @@ struct TailscaleIntegrationSectionTests {
|
||||
@Test func `tailscale section builds body for funnel mode`() {
|
||||
let service = TailscaleService(
|
||||
isInstalled: true,
|
||||
isAppInstalled: true,
|
||||
isRunning: false,
|
||||
tailscaleHostname: nil,
|
||||
tailscaleIP: nil,
|
||||
|
||||
Reference in New Issue
Block a user