feat: remove wifi and external vpn requirements

This commit is contained in:
zy860 2026-08-18 14:50:24 +08:00
parent b86f6d9f24
commit aadf40aa30
11 changed files with 219 additions and 163 deletions

View file

@ -62,22 +62,22 @@ StikDebug enables **JIT** for sideloaded apps on iOS 17.4+ without needing a com
- StikDebug installed (via AltSource, direct .ipa, or self-built)
- A valid **pairing file** (.plist / .mobiledevicepairing) for your device
- SideStore / AltStore / similar sideload tool (for app refreshing)
- A loopback VPN such as [LocalDevVPN](https://apps.apple.com/us/app/localdevvpn/id6755608044)
- Permission to install and run StikDebug's embedded Network Extension VPN
### Steps
1. **Obtain a pairing file**
- Detailed guide: [Pairing File Instructions](https://github.com/StephenDev0/StikDebug-Guide/blob/main/pairing_file.md) (or ask in Discord).
2. **Set up VPN**
- Launch LocalDevVPN and enable the VPN.
2. **Set up the embedded VPN**
- Open StikDebug's Settings and tap **Start VPN**. The app creates and controls its own local loopback tunnel.
4. **Enable JIT for an app**
- Launch StikDebug and tapp the `Enable JIT` button.
3. **Enable JIT for an app**
- Launch StikDebug and tap the `Enable JIT` button.
- Select your sideloaded app from the list in StikDebug.
**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 the embedded VPN is connected. It may be a pairing file issue.
- Pairing file issues → Replace file with device unlocked & trusted.
- Still stuck? Join the [Discord](https://discord.gg/ZnNcrRT3M8) with logs/screenshots.

View file

@ -13,6 +13,7 @@
A1B2C3D40123456789ABC002 /* IPv4PacketRewriter.swift in StikDebugTunnel Sources */ = {isa = PBXBuildFile; fileRef = A3B2C3D40123456789ABC004 /* IPv4PacketRewriter.swift */; };
A1B2C3D40123456789ABC003 /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A3B2C3D40123456789ABC002 /* NetworkExtension.framework */; };
A1B2C3D40123456789ABC004 /* StikDebugTunnel.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A3B2C3D40123456789ABC001 /* StikDebugTunnel.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
A1B2C3D40123456789ABC005 /* NetworkExtension.framework in StikDebug Frameworks */ = {isa = PBXBuildFile; fileRef = A3B2C3D40123456789ABC002 /* NetworkExtension.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -108,6 +109,7 @@
files = (
68D569C02E1B415700A5BA36 /* LanguageSupport in Frameworks */,
68D569BE2E1B415700A5BA36 /* CodeEditorView in Frameworks */,
A1B2C3D40123456789ABC005 /* NetworkExtension.framework in StikDebug Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View file

@ -3,6 +3,7 @@
// StikDebugTunnel
//
import Darwin
import NetworkExtension
final class PacketTunnelProvider: NEPacketTunnelProvider {

View file

@ -664,19 +664,121 @@
}
}
},
"Download LocalDevVPN" : {
"Embedded VPN" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Download LocalDevVPN"
"value" : "Embedded VPN"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "下载 LocalDevVPN"
"value" : "内置 VPN"
}
}
}
},
"Start VPN" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Start VPN"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "启动 VPN"
}
}
}
},
"Stop VPN" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Stop VPN"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "停止 VPN"
}
}
}
},
"VPN connected" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "VPN connected"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "VPN 已连接"
}
}
}
},
"VPN connecting" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "VPN connecting"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "VPN 连接中"
}
}
}
},
"VPN disconnected" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "VPN disconnected"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "VPN 未连接"
}
}
}
},
"VPN failed" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "VPN failed"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "VPN 错误"
}
}
}

View file

@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.networking.networkextension</key>
<array>
<string>packet-tunnel-provider</string>
</array>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>

View file

@ -6,7 +6,6 @@
//
import SwiftUI
import Network
import idevice
// Register default settings before the app starts
@ -20,101 +19,6 @@ private func registerAdvancedOptionsDefault() {
UserDefaults.standard.register(defaults: ["keepAliveLocation": true])
}
// MARK: - DNS Checker
class DNSChecker: ObservableObject {
@Published var appleIP: String?
@Published var controlIP: String?
@Published var dnsError: String?
func checkDNS() {
checkIfConnectedToWifi { [weak self] wifiConnected in
guard let self = self else { return }
if wifiConnected {
let group = DispatchGroup()
group.enter()
self.lookupIPAddress(for: "gs.apple.com") { ip in
DispatchQueue.main.async {
self.appleIP = ip
}
group.leave()
}
group.enter()
self.lookupIPAddress(for: "google.com") { ip in
DispatchQueue.main.async {
self.controlIP = ip
}
group.leave()
}
group.notify(queue: .main) {
if self.controlIP == nil {
self.dnsError = "No internet connection."
} else if self.appleIP == nil {
self.dnsError = "Apple DNS blocked. Your network might be filtering Apple traffic."
} else {
self.dnsError = nil
}
}
} else {
DispatchQueue.main.async {
self.dnsError = nil
}
}
}
}
private func checkIfConnectedToWifi(completion: @escaping (Bool) -> Void) {
let monitor = NWPathMonitor(requiredInterfaceType: .wifi)
monitor.pathUpdateHandler = { path in
completion(path.status == .satisfied)
monitor.cancel()
}
let queue = DispatchQueue.global(qos: .background)
monitor.start(queue: queue)
}
private func lookupIPAddress(for host: String, completion: @escaping (String?) -> Void) {
DispatchQueue.global(qos: .background).async {
var hints = addrinfo(
ai_flags: 0,
ai_family: AF_UNSPEC,
ai_socktype: SOCK_STREAM,
ai_protocol: 0,
ai_addrlen: 0,
ai_canonname: nil,
ai_addr: nil,
ai_next: nil
)
var res: UnsafeMutablePointer<addrinfo>?
let err = getaddrinfo(host, nil, &hints, &res)
if err != 0 {
DispatchQueue.main.async { completion(nil) }
return
}
var ipAddress: String?
var ptr = res
while ptr != nil {
if let addr = ptr?.pointee.ai_addr {
var hostBuffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
if getnameinfo(addr, ptr!.pointee.ai_addrlen,
&hostBuffer, socklen_t(hostBuffer.count),
nil, 0, NI_NUMERICHOST) == 0 {
ipAddress = String(cString: hostBuffer)
break
}
}
ptr = ptr?.pointee.ai_next
}
freeaddrinfo(res)
DispatchQueue.main.async { completion(ipAddress) }
}
}
}
// MARK: - Main App
// Global state variable for the tunnel connection.
@ -342,7 +246,7 @@ func startTunnelInBackground(showErrorUI: Bool = true) {
} else {
showAlert(
title: "Connection Error",
message: "\(error.localizedDescription)\n\nMake sure Wi‑Fi and LocalDevVPN are connected and that the device is reachable.",
message: "\(error.localizedDescription)\n\nCheck that the embedded VPN is connected and that the pairing file is current.",
showOk: false,
showTryAgain: true
) { shouldTryAgain in
@ -360,51 +264,6 @@ func startTunnelInBackground(showErrorUI: Bool = true) {
}
func checkDeviceConnection(callback: @escaping (Bool, String?) -> Void) {
let targetIP = DeviceConnectionContext.targetIPAddress
let host = NWEndpoint.Host(targetIP)
let port = NWEndpoint.Port(rawValue: 62078)!
let connection = NWConnection(host: host, port: port, using: .tcp)
var timeoutWorkItem: DispatchWorkItem?
timeoutWorkItem = DispatchWorkItem { [weak connection] in
if connection?.state != .ready {
connection?.cancel()
DispatchQueue.main.async {
if timeoutWorkItem?.isCancelled == false {
let message = "[TIMEOUT] Could not reach the device at \(targetIP). Make sure it’s online and on the same network."
callback(false, message)
}
}
}
}
connection.stateUpdateHandler = { [weak connection] state in
switch state {
case .ready:
timeoutWorkItem?.cancel()
connection?.cancel()
DispatchQueue.main.async {
callback(true, nil)
}
case .failed(let error):
timeoutWorkItem?.cancel()
connection?.cancel()
DispatchQueue.main.async {
let message = "Could not reach the device at \(targetIP): \(error.localizedDescription)"
callback(false, message)
}
default:
break
}
}
connection.start(queue: .global())
if let workItem = timeoutWorkItem {
DispatchQueue.global().asyncAfter(deadline: .now() + 20, execute: workItem)
}
}
public func showAlert(title: String, message: String, showOk: Bool, showTryAgain: Bool = false, primaryButtonText: String? = nil, completion: ((Bool) -> Void)? = nil) {
DispatchQueue.main.async {
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,

View file

@ -69,15 +69,15 @@ enum DeviceTransportError: LocalizedError, Equatable {
var errorDescription: String? {
switch self {
case .invalidIPAddress(let ip):
return "Invalid RPPairing IPv4 address: (ip)"
return "Invalid RPPairing IPv4 address: \(ip)"
case .pairingFileMissing(let path):
return "Pairing file not found at (path)"
return "Pairing file not found at \(path)"
case .pairingFileReadFailed(let message):
return "Failed to read the RPPairing file: (message)"
return "Failed to read the RPPairing file: \(message)"
case .vpnUnavailable(let message):
return "Embedded VPN unavailable: (message)"
return "Embedded VPN unavailable: \(message)"
case .ffiFailure(let message):
return "RPPairing tunnel creation failed: (message)"
return "RPPairing tunnel creation failed: \(message)"
case .incompleteTunnel:
return "RPPairing returned incomplete tunnel handles"
}

View file

@ -53,6 +53,9 @@ final class StikDebugVPNManager: ObservableObject {
lastEnsureError = ensureError
startingWaiter = nil
stateLock.unlock()
if ensureError != nil {
publish(.failed)
}
waiter.signal()
}

View file

@ -44,11 +44,11 @@ enum StikDebugVPNError: LocalizedError, Equatable {
var errorDescription: String? {
switch self {
case .profileLoadFailed(let message):
return "Unable to load the embedded VPN profile: (message)"
return "Unable to load the embedded VPN profile: \(message)"
case .profileSaveFailed(let message):
return "Unable to save the embedded VPN profile: (message)"
return "Unable to save the embedded VPN profile: \(message)"
case .permissionDenied(let message):
return "The embedded VPN permission was denied: (message)"
return "The embedded VPN permission was denied: \(message)"
case .competingVPN:
return "Another VPN is active. Stop it before starting the StikDebug local tunnel."
case .timeout:

View file

@ -9,7 +9,6 @@ import UIKit
private enum SettingsLinks {
static let githubStars = URL(string: "https://github.com/StephenDev0/StikDebug/stargazers")!
static let pairingFileGuide = URL(string: "https://github.com/StephenDev0/StikDebug-Guide/blob/main/pairing_file.md")!
static let localDevVPN = URL(string: "https://apps.apple.com/us/app/localdevvpn/id6755608044")!
static let discord = URL(string: "https://discord.gg/qahjXNTDwS")!
}
@ -19,6 +18,7 @@ struct SettingsView: View {
@AppStorage("keepAliveLocation") private var keepAliveLocation = true
@AppStorage("customTargetIP") private var customTargetIP = ""
@Environment(LanguageManager.self) private var langManager
@StateObject private var vpnManager = StikDebugVPNManager.shared
@State private var isShowingPairingFilePicker = false
@State private var isImportingFile = false
@ -28,6 +28,7 @@ struct SettingsView: View {
@State private var ddiDownloadProgress: Double = 0.0
@State private var ddiStatusMessage: String = ""
@State private var ddiResultMessage: (text: String, isError: Bool)?
@State private var vpnErrorMessage: String?
private var appVersion: String {
let marketingVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
@ -96,6 +97,38 @@ struct SettingsView: View {
}
}
Section("Embedded VPN".localized) {
HStack {
Label(vpnStatusTitle.localized, systemImage: vpnStatusIcon)
Spacer()
Circle()
.fill(vpnStatusColor)
.frame(width: 9, height: 9)
}
if vpnManager.currentStatus == .connected || vpnManager.currentStatus == .connecting {
Button {
vpnManager.stop()
vpnErrorMessage = nil
} label: {
Label("Stop VPN".localized, systemImage: "stop.circle")
}
} else {
Button {
startEmbeddedVPN()
} label: {
Label("Start VPN".localized, systemImage: "play.circle")
}
.disabled(vpnManager.currentStatus == .connecting)
}
if let vpnErrorMessage {
Text(vpnErrorMessage)
.font(.caption)
.foregroundStyle(.red)
}
}
Section {
Toggle(isOn: $keepAliveAudio) {
VStack(alignment: .leading, spacing: 2) {
@ -163,9 +196,6 @@ struct SettingsView: View {
Link(destination: SettingsLinks.pairingFileGuide) {
Label("Pairing File Guide".localized, systemImage: "questionmark.circle")
}
Link(destination: SettingsLinks.localDevVPN) {
Label("Download LocalDevVPN".localized, systemImage: "arrow.down.circle")
}
Link(destination: SettingsLinks.discord) {
Label("Discord Support".localized, systemImage: "bubble.left.and.bubble.right")
}
@ -231,6 +261,60 @@ struct SettingsView: View {
return "Version \(appVersion) • iOS \(UIDevice.current.systemVersion) • \(txmLabel)"
}
private var vpnStatusTitle: String {
switch vpnManager.currentStatus {
case .connected:
return "VPN connected"
case .connecting:
return "VPN connecting"
case .disconnecting:
return "VPN connecting"
case .failed:
return "VPN failed"
case .disconnected:
return "VPN disconnected"
}
}
private var vpnStatusIcon: String {
switch vpnManager.currentStatus {
case .connected:
return "checkmark.shield.fill"
case .connecting, .disconnecting:
return "arrow.triangle.2.circlepath"
case .failed:
return "exclamationmark.shield.fill"
case .disconnected:
return "shield.slash"
}
}
private var vpnStatusColor: Color {
switch vpnManager.currentStatus {
case .connected:
return .green
case .connecting, .disconnecting:
return .orange
case .failed:
return .red
case .disconnected:
return .secondary
}
}
private func startEmbeddedVPN() {
vpnErrorMessage = nil
DispatchQueue.global(qos: .userInitiated).async {
do {
try vpnManager.start()
} catch {
DispatchQueue.main.async {
vpnErrorMessage = error.localizedDescription
}
}
}
}
// MARK: - Business Logic
private func openAppFolder() {

View file

@ -59,6 +59,7 @@ struct StikJITTests {
@Test func ipv4PacketRewriterSwapsPacketEndpoints() {
var packet = [UInt8](repeating: 0, count: 20)
packet[0] = 0x45
packet.replaceSubrange(12..<16, with: [10, 7, 0, 2])
packet.replaceSubrange(16..<20, with: [10, 7, 0, 1])