diff --git a/README.md b/README.md index 4f27be8..d9edf2a 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,8 @@ 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. +- 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. - 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/BackgroundAudioManager.swift b/StikDebug/Services/BackgroundAudioManager.swift index c19f22b..181016c 100644 --- a/StikDebug/Services/BackgroundAudioManager.swift +++ b/StikDebug/Services/BackgroundAudioManager.swift @@ -13,6 +13,7 @@ final class BackgroundAudioManager { private var isRunning = false private var persistentEnabled = false private var activityCount = 0 + private var forcedActivityCount = 0 private var healthCheckTimer: Timer? private init() { @@ -40,18 +41,33 @@ final class BackgroundAudioManager { refreshRunningState() } - func requestStart() { - activityCount += 1 + /// Request that the app be kept alive by silent audio. + /// + /// 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() } - func requestStop() { - activityCount = max(activityCount - 1, 0) + func requestStop(force: Bool = false) { + if force { + forcedActivityCount = max(forcedActivityCount - 1, 0) + } else { + activityCount = max(activityCount - 1, 0) + } 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 { if shouldRun { recoverIfNeeded() @@ -100,7 +116,23 @@ final class BackgroundAudioManager { let frameCount = AVAudioFrameCount(format.sampleRate) guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else { return } 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.. 0 + || (activityCount > 0 && UserDefaults.standard.bool(forKey: "keepAliveLocation")) + if shouldRun { + start() + } else if isRunning { stop() } } diff --git a/StikDebug/Services/DebugKeepAliveLease.swift b/StikDebug/Services/DebugKeepAliveLease.swift index 41659a5..024859d 100644 --- a/StikDebug/Services/DebugKeepAliveLease.swift +++ b/StikDebug/Services/DebugKeepAliveLease.swift @@ -6,6 +6,11 @@ import Foundation 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 { private let stateLock = NSLock() private var isActive = false @@ -25,13 +30,11 @@ final class DebugKeepAliveLease { stateLock.unlock() runOnMain { - BackgroundAudioManager.shared.requestStop() - BackgroundLocationManager.shared.requestStop() - - if self.backgroundTaskID != .invalid { - UIApplication.shared.endBackgroundTask(self.backgroundTaskID) - self.backgroundTaskID = .invalid - } + // Forced holds so the session stays alive even if the user turned the + // keep-alive toggles off; released here when the session ends. + BackgroundAudioManager.shared.requestStop(force: true) + BackgroundLocationManager.shared.requestStop(force: true) + self.endBackgroundTask() } } @@ -45,15 +48,42 @@ final class DebugKeepAliveLease { stateLock.unlock() runOnMain { - BackgroundAudioManager.shared.requestStart() - BackgroundLocationManager.shared.requestStart() - self.backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "StikDebugDebugSession") { [weak self] in - LogManager.shared.addWarningLog("Debug session background task expired") - self?.invalidate() + BackgroundAudioManager.shared.requestStart(force: true) + BackgroundLocationManager.shared.requestStart(force: true) + self.beginBackgroundTask() + } + } + + // 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) { if Thread.isMainThread { work() 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") } diff --git a/StikDebug/Views/SettingsView.swift b/StikDebug/Views/SettingsView.swift index b2ef6a0..52d1040 100644 --- a/StikDebug/Views/SettingsView.swift +++ b/StikDebug/Views/SettingsView.swift @@ -105,8 +105,8 @@ struct SettingsView: View { .font(.caption).foregroundStyle(.secondary) } } - .onChange(of: keepAliveLocation) { _, enabled in - if !enabled { BackgroundLocationManager.shared.stop() } + .onChange(of: keepAliveLocation) { _, _ in + BackgroundLocationManager.shared.refreshFromSettings() } } header: {