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:
Claude 2026-07-14 08:59:20 +00:00
parent 984a94a031
commit 656ec6d512
No known key found for this signature in database
9 changed files with 309 additions and 40 deletions

View file

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

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

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

View file

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

View file

@ -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()

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

View file

@ -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: {