diff --git a/StikDebug/App/AppBootstrapper.swift b/StikDebug/App/AppBootstrapper.swift index 2b3368c..9dd8398 100644 --- a/StikDebug/App/AppBootstrapper.swift +++ b/StikDebug/App/AppBootstrapper.swift @@ -13,6 +13,7 @@ enum AppBootstrapper { startConfiguredKeepAliveServices() applyDocumentPickerCopyWorkaround() NetworkPathMonitor.shared.start() + LocationSimulationKeepAlive.shared.restoreIfNeeded() } private static func registerDefaultSettings() { diff --git a/StikDebug/Services/LocationSimulationKeepAlive.swift b/StikDebug/Services/LocationSimulationKeepAlive.swift index 0159257..ccb1038 100644 --- a/StikDebug/Services/LocationSimulationKeepAlive.swift +++ b/StikDebug/Services/LocationSimulationKeepAlive.swift @@ -29,6 +29,10 @@ final class LocationSimulationKeepAlive { /// interval the view's timer used. private static let resendInterval: TimeInterval = 4 + /// Remembers the held coordinate across launches, so a location survives the + /// app being killed or the device rebooting. + private static let storageKey = "heldSimulatedLocation" + private let stateLock = NSLock() private let timerQueue = DispatchQueue(label: "com.stik.location-keepalive") @@ -63,6 +67,7 @@ final class LocationSimulationKeepAlive { coordinate = newCoordinate stateLock.unlock() + persist(newCoordinate) acquireKeepAlive() startTimer() @@ -71,6 +76,46 @@ final class LocationSimulationKeepAlive { } } + /// Re-arm a location that was still held when the app last stopped running. + /// + /// iOS gives sideloaded apps no way to launch themselves, so a reboot or a + /// kill always ends the simulation. The next best thing is to pick it straight + /// back up on launch: the resend loop tolerates failures, so this can be armed + /// before LocalDevVPN is connected and will take hold once it is. + func restoreIfNeeded() { + guard !isActive else { return } + + guard let values = UserDefaults.standard.array(forKey: Self.storageKey) as? [Double], + values.count == 2 else { + return + } + + let restored = CLLocationCoordinate2D(latitude: values[0], longitude: values[1]) + guard CLLocationCoordinate2DIsValid(restored) else { + UserDefaults.standard.removeObject(forKey: Self.storageKey) + return + } + + // Without a pairing file nothing can ever succeed, and retrying forever + // would just burn battery. + guard FileManager.default.fileExists(atPath: PairingFileStore.prepareURL().path) else { + return + } + + LogManager.shared.addInfoLog( + String(format: "Restoring held simulated location: %.6f, %.6f", restored.latitude, restored.longitude) + ) + hold(restored) + } + + private func persist(_ value: CLLocationCoordinate2D?) { + if let value { + UserDefaults.standard.set([value.latitude, value.longitude], forKey: Self.storageKey) + } else { + UserDefaults.standard.removeObject(forKey: Self.storageKey) + } + } + /// Stop holding a simulated location and release the keep-alive. /// /// This does **not** clear the simulation on the device — callers that want @@ -83,6 +128,7 @@ final class LocationSimulationKeepAlive { timer = nil stateLock.unlock() + persist(nil) releaseKeepAlive() if wasActive { diff --git a/StikDebug/Services/NetworkPathMonitor.swift b/StikDebug/Services/NetworkPathMonitor.swift index a4b9896..9fce695 100644 --- a/StikDebug/Services/NetworkPathMonitor.swift +++ b/StikDebug/Services/NetworkPathMonitor.swift @@ -76,7 +76,15 @@ final class NetworkPathMonitor { // A utun (VPN) interface reports as `.other`; treat its presence as an // active VPN. The primary usable interface is the first one actually in use. let usesVPN = path.availableInterfaces.contains { $0.type == .other } - let interface = path.availableInterfaces.first(where: { path.usesInterfaceType($0.type) })?.type + + // Ignore .other (the utun VPN interface) when deciding which interface we + // are actually on. LocalDevVPN is always present and always "in use", so + // letting it win would report "Other" no matter what — a Wi-Fi↔cellular + // handoff would produce an identical signature, look like no change at + // all, and never trigger a reconnect. + let physical = path.availableInterfaces.filter { $0.type != .other } + let interface = physical.first(where: { path.usesInterfaceType($0.type) })?.type + ?? physical.first?.type ?? path.availableInterfaces.first?.type let status = Status( diff --git a/StikDebug/Services/TunnelManager.swift b/StikDebug/Services/TunnelManager.swift index 7b31a3d..afec7b0 100644 --- a/StikDebug/Services/TunnelManager.swift +++ b/StikDebug/Services/TunnelManager.swift @@ -12,6 +12,7 @@ final class TunnelManager: ObservableObject { private var isStarting = false private var pathChangeWorkItem: DispatchWorkItem? + private var lastInterfaceName: String? private init() { // Reconnect when the network path changes (e.g. a Wi-Fi↔cellular handoff) @@ -124,9 +125,25 @@ final class TunnelManager: ObservableObject { let status = NetworkPathMonitor.shared.status guard status.isReachable else { return } - // Re-establish the primary tunnel if it dropped during the change. - guard !isConnected else { return } - LogManager.shared.addInfoLog("Network path changed (\(status.description)); reconnecting tunnel") + let previous = lastInterfaceName + lastInterfaceName = status.interfaceName + + if !isConnected { + LogManager.shared.addInfoLog("Network path changed (\(status.description)); connecting tunnel") + start(showErrorUI: false) + return + } + + // isConnected only means "the last connect succeeded" — nothing in the app + // notices a tunnel that died quietly, which is exactly what a handoff does. + // So don't trust it: if the physical interface actually changed, treat the + // old tunnel as dead and rebuild. startTunnel() is semaphore-guarded and + // recreates its adapter, so reconnecting a live tunnel is safe. + guard let previous, previous != status.interfaceName else { return } + LogManager.shared.addInfoLog( + "Interface changed (\(previous) → \(status.interfaceName)); rebuilding tunnel" + ) + markDisconnected() start(showErrorUI: false) }