mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(apple): preserve asset failures and authored resource coverage
This commit is contained in:
1 parent
b488a10d96
commit
dbbaf2abb0
7 files changed
+331
-51
No files matched your search
@@ -460,7 +460,13 @@ complete observation.
|
||||
`inventory_coverage` describes traversal of source entries and remains
|
||||
`unknown` when the reader cannot represent a filename. Represented symlinks
|
||||
are inventoried but their targets remain uninspected, with a limitation.
|
||||
Image extraction and undocumented rendition interpretation are unsupported.
|
||||
`catalog_coverage` and `resource_key_coverage` separately describe examined
|
||||
catalog metadata and the UI join; an unmatched key cannot establish absence
|
||||
when either source is incomplete. Resource wrappers retain their serialized
|
||||
class and authored runtime class, including AppKit `NSCustomResource` names.
|
||||
Invalid producer JSON and subprocess failures retain captured output with
|
||||
their typed failure reason. Image extraction and undocumented rendition
|
||||
interpretation are unsupported.
|
||||
- `inspect_keyed_archive` / `rea inspect-keyed-archive <plist>` reads XML or
|
||||
binary Foundation keyed archives without instantiating classes. For an active
|
||||
app, provide its relative archive path as the second CLI argument or MCP
|
||||
|
||||
@@ -54,6 +54,8 @@ describe("Apple asset catalog traversal coverage", () => {
|
||||
runAssetUtil: async () => "[]",
|
||||
});
|
||||
expect(result.inventory_coverage.status).toBe("complete");
|
||||
expect(result.catalog_coverage).toMatchObject({ status: "unknown" });
|
||||
expect(result.resource_key_coverage).toMatchObject({ status: "unknown" });
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringContaining("catalogs beneath them may remain uninspected"),
|
||||
);
|
||||
@@ -184,6 +186,11 @@ describe("Apple asset catalog application workflow", () => {
|
||||
expect(result.catalogs.map(({ path }) => path)).toEqual([
|
||||
"Contents/Resources/Assets.car",
|
||||
]);
|
||||
expect(result.catalog_coverage).toEqual({
|
||||
status: "partial",
|
||||
reason: "catalog_entries_uninspected",
|
||||
});
|
||||
expect(result.resource_key_coverage.status).toBe("partial");
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringContaining("Contents/Resources/Nested/Assets.car (symlink)"),
|
||||
);
|
||||
@@ -452,3 +459,88 @@ describe("Apple asset catalog and Interface Builder concurrency", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["not json", "{}", "[null]"])(
|
||||
"preserves successful assetutil output failures for %s",
|
||||
async (output) => {
|
||||
const bundle = await createTestTempDirectory("rea-asset-output-");
|
||||
await writeFile(join(bundle, "Assets.car"), "inert catalog");
|
||||
await expect(
|
||||
analyzeAppleAssetCatalogs({
|
||||
bundlePath: bundle,
|
||||
targetSha256: "a".repeat(64),
|
||||
environment: {},
|
||||
runAssetUtil: async () => output,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
_tag: "AnalysisOutputError",
|
||||
capturedOutput: { stdout: output, exit_code: 0 },
|
||||
});
|
||||
},
|
||||
);
|
||||
it.each(["ETIMEDOUT", 1] as const)(
|
||||
"preserves assetutil command failure %s and diagnostics",
|
||||
async (code) => {
|
||||
const bundle = await createTestTempDirectory("rea-asset-command-");
|
||||
await writeFile(join(bundle, "Assets.car"), "inert catalog");
|
||||
const cause = Object.assign(new Error("native refusal"), {
|
||||
code,
|
||||
stdout: "",
|
||||
stderr: "specific catalog diagnostic",
|
||||
signal: code === "ETIMEDOUT" ? "SIGTERM" : null,
|
||||
});
|
||||
await expect(
|
||||
analyzeAppleAssetCatalogs({
|
||||
bundlePath: bundle,
|
||||
targetSha256: "a".repeat(64),
|
||||
environment: {},
|
||||
runAssetUtil: async () => {
|
||||
throw cause;
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
_tag:
|
||||
code === "ETIMEDOUT" ? "AnalysisTimeoutError" : "AnalysisOutputError",
|
||||
capturedOutput: { stderr: "specific catalog diagnostic" },
|
||||
...(code === "ETIMEDOUT" ? { timeoutMs: 60000 } : {}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["Toolbar", "MissingToolbar"])(
|
||||
"retains AppKit NSCustomResource image key %s with its archive identity",
|
||||
async (name) => {
|
||||
const bundle = await createTestTempDirectory("rea-asset-custom-resource-");
|
||||
await writeFile(
|
||||
join(bundle, "Panel.nib"),
|
||||
encodeNibArchiveFixture({
|
||||
classes: ["NSIBObjectData", "NSCustomResource", "NSString"],
|
||||
objects: [
|
||||
{ classIndex: 0, values: { NSRoot: { ref: 1 } } },
|
||||
{
|
||||
classIndex: 1,
|
||||
values: { NSClassName: { ref: 2 }, NSResourceName: { ref: 3 } },
|
||||
},
|
||||
{ classIndex: 2, values: { "NS.bytes": "NSImage" } },
|
||||
{ classIndex: 2, values: { "NS.bytes": name } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await writeFile(join(bundle, "Assets.car"), "inert catalog");
|
||||
const result = await analyzeAppleAssetCatalogs({
|
||||
bundlePath: bundle,
|
||||
targetSha256: "a".repeat(64),
|
||||
environment: {},
|
||||
runAssetUtil: async () => JSON.stringify([{ Name: "Toolbar" }]),
|
||||
});
|
||||
expect(result.resource_key_matches).toMatchObject([
|
||||
{
|
||||
field: "NSResourceName",
|
||||
key: name,
|
||||
source_path: "Panel.nib",
|
||||
status: name === "Toolbar" ? "matched" : "unmatched",
|
||||
},
|
||||
]);
|
||||
expect(result.resource_key_coverage.status).toBe("complete");
|
||||
},
|
||||
);
|
||||
@@ -1,3 +1,14 @@
|
||||
import {
|
||||
AnalysisError,
|
||||
type AnalysisCapturedOutput,
|
||||
} from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisOutputError,
|
||||
AnalysisTimeoutError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { fixedAllowanceOptions } from "../../domain/fixedAllowanceRemediation.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { lstat } from "node:fs/promises";
|
||||
import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
|
||||
@@ -13,10 +24,12 @@ import {
|
||||
appleResourceKeyFields,
|
||||
parseAppleAssetCatalogRecords,
|
||||
projectAppleAssetCatalogPage,
|
||||
type AppleAssetAnalysisCoverage,
|
||||
} from "../../domain/apple/appleAssetCatalog.js";
|
||||
import {
|
||||
ExecFileOutputCancelledError,
|
||||
execFileOutput,
|
||||
execFileOutputFailure,
|
||||
} from "../../process/ExecFileOutput.js";
|
||||
|
||||
const ASSETUTIL = "/usr/bin/assetutil";
|
||||
@@ -70,8 +83,14 @@ export const analyzeAppleAssetCatalogs = async (input: {
|
||||
let catalogs: Awaited<ReturnType<typeof collectAssetCatalogs>>["catalogs"];
|
||||
let uninspected: readonly string[];
|
||||
let traversal: ArtifactReaderTraversal;
|
||||
let catalogCoverage: AppleAssetAnalysisCoverage;
|
||||
try {
|
||||
({ catalogs, uninspected, traversal } = await collectAssetCatalogs(input));
|
||||
({
|
||||
catalogs,
|
||||
uninspected,
|
||||
traversal,
|
||||
coverage: catalogCoverage,
|
||||
} = await collectAssetCatalogs(input));
|
||||
} catch (cause: unknown) {
|
||||
interfaceBuilderCancel.abort();
|
||||
await interfaceBuilderAnalysis;
|
||||
@@ -90,6 +109,13 @@ export const analyzeAppleAssetCatalogs = async (input: {
|
||||
: { resourceRoot: input.resourceRoot }),
|
||||
catalogs,
|
||||
inventoryCoverage: { scope: "container-entries", status: traversal.status },
|
||||
catalogCoverage,
|
||||
resourceKeyCoverage:
|
||||
catalogCoverage.status !== "complete"
|
||||
? catalogCoverage
|
||||
: interfaceBuilder.graph.truncated
|
||||
? { status: "partial", reason: "interface_builder_graph_incomplete" }
|
||||
: { status: "complete", reason: null },
|
||||
offset: page.offset,
|
||||
limit: page.limit,
|
||||
resourceReferences,
|
||||
@@ -201,7 +227,16 @@ const collectAssetCatalogs = async (input: {
|
||||
"path",
|
||||
`Asset catalog entries are not regular files and were not inspected: ${uninspected.join(", ")}`,
|
||||
);
|
||||
return { catalogs, uninspected, traversal: traversal.snapshot() };
|
||||
const coverage: AppleAssetAnalysisCoverage =
|
||||
traversal.snapshot().status === "unknown" || symlinks > 0
|
||||
? {
|
||||
status: "unknown",
|
||||
reason: "source_names_or_symlink_targets_unexamined",
|
||||
}
|
||||
: uninspected.length > 0
|
||||
? { status: "partial", reason: "catalog_entries_uninspected" }
|
||||
: { status: "complete", reason: null };
|
||||
return { catalogs, uninspected, traversal: traversal.snapshot(), coverage };
|
||||
};
|
||||
|
||||
const inspectAssetCatalogEntry = async (input: {
|
||||
@@ -224,7 +259,12 @@ const inspectAssetCatalogEntry = async (input: {
|
||||
"integrity",
|
||||
`Asset catalog changed before inspection: ${entry.path}`,
|
||||
);
|
||||
const output = await input.runAssetUtil(entry.adapterKey, signal);
|
||||
let output: string;
|
||||
try {
|
||||
output = await input.runAssetUtil(entry.adapterKey, signal);
|
||||
} catch (cause) {
|
||||
throw assetUtilFailure(cause, signal);
|
||||
}
|
||||
const after = await lstat(entry.adapterKey, { bigint: true });
|
||||
if (
|
||||
!after.isFile() ||
|
||||
@@ -235,14 +275,22 @@ const inspectAssetCatalogEntry = async (input: {
|
||||
"integrity",
|
||||
`Asset catalog changed during inspection: ${entry.path}`,
|
||||
);
|
||||
const capturedOutput: AnalysisCapturedOutput = {
|
||||
stdout: output,
|
||||
stderr: "",
|
||||
truncated: false,
|
||||
stdout_bytes: Buffer.byteLength(output),
|
||||
exit_code: 0,
|
||||
signal: null,
|
||||
};
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(output);
|
||||
} catch (cause: unknown) {
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
throw new AnalysisOutputError(
|
||||
"inspect_asset_catalog",
|
||||
`assetutil returned invalid JSON for ${entry.path}`,
|
||||
{ cause },
|
||||
{ cause, capturedOutput },
|
||||
);
|
||||
}
|
||||
try {
|
||||
@@ -255,11 +303,11 @@ const inspectAssetCatalogEntry = async (input: {
|
||||
metadataBytes: Buffer.byteLength(output, "utf8"),
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`assetutil returned an unsupported record for ${entry.path}`,
|
||||
{ cause },
|
||||
);
|
||||
if (!(cause instanceof AnalysisOutputError)) throw cause;
|
||||
throw new AnalysisOutputError("inspect_asset_catalog", cause.reason, {
|
||||
cause,
|
||||
capturedOutput,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -299,38 +347,76 @@ const runAssetUtil = async (
|
||||
path: string,
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> => {
|
||||
try {
|
||||
return (
|
||||
await execFileOutput(ASSETUTIL, ["--info", path], {
|
||||
env: environment,
|
||||
timeout: 60_000,
|
||||
maxBuffer: MAX_CATALOG_BYTES,
|
||||
stopSignal: "SIGTERM",
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
})
|
||||
).stdout;
|
||||
} catch (cause: unknown) {
|
||||
if (signal?.aborted === true)
|
||||
throw new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
"Asset catalog inspection cancelled",
|
||||
{ cause: cancellationCause(cause, signal) },
|
||||
);
|
||||
const code =
|
||||
typeof cause === "object" && cause !== null && "code" in cause
|
||||
? cause.code
|
||||
: undefined;
|
||||
throw new ArtifactReaderFailure(
|
||||
code === "ENOENT"
|
||||
? "unavailable"
|
||||
: code === "ETIMEDOUT" || code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
|
||||
? "limit"
|
||||
: "io",
|
||||
`assetutil failed${code === undefined ? "" : ` (${String(code)})`}`,
|
||||
{ cause },
|
||||
): Promise<string> =>
|
||||
(
|
||||
await execFileOutput(ASSETUTIL, ["--info", path], {
|
||||
env: environment,
|
||||
timeout: 60_000,
|
||||
maxBuffer: MAX_CATALOG_BYTES,
|
||||
stopSignal: "SIGTERM",
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
})
|
||||
).stdout;
|
||||
|
||||
/** Preserve the command owner's failure and captured output before artifact adaptation. */
|
||||
const assetUtilFailure = (cause: unknown, signal?: AbortSignal): unknown => {
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
if (signal?.aborted || cause instanceof ExecFileOutputCancelledError)
|
||||
return new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
"Asset catalog inspection cancelled",
|
||||
{ cause: cancellationCause(cause, signal) },
|
||||
);
|
||||
}
|
||||
const output = execFileOutputFailure(cause);
|
||||
const code =
|
||||
cause instanceof Error && "code" in cause ? cause.code : undefined;
|
||||
const capturedOutput: AnalysisCapturedOutput | undefined =
|
||||
output === undefined
|
||||
? undefined
|
||||
: {
|
||||
stdout: output.stdout,
|
||||
stderr: output.stderr,
|
||||
truncated: output.outputTruncated,
|
||||
...(output.stdoutBytes === undefined
|
||||
? {}
|
||||
: { stdout_bytes: output.stdoutBytes }),
|
||||
...(output.stderrBytes === undefined
|
||||
? {}
|
||||
: { stderr_bytes: output.stderrBytes }),
|
||||
exit_code: typeof output.code === "number" ? output.code : null,
|
||||
signal: output.signal,
|
||||
};
|
||||
const options = { cause, capturedOutput };
|
||||
if (code === "ETIMEDOUT")
|
||||
return new AnalysisTimeoutError("inspect_asset_catalog", 60_000, options);
|
||||
if (code === "ENOENT")
|
||||
return new AnalysisCapabilityUnavailableError(
|
||||
"artifact",
|
||||
"inspect_asset_catalog",
|
||||
"macOS assetutil is unavailable",
|
||||
options,
|
||||
);
|
||||
if (code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER")
|
||||
return new AnalysisResourceConstraintError(
|
||||
"inspect_asset_catalog",
|
||||
"transport",
|
||||
"assetutil output exceeds the 64 MiB capture allowance",
|
||||
{ maximum_output_bytes: MAX_CATALOG_BYTES },
|
||||
{
|
||||
...options,
|
||||
...fixedAllowanceOptions(
|
||||
"the fixed 64 MiB assetutil capture allowance",
|
||||
"output",
|
||||
),
|
||||
},
|
||||
);
|
||||
if (output !== undefined)
|
||||
return new AnalysisOutputError(
|
||||
"inspect_asset_catalog",
|
||||
"assetutil did not produce usable catalog metadata",
|
||||
options,
|
||||
);
|
||||
return cause;
|
||||
};
|
||||
|
||||
const digestBounded = async (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { parseBinary } from "plist";
|
||||
import { appleResourceKeyFields } from "../../domain/apple/appleAssetCatalog.js";
|
||||
import {
|
||||
interfaceBuilderConnectionFieldNames,
|
||||
interfaceBuilderRuntimeClassName,
|
||||
requireInterfaceBuilderArchive,
|
||||
isInterfaceBuilderConnectionClass,
|
||||
isSourceLessAppKitAnnotationConnector,
|
||||
@@ -637,12 +638,13 @@ const projectNibArchive = (
|
||||
const object = byId.get(objectId);
|
||||
if (object === undefined) return null;
|
||||
const storedClass = normalizeClassName(object.class_name);
|
||||
const resolvedClass =
|
||||
storedClass === "NSClassSwapper"
|
||||
? (referencedString(object.values.NSClassName) ?? storedClass)
|
||||
: storedClass === "UIClassSwapper"
|
||||
? (referencedString(object.values.UIClassName) ?? storedClass)
|
||||
: storedClass;
|
||||
const resolvedClass = interfaceBuilderRuntimeClassName(
|
||||
storedClass,
|
||||
(field) => {
|
||||
const value = object.values[field];
|
||||
return typeof value === "string" ? value : referencedString(value);
|
||||
},
|
||||
);
|
||||
runtimeClasses.set(objectId, resolvedClass);
|
||||
return resolvedClass;
|
||||
};
|
||||
@@ -696,6 +698,13 @@ const projectNibArchive = (
|
||||
label: name,
|
||||
objectID: String(object.id),
|
||||
nibValues,
|
||||
...(className === normalizeClassName(object.class_name)
|
||||
? {}
|
||||
: {
|
||||
interface_builder_archived_class_name: normalizeClassName(
|
||||
object.class_name,
|
||||
),
|
||||
}),
|
||||
...Object.fromEntries(resourceFields),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -18,6 +18,7 @@ const RESOURCE_KEY_FIELDS = new Set([
|
||||
"asset_name",
|
||||
"UIImageName",
|
||||
"NSImageName",
|
||||
"NSResourceName",
|
||||
]);
|
||||
|
||||
/** Recognized direct scalar resource fields; nested dictionaries are not inferred. */
|
||||
@@ -70,12 +71,26 @@ const resourceKeyMatchSchema = z.strictObject({
|
||||
),
|
||||
});
|
||||
|
||||
const analysisCoverageSchema = z.strictObject({
|
||||
status: z.enum(["complete", "partial", "unknown"]),
|
||||
reason: z.string().nullable(),
|
||||
});
|
||||
export type AppleAssetAnalysisCoverage = z.infer<typeof analysisCoverageSchema>;
|
||||
|
||||
/** Normalized result returned by asset catalog inspection. */
|
||||
export const appleAssetCatalogResultSchema = bundleResourceBoundResultSchema(
|
||||
z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
{
|
||||
catalogs: z.array(catalogSchema),
|
||||
inventory_coverage: artifactInventoryCoverageSchema,
|
||||
catalog_coverage: analysisCoverageSchema.default({
|
||||
status: "unknown",
|
||||
reason: "catalog_coverage_not_reported",
|
||||
}),
|
||||
resource_key_coverage: analysisCoverageSchema.default({
|
||||
status: "unknown",
|
||||
reason: "resource_key_coverage_not_reported",
|
||||
}),
|
||||
total_records: z.int().nonnegative(),
|
||||
offset: z.int().nonnegative(),
|
||||
limit: z.int().positive(),
|
||||
@@ -115,6 +130,8 @@ export const parseAppleAssetCatalogRecords = (
|
||||
/** Project catalog records and apply stable global pagination. */
|
||||
export const projectAppleAssetCatalogPage = (input: {
|
||||
readonly inventoryCoverage?: ArtifactInventoryCoverage;
|
||||
readonly catalogCoverage?: AppleAssetAnalysisCoverage;
|
||||
readonly resourceKeyCoverage?: AppleAssetAnalysisCoverage;
|
||||
readonly targetSha256: string | null;
|
||||
readonly resourceRoot?: import("./bundleResourceRoot.js").BundleResourceRoot;
|
||||
readonly catalogs: readonly {
|
||||
@@ -188,6 +205,14 @@ export const projectAppleAssetCatalogPage = (input: {
|
||||
scope: "container-entries",
|
||||
status: "unknown",
|
||||
},
|
||||
catalog_coverage: input.catalogCoverage ?? {
|
||||
status: "unknown",
|
||||
reason: "catalog_coverage_not_reported",
|
||||
},
|
||||
resource_key_coverage: input.resourceKeyCoverage ?? {
|
||||
status: "unknown",
|
||||
reason: "resource_key_coverage_not_reported",
|
||||
},
|
||||
target_sha256: input.targetSha256,
|
||||
...(input.resourceRoot === undefined
|
||||
? {}
|
||||
|
||||
@@ -352,3 +352,36 @@ it.each([
|
||||
}).objects[0]?.kind,
|
||||
).toBe(kind);
|
||||
});
|
||||
|
||||
it.each(["NSCustomObject", "NSClassSwapper", "UIClassSwapper"])(
|
||||
"uses the guarded authored class on %s and retains the archived class",
|
||||
(archivedClass) => {
|
||||
const source = archive({
|
||||
[archivedClass === "UIClassSwapper" ? "UIClassName" : "NSClassName"]:
|
||||
"FixtureWindowController",
|
||||
});
|
||||
source.$objects[4] = {
|
||||
$classname: archivedClass,
|
||||
$classes: [archivedClass, "NSObject"],
|
||||
};
|
||||
expect(parseInterfaceBuilderRecords(source).objects[0]).toMatchObject({
|
||||
class_name: "FixtureWindowController",
|
||||
kind: "view_controller",
|
||||
attributes: { interface_builder_archived_class_name: archivedClass },
|
||||
});
|
||||
},
|
||||
);
|
||||
it("retains action connectors as serialized helpers without inventing UI controls", () => {
|
||||
const source = archive();
|
||||
source.$objects[5] = {
|
||||
$classname: "NSNibControlConnector",
|
||||
$classes: ["NSNibControlConnector", "NSNibConnector", "NSObject"],
|
||||
};
|
||||
const parsed = parseInterfaceBuilderRecords(source);
|
||||
expect(parsed.objects.find(({ id }) => id === "3")?.kind).toBe("other");
|
||||
expect(parsed.connections[0]).toMatchObject({
|
||||
kind: "action",
|
||||
source_id: "2",
|
||||
destination_id: "1",
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,23 @@ export const interfaceBuilderConnectionFieldNames = (className: string) =>
|
||||
? { source: "UISource", destination: "UIDestination", label: "UILabel" }
|
||||
: { source: "NSSource", destination: "NSDestination", label: "NSLabel" };
|
||||
|
||||
/** Only Interface Builder's known placeholder records establish an authored class override. */
|
||||
export const interfaceBuilderRuntimeClassName = (
|
||||
archivedClass: string | null,
|
||||
authoredName: (field: string) => string | null,
|
||||
): string | null => {
|
||||
switch (archivedClass) {
|
||||
case "NSClassSwapper":
|
||||
case "NSCustomObject":
|
||||
case "NSCustomResource":
|
||||
return authoredName("NSClassName") ?? archivedClass;
|
||||
case "UIClassSwapper":
|
||||
return authoredName("UIClassName") ?? archivedClass;
|
||||
default:
|
||||
return archivedClass;
|
||||
}
|
||||
};
|
||||
|
||||
/** A decoded plist is not an Interface Builder document merely because it is named .nib. */
|
||||
export const requireInterfaceBuilderArchive = (value: unknown): void => {
|
||||
const root = record(value);
|
||||
@@ -423,13 +440,20 @@ const parseKeyedArchive = (
|
||||
for (const [index, raw] of objectTable.entries()) {
|
||||
if (index === 0) continue;
|
||||
const resolved = record(resolve(raw));
|
||||
const className = classNameOf(raw);
|
||||
const archivedClassName = classNameOf(raw);
|
||||
// An archived object names its class with its own `$class` reference;
|
||||
// when that reference cannot be resolved the object is still observed,
|
||||
// with an unknown class. Scalars and class descriptors are not objects.
|
||||
if (className === null && !Object.hasOwn(record(raw), "$class")) continue;
|
||||
if (archivedClassName === null && !Object.hasOwn(record(raw), "$class"))
|
||||
continue;
|
||||
objectCount += 1;
|
||||
const fields = { ...resolved };
|
||||
const className = interfaceBuilderRuntimeClassName(
|
||||
archivedClassName,
|
||||
(field) => firstString(fields[field]),
|
||||
);
|
||||
if (className !== archivedClassName)
|
||||
fields.interface_builder_archived_class_name = archivedClassName;
|
||||
// Keep the unresolvable class reference itself, e.g. a dangling UID.
|
||||
if (className === null) fields.$class = record(raw).$class;
|
||||
// Authored objectID, object-id, id and archiveUID fields are data. Only
|
||||
@@ -566,6 +590,11 @@ const classifyObject = (
|
||||
className: string | null,
|
||||
classChain: readonly string[] = [],
|
||||
): InterfaceBuilderObject["kind"] => {
|
||||
if (
|
||||
isInterfaceBuilderConnectionClass(className ?? "") ||
|
||||
classChain.includes("NSNibConnector")
|
||||
)
|
||||
return "other";
|
||||
for (const [kind, bases] of archivedKindBases)
|
||||
if (bases.some((base) => classChain.includes(base))) return kind;
|
||||
const value = className?.toLowerCase() ?? "";
|
||||
|
||||
Reference in new issue
Block a user