From 33a55562f71c1ef9620dca87407fd926c2b0c548 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 16 Aug 2026 10:14:33 -0700 Subject: [PATCH] fix(ios-qa): compile the private-API touch bridge out of Release builds PR #2264 claimed DebugBridgeTouch.m (KIF-derived in-process touch synthesis using private UIKit/IOKit symbols: _touchesEvent, IOHIDEventCreateDigitizer*, _AXSSetAutomationEnabled) was "compiled out in Release," but the body was gated only by TARGET_OS_IOS, so a Release iOS build carried the private symbols (App Store rejection risk). The safety half of the fix (closed PR #2269) never landed. Gate the body on `#if TARGET_OS_IOS && DEBUG` and add the cSettings DEBUG define to the DebugBridgeTouch target so `#if DEBUG` is true in debug and false in release (mirrors the Core/UI swiftSettings). A free static tripwire pins both halves; the nm/strings symbol proof needs an iOS-SDK build and belongs in the device/periodic tier. Co-Authored-By: Claude Fable 5 --- ios-qa/templates/DebugBridgeTouch.m.template | 7 +- ios-qa/templates/Package.swift.template | 7 ++ test/fixtures/ios-qa/FixtureApp/Package.swift | 3 + .../DebugBridgeTouch/DebugBridgeTouch.m | 7 +- test/ios-debug-bridge-release-guard.test.ts | 64 +++++++++++++++++++ 5 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 test/ios-debug-bridge-release-guard.test.ts diff --git a/ios-qa/templates/DebugBridgeTouch.m.template b/ios-qa/templates/DebugBridgeTouch.m.template index aa6954c6d..a48141162 100644 --- a/ios-qa/templates/DebugBridgeTouch.m.template +++ b/ios-qa/templates/DebugBridgeTouch.m.template @@ -20,7 +20,12 @@ #import "DebugBridgeTouch.h" #import -#if TARGET_OS_IOS +// DEBUG gate in addition to TARGET_OS_IOS: the private-API touch synthesis must +// compile out of Release builds entirely (App Store rejection risk + no +// automation code in shipped binaries). DEBUG is defined for this target only +// in the debug configuration (see cSettings in Package.swift), so a Release iOS +// build emits an empty translation unit — zero private symbols. +#if TARGET_OS_IOS && DEBUG #import #import diff --git a/ios-qa/templates/Package.swift.template b/ios-qa/templates/Package.swift.template index 427a3399d..32d3e61f0 100644 --- a/ios-qa/templates/Package.swift.template +++ b/ios-qa/templates/Package.swift.template @@ -43,6 +43,13 @@ let package = Package( dependencies: [], path: "Sources/DebugBridgeTouch", publicHeadersPath: "include", + cSettings: [ + // Defines DEBUG for the ObjC translation unit in debug config + // only, so DebugBridgeTouch.m's `#if TARGET_OS_IOS && DEBUG` + // body compiles out of Release builds. Mirrors the swiftSettings + // DEBUG define on the Core/UI targets. + .define("DEBUG", .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). diff --git a/test/fixtures/ios-qa/FixtureApp/Package.swift b/test/fixtures/ios-qa/FixtureApp/Package.swift index 40477f0fe..e0f31cb3e 100644 --- a/test/fixtures/ios-qa/FixtureApp/Package.swift +++ b/test/fixtures/ios-qa/FixtureApp/Package.swift @@ -32,6 +32,9 @@ let package = Package( dependencies: [], path: "Sources/DebugBridgeTouch", publicHeadersPath: "include", + cSettings: [ + .define("DEBUG", .when(configuration: .debug)), + ], linkerSettings: [ .linkedFramework("UIKit", .when(platforms: [.iOS])), ] diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m index aa6954c6d..a48141162 100644 --- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m +++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m @@ -20,7 +20,12 @@ #import "DebugBridgeTouch.h" #import -#if TARGET_OS_IOS +// DEBUG gate in addition to TARGET_OS_IOS: the private-API touch synthesis must +// compile out of Release builds entirely (App Store rejection risk + no +// automation code in shipped binaries). DEBUG is defined for this target only +// in the debug configuration (see cSettings in Package.swift), so a Release iOS +// build emits an empty translation unit — zero private symbols. +#if TARGET_OS_IOS && DEBUG #import #import diff --git a/test/ios-debug-bridge-release-guard.test.ts b/test/ios-debug-bridge-release-guard.test.ts new file mode 100644 index 000000000..925321c8c --- /dev/null +++ b/test/ios-debug-bridge-release-guard.test.ts @@ -0,0 +1,64 @@ +import { describe, test, expect } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; + +// Static tripwire (free tier, runs on every PR): the private-API ObjC touch +// bridge MUST compile out of Release builds. That safety property has two +// load-bearing halves, and this test pins both against regression: +// +// 1. DebugBridgeTouch.m's body is gated `#if TARGET_OS_IOS && DEBUG` — NOT a +// bare `#if TARGET_OS_IOS` (which shipped the private symbols in Release +// iOS builds; PR #2264 claimed they were compiled out but the guard was +// platform-only). +// 2. The DebugBridgeTouch target in Package.swift carries a cSettings +// DEBUG define scoped to the debug configuration — without it, `#if DEBUG` +// is false even in Debug and the bridge silently breaks in the case it +// exists to serve. +// +// The full proof — an iOS-SDK Release build asserting `nm`/`strings` of the +// built binary contain none of _touchesEvent / IOHIDEventCreateDigitizer* / +// _AXSSetAutomationEnabled / DebugBridgeTouch — needs an iOS builder and lives +// in the device/periodic tier (the macOS `swift build` lane can't build the +// Touch target). This tripwire guards the source-level invariant everywhere. + +const ROOT = path.resolve(import.meta.dir, '..'); + +const TOUCH_SOURCES = [ + 'ios-qa/templates/DebugBridgeTouch.m.template', + 'test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m', +]; + +const PACKAGE_MANIFESTS = [ + 'ios-qa/templates/Package.swift.template', + 'test/fixtures/ios-qa/FixtureApp/Package.swift', +]; + +describe('DebugBridgeTouch Release compile-out guard', () => { + for (const rel of TOUCH_SOURCES) { + test(`${rel} gates the body on TARGET_OS_IOS && DEBUG`, () => { + const src = fs.readFileSync(path.join(ROOT, rel), 'utf-8'); + // The primary body guard must be the DEBUG-qualified form. + expect(src).toContain('#if TARGET_OS_IOS && DEBUG'); + // And must NOT carry a bare platform-only guard as the body gate — that + // was the exact regression (private API shipped in Release). + expect(src).not.toMatch(/^#if TARGET_OS_IOS$/m); + }); + } + + for (const rel of PACKAGE_MANIFESTS) { + test(`${rel} defines DEBUG for the DebugBridgeTouch target in debug config`, () => { + const src = fs.readFileSync(path.join(ROOT, rel), 'utf-8'); + // Anchor on the target's unique `path:` (the `name:` string also appears + // in the products/.library section). The DEBUG cSettings define sits just + // after the path line; take a forward window to the next .target( (or end) + // so the assertion is scoped to THIS target, not the whole manifest. + const anchor = src.indexOf('path: "Sources/DebugBridgeTouch"'); + expect(anchor).toBeGreaterThan(-1); + const rest = src.slice(anchor); + const nextTarget = rest.indexOf('.target('); + const block = nextTarget > -1 ? rest.slice(0, nextTarget) : rest; + expect(block).toContain('cSettings:'); + expect(block).toMatch(/\.define\("DEBUG",\s*\.when\(configuration:\s*\.debug\)\)/); + }); + } +});