diff --git a/mac/VibeTunnel/Core/Services/TailscaleService.swift b/mac/VibeTunnel/Core/Services/TailscaleService.swift index 8d1a3999..701b45cb 100644 --- a/mac/VibeTunnel/Core/Services/TailscaleService.swift +++ b/mac/VibeTunnel/Core/Services/TailscaleService.swift @@ -14,15 +14,18 @@ import os final class TailscaleService { static let shared = TailscaleService() + /// Tailscale local API endpoint + private static let tailscaleAPIEndpoint = "http://100.100.100.100/api/data" + + /// API request timeout in seconds + private static let apiTimeoutInterval: TimeInterval = 5.0 + /// Logger instance for debugging private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "TailscaleService") /// Indicates if Tailscale app is installed on the system private(set) var isInstalled = false - /// Indicates if Tailscale CLI is available - private(set) var isCLIAvailable = false - /// Indicates if Tailscale is currently running private(set) var isRunning = false @@ -35,9 +38,6 @@ final class TailscaleService { /// Error message if status check fails private(set) var statusError: String? - /// Path to the tailscale executable - private var tailscalePath: String? - private init() { Task { await checkTailscaleStatus() @@ -51,66 +51,49 @@ final class TailscaleService { return isAppInstalled } - /// Checks if Tailscale CLI is available - func checkCLIAvailability() async -> Bool { - let checkPaths = [ - "/Applications/Tailscale.app/Contents/MacOS/Tailscale", - "/usr/local/bin/tailscale", - "/opt/homebrew/bin/tailscale" - ] + /// Struct to decode Tailscale API response + private struct TailscaleAPIResponse: Codable { + let status: String + let deviceName: String + let tailnetName: String + let iPv4: String? - for path in checkPaths { - if FileManager.default.fileExists(atPath: path) { - logger.info("Tailscale CLI found at: \(path)") - tailscalePath = path - return true - } + private enum CodingKeys: String, CodingKey { + case status = "Status" + case deviceName = "DeviceName" + case tailnetName = "TailnetName" + case iPv4 = "IPv4" + } + } + + /// Fetches Tailscale status from the API + private func fetchTailscaleStatus() async -> TailscaleAPIResponse? { + guard let url = URL(string: Self.tailscaleAPIEndpoint) else { + logger.error("Invalid Tailscale API URL") + return nil } - // Also check if we can run the tailscale command using which do { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/which") - process.arguments = ["tailscale"] + // Configure URLSession with timeout + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = Self.apiTimeoutInterval + let session = URLSession(configuration: configuration) - // Set up PATH to include common installation directories - var environment = ProcessInfo.processInfo.environment - let additionalPaths = [ - "/usr/local/bin", - "/opt/homebrew/bin", - "/Applications/Tailscale.app/Contents/MacOS" - ] - if let currentPath = environment["PATH"] { - environment["PATH"] = "\(currentPath):\(additionalPaths.joined(separator: ":"))" - } else { - environment["PATH"] = additionalPaths.joined(separator: ":") + let (data, response) = try await session.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 + else { + logger.warning("Tailscale API returned non-200 status") + return nil } - process.environment = environment - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = pipe - - try process.run() - process.waitUntilExit() - - if process.terminationStatus == 0 { - let data = pipe.fileHandleForReading.readDataToEndOfFile() - if let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), - !output.isEmpty - { - logger.info("Tailscale CLI found at: \(output)") - tailscalePath = output - return true - } - } + let decoder = JSONDecoder() + return try decoder.decode(TailscaleAPIResponse.self, from: data) } catch { - logger.debug("Failed to check for tailscale command: \(error)") + logger.debug("Failed to fetch Tailscale status: \(error)") + return nil } - - logger.info("Tailscale CLI not found") - tailscalePath = nil - return false } /// Checks the current Tailscale status and updates properties @@ -119,7 +102,6 @@ final class TailscaleService { isInstalled = checkAppInstallation() guard isInstalled else { - isCLIAvailable = false isRunning = false tailscaleHostname = nil tailscaleIP = nil @@ -127,168 +109,40 @@ final class TailscaleService { return } - // Then check if CLI is available - isCLIAvailable = await checkCLIAvailability() + // Try to fetch status from API + if let apiResponse = await fetchTailscaleStatus() { + // Tailscale is running if API responds + isRunning = apiResponse.status.lowercased() == "running" - guard isCLIAvailable else { - isRunning = false - tailscaleHostname = nil - tailscaleIP = nil - statusError = nil // No error, just CLI not available - return - } - - // Check if Tailscale daemon is running by looking for the process - let isAppRunning = NSWorkspace.shared.runningApplications.contains { app in - app.bundleIdentifier == "io.tailscale.ipn.macsys" || - app.bundleIdentifier == "io.tailscale.ipn.macos" - } - - if !isAppRunning { - isRunning = false - tailscaleHostname = nil - tailscaleIP = nil - statusError = "Tailscale app is not running" - logger.info("Tailscale app is not running - skipping status check") - return - } + if isRunning { + // Extract hostname from device name and tailnet name + // Format: devicename.tailnetname (without .ts.net suffix) + let deviceName = apiResponse.deviceName.lowercased().replacingOccurrences(of: " ", with: "-") + let tailnetName = apiResponse.tailnetName + .replacingOccurrences(of: ".ts.net", with: "") + .replacingOccurrences(of: ".tailscale.net", with: "") - // If CLI is available, check status - do { - let process = Process() + tailscaleHostname = "\(deviceName).\(tailnetName).ts.net" + tailscaleIP = apiResponse.iPv4 + statusError = nil - // Use the discovered tailscale path - if let tailscalePath { - process.executableURL = URL(fileURLWithPath: tailscalePath) - process.arguments = ["status", "--json"] + logger + .info( + "Tailscale status: running=true, hostname=\(self.tailscaleHostname ?? "nil"), IP=\(self.tailscaleIP ?? "nil")" + ) } else { - // Fallback to env if path not found (shouldn't happen if isCLIAvailable is true) - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = ["tailscale", "status", "--json"] - - // Set up PATH environment variable - var environment = ProcessInfo.processInfo.environment - let additionalPaths = [ - "/usr/local/bin", - "/opt/homebrew/bin", - "/Applications/Tailscale.app/Contents/MacOS" - ] - if let currentPath = environment["PATH"] { - environment["PATH"] = "\(currentPath):\(additionalPaths.joined(separator: ":"))" - } else { - environment["PATH"] = additionalPaths.joined(separator: ":") - } - process.environment = environment - } - - let outputPipe = Pipe() - let errorPipe = Pipe() - process.standardOutput = outputPipe - process.standardError = errorPipe - - try process.run() - process.waitUntilExit() - - let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() - let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() - - if process.terminationStatus == 0 { - // Check if we have data - guard !outputData.isEmpty else { - isRunning = false - tailscaleHostname = nil - tailscaleIP = nil - statusError = "Tailscale returned empty response" - logger.warning("Tailscale status command returned empty data") - return - } - - // Log raw output for debugging - let rawOutput = String(data: outputData, encoding: .utf8) ?? "" - logger.debug("Tailscale raw output: \(rawOutput)") - - // Parse JSON output - do { - let jsonObject = try JSONSerialization.jsonObject(with: outputData) - - // Ensure it's a dictionary - guard let json = jsonObject as? [String: Any] else { - isRunning = false - tailscaleHostname = nil - tailscaleIP = nil - statusError = "Tailscale returned invalid JSON format (not a dictionary)" - logger.warning("Tailscale status returned non-dictionary JSON: \(type(of: jsonObject))") - return - } - - // Check if we're logged in and connected - if let self_ = json["Self"] as? [String: Any], - let dnsName = self_["DNSName"] as? String - { - // Check online status - it might be missing or false - let online = self_["Online"] as? Bool ?? false - isRunning = online - - // Use the DNSName which is already properly formatted for DNS - // Remove trailing dot if present - tailscaleHostname = dnsName.hasSuffix(".") ? String(dnsName.dropLast()) : dnsName - - // Get Tailscale IP - if let tailscaleIPs = self_["TailscaleIPs"] as? [String], - let firstIP = tailscaleIPs.first - { - tailscaleIP = firstIP - } - - statusError = nil - logger - .info( - "Tailscale status: running=\(online), hostname=\(self.tailscaleHostname ?? "nil"), IP=\(self.tailscaleIP ?? "nil")" - ) - } else { - isRunning = false - tailscaleHostname = nil - tailscaleIP = nil - statusError = "Tailscale is not logged in" - logger.warning("Tailscale status check failed - missing required fields in JSON") - logger.debug("JSON keys: \(json.keys.sorted())") - } - } catch let parseError { - isRunning = false - tailscaleHostname = nil - tailscaleIP = nil - - // Check if this is the GUI startup error - if rawOutput.contains("The Tailscale GUI failed to start") { - statusError = "Tailscale app is not running" - } else { - statusError = "Failed to parse Tailscale status: \(parseError.localizedDescription)" - } - - logger.error("JSON parsing error: \(parseError)") - logger.debug("Failed to parse data: \(rawOutput.prefix(200))...") - } - } else { - // Tailscale CLI returned error - let errorOutput = String(data: errorData, encoding: .utf8) ?? String(data: outputData, encoding: .utf8) ?? "Unknown error" - isRunning = false + // Tailscale installed but not running properly tailscaleHostname = nil tailscaleIP = nil - - if errorOutput.contains("not logged in") { - statusError = "Tailscale is not logged in" - } else if errorOutput.contains("stopped") { - statusError = "Tailscale is stopped" - } else { - statusError = "Tailscale error: \(errorOutput.trimmingCharacters(in: .whitespacesAndNewlines))" - } + statusError = "Tailscale is not running" } - } catch { - logger.error("Failed to check Tailscale status: \(error)") + } else { + // API not responding - Tailscale not running isRunning = false tailscaleHostname = nil tailscaleIP = nil - statusError = "Failed to check status: \(error.localizedDescription)" + statusError = "Please start the Tailscale app" + logger.info("Tailscale API not responding - app likely not running") } } diff --git a/mac/VibeTunnel/Info.plist b/mac/VibeTunnel/Info.plist index 80001b6b..39689cc6 100644 --- a/mac/VibeTunnel/Info.plist +++ b/mac/VibeTunnel/Info.plist @@ -16,14 +16,12 @@ NSExceptionDomains - vibetunnel.sh + 0.0.0.0 NSExceptionAllowsInsecureHTTPLoads - - NSIncludesSubdomains - localhost + 100.100.100.100 NSExceptionAllowsInsecureHTTPLoads @@ -33,11 +31,18 @@ NSExceptionAllowsInsecureHTTPLoads - 0.0.0.0 + localhost NSExceptionAllowsInsecureHTTPLoads + vibetunnel.sh + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + NSSupportsAutomaticTermination @@ -57,4 +62,4 @@ NSUserNotificationsUsageDescription VibeTunnel will notify you about important events such as new terminal connections, session status changes, and available updates. - \ No newline at end of file + diff --git a/mac/VibeTunnel/Presentation/Components/CustomMenuWindow.swift b/mac/VibeTunnel/Presentation/Components/CustomMenuWindow.swift index 1ccee224..4aebe6a4 100644 --- a/mac/VibeTunnel/Presentation/Components/CustomMenuWindow.swift +++ b/mac/VibeTunnel/Presentation/Components/CustomMenuWindow.swift @@ -140,7 +140,7 @@ final class CustomMenuWindow: NSPanel { // Activate app and show window NSApp.activate(ignoringOtherApps: true) makeKeyAndOrderFront(nil) - + // Force button state update again after window is shown DispatchQueue.main.async { [weak self] in self?.statusBarButton?.state = .on @@ -336,7 +336,7 @@ struct CustomMenuContainer: View { Color.white.opacity(0.5) } } - + private var backgroundMaterial: some ShapeStyle { switch colorScheme { case .dark: diff --git a/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift b/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift index f8e135a9..eab0ba81 100644 --- a/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift +++ b/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift @@ -99,10 +99,10 @@ final class StatusBarMenuManager: NSObject { // Update menu state to custom window FIRST before any async operations updateMenuState(.customWindow, button: button) - + // Ensure button state is set immediately and persistently button.state = .on - + // Force another button state update to ensure it sticks DispatchQueue.main.async { button.state = .on diff --git a/mac/VibeTunnel/Presentation/Components/VibeTunnelMenuView.swift b/mac/VibeTunnel/Presentation/Components/VibeTunnelMenuView.swift index 0bde45b2..14288155 100644 --- a/mac/VibeTunnel/Presentation/Components/VibeTunnelMenuView.swift +++ b/mac/VibeTunnel/Presentation/Components/VibeTunnelMenuView.swift @@ -279,42 +279,21 @@ struct ServerInfoHeader: View { ServerAddressRow() if ngrokService.isActive, let publicURL = ngrokService.publicUrl { - HStack(spacing: 4) { - Image(systemName: "network") - .font(.system(size: 10)) - .foregroundColor(.purple) - Text("ngrok:") - .font(.system(size: 11)) - .foregroundColor(.secondary) - Text(publicURL) - .font(.system(size: 11, design: .monospaced)) - .foregroundColor(.purple) - .lineLimit(1) - .truncationMode(.middle) - } + ServerAddressRow( + icon: "network", + label: "ngrok:", + address: publicURL, + url: URL(string: publicURL) + ) } if tailscaleService.isRunning, let hostname = tailscaleService.tailscaleHostname { - HStack(spacing: 4) { - Image(systemName: "shield") - .font(.system(size: 10)) - .foregroundColor(.blue) - Text("Tailscale:") - .font(.system(size: 11)) - .foregroundColor(.secondary) - Button(action: { - if let url = URL(string: "http://\(hostname)") { - NSWorkspace.shared.open(url) - } - }) { - Text(hostname) - .font(.system(size: 11, design: .monospaced)) - .foregroundColor(.blue) - .underline() - } - .buttonStyle(.plain) - .pointingHandCursor() - } + ServerAddressRow( + icon: "shield", + label: "Tailscale:", + address: hostname, + url: URL(string: "http://\(hostname):\(serverManager.port)") + ) } } } @@ -323,25 +302,42 @@ struct ServerInfoHeader: View { } struct ServerAddressRow: View { + let icon: String + let label: String + let address: String + let url: URL? + @Environment(ServerManager.self) var serverManager + init( + icon: String = "server.rack", + label: String = "Local:", + address: String? = nil, + url: URL? = nil + ) { + self.icon = icon + self.label = label + self.address = address ?? "" + self.url = url + } + var body: some View { HStack(spacing: 4) { - Image(systemName: "server.rack") + Image(systemName: icon) .font(.system(size: 10)) - .foregroundColor(Color(red: 0.0, green: 0.7, blue: 0.0)) - Text("Local:") + .foregroundColor(.green) + Text(label) .font(.system(size: 11)) .foregroundColor(.secondary) Button(action: { - if let url = URL(string: "http://\(serverAddress)") { + if let url = url ?? URL(string: "http://\(computedAddress)") { NSWorkspace.shared.open(url) } }) { - Text(serverAddress) + Text(computedAddress) .font(.system(size: 11, design: .monospaced)) - .foregroundColor(.accentColor) + .foregroundColor(.green) .underline() } .buttonStyle(.plain) @@ -349,7 +345,12 @@ struct ServerAddressRow: View { } } - private var serverAddress: String { + private var computedAddress: String { + if !address.isEmpty { + return address + } + + // Default behavior for local server let bindAddress = serverManager.bindAddress if bindAddress == "127.0.0.1" { return "127.0.0.1:\(serverManager.port)" @@ -380,7 +381,10 @@ struct ServerStatusBadge: View { .fill(isRunning ? Color(red: 0.0, green: 0.7, blue: 0.0).opacity(0.1) : Color.red.opacity(0.1)) .overlay( Capsule() - .stroke(isRunning ? Color(red: 0.0, green: 0.7, blue: 0.0).opacity(0.3) : Color.red.opacity(0.3), lineWidth: 0.5) + .stroke( + isRunning ? Color(red: 0.0, green: 0.7, blue: 0.0).opacity(0.3) : Color.red.opacity(0.3), + lineWidth: 0.5 + ) ) ) } @@ -700,9 +704,9 @@ struct SessionRow: View { private var activityColor: Color { if isActive { - Color(red: 1.0, green: 0.5, blue: 0.0) // Brighter, more saturated orange + Color(red: 1.0, green: 0.5, blue: 0.0) // Brighter, more saturated orange } else { - Color(red: 0.0, green: 0.7, blue: 0.0) // Darker, more visible green + Color(red: 0.0, green: 0.7, blue: 0.0) // Darker, more visible green } } @@ -714,7 +718,7 @@ struct SessionRow: View { private var hoverBackgroundColor: Color { colorScheme == .dark ? Color.accentColor.opacity(0.08) : Color.accentColor.opacity(0.15) } - + private var duration: String { // Parse ISO8601 date string with fractional seconds let formatter = ISO8601DateFormatter() diff --git a/mac/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift b/mac/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift index 2ce03dc5..e869ba5d 100644 --- a/mac/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift +++ b/mac/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift @@ -807,199 +807,92 @@ private struct TailscaleIntegrationSection: View { var body: some View { Section { VStack(alignment: .leading, spacing: 12) { - if tailscaleService.isInstalled { - // Tailscale app is installed - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - Text("Tailscale is installed") - .font(.callout) - - Spacer() - } - - if tailscaleService.isCLIAvailable { - // CLI is available, show status - if tailscaleService.isRunning || tailscaleService.tailscaleHostname != nil { - // Show Tailscale hostname and connection info (even if offline, as user might still - // connect) - VStack(alignment: .leading, spacing: 8) { - if let hostname = tailscaleService.tailscaleHostname { - HStack { - Text("Tailscale hostname:") - .font(.caption) - .foregroundColor(.secondary) - Text(hostname) - .font(.caption) - .textSelection(.enabled) - } - } - - if let tailscaleIP = tailscaleService.tailscaleIP { - HStack { - Text("Tailscale IP:") - .font(.caption) - .foregroundColor(.secondary) - Text(tailscaleIP) - .font(.caption) - .textSelection(.enabled) - } - } - - // Access URL - if let hostname = tailscaleService.tailscaleHostname { - VStack(alignment: .leading, spacing: 4) { - if accessMode == .localhost { - // Show warning if in localhost-only mode - HStack(spacing: 4) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.orange) - .font(.system(size: 12)) - Text( - "Server is in localhost-only mode. Change to 'Network' mode above to access via Tailscale." - ) - .font(.caption) - .foregroundColor(.orange) - } - .padding(.vertical, 4) - } else { - // Show the access URL - HStack(spacing: 5) { - Text("Access VibeTunnel at:") - .font(.caption) - .foregroundColor(.secondary) - - let urlString = "http://\(hostname):\(serverPort)" - if let url = URL(string: urlString) { - Link(urlString, destination: url) - .font(.caption) - .foregroundStyle(.blue) - } - } - - if !tailscaleService.isRunning { - HStack(spacing: 4) { - Image(systemName: "exclamationmark.triangle") - .foregroundColor(.orange) - .font(.system(size: 10)) - Text("Tailscale reports as offline but may still be accessible") - .font(.caption2) - .foregroundColor(.orange) - } - } - } - } - } - } + HStack { + if tailscaleService.isInstalled { + if tailscaleService.isRunning { + // Green dot: Tailscale is installed and running + Image(systemName: "circle.fill") + .foregroundColor(.green) + .font(.system(size: 10)) + Text("Tailscale is installed and running") + .font(.callout) } else { - // CLI available but Tailscale not running/logged in - VStack(alignment: .leading, spacing: 8) { - if let error = tailscaleService.statusError { - HStack { - Image(systemName: "exclamationmark.triangle") - .foregroundColor(.orange) - Text(error) - .font(.caption) - .foregroundColor(.secondary) - } - } - - HStack { - Button("Open Tailscale") { - tailscaleService.openTailscaleApp() - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - - if let url = URL(string: "https://tailscale.com/kb/1017/install/") { - Link("Setup Guide", destination: url) - .font(.caption) - } - } - } - } - } else { - // App installed but CLI not available - VStack(alignment: .leading, spacing: 12) { - HStack { - Image(systemName: "info.circle") - .foregroundColor(.blue) - Text("Tailscale CLI not available") - .font(.caption) - .foregroundColor(.secondary) - } - - Text( - "To see your Tailscale status here, install the Tailscale CLI. You can still use Tailscale - just open the app and connect to your tailnet." - ) - .font(.caption) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - - HStack(spacing: 12) { - Button("Open Tailscale") { - tailscaleService.openTailscaleApp() - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - - if let url = URL(string: "https://tailscale.com/kb/1090/install-tailscale-cli/") { - Link("Install CLI", destination: url) - .font(.caption) - } - } - } - } - } else { - // Tailscale is not installed - VStack(alignment: .leading, spacing: 12) { - HStack { - Image(systemName: "info.circle") - .foregroundColor(.blue) - Text("Tailscale is not installed") + // Orange dot: Tailscale is installed but not running + Image(systemName: "circle.fill") + .foregroundColor(.orange) + .font(.system(size: 10)) + Text("Tailscale is installed but not running") .font(.callout) } + } else { + // Yellow dot: Tailscale is not installed + Image(systemName: "circle.fill") + .foregroundColor(.yellow) + .font(.system(size: 10)) + Text("Tailscale is not installed") + .font(.callout) + } - Text( - "Tailscale creates a secure peer-to-peer VPN for accessing VibeTunnel from any device - your phone, tablet, or another computer." - ) - .font(.caption) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) + Spacer() + } - HStack(spacing: 12) { - Button(action: { - tailscaleService.openAppStore() - }) { - Text("App Store") - } - .buttonStyle(.link) - .controlSize(.small) - - Button(action: { - tailscaleService.openDownloadPage() - }) { - Text("Direct Download") - } - .buttonStyle(.link) - .controlSize(.small) - - Button(action: { - tailscaleService.openSetupGuide() - }) { - Text("Setup Guide") - } - .buttonStyle(.link) - .controlSize(.small) - } - - Button("Check Again") { - Task { - await tailscaleService.checkTailscaleStatus() - } + // Show additional content based on state + if !tailscaleService.isInstalled { + // Show download links when not installed + HStack(spacing: 12) { + Button(action: { + tailscaleService.openAppStore() + }) { + Text("App Store") } .buttonStyle(.link) - .font(.caption) + .controlSize(.small) + + Button(action: { + tailscaleService.openDownloadPage() + }) { + Text("Direct Download") + } + .buttonStyle(.link) + .controlSize(.small) + + Button(action: { + tailscaleService.openSetupGuide() + }) { + Text("Setup Guide") + } + .buttonStyle(.link) + .controlSize(.small) + } + } else if tailscaleService.isRunning { + // Show dashboard URL when running + if let hostname = tailscaleService.tailscaleHostname { + HStack(spacing: 5) { + Text("Access VibeTunnel at:") + .font(.caption) + .foregroundColor(.secondary) + + let urlString = "http://\(hostname):\(serverPort)" + if let url = URL(string: urlString) { + Link(urlString, destination: url) + .font(.caption) + .foregroundStyle(.blue) + } + } + + // Show warning if in localhost-only mode + if accessMode == .localhost { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + .font(.system(size: 12)) + Text( + "Server is in localhost-only mode. Change to 'Network' mode above to access via Tailscale." + ) + .font(.caption) + .foregroundStyle(.secondary) + } + } } } } @@ -1020,7 +913,7 @@ private struct TailscaleIntegrationSection: View { await tailscaleService.checkTailscaleStatus() logger .info( - "TailscaleIntegrationSection: Status check complete - isInstalled: \(tailscaleService.isInstalled), isCLIAvailable: \(tailscaleService.isCLIAvailable), isRunning: \(tailscaleService.isRunning), hostname: \(tailscaleService.tailscaleHostname ?? "nil")" + "TailscaleIntegrationSection: Status check complete - isInstalled: \(tailscaleService.isInstalled), isRunning: \(tailscaleService.isRunning), hostname: \(tailscaleService.tailscaleHostname ?? "nil")" ) // Set up timer for automatic updates every 5 seconds diff --git a/mac/VibeTunnelTests/TerminalLaunchTests.swift b/mac/VibeTunnelTests/TerminalLaunchTests.swift index 227e754a..aeddfcfd 100644 --- a/mac/VibeTunnelTests/TerminalLaunchTests.swift +++ b/mac/VibeTunnelTests/TerminalLaunchTests.swift @@ -74,8 +74,9 @@ struct TerminalLaunchTests { // iTerm2 URL with working directory if let url = Terminal.iTerm2.commandURL(for: command, workingDirectory: workDir) { #expect(url.absoluteString.contains("cd=")) - #expect(url.absoluteString - .contains(workDir.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "") + #expect( + url.absoluteString + .contains(workDir.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "") ) } }