mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 12:02:14 +00:00
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:
parent
8f0439cd0c
commit
6c167f660c
5 changed files with 193 additions and 13 deletions
|
|
@ -74,7 +74,7 @@ StikDebug enables **JIT** for sideloaded apps on iOS 17.4+ without needing a com
|
|||
|
||||
**Troubleshooting**
|
||||
- "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.
|
||||
- Still stuck? Join the [Discord](https://discord.gg/ZnNcrRT3M8) with logs/screenshots.
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ enum AppBootstrapper {
|
|||
registerDefaultSettings()
|
||||
startConfiguredKeepAliveServices()
|
||||
applyDocumentPickerCopyWorkaround()
|
||||
NetworkPathMonitor.shared.start()
|
||||
}
|
||||
|
||||
private static func registerDefaultSettings() {
|
||||
|
|
|
|||
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
|
||||
|
||||
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() {
|
||||
runOnMain {
|
||||
|
|
@ -41,13 +53,7 @@ final class TunnelManager: ObservableObject {
|
|||
isStarting = true
|
||||
|
||||
DispatchQueue.global(qos: .userInteractive).async { [showErrorUI] in
|
||||
let result: Result<Void, NSError>
|
||||
do {
|
||||
try JITEnableContext.shared.startTunnel()
|
||||
result = .success(())
|
||||
} catch {
|
||||
result = .failure(error as NSError)
|
||||
}
|
||||
let result = self.connectWithRetry()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
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) {
|
||||
isStarting = false
|
||||
|
||||
|
|
@ -159,7 +234,7 @@ private func tunnelConnectionAlertMessage(for error: NSError) -> String {
|
|||
recoverySteps = [
|
||||
"Open LocalDevVPN and confirm the VPN is connected.",
|
||||
"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."
|
||||
]
|
||||
} 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") {
|
||||
likelyCause = "The app could not reach the device before the connection timed out."
|
||||
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.",
|
||||
"Confirm LocalDevVPN is exposing the device at \(targetIP)."
|
||||
]
|
||||
|
|
@ -180,12 +255,12 @@ private func tunnelConnectionAlertMessage(for error: NSError) -> String {
|
|||
recoverySteps = [
|
||||
"Disconnect and reconnect LocalDevVPN.",
|
||||
"Confirm iOS shows the VPN indicator.",
|
||||
"Try switching Wi-Fi off and on."
|
||||
"Toggle your network connection (or Airplane Mode) off and on."
|
||||
]
|
||||
} else {
|
||||
likelyCause = "The tunnel could not be created."
|
||||
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.",
|
||||
"Reconnect LocalDevVPN, then try again."
|
||||
]
|
||||
|
|
|
|||
|
|
@ -8,4 +8,7 @@ import Foundation
|
|||
extension Notification.Name {
|
||||
static let pairingFileImported = Notification.Name("PairingFileImported")
|
||||
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")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue