From 8d4d631606c701aac150cf009d3b80ae4a7ae7d5 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:47:10 +0800 Subject: [PATCH] fix(apple): decode UIKit keyed-archive outlet connections Select authoritative connection fields by runtime class in a shared owner used by keyed archives and NIBArchive projection. Preserve archive identities, endpoint relationships and coverage, and replace duplicate synthetic outlet assertions with a rooted public CLI fixture. Validation: compiled CLI acceptance against the issue-sourced rooted UIKit fixture and 41 filesystem tests. Native UIKit framework validation was not rerun. Fixes #2177. --- .../apple/InterfaceBuilderAnalysis.ts | 21 +++-- .../apple/interfaceBuilderKeyedArchive.ts | 62 ++++++++----- .../interfaceBuilderUIKitKeyedArchive.test.ts | 88 +++++++++++++++++++ .../filesystem/interfaceBuilderBundle.test.ts | 31 ++----- .../interface-builder/UIKitOutlet.xml | 85 ++++++++++++++++++ 5 files changed, 231 insertions(+), 56 deletions(-) create mode 100644 tests/acceptance/applications/interfaceBuilderUIKitKeyedArchive.test.ts create mode 100644 tests/fixtures/interface-builder/UIKitOutlet.xml diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index 89cf499e65..20af7a3cb2 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -2,6 +2,10 @@ import { createHash } from "node:crypto"; import { parseBinary } from "plist"; import { appleResourceKeyFields } from "../../domain/apple/appleAssetCatalog.js"; +import { + interfaceBuilderConnectionFieldNames, + isUIKitRuntimeConnectionClass, +} from "../../domain/apple/interfaceBuilderKeyedArchive.js"; import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js"; import { projectPlistValue } from "../../domain/apple/plistValue.js"; import { @@ -438,8 +442,10 @@ const projectNibArchive = ( archive.objects .filter((object) => { const name = runtimeClass(object.id) ?? ""; - return !/^(?:NSObject|NSIBObjectData|NSString|NSMutableString|NSNumber|NSArray|NSMutableArray|NSSet|NSMutableSet|NSDictionary|NSMutableDictionary|NSApplication|NSNib.*Connector|UIRuntime\w*Connection)$/u.test( - name, + return ( + !/^(?:NSObject|NSIBObjectData|NSString|NSMutableString|NSNumber|NSArray|NSMutableArray|NSSet|NSMutableSet|NSDictionary|NSMutableDictionary|NSApplication|NSNib.*Connector)$/u.test( + name, + ) && !isUIKitRuntimeConnectionClass(name) ); }) .map((object) => { @@ -498,15 +504,14 @@ const projectNibArchive = ( const className = normalizeClassName(object.class_name); // AppKit connectors use NS-prefixed keys; UIKit's runtime outlet and // event connections use UI-prefixed keys with the same roles. - const uiKit = /^UIRuntime\w*Connection$/u.test(className); + const uiKit = isUIKitRuntimeConnectionClass(className); if (!uiKit && !className.includes("Connector")) continue; const values = object.values; - const source = reference(uiKit ? values.UISource : values.NSSource); + const connectionFields = interfaceBuilderConnectionFieldNames(className); + const source = reference(values[connectionFields.source]); if (source === null) continue; - const destination = reference( - uiKit ? values.UIDestination : values.NSDestination, - ); - const label = referencedString(uiKit ? values.UILabel : values.NSLabel); + const destination = reference(values[connectionFields.destination]); + const label = referencedString(values[connectionFields.label]); const type = className.includes("Outlet") ? "outlet" : className.includes("Control") || className.includes("Event") diff --git a/src/domain/apple/interfaceBuilderKeyedArchive.ts b/src/domain/apple/interfaceBuilderKeyedArchive.ts index c0b7f35077..207950bf8b 100644 --- a/src/domain/apple/interfaceBuilderKeyedArchive.ts +++ b/src/domain/apple/interfaceBuilderKeyedArchive.ts @@ -34,6 +34,16 @@ const connection = z.strictObject({ type InterfaceBuilderObject = z.infer; type InterfaceBuilderConnection = z.infer; +/** UIKit runtime connection classes store their endpoints and labels under UI-prefixed keys. */ +export const isUIKitRuntimeConnectionClass = (className: string): boolean => + /^UIRuntime\w*Connection$/u.test(className); + +/** The connection class selects the source archive field namespace. */ +export const interfaceBuilderConnectionFieldNames = (className: string) => + isUIKitRuntimeConnectionClass(className) + ? { source: "UISource", destination: "UIDestination", label: "UILabel" } + : { source: "NSSource", destination: "NSDestination", label: "NSLabel" }; + /** Parse ibtool dictionaries or compiled keyed archives into typed records. */ export const parseInterfaceBuilderRecords = ( value: unknown, @@ -349,35 +359,41 @@ const parseKeyedArchive = ( ) ) { const controlAction = /ControlConnector/iu.test(className); - // AppKit connectors (NSNibOutletConnector, NSNibControlConnector) - // archive NSSource as the outlet owner or sending control and - // NSDestination as the outlet value or action target, so they are read - // in that order without the inversion the unprefixed fields use. - const source = firstObjectReference( - controlAction ? fields.destination : fields.source, - archivedObjectReference(fields.NSSource), - controlAction ? fields.to : fields.from, - controlAction ? fields.target : fields.owner, - ); + const uiKit = isUIKitRuntimeConnectionClass(className); + // AppKit and UIKit archive the same endpoint roles under their own + // class-specific field namespaces; the source is the outlet owner. + const connectionFields = interfaceBuilderConnectionFieldNames(className); + const source = uiKit + ? archivedObjectReference(fields[connectionFields.source]) + : firstObjectReference( + controlAction ? fields.destination : fields.source, + archivedObjectReference(fields[connectionFields.source]), + controlAction ? fields.to : fields.from, + controlAction ? fields.target : fields.owner, + ); if (source === null) continue; const kind = controlAction ? "action" : classifyConnection(className); const parsedConnection = connection.parse({ id: `archive:${objectId}`, kind, source_id: source, - destination_id: firstObjectReference( - controlAction ? fields.source : fields.destination, - archivedObjectReference(fields.NSDestination), - controlAction ? fields.from : fields.to, - controlAction ? fields.owner : fields.target, - ), - label: firstString( - fields.label, - fields.NSLabel, - fields.selector, - fields.identifier, - fields.action, - ), + destination_id: uiKit + ? archivedObjectReference(fields[connectionFields.destination]) + : firstObjectReference( + controlAction ? fields.source : fields.destination, + archivedObjectReference(fields[connectionFields.destination]), + controlAction ? fields.from : fields.to, + controlAction ? fields.owner : fields.target, + ), + label: uiKit + ? firstString(fields[connectionFields.label]) + : firstString( + fields.label, + fields[connectionFields.label], + fields.selector, + fields.identifier, + fields.action, + ), attributes: jsonSafeRecord(fields), }); if (connections.length < 40_000) connections.push(parsedConnection); diff --git a/tests/acceptance/applications/interfaceBuilderUIKitKeyedArchive.test.ts b/tests/acceptance/applications/interfaceBuilderUIKitKeyedArchive.test.ts new file mode 100644 index 0000000000..02ec7c607b --- /dev/null +++ b/tests/acceptance/applications/interfaceBuilderUIKitKeyedArchive.test.ts @@ -0,0 +1,88 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; + +import { interfaceBuilderAnalysisSchema } from "../../../src/domain/apple/interfaceBuilderGraph.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { thinMach } from "../../../src/domain/binaryTarget.fixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "decodes a rooted UIKit keyed-archive outlet through CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-cli-uikit-outlet-"); + const app = join(root, "UIKitFixture.app"); + const contents = join(app, "Contents"); + const resources = join(contents, "Resources"); + await mkdir(resources, { recursive: true }); + await mkdir(join(contents, "MacOS")); + await writeFile( + join(contents, "MacOS", "Fixture"), + thinMach(0xfeedfacf, 0x0100000c), + ); + await writeFile( + join(contents, "Info.plist"), + "CFBundleExecutableFixture", + ); + await writeFile( + join(resources, "Main.nib"), + await readFile( + join(process.cwd(), "tests/fixtures/interface-builder/UIKitOutlet.xml"), + ), + ); + + const output = await cli.run({ + arguments: ["decode-interface-builder", app, "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(output.exitCode, output.stderr).toBe(0); + const result = interfaceBuilderAnalysisSchema.parse( + parseEvidence(output.json).normalized_result, + ); + const owner = result.graph.nodes.find( + ({ attributes }) => attributes.interface_builder_object_id === "1", + ); + const destination = result.graph.nodes.find( + ({ attributes }) => attributes.interface_builder_object_id === "2", + ); + const outlet = result.graph.nodes.find(({ kind }) => kind === "outlet"); + expect(result.documents).toEqual([ + expect.objectContaining({ connection_count: 1 }), + ]); + expect(outlet).toMatchObject({ + id: 'ib:"Contents/Resources/Main.nib":outlet:["archive:3"]', + name: "view", + attributes: { + UISource: { archiveUID: 1 }, + UIDestination: { archiveUID: 2 }, + UILabel: "view", + }, + }); + expect(owner).toBeDefined(); + expect(destination).toBeDefined(); + expect( + result.graph.edges.some( + ({ from, to, relation }) => + from === owner?.id && to === outlet?.id && relation === "contains", + ), + ).toBe(true); + expect( + result.graph.edges.some( + ({ from, to, relation }) => + from === outlet?.id && + to === destination?.id && + relation === "outlet_to", + ), + ).toBe(true); + expect(result.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "connections:Contents/Resources/Main.nib", + status: "complete", + examined: 1, + omitted: 0, + }), + ); + }, +); diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 788127dc3f..c309087943 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -992,8 +992,8 @@ describe("bounded Interface Builder archive decoding", () => { ); }); -describe("compiled UIKit NIB connections", () => { - it("projects UIKit runtime outlet and event connections", async () => { +describe("NIBArchive UIKit event connections", () => { + it("projects a runtime event connection and its event mask", async () => { const root = await createTestTempDirectory("rea-ib-test-"); const bundle = join(root, "Example.app"); await mkdir(bundle, { recursive: true }); @@ -1005,7 +1005,6 @@ describe("compiled UIKit NIB connections", () => { "UIClassSwapper", "UIButton", "UIRuntimeEventConnection", - "UIRuntimeOutletConnection", "NSString", ], objects: [ @@ -1021,18 +1020,10 @@ describe("compiled UIKit NIB connections", () => { UIEventMask: 64, }, }, - { - classIndex: 4, - values: { - UISource: { ref: 1 }, - UIDestination: { ref: 2 }, - UILabel: { ref: 8 }, - }, - }, - { classIndex: 5, values: { "NS.bytes": "IBFilesOwner" } }, - { classIndex: 5, values: { "NS.bytes": "BuildCell" } }, - { classIndex: 5, values: { "NS.bytes": "buildTapped:" } }, - { classIndex: 5, values: { "NS.bytes": "buildButton" } }, + { classIndex: 2, values: {} }, + { classIndex: 4, values: { "NS.bytes": "IBFilesOwner" } }, + { classIndex: 4, values: { "NS.bytes": "BuildCell" } }, + { classIndex: 4, values: { "NS.bytes": "buildTapped:" } }, ], }), ); @@ -1062,16 +1053,6 @@ describe("compiled UIKit NIB connections", () => { .filter(({ from }) => from === action?.id) .map(({ to }) => named(to)), ).toEqual(expect.arrayContaining(["BuildCell"])); - const outlet = analysis.graph.nodes.find(({ kind }) => kind === "outlet"); - expect(outlet?.name).toBe("buildButton"); - expect( - analysis.graph.edges - .filter( - ({ relation, from }) => - relation === "outlet_to" && from === outlet?.id, - ) - .map(({ to }) => named(to)), - ).toEqual(["UIButton"]); expect( analysis.graph.nodes.find(({ name }) => name === "IBFilesOwner")?.kind, ).toBe("placeholder"); diff --git a/tests/fixtures/interface-builder/UIKitOutlet.xml b/tests/fixtures/interface-builder/UIKitOutlet.xml new file mode 100644 index 0000000000..aa7f033fa8 --- /dev/null +++ b/tests/fixtures/interface-builder/UIKitOutlet.xml @@ -0,0 +1,85 @@ + + + + + + $archiver + NSKeyedArchiver + $version + 100000 + $objects + + $null + + $class + CF$UID4 + + + $class + CF$UID5 + + + $class + CF$UID6 + UISource + CF$UID1 + UIDestination + CF$UID2 + UILabel + CF$UID7 + + + $classnameUIViewController + $classes + UIViewControllerUIResponderNSObject + + + $classnameUIView + $classes + UIViewUIResponderNSObject + + + $classnameUIRuntimeOutletConnection + $classes + UIRuntimeOutletConnectionUIRuntimeConnectionNSObject + + view + + $class + CF$UID11 + NS.objects + + CF$UID1 + CF$UID2 + CF$UID3 + + + + $class + CF$UID11 + NS.objects + CF$UID3 + + + $class + CF$UID11 + NS.objects + + CF$UID1 + CF$UID2 + + + + $classnameNSArray + $classesNSArrayNSObject + + + $top + + rootCF$UID8 + UINibConnectionsKeyCF$UID9 + UINibObjectsKeyCF$UID10 + UINibTopLevelObjectsKeyCF$UID10 + + +