mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +00:00
Improve Cloudflare integration implementation (#306)
Co-authored-by: Claudio Canales <klaudioz@gmail.com>
This commit is contained in:
parent
83fa3a22b8
commit
84fa7333f0
10 changed files with 1293 additions and 38 deletions
|
|
@ -618,4 +618,4 @@
|
|||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 788687E92DFF4FCB00B22C15 /* Project object */;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
571
mac/VibeTunnel/Core/Services/CloudflareService.swift
Normal file
571
mac/VibeTunnel/Core/Services/CloudflareService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -48,7 +48,7 @@
|
|||
<key>NSSupportsAutomaticTermination</key>
|
||||
<true/>
|
||||
<key>NSSupportsSuddenTermination</key>
|
||||
<true/>
|
||||
<false/>
|
||||
<key>SUEnableAutomaticChecks</key>
|
||||
<true/>
|
||||
<key>SUFeedURL</key>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
253
mac/VibeTunnelTests/CloudflareServiceTests.swift
Normal file
253
mac/VibeTunnelTests/CloudflareServiceTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue