fix(ios-qa): keep private touch APIs out of release apps

This commit is contained in:
t 2026-07-14 18:40:45 -07:00
parent 630e3d3a28
commit b8491d2d18
7 changed files with 85 additions and 33 deletions

View File

@ -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.

View File

@ -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 <TargetConditionals.h>
#if DEBUG
#if TARGET_OS_IOS
#import <UIKit/UIKit.h>
@ -341,3 +346,5 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) {
@end
#endif // TARGET_OS_IOS
#endif // DEBUG

View File

@ -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/<binary>
// | 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).

View File

@ -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])),
]

View File

@ -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 <TargetConditionals.h>
#if DEBUG
#if TARGET_OS_IOS
#import <UIKit/UIKit.h>
@ -299,3 +304,5 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) {
@end
#endif // TARGET_OS_IOS
#endif // DEBUG

View File

@ -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.

View File

@ -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);
});