Make a held location survive interface changes and app restarts

Three fixes aimed at a simulation that stays up without manual intervention.

NetworkPathMonitor: ignore the utun (.other) interface when reporting which
interface is in use. LocalDevVPN is always present and always in use, so it
masked the real path — every state looked like "Other", a Wi-Fi<->cellular
handoff produced an identical signature, and no change was ever broadcast.

TunnelManager: stop trusting isConnected. It only records that the last
connect succeeded, and nothing in the app notices a tunnel that dies quietly,
which is precisely what a handoff causes — so the reconnect was skipped in the
exact case it existed for. Now a genuine interface change rebuilds the tunnel
regardless. startTunnel() is semaphore-guarded and recreates its adapter, so
reconnecting a live tunnel is safe.

LocationSimulationKeepAlive: persist the held coordinate and re-arm it on
launch. iOS gives sideloaded apps no way to start themselves, so a reboot or a
kill will always drop the simulation; restoring on launch means reconnecting
the VPN and opening the app is enough to get it back, with no need to re-pick
the location. Armed before the VPN is up is fine — the resend loop tolerates
failure and takes hold once the tunnel exists. Skipped when no pairing file
exists, since nothing could succeed and it would only burn battery.

Not verified on device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
umarfatmi05 2026-07-24 21:08:40 -04:00
parent 91c16dd126
commit ef78da3c05
4 changed files with 76 additions and 4 deletions

View file

@ -13,6 +13,7 @@ enum AppBootstrapper {
startConfiguredKeepAliveServices()
applyDocumentPickerCopyWorkaround()
NetworkPathMonitor.shared.start()
LocationSimulationKeepAlive.shared.restoreIfNeeded()
}
private static func registerDefaultSettings() {

View file

@ -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 {

View file

@ -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(

View file

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