Three fixes aimed at a simulation that stays up without manual intervention.
NetworkPathMonitor: ignore the utun (.other) interface when reporting which
interface is in use. LocalDevVPN is always present and always in use, so it
masked the real path — every state looked like "Other", a Wi-Fi<->cellular
handoff produced an identical signature, and no change was ever broadcast.
TunnelManager: stop trusting isConnected. It only records that the last
connect succeeded, and nothing in the app notices a tunnel that dies quietly,
which is precisely what a handoff causes — so the reconnect was skipped in the
exact case it existed for. Now a genuine interface change rebuilds the tunnel
regardless. startTunnel() is semaphore-guarded and recreates its adapter, so
reconnecting a live tunnel is safe.
LocationSimulationKeepAlive: persist the held coordinate and re-arm it on
launch. iOS gives sideloaded apps no way to start themselves, so a reboot or a
kill will always drop the simulation; restoring on launch means reconnecting
the VPN and opening the app is enough to get it back, with no need to re-pick
the location. Armed before the VPN is up is fine — the resend loop tolerates
failure and takes hold once the tunnel exists. Skipped when no pairing file
exists, since nothing could succeed and it would only burn battery.
Not verified on device.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The simulated location only held while the map screen was visible and the app
was foregrounded. The resend loop was a main-runloop Timer in the view's
@State, and .onDisappear tore it down along with the background assertion — so
switching tabs, locking the phone, or ~30s of background each stopped the
resends, let the simulation connection die, and snapped the device back to its
real location.
- Add LocationSimulationKeepAlive, which owns the held coordinate, resends it
from a dispatch timer instead of the main runloop, takes forced audio and
location holds (so it works with the user's keep-alive toggles off), and
renews its background assertion rather than letting it expire.
- MapSelectionView delegates the resend loop to the service, and .onDisappear
no longer stops the simulation — only an explicit Clear releases it. On
reappear the view re-adopts the held coordinate so the UI is not misleading.
- MainTabView's URL-scheme path holds on simulate, and releases *before*
clearing so a queued resend cannot re-apply a location just cleared.
This is the part of the reliability problem the PR never addressed: its
network-path work only governs the JIT/debug tunnel, while location simulation
runs over its own separate rp_pairing connection.
Not verified on device — the background-suspension behaviour needs testing on
real hardware.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Build the cellular work on GitHub's macOS runners so it can be compiled and
packaged without a local Mac.
- Trigger the IPA build on the working branches, not just main.
- Use latest-stable instead of a pinned Xcode. The pin (26.0.1) predates the
PR, which had itself bumped to 26.6.0; pinned versions get dropped from the
runner images and fail the build for reasons unrelated to the code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
StikDebug assumed Wi-Fi even though the tunnel rides LocalDevVPN and works
equally well on cellular. On a Wi-Fi<->cellular handoff the loopback tunnel
dropped silently and the JIT/debug session died with no recovery.
- Add NetworkPathMonitor (NWPathMonitor) reporting reachability, interface
type (Wi-Fi/cellular/VPN) and expensiveness, posting .networkPathDidChange
on real transitions (deduplicated by signature).
- TunnelManager retries connect 3x with 1s/2s backoff so transient cellular
failures recover, short-circuiting on permanent, user-actionable errors
(missing/invalid pairing -9/-17, bad target IP -18).
- TunnelManager reconnects on a debounced (1.5s) path change when the tunnel
is down, instead of staying dropped.
- Drop the false "Wi-Fi required" wording from connection alerts and README.
Ported from StephenDev0/StikDebug#432 commit 656ec6d (cellular slice only).
The keep-alive half of that commit follows as a separate commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>