mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +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 */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = 788687E92DFF4FCB00B22C15 /* Project object */;
|
rootObject = 788687E92DFF4FCB00B22C15 /* Project object */;
|
||||||
}
|
}
|
||||||
|
|
@ -18,6 +18,14 @@ private struct TerminalLauncherKey: EnvironmentKey {
|
||||||
static let defaultValue: TerminalLauncher? = nil
|
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
|
// MARK: - Environment Values Extensions
|
||||||
|
|
||||||
extension EnvironmentValues {
|
extension EnvironmentValues {
|
||||||
|
|
@ -40,6 +48,16 @@ extension EnvironmentValues {
|
||||||
get { self[TerminalLauncherKey.self] }
|
get { self[TerminalLauncherKey.self] }
|
||||||
set { self[TerminalLauncherKey.self] = newValue }
|
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
|
// MARK: - View Extensions
|
||||||
|
|
@ -51,7 +69,9 @@ extension View {
|
||||||
serverManager: ServerManager? = nil,
|
serverManager: ServerManager? = nil,
|
||||||
ngrokService: NgrokService? = nil,
|
ngrokService: NgrokService? = nil,
|
||||||
systemPermissionManager: SystemPermissionManager? = nil,
|
systemPermissionManager: SystemPermissionManager? = nil,
|
||||||
terminalLauncher: TerminalLauncher? = nil
|
terminalLauncher: TerminalLauncher? = nil,
|
||||||
|
tailscaleService: TailscaleService? = nil,
|
||||||
|
cloudflareService: CloudflareService? = nil
|
||||||
)
|
)
|
||||||
-> some View
|
-> some View
|
||||||
{
|
{
|
||||||
|
|
@ -63,5 +83,7 @@ extension View {
|
||||||
systemPermissionManager ?? SystemPermissionManager.shared
|
systemPermissionManager ?? SystemPermissionManager.shared
|
||||||
)
|
)
|
||||||
.environment(\.terminalLauncher, terminalLauncher ?? TerminalLauncher.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>
|
<key>NSSupportsAutomaticTermination</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSSupportsSuddenTermination</key>
|
<key>NSSupportsSuddenTermination</key>
|
||||||
<true/>
|
<false/>
|
||||||
<key>SUEnableAutomaticChecks</key>
|
<key>SUEnableAutomaticChecks</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>SUFeedURL</key>
|
<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
|
private var ngrokService
|
||||||
@Environment(TailscaleService.self)
|
@Environment(TailscaleService.self)
|
||||||
private var tailscaleService
|
private var tailscaleService
|
||||||
|
@Environment(CloudflareService.self)
|
||||||
|
private var cloudflareService
|
||||||
|
|
||||||
@State private var ngrokAuthToken = ""
|
@State private var ngrokAuthToken = ""
|
||||||
@State private var ngrokStatus: NgrokTunnelStatus?
|
@State private var ngrokStatus: NgrokTunnelStatus?
|
||||||
|
|
@ -73,6 +75,12 @@ struct DashboardSettingsView: View {
|
||||||
accessMode: accessMode
|
accessMode: accessMode
|
||||||
)
|
)
|
||||||
|
|
||||||
|
CloudflareIntegrationSection(
|
||||||
|
cloudflareService: cloudflareService,
|
||||||
|
serverPort: serverPort,
|
||||||
|
accessMode: accessMode
|
||||||
|
)
|
||||||
|
|
||||||
NgrokIntegrationSection(
|
NgrokIntegrationSection(
|
||||||
ngrokEnabled: $ngrokEnabled,
|
ngrokEnabled: $ngrokEnabled,
|
||||||
ngrokAuthToken: $ngrokAuthToken,
|
ngrokAuthToken: $ngrokAuthToken,
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ struct AccessDashboardPageView: View {
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
Text(
|
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)
|
.font(.body)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ struct VibeTunnelApp: App {
|
||||||
@State var serverManager = ServerManager.shared
|
@State var serverManager = ServerManager.shared
|
||||||
@State var ngrokService = NgrokService.shared
|
@State var ngrokService = NgrokService.shared
|
||||||
@State var tailscaleService = TailscaleService.shared
|
@State var tailscaleService = TailscaleService.shared
|
||||||
|
@State var cloudflareService = CloudflareService.shared
|
||||||
@State var permissionManager = SystemPermissionManager.shared
|
@State var permissionManager = SystemPermissionManager.shared
|
||||||
@State var terminalLauncher = TerminalLauncher.shared
|
@State var terminalLauncher = TerminalLauncher.shared
|
||||||
@State var gitRepositoryMonitor = GitRepositoryMonitor()
|
@State var gitRepositoryMonitor = GitRepositoryMonitor()
|
||||||
|
|
@ -45,6 +46,7 @@ struct VibeTunnelApp: App {
|
||||||
.environment(serverManager)
|
.environment(serverManager)
|
||||||
.environment(ngrokService)
|
.environment(ngrokService)
|
||||||
.environment(tailscaleService)
|
.environment(tailscaleService)
|
||||||
|
.environment(cloudflareService)
|
||||||
.environment(permissionManager)
|
.environment(permissionManager)
|
||||||
.environment(terminalLauncher)
|
.environment(terminalLauncher)
|
||||||
.environment(gitRepositoryMonitor)
|
.environment(gitRepositoryMonitor)
|
||||||
|
|
@ -64,6 +66,7 @@ struct VibeTunnelApp: App {
|
||||||
.environment(serverManager)
|
.environment(serverManager)
|
||||||
.environment(ngrokService)
|
.environment(ngrokService)
|
||||||
.environment(tailscaleService)
|
.environment(tailscaleService)
|
||||||
|
.environment(cloudflareService)
|
||||||
.environment(permissionManager)
|
.environment(permissionManager)
|
||||||
.environment(terminalLauncher)
|
.environment(terminalLauncher)
|
||||||
.environment(gitRepositoryMonitor)
|
.environment(gitRepositoryMonitor)
|
||||||
|
|
@ -83,6 +86,7 @@ struct VibeTunnelApp: App {
|
||||||
.environment(serverManager)
|
.environment(serverManager)
|
||||||
.environment(ngrokService)
|
.environment(ngrokService)
|
||||||
.environment(tailscaleService)
|
.environment(tailscaleService)
|
||||||
|
.environment(cloudflareService)
|
||||||
.environment(permissionManager)
|
.environment(permissionManager)
|
||||||
.environment(terminalLauncher)
|
.environment(terminalLauncher)
|
||||||
.environment(gitRepositoryMonitor)
|
.environment(gitRepositoryMonitor)
|
||||||
|
|
@ -293,6 +297,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
|
||||||
repositoryDiscovery: repositoryDiscoveryService
|
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) {
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
|
logger.info("🚨 applicationWillTerminate called - starting cleanup process")
|
||||||
|
|
||||||
let processInfo = ProcessInfo.processInfo
|
let processInfo = ProcessInfo.processInfo
|
||||||
let isRunningInTests = processInfo.environment["XCTestConfigurationFilePath"] != nil ||
|
let isRunningInTests = processInfo.environment["XCTestConfigurationFilePath"] != nil ||
|
||||||
processInfo.environment["XCTestBundlePath"] != nil ||
|
processInfo.environment["XCTestBundlePath"] != nil ||
|
||||||
processInfo.environment["XCTestSessionIdentifier"] != nil ||
|
processInfo.environment["XCTestSessionIdentifier"] != nil ||
|
||||||
processInfo.arguments.contains("-XCTest") ||
|
processInfo.arguments.contains("-XCTest") ||
|
||||||
NSClassFromString("XCTestCase") != nil
|
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
|
// Skip cleanup during tests
|
||||||
if isRunningInTests {
|
if isRunningInTests {
|
||||||
logger.info("Running in test mode - skipping termination cleanup")
|
logger.info("Running in test mode - skipping termination cleanup")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Terminal control is now handled via SharedUnixSocketManager
|
// Ultra-fast cleanup for cloudflared - just send signals and exit
|
||||||
// No explicit stop needed as it's cleaned up with the socket manager
|
if let cloudflareService = app?.cloudflareService, cloudflareService.isRunning {
|
||||||
|
logger.info("🔥 Sending quick termination signal to Cloudflare")
|
||||||
// Stop HTTP server synchronously to ensure it completes before app exits
|
cloudflareService.sendTerminationSignal()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop HTTP server with very short timeout
|
||||||
if let serverManager = app?.serverManager {
|
if let serverManager = app?.serverManager {
|
||||||
let semaphore = DispatchSemaphore(value: 0)
|
let semaphore = DispatchSemaphore(value: 0)
|
||||||
Task {
|
Task {
|
||||||
await serverManager.stop()
|
await serverManager.stop()
|
||||||
semaphore.signal()
|
semaphore.signal()
|
||||||
}
|
}
|
||||||
// Wait up to 5 seconds for server to stop
|
// Only wait 0.5 seconds max
|
||||||
let timeout = DispatchTime.now() + .seconds(5)
|
_ = semaphore.wait(timeout: .now() + .milliseconds(500))
|
||||||
if semaphore.wait(timeout: timeout) == .timedOut {
|
}
|
||||||
logger.warning("Server stop timed out during app termination")
|
|
||||||
}
|
// 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
|
#endif
|
||||||
|
|
||||||
// Remove update check notification observer
|
|
||||||
NotificationCenter.default.removeObserver(
|
NotificationCenter.default.removeObserver(
|
||||||
self,
|
self,
|
||||||
name: Notification.Name("checkForUpdates"),
|
name: Notification.Name("checkForUpdates"),
|
||||||
object: nil
|
object: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info("🚨 applicationWillTerminate completed quickly")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - UNUserNotificationCenterDelegate
|
// MARK: - UNUserNotificationCenterDelegate
|
||||||
|
|
@ -503,4 +503,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
|
||||||
// Show notifications even when app is in foreground
|
// Show notifications even when app is in foreground
|
||||||
completionHandler([.banner, .sound])
|
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">
|
<p class="description">
|
||||||
To access your terminals from any device, create a tunnel from your device.
|
To access your terminals from any device, create a tunnel from your device.
|
||||||
This can be done via <strong>ngrok</strong> in settings or
|
This can be done via <strong>ngrok</strong> in settings or
|
||||||
<strong>Tailscale</strong> (recommended).
|
<strong>Cloudflare</strong> or <strong>Tailscale</strong>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<vt-button
|
<vt-button
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue