Support cellular: monitor network path and reconnect the tunnel

StikDebug assumed Wi-Fi even though the tunnel rides LocalDevVPN and works
equally well on cellular. On a Wi-Fi<->cellular handoff the loopback tunnel
dropped silently and the JIT/debug session died with no recovery.

- Add NetworkPathMonitor (NWPathMonitor) reporting reachability, interface
  type (Wi-Fi/cellular/VPN) and expensiveness, posting .networkPathDidChange
  on real transitions (deduplicated by signature).
- TunnelManager retries connect 3x with 1s/2s backoff so transient cellular
  failures recover, short-circuiting on permanent, user-actionable errors
  (missing/invalid pairing -9/-17, bad target IP -18).
- TunnelManager reconnects on a debounced (1.5s) path change when the tunnel
  is down, instead of staying dropped.
- Drop the false "Wi-Fi required" wording from connection alerts and README.

Ported from StephenDev0/StikDebug#432 commit 656ec6d (cellular slice only).
The keep-alive half of that commit follows as a separate commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
umarfatmi05 2026-07-24 20:12:11 -04:00
parent 8f0439cd0c
commit 6c167f660c
5 changed files with 193 additions and 13 deletions

View file

@ -74,7 +74,7 @@ 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.
- 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.

View file

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

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

View file

@ -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."
] ]

View file

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