fix(artifacts): project compiled AppKit actions from the control to its target (#905)

* fix(artifacts): project compiled AppKit actions from the control to its target

NSNibControlConnector archives the sending control as NSSource and the
action target as NSDestination; a nil target is the first responder. The
NIBArchive projection swapped them for control connectors, so every
compiled AppKit action ran from its target to the control. The action
trace, which follows control -> action, could not find them, and the
selector node was keyed by the control instead of the target class. When
the target was nil, the swapped source was null and the action was
dropped: Chess lost 29 menu actions, KeepingYouAwake 7.

Connectors now keep NSSource -> NSDestination. The NIBArchive test
encoder moves to a fixture module so a bundle test can build compiled
NIBs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(artifacts): report observed action routes when a compiled route is missing

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(artifacts): report compiled connector records when a storyboard route is missing

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(artifacts): write the storyboard fixture's action in Cocoa syntax

MacFixture.storyboard declared its button action with the iOS attribute,
<action selector="buildTapped:" destination="vc1">. Cocoa documents
name an action's receiver with target=. ibtool compiled the iOS spelling
into an NSNibControlConnector whose NSSource is the view controller
placeholder and whose NSDestination is the button. That is the reverse
of what Xcode-authored AppKit NIBs contain (Music, Chess,
KeepingYouAwake, and Transmission all archive NSSource as the control),
and it is what the old inversion had matched. With target= the compiled
fixture archives the button as NSSource. The temporary connector dump is
removed; the observed routes remain in the assertion message.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Kaoru0822
2026-10-07 17:40:18 +08:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 62b37111e7
commit 900d7c8606
5 changed files with 193 additions and 104 deletions
+6 -8
View File
@@ -277,16 +277,14 @@ const projectNibArchive = (archive: NibArchiveDocument) => {
: className.includes("Control")
? "action"
: className;
const controlAction = className.includes("Control");
const connectionSource = controlAction ? destination : source;
const connectionDestination = controlAction ? source : destination;
if (connectionSource === null) continue;
(connections[String(connectionSource)] ??= []).push({
// NSSource is the outlet owner or the sending control; NSDestination is
// the outlet value or the action target, and a nil target is the first
// responder.
(connections[String(source)] ??= []).push({
type,
"destination-id":
connectionDestination === null ? null : String(connectionDestination),
"destination-id": destination === null ? null : String(destination),
label,
source_id: String(connectionSource),
source_id: String(source),
archive_object_id: String(object.id),
});
}
+94
View File
@@ -0,0 +1,94 @@
/** Encode a minimal NIBArchive object table for decoder and projection tests. */
export const encodeNibArchiveFixture = (input: {
readonly classes: readonly string[];
readonly objects: readonly {
readonly classIndex: number;
readonly values: Readonly<
Record<string, number | string | boolean | null | { ref: number }>
>;
}[];
}): Buffer => {
const keys = [
...new Set(input.objects.flatMap(({ values }) => Object.keys(values))),
];
const keyIndex = new Map(keys.map((key, index) => [key, index]));
const valueRecords = input.objects.flatMap(({ values }) =>
Object.entries(values).map(([key, value]) =>
Buffer.concat([varint(keyIndex.get(key) ?? 0), encodeValue(value)]),
),
);
let valueStart = 0;
const records = input.objects.map(({ classIndex, values }) => {
const count = Object.keys(values).length;
const result = Buffer.concat([
varint(classIndex),
varint(valueStart),
varint(count),
]);
valueStart += count;
return result;
});
const objects = Buffer.concat(records);
const encodedKeys = Buffer.concat(
keys.map((key) =>
Buffer.concat([varint(Buffer.byteLength(key)), Buffer.from(key)]),
),
);
const values = Buffer.concat(valueRecords);
const classes = Buffer.concat(
input.classes.map((name) => {
const bytes = Buffer.from(name);
return Buffer.concat([varint(bytes.length), varint(0), bytes]);
}),
);
const objectsOffset = 50;
const keysOffset = objectsOffset + objects.length;
const valuesOffset = keysOffset + encodedKeys.length;
const classesOffset = valuesOffset + values.length;
const header = Buffer.alloc(50);
header.write("NIBArchive", 0, "ascii");
header.writeUInt32LE(1, 10);
header.writeUInt32LE(10, 14);
header.writeUInt32LE(input.objects.length, 18);
header.writeUInt32LE(objectsOffset, 22);
header.writeUInt32LE(keys.length, 26);
header.writeUInt32LE(keysOffset, 30);
header.writeUInt32LE(valueStart, 34);
header.writeUInt32LE(valuesOffset, 38);
header.writeUInt32LE(input.classes.length, 42);
header.writeUInt32LE(classesOffset, 46);
return Buffer.concat([header, objects, encodedKeys, values, classes]);
};
const varint = (value: number): Buffer => {
const bytes: number[] = [];
let remaining = value;
do {
let byte = remaining & 0x7f;
remaining >>>= 7;
if (remaining === 0) byte |= 0x80;
bytes.push(byte);
} while (remaining > 0);
return Buffer.from(bytes);
};
const encodeValue = (
value: number | string | boolean | null | { ref: number },
): Buffer => {
if (typeof value === "boolean") return Buffer.from([value ? 4 : 5]);
if (value === null) return Buffer.from([9]);
if (typeof value === "number") {
const bytes = Buffer.alloc(5);
bytes[0] = 2;
bytes.writeInt32LE(value, 1);
return bytes;
}
if (typeof value === "object") {
const bytes = Buffer.alloc(5);
bytes[0] = 10;
bytes.writeUInt32LE(value.ref, 1);
return bytes;
}
const data = Buffer.from(value);
return Buffer.concat([Buffer.from([8]), varint(data.length), data]);
};
+1 -94
View File
@@ -1,100 +1,7 @@
import { describe, expect, it } from "vitest";
import { decodeNibArchive } from "./NibArchive.js";
const encodeArchive = (input: {
readonly classes: readonly string[];
readonly objects: readonly {
readonly classIndex: number;
readonly values: Readonly<
Record<string, number | string | boolean | null | { ref: number }>
>;
}[];
}): Buffer => {
const keys = [
...new Set(input.objects.flatMap(({ values }) => Object.keys(values))),
];
const keyIndex = new Map(keys.map((key, index) => [key, index]));
const valueRecords = input.objects.flatMap(({ values }) =>
Object.entries(values).map(([key, value]) =>
Buffer.concat([varint(keyIndex.get(key) ?? 0), encodeValue(value)]),
),
);
let valueStart = 0;
const records = input.objects.map(({ classIndex, values }) => {
const count = Object.keys(values).length;
const result = Buffer.concat([
varint(classIndex),
varint(valueStart),
varint(count),
]);
valueStart += count;
return result;
});
const objects = Buffer.concat(records);
const encodedKeys = Buffer.concat(
keys.map((key) =>
Buffer.concat([varint(Buffer.byteLength(key)), Buffer.from(key)]),
),
);
const values = Buffer.concat(valueRecords);
const classes = Buffer.concat(
input.classes.map((name) => {
const bytes = Buffer.from(name);
return Buffer.concat([varint(bytes.length), varint(0), bytes]);
}),
);
const objectsOffset = 50;
const keysOffset = objectsOffset + objects.length;
const valuesOffset = keysOffset + encodedKeys.length;
const classesOffset = valuesOffset + values.length;
const header = Buffer.alloc(50);
header.write("NIBArchive", 0, "ascii");
header.writeUInt32LE(1, 10);
header.writeUInt32LE(10, 14);
header.writeUInt32LE(input.objects.length, 18);
header.writeUInt32LE(objectsOffset, 22);
header.writeUInt32LE(keys.length, 26);
header.writeUInt32LE(keysOffset, 30);
header.writeUInt32LE(valueStart, 34);
header.writeUInt32LE(valuesOffset, 38);
header.writeUInt32LE(input.classes.length, 42);
header.writeUInt32LE(classesOffset, 46);
return Buffer.concat([header, objects, encodedKeys, values, classes]);
};
const varint = (value: number): Buffer => {
const bytes: number[] = [];
let remaining = value;
do {
let byte = remaining & 0x7f;
remaining >>>= 7;
if (remaining === 0) byte |= 0x80;
bytes.push(byte);
} while (remaining > 0);
return Buffer.from(bytes);
};
const encodeValue = (
value: number | string | boolean | null | { ref: number },
): Buffer => {
if (typeof value === "boolean") return Buffer.from([value ? 4 : 5]);
if (value === null) return Buffer.from([9]);
if (typeof value === "number") {
const bytes = Buffer.alloc(5);
bytes[0] = 2;
bytes.writeInt32LE(value, 1);
return bytes;
}
if (typeof value === "object") {
const bytes = Buffer.alloc(5);
bytes[0] = 10;
bytes.writeUInt32LE(value.ref, 1);
return bytes;
}
const data = Buffer.from(value);
return Buffer.concat([Buffer.from([8]), varint(data.length), data]);
};
import { encodeNibArchiveFixture as encodeArchive } from "./NibArchive.fixture.js";
describe("NIBArchive decoder", () => {
it("decodes bounded object, key, class, and reference tables", () => {
@@ -8,6 +8,7 @@ import { buildBinary } from "plist";
import { describe, expect, it } from "vitest";
import { analyzeInterfaceBuilderBundle } from "../../../src/application/InterfaceBuilderAnalysis.js";
import { encodeNibArchiveFixture } from "../../../src/artifacts/NibArchive.fixture.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const compile = promisify(execFile);
@@ -71,6 +72,84 @@ describe("compiled Interface Builder bundle reader", () => {
);
});
it("projects compiled AppKit actions from the control to their target", async () => {
const root = await createTestTempDirectory("rea-ib-test-");
const bundle = join(root, "Example.app");
const resources = join(bundle, "Contents", "Resources");
await mkdir(resources, { recursive: true });
// NSNibControlConnector archives the sending control as NSSource and its
// target as NSDestination; a nil target is the first responder.
await writeFile(
join(resources, "Panel.nib"),
encodeNibArchiveFixture({
classes: [
"NSNibExternalObjectPlaceholder",
"NSButton",
"NSNibControlConnector",
"NSString",
"NSMenuItem",
],
objects: [
{ classIndex: 0, values: {} },
{ classIndex: 1, values: {} },
{
classIndex: 2,
values: {
NSSource: { ref: 1 },
NSDestination: { ref: 0 },
NSLabel: { ref: 3 },
},
},
{ classIndex: 3, values: { "NS.bytes": "doOK:" } },
{ classIndex: 4, values: {} },
{
classIndex: 2,
values: {
NSSource: { ref: 4 },
NSDestination: null,
NSLabel: { ref: 6 },
},
},
{ classIndex: 3, values: { "NS.bytes": "arrangeInFront:" } },
],
}),
);
const analysis = await analyzeInterfaceBuilderBundle({
bundlePath: bundle,
targetSha256: "e".repeat(64),
});
const byId = new Map(analysis.graph.nodes.map((node) => [node.id, node]));
const routes = analysis.graph.nodes
.filter(({ kind }) => kind === "action")
.map((action) => ({
action: action.name,
from: analysis.graph.edges
.filter(
({ relation, to }) =>
relation === "target_action" && to === action.id,
)
.map(({ from }) => byId.get(from)?.name),
to: analysis.graph.edges
.filter(
({ relation, from, to }) =>
relation === "target_action" &&
from === action.id &&
(to === null || byId.get(to)?.kind !== "objc_selector"),
)
.map(({ to }) => (to === null ? null : byId.get(to)?.name)),
}))
.sort((left, right) => left.action.localeCompare(right.action));
expect(routes).toEqual([
{ action: "arrangeInFront:", from: ["NSMenuItem"], to: [null] },
{
action: "doOK:",
from: ["NSButton"],
to: ["NSNibExternalObjectPlaceholder"],
},
]);
});
it("honors cancellation during directory traversal", async () => {
const root = await createTestTempDirectory("rea-ib-test-");
const controller = new AbortController();
@@ -183,7 +262,18 @@ describe("bounded Interface Builder archive decoding", () => {
analysis.graph.nodes.find(({ id }) => id === from)?.kind ===
"control",
);
expect(actionSource).toBeDefined();
const describeNode = (id: string | null) => {
const node = analysis.graph.nodes.find((item) => item.id === id);
return node === undefined ? String(id) : `${node.kind}:${node.name}`;
};
const actionRoutes = analysis.graph.edges
.filter(
({ relation, from, to }) =>
relation === "target_action" &&
(from === action?.id || to === action?.id),
)
.map(({ from, to }) => `${describeNode(from)} -> ${describeNode(to)}`);
expect(actionSource, actionRoutes.join("; ")).toBeDefined();
expect(
analysis.graph.edges.some(
({ from, relation, to }) =>
+1 -1
View File
@@ -11,7 +11,7 @@
<rect key="frame" x="120" y="400" width="150" height="44"/>
<title key="title" string="Build"/>
<connections>
<action selector="buildTapped:" destination="vc1" id="action1"/>
<action selector="buildTapped:" target="vc1" id="action1"/>
</connections>
</button>
</subviews>