diff --git a/README.md b/README.md index d698aa5..433046f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/StikDebug/App/AppBootstrapper.swift b/StikDebug/App/AppBootstrapper.swift index ffb94ac..2b3368c 100644 --- a/StikDebug/App/AppBootstrapper.swift +++ b/StikDebug/App/AppBootstrapper.swift @@ -12,6 +12,7 @@ enum AppBootstrapper { registerDefaultSettings() startConfiguredKeepAliveServices() applyDocumentPickerCopyWorkaround() + NetworkPathMonitor.shared.start() } private static func registerDefaultSettings() { diff --git a/StikDebug/Services/NetworkPathMonitor.swift b/StikDebug/Services/NetworkPathMonitor.swift new file mode 100644 index 0000000..a4b9896 --- /dev/null +++ b/StikDebug/Services/NetworkPathMonitor.swift @@ -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) + } +} diff --git a/StikDebug/Services/TunnelManager.swift b/StikDebug/Services/TunnelManager.swift index 2d0c83a..7b31a3d 100644 --- a/StikDebug/Services/TunnelManager.swift +++ b/StikDebug/Services/TunnelManager.swift @@ -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 - 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 { + 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, 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." ] diff --git a/StikDebug/Support/NotificationName+StikDebug.swift b/StikDebug/Support/NotificationName+StikDebug.swift index 7cb27e8..5340673 100644 --- a/StikDebug/Support/NotificationName+StikDebug.swift +++ b/StikDebug/Support/NotificationName+StikDebug.swift @@ -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") }