diff --git a/README.md b/README.md index b4c02dbf..484979ab 100644 --- a/README.md +++ b/README.md @@ -77,13 +77,47 @@ The server runs as a standalone Bun executable with embedded Node.js modules, pr ## Remote Access Options ### Option 1: Tailscale (Recommended) -1. Install [Tailscale](https://tailscale.com) on your Mac and remote device -2. Access VibeTunnel at `http://[your-mac-name]:4020` + +[Tailscale](https://tailscale.com) creates a secure peer-to-peer VPN network between your devices. It's the most secure option as traffic stays within your private network without exposing VibeTunnel to the public internet. + +**How it works**: Tailscale creates an encrypted WireGuard tunnel between your devices, allowing them to communicate as if they were on the same local network, regardless of their physical location. + +**Setup Guide**: +1. Install Tailscale on your Mac: [Download from Mac App Store](https://apps.apple.com/us/app/tailscale/id1475387142) or [Direct Download](https://tailscale.com/download/macos) +2. Install Tailscale on your remote device: + - **iOS**: [Download from App Store](https://apps.apple.com/us/app/tailscale/id1470499037) + - **Android**: [Download from Google Play](https://play.google.com/store/apps/details?id=com.tailscale.ipn) + - **Other platforms**: [All Downloads](https://tailscale.com/download) +3. Sign in to both devices with the same account +4. Find your Mac's Tailscale hostname in the Tailscale menu bar app (e.g., `my-mac.tailnet-name.ts.net`) +5. Access VibeTunnel at `http://[your-tailscale-hostname]:4020` + +**Benefits**: +- End-to-end encrypted traffic +- No public internet exposure +- Works behind NAT and firewalls +- Zero configuration after initial setup ### Option 2: ngrok -1. Add your ngrok auth token in VibeTunnel settings -2. Enable ngrok tunneling -3. Share the generated URL + +[ngrok](https://ngrok.com) creates secure tunnels to your localhost, making VibeTunnel accessible via a public URL. Perfect for quick sharing or temporary access. + +**How it works**: ngrok establishes a secure tunnel from a public endpoint to your local VibeTunnel server, handling SSL/TLS encryption and providing a unique URL for access. + +**Setup Guide**: +1. Create a free ngrok account: [Sign up for ngrok](https://dashboard.ngrok.com/signup) +2. Copy your auth token from the [ngrok dashboard](https://dashboard.ngrok.com/get-started/your-authtoken) +3. Add the token in VibeTunnel settings (Settings → Remote Access → ngrok) +4. Enable ngrok tunneling in VibeTunnel +5. Share the generated `https://[random].ngrok-free.app` URL + +**Benefits**: +- Public HTTPS URL with SSL certificate +- No firewall configuration needed +- Built-in request inspection and replay +- Custom domains available (paid plans) + +**Note**: Free ngrok URLs change each time you restart the tunnel. Consider a paid plan for persistent URLs. ### Option 3: Local Network 1. Set a dashboard password in settings diff --git a/mac/VibeTunnel/Core/Services/ServerManager.swift b/mac/VibeTunnel/Core/Services/ServerManager.swift index 523c9b05..f8112324 100644 --- a/mac/VibeTunnel/Core/Services/ServerManager.swift +++ b/mac/VibeTunnel/Core/Services/ServerManager.swift @@ -60,7 +60,8 @@ class ServerManager { var bindAddress: String { get { - let mode = DashboardAccessMode(rawValue: UserDefaults.standard.string(forKey: "dashboardAccessMode") ?? "" + let mode = DashboardAccessMode( + rawValue: UserDefaults.standard.string(forKey: "dashboardAccessMode") ?? "" ) ?? .localhost return mode.bindAddress @@ -466,7 +467,8 @@ class ServerManager { let delay = baseDelay * pow(2.0, Double(consecutiveCrashes - 1)) logger - .info("Will restart server after \(delay) seconds (attempt \(self.consecutiveCrashes) of \(maxRetries))" + .info( + "Will restart server after \(delay) seconds (attempt \(self.consecutiveCrashes) of \(maxRetries))" ) // Wait with exponential backoff diff --git a/mac/VibeTunnel/Core/Services/TailscaleService.swift b/mac/VibeTunnel/Core/Services/TailscaleService.swift new file mode 100644 index 00000000..c67c75b1 --- /dev/null +++ b/mac/VibeTunnel/Core/Services/TailscaleService.swift @@ -0,0 +1,268 @@ +import AppKit +import Foundation +import Observation +import os + +/// Manages Tailscale integration and status checking. +/// +/// `TailscaleService` provides functionality to check if Tailscale is installed +/// and running on the system, and retrieves the device's Tailscale hostname +/// for network access. Unlike ngrok, Tailscale doesn't require auth tokens +/// as it uses system-level authentication. +@Observable +@MainActor +final class TailscaleService { + static let shared = TailscaleService() + + /// 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 + + /// The Tailscale hostname for this device (e.g., "my-mac.tailnet-name.ts.net") + private(set) var tailscaleHostname: String? + + /// The Tailscale IP address for this device + private(set) var tailscaleIP: String? + + /// 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() + } + } + + /// Checks if Tailscale app is installed + func checkAppInstallation() -> Bool { + let isAppInstalled = FileManager.default.fileExists(atPath: "/Applications/Tailscale.app") + logger.info("Tailscale app installed: \(isAppInstalled)") + 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" + ] + + for path in checkPaths { + if FileManager.default.fileExists(atPath: path) { + logger.info("Tailscale CLI found at: \(path)") + tailscalePath = path + return true + } + } + + // 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"] + + // 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: ":") + } + 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 + } + } + } catch { + logger.debug("Failed to check for tailscale command: \(error)") + } + + logger.info("Tailscale CLI not found") + tailscalePath = nil + return false + } + + /// Checks the current Tailscale status and updates properties + func checkTailscaleStatus() async { + // First check if app is installed + isInstalled = checkAppInstallation() + + guard isInstalled else { + isCLIAvailable = false + isRunning = false + tailscaleHostname = nil + tailscaleIP = nil + statusError = "Tailscale is not installed" + return + } + + // Then check if CLI is available + isCLIAvailable = await checkCLIAvailability() + + guard isCLIAvailable else { + isRunning = false + tailscaleHostname = nil + tailscaleIP = nil + statusError = nil // No error, just CLI not available + return + } + + // If CLI is available, check status + do { + let process = Process() + + // Use the discovered tailscale path + if let tailscalePath { + process.executableURL = URL(fileURLWithPath: tailscalePath) + process.arguments = ["status", "--json"] + } 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 pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + try process.run() + process.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + + if process.terminationStatus == 0 { + // Parse JSON output + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + // 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())") + } + } else { + isRunning = false + statusError = "Failed to parse Tailscale status" + } + } else { + // Tailscale CLI returned error + let errorOutput = String(data: data, encoding: .utf8) ?? "Unknown error" + isRunning = false + 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))" + } + } + } catch { + logger.error("Failed to check Tailscale status: \(error)") + isRunning = false + tailscaleHostname = nil + tailscaleIP = nil + statusError = "Failed to check status: \(error.localizedDescription)" + } + } + + /// Opens the Tailscale app + func openTailscaleApp() { + if let url = URL(string: "file:///Applications/Tailscale.app") { + NSWorkspace.shared.open(url) + } + } + + /// Opens the Mac App Store page for Tailscale + func openAppStore() { + if let url = URL(string: "https://apps.apple.com/us/app/tailscale/id1475387142") { + NSWorkspace.shared.open(url) + } + } + + /// Opens the Tailscale download page + func openDownloadPage() { + if let url = URL(string: "https://tailscale.com/download/macos") { + NSWorkspace.shared.open(url) + } + } + + /// Opens the Tailscale setup guide + func openSetupGuide() { + if let url = URL(string: "https://tailscale.com/kb/1017/install/") { + NSWorkspace.shared.open(url) + } + } +} diff --git a/mac/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift b/mac/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift index 4c83947d..2ce03dc5 100644 --- a/mac/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift +++ b/mac/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift @@ -24,6 +24,8 @@ struct DashboardSettingsView: View { private var serverManager @Environment(NgrokService.self) private var ngrokService + @Environment(TailscaleService.self) + private var tailscaleService @State private var ngrokAuthToken = "" @State private var ngrokStatus: NgrokTunnelStatus? @@ -65,6 +67,12 @@ struct DashboardSettingsView: View { serverManager: serverManager ) + TailscaleIntegrationSection( + tailscaleService: tailscaleService, + serverPort: serverPort, + accessMode: accessMode + ) + NgrokIntegrationSection( ngrokEnabled: $ngrokEnabled, ngrokAuthToken: $ngrokAuthToken, @@ -647,10 +655,12 @@ private struct NgrokIntegrationSection: View { Text("ngrok Integration") .font(.headline) } footer: { - Text("ngrok creates secure tunnels to your dashboard from anywhere.") - .font(.caption) - .frame(maxWidth: .infinity) - .multilineTextAlignment(.center) + Text( + "ngrok creates secure public tunnels to access your terminal sessions from any device (including phones and tablets) via the internet." + ) + .font(.caption) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) } } } @@ -783,6 +793,253 @@ private struct ErrorView: View { } } +// MARK: - Tailscale Integration Section + +private struct TailscaleIntegrationSection: View { + let tailscaleService: TailscaleService + let serverPort: String + let accessMode: DashboardAccessMode + + @State private var statusCheckTimer: Timer? + + private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "TailscaleIntegrationSection") + + 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) + } + } + } + } + } + } + } 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") + .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) + + 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() + } + } + .buttonStyle(.link) + .font(.caption) + } + } + } + } header: { + Text("Tailscale Integration") + .font(.headline) + } footer: { + Text( + "Recommended: Tailscale provides secure, private access to your terminal sessions from any device (including phones and tablets) without exposing VibeTunnel to the public internet." + ) + .font(.caption) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + } + .task { + // Check status when view appears + logger.info("TailscaleIntegrationSection: Starting initial status check") + await tailscaleService.checkTailscaleStatus() + logger + .info( + "TailscaleIntegrationSection: Status check complete - isInstalled: \(tailscaleService.isInstalled), isCLIAvailable: \(tailscaleService.isCLIAvailable), isRunning: \(tailscaleService.isRunning), hostname: \(tailscaleService.tailscaleHostname ?? "nil")" + ) + + // Set up timer for automatic updates every 5 seconds + statusCheckTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in + Task { + logger.debug("TailscaleIntegrationSection: Running periodic status check") + await tailscaleService.checkTailscaleStatus() + } + } + } + .onDisappear { + // Clean up timer when view disappears + statusCheckTimer?.invalidate() + statusCheckTimer = nil + logger.info("TailscaleIntegrationSection: Stopped status check timer") + } + } +} + // MARK: - Previews #Preview("Dashboard Settings") { diff --git a/mac/VibeTunnel/Utilities/SettingsOpener.swift b/mac/VibeTunnel/Utilities/SettingsOpener.swift index 69ed41ca..b7703f99 100644 --- a/mac/VibeTunnel/Utilities/SettingsOpener.swift +++ b/mac/VibeTunnel/Utilities/SettingsOpener.swift @@ -69,8 +69,9 @@ enum SettingsOpener { // Check by title if window.isVisible && window.styleMask.contains(.titled) && - (window.title.localizedCaseInsensitiveContains("settings") || - window.title.localizedCaseInsensitiveContains("preferences") + ( + window.title.localizedCaseInsensitiveContains("settings") || + window.title.localizedCaseInsensitiveContains("preferences") ) { return true diff --git a/mac/VibeTunnel/VibeTunnelApp.swift b/mac/VibeTunnel/VibeTunnelApp.swift index 364db14f..0c17a330 100644 --- a/mac/VibeTunnel/VibeTunnelApp.swift +++ b/mac/VibeTunnel/VibeTunnelApp.swift @@ -11,6 +11,7 @@ struct VibeTunnelApp: App { @State var sessionMonitor = SessionMonitor.shared @State var serverManager = ServerManager.shared @State var ngrokService = NgrokService.shared + @State var tailscaleService = TailscaleService.shared @State var permissionManager = SystemPermissionManager.shared @State var terminalLauncher = TerminalLauncher.shared @@ -36,6 +37,7 @@ struct VibeTunnelApp: App { .environment(sessionMonitor) .environment(serverManager) .environment(ngrokService) + .environment(tailscaleService) .environment(permissionManager) .environment(terminalLauncher) } @@ -52,6 +54,7 @@ struct VibeTunnelApp: App { .environment(sessionMonitor) .environment(serverManager) .environment(ngrokService) + .environment(tailscaleService) .environment(permissionManager) .environment(terminalLauncher) } else { @@ -66,6 +69,7 @@ struct VibeTunnelApp: App { .environment(sessionMonitor) .environment(serverManager) .environment(ngrokService) + .environment(tailscaleService) .environment(permissionManager) .environment(terminalLauncher) } @@ -90,6 +94,7 @@ struct VibeTunnelApp: App { .environment(sessionMonitor) .environment(serverManager) .environment(ngrokService) + .environment(tailscaleService) .environment(permissionManager) .environment(terminalLauncher) } label: {