mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-16 04:20:30 +00:00
Keep JIT/debug sessions alive when backgrounded and support cellular
When you enable JIT for an app and switch to it (e.g. a game), StikDebug gets backgrounded. If iOS suspends it, the debug connection it holds — and the marco/polo heartbeat that keeps that connection trusted — goes idle, the device tears it down, and the app loses its session after a while. The app also assumed Wi-Fi even though the tunnel rides LocalDevVPN and works on cellular. Session survival (backgrounding): - DebugKeepAliveLease now forces the silent-audio and background-location keep-alive on for the whole debug session, regardless of the user's keep-alive toggles, so a session survives being switched out even if those toggles are off. - Stop the background-task expiration handler from tearing the whole keep-alive down. It used to call invalidate() (killing audio/location) the moment the ~30s assertion elapsed; now it just renews the assertion while the silent-audio keep-alive continues to sustain background execution, so a long backgrounded session keeps running instead of dropping. - Play an inaudible non-zero signal instead of digital silence so iOS does not treat the audio session as idle and reclaim it (suspending the app). Cellular support: - Add NetworkPathMonitor (NWPathMonitor) so the app observes the real path (Wi-Fi, cellular, or VPN) instead of assuming Wi-Fi. - Retry the tunnel connect with backoff so transient cellular failures recover instead of erroring, skipping permanent errors (bad/missing pairing file, invalid target IP). - Reconnect the tunnel on network-path changes (Wi-Fi<->cellular handoff). - Remove the false "Wi-Fi required" wording from the connection alerts and the README. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qznh32XffG6vwFLMcwm1Mq
This commit is contained in:
parent
984a94a031
commit
656ec6d512
9 changed files with 309 additions and 40 deletions
|
|
@ -77,7 +77,8 @@ StikDebug enables **JIT** for sideloaded apps on iOS 17.4+ without needing a com
|
||||||
|
|
||||||
**Troubleshooting**
|
**Troubleshooting**
|
||||||
- "Connection dropped" or loopback errors → Check iOS version compatibility / beta warnings.
|
- "Connection dropped" or loopback errors → Check iOS version compatibility / beta warnings.
|
||||||
- Heartbeat errors → Ensure that the VPN is on and that you are connecected to Wi-Fi. It may be a pairing file issue.
|
- Heartbeat errors → Ensure that LocalDevVPN is on. Any network works (Wi-Fi **or** cellular); the tunnel rides the VPN, so cellular-only is supported. It may also be a pairing file issue.
|
||||||
|
- Session drops after switching to another app → Keep the background keep-alive enabled (Settings → Background Keep-Alive). While a JIT/debug session is active, StikDebug plays inaudible audio and holds a background assertion so the session survives being switched out of the app instead of timing out.
|
||||||
- Pairing file issues → Replace file with device unlocked & trusted.
|
- Pairing file issues → Replace file with device unlocked & trusted.
|
||||||
- Still stuck? Join the [Discord](https://discord.gg/ZnNcrRT3M8) with logs/screenshots.
|
- Still stuck? Join the [Discord](https://discord.gg/ZnNcrRT3M8) with logs/screenshots.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ enum AppBootstrapper {
|
||||||
registerDefaultSettings()
|
registerDefaultSettings()
|
||||||
startConfiguredKeepAliveServices()
|
startConfiguredKeepAliveServices()
|
||||||
applyDocumentPickerCopyWorkaround()
|
applyDocumentPickerCopyWorkaround()
|
||||||
|
NetworkPathMonitor.shared.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func registerDefaultSettings() {
|
private static func registerDefaultSettings() {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ final class BackgroundAudioManager {
|
||||||
private var isRunning = false
|
private var isRunning = false
|
||||||
private var persistentEnabled = false
|
private var persistentEnabled = false
|
||||||
private var activityCount = 0
|
private var activityCount = 0
|
||||||
|
private var forcedActivityCount = 0
|
||||||
private var healthCheckTimer: Timer?
|
private var healthCheckTimer: Timer?
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
|
|
@ -40,18 +41,33 @@ final class BackgroundAudioManager {
|
||||||
refreshRunningState()
|
refreshRunningState()
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestStart() {
|
/// Request that the app be kept alive by silent audio.
|
||||||
activityCount += 1
|
///
|
||||||
|
/// Pass `force: true` for an active debug session that must keep running
|
||||||
|
/// regardless of the user's "Silent Audio" toggle — forced holds bypass the
|
||||||
|
/// setting so a session survives being switched out of the app.
|
||||||
|
func requestStart(force: Bool = false) {
|
||||||
|
if force {
|
||||||
|
forcedActivityCount += 1
|
||||||
|
} else {
|
||||||
|
activityCount += 1
|
||||||
|
}
|
||||||
refreshRunningState()
|
refreshRunningState()
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestStop() {
|
func requestStop(force: Bool = false) {
|
||||||
activityCount = max(activityCount - 1, 0)
|
if force {
|
||||||
|
forcedActivityCount = max(forcedActivityCount - 1, 0)
|
||||||
|
} else {
|
||||||
|
activityCount = max(activityCount - 1, 0)
|
||||||
|
}
|
||||||
refreshRunningState()
|
refreshRunningState()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refreshRunningState() {
|
private func refreshRunningState() {
|
||||||
let shouldRun = persistentEnabled || (activityCount > 0 && UserDefaults.standard.bool(forKey: "keepAliveAudio"))
|
let shouldRun = persistentEnabled
|
||||||
|
|| forcedActivityCount > 0
|
||||||
|
|| (activityCount > 0 && UserDefaults.standard.bool(forKey: "keepAliveAudio"))
|
||||||
guard shouldRun != isRunning else {
|
guard shouldRun != isRunning else {
|
||||||
if shouldRun {
|
if shouldRun {
|
||||||
recoverIfNeeded()
|
recoverIfNeeded()
|
||||||
|
|
@ -100,7 +116,23 @@ final class BackgroundAudioManager {
|
||||||
let frameCount = AVAudioFrameCount(format.sampleRate)
|
let frameCount = AVAudioFrameCount(format.sampleRate)
|
||||||
guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else { return }
|
guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else { return }
|
||||||
buffer.frameLength = frameCount
|
buffer.frameLength = frameCount
|
||||||
// PCM buffer is zero-initialized — pure silence
|
|
||||||
|
// Fill with an inaudible but non-zero signal. Some iOS versions treat a
|
||||||
|
// purely digital-silent playback session as idle and reclaim it, which
|
||||||
|
// lets the app get suspended in the background. A tiny alternating sample
|
||||||
|
// (~-80 dBFS, DC-free) keeps the session genuinely "playing" while staying
|
||||||
|
// far below anything audible or disruptive to the foreground app's audio.
|
||||||
|
if !format.isInterleaved, let channels = buffer.floatChannelData {
|
||||||
|
let amplitude: Float = 0.0001
|
||||||
|
let frames = Int(frameCount)
|
||||||
|
for channel in 0..<Int(format.channelCount) {
|
||||||
|
let samples = channels[channel]
|
||||||
|
for frame in 0..<frames {
|
||||||
|
samples[frame] = (frame & 1 == 0) ? amplitude : -amplitude
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
player.scheduleBuffer(buffer, at: nil, options: .loops)
|
player.scheduleBuffer(buffer, at: nil, options: .loops)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ final class BackgroundLocationManager: NSObject, CLLocationManagerDelegate {
|
||||||
private let locationManager = CLLocationManager()
|
private let locationManager = CLLocationManager()
|
||||||
private var isRunning = false
|
private var isRunning = false
|
||||||
private var activityCount = 0
|
private var activityCount = 0
|
||||||
|
private var forcedActivityCount = 0
|
||||||
|
|
||||||
private override init() {
|
private override init() {
|
||||||
super.init()
|
super.init()
|
||||||
|
|
@ -40,16 +41,41 @@ final class BackgroundLocationManager: NSObject, CLLocationManagerDelegate {
|
||||||
locationManager.stopUpdatingLocation()
|
locationManager.stopUpdatingLocation()
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestStart() {
|
/// Request that the app be kept alive by background location updates.
|
||||||
activityCount += 1
|
///
|
||||||
if activityCount == 1, UserDefaults.standard.bool(forKey: "keepAliveLocation") {
|
/// Pass `force: true` for an active debug session that must keep running
|
||||||
start()
|
/// regardless of the user's "Background Location" toggle. Forced holds
|
||||||
|
/// bypass the setting so a session survives being switched out.
|
||||||
|
func requestStart(force: Bool = false) {
|
||||||
|
if force {
|
||||||
|
forcedActivityCount += 1
|
||||||
|
} else {
|
||||||
|
activityCount += 1
|
||||||
}
|
}
|
||||||
|
refreshRunningState()
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestStop() {
|
func requestStop(force: Bool = false) {
|
||||||
activityCount = max(activityCount - 1, 0)
|
if force {
|
||||||
if activityCount == 0 {
|
forcedActivityCount = max(forcedActivityCount - 1, 0)
|
||||||
|
} else {
|
||||||
|
activityCount = max(activityCount - 1, 0)
|
||||||
|
}
|
||||||
|
refreshRunningState()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-evaluate whether location should be running. Call after the user
|
||||||
|
/// toggles the "Background Location" setting.
|
||||||
|
func refreshFromSettings() {
|
||||||
|
refreshRunningState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshRunningState() {
|
||||||
|
let shouldRun = forcedActivityCount > 0
|
||||||
|
|| (activityCount > 0 && UserDefaults.standard.bool(forKey: "keepAliveLocation"))
|
||||||
|
if shouldRun {
|
||||||
|
start()
|
||||||
|
} else if isRunning {
|
||||||
stop()
|
stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,11 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
/// Keeps StikDebug running in the background for the duration of a debug/JIT
|
||||||
|
/// session so the debug connection (and its heartbeat) survives being switched
|
||||||
|
/// out of the app — e.g. while playing a JIT-enabled game. If iOS suspends the
|
||||||
|
/// app, that connection goes idle and the device tears it down, which is what
|
||||||
|
/// makes the target app lose its session/JIT after a while.
|
||||||
final class DebugKeepAliveLease {
|
final class DebugKeepAliveLease {
|
||||||
private let stateLock = NSLock()
|
private let stateLock = NSLock()
|
||||||
private var isActive = false
|
private var isActive = false
|
||||||
|
|
@ -25,13 +30,11 @@ final class DebugKeepAliveLease {
|
||||||
stateLock.unlock()
|
stateLock.unlock()
|
||||||
|
|
||||||
runOnMain {
|
runOnMain {
|
||||||
BackgroundAudioManager.shared.requestStop()
|
// Forced holds so the session stays alive even if the user turned the
|
||||||
BackgroundLocationManager.shared.requestStop()
|
// keep-alive toggles off; released here when the session ends.
|
||||||
|
BackgroundAudioManager.shared.requestStop(force: true)
|
||||||
if self.backgroundTaskID != .invalid {
|
BackgroundLocationManager.shared.requestStop(force: true)
|
||||||
UIApplication.shared.endBackgroundTask(self.backgroundTaskID)
|
self.endBackgroundTask()
|
||||||
self.backgroundTaskID = .invalid
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,15 +48,42 @@ final class DebugKeepAliveLease {
|
||||||
stateLock.unlock()
|
stateLock.unlock()
|
||||||
|
|
||||||
runOnMain {
|
runOnMain {
|
||||||
BackgroundAudioManager.shared.requestStart()
|
BackgroundAudioManager.shared.requestStart(force: true)
|
||||||
BackgroundLocationManager.shared.requestStart()
|
BackgroundLocationManager.shared.requestStart(force: true)
|
||||||
self.backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "StikDebugDebugSession") { [weak self] in
|
self.beginBackgroundTask()
|
||||||
LogManager.shared.addWarningLog("Debug session background task expired")
|
}
|
||||||
self?.invalidate()
|
}
|
||||||
|
|
||||||
|
// MARK: - Background task (main-thread only)
|
||||||
|
|
||||||
|
private func beginBackgroundTask() {
|
||||||
|
endBackgroundTask()
|
||||||
|
backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "StikDebugDebugSession") { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
// The silent-audio keep-alive is what actually sustains background
|
||||||
|
// execution, so do NOT tear the session down when this assertion
|
||||||
|
// expires (the old behaviour, which dropped long sessions). Release
|
||||||
|
// the expiring task and take a fresh one so the session keeps running.
|
||||||
|
self.stateLock.lock()
|
||||||
|
let stillActive = self.isActive
|
||||||
|
self.stateLock.unlock()
|
||||||
|
|
||||||
|
if stillActive {
|
||||||
|
LogManager.shared.addInfoLog("Debug session background window renewed (keep-alive continues)")
|
||||||
|
self.beginBackgroundTask()
|
||||||
|
} else {
|
||||||
|
self.endBackgroundTask()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func endBackgroundTask() {
|
||||||
|
if backgroundTaskID != .invalid {
|
||||||
|
UIApplication.shared.endBackgroundTask(backgroundTaskID)
|
||||||
|
backgroundTaskID = .invalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func runOnMain(_ work: @escaping () -> Void) {
|
private func runOnMain(_ work: @escaping () -> Void) {
|
||||||
if Thread.isMainThread {
|
if Thread.isMainThread {
|
||||||
work()
|
work()
|
||||||
|
|
|
||||||
101
StikDebug/Services/NetworkPathMonitor.swift
Normal file
101
StikDebug/Services/NetworkPathMonitor.swift
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
//
|
||||||
|
// NetworkPathMonitor.swift
|
||||||
|
// StikDebug
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
|
/// Observes the device's network path so the app never *assumes* Wi-Fi.
|
||||||
|
///
|
||||||
|
/// StikDebug reaches the device's own services through LocalDevVPN's tunnel,
|
||||||
|
/// which works over Wi-Fi *or* cellular. This monitor reports what path is
|
||||||
|
/// actually available (including cellular and VPN) and broadcasts changes so the
|
||||||
|
/// tunnel can reconnect after a Wi-Fi↔cellular handoff instead of silently
|
||||||
|
/// staying dropped.
|
||||||
|
final class NetworkPathMonitor {
|
||||||
|
static let shared = NetworkPathMonitor()
|
||||||
|
|
||||||
|
struct Status: CustomStringConvertible {
|
||||||
|
var isReachable: Bool
|
||||||
|
var isExpensive: Bool
|
||||||
|
var usesVPN: Bool
|
||||||
|
var interface: NWInterface.InterfaceType?
|
||||||
|
|
||||||
|
var interfaceName: String {
|
||||||
|
switch interface {
|
||||||
|
case .wifi: return "Wi-Fi"
|
||||||
|
case .cellular: return "Cellular"
|
||||||
|
case .wiredEthernet: return "Ethernet"
|
||||||
|
case .loopback: return "Loopback"
|
||||||
|
case .other: return "Other"
|
||||||
|
case .none: return "None"
|
||||||
|
@unknown default: return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
var parts = [isReachable ? "reachable" : "unreachable", interfaceName]
|
||||||
|
if usesVPN { parts.append("VPN") }
|
||||||
|
if isExpensive { parts.append("expensive") }
|
||||||
|
return parts.joined(separator: ", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let monitor = NWPathMonitor()
|
||||||
|
private let queue = DispatchQueue(label: "com.stik.networkpath")
|
||||||
|
private let stateLock = NSLock()
|
||||||
|
private var started = false
|
||||||
|
private var current = Status(isReachable: false, isExpensive: false, usesVPN: false, interface: nil)
|
||||||
|
private var lastSignature: String?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
var status: Status {
|
||||||
|
stateLock.lock()
|
||||||
|
defer { stateLock.unlock() }
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
stateLock.lock()
|
||||||
|
guard !started else {
|
||||||
|
stateLock.unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
started = true
|
||||||
|
stateLock.unlock()
|
||||||
|
|
||||||
|
monitor.pathUpdateHandler = { [weak self] path in
|
||||||
|
self?.handle(path)
|
||||||
|
}
|
||||||
|
monitor.start(queue: queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(_ path: NWPath) {
|
||||||
|
// 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
|
||||||
|
?? path.availableInterfaces.first?.type
|
||||||
|
|
||||||
|
let status = Status(
|
||||||
|
isReachable: path.status == .satisfied,
|
||||||
|
isExpensive: path.isExpensive,
|
||||||
|
usesVPN: usesVPN,
|
||||||
|
interface: interface
|
||||||
|
)
|
||||||
|
|
||||||
|
let signature = "\(status.isReachable)|\(status.interfaceName)|\(status.usesVPN)"
|
||||||
|
|
||||||
|
stateLock.lock()
|
||||||
|
current = status
|
||||||
|
let changed = signature != lastSignature
|
||||||
|
lastSignature = signature
|
||||||
|
stateLock.unlock()
|
||||||
|
|
||||||
|
guard changed else { return }
|
||||||
|
LogManager.shared.addInfoLog("Network path: \(status.description)")
|
||||||
|
NotificationCenter.default.post(name: .networkPathDidChange, object: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,8 +11,20 @@ final class TunnelManager: ObservableObject {
|
||||||
@Published private(set) var isConnected = false
|
@Published private(set) var isConnected = false
|
||||||
|
|
||||||
private var isStarting = false
|
private var isStarting = false
|
||||||
|
private var pathChangeWorkItem: DispatchWorkItem?
|
||||||
|
|
||||||
private init() {}
|
private init() {
|
||||||
|
// Reconnect when the network path changes (e.g. a Wi-Fi↔cellular handoff)
|
||||||
|
// so the tunnel comes back instead of staying dropped. Block-based
|
||||||
|
// observer avoids needing an @objc selector on this non-NSObject singleton.
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
forName: .networkPathDidChange,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.networkPathChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func markDisconnected() {
|
func markDisconnected() {
|
||||||
runOnMain {
|
runOnMain {
|
||||||
|
|
@ -41,13 +53,7 @@ final class TunnelManager: ObservableObject {
|
||||||
isStarting = true
|
isStarting = true
|
||||||
|
|
||||||
DispatchQueue.global(qos: .userInteractive).async { [showErrorUI] in
|
DispatchQueue.global(qos: .userInteractive).async { [showErrorUI] in
|
||||||
let result: Result<Void, NSError>
|
let result = self.connectWithRetry()
|
||||||
do {
|
|
||||||
try JITEnableContext.shared.startTunnel()
|
|
||||||
result = .success(())
|
|
||||||
} catch {
|
|
||||||
result = .failure(error as NSError)
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.finishStart(result, showErrorUI: showErrorUI)
|
self.finishStart(result, showErrorUI: showErrorUI)
|
||||||
|
|
@ -55,6 +61,75 @@ final class TunnelManager: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attempts the tunnel connection a few times with a short backoff before
|
||||||
|
/// giving up. Cellular paths (and Wi-Fi↔cellular handoffs) are more prone to
|
||||||
|
/// transient connect failures than Wi-Fi, so a couple of retries turn a
|
||||||
|
/// spurious drop into a successful connection instead of an error alert.
|
||||||
|
private func connectWithRetry() -> Result<Void, NSError> {
|
||||||
|
let maxAttempts = 3
|
||||||
|
var lastError: NSError?
|
||||||
|
|
||||||
|
for attempt in 1...maxAttempts {
|
||||||
|
do {
|
||||||
|
try JITEnableContext.shared.startTunnel()
|
||||||
|
if attempt > 1 {
|
||||||
|
LogManager.shared.addInfoLog("Tunnel connected on attempt \(attempt)")
|
||||||
|
}
|
||||||
|
return .success(())
|
||||||
|
} catch let error as NSError {
|
||||||
|
lastError = error
|
||||||
|
|
||||||
|
if Self.isPermanentTunnelError(error) || attempt == maxAttempts {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
let backoff = TimeInterval(attempt) // 1s, then 2s
|
||||||
|
LogManager.shared.addWarningLog(
|
||||||
|
"Tunnel connect attempt \(attempt) failed: \(error.localizedDescription). Retrying in \(Int(backoff))s…"
|
||||||
|
)
|
||||||
|
Thread.sleep(forTimeInterval: backoff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return .failure(lastError ?? NSError(
|
||||||
|
domain: "StikDebug",
|
||||||
|
code: -1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Tunnel connection failed"]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True for failures that another attempt won't fix — they need user action
|
||||||
|
/// (fresh pairing file, valid target IP), not a retry.
|
||||||
|
private static func isPermanentTunnelError(_ error: NSError) -> Bool {
|
||||||
|
// -9 invalid/expired pairing, -17 missing pairing, -18 bad target IP.
|
||||||
|
if [-9, -17, -18].contains(error.code) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let message = error.localizedDescription.lowercased()
|
||||||
|
return message.contains("parse target ip")
|
||||||
|
|| message.contains("pairing file not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func networkPathChanged() {
|
||||||
|
// Debounce: a handoff can emit several path updates in quick succession.
|
||||||
|
pathChangeWorkItem?.cancel()
|
||||||
|
let item = DispatchWorkItem { [weak self] in
|
||||||
|
self?.handleNetworkPathChange()
|
||||||
|
}
|
||||||
|
pathChangeWorkItem = item
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5, execute: item)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleNetworkPathChange() {
|
||||||
|
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")
|
||||||
|
start(showErrorUI: false)
|
||||||
|
}
|
||||||
|
|
||||||
private func finishStart(_ result: Result<Void, NSError>, showErrorUI: Bool) {
|
private func finishStart(_ result: Result<Void, NSError>, showErrorUI: Bool) {
|
||||||
isStarting = false
|
isStarting = false
|
||||||
|
|
||||||
|
|
@ -159,7 +234,7 @@ private func tunnelConnectionAlertMessage(for error: NSError) -> String {
|
||||||
recoverySteps = [
|
recoverySteps = [
|
||||||
"Open LocalDevVPN and confirm the VPN is connected.",
|
"Open LocalDevVPN and confirm the VPN is connected.",
|
||||||
"Make sure LocalDevVPN is using the default \(DeviceConnectionContext.defaultTargetIPAddress) address.",
|
"Make sure LocalDevVPN is using the default \(DeviceConnectionContext.defaultTargetIPAddress) address.",
|
||||||
"Reconnect Wi-Fi and LocalDevVPN, then try again.",
|
"Reconnect LocalDevVPN, then try again (Wi-Fi or cellular both work).",
|
||||||
"If this keeps happening, select a fresh pairing file."
|
"If this keeps happening, select a fresh pairing file."
|
||||||
]
|
]
|
||||||
} else if error.code == -18 || lowercasedMessage.contains("parse target ip") {
|
} else if error.code == -18 || lowercasedMessage.contains("parse target ip") {
|
||||||
|
|
@ -171,7 +246,7 @@ private func tunnelConnectionAlertMessage(for error: NSError) -> String {
|
||||||
} else if lowercasedMessage.contains("timed out") || lowercasedMessage.contains("timeout") {
|
} else if lowercasedMessage.contains("timed out") || lowercasedMessage.contains("timeout") {
|
||||||
likelyCause = "The app could not reach the device before the connection timed out."
|
likelyCause = "The app could not reach the device before the connection timed out."
|
||||||
recoverySteps = [
|
recoverySteps = [
|
||||||
"Confirm Wi-Fi and LocalDevVPN are both connected.",
|
"Confirm you have network access (Wi-Fi or cellular) and LocalDevVPN is connected.",
|
||||||
"Wake and unlock the target device.",
|
"Wake and unlock the target device.",
|
||||||
"Confirm LocalDevVPN is exposing the device at \(targetIP)."
|
"Confirm LocalDevVPN is exposing the device at \(targetIP)."
|
||||||
]
|
]
|
||||||
|
|
@ -180,12 +255,12 @@ private func tunnelConnectionAlertMessage(for error: NSError) -> String {
|
||||||
recoverySteps = [
|
recoverySteps = [
|
||||||
"Disconnect and reconnect LocalDevVPN.",
|
"Disconnect and reconnect LocalDevVPN.",
|
||||||
"Confirm iOS shows the VPN indicator.",
|
"Confirm iOS shows the VPN indicator.",
|
||||||
"Try switching Wi-Fi off and on."
|
"Toggle your network connection (or Airplane Mode) off and on."
|
||||||
]
|
]
|
||||||
} else {
|
} else {
|
||||||
likelyCause = "The tunnel could not be created."
|
likelyCause = "The tunnel could not be created."
|
||||||
recoverySteps = [
|
recoverySteps = [
|
||||||
"Confirm Wi-Fi and LocalDevVPN are connected.",
|
"Confirm network access (Wi-Fi or cellular) and LocalDevVPN are connected.",
|
||||||
"Wake and unlock the target device.",
|
"Wake and unlock the target device.",
|
||||||
"Reconnect LocalDevVPN, then try again."
|
"Reconnect LocalDevVPN, then try again."
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -8,4 +8,7 @@ import Foundation
|
||||||
extension Notification.Name {
|
extension Notification.Name {
|
||||||
static let pairingFileImported = Notification.Name("PairingFileImported")
|
static let pairingFileImported = Notification.Name("PairingFileImported")
|
||||||
static let intentJSScriptReady = Notification.Name("intentJSScriptReady")
|
static let intentJSScriptReady = Notification.Name("intentJSScriptReady")
|
||||||
|
/// Posted when the reachable network path changes (interface type, VPN
|
||||||
|
/// presence, or reachability), so the tunnel can revalidate/reconnect.
|
||||||
|
static let networkPathDidChange = Notification.Name("NetworkPathDidChange")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,8 +105,8 @@ struct SettingsView: View {
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: keepAliveLocation) { _, enabled in
|
.onChange(of: keepAliveLocation) { _, _ in
|
||||||
if !enabled { BackgroundLocationManager.shared.stop() }
|
BackgroundLocationManager.shared.refreshFromSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
} header: {
|
} header: {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue