mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
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");
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user