mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 12:02:14 +00:00
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:
parent
91c16dd126
commit
ef78da3c05
4 changed files with 76 additions and 4 deletions
|
|
@ -13,6 +13,7 @@ enum AppBootstrapper {
|
|||
startConfiguredKeepAliveServices()
|
||||
applyDocumentPickerCopyWorkaround()
|
||||
NetworkPathMonitor.shared.start()
|
||||
LocationSimulationKeepAlive.shared.restoreIfNeeded()
|
||||
}
|
||||
|
||||
private static func registerDefaultSettings() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue