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.
This commit is contained in:
morluto committed 2026-10-13 03:47:45 +08:00
1 parent 7fb097d72c
commit 8d4d631606
5 files changed
+231 -56

No files matched your search

@@ -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")
@@ -34,6 +34,16 @@ const connection = z.strictObject({
type InterfaceBuilderObject = z.infer<typeof objectNode>;
type InterfaceBuilderConnection = z.infer<typeof connection>;
/** 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);
@@ -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"),
"<plist><dict><key>CFBundleExecutable</key><string>Fixture</string></dict></plist>",
);
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,
}),
);
},
);
@@ -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");
+85
View File
@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Rooted portable archive from #2177; that issue reports Foundation and UINib validation. -->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>$archiver</key>
<string>NSKeyedArchiver</string>
<key>$version</key>
<integer>100000</integer>
<key>$objects</key>
<array>
<string>$null</string>
<dict>
<key>$class</key>
<dict><key>CF$UID</key><integer>4</integer></dict>
</dict>
<dict>
<key>$class</key>
<dict><key>CF$UID</key><integer>5</integer></dict>
</dict>
<dict>
<key>$class</key>
<dict><key>CF$UID</key><integer>6</integer></dict>
<key>UISource</key>
<dict><key>CF$UID</key><integer>1</integer></dict>
<key>UIDestination</key>
<dict><key>CF$UID</key><integer>2</integer></dict>
<key>UILabel</key>
<dict><key>CF$UID</key><integer>7</integer></dict>
</dict>
<dict>
<key>$classname</key><string>UIViewController</string>
<key>$classes</key>
<array><string>UIViewController</string><string>UIResponder</string><string>NSObject</string></array>
</dict>
<dict>
<key>$classname</key><string>UIView</string>
<key>$classes</key>
<array><string>UIView</string><string>UIResponder</string><string>NSObject</string></array>
</dict>
<dict>
<key>$classname</key><string>UIRuntimeOutletConnection</string>
<key>$classes</key>
<array><string>UIRuntimeOutletConnection</string><string>UIRuntimeConnection</string><string>NSObject</string></array>
</dict>
<string>view</string>
<dict>
<key>$class</key>
<dict><key>CF$UID</key><integer>11</integer></dict>
<key>NS.objects</key>
<array>
<dict><key>CF$UID</key><integer>1</integer></dict>
<dict><key>CF$UID</key><integer>2</integer></dict>
<dict><key>CF$UID</key><integer>3</integer></dict>
</array>
</dict>
<dict>
<key>$class</key>
<dict><key>CF$UID</key><integer>11</integer></dict>
<key>NS.objects</key>
<array><dict><key>CF$UID</key><integer>3</integer></dict></array>
</dict>
<dict>
<key>$class</key>
<dict><key>CF$UID</key><integer>11</integer></dict>
<key>NS.objects</key>
<array>
<dict><key>CF$UID</key><integer>1</integer></dict>
<dict><key>CF$UID</key><integer>2</integer></dict>
</array>
</dict>
<dict>
<key>$classname</key><string>NSArray</string>
<key>$classes</key><array><string>NSArray</string><string>NSObject</string></array>
</dict>
</array>
<key>$top</key>
<dict>
<key>root</key><dict><key>CF$UID</key><integer>8</integer></dict>
<key>UINibConnectionsKey</key><dict><key>CF$UID</key><integer>9</integer></dict>
<key>UINibObjectsKey</key><dict><key>CF$UID</key><integer>10</integer></dict>
<key>UINibTopLevelObjectsKey</key><dict><key>CF$UID</key><integer>10</integer></dict>
</dict>
</dict>
</plist>