From 2e19cf7ee5a3f52a6bb46564dde09f2e016fa439 Mon Sep 17 00:00:00 2001 From: Alex Evenson Date: Tue, 2 Jun 2026 08:36:16 -0700 Subject: [PATCH] =?UTF-8?q?fix(ios-qa):=20DebugBridge=20templates=20?= =?UTF-8?q?=E2=80=94=20clean=20install=20on=20Swift=206.x=20+=20no=20priva?= =?UTF-8?q?te=20SPI=20leak=20into=20Release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes five template bugs that break a fresh `/ios-qa` DebugBridge install on Swift 6.x toolchains, the most serious of which compiles private UITouch/UIEvent/IOKit SPIs into iOS Release builds (App Store Guideline 2.1 rejection class). 1. gen-accessors-tool/Package.swift: drop the GenAccessorsTests testTarget — Tests/ doesn't exist, so `swift build` failed with "invalid custom path". 2. Package.swift.template: move `// swift-tools-version:5.9` to line 1. Swift 6.0+ rejects a misplaced directive ("manifest is backward-incompatible with Swift < 6.0"). 3. Package.swift.template: drop the DebugBridgeCoreTests testTarget — never populated by the installer; same invalid-custom-path failure. 4. Delete DebugBridgeWiring.swift.template — it imported a non-existent `DebugBridge` module, referenced symbols that don't exist (AccessibilityScanner/SnapshotCapture/MutationDispatcher), and called a start(appState:recording:) overload that doesn't exist. Bridges.swift's `DebugBridgeUIWiring.installAll()` already does the real wiring; updated the DebugBridgeManager comment (template + fixture) to point there. 5. DebugBridgeTouch.m.template: gate the iOS impl behind `#if TARGET_OS_IOS && defined(DEBUG)` (was `#if TARGET_OS_IOS`), and add a cSettings DEBUG define to the DebugBridgeTouch target in Package.swift.template. swiftSettings do not reach Obj-C translation units, so the prior gate leaked the private SPIs into iOS Release binaries — the exact failure that forced a DebugBridge removal downstream. Fixture parity: applied the same .m gate + Package cSettings to the FixtureApp so the template↔fixture parity test stays green, and added host-independent static guards (gate present, cSettings present, tools-version on line 1) to test/skill-e2e-ios-swift-build.test.ts — the iOS-Release leak can't be caught by the macOS nm-scan since DebugBridgeTouch links UIKit. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../scripts/gen-accessors-tool/Package.swift | 5 --- .../DebugBridgeManager.swift.template | 10 +++-- ios-qa/templates/DebugBridgeTouch.m.template | 14 +++--- .../DebugBridgeWiring.swift.template | 43 ------------------- ios-qa/templates/Package.swift.template | 16 ++++--- test/fixtures/ios-qa/FixtureApp/Package.swift | 9 ++++ .../DebugBridgeCore/DebugBridgeManager.swift | 10 +++-- .../DebugBridgeTouch/DebugBridgeTouch.m | 14 +++--- test/skill-e2e-ios-swift-build.test.ts | 32 ++++++++++++++ 9 files changed, 81 insertions(+), 72 deletions(-) delete mode 100644 ios-qa/templates/DebugBridgeWiring.swift.template diff --git a/ios-qa/scripts/gen-accessors-tool/Package.swift b/ios-qa/scripts/gen-accessors-tool/Package.swift index 653b68f36..f71c58793 100644 --- a/ios-qa/scripts/gen-accessors-tool/Package.swift +++ b/ios-qa/scripts/gen-accessors-tool/Package.swift @@ -31,10 +31,5 @@ let package = Package( ], path: "Sources/GenAccessors" ), - .testTarget( - name: "GenAccessorsTests", - dependencies: ["GenAccessors"], - path: "Tests/GenAccessorsTests" - ), ] ) diff --git a/ios-qa/templates/DebugBridgeManager.swift.template b/ios-qa/templates/DebugBridgeManager.swift.template index e18e2fb05..c019e6e16 100644 --- a/ios-qa/templates/DebugBridgeManager.swift.template +++ b/ios-qa/templates/DebugBridgeManager.swift.template @@ -22,12 +22,16 @@ public final class DebugBridgeManager { // 2. Boot the StateServer. StateServer.shared.start() - // 3. The consuming app installs DebugOverlayWindow separately. See - // the example in DebugBridgeWiring.swift.template: + // 3. The consuming app installs the UI bridges + overlay separately, + // from DebugBridgeUI, via: // // #if canImport(UIKit) - // DebugOverlayWindow.shared.install(recording: recording) + // DebugBridgeUIWiring.installAll() // #endif + // + // See Bridges.swift.template (`DebugBridgeUIWiring.installAll()`), + // which wires ElementsBridgeImpl / ScreenshotBridgeImpl / + // MutationBridgeImpl and installs the overlay window. } } diff --git a/ios-qa/templates/DebugBridgeTouch.m.template b/ios-qa/templates/DebugBridgeTouch.m.template index 7f7b7d1a3..f41f5c709 100644 --- a/ios-qa/templates/DebugBridgeTouch.m.template +++ b/ios-qa/templates/DebugBridgeTouch.m.template @@ -20,7 +20,7 @@ #import "DebugBridgeTouch.h" #import -#if TARGET_OS_IOS +#if TARGET_OS_IOS && defined(DEBUG) #import #import @@ -286,11 +286,13 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) { @end -#else // !TARGET_OS_IOS +#else // !(TARGET_OS_IOS && DEBUG) — iOS Release, macOS, or Catalyst -// macOS / Catalyst / other non-iOS host build: no-op stub so the module -// resolves cleanly without UIKit or IOKit. The Swift cross-platform tests -// don't exercise touch synthesis; that's iOS-only by definition. +// iOS Release / macOS / Catalyst / other non-iOS host build: no-op stub so +// the module resolves cleanly without UIKit or IOKit, AND so the private +// touch-synthesis SPIs never compile into a shippable (Release) binary. The +// Swift cross-platform tests don't exercise touch synthesis; that's +// iOS-Debug-only by definition. @implementation DebugBridgeTouch + (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window { (void)point; (void)window; @@ -298,4 +300,4 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) { } @end -#endif // TARGET_OS_IOS +#endif // TARGET_OS_IOS && defined(DEBUG) diff --git a/ios-qa/templates/DebugBridgeWiring.swift.template b/ios-qa/templates/DebugBridgeWiring.swift.template deleted file mode 100644 index 009a70861..000000000 --- a/ios-qa/templates/DebugBridgeWiring.swift.template +++ /dev/null @@ -1,43 +0,0 @@ -// AUTO-GENERATED from gstack/ios-qa/templates/DebugBridgeWiring.swift.template -// -// Wiring snippet for the app's @main entry. Users paste this into their -// App.swift inside the `init()` of the SwiftUI App struct, gated by -// #if DEBUG. The wiring is intentionally tiny; everything heavy lives in -// the DebugBridge target. - -#if DEBUG -import DebugBridge - -@MainActor -func startGstackDebugBridge(appState: AppState) { - // Read --recording flag from launch arguments - let recording = ProcessInfo.processInfo.arguments.contains("--gstack-recording") - - // Install accessibility + screenshot + mutation bridges before starting - // the server so the first authenticated request can use them. - ElementsBridge.resolver = { AccessibilityScanner.snapshot() } - ScreenshotBridge.resolver = { SnapshotCapture.capturePNG() } - MutationBridge.resolver = { op, payload in - MutationDispatcher.shared.run(op: op, payload: payload) - } - - DebugBridgeManager.shared.start(appState: appState, recording: recording) -} -#endif - -// Example usage in the app's @main entry (paste this into App.swift): -// -// @main -// struct MyApp: App { -// @State private var appState = MyAppState() -// -// init() { -// #if DEBUG -// startGstackDebugBridge(appState: appState) -// #endif -// } -// -// var body: some Scene { -// WindowGroup { ContentView() } -// } -// } diff --git a/ios-qa/templates/Package.swift.template b/ios-qa/templates/Package.swift.template index 88d6bd319..bb6e9f38b 100644 --- a/ios-qa/templates/Package.swift.template +++ b/ios-qa/templates/Package.swift.template @@ -1,3 +1,4 @@ +// swift-tools-version:5.9 // AUTO-GENERATED from gstack/ios-qa/templates/Package.swift.template // // Drop-in SPM package definition for the DebugBridge stack. Three targets: @@ -18,7 +19,6 @@ // CI invariant: `swift build -c release` + `nm -j build/Release/ // | grep -q DebugBridge && exit 1`. -// swift-tools-version:5.9 import PackageDescription let package = Package( @@ -43,6 +43,15 @@ let package = Package( dependencies: [], path: "Sources/DebugBridgeTouch", publicHeadersPath: "include", + cSettings: [ + // DEBUG gate for the Obj-C translation unit. swiftSettings do + // NOT propagate to .m files, so without this the private + // UITouch/UIEvent/IOKit SPIs in DebugBridgeTouch.m would + // compile into Release builds and trip Apple's static API + // scanner (App Store Guideline 2.1). Pairs with the + // `#if TARGET_OS_IOS && defined(DEBUG)` gate in the .m file. + .define("DEBUG", to: "1", .when(configuration: .debug)), + ], linkerSettings: [ // IOKit is loaded dynamically via dlopen at runtime (it's a // private framework on iOS and can't be linked statically). @@ -58,10 +67,5 @@ let package = Package( .define("DEBUG", .when(configuration: .debug)), ] ), - .testTarget( - name: "DebugBridgeCoreTests", - dependencies: ["DebugBridgeCore"], - path: "Tests/DebugBridgeCoreTests" - ), ] ) diff --git a/test/fixtures/ios-qa/FixtureApp/Package.swift b/test/fixtures/ios-qa/FixtureApp/Package.swift index 40477f0fe..18abc637f 100644 --- a/test/fixtures/ios-qa/FixtureApp/Package.swift +++ b/test/fixtures/ios-qa/FixtureApp/Package.swift @@ -32,6 +32,15 @@ let package = Package( dependencies: [], path: "Sources/DebugBridgeTouch", publicHeadersPath: "include", + cSettings: [ + // DEBUG gate for the Obj-C translation unit. swiftSettings do + // NOT propagate to .m files, so without this the private + // UITouch/UIEvent/IOKit SPIs in DebugBridgeTouch.m would + // compile into Release builds and trip Apple's static API + // scanner (App Store Guideline 2.1). Pairs with the + // `#if TARGET_OS_IOS && defined(DEBUG)` gate in the .m file. + .define("DEBUG", to: "1", .when(configuration: .debug)), + ], linkerSettings: [ .linkedFramework("UIKit", .when(platforms: [.iOS])), ] diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/DebugBridgeManager.swift b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/DebugBridgeManager.swift index e18e2fb05..c019e6e16 100644 --- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/DebugBridgeManager.swift +++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/DebugBridgeManager.swift @@ -22,12 +22,16 @@ public final class DebugBridgeManager { // 2. Boot the StateServer. StateServer.shared.start() - // 3. The consuming app installs DebugOverlayWindow separately. See - // the example in DebugBridgeWiring.swift.template: + // 3. The consuming app installs the UI bridges + overlay separately, + // from DebugBridgeUI, via: // // #if canImport(UIKit) - // DebugOverlayWindow.shared.install(recording: recording) + // DebugBridgeUIWiring.installAll() // #endif + // + // See Bridges.swift.template (`DebugBridgeUIWiring.installAll()`), + // which wires ElementsBridgeImpl / ScreenshotBridgeImpl / + // MutationBridgeImpl and installs the overlay window. } } diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m index 7f7b7d1a3..f41f5c709 100644 --- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m +++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m @@ -20,7 +20,7 @@ #import "DebugBridgeTouch.h" #import -#if TARGET_OS_IOS +#if TARGET_OS_IOS && defined(DEBUG) #import #import @@ -286,11 +286,13 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) { @end -#else // !TARGET_OS_IOS +#else // !(TARGET_OS_IOS && DEBUG) — iOS Release, macOS, or Catalyst -// macOS / Catalyst / other non-iOS host build: no-op stub so the module -// resolves cleanly without UIKit or IOKit. The Swift cross-platform tests -// don't exercise touch synthesis; that's iOS-only by definition. +// iOS Release / macOS / Catalyst / other non-iOS host build: no-op stub so +// the module resolves cleanly without UIKit or IOKit, AND so the private +// touch-synthesis SPIs never compile into a shippable (Release) binary. The +// Swift cross-platform tests don't exercise touch synthesis; that's +// iOS-Debug-only by definition. @implementation DebugBridgeTouch + (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window { (void)point; (void)window; @@ -298,4 +300,4 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) { } @end -#endif // TARGET_OS_IOS +#endif // TARGET_OS_IOS && defined(DEBUG) diff --git a/test/skill-e2e-ios-swift-build.test.ts b/test/skill-e2e-ios-swift-build.test.ts index 8a8c3b92b..6534816ec 100644 --- a/test/skill-e2e-ios-swift-build.test.ts +++ b/test/skill-e2e-ios-swift-build.test.ts @@ -61,6 +61,38 @@ describe('template ↔ fixture parity', () => { }); }); +// Static guards for the iOS-Release private-SPI leak. The real failure only +// manifests in an iOS Release build (TARGET_OS_IOS true, DEBUG undefined), +// which the macOS `swift build` host cannot produce — DebugBridgeTouch links +// UIKit. So a host-side nm-scan of Touch always reads clean and proves +// nothing. These cheap text assertions lock the two gates that keep the +// private UITouch/UIEvent/IOKit SPIs out of App Store binaries (the exact +// failure that forced a DebugBridge removal in a downstream app's PR #342). +describe('iOS-Release private-SPI leak guard', () => { + test('DebugBridgeTouch.m.template gates the iOS impl behind DEBUG', () => { + const m = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8'); + // The SPI-bearing block must require BOTH iOS AND DEBUG. A bare + // `#if TARGET_OS_IOS` compiles the private SPIs into iOS Release. + expect(m).toContain('#if TARGET_OS_IOS && defined(DEBUG)'); + expect(m).not.toMatch(/#if\s+TARGET_OS_IOS\s*$/m); + }); + + test('Package.swift.template DEBUG-gates the Obj-C touch target via cSettings', () => { + const pkg = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8'); + // swiftSettings do NOT reach .m translation units — the Obj-C target + // needs its own cSettings DEBUG define as belt-and-suspenders. + const touchTarget = pkg.match(/\.target\(\s*\n\s*name:\s*"DebugBridgeTouch"[\s\S]*?\n\s{8}\)/); + expect(touchTarget).not.toBeNull(); + expect(touchTarget![0]).toMatch(/cSettings:\s*\[[\s\S]*?\.define\(\s*"DEBUG",\s*to:\s*"1",\s*\.when\(configuration:\s*\.debug\)\)/); + }); + + test('tools-version directive is the first line of Package.swift.template', () => { + const pkg = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8'); + // Swift 6.0+ rejects a tools-version directive that is not on line 1. + expect(pkg.split('\n')[0]).toMatch(/^\/\/ swift-tools-version:/); + }); +}); + function hasSwift(): boolean { const r = spawnSync('swift', ['--version'], { stdio: 'pipe' }); return r.status === 0;