fix: dynamically resolve Remote Pairing ports for JIT and device tools

Discover _remotepairing._tcp via Bonjour, retain the 49152 fallback,
expose a manual port override, and report the actual selected endpoint
on connection failure.
This commit is contained in:
明宇 2026-10-11 15:18:18 +08:00
parent 871f1461d9
commit 61542ce0f6
5 changed files with 122 additions and 4 deletions

View file

@ -791,7 +791,7 @@ func simulate_location(_ deviceIP: String, _ latitude: Double, _ longitude: Doub
var address = sockaddr_in()
address.sin_family = sa_family_t(AF_INET)
address.sin_port = in_port_t(49152).bigEndian
address.sin_port = in_port_t(RemotePairingPortResolver.resolve()).bigEndian
let inetResult = deviceIP.withCString { inet_pton(AF_INET, $0, &address.sin_addr) }
guard inetResult == 1 else {

View file

@ -9,6 +9,99 @@ import Foundation
import idevice
import Darwin
final class RemotePairingPortResolver: NSObject, NetServiceBrowserDelegate, NetServiceDelegate {
static let fallback: UInt16 = 49152
private static let overrideKey = "TunnelRemotePairingPort"
private static let stateLock = NSLock()
private static var selectedEndpoint = "Not attempted yet"
/// The precise port and discovery method used for the latest connection attempt.
static var lastAttemptDescription: String {
stateLock.lock()
defer { stateLock.unlock() }
return selectedEndpoint
}
private static func record(port: UInt16, source: String) {
let endpoint = "\(DeviceConnectionContext.targetIPAddress):\(port) [\(source)]"
stateLock.lock()
selectedEndpoint = endpoint
stateLock.unlock()
NSLog("[StikDebug] Remote Pairing selected endpoint: %@", endpoint)
}
private let browser = NetServiceBrowser()
private var services: [NetService] = []
private var resolvedPort: UInt16?
private var resolvedServiceName: String?
private var discoveryError: String?
/// Resolve on a worker thread with an active run loop. An explicit Settings
/// override takes precedence when multicast DNS cannot see the local service.
static func resolve(timeout: TimeInterval = 5) -> UInt16 {
let override = (UserDefaults.standard.string(forKey: overrideKey) ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
if !override.isEmpty, let port = UInt16(override), port != 0 {
record(port: port, source: "manual override")
return port
}
if !override.isEmpty {
NSLog("[StikDebug] Ignoring invalid manual Remote Pairing port: %@", override)
}
let worker = RemotePairingPortResolver()
let completion = DispatchSemaphore(value: 0)
DispatchQueue.global(qos: .utility).async {
worker.search(timeout: timeout)
completion.signal()
}
guard completion.wait(timeout: .now() + timeout + 1) == .success else {
record(port: fallback, source: "Bonjour timeout; fallback")
return fallback
}
if let port = worker.resolvedPort {
record(port: port, source: "Bonjour: \(worker.resolvedServiceName ?? "unknown service")")
return port
}
let detail = worker.discoveryError ?? "no _remotepairing._tcp service resolved"
record(port: fallback, source: "Bonjour unavailable (\(detail)); fallback")
return fallback
}
private func search(timeout: TimeInterval) {
browser.delegate = self
browser.schedule(in: .current, forMode: .default)
browser.searchForServices(ofType: "_remotepairing._tcp.", inDomain: "local.")
let deadline = Date().addingTimeInterval(timeout)
while resolvedPort == nil && Date() < deadline {
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.1))
}
browser.stop()
services.forEach { $0.stop() }
}
func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) {
services.append(service)
service.delegate = self
service.resolve(withTimeout: 4)
}
func netServiceBrowser(_ browser: NetServiceBrowser, didNotSearch errorDict: [String: NSNumber]) {
discoveryError = "browser error \(errorDict)"
}
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
discoveryError = "service \(sender.name): \(errorDict)"
}
func netServiceDidResolveAddress(_ sender: NetService) {
if (1...65535).contains(sender.port), resolvedPort == nil {
resolvedPort = UInt16(sender.port)
resolvedServiceName = sender.name
}
}
}
typealias LogFunc = (String?) -> Void
typealias DebugAppCallback = (_ pid: Int32, _ debugProxy: OpaquePointer?, _ remoteServer: OpaquePointer?, _ semaphore: DispatchSemaphore) -> Void
typealias SyslogLineHandler = (String) -> Void
@ -169,7 +262,8 @@ final class JITEnableContext {
var addr = sockaddr_in()
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = in_port_t(49152).bigEndian
let remotePairingPort = RemotePairingPortResolver.resolve()
addr.sin_port = in_port_t(remotePairingPort).bigEndian
let deviceIP = DeviceConnectionContext.targetIPAddress
let parseResult = deviceIP.withCString { inet_pton(AF_INET, $0, &addr.sin_addr) }

View file

@ -16,8 +16,11 @@
</array>
</dict>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>StikDebug discovers this iPhone's Remote Pairing service to enable JIT.</string>
<key>NSBonjourServices</key>
<array>
<string>_remotepairing._tcp</string>
<string>_stikdebug._tcp</string>
<string>_stikdebug._udp</string>
</array>

View file

@ -129,7 +129,7 @@ func markTunnelDisconnected() {
}
private func tunnelConnectionLogMessage(for error: NSError) -> String {
let target = "\(DeviceConnectionContext.targetIPAddress):49152"
let target = RemotePairingPortResolver.lastAttemptDescription
return "Tunnel connection failed for \(target): \(error.localizedDescription) (Domain: \(error.domain), Code: \(error.code), Raw: \(String(describing: error)))"
}
@ -157,6 +157,14 @@ private func tunnelConnectionAlertMessage(for error: NSError) -> String {
"Reconnect Wi-Fi and LocalDevVPN, then try again.",
"If this keeps happening, select a fresh pairing file."
]
} else if error.code == 16 || lowercasedMessage.contains("device socket io failed") {
likelyCause = "The Remote Pairing tunnel could not connect to the selected endpoint."
recoverySteps = [
"Check the Target attempted line below (including whether Bonjour or fallback selected the port).",
"In SideStore Network Discovery, find the _remotepairing._tcp service port.",
"If it differs, set Settings > Advanced > Remote Pairing Port to SideStore's discovered port, then reconnect.",
"If the addresses and ports already match, check LocalDevVPN routing and the pairing file."
]
} else if error.code == -18 || lowercasedMessage.contains("parse target ip") {
likelyCause = "The configured target IP address is not valid."
recoverySteps = [
@ -193,7 +201,7 @@ private func tunnelConnectionAlertMessage(for error: NSError) -> String {
return """
\(likelyCause)
Target: \(targetIP):49152
Target attempted: \(RemotePairingPortResolver.lastAttemptDescription)
Expected LocalDevVPN IP: \(DeviceConnectionContext.defaultTargetIPAddress)
Try this:

View file

@ -19,6 +19,7 @@ struct SettingsView: View {
@AppStorage("keepAliveAudio") private var keepAliveAudio = true
@AppStorage("keepAliveLocation") private var keepAliveLocation = true
@AppStorage(UserDefaults.Keys.targetDeviceIP) private var targetDeviceIP = DeviceConnectionContext.defaultTargetIPAddress
@AppStorage("TunnelRemotePairingPort") private var remotePairingPortOverride = ""
@AppStorage(UserDefaults.Keys.mallocDebug) private var mallocDebug = false
@State private var isShowingPairingFilePicker = false
@ -153,6 +154,18 @@ struct SettingsView: View {
.keyboardType(.numbersAndPunctuation)
.frame(maxWidth: 160)
}
HStack {
Text("Remote Pairing Port")
Spacer()
TextField("Auto (Bonjour)", text: $remotePairingPortOverride)
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
.keyboardType(.numberPad)
.frame(maxWidth: 160)
}
Text("Leave blank for automatic discovery. Use the port reported by SideStore Network Discovery if Bonjour fails. Restart the tunnel after changing this value.")
.font(.caption)
.foregroundStyle(.secondary)
Button { openAppFolder() } label: {
Label("App Folder", systemImage: "folder")
}.foregroundStyle(.primary)