vibetunnel/mac/VibeTunnel/Presentation/Views/Settings/CloudflareIntegrationSection.swift

344 lines
13 KiB
Swift

import os.log
import SwiftUI
/// 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: BundleIdentifiers.loggerSubsystem, 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 {
ClickableURLView(
label: "Public URL:",
url: publicUrl
)
}
// Error display - only show when tunnel is enabled or being toggled
if tunnelEnabled || isTogglingTunnel {
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) ?? 4_020
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 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)
}