From b8491d2d18065d18b9dcae92a13d7d32be812225 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 18:40:45 -0700 Subject: [PATCH] fix(ios-qa): keep private touch APIs out of release apps --- ios-qa/templates/DebugBridgeTouch.h.template | 2 +- ios-qa/templates/DebugBridgeTouch.m.template | 11 ++- ios-qa/templates/Package.swift.template | 10 ++- test/fixtures/ios-qa/FixtureApp/Package.swift | 5 +- .../DebugBridgeTouch/DebugBridgeTouch.m | 11 ++- .../include/DebugBridgeTouch.h | 2 +- test/skill-e2e-ios-swift-build.test.ts | 77 +++++++++++++------ 7 files changed, 85 insertions(+), 33 deletions(-) diff --git a/ios-qa/templates/DebugBridgeTouch.h.template b/ios-qa/templates/DebugBridgeTouch.h.template index 86a2ddaa8..7106791bf 100644 --- a/ios-qa/templates/DebugBridgeTouch.h.template +++ b/ios-qa/templates/DebugBridgeTouch.h.template @@ -1,7 +1,7 @@ // // DebugBridgeTouch.h — public Objective-C interface for in-process touch // synthesis. Implementation derived from KIF (https://github.com/kif-framework/KIF), -// MIT-licensed. The minimal subset needed to deliver a real UITouch to a +// Apache-2.0 licensed. The minimal subset needed to deliver a real UITouch to a // point on the key window, including SwiftUI Buttons via iOS 18+ // _UIHitTestContext. DEBUG-only — never link in Release. diff --git a/ios-qa/templates/DebugBridgeTouch.m.template b/ios-qa/templates/DebugBridgeTouch.m.template index aa6954c6d..2c664f08a 100644 --- a/ios-qa/templates/DebugBridgeTouch.m.template +++ b/ios-qa/templates/DebugBridgeTouch.m.template @@ -1,7 +1,10 @@ // // DebugBridgeTouch.m — minimal port of KIF's in-process touch synthesis. -// Original code: https://github.com/kif-framework/KIF — MIT-licensed -// (Square, Inc. + KIF contributors). Adapted to a single-file, tap-only, +// Original code: https://github.com/kif-framework/KIF — Apache-2.0 +// Copyright 2011-2016 Square, Inc. and KIF contributors. +// Licensed under the Apache License, Version 2.0. You may obtain a copy at +// https://www.apache.org/licenses/LICENSE-2.0 +// Adapted to a single-file, tap-only, // iOS 18+ aware subset for the gstack/ios-qa DebugBridge. // // Uses these private UIKit selectors (DEBUG-only; never shipped to App Store): @@ -20,6 +23,8 @@ #import "DebugBridgeTouch.h" #import +#if DEBUG + #if TARGET_OS_IOS #import @@ -341,3 +346,5 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) { @end #endif // TARGET_OS_IOS + +#endif // DEBUG diff --git a/ios-qa/templates/Package.swift.template b/ios-qa/templates/Package.swift.template index 427a3399d..9b2e2f65a 100644 --- a/ios-qa/templates/Package.swift.template +++ b/ios-qa/templates/Package.swift.template @@ -12,9 +12,10 @@ // 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. +// The structural Release-build guard is compile-time: every Swift source is +// wrapped in `#if DEBUG`, and the Objective-C touch target receives the same +// DEBUG define so its private-API implementation compiles to an empty object +// in Release. // // CI invariant: `swift build -c release` + `nm -j build/Release/ // | grep -q DebugBridge && exit 1`. @@ -43,6 +44,9 @@ let package = Package( dependencies: [], path: "Sources/DebugBridgeTouch", publicHeadersPath: "include", + cSettings: [ + .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..660b0b6cf 100644 --- a/test/fixtures/ios-qa/FixtureApp/Package.swift +++ b/test/fixtures/ios-qa/FixtureApp/Package.swift @@ -3,7 +3,7 @@ // DebugBridgeCore (Foundation+Network) builds cross-platform. // DebugBridgeUI (UIKit/SwiftUI) is iOS-only. // DebugBridgeTouch (Objective-C) is iOS-only — in-process tap synthesis -// derived from KIF (MIT). DEBUG-only. +// derived from KIF (Apache-2.0). DEBUG-only. import PackageDescription @@ -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 7f7b7d1a3..a8b413d85 100644 --- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m +++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m @@ -1,7 +1,10 @@ // // DebugBridgeTouch.m — minimal port of KIF's in-process touch synthesis. -// Original code: https://github.com/kif-framework/KIF — MIT-licensed -// (Square, Inc. + KIF contributors). Adapted to a single-file, tap-only, +// Original code: https://github.com/kif-framework/KIF — Apache-2.0 +// Copyright 2011-2016 Square, Inc. and KIF contributors. +// Licensed under the Apache License, Version 2.0. You may obtain a copy at +// https://www.apache.org/licenses/LICENSE-2.0 +// Adapted to a single-file, tap-only, // iOS 18+ aware subset for the gstack/ios-qa DebugBridge. // // Uses these private UIKit selectors (DEBUG-only; never shipped to App Store): @@ -20,6 +23,8 @@ #import "DebugBridgeTouch.h" #import +#if DEBUG + #if TARGET_OS_IOS #import @@ -299,3 +304,5 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) { @end #endif // TARGET_OS_IOS + +#endif // DEBUG diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h index 1f85c1211..e44034530 100644 --- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h +++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h @@ -1,7 +1,7 @@ // // DebugBridgeTouch.h — public Objective-C interface for in-process touch // synthesis. Implementation derived from KIF (https://github.com/kif-framework/KIF), -// MIT-licensed. The minimal subset needed to deliver a real UITouch to a +// Apache-2.0 licensed. The minimal subset needed to deliver a real UITouch to a // point on the key window, including SwiftUI Buttons via iOS 18+ // _UIHitTestContext. DEBUG-only — never link in Release. diff --git a/test/skill-e2e-ios-swift-build.test.ts b/test/skill-e2e-ios-swift-build.test.ts index 8a8c3b92b..d6e4bf0e1 100644 --- a/test/skill-e2e-ios-swift-build.test.ts +++ b/test/skill-e2e-ios-swift-build.test.ts @@ -19,36 +19,15 @@ import { describe, test, expect } from 'bun:test'; import { spawnSync } from 'child_process'; -import { existsSync, readFileSync } from 'fs'; +import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; import { join } from 'path'; const ROOT = join(import.meta.dir, '..'); const FIXTURE_PATH = join(ROOT, 'test/fixtures/ios-qa/FixtureApp'); const TEMPLATES_PATH = join(ROOT, 'ios-qa/templates'); -// Parity: canonical Obj-C touch templates must match the fixture's working -// copy. The fixture is the only place the .m / .h are exercised end-to-end -// on a real device, so any divergence means consuming apps would ship a -// stale, untested version of the SwiftUI hit-test fix. describe('template ↔ fixture parity', () => { - test('DebugBridgeTouch.h.template matches fixture include', () => { - const tmpl = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.h.template'), 'utf-8'); - const fixture = readFileSync( - join(FIXTURE_PATH, 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'), - 'utf-8', - ); - expect(tmpl).toBe(fixture); - }); - - test('DebugBridgeTouch.m.template matches fixture .m', () => { - const tmpl = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8'); - const fixture = readFileSync( - join(FIXTURE_PATH, 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'), - 'utf-8', - ); - expect(tmpl).toBe(fixture); - }); - test('Package.swift.template declares all 3 DebugBridge targets', () => { const tmpl = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8'); // Each target must be present as a library product AND a target definition. @@ -59,6 +38,13 @@ describe('template ↔ fixture parity', () => { // app gets the transitive set with one dependency entry. expect(tmpl).toMatch(/name:\s*"DebugBridgeUI"[\s\S]*?dependencies:\s*\["DebugBridgeCore",\s*"DebugBridgeTouch"\]/); }); + + test('KIF-derived touch source preserves Apache-2.0 attribution', () => { + const implementation = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8'); + expect(implementation).toContain('Copyright 2011-2016 Square, Inc. and KIF contributors.'); + expect(implementation).toContain('Apache License, Version 2.0'); + expect(implementation).not.toContain('MIT-licensed'); + }); }); function hasSwift(): boolean { @@ -151,4 +137,49 @@ describeIfSwift('swift build invariants', () => { } expect(foundForbidden).toBe(0); }, 300_000); + + test('final Release iOS app excludes private touch APIs', () => { + if (spawnSync('xcrun', ['--find', 'xcodebuild']).status !== 0 || + spawnSync('xcodegen', ['--version']).status !== 0) return; + + const workDir = mkdtempSync(join(tmpdir(), 'gstack-ios-release-guard-')); + const fixtureDir = join(workDir, 'FixtureApp'); + try { + cpSync(FIXTURE_PATH, fixtureDir, { recursive: true }); + writeFileSync( + join(fixtureDir, 'Package.swift'), + readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8'), + ); + writeFileSync( + join(fixtureDir, 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'), + readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8'), + ); + writeFileSync( + join(fixtureDir, 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'), + readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.h.template'), 'utf-8'), + ); + expect(spawnSync('xcodegen', [ + 'generate', '--spec', join(fixtureDir, 'project.yml'), + '--project', fixtureDir, '--project-root', fixtureDir, + ], { cwd: fixtureDir, stdio: 'pipe' }).status).toBe(0); + + const derivedData = join(workDir, 'DerivedData'); + const build = spawnSync('xcodebuild', [ + '-project', join(fixtureDir, 'FixtureApp.xcodeproj'), + '-scheme', 'FixtureApp', '-configuration', 'Release', + '-sdk', 'iphonesimulator', '-destination', 'generic/platform=iOS Simulator', + '-derivedDataPath', derivedData, 'CODE_SIGNING_ALLOWED=NO', 'build', + ], { cwd: fixtureDir, stdio: 'pipe', timeout: 300_000 }); + expect(build.status).toBe(0); + + const binary = join(derivedData, 'Build/Products/Release-iphonesimulator/FixtureApp.app/FixtureApp'); + const contents = spawnSync('strings', ['-a', binary]).stdout?.toString() ?? ''; + for (const forbidden of [ + 'DebugBridgeTouch', '_touchesEvent', '_AXSSetAutomationEnabled', + 'IOHIDEventCreateDigitizerEvent', + ]) expect(contents).not.toContain(forbidden); + } finally { + rmSync(workDir, { recursive: true, force: true }); + } + }, 360_000); });