Add version history and side-by-side diff for binary entitlements

- Show all OS versions where the same binary path exists
- Add side-by-side diff view comparing entitlements between versions
- Normalize plist by sorting root-level keys before comparison
- Add plist utilities for parsing and key extraction
This commit is contained in:
cc
2026-04-14 16:56:57 +02:00
parent 7d35a88b39
commit 2c7069e6ba
6 changed files with 490 additions and 27 deletions
+31 -1
View File
@@ -1,4 +1,4 @@
import type { Engine } from "./types";
import type { Engine, PathHistory } from "./types";
import type { OS } from "@/lib/types";
import { dataBaseURL } from "@/lib/env";
import { fetchText, fetchLines } from "@/lib/client";
@@ -42,6 +42,10 @@ class KVStore {
});
}
has(key: string): boolean {
return this.#index.has(key);
}
*keys(): IterableIterator<string> {
yield* this.#index.keys();
}
@@ -93,6 +97,32 @@ export class KVEngine implements Engine {
return lines.split("\n").filter(Boolean);
}
async getPathHistory(path: string): Promise<PathHistory[]> {
const osList = await this.listOS();
const results: PathHistory[] = [];
const checks = osList.map(async (os) => {
const tag = `${os.version}_${os.build}`;
try {
const reader = await this.openKV(`${this.#baseURL}/${tag}/blobs`);
return { os, available: reader.has(path) };
} catch {
return { os, available: false };
}
});
const settled = await Promise.all(checks);
return settled.sort((a, b) => {
const vA = a.os.version.split(".").map(Number);
const vB = b.os.version.split(".").map(Number);
for (let i = 0; i < Math.max(vA.length, vB.length); i++) {
const diff = (vB[i] || 0) - (vA[i] || 0);
if (diff !== 0) return diff;
}
return 0;
});
}
#osCache: OS[] | null = null;
private async findOS(build: string): Promise<OS> {
+6
View File
@@ -1,9 +1,15 @@
import type { OS } from "@/lib/types";
export interface PathHistory {
os: OS;
available: boolean;
}
export interface Engine {
listOS(): Promise<OS[]>;
getPaths(build: string): Promise<string[]>;
getBinaryXML(build: string, path: string): Promise<string>;
getKeys(build: string): Promise<string[]>;
getPathsForKey(build: string, key: string): Promise<string[]>;
getPathHistory(path: string): Promise<PathHistory[]>;
}
+20 -1
View File
@@ -1,4 +1,4 @@
import type { Engine } from "./types";
import type { Engine, PathHistory } from "./types";
import type { OS } from "@/lib/types";
import { dataBaseURL } from "@/lib/env";
@@ -197,4 +197,23 @@ export class WASMEngine implements Engine {
});
return rows.map((row) => row[0] as string);
}
async getPathHistory(path: string): Promise<PathHistory[]> {
const db = await getDB();
const osList = await this.listOS();
const rows = db.exec({
sql: `SELECT DISTINCT os.build FROM bin JOIN os ON bin.osid=os.id WHERE bin.path=?`,
bind: [path],
rowMode: "array",
returnValue: "resultRows",
});
const availableBuilds = new Set(rows.map((row) => row[0] as string));
return osList.map((os) => ({
os,
available: availableBuilds.has(os.build),
}));
}
}
+143
View File
@@ -0,0 +1,143 @@
export function parsePlist(xml: string): Document {
const parser = new DOMParser();
return parser.parseFromString(xml, "application/xml");
}
interface PlistEntry {
key: string;
value: string;
}
export function normalizePlist(xml: string): string {
const doc = parsePlist(xml);
const rootDict = doc.querySelector("plist > dict");
if (!rootDict) return xml;
const entries: PlistEntry[] = [];
const children = Array.from(rootDict.children);
for (let i = 0; i < children.length; i += 2) {
const keyEl = children[i];
const valueEl = children[i + 1];
if (keyEl?.tagName === "key" && valueEl) {
entries.push({
key: keyEl.textContent || "",
value: new XMLSerializer().serializeToString(valueEl),
});
}
}
entries.sort((a, b) => a.key.localeCompare(b.key));
const lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<plist version="1.0">',
"<dict>",
...entries.map((e) => `<key>${e.key}</key>\n${e.value}`),
"</dict>",
"</plist>",
];
return lines.join("\n");
}
export interface PlistDiff {
added: string[];
removed: string[];
changed: string[];
unchanged: string[];
}
export function diffPlistKeys(oldXml: string, newXml: string): PlistDiff {
const oldKeys = extractRootKeys(oldXml);
const newKeys = extractRootKeys(newXml);
const oldDoc = parsePlist(oldXml);
const newDoc = parsePlist(newXml);
const added: string[] = [];
const removed: string[] = [];
const changed: string[] = [];
const unchanged: string[] = [];
const allKeys = new Set([...oldKeys, ...newKeys]);
for (const key of allKeys) {
const inOld = oldKeys.has(key);
const inNew = newKeys.has(key);
if (!inOld && inNew) {
added.push(key);
} else if (inOld && !inNew) {
removed.push(key);
} else {
const oldValue = getKeyValue(oldDoc, key);
const newValue = getKeyValue(newDoc, key);
if (oldValue === newValue) {
unchanged.push(key);
} else {
changed.push(key);
}
}
}
return { added, removed, changed, unchanged };
}
function extractRootKeys(xml: string): Set<string> {
const doc = parsePlist(xml);
const keys = new Set<string>();
const rootDict = doc.querySelector("plist > dict");
if (!rootDict) return keys;
const keyElements = rootDict.querySelectorAll(":scope > key");
keyElements.forEach((el) => {
if (el.textContent) keys.add(el.textContent);
});
return keys;
}
function getKeyValue(doc: Document, keyName: string): string {
const rootDict = doc.querySelector("plist > dict");
if (!rootDict) return "";
const keys = rootDict.querySelectorAll(":scope > key");
for (const key of keys) {
if (key.textContent === keyName) {
const value = key.nextElementSibling;
if (value) {
return value.outerHTML;
}
}
}
return "";
}
export function prettifyXml(src: string): string {
// Remove DOCTYPE to avoid DTD loading issues
const cleanSrc = src.replace(/<!DOCTYPE[^>]*>/i, "");
const xmlDoc = new DOMParser().parseFromString(cleanSrc, "application/xml");
if (xmlDoc.querySelector("parsererror")) {
return src;
}
const xsltDoc = new DOMParser().parseFromString(
`<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output omit-xml-declaration="yes" indent="yes"/>
<xsl:template match="node()|@*">
<xsl:copy><xsl:apply-templates select="node()|@*"/></xsl:copy>
</xsl:template>
</xsl:stylesheet>`,
"application/xml",
);
try {
const xsltProcessor = new XSLTProcessor();
xsltProcessor.importStylesheet(xsltDoc);
const resultDoc = xsltProcessor.transformToDocument(xmlDoc);
return new XMLSerializer().serializeToString(resultDoc);
} catch {
return src;
}
}