From ecfd9af9d6f0e50910c8383ca48aec81e7411a7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Tott=C3=A9?= Date: Sat, 15 Aug 2026 19:58:44 +0100 Subject: [PATCH] ios-qa: guard DebugBridgeTouch.m on DEBUG, not just TARGET_OS_IOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DebugBridgeTouch.m and its header both promise the code is DEBUG-only and never shipped: "Uses these private UIKit selectors (DEBUG-only; never shipped to App Store)" "DEBUG-only — never link in Release." Nothing enforced it. The only guard was `#if TARGET_OS_IOS`, so a Release build for iOS compiled the entire implementation in, private API and all. Measured on a real app (an iOS Release build, `nm -j` on the app binary): DebugBridge symbols 15 IOHIDEventCreateDigitizer 2 AXSSetAutomationEnabled 1 symbol, 2 strings IOKit.framework 4 strings including +[DebugBridgeTouch sendTapAtPoint:inWindow:] and _OBJC_CLASS_$_DebugBridgeTouch. That is a Guideline 2.5.1 private-API exposure in a shippable binary, and it fails Package.swift's own stated CI invariant: nm -j build/Release/ | grep -q DebugBridge && exit 1 WHY THE EXISTING GUARD DOES NOT COVER THIS Package.swift documents the protection as `.when(configuration: .debug)` on the consuming target's dependency. That works for SwiftPM consumers. It cannot be expressed by an app that integrates DebugBridge as a local package inside an .xcodeproj: Xcode's Filters column under Frameworks, Libraries, and Embedded Content offers platform conditions only — iOS, macOS, visionOS — never build configuration. So for xcodeproj consumers the documented guard silently does nothing, which is precisely the case that was measured. The Swift targets were already safe: all four .swift files are `#if DEBUG` guarded and Package.swift defines DEBUG for them via swiftSettings. Only the Objective-C target, the one that actually links private API, was unguarded. THE FIX 1. DebugBridgeTouch.m.template now branches `#if !defined(DEBUG)` first and emits nothing at all in Release, falling through to the existing iOS and non-iOS branches only in Debug. 2. Package.swift.template declares DEBUG explicitly for the ObjC target: cSettings: [.define("DEBUG", .when(configuration: .debug))] The two Swift targets already did this. Relying on SwiftPM's implicit DEBUG for C-family targets is not worth betting a private-API exposure on. VERIFIED, by compiling the generated file for iOS both ways: xcrun -sdk iphoneos clang -c DebugBridgeTouch.m -arch arm64 ... Release (no -DDEBUG) 0 DebugBridge symbols, 0 private-API symbols, 448 B Debug (-DDEBUG=1) 7 DebugBridge symbols, 6 private-API symbols, 13104 B The harness is unchanged in Debug. Release now emits an empty translation unit. Co-Authored-By: Claude Opus 5 --- ios-qa/templates/DebugBridgeTouch.m.template | 26 ++++++++++++++++++-- ios-qa/templates/Package.swift.template | 26 +++++++++++++++++--- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/ios-qa/templates/DebugBridgeTouch.m.template b/ios-qa/templates/DebugBridgeTouch.m.template index aa6954c6d..e82199467 100644 --- a/ios-qa/templates/DebugBridgeTouch.m.template +++ b/ios-qa/templates/DebugBridgeTouch.m.template @@ -20,7 +20,29 @@ #import "DebugBridgeTouch.h" #import -#if TARGET_OS_IOS +#if !defined(DEBUG) + +// RELEASE BUILD: this file deliberately emits NOTHING — no class, no symbols, no +// private-API references. The header still declares the interface, which is inert +// on its own; every consumer of DebugBridgeTouch is itself `#if DEBUG` guarded, so +// nothing references it here and nothing fails to link. +// +// This guard was added because the comments at the top of this file and in the +// header both PROMISED "DEBUG-only; never shipped to App Store" and "never link in +// Release" — and nothing enforced it. Only `#if TARGET_OS_IOS` was tested, so a +// Release build for iOS compiled the whole implementation in. Measured on a real +// app (Seize Day, 2026-08-15), `nm -j` on a Release binary returned 15 DebugBridge +// symbols including +[DebugBridgeTouch sendTapAtPoint:inWindow:], plus +// IOHIDEventCreateDigitizer, AXSSetAutomationEnabled and IOKit.framework strings. +// That is a Guideline 2.5.1 private-API exposure in a shippable binary. +// +// Package.swift's stated guard — `.when(configuration: .debug)` on the consuming +// target's dependency — only exists for SwiftPM consumers. An app integrating this +// as a local package inside an .xcodeproj cannot express it: Xcode's Filters column +// in Frameworks/Libraries offers platform conditions only, never configuration. So +// the guard has to live here, in the source, where it holds for every consumer. + +#elif TARGET_OS_IOS #import #import @@ -340,4 +362,4 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) { } @end -#endif // TARGET_OS_IOS +#endif // !DEBUG / TARGET_OS_IOS diff --git a/ios-qa/templates/Package.swift.template b/ios-qa/templates/Package.swift.template index 427a3399d..39100d637 100644 --- a/ios-qa/templates/Package.swift.template +++ b/ios-qa/templates/Package.swift.template @@ -12,9 +12,22 @@ // MutationBridge implementations. Depends on the other // two. // -// The structural Release-build guard is the `.when(configuration: .debug)` -// conditional on every consuming target's dependency. SwiftPM refuses to link -// DebugBridge* in Release config. +// Release-build guards, in order of reliability: +// +// 1. SOURCE. Every file is `#if DEBUG` guarded, including DebugBridgeTouch.m, +// which holds regardless of how the package is integrated. This is the one +// that actually protects you. +// 2. `.when(configuration: .debug)` on the consuming target's dependency. This +// works for SwiftPM consumers ONLY. An app integrating this as a local +// package inside an .xcodeproj CANNOT express it — Xcode's Filters column in +// Frameworks/Libraries offers platform conditions, never configuration — so +// do not rely on it alone. +// +// Guard 1 was added 2026-08-15 after a measured failure: DebugBridgeTouch.m was +// guarded only by `#if TARGET_OS_IOS`, so a Release iOS build of a real app linked +// it, and `nm -j` returned 15 DebugBridge symbols plus IOHIDEventCreateDigitizer, +// AXSSetAutomationEnabled and IOKit.framework strings. Guideline 2.5.1 exposure in +// a shippable binary, with guard 2 present and doing nothing. // // CI invariant: `swift build -c release` + `nm -j build/Release/ // | grep -q DebugBridge && exit 1`. @@ -43,6 +56,13 @@ let package = Package( dependencies: [], path: "Sources/DebugBridgeTouch", publicHeadersPath: "include", + cSettings: [ + // Explicit, because the source guard depends on it. SwiftPM's + // implicit DEBUG for C-family targets is not something to bet a + // private-API exposure on — the two Swift targets already declare + // it, and this target is the one that actually links private API. + .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).