- Replace History tab with Sync tab (file list, NAS status, free-up-space retention) - Add AppMenuView hamburger sheet (History, Errors, Connection Status, Sync Activity) - BackupView: Kisani logo chip in toolbar, hamburger menu button, swipeable ring pages (progress/pending/failed/uploaded), always-visible compact stats strip - SettingsView: NETWORK section (SSID, NAS reachability, trusted-network warning, refresh button) - BrowseView: pull-to-refresh, long-press context menu on items - LANMonitor: nasReachable, nasCheckTime, checkNASReachability(host:port:) - ConnectionStore: localRetentionDays (0/60/90/365 days) - Regenerate xcodeproj with xcodegen to include new files Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
126 lines
4.3 KiB
Swift
126 lines
4.3 KiB
Swift
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
|
|
@Published private(set) var nasReachable: Bool? = nil
|
|
@Published private(set) var nasCheckTime: Date? = nil
|
|
|
|
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<ifaddrs>?
|
|
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)
|
|
}
|
|
|
|
func checkSSID() async {
|
|
currentSSID = await fetchCurrentSSID()
|
|
}
|
|
|
|
func fetchCurrentSSID() async -> String? {
|
|
await withCheckedContinuation { continuation in
|
|
NEHotspotNetwork.fetchCurrent { network in
|
|
continuation.resume(returning: network?.ssid)
|
|
}
|
|
}
|
|
}
|
|
|
|
func checkNASReachability(host: String, port: Int) async {
|
|
let ep = NWEndpoint.hostPort(
|
|
host: NWEndpoint.Host(host),
|
|
port: NWEndpoint.Port(rawValue: UInt16(port)) ?? 445
|
|
)
|
|
let connection = NWConnection(to: ep, using: .tcp)
|
|
var done = false
|
|
|
|
let reachable = await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
|
connection.stateUpdateHandler = { state in
|
|
guard !done else { return }
|
|
switch state {
|
|
case .ready:
|
|
done = true
|
|
connection.cancel()
|
|
cont.resume(returning: true)
|
|
case .failed, .waiting:
|
|
done = true
|
|
connection.cancel()
|
|
cont.resume(returning: false)
|
|
default: break
|
|
}
|
|
}
|
|
connection.start(queue: queue)
|
|
DispatchQueue.global().asyncAfter(deadline: .now() + 4) {
|
|
guard !done else { return }
|
|
done = true
|
|
connection.cancel()
|
|
cont.resume(returning: false)
|
|
}
|
|
}
|
|
|
|
nasReachable = reachable
|
|
nasCheckTime = Date()
|
|
}
|
|
|
|
func checkAndTriggerIfTrusted(trustedSSIDs: [String]) async {
|
|
let ssid = await fetchCurrentSSID()
|
|
guard let ssid, trustedSSIDs.contains(ssid) else { return }
|
|
onTrustedNetworkJoined?(ssid)
|
|
}
|
|
}
|