import Foundation import Network import NetworkExtension import Darwin import UIKit import Combine @MainActor final class LANMonitor: ObservableObject { static let shared = LANMonitor() @Published private(set) var isOnNetwork: Bool = false @Published private(set) var currentSSID: String? = nil @Published private(set) var isOnCellular: Bool = false @Published private(set) var isTailscaleActive: Bool = false private let monitor = NWPathMonitor() private let queue = DispatchQueue(label: "com.albert.nasbackup.lan", qos: .utility) var onTrustedNetworkJoined: ((String) -> Void)? private init() {} func start() { monitor.pathUpdateHandler = { [weak self] path in guard let self else { return } Task { @MainActor in self.isOnNetwork = path.status == .satisfied self.isOnCellular = path.usesInterfaceType(.cellular) self.isTailscaleActive = self.detectTailscale() if path.status == .satisfied { await self.checkSSID() } else { self.currentSSID = nil } } } monitor.start(queue: queue) } func stop() { monitor.cancel() } // Detect Tailscale: look for a utun interface with an IP in the 100.64.0.0/10 CGNAT range private func detectTailscale() -> Bool { var ifaddr: UnsafeMutablePointer? guard getifaddrs(&ifaddr) == 0 else { return false } defer { freeifaddrs(ifaddr) } var ptr = ifaddr while let current = ptr { defer { ptr = current.pointee.ifa_next } let name = String(cString: current.pointee.ifa_name) guard name.hasPrefix("utun"), let addr = current.pointee.ifa_addr, addr.pointee.sa_family == UInt8(AF_INET) else { continue } var sa = addr.pointee var buf = [CChar](repeating: 0, count: Int(NI_MAXHOST)) getnameinfo(&sa, socklen_t(sa.sa_len), &buf, socklen_t(buf.count), nil, 0, NI_NUMERICHOST) if String(cString: buf).hasPrefix("100.") { return true } } return false } // Open the Tailscale app so the user can connect the VPN func openTailscaleApp() { guard let url = URL(string: "tailscale://") else { return } UIApplication.shared.open(url) } private func checkSSID() async { currentSSID = await fetchCurrentSSID() } func fetchCurrentSSID() async -> String? { await withCheckedContinuation { continuation in NEHotspotNetwork.fetchCurrent { network in continuation.resume(returning: network?.ssid) } } } func checkAndTriggerIfTrusted(trustedSSIDs: [String]) async { let ssid = await fetchCurrentSSID() guard let ssid, trustedSSIDs.contains(ssid) else { return } onTrustedNetworkJoined?(ssid) } }