mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 20:10:30 +00:00
Keep the app unsuspended during a debug session so reconnect can run
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 <noreply@anthropic.com>
This commit is contained in:
parent
6c167f660c
commit
5ba55e7bb8
5 changed files with 116 additions and 27 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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..<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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ final class BackgroundLocationManager: NSObject, CLLocationManagerDelegate {
|
|||
private let locationManager = CLLocationManager()
|
||||
private var isRunning = false
|
||||
private var activityCount = 0
|
||||
private var forcedActivityCount = 0
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
|
|
@ -40,16 +41,41 @@ final class BackgroundLocationManager: NSObject, CLLocationManagerDelegate {
|
|||
locationManager.stopUpdatingLocation()
|
||||
}
|
||||
|
||||
func requestStart() {
|
||||
activityCount += 1
|
||||
if activityCount == 1, UserDefaults.standard.bool(forKey: "keepAliveLocation") {
|
||||
start()
|
||||
/// Request that the app be kept alive by background location updates.
|
||||
///
|
||||
/// Pass `force: true` for an active debug session that must keep running
|
||||
/// 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() {
|
||||
activityCount = max(activityCount - 1, 0)
|
||||
if activityCount == 0 {
|
||||
func requestStop(force: Bool = false) {
|
||||
if force {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue