vibetunnel/ios/VibeTunnel/Services/NetworkMonitor.swift
2025-06-23 14:58:11 +02:00

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