From 84fa7333f0c9e2bcbb28a0627bd2961878c87cd8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 11 Jul 2025 07:43:53 +0200 Subject: [PATCH] Improve Cloudflare integration implementation (#306) Co-authored-by: Claudio Canales --- mac/VibeTunnel-Mac.xcodeproj/project.pbxproj | 2 +- .../EnvironmentValues+Services.swift | 24 +- .../Core/Services/CloudflareService.swift | 571 ++++++++++++++++++ mac/VibeTunnel/Info.plist | 2 +- .../CloudflareIntegrationSection.swift | 388 ++++++++++++ .../Settings/DashboardSettingsView.swift | 8 + .../Welcome/AccessDashboardPageView.swift | 2 +- mac/VibeTunnel/VibeTunnelApp.swift | 79 ++- .../CloudflareServiceTests.swift | 253 ++++++++ tauri/src/components/welcome-app.ts | 2 +- 10 files changed, 1293 insertions(+), 38 deletions(-) create mode 100644 mac/VibeTunnel/Core/Services/CloudflareService.swift create mode 100644 mac/VibeTunnel/Presentation/Views/Settings/CloudflareIntegrationSection.swift create mode 100644 mac/VibeTunnelTests/CloudflareServiceTests.swift diff --git a/mac/VibeTunnel-Mac.xcodeproj/project.pbxproj b/mac/VibeTunnel-Mac.xcodeproj/project.pbxproj index 302cfc4d..5a437252 100644 --- a/mac/VibeTunnel-Mac.xcodeproj/project.pbxproj +++ b/mac/VibeTunnel-Mac.xcodeproj/project.pbxproj @@ -618,4 +618,4 @@ /* End XCSwiftPackageProductDependency section */ }; rootObject = 788687E92DFF4FCB00B22C15 /* Project object */; -} +} \ No newline at end of file diff --git a/mac/VibeTunnel/Core/Extensions/EnvironmentValues+Services.swift b/mac/VibeTunnel/Core/Extensions/EnvironmentValues+Services.swift index 1aa3b3dd..de5c1e5d 100644 --- a/mac/VibeTunnel/Core/Extensions/EnvironmentValues+Services.swift +++ b/mac/VibeTunnel/Core/Extensions/EnvironmentValues+Services.swift @@ -18,6 +18,14 @@ private struct TerminalLauncherKey: EnvironmentKey { static let defaultValue: TerminalLauncher? = nil } +private struct TailscaleServiceKey: EnvironmentKey { + static let defaultValue: TailscaleService? = nil +} + +private struct CloudflareServiceKey: EnvironmentKey { + static let defaultValue: CloudflareService? = nil +} + // MARK: - Environment Values Extensions extension EnvironmentValues { @@ -40,6 +48,16 @@ extension EnvironmentValues { get { self[TerminalLauncherKey.self] } set { self[TerminalLauncherKey.self] = newValue } } + + var tailscaleService: TailscaleService? { + get { self[TailscaleServiceKey.self] } + set { self[TailscaleServiceKey.self] = newValue } + } + + var cloudflareService: CloudflareService? { + get { self[CloudflareServiceKey.self] } + set { self[CloudflareServiceKey.self] = newValue } + } } // MARK: - View Extensions @@ -51,7 +69,9 @@ extension View { serverManager: ServerManager? = nil, ngrokService: NgrokService? = nil, systemPermissionManager: SystemPermissionManager? = nil, - terminalLauncher: TerminalLauncher? = nil + terminalLauncher: TerminalLauncher? = nil, + tailscaleService: TailscaleService? = nil, + cloudflareService: CloudflareService? = nil ) -> some View { @@ -63,5 +83,7 @@ extension View { systemPermissionManager ?? SystemPermissionManager.shared ) .environment(\.terminalLauncher, terminalLauncher ?? TerminalLauncher.shared) + .environment(\.tailscaleService, tailscaleService ?? TailscaleService.shared) + .environment(\.cloudflareService, cloudflareService ?? CloudflareService.shared) } } diff --git a/mac/VibeTunnel/Core/Services/CloudflareService.swift b/mac/VibeTunnel/Core/Services/CloudflareService.swift new file mode 100644 index 00000000..34319710 --- /dev/null +++ b/mac/VibeTunnel/Core/Services/CloudflareService.swift @@ -0,0 +1,571 @@ +import AppKit +import Darwin +import Foundation +import Observation +import os + +/// Manages Cloudflare tunnel integration and status checking. +/// +/// `CloudflareService` provides functionality to check if cloudflared CLI is installed +/// and running on the system, and manages Quick Tunnels for exposing the local +/// VibeTunnel server. Unlike ngrok, cloudflared Quick Tunnels don't require auth tokens. +@Observable +@MainActor +final class CloudflareService { + static let shared = CloudflareService() + + /// Standard paths to check for cloudflared binary + private static let cloudflaredPaths = [ + "/usr/local/bin/cloudflared", + "/opt/homebrew/bin/cloudflared", + "/usr/bin/cloudflared" + ] + + // MARK: - Constants + + /// Periodic status check interval in seconds + private static let statusCheckInterval: TimeInterval = 5.0 + + /// Timeout for stopping tunnel in seconds + private static let stopTimeoutSeconds: UInt64 = 500_000_000 // 0.5 seconds in nanoseconds + + /// Timeout for process termination in seconds + private static let processTerminationTimeout: UInt64 = 2_000_000_000 // 2 seconds in nanoseconds + + /// Server stop timeout during app termination in milliseconds + private static let serverStopTimeoutMillis = 500 + + /// Logger instance for debugging + private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "CloudflareService") + + /// Indicates if cloudflared CLI is installed on the system + private(set) var isInstalled = false + + /// Indicates if a Cloudflare tunnel is currently running + private(set) var isRunning = false + + /// The public URL for the active tunnel (e.g., "https://random-words.trycloudflare.com") + private(set) var publicUrl: String? + + /// Error message if status check fails + private(set) var statusError: String? + + /// Path to the cloudflared binary if found + private(set) var cloudflaredPath: String? + + /// Currently running cloudflared process + private var cloudflaredProcess: Process? + + /// Task for monitoring tunnel status + private var statusMonitoringTask: Task? + + /// Background tasks for monitoring output + private var outputMonitoringTasks: [Task] = [] + + private init() { + Task { + await checkCloudflaredStatus() + } + } + + /// Checks if cloudflared CLI is installed + func checkCLIInstallation() -> Bool { + // Check standard paths first + for path in Self.cloudflaredPaths { + if FileManager.default.fileExists(atPath: path) { + cloudflaredPath = path + logger.info("Found cloudflared at: \(path)") + return true + } + } + + // Try using 'which' command as fallback + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/which") + process.arguments = ["cloudflared"] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + do { + try process.run() + process.waitUntilExit() + + if process.terminationStatus == 0 { + let data = pipe.fileHandleForReading.readDataToEndOfFile() + if let path = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), + !path.isEmpty { + cloudflaredPath = path + logger.info("Found cloudflared via 'which' at: \(path)") + return true + } + } + } catch { + logger.debug("Failed to run 'which cloudflared': \(error)") + } + + logger.info("cloudflared CLI not found") + return false + } + + /// Checks if there's a running cloudflared Quick Tunnel process + private func checkRunningProcess() -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep") + process.arguments = ["-f", "cloudflared.*tunnel.*--url"] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + do { + try process.run() + process.waitUntilExit() + + if process.terminationStatus == 0 { + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + return !output.isNilOrEmpty + } + } catch { + logger.debug("Failed to check running cloudflared processes: \(error)") + } + + return false + } + + /// Checks the current cloudflared status and updates properties + func checkCloudflaredStatus() async { + // First check if CLI is installed + isInstalled = checkCLIInstallation() + + guard isInstalled else { + isRunning = false + publicUrl = nil + statusError = "cloudflared is not installed" + return + } + + // Check if there's a running process + let wasRunning = isRunning + isRunning = checkRunningProcess() + + if isRunning { + statusError = nil + logger.info("cloudflared tunnel is running") + + // Don't clear publicUrl if we already have it + // Only clear it if we're transitioning from running to not running + if !wasRunning { + // Tunnel just started, URL will be set by startQuickTunnel + logger.info("Tunnel detected as running, preserving existing URL: \(self.publicUrl ?? "none")") + } + } else { + // Only clear URL when tunnel is not running + publicUrl = nil + statusError = "No active cloudflared tunnel" + logger.info("No active cloudflared tunnel found") + } + } + + /// Starts a Quick Tunnel using cloudflared + func startQuickTunnel(port: Int) async throws { + guard isInstalled, let binaryPath = cloudflaredPath else { + throw CloudflareError.notInstalled + } + + guard !isRunning else { + throw CloudflareError.tunnelAlreadyRunning + } + + logger.info("Starting cloudflared Quick Tunnel on port \(port)") + + let process = Process() + process.executableURL = URL(fileURLWithPath: binaryPath) + process.arguments = ["tunnel", "--url", "http://localhost:\(port)"] + + // Create pipes for monitoring + let outputPipe = Pipe() + let errorPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = errorPipe + + do { + try process.run() + cloudflaredProcess = process + + // Immediately mark as running since process started successfully + isRunning = true + statusError = nil + + // Start background monitoring for URL extraction + startTunnelURLMonitoring(outputPipe: outputPipe, errorPipe: errorPipe) + + // Start periodic monitoring + startPeriodicMonitoring() + + logger.info("Cloudflare tunnel process started successfully, URL will be available shortly") + + } catch { + // Clean up on failure + if let process = cloudflaredProcess { + process.terminate() + cloudflaredProcess = nil + } + + logger.error("Failed to start cloudflared process: \(error)") + throw CloudflareError.tunnelCreationFailed(error.localizedDescription) + } + } + + /// Sends a termination signal to the cloudflared process without waiting + /// This is used during app termination for quick cleanup + func sendTerminationSignal() { + logger.info("🚀 Quick termination signal requested") + + // Cancel monitoring tasks immediately + statusMonitoringTask?.cancel() + statusMonitoringTask = nil + outputMonitoringTasks.forEach { $0.cancel() } + outputMonitoringTasks.removeAll() + + // Send termination signal to our process if we have one + if let process = cloudflaredProcess { + logger.info("🚀 Sending SIGTERM to cloudflared process PID \(process.processIdentifier)") + process.terminate() + // Don't wait - let it clean up asynchronously + } + + // Also send pkill command but don't wait for it + let pkillProcess = Process() + pkillProcess.executableURL = URL(fileURLWithPath: "/usr/bin/pkill") + pkillProcess.arguments = ["-TERM", "-f", "cloudflared.*tunnel.*--url"] + try? pkillProcess.run() + // Don't wait for pkill to complete + + // Update state immediately + isRunning = false + publicUrl = nil + cloudflaredProcess = nil + + logger.info("🚀 Quick termination signal sent") + } + + /// Stops the running Quick Tunnel + func stopQuickTunnel() async { + logger.info("🛑 Starting cloudflared Quick Tunnel stop process") + + // Cancel monitoring tasks first + statusMonitoringTask?.cancel() + statusMonitoringTask = nil + outputMonitoringTasks.forEach { $0.cancel() } + outputMonitoringTasks.removeAll() + + // Try to terminate the process we spawned first + if let process = cloudflaredProcess { + logger.info("🛑 Found cloudflared process to terminate: PID \(process.processIdentifier)") + + // Send terminate signal + process.terminate() + + // For normal stops, we can wait a bit + try? await Task.sleep(nanoseconds: Self.stopTimeoutSeconds) + + // Check if it's still running and force kill if needed + if process.isRunning { + logger.warning("🛑 Process didn't terminate gracefully, sending SIGKILL") + process.interrupt() + + // Wait for exit with timeout + await withTaskGroup(of: Void.self) { group in + group.addTask { + process.waitUntilExit() + } + + group.addTask { + try? await Task.sleep(nanoseconds: Self.processTerminationTimeout) + } + + // Cancel remaining tasks after first one completes + await group.next() + group.cancelAll() + } + } + } + + // Clean up any orphaned processes + await cleanupOrphanedProcessesAsync() + + // Clean up state + cloudflaredProcess = nil + isRunning = false + publicUrl = nil + statusError = nil + + logger.info("🛑 Cloudflared Quick Tunnel stop completed") + } + + /// Async version of orphaned process cleanup for normal stops + private func cleanupOrphanedProcessesAsync() async { + await Task.detached { + // Run pkill in background + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/pkill") + process.arguments = ["-f", "cloudflared.*tunnel.*--url"] + + do { + try process.run() + process.waitUntilExit() + } catch { + // Ignore errors during cleanup + } + }.value + } + + /// Lightweight process check without the heavy sysctl operations + private func quickProcessCheck() -> Bool { + // Just check if our process reference is still valid and running + if let process = cloudflaredProcess, process.isRunning { + return true + } + + // Do a quick pgrep check without heavy processing + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep") + process.arguments = ["-f", "cloudflared.*tunnel.*--url"] + + do { + try process.run() + process.waitUntilExit() + return process.terminationStatus == 0 + } catch { + return false + } + } + + /// Start background monitoring for tunnel URL extraction + private func startTunnelURLMonitoring(outputPipe: Pipe, errorPipe: Pipe) { + // Cancel any existing monitoring tasks + outputMonitoringTasks.forEach { $0.cancel() } + outputMonitoringTasks.removeAll() + + // Monitor stdout using readabilityHandler + let stdoutHandle = outputPipe.fileHandleForReading + stdoutHandle.readabilityHandler = { [weak self] handle in + let data = handle.availableData + if !data.isEmpty { + if let output = String(data: data, encoding: .utf8) { + Task { @MainActor in + await self?.processOutput(output, isError: false) + } + } + } else { + // No more data, stop monitoring + handle.readabilityHandler = nil + } + } + + // Monitor stderr using readabilityHandler + let stderrHandle = errorPipe.fileHandleForReading + stderrHandle.readabilityHandler = { [weak self] handle in + let data = handle.availableData + if !data.isEmpty { + if let output = String(data: data, encoding: .utf8) { + Task { @MainActor in + await self?.processOutput(output, isError: true) + } + } + } else { + // No more data, stop monitoring + handle.readabilityHandler = nil + } + } + + // Store cleanup task for proper handler removal + let cleanupTask = Task.detached { @Sendable [weak self] in + // Wait for cancellation + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second + } + + // Clean up handlers when cancelled + await MainActor.run { + stdoutHandle.readabilityHandler = nil + stderrHandle.readabilityHandler = nil + self?.logger.info("🔍 Cleaned up file handle readability handlers") + } + } + + outputMonitoringTasks = [cleanupTask] + } + + /// Process output from cloudflared (called on MainActor) + private func processOutput(_ output: String, isError: Bool) async { + let prefix = isError ? "cloudflared stderr" : "cloudflared output" + logger.debug("\(prefix): \(output)") + + if let url = extractTunnelURL(from: output) { + logger.info("🔗 Setting publicUrl to: \(url)") + self.publicUrl = url + logger.info("🔗 publicUrl is now: \(self.publicUrl ?? "nil")") + } + } + + /// Start periodic monitoring to check if tunnel is still running + private func startPeriodicMonitoring() { + statusMonitoringTask?.cancel() + + statusMonitoringTask = Task.detached { @Sendable in + while !Task.isCancelled { + // Check periodically if the process is still running + try? await Task.sleep(nanoseconds: UInt64(Self.statusCheckInterval * 1_000_000_000)) + + await CloudflareService.shared.checkProcessStatus() + } + } + } + + /// Check if the tunnel process is still running (called on MainActor) + private func checkProcessStatus() async { + guard let process = cloudflaredProcess else { + // Process is gone, update status + isRunning = false + publicUrl = nil + statusError = "Tunnel process terminated" + return + } + + if !process.isRunning { + // Process died, update status + isRunning = false + publicUrl = nil + statusError = "Tunnel process terminated unexpectedly" + cloudflaredProcess = nil + return + } + } + + /// Extracts tunnel URL from cloudflared output + private func extractTunnelURL(from output: String) -> String? { + // More specific regex to match exactly the cloudflare tunnel URL format + // Matches: https://subdomain.trycloudflare.com with optional trailing slash + let pattern = #"https://[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.trycloudflare\.com/?(?:\s|$)"# + + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + logger.error("Failed to create regex for URL extraction") + return nil + } + + let range = NSRange(location: 0, length: output.utf16.count) + + if let match = regex.firstMatch(in: output, options: [], range: range) { + let urlRange = Range(match.range, in: output) + if let urlRange = urlRange { + var url = String(output[urlRange]).trimmingCharacters(in: .whitespacesAndNewlines) + // Remove trailing slash if present + if url.hasSuffix("/") { + url = String(url.dropLast()) + } + logger.info("Extracted tunnel URL: \(url)") + return url + } + } + + return nil + } + + /// Kills orphaned cloudflared tunnel processes using pkill + /// This is a simple, reliable cleanup method for processes that may have been orphaned + private func killOrphanedCloudflaredProcesses() { + logger.info("🔍 Cleaning up orphaned cloudflared tunnel processes") + + // Use pkill to terminate any cloudflared tunnel processes + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/pkill") + process.arguments = ["-f", "cloudflared.*tunnel.*--url"] + + do { + try process.run() + process.waitUntilExit() + + if process.terminationStatus == 0 { + logger.info("🔍 Successfully cleaned up orphaned cloudflared processes") + } else { + logger.debug("🔍 No orphaned cloudflared processes found") + } + } catch { + logger.error("🔍 Failed to run pkill: \(error)") + } + } + + /// Opens the Homebrew installation command + func openHomebrewInstall() { + let command = "brew install cloudflared" + let pasteboard = NSPasteboard.general + pasteboard.declareTypes([.string], owner: nil) + pasteboard.setString(command, forType: .string) + + logger.info("Copied Homebrew install command to clipboard: \(command)") + + // Optionally open Terminal to run the command + if let url = URL(string: "https://formulae.brew.sh/formula/cloudflared") { + NSWorkspace.shared.open(url) + } + } + + /// Opens the direct download page + func openDownloadPage() { + if let url = URL(string: "https://github.com/cloudflare/cloudflared/releases/latest") { + NSWorkspace.shared.open(url) + } + } + + /// Opens the setup guide + func openSetupGuide() { + if let url = URL(string: "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/") { + NSWorkspace.shared.open(url) + } + } +} + +/// Cloudflare-specific errors +enum CloudflareError: LocalizedError, Equatable { + case notInstalled + case tunnelAlreadyRunning + case tunnelCreationFailed(String) + case networkError(String) + case invalidOutput + case processTerminated + + var errorDescription: String? { + switch self { + case .notInstalled: + return "cloudflared is not installed" + case .tunnelAlreadyRunning: + return "A tunnel is already running" + case .tunnelCreationFailed(let message): + return "Failed to create tunnel: \(message)" + case .networkError(let message): + return "Network error: \(message)" + case .invalidOutput: + return "Invalid output from cloudflared" + case .processTerminated: + return "cloudflared process terminated unexpectedly" + } + } +} + +// MARK: - String Extensions + +private extension String { + var isNilOrEmpty: Bool { + return self.isEmpty + } +} + +private extension Optional where Wrapped == String { + var isNilOrEmpty: Bool { + return self?.isEmpty ?? true + } +} \ No newline at end of file diff --git a/mac/VibeTunnel/Info.plist b/mac/VibeTunnel/Info.plist index c752a05c..0b079727 100644 --- a/mac/VibeTunnel/Info.plist +++ b/mac/VibeTunnel/Info.plist @@ -48,7 +48,7 @@ NSSupportsAutomaticTermination NSSupportsSuddenTermination - + SUEnableAutomaticChecks SUFeedURL diff --git a/mac/VibeTunnel/Presentation/Views/Settings/CloudflareIntegrationSection.swift b/mac/VibeTunnel/Presentation/Views/Settings/CloudflareIntegrationSection.swift new file mode 100644 index 00000000..ddedafbf --- /dev/null +++ b/mac/VibeTunnel/Presentation/Views/Settings/CloudflareIntegrationSection.swift @@ -0,0 +1,388 @@ +import SwiftUI +import os.log + +/// CloudflareIntegrationSection displays Cloudflare tunnel status and management controls +/// Following the same pattern as TailscaleIntegrationSection +struct CloudflareIntegrationSection: View { + let cloudflareService: CloudflareService + let serverPort: String + let accessMode: DashboardAccessMode + + @State private var statusCheckTimer: Timer? + @State private var toggleTimeoutTimer: Timer? + @State private var isTogglingTunnel = false + @State private var tunnelEnabled = false + + private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "CloudflareIntegrationSection") + + // MARK: - Constants + private let statusCheckInterval: TimeInterval = 10.0 // seconds + private let startTimeoutInterval: TimeInterval = 15.0 // seconds + private let stopTimeoutInterval: TimeInterval = 10.0 // seconds + + var body: some View { + Section { + VStack(alignment: .leading, spacing: 12) { + // Status display + HStack { + if cloudflareService.isInstalled { + if cloudflareService.isRunning { + // Green dot: cloudflared is installed and tunnel is running + Image(systemName: "circle.fill") + .foregroundColor(.green) + .font(.system(size: 10)) + Text("Cloudflare tunnel is running") + .font(.callout) + } else { + // Orange dot: cloudflared is installed but tunnel not running + Image(systemName: "circle.fill") + .foregroundColor(.orange) + .font(.system(size: 10)) + Text("cloudflared is installed") + .font(.callout) + } + } else { + // Yellow dot: cloudflared is not installed + Image(systemName: "circle.fill") + .foregroundColor(.yellow) + .font(.system(size: 10)) + Text("cloudflared is not installed") + .font(.callout) + } + + Spacer() + } + + // Show additional content based on state + if !cloudflareService.isInstalled { + // Show installation links when not installed + HStack(spacing: 12) { + Button(action: { + cloudflareService.openHomebrewInstall() + }, label: { + Text("Homebrew") + }) + .buttonStyle(.link) + .controlSize(.small) + + Button(action: { + cloudflareService.openDownloadPage() + }, label: { + Text("Direct Download") + }) + .buttonStyle(.link) + .controlSize(.small) + + Button(action: { + cloudflareService.openSetupGuide() + }, label: { + Text("Setup Guide") + }) + .buttonStyle(.link) + .controlSize(.small) + } + } else { + // Show tunnel controls when cloudflared is installed + VStack(alignment: .leading, spacing: 8) { + // Tunnel toggle + HStack { + Toggle("Enable Quick Tunnel", isOn: $tunnelEnabled) + .disabled(isTogglingTunnel) + .onChange(of: tunnelEnabled) { _, newValue in + if newValue { + startTunnel() + } else { + stopTunnel() + } + } + + if isTogglingTunnel { + ProgressView() + .scaleEffect(0.7) + } else if cloudflareService.isRunning { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Connected") + .font(.caption) + .foregroundColor(.secondary) + } + } + + // Public URL display + if let publicUrl = cloudflareService.publicUrl, !publicUrl.isEmpty { + PublicURLView(url: publicUrl) + } + + // Error display + if let error = cloudflareService.statusError, !error.isEmpty { + ErrorView(error: error) + } + } + } + } + } header: { + Text("Cloudflare Integration") + .font(.headline) + } footer: { + Text( + "Cloudflare Quick Tunnels provide free, secure public access to your terminal sessions from any device. No account required." + ) + .font(.caption) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + } + .task { + // Reset any stuck toggling state first + if isTogglingTunnel { + logger.warning("CloudflareIntegrationSection: Found stuck isTogglingTunnel state, resetting") + isTogglingTunnel = false + } + + // Check status when view appears + logger.info("CloudflareIntegrationSection: Starting initial status check, isTogglingTunnel: \(isTogglingTunnel)") + await cloudflareService.checkCloudflaredStatus() + await syncUIWithService() + + // Set up timer for automatic updates + statusCheckTimer = Timer.scheduledTimer(withTimeInterval: statusCheckInterval, repeats: true) { _ in + Task { @MainActor in + logger.debug("CloudflareIntegrationSection: Running periodic status check, isTogglingTunnel: \(isTogglingTunnel)") + // Only check if we're not currently toggling + if !isTogglingTunnel { + await cloudflareService.checkCloudflaredStatus() + await syncUIWithService() + } else { + logger.debug("CloudflareIntegrationSection: Skipping periodic check while toggling") + } + } + } + } + .onDisappear { + // Clean up timers when view disappears + statusCheckTimer?.invalidate() + statusCheckTimer = nil + toggleTimeoutTimer?.invalidate() + toggleTimeoutTimer = nil + logger.info("CloudflareIntegrationSection: Stopped timers") + } + } + + // MARK: - Private Methods + + private func syncUIWithService() async { + await MainActor.run { + let wasEnabled = tunnelEnabled + let oldUrl = cloudflareService.publicUrl + + tunnelEnabled = cloudflareService.isRunning + + if wasEnabled != tunnelEnabled { + logger.info("CloudflareIntegrationSection: Tunnel enabled changed: \(wasEnabled) -> \(tunnelEnabled)") + } + + if oldUrl != cloudflareService.publicUrl { + logger.info("CloudflareIntegrationSection: URL changed: \(oldUrl ?? "nil") -> \(cloudflareService.publicUrl ?? "nil")") + } + + logger.info("CloudflareIntegrationSection: Synced UI - isRunning: \(cloudflareService.isRunning), publicUrl: \(cloudflareService.publicUrl ?? "nil")") + } + } + + private func startTunnel() { + guard !isTogglingTunnel else { + logger.warning("Already toggling tunnel, ignoring start request") + return + } + + isTogglingTunnel = true + logger.info("Starting Cloudflare Quick Tunnel on port \(serverPort)") + + // Set up timeout to force reset if stuck + toggleTimeoutTimer?.invalidate() + toggleTimeoutTimer = Timer.scheduledTimer(withTimeInterval: startTimeoutInterval, repeats: false) { _ in + Task { @MainActor in + if isTogglingTunnel { + logger.error("CloudflareIntegrationSection: Tunnel start timed out, force resetting isTogglingTunnel") + isTogglingTunnel = false + tunnelEnabled = false + } + } + } + + Task { + defer { + // Always reset toggling state and cancel timeout + Task { @MainActor in + toggleTimeoutTimer?.invalidate() + toggleTimeoutTimer = nil + isTogglingTunnel = false + logger.info("CloudflareIntegrationSection: Reset isTogglingTunnel = false") + } + } + + do { + let port = Int(serverPort) ?? 4020 + logger.info("Calling startQuickTunnel with port \(port)") + try await cloudflareService.startQuickTunnel(port: port) + logger.info("Cloudflare tunnel started successfully, URL: \(cloudflareService.publicUrl ?? "nil")") + + // Sync UI with service state + await syncUIWithService() + + } catch { + logger.error("Failed to start Cloudflare tunnel: \(error)") + + // Reset toggle on failure + await MainActor.run { + tunnelEnabled = false + } + } + } + } + + private func stopTunnel() { + guard !isTogglingTunnel else { + logger.warning("Already toggling tunnel, ignoring stop request") + return + } + + isTogglingTunnel = true + logger.info("Stopping Cloudflare Quick Tunnel") + + // Set up timeout to force reset if stuck + toggleTimeoutTimer?.invalidate() + toggleTimeoutTimer = Timer.scheduledTimer(withTimeInterval: stopTimeoutInterval, repeats: false) { _ in + Task { @MainActor in + if isTogglingTunnel { + logger.error("CloudflareIntegrationSection: Tunnel stop timed out, force resetting isTogglingTunnel") + isTogglingTunnel = false + } + } + } + + Task { + defer { + // Always reset toggling state and cancel timeout + Task { @MainActor in + toggleTimeoutTimer?.invalidate() + toggleTimeoutTimer = nil + isTogglingTunnel = false + logger.info("CloudflareIntegrationSection: Reset isTogglingTunnel = false after stop") + } + } + + await cloudflareService.stopQuickTunnel() + logger.info("Cloudflare tunnel stopped") + + // Sync UI with service state + await syncUIWithService() + } + } +} + +// MARK: - Reusable Components + +/// Displays a public URL with copy functionality +private struct PublicURLView: View { + let url: String + + @State private var showCopiedFeedback = false + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Public URL:") + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + + Button(action: { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(url, forType: .string) + withAnimation { + showCopiedFeedback = true + } + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + withAnimation { + showCopiedFeedback = false + } + } + }, label: { + Image(systemName: showCopiedFeedback ? "checkmark" : "doc.on.doc") + .foregroundColor(showCopiedFeedback ? .green : .accentColor) + }) + .buttonStyle(.borderless) + .help("Copy URL") + } + + HStack { + Text(url) + .font(.caption) + .foregroundColor(.blue) + .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.middle) + + Spacer() + + Button(action: { + if let nsUrl = URL(string: url) { + NSWorkspace.shared.open(nsUrl) + } + }, label: { + Image(systemName: "arrow.up.right.square") + .foregroundColor(.accentColor) + }) + .buttonStyle(.borderless) + .help("Open in Browser") + } + } + .padding(.vertical, 4) + .padding(.horizontal, 8) + .background(Color.gray.opacity(0.1)) + .cornerRadius(6) + } +} + +/// Displays error messages with warning icon +private struct ErrorView: View { + let error: String + + var body: some View { + HStack { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.red) + Text(error) + .font(.caption) + .foregroundColor(.red) + .lineLimit(2) + } + .padding(.vertical, 4) + .padding(.horizontal, 8) + .background(Color.red.opacity(0.1)) + .cornerRadius(6) + } +} + +// MARK: - Previews + +#Preview("Cloudflare Integration - Not Installed") { + CloudflareIntegrationSection( + cloudflareService: CloudflareService.shared, + serverPort: "4020", + accessMode: .network + ) + .frame(width: 500) + .formStyle(.grouped) +} + +#Preview("Cloudflare Integration - Installed") { + CloudflareIntegrationSection( + cloudflareService: CloudflareService.shared, + serverPort: "4020", + accessMode: .network + ) + .frame(width: 500) + .formStyle(.grouped) +} \ No newline at end of file diff --git a/mac/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift b/mac/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift index bdedf8df..15504c9b 100644 --- a/mac/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift +++ b/mac/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift @@ -26,6 +26,8 @@ struct DashboardSettingsView: View { private var ngrokService @Environment(TailscaleService.self) private var tailscaleService + @Environment(CloudflareService.self) + private var cloudflareService @State private var ngrokAuthToken = "" @State private var ngrokStatus: NgrokTunnelStatus? @@ -73,6 +75,12 @@ struct DashboardSettingsView: View { accessMode: accessMode ) + CloudflareIntegrationSection( + cloudflareService: cloudflareService, + serverPort: serverPort, + accessMode: accessMode + ) + NgrokIntegrationSection( ngrokEnabled: $ngrokEnabled, ngrokAuthToken: $ngrokAuthToken, diff --git a/mac/VibeTunnel/Presentation/Views/Welcome/AccessDashboardPageView.swift b/mac/VibeTunnel/Presentation/Views/Welcome/AccessDashboardPageView.swift index b41f6259..6b20ff18 100644 --- a/mac/VibeTunnel/Presentation/Views/Welcome/AccessDashboardPageView.swift +++ b/mac/VibeTunnel/Presentation/Views/Welcome/AccessDashboardPageView.swift @@ -33,7 +33,7 @@ struct AccessDashboardPageView: View { .fontWeight(.semibold) Text( - "To access your terminals from any device, create a tunnel from your device.\n\nThis can be done via **ngrok** in settings or **Tailscale** (recommended)." + "To access your terminals from any device, create a tunnel from your device.\n\nThis can be done via **ngrok** in settings or **Cloudflare** or **Tailscale**." ) .font(.body) .foregroundColor(.secondary) diff --git a/mac/VibeTunnel/VibeTunnelApp.swift b/mac/VibeTunnel/VibeTunnelApp.swift index 1c3e0451..e5e87639 100644 --- a/mac/VibeTunnel/VibeTunnelApp.swift +++ b/mac/VibeTunnel/VibeTunnelApp.swift @@ -16,6 +16,7 @@ struct VibeTunnelApp: App { @State var serverManager = ServerManager.shared @State var ngrokService = NgrokService.shared @State var tailscaleService = TailscaleService.shared + @State var cloudflareService = CloudflareService.shared @State var permissionManager = SystemPermissionManager.shared @State var terminalLauncher = TerminalLauncher.shared @State var gitRepositoryMonitor = GitRepositoryMonitor() @@ -45,6 +46,7 @@ struct VibeTunnelApp: App { .environment(serverManager) .environment(ngrokService) .environment(tailscaleService) + .environment(cloudflareService) .environment(permissionManager) .environment(terminalLauncher) .environment(gitRepositoryMonitor) @@ -64,6 +66,7 @@ struct VibeTunnelApp: App { .environment(serverManager) .environment(ngrokService) .environment(tailscaleService) + .environment(cloudflareService) .environment(permissionManager) .environment(terminalLauncher) .environment(gitRepositoryMonitor) @@ -83,6 +86,7 @@ struct VibeTunnelApp: App { .environment(serverManager) .environment(ngrokService) .environment(tailscaleService) + .environment(cloudflareService) .environment(permissionManager) .environment(terminalLauncher) .environment(gitRepositoryMonitor) @@ -293,6 +297,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser repositoryDiscovery: repositoryDiscoveryService ) } + + // Set up multi-layer cleanup for cloudflared processes + setupMultiLayerCleanup() } } @@ -415,63 +422,56 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } func applicationWillTerminate(_ notification: Notification) { + logger.info("🚨 applicationWillTerminate called - starting cleanup process") + let processInfo = ProcessInfo.processInfo let isRunningInTests = processInfo.environment["XCTestConfigurationFilePath"] != nil || processInfo.environment["XCTestBundlePath"] != nil || processInfo.environment["XCTestSessionIdentifier"] != nil || processInfo.arguments.contains("-XCTest") || NSClassFromString("XCTestCase") != nil - let isRunningInPreview = processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" - #if DEBUG - let isRunningInDebug = true - #else - let isRunningInDebug = processInfo.environment["DYLD_INSERT_LIBRARIES"]? - .contains("libMainThreadChecker.dylib") ?? false || - processInfo.environment["__XCODE_BUILT_PRODUCTS_DIR_PATHS"] != nil - #endif - + // Skip cleanup during tests if isRunningInTests { logger.info("Running in test mode - skipping termination cleanup") return } - - // Terminal control is now handled via SharedUnixSocketManager - // No explicit stop needed as it's cleaned up with the socket manager - - // Stop HTTP server synchronously to ensure it completes before app exits + + // Ultra-fast cleanup for cloudflared - just send signals and exit + if let cloudflareService = app?.cloudflareService, cloudflareService.isRunning { + logger.info("🔥 Sending quick termination signal to Cloudflare") + cloudflareService.sendTerminationSignal() + } + + // Stop HTTP server with very short timeout if let serverManager = app?.serverManager { let semaphore = DispatchSemaphore(value: 0) Task { await serverManager.stop() semaphore.signal() } - // Wait up to 5 seconds for server to stop - let timeout = DispatchTime.now() + .seconds(5) - if semaphore.wait(timeout: timeout) == .timedOut { - logger.warning("Server stop timed out during app termination") - } + // Only wait 0.5 seconds max + _ = semaphore.wait(timeout: .now() + .milliseconds(500)) + } + + // Remove observers (quick operations) + #if !DEBUG + if !isRunningInTests { + DistributedNotificationCenter.default().removeObserver( + self, + name: Self.showSettingsNotification, + object: nil + ) } - - // Remove distributed notification observer - #if DEBUG - // Skip removing observer in debug builds - #else - if !isRunningInPreview && !isRunningInTests && !isRunningInDebug { - DistributedNotificationCenter.default().removeObserver( - self, - name: Self.showSettingsNotification, - object: nil - ) - } #endif - - // Remove update check notification observer + NotificationCenter.default.removeObserver( self, name: Notification.Name("checkForUpdates"), object: nil ) + + logger.info("🚨 applicationWillTerminate completed quickly") } // MARK: - UNUserNotificationCenterDelegate @@ -503,4 +503,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser // Show notifications even when app is in foreground completionHandler([.banner, .sound]) } + + /// Set up lightweight cleanup system for cloudflared processes + private func setupMultiLayerCleanup() { + logger.info("🛡️ Setting up cloudflared cleanup system") + + // Only set up minimal cleanup - no atexit, no complex watchdog + // The OS will clean up child processes automatically when parent dies + + logger.info("🛡️ Cleanup system initialized (minimal mode)") + } + + + } diff --git a/mac/VibeTunnelTests/CloudflareServiceTests.swift b/mac/VibeTunnelTests/CloudflareServiceTests.swift new file mode 100644 index 00000000..cb7c07a9 --- /dev/null +++ b/mac/VibeTunnelTests/CloudflareServiceTests.swift @@ -0,0 +1,253 @@ +import Foundation +import Testing +@testable import VibeTunnel + +@Suite("Cloudflare Service Tests", .tags(.networking)) +struct CloudflareServiceTests { + let testPort = 8_888 + + @Test("Singleton instance") + @MainActor + func singletonInstance() { + let instance1 = CloudflareService.shared + let instance2 = CloudflareService.shared + #expect(instance1 === instance2) + } + + @Test("Initial state") + @MainActor + func initialState() { + let service = CloudflareService.shared + + // Initial state should have no public URL regardless of installation status + #expect(service.publicUrl == nil) + + // If cloudflared is installed, cloudflaredPath should be set + if service.isInstalled { + #expect(service.cloudflaredPath != nil) + } else { + #expect(service.cloudflaredPath == nil) + } + } + + @Test("CLI installation check") + @MainActor + func cliInstallationCheck() { + let service = CloudflareService.shared + + // This will return true or false depending on whether cloudflared is installed + let isInstalled = service.checkCLIInstallation() + + // The service's isInstalled property should match what checkCLIInstallation returns + // Note: Service might have cached state, so we check the method result + #expect(isInstalled == service.checkCLIInstallation()) + + // If installed, cloudflaredPath should be set + if isInstalled { + #expect(service.cloudflaredPath != nil) + #expect(!service.cloudflaredPath!.isEmpty) + } + } + + @Test("Status check when not installed") + @MainActor + func statusCheckWhenNotInstalled() async { + let service = CloudflareService.shared + + // If cloudflared is not installed, status should reflect that + await service.checkCloudflaredStatus() + + if !service.isInstalled { + #expect(service.isRunning == false) + #expect(service.publicUrl == nil) + #expect(service.statusError == "cloudflared is not installed") + } + } + + @Test("Start tunnel without installation fails") + @MainActor + func startTunnelWithoutInstallation() async throws { + let service = CloudflareService.shared + + // If cloudflared is not installed, starting should fail + if !service.isInstalled { + do { + try await service.startQuickTunnel(port: testPort) + Issue.record("Expected error to be thrown") + } catch let error as CloudflareError { + #expect(error == .notInstalled) + } catch { + Issue.record("Expected CloudflareError.notInstalled") + } + } + } + + @Test("Start tunnel when already running fails") + @MainActor + func startTunnelWhenAlreadyRunning() async throws { + let service = CloudflareService.shared + + // Skip if not installed + guard service.isInstalled else { + return + } + + // If tunnel is already running, starting again should fail + if service.isRunning { + do { + try await service.startQuickTunnel(port: testPort) + Issue.record("Expected error to be thrown") + } catch let error as CloudflareError { + #expect(error == .tunnelAlreadyRunning) + } catch { + Issue.record("Expected CloudflareError.tunnelAlreadyRunning") + } + } + } + + @Test("Stop tunnel when not running") + @MainActor + func stopTunnelWhenNotRunning() async { + let service = CloudflareService.shared + + // Ensure not running by stopping first + await service.stopQuickTunnel() + + // Refresh status to ensure we have the latest state + await service.checkCloudflaredStatus() + + // Stop again should be safe + await service.stopQuickTunnel() + + // After stopping our managed tunnel, the service should report not running + // Note: There might be external cloudflared processes, but our service shouldn't be managing them + #expect(service.publicUrl == nil) + } + + @Test("URL extraction from output") + @MainActor + func urlExtractionFromOutput() { + // Test URL extraction with sample cloudflared output + let testOutputs = [ + "Your free tunnel has started! Visit it: https://example-test.trycloudflare.com", + "2024-01-01 12:00:00 INF https://another-test.trycloudflare.com", + "Tunnel URL: https://third-test.trycloudflare.com", + "No URL in this output", + "https://invalid-domain.com should not match" + ] + + // This test verifies the URL extraction logic indirectly + // The actual extraction is private, but we can test the pattern + let pattern = "https://[a-zA-Z0-9-]+\\.trycloudflare\\.com" + let regex = try? NSRegularExpression(pattern: pattern, options: []) + + for output in testOutputs { + let range = NSRange(location: 0, length: output.count) + let matches = regex?.matches(in: output, options: [], range: range) + + if output.contains("trycloudflare.com") && !output.contains("invalid-domain") { + #expect(matches?.count == 1) + } + } + } + + @Test("CloudflareError descriptions") + func cloudflareErrorDescriptions() { + let errors: [CloudflareError] = [ + .notInstalled, + .tunnelAlreadyRunning, + .tunnelCreationFailed("test error"), + .networkError("connection failed"), + .invalidOutput, + .processTerminated + ] + + for error in errors { + #expect(error.errorDescription != nil) + #expect(!error.errorDescription!.isEmpty) + } + } + + @Test("CloudflareError equality") + func cloudflareErrorEquality() { + #expect(CloudflareError.notInstalled == CloudflareError.notInstalled) + #expect(CloudflareError.tunnelAlreadyRunning == CloudflareError.tunnelAlreadyRunning) + #expect(CloudflareError.tunnelCreationFailed("a") == CloudflareError.tunnelCreationFailed("a")) + #expect(CloudflareError.tunnelCreationFailed("a") != CloudflareError.tunnelCreationFailed("b")) + #expect(CloudflareError.networkError("a") == CloudflareError.networkError("a")) + #expect(CloudflareError.networkError("a") != CloudflareError.networkError("b")) + } + + @Test("Installation method URLs") + @MainActor + func installationMethodUrls() { + let service = CloudflareService.shared + + // Test that installation methods don't crash + // These should open URLs or copy to clipboard + service.openHomebrewInstall() + service.openDownloadPage() + service.openSetupGuide() + + // No exceptions should be thrown + #expect(Bool(true)) + } + + @Test("Service state consistency") + @MainActor + func serviceStateConsistency() async { + let service = CloudflareService.shared + + await service.checkCloudflaredStatus() + + // If not installed, should not be running + if !service.isInstalled { + #expect(service.isRunning == false) + #expect(service.publicUrl == nil) + } + + // If not running, should not have public URL + if !service.isRunning { + #expect(service.publicUrl == nil) + } + + // If running, should be installed + if service.isRunning { + #expect(service.isInstalled == true) + } + } + + @Test("Concurrent status checks") + @MainActor + func concurrentStatusChecks() async { + let service = CloudflareService.shared + + // Run multiple status checks concurrently + await withTaskGroup(of: Void.self) { group in + for _ in 0..<5 { + group.addTask { + await service.checkCloudflaredStatus() + } + } + } + + // Service should still be in a consistent state + let finalState = service.isRunning + #expect(finalState == service.isRunning) // Should be consistent + } + + @Test("Status error handling") + @MainActor + func statusErrorHandling() async { + let service = CloudflareService.shared + + await service.checkCloudflaredStatus() + + // If not installed, should have appropriate error + if !service.isInstalled { + #expect(service.statusError == "cloudflared is not installed") + } else if !service.isRunning { + #expect(service.statusError == "No active cloudflared tunnel") + } + } +} \ No newline at end of file diff --git a/tauri/src/components/welcome-app.ts b/tauri/src/components/welcome-app.ts index 11839755..efc48940 100644 --- a/tauri/src/components/welcome-app.ts +++ b/tauri/src/components/welcome-app.ts @@ -664,7 +664,7 @@ export class WelcomeApp extends TauriBase {

To access your terminals from any device, create a tunnel from your device. This can be done via ngrok in settings or - Tailscale (recommended). + Cloudflare or Tailscale.