diff --git a/src/domain/apple/interfaceBuilderKeyedArchive.ts b/src/domain/apple/interfaceBuilderKeyedArchive.ts
index 56ac6d1990..8b0cb5a812 100644
--- a/src/domain/apple/interfaceBuilderKeyedArchive.ts
+++ b/src/domain/apple/interfaceBuilderKeyedArchive.ts
@@ -233,6 +233,9 @@ const keyedArchiveTable = (objectTable: readonly unknown[]) => {
classFactsOfObject(value)?.name ?? null;
const hasClassSuperclass = (value: unknown, superclass: string): boolean =>
classFactsOfObject(value)?.superclasses.includes(superclass) ?? false;
+ /** The class and superclass names an object's descriptor declares in `$classes`. */
+ const classChainOf = (value: unknown): readonly string[] =>
+ classFactsOfObject(value)?.superclasses ?? [];
/** The string an archived string object holds, if it is one. */
const archivedString = (target: unknown): string | undefined => {
if (typeof target === "string") return target;
@@ -369,7 +372,7 @@ const keyedArchiveTable = (objectTable: readonly unknown[]) => {
: [[facts.name, { superclasses: facts.rawSuperclasses }]];
}),
);
- return { resolve, hierarchy, hasClassSuperclass, classTable };
+ return { resolve, hierarchy, hasClassSuperclass, classChainOf, classTable };
};
/** Project class and connection objects from an NSKeyedArchiver object table. */
@@ -383,6 +386,7 @@ const parseKeyedArchive = (
resolve,
hierarchy: toHierarchy,
hasClassSuperclass,
+ classChainOf,
classTable,
} = keyedArchiveTable(objectTable);
const objects: InterfaceBuilderObject[] = [];
@@ -410,7 +414,7 @@ const parseKeyedArchive = (
(typeof fields.archiveUID === "number"
? String(fields.archiveUID)
: String(index));
- const kind = classifyObject(className);
+ const kind = classifyObject(className, classChainOf(raw));
if (objects.length < 20_000)
objects.push(
objectNode.parse({
@@ -538,9 +542,33 @@ const firstObjectReference = (...values: unknown[]): string | null => {
return null;
};
+/**
+ * Framework base classes whose presence in an archived `$classes` chain settles
+ * an object's kind, most specific first: UIControl and NSControl are views.
+ */
+const archivedKindBases: readonly (readonly [
+ InterfaceBuilderObject["kind"],
+ readonly string[],
+])[] = [
+ [
+ "view_controller",
+ ["UIViewController", "NSViewController", "NSWindowController"],
+ ],
+ ["control", ["UIControl", "NSControl"]],
+ ["view", ["UIView", "NSView", "UIWindow", "NSWindow"]],
+];
+
+/**
+ * Classify an object by its declared superclass chain when the archive records
+ * one, otherwise by its class name alone. Names are only a heuristic:
+ * `UINavigationController` names no view controller and contains "control".
+ */
const classifyObject = (
className: string | null,
+ classChain: readonly string[] = [],
): InterfaceBuilderObject["kind"] => {
+ for (const [kind, bases] of archivedKindBases)
+ if (bases.some((base) => classChain.includes(base))) return kind;
const value = className?.toLowerCase() ?? "";
if (value.includes("constraint")) return "constraint";
if (value.includes("layoutguide")) return "layout_guide";
diff --git a/tests/acceptance/applications/interfaceBuilderUIKitKeyedArchive.test.ts b/tests/acceptance/applications/interfaceBuilderUIKitKeyedArchive.test.ts
index 02ec7c607b..0665922320 100644
--- a/tests/acceptance/applications/interfaceBuilderUIKitKeyedArchive.test.ts
+++ b/tests/acceptance/applications/interfaceBuilderUIKitKeyedArchive.test.ts
@@ -1,6 +1,7 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
+import { build } from "plist";
import { expect } from "vitest";
import { interfaceBuilderAnalysisSchema } from "../../../src/domain/apple/interfaceBuilderGraph.js";
@@ -9,25 +10,31 @@ import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
+/** Write an app bundle whose only Interface Builder document is `Main.nib`. */
+const writeUIKitApp = async (prefix: string, nib: string | Buffer) => {
+ const root = await createTestTempDirectory(prefix);
+ 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"), nib);
+ return app;
+};
+
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"),
+ const app = await writeUIKitApp(
+ "rea-cli-uikit-outlet-",
await readFile(
join(process.cwd(), "tests/fixtures/interface-builder/UIKitOutlet.xml"),
),
@@ -86,3 +93,77 @@ cliTest(
);
},
);
+
+cliTest(
+ "classifies UIKit keyed-archive objects by their declared superclasses through CLI",
+ async ({ cli }) => {
+ // Class name and its archived `$classes` chain, with the expected kind.
+ // Neutral names classify only through their declared bases; controls also
+ // declare their view base. The constraint declares no framework base, so
+ // its name still classifies it.
+ const cases = [
+ ["UINavigationController", ["UIViewController"], "view_controller"],
+ ["UITabBarController", ["UIViewController"], "view_controller"],
+ ["UISearchController", ["UIViewController"], "view_controller"],
+ ["UIViewController", [], "view_controller"],
+ ["SettingsScreen", ["UIViewController"], "view_controller"],
+ ["InspectorPane", ["NSViewController"], "view_controller"],
+ ["DocumentShell", ["NSWindowController"], "view_controller"],
+ ["UIButton", ["UIControl", "UIView"], "control"],
+ ["RatingPicker", ["UIControl", "UIView"], "control"],
+ ["LevelGauge", ["NSControl", "NSView"], "control"],
+ ["UIImageView", ["UIView"], "view"],
+ ["CanvasSurface", ["NSView"], "view"],
+ ["AppShell", ["UIWindow", "UIView"], "view"],
+ ["Palette", ["NSWindow"], "view"],
+ ["NSLayoutConstraint", [], "constraint"],
+ ] as const;
+ const uid = (value: number) => ({ CF$UID: value });
+ const descriptorUid = (index: number) => cases.length + 1 + index;
+ const rootUid = cases.length * 2 + 1;
+ const nib = build({
+ $archiver: "NSKeyedArchiver",
+ $version: 100000,
+ $objects: [
+ "$null",
+ ...cases.map((_, index) => ({ $class: uid(descriptorUid(index)) })),
+ ...cases.map(([name, bases]) => ({
+ $classname: name,
+ $classes: [name, ...bases, "NSObject"],
+ })),
+ {
+ $class: uid(rootUid + 1),
+ "NS.objects": cases.map((_, index) => uid(index + 1)),
+ },
+ { $classname: "NSArray", $classes: ["NSArray", "NSObject"] },
+ ],
+ $top: { root: uid(rootUid) },
+ });
+ const app = await writeUIKitApp("rea-cli-uikit-kinds-", nib);
+
+ 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 names = new Set(cases.map(([name]) => name));
+ expect(
+ result.graph.nodes
+ .filter(({ attributes }) =>
+ names.has(String(attributes.class_name ?? "")),
+ )
+ .map(({ kind, attributes }) => [attributes.class_name, kind]),
+ ).toEqual(cases.map(([name, , kind]) => [name, kind]));
+ expect(result.graph.coverage).toContainEqual(
+ expect.objectContaining({
+ facet: "objects:Contents/Resources/Main.nib",
+ status: "complete",
+ examined: cases.length + 1,
+ omitted: 0,
+ }),
+ );
+ },
+);