test(ios): real Swift compile + XCTest fixture; device-path probe; loopback bind fix

Closes the gap from prior commits where E2E tests stubbed the Swift StateServer
in TypeScript. Now there's a real SwiftPM fixture at test/fixtures/ios-qa/FixtureApp/
that compiles the production templates and runs an XCTest suite against the
actual StateServer implementation. Three new test layers:

- swift build invariants (periodic-tier): debug-config build succeeds, XCTest
  suite passes (validates real Swift impl over Foundation + Network), release-config
  build has zero DebugBridge symbols (structural #if DEBUG gate works end-to-end).

- Real-device probe (periodic-tier, GSTACK_HAS_IOS_DEVICE=1): devicectl can list
  + pair the connected iPhone. Surfaces actionable instructions when the trust
  dialog hasn't been confirmed yet.

- Fixture sources copied from ios-qa/templates/ — Package.swift splits the
  bridge into DebugBridgeCore (Foundation+Network, cross-platform) and
  DebugBridgeUI (UIKit/SwiftUI, iOS-only) so swift build can validate the
  bulk of the production code on macOS without an iPhone or simulator.

Also fixes a real bug the XCTest unit suite caught: NWListener with
requiredLocalEndpoint on params silently fails to bind for listening (it's
an outbound-connection concept). Replaced with .requiredInterfaceType=.loopback
+ .acceptLocalOnly=true + a per-connection peer-address check. The fork's
inherited code had this bug; we shipped it untouched in v1.41.0.0 and the
new XCTest suite caught it immediately.
This commit is contained in:
Garry Tan
2026-05-17 20:12:03 -07:00
parent d184edef78
commit b4fe510648
11 changed files with 1246 additions and 2 deletions
+31 -2
View File
@@ -134,20 +134,36 @@ public final class StateServer {
private func startListener(family: AddressFamily) {
do {
// Loopback-only binding: requiredInterfaceType=.loopback restricts the
// listener to lo0. NWListener does NOT honor requiredLocalEndpoint on
// its NWParameters (that's an outbound-connection concept) — so we
// pair the loopback interface gate with an explicit per-connection
// peer-address check below.
let params = NWParameters.tcp
params.requiredLocalEndpoint = .hostPort(host: family.host, port: NWEndpoint.Port(rawValue: port)!)
params.allowLocalEndpointReuse = true
params.requiredInterfaceType = .loopback
params.acceptLocalOnly = true
let listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!)
listener.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
if case .ready = state {
self?.logger.notice("StateServer listening on \(String(describing: family))")
} else if case .failed(let err) = state {
self?.logger.error("StateServer listener failed: \(err.localizedDescription, privacy: .public)")
}
}
}
listener.newConnectionHandler = { [weak self] connection in
Task { @MainActor in self?.handle(connection) }
Task { @MainActor in
// Defense-in-depth: even with .loopback interface gate, double-check
// the peer is loopback. Reject otherwise.
if let self, self.isLoopbackPeer(connection) {
self.handle(connection)
} else {
connection.cancel()
}
}
}
listener.start(queue: .global(qos: .userInitiated))
@@ -160,6 +176,19 @@ public final class StateServer {
}
}
private func isLoopbackPeer(_ connection: NWConnection) -> Bool {
switch connection.endpoint {
case .hostPort(let host, _):
switch host {
case .ipv4(let addr): return addr == .loopback
case .ipv6(let addr): return addr.isLoopback
case .name(let name, _): return name == "localhost"
@unknown default: return false
}
default: return false
}
}
// MARK: Request handling
private func handle(_ connection: NWConnection) {