mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-06-28 05:29:29 +00:00
230 lines
7.4 KiB
Swift
230 lines
7.4 KiB
Swift
import Foundation
|
|
import Network
|
|
import SwiftUI
|
|
|
|
private let logger = Logger(category: "NetworkMonitor")
|
|
|
|
/// Monitors network connectivity and provides offline/online state
|
|
@MainActor
|
|
@Observable
|
|
final class NetworkMonitor {
|
|
static let shared = NetworkMonitor()
|
|
|
|
private(set) var isConnected = true
|
|
private(set) var connectionType = NWInterface.InterfaceType.other
|
|
private(set) var isExpensive = false
|
|
private(set) var isConstrained = false
|
|
|
|
private let monitor = NWPathMonitor()
|
|
private let queue = DispatchQueue(label: "NetworkMonitor")
|
|
|
|
private init() {
|
|
startMonitoring()
|
|
}
|
|
|
|
deinit {
|
|
monitor.cancel()
|
|
}
|
|
|
|
private func startMonitoring() {
|
|
monitor.pathUpdateHandler = { [weak self] path in
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
|
|
let wasConnected = self.isConnected
|
|
self.isConnected = path.status == .satisfied
|
|
self.isExpensive = path.isExpensive
|
|
self.isConstrained = path.isConstrained
|
|
|
|
// Update connection type
|
|
if let interface = path.availableInterfaces.first {
|
|
self.connectionType = interface.type
|
|
}
|
|
|
|
// Log state changes
|
|
if wasConnected != self.isConnected {
|
|
logger.info("Connection state changed: \(self.isConnected ? "Online" : "Offline")")
|
|
|
|
// Post notification for other parts of the app
|
|
NotificationCenter.default.post(
|
|
name: self.isConnected ? .networkBecameAvailable : .networkBecameUnavailable,
|
|
object: nil
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
monitor.start(queue: queue)
|
|
}
|
|
|
|
private func stopMonitoring() {
|
|
monitor.cancel()
|
|
}
|
|
|
|
/// Check if a specific host is reachable
|
|
func checkHostReachability(_ host: String) async -> Bool {
|
|
// Try to resolve the host
|
|
guard let url = URL(string: host),
|
|
url.host != nil
|
|
else {
|
|
return false
|
|
}
|
|
|
|
actor ResponseTracker {
|
|
private var hasResponded = false
|
|
|
|
func checkAndRespond() -> Bool {
|
|
if hasResponded {
|
|
return false
|
|
}
|
|
hasResponded = true
|
|
return true
|
|
}
|
|
}
|
|
|
|
return await withCheckedContinuation { continuation in
|
|
let monitor = NWPathMonitor()
|
|
let queue = DispatchQueue(label: "HostReachability")
|
|
let tracker = ResponseTracker()
|
|
|
|
monitor.pathUpdateHandler = { path in
|
|
Task {
|
|
let shouldRespond = await tracker.checkAndRespond()
|
|
if shouldRespond {
|
|
let isReachable = path.status == .satisfied
|
|
monitor.cancel()
|
|
continuation.resume(returning: isReachable)
|
|
}
|
|
}
|
|
}
|
|
|
|
monitor.start(queue: queue)
|
|
|
|
// Timeout after 5 seconds
|
|
queue.asyncAfter(deadline: .now() + 5) {
|
|
Task {
|
|
let shouldRespond = await tracker.checkAndRespond()
|
|
if shouldRespond {
|
|
monitor.cancel()
|
|
continuation.resume(returning: false)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Notification Names
|
|
|
|
extension Notification.Name {
|
|
static let networkBecameAvailable = Notification.Name("networkBecameAvailable")
|
|
static let networkBecameUnavailable = Notification.Name("networkBecameUnavailable")
|
|
}
|
|
|
|
// MARK: - View Modifier for Offline Banner
|
|
|
|
struct OfflineBanner: ViewModifier {
|
|
@State private var networkMonitor = NetworkMonitor.shared
|
|
@State private var showBanner = false
|
|
|
|
func body(content: Content) -> some View {
|
|
ZStack(alignment: .top) {
|
|
content
|
|
|
|
if showBanner && !networkMonitor.isConnected {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Image(systemName: "wifi.slash")
|
|
.foregroundColor(Theme.Colors.terminalBackground)
|
|
|
|
Text("No Internet Connection")
|
|
.foregroundColor(Theme.Colors.terminalBackground)
|
|
.font(.footnote.bold())
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
.background(Theme.Colors.errorAccent)
|
|
.animation(.easeInOut(duration: 0.3), value: showBanner)
|
|
.transition(.move(edge: .top).combined(with: .opacity))
|
|
|
|
Spacer()
|
|
}
|
|
.ignoresSafeArea()
|
|
}
|
|
}
|
|
.onAppear {
|
|
showBanner = true
|
|
}
|
|
.onChange(of: networkMonitor.isConnected) { oldValue, newValue in
|
|
if !oldValue && newValue {
|
|
// Coming back online - hide banner after delay
|
|
Task {
|
|
try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
|
|
showBanner = false
|
|
}
|
|
} else if oldValue && !newValue {
|
|
// Going offline - show banner immediately
|
|
showBanner = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
func offlineBanner() -> some View {
|
|
modifier(OfflineBanner())
|
|
}
|
|
}
|
|
|
|
// MARK: - Connection Status View
|
|
|
|
struct ConnectionStatusView: View {
|
|
@State private var networkMonitor = NetworkMonitor.shared
|
|
|
|
var body: some View {
|
|
HStack(spacing: 8) {
|
|
Circle()
|
|
.fill(networkMonitor.isConnected ? Theme.Colors.successAccent : Theme.Colors.errorAccent)
|
|
.frame(width: 8, height: 8)
|
|
|
|
Text(networkMonitor.isConnected ? "Online" : "Offline")
|
|
.font(.caption)
|
|
.foregroundColor(Theme.Colors.terminalGray)
|
|
|
|
if networkMonitor.isConnected {
|
|
switch networkMonitor.connectionType {
|
|
case .wifi:
|
|
Image(systemName: "wifi")
|
|
.font(.caption)
|
|
.foregroundColor(Theme.Colors.terminalGray)
|
|
case .cellular:
|
|
Image(systemName: "antenna.radiowaves.left.and.right")
|
|
.font(.caption)
|
|
.foregroundColor(Theme.Colors.terminalGray)
|
|
case .wiredEthernet:
|
|
Image(systemName: "cable.connector")
|
|
.font(.caption)
|
|
.foregroundColor(Theme.Colors.terminalGray)
|
|
default:
|
|
EmptyView()
|
|
}
|
|
|
|
if networkMonitor.isExpensive {
|
|
Image(systemName: "dollarsign.circle")
|
|
.font(.caption)
|
|
.foregroundColor(Theme.Colors.warningAccent)
|
|
.help("Connection may incur charges")
|
|
}
|
|
|
|
if networkMonitor.isConstrained {
|
|
Image(systemName: "tortoise")
|
|
.font(.caption)
|
|
.foregroundColor(Theme.Colors.warningAccent)
|
|
.help("Low Data Mode is enabled")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|