Improve Cloudflare integration implementation (#306)

Co-authored-by: Claudio Canales <klaudioz@gmail.com>
This commit is contained in:
Peter Steinberger 2025-07-11 07:43:53 +02:00 committed by GitHub
parent 83fa3a22b8
commit 84fa7333f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1293 additions and 38 deletions

View file

@ -618,4 +618,4 @@
/* End XCSwiftPackageProductDependency section */
};
rootObject = 788687E92DFF4FCB00B22C15 /* Project object */;
}
}

View file

@ -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)
}
}

View file

@ -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<Void, Never>?
/// Background tasks for monitoring output
private var outputMonitoringTasks: [Task<Void, Never>] = []
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
}
}

View file

@ -48,7 +48,7 @@
<key>NSSupportsAutomaticTermination</key>
<true/>
<key>NSSupportsSuddenTermination</key>
<true/>
<false/>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>SUFeedURL</key>

View file

@ -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)
}

View file

@ -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,

View file

@ -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)

View file

@ -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)")
}
}

View file

@ -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")
}
}
}

View file

@ -664,7 +664,7 @@ export class WelcomeApp extends TauriBase {
<p class="description">
To access your terminals from any device, create a tunnel from your device.
This can be done via <strong>ngrok</strong> in settings or
<strong>Tailscale</strong> (recommended).
<strong>Cloudflare</strong> or <strong>Tailscale</strong>.
</p>
<vt-button