diff --git a/apps/macos/Sources/Clawdis/ClawdisConfigFile.swift b/apps/macos/Sources/Clawdis/ClawdisConfigFile.swift index 8807f8f49..ce4418074 100644 --- a/apps/macos/Sources/Clawdis/ClawdisConfigFile.swift +++ b/apps/macos/Sources/Clawdis/ClawdisConfigFile.swift @@ -30,6 +30,23 @@ enum ClawdisConfigFile { } catch {} } + static func loadGatewayDict() -> [String: Any] { + let root = self.loadDict() + return root["gateway"] as? [String: Any] ?? [:] + } + + static func updateGatewayDict(_ mutate: (inout [String: Any]) -> Void) { + var root = self.loadDict() + var gateway = root["gateway"] as? [String: Any] ?? [:] + mutate(&gateway) + if gateway.isEmpty { + root.removeValue(forKey: "gateway") + } else { + root["gateway"] = gateway + } + self.saveDict(root) + } + static func browserControlEnabled(defaultValue: Bool = true) -> Bool { let root = self.loadDict() let browser = root["browser"] as? [String: Any] diff --git a/apps/macos/Sources/Clawdis/GeneralSettings.swift b/apps/macos/Sources/Clawdis/GeneralSettings.swift index f763b9566..6cb504d2a 100644 --- a/apps/macos/Sources/Clawdis/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdis/GeneralSettings.swift @@ -126,6 +126,9 @@ struct GeneralSettings: View { if self.state.connectionMode == .local { self.gatewayInstallerCard + TailscaleIntegrationSection( + connectionMode: self.state.connectionMode, + isPaused: self.state.isPaused) self.healthRow } @@ -645,6 +648,7 @@ struct GeneralSettings_Previews: PreviewProvider { static var previews: some View { GeneralSettings(state: .preview) .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) + .environment(TailscaleService.shared) } } #endif diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index 6bf6ee948..8e6a29427 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -16,6 +16,7 @@ struct ClawdisApp: App { @State private var isMenuPresented = false @State private var isPanelVisible = false @State private var menuInjector = MenuContextCardInjector.shared + @State private var tailscaleService = TailscaleService.shared @MainActor private func updateStatusHighlight() { @@ -66,6 +67,7 @@ struct ClawdisApp: App { Settings { SettingsRootView(state: self.state, updater: self.delegate.updaterController) .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading) + .environment(self.tailscaleService) } .defaultSize(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) .windowResizability(.contentSize) diff --git a/apps/macos/Sources/Clawdis/TailscaleIntegrationSection.swift b/apps/macos/Sources/Clawdis/TailscaleIntegrationSection.swift new file mode 100644 index 000000000..25c50ce97 --- /dev/null +++ b/apps/macos/Sources/Clawdis/TailscaleIntegrationSection.swift @@ -0,0 +1,370 @@ +import SwiftUI + +private enum GatewayTailscaleMode: String, CaseIterable, Identifiable { + case off + case serve + case funnel + + var id: String { self.rawValue } + + var label: String { + switch self { + case .off: "Off" + case .serve: "Tailnet (Serve)" + case .funnel: "Public (Funnel)" + } + } + + var description: String { + switch self { + case .off: + "No automatic Tailscale configuration." + case .serve: + "Tailnet-only HTTPS via Tailscale Serve." + case .funnel: + "Public HTTPS via Tailscale Funnel (requires auth)." + } + } +} + +private enum GatewayAuthMode: String, CaseIterable, Identifiable { + case system + case password + + var id: String { self.rawValue } + + var label: String { + switch self { + case .system: "System password" + case .password: "Shared password" + } + } +} + +struct TailscaleIntegrationSection: View { + let connectionMode: AppState.ConnectionMode + let isPaused: Bool + + @Environment(TailscaleService.self) private var tailscaleService + + @State private var hasLoaded = false + @State private var tailscaleMode: GatewayTailscaleMode = .off + @State private var requireCredentialsForServe = false + @State private var authMode: GatewayAuthMode = .system + @State private var username: String = "" + @State private var password: String = "" + @State private var statusMessage: String? + @State private var validationMessage: String? + @State private var statusTimer: Timer? + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Tailscale (dashboard access)") + .font(.callout.weight(.semibold)) + + self.statusRow + + if !self.tailscaleService.isInstalled { + self.installButtons + } else { + self.modePicker + if self.tailscaleMode != .off { + self.accessURLRow + } + if self.tailscaleMode == .serve { + self.serveAuthSection + } + if self.tailscaleMode == .funnel { + self.funnelAuthSection + } + } + + if self.connectionMode != .local { + Text("Local mode required. Update settings on the gateway host.") + .font(.caption) + .foregroundStyle(.secondary) + } + + if let validationMessage { + Text(validationMessage) + .font(.caption) + .foregroundStyle(.orange) + } else if let statusMessage { + Text(statusMessage) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(12) + .background(Color.gray.opacity(0.08)) + .cornerRadius(10) + .disabled(self.connectionMode != .local) + .task { + guard !self.hasLoaded else { return } + self.hasLoaded = true + self.loadConfig() + await self.tailscaleService.checkTailscaleStatus() + self.startStatusTimer() + } + .onDisappear { + self.stopStatusTimer() + } + .onChange(of: self.tailscaleMode) { _, _ in + self.applySettings() + } + .onChange(of: self.requireCredentialsForServe) { _, _ in + self.applySettings() + } + .onChange(of: self.authMode) { _, _ in + self.applySettings() + } + } + + private var statusRow: some View { + HStack(spacing: 8) { + Circle() + .fill(self.statusColor) + .frame(width: 10, height: 10) + Text(self.statusText) + .font(.callout) + Spacer() + Button("Refresh") { + Task { await self.tailscaleService.checkTailscaleStatus() } + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + + private var statusColor: Color { + if !self.tailscaleService.isInstalled { return .yellow } + if self.tailscaleService.isRunning { return .green } + return .orange + } + + private var statusText: String { + if !self.tailscaleService.isInstalled { return "Tailscale is not installed" } + if self.tailscaleService.isRunning { return "Tailscale is installed and running" } + return "Tailscale is installed but not running" + } + + private var installButtons: some View { + HStack(spacing: 12) { + Button("App Store") { self.tailscaleService.openAppStore() } + .buttonStyle(.link) + Button("Direct Download") { self.tailscaleService.openDownloadPage() } + .buttonStyle(.link) + Button("Setup Guide") { self.tailscaleService.openSetupGuide() } + .buttonStyle(.link) + } + .controlSize(.small) + } + + private var modePicker: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Exposure mode") + .font(.callout.weight(.semibold)) + Picker("Exposure", selection: self.$tailscaleMode) { + ForEach(GatewayTailscaleMode.allCases) { mode in + Text(mode.label).tag(mode) + } + } + .pickerStyle(.segmented) + Text(self.tailscaleMode.description) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private var accessURLRow: some View { + if let host = self.tailscaleService.tailscaleHostname { + let url = "https://\(host)/ui/" + HStack(spacing: 8) { + Text("Dashboard URL:") + .font(.caption) + .foregroundStyle(.secondary) + if let link = URL(string: url) { + Link(url, destination: link) + .font(.system(.caption, design: .monospaced)) + } else { + Text(url) + .font(.system(.caption, design: .monospaced)) + } + } + } else if !self.tailscaleService.isRunning { + Text("Start Tailscale to get your tailnet hostname.") + .font(.caption) + .foregroundStyle(.secondary) + } + + if self.tailscaleService.isInstalled, !self.tailscaleService.isRunning { + Button("Start Tailscale") { self.tailscaleService.openTailscaleApp() } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + + private var serveAuthSection: some View { + VStack(alignment: .leading, spacing: 8) { + Toggle("Require credentials", isOn: self.$requireCredentialsForServe) + .toggleStyle(.checkbox) + if self.requireCredentialsForServe { + self.authModePicker + self.authFields + } else { + Text("Serve uses Tailscale identity headers; no password required.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + private var funnelAuthSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Funnel requires authentication.") + .font(.caption) + .foregroundStyle(.secondary) + self.authModePicker + self.authFields + } + } + + private var authModePicker: some View { + Picker("Auth", selection: self.$authMode) { + ForEach(GatewayAuthMode.allCases) { mode in + Text(mode.label).tag(mode) + } + } + .pickerStyle(.segmented) + } + + @ViewBuilder + private var authFields: some View { + if self.authMode == .system { + TextField("Username (optional)", text: self.$username) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 240) + .onSubmit { self.applySettings() } + } else { + SecureField("Password", text: self.$password) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 240) + .onSubmit { self.applySettings() } + Text("Stored in ~/.clawdis/clawdis.json. Prefer CLAWDIS_GATEWAY_PASSWORD for production.") + .font(.caption) + .foregroundStyle(.secondary) + Button("Update password") { self.applySettings() } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + + private func loadConfig() { + let gateway = ClawdisConfigFile.loadGatewayDict() + let tailscale = gateway["tailscale"] as? [String: Any] ?? [:] + let modeRaw = (tailscale["mode"] as? String) ?? "off" + self.tailscaleMode = GatewayTailscaleMode(rawValue: modeRaw) ?? .off + + let auth = gateway["auth"] as? [String: Any] ?? [:] + let authModeRaw = auth["mode"] as? String + let allowTailscale = auth["allowTailscale"] as? Bool + + if let authModeRaw, authModeRaw == "password" { self.authMode = .password } + else { self.authMode = .system } + + self.username = auth["username"] as? String ?? "" + self.password = auth["password"] as? String ?? "" + + if self.tailscaleMode == .serve { + let usesExplicitAuth = authModeRaw == "password" || authModeRaw == "system" + if let allowTailscale, allowTailscale == false { + self.requireCredentialsForServe = true + } else { + self.requireCredentialsForServe = usesExplicitAuth + } + } else { + self.requireCredentialsForServe = false + } + } + + private func applySettings() { + guard self.hasLoaded else { return } + self.validationMessage = nil + self.statusMessage = nil + + let trimmedPassword = self.password.trimmingCharacters(in: .whitespacesAndNewlines) + if (self.tailscaleMode == .funnel || (self.tailscaleMode == .serve && self.requireCredentialsForServe)) + && self.authMode == .password + && trimmedPassword.isEmpty + { + self.validationMessage = "Password required for this mode." + return + } + + ClawdisConfigFile.updateGatewayDict { gateway in + var tailscale = gateway["tailscale"] as? [String: Any] ?? [:] + tailscale["mode"] = self.tailscaleMode.rawValue + gateway["tailscale"] = tailscale + + if self.tailscaleMode != .off { + gateway["bind"] = "loopback" + } + + guard self.tailscaleMode != .off else { return } + var auth = gateway["auth"] as? [String: Any] ?? [:] + + if self.tailscaleMode == .serve && !self.requireCredentialsForServe { + auth["allowTailscale"] = true + auth.removeValue(forKey: "mode") + auth.removeValue(forKey: "password") + auth.removeValue(forKey: "username") + } else { + auth["allowTailscale"] = false + auth["mode"] = self.authMode.rawValue + if self.authMode == .password { + auth["password"] = trimmedPassword + auth.removeValue(forKey: "username") + } else { + let trimmedUsername = self.username.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedUsername.isEmpty { + auth.removeValue(forKey: "username") + } else { + auth["username"] = trimmedUsername + } + auth.removeValue(forKey: "password") + } + } + + if auth.isEmpty { + gateway.removeValue(forKey: "auth") + } else { + gateway["auth"] = auth + } + } + + if self.connectionMode == .local, !self.isPaused { + self.statusMessage = "Saved to ~/.clawdis/clawdis.json. Restarting gateway…" + } else { + self.statusMessage = "Saved to ~/.clawdis/clawdis.json. Restart the gateway to apply." + } + self.restartGatewayIfNeeded() + } + + private func restartGatewayIfNeeded() { + guard self.connectionMode == .local, !self.isPaused else { return } + Task { await GatewayLaunchAgentManager.kickstart() } + } + + private func startStatusTimer() { + self.stopStatusTimer() + self.statusTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in + Task { await self.tailscaleService.checkTailscaleStatus() } + } + } + + private func stopStatusTimer() { + self.statusTimer?.invalidate() + self.statusTimer = nil + } +} diff --git a/apps/macos/Sources/Clawdis/TailscaleService.swift b/apps/macos/Sources/Clawdis/TailscaleService.swift new file mode 100644 index 000000000..f6bf00263 --- /dev/null +++ b/apps/macos/Sources/Clawdis/TailscaleService.swift @@ -0,0 +1,150 @@ +import AppKit +import Foundation +import Observation +import os + +/// Manages Tailscale integration and status checking. +@Observable +@MainActor +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 + + private let logger = Logger(subsystem: "com.steipete.clawdis", category: "tailscale") + + /// Indicates if the Tailscale app is installed on the system. + private(set) var isInstalled = false + + /// Indicates if Tailscale is currently running. + private(set) var isRunning = false + + /// The Tailscale hostname for this device (e.g., "my-mac.tailnet.ts.net"). + private(set) var tailscaleHostname: String? + + /// The Tailscale IPv4 address for this device. + private(set) var tailscaleIP: String? + + /// Error message if status check fails. + private(set) var statusError: String? + + private init() { + Task { await self.checkTailscaleStatus() } + } + + func checkAppInstallation() -> Bool { + let installed = FileManager.default.fileExists(atPath: "/Applications/Tailscale.app") + self.logger.info("Tailscale app installed: \(installed)") + return installed + } + + private struct TailscaleAPIResponse: Codable { + let status: String + let deviceName: String + let tailnetName: String + let iPv4: String? + + private enum CodingKeys: String, CodingKey { + case status = "Status" + case deviceName = "DeviceName" + case tailnetName = "TailnetName" + case iPv4 = "IPv4" + } + } + + private func fetchTailscaleStatus() async -> TailscaleAPIResponse? { + guard let url = URL(string: Self.tailscaleAPIEndpoint) else { + self.logger.error("Invalid Tailscale API URL") + return nil + } + + do { + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = Self.apiTimeoutInterval + let session = URLSession(configuration: configuration) + + let (data, response) = try await session.data(from: url) + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 + else { + self.logger.warning("Tailscale API returned non-200 status") + return nil + } + + let decoder = JSONDecoder() + return try decoder.decode(TailscaleAPIResponse.self, from: data) + } catch { + self.logger.debug("Failed to fetch Tailscale status: \(String(describing: error))") + return nil + } + } + + func checkTailscaleStatus() async { + self.isInstalled = self.checkAppInstallation() + guard self.isInstalled else { + self.isRunning = false + self.tailscaleHostname = nil + self.tailscaleIP = nil + self.statusError = "Tailscale is not installed" + return + } + + if let apiResponse = await fetchTailscaleStatus() { + self.isRunning = apiResponse.status.lowercased() == "running" + + if self.isRunning { + let deviceName = apiResponse.deviceName + .lowercased() + .replacingOccurrences(of: " ", with: "-") + let tailnetName = apiResponse.tailnetName + .replacingOccurrences(of: ".ts.net", with: "") + .replacingOccurrences(of: ".tailscale.net", with: "") + + self.tailscaleHostname = "\(deviceName).\(tailnetName).ts.net" + self.tailscaleIP = apiResponse.iPv4 + self.statusError = nil + + self.logger.info( + "Tailscale running host=\(self.tailscaleHostname ?? "nil") ip=\(self.tailscaleIP ?? "nil")") + } else { + self.tailscaleHostname = nil + self.tailscaleIP = nil + self.statusError = "Tailscale is not running" + } + } 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") + } + } + + func openTailscaleApp() { + if let url = URL(string: "file:///Applications/Tailscale.app") { + NSWorkspace.shared.open(url) + } + } + + func openAppStore() { + if let url = URL(string: "https://apps.apple.com/us/app/tailscale/id1475387142") { + NSWorkspace.shared.open(url) + } + } + + func openDownloadPage() { + if let url = URL(string: "https://tailscale.com/download/macos") { + NSWorkspace.shared.open(url) + } + } + + func openSetupGuide() { + if let url = URL(string: "https://tailscale.com/kb/1017/install/") { + NSWorkspace.shared.open(url) + } + } +}