- Rename app to Kisani (CFBundleDisplayName, xcodeproj → Kisani.xcodeproj) - BackupView: kisani. wordmark + SwiftUI-drawn server logo mark (no asset, adaptive ink/bg), 206pt progress ring with active-state glow, 4-page swipeable ring (progress/pending/failed/uploaded), 4-metric stats strip, friendly NAS card (Home Server label), hamburger → AppMenuView sheet - GalleryView: new NAS + local photo grid with source picker chip, 3-col lazy grid, ThumbnailCache, sync-status dots (green = backed up) - MainTabView: Browse tab → Gallery tab; tab bar uses ultraThinMaterial blur background (systemGray3 icons, 9.5pt labels) - All app icon sizes regenerated from 1024pt source via sips - LANMonitor: nasReachable Bool?, checkNASReachability(host:port:) TCP ping - project.yml: photo library usage description updated to Kisani branding Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
130 lines
4.5 KiB
Swift
130 lines
4.5 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()
|
|
if let conn = ConnectionStore.shared.savedConnection {
|
|
await self.checkNASReachability(host: conn.host, port: conn.port)
|
|
}
|
|
} else {
|
|
self.currentSSID = nil
|
|
self.nasReachable = 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)
|
|
}
|
|
}
|