From 5ba55e7bb8b56348bb7a06aa6ba060669fa0a52e Mon Sep 17 00:00:00 2001 From: umarfatmi05 Date: Fri, 24 Jul 2026 20:12:32 -0400 Subject: [PATCH] Keep the app unsuspended during a debug session so reconnect can run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tunnel reconnect in the previous commit only fires while StikDebug is actually executing. In the real handoff scenario the user has switched to the JIT-enabled app, so StikDebug is backgrounded — and upstream lets iOS suspend it, which means the path-change notification is never delivered and the tunnel stays dropped. Two upstream behaviours cause that suspension: - Keep-alive only ran if the user had the "Silent Audio"/"Background Location" toggles on. DebugKeepAliveLease now takes forced holds (force: true) that bypass those toggles for the duration of a debug session, released when the session ends. Both managers keep separate forced/unforced counters. - The background-task expiration handler called invalidate(), tearing down audio and location the moment the ~30s assertion elapsed. It now renews the assertion (re-checking isActive) while silent audio sustains execution. Also play an inaudible non-zero signal (~-80 dBFS, DC-free) instead of digital silence, which some iOS versions treat as idle and reclaim. SettingsView calls refreshFromSettings() rather than stop(), so toggling the setting off no longer kills a forced session hold. This is the minimal slice of the PR's keep-alive work that the cellular reconnect depends on. Drop this commit for a foreground-only cellular fix. Ported from StephenDev0/StikDebug#432 commit 656ec6d (keep-alive slice). Co-Authored-By: Claude Opus 5 --- README.md | 1 + .../Services/BackgroundAudioManager.swift | 44 ++++++++++++--- .../Services/BackgroundLocationManager.swift | 40 +++++++++++--- StikDebug/Services/DebugKeepAliveLease.swift | 54 ++++++++++++++----- StikDebug/Views/SettingsView.swift | 4 +- 5 files changed, 116 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 433046f..22a655b 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,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 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/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/Views/SettingsView.swift b/StikDebug/Views/SettingsView.swift index c876037..f1f3738 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: {