From 2c7069e6baa9c16519e71fc8a265945ef1bf469c Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 14 Apr 2026 16:56:57 +0200 Subject: [PATCH] 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 --- src/app/os/bin/page.tsx | 127 +++++++++++++++++----- src/components/diff-viewer.tsx | 188 +++++++++++++++++++++++++++++++++ src/lib/engine/kv.ts | 32 +++++- src/lib/engine/types.ts | 6 ++ src/lib/engine/wasm.ts | 21 +++- src/lib/plist.ts | 143 +++++++++++++++++++++++++ 6 files changed, 490 insertions(+), 27 deletions(-) create mode 100644 src/components/diff-viewer.tsx create mode 100644 src/lib/plist.ts diff --git a/src/app/os/bin/page.tsx b/src/app/os/bin/page.tsx index add5501..b1b99e6 100644 --- a/src/app/os/bin/page.tsx +++ b/src/app/os/bin/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useMemo } from "react"; import { redirect, useSearchParams } from "next/navigation"; import { createElement, @@ -9,36 +9,18 @@ import { import { tomorrow } from "react-syntax-highlighter/dist/esm/styles/prism"; import { CopyButton } from "@/components/copy-button"; +import { DiffViewer } from "@/components/diff-viewer"; import { addBasePath } from "@/lib/env"; import { createEngine } from "@/lib/engine"; - -function prettifyXml(src: string) { - const xmlDoc = new DOMParser().parseFromString(src, "application/xml"); - const xsltDoc = new DOMParser().parseFromString( - ` - - - - - - - `, - "application/xml", - ); - - const xsltProcessor = new XSLTProcessor(); - xsltProcessor.importStylesheet(xsltDoc); - const resultDoc = xsltProcessor.transformToDocument(xmlDoc); - const resultXml = new XMLSerializer().serializeToString(resultDoc); - return resultXml; -} +import type { PathHistory } from "@/lib/engine/types"; +import { normalizePlist, prettifyXml } from "@/lib/plist"; export default function BinaryDetail() { const params = useSearchParams(); const os = params.get("os"); const path = params.get("path"); + const compareWith = params.get("compare"); const [group, build] = os ? os.split("/") : ["", ""]; @@ -55,6 +37,9 @@ export default function BinaryDetail() { const [loading, setLoading] = useState(false); const [xml, setXML] = useState(""); const [xmlKeys, setXMLKeys] = useState>(new Set()); + const [history, setHistory] = useState([]); + const [compareXml, setCompareXml] = useState(""); + const [compareLoading, setCompareLoading] = useState(false); useEffect(() => { async function load() { @@ -74,12 +59,43 @@ export default function BinaryDetail() { } catch { setXML(rawXml); } + + const hist = await engine.getPathHistory(path!); + setHistory(hist); } setLoading(true); load().finally(() => setLoading(false)); }, [group, build, path]); + useEffect(() => { + if (!compareWith || !group) return; + + async function loadCompare() { + const engine = await createEngine(group); + const rawXml = await engine.getBinaryXML(compareWith!, path!); + const prettified = prettifyXml(rawXml); + setCompareXml(prettified); + } + + setCompareLoading(true); + loadCompare().finally(() => setCompareLoading(false)); + }, [group, compareWith, path]); + + const normalizedXml = useMemo( + () => (xml ? normalizePlist(xml) : ""), + [xml], + ); + const normalizedCompareXml = useMemo( + () => (compareXml ? normalizePlist(compareXml) : ""), + [compareXml], + ); + + const availableHistory = history.filter((h) => h.available); + const currentOs = history.find( + (h) => h.os.build === build || `${h.os.version}_${h.os.build}` === build, + ); + return (
@@ -87,7 +103,7 @@ export default function BinaryDetail() {

Entitlements of

- + {path}

@@ -95,8 +111,67 @@ export default function BinaryDetail() { {!loading && xml && }
+ {availableHistory.length > 1 && ( +
+

Version History

+

+ This binary exists in {availableHistory.length} OS versions. + Select a version to compare: +

+
+ {availableHistory.map((h) => { + const isCurrent = + h.os.build === build || + `${h.os.version}_${h.os.build}` === build; + const isComparing = compareWith === `${h.os.version}_${h.os.build}`; + const versionTag = `${h.os.version}_${h.os.build}`; + + if (isCurrent) { + return ( + + {h.os.version} (current) + + ); + } + + const href = isComparing + ? addBasePath(`/os/bin?os=${encodeURIComponent(os!)}&path=${encodeURIComponent(path!)}`) + : addBasePath(`/os/bin?os=${encodeURIComponent(os!)}&path=${encodeURIComponent(path!)}&compare=${encodeURIComponent(versionTag)}`); + + return ( + + {h.os.version} + {isComparing && " (comparing)"} + + ); + })} +
+
+ )} + {loading &&

Loading...

} - {!loading && xml && ( + + {!loading && compareWith && !compareLoading && normalizedCompareXml && ( + + )} + + {!loading && !compareWith && xml && ( )} + + {compareLoading &&

Loading comparison...

}
); diff --git a/src/components/diff-viewer.tsx b/src/components/diff-viewer.tsx new file mode 100644 index 0000000..d0f6f6a --- /dev/null +++ b/src/components/diff-viewer.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { useMemo } from "react"; +import { diffPlistKeys, type PlistDiff } from "@/lib/plist"; + +interface DiffLine { + type: "unchanged" | "added" | "removed" | "changed"; + oldLine?: string; + newLine?: string; + key?: string; +} + +function computeLineDiff(oldLines: string[], newLines: string[]): DiffLine[] { + const result: DiffLine[] = []; + const oldSet = new Set(oldLines); + const newSet = new Set(newLines); + + const maxLen = Math.max(oldLines.length, newLines.length); + + let oi = 0; + let ni = 0; + + while (oi < oldLines.length || ni < newLines.length) { + const oldLine = oldLines[oi]; + const newLine = newLines[ni]; + + if (oldLine === newLine) { + result.push({ type: "unchanged", oldLine, newLine }); + oi++; + ni++; + } else if (oldLine && !newSet.has(oldLine)) { + result.push({ type: "removed", oldLine, newLine: undefined }); + oi++; + } else if (newLine && !oldSet.has(newLine)) { + result.push({ type: "added", oldLine: undefined, newLine }); + ni++; + } else { + result.push({ type: "changed", oldLine, newLine }); + oi++; + ni++; + } + } + + return result; +} + +interface DiffViewerProps { + oldXml: string; + newXml: string; + oldLabel: string; + newLabel: string; +} + +export function DiffViewer({ + oldXml, + newXml, + oldLabel, + newLabel, +}: DiffViewerProps) { + const diff = useMemo(() => diffPlistKeys(oldXml, newXml), [oldXml, newXml]); + + const oldLines = useMemo( + () => oldXml.split("\n").filter((l) => l.trim()), + [oldXml], + ); + const newLines = useMemo( + () => newXml.split("\n").filter((l) => l.trim()), + [newXml], + ); + + const lineDiff = useMemo( + () => computeLineDiff(oldLines, newLines), + [oldLines, newLines], + ); + + return ( +
+ +
+
+ {oldLabel} +
+
+ {newLabel} +
+
+
+ {lineDiff.map((line, i) => ( + + ))} +
+
+
+
+ ); +} + +function DiffLineRow({ line }: { line: DiffLine }) { + const baseClasses = "px-3 py-0.5 font-mono text-xs whitespace-pre overflow-x-auto"; + + switch (line.type) { + case "unchanged": + return ( + <> +
+ {line.oldLine} +
+
+ {line.newLine} +
+ + ); + case "removed": + return ( + <> +
+ {line.oldLine} +
+
+ + ); + case "added": + return ( + <> +
+
+ {line.newLine} +
+ + ); + case "changed": + return ( + <> +
+ {line.oldLine} +
+
+ {line.newLine} +
+ + ); + } +} + +function DiffSummary({ diff }: { diff: PlistDiff }) { + const hasChanges = + diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0; + + if (!hasChanges) { + return ( +
+ No changes in root-level keys +
+ ); + } + + return ( +
+ {diff.added.length > 0 && ( +
+ + + {diff.added.length} added:{" "} + {diff.added.join(", ")} + +
+ )} + {diff.removed.length > 0 && ( +
+ + + {diff.removed.length} removed:{" "} + {diff.removed.join(", ")} + +
+ )} + {diff.changed.length > 0 && ( +
+ + + {diff.changed.length} changed:{" "} + {diff.changed.join(", ")} + +
+ )} +
+ ); +} diff --git a/src/lib/engine/kv.ts b/src/lib/engine/kv.ts index 5325641..19d4496 100644 --- a/src/lib/engine/kv.ts +++ b/src/lib/engine/kv.ts @@ -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 { yield* this.#index.keys(); } @@ -93,6 +97,32 @@ export class KVEngine implements Engine { return lines.split("\n").filter(Boolean); } + async getPathHistory(path: string): Promise { + 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 { diff --git a/src/lib/engine/types.ts b/src/lib/engine/types.ts index 167dfdc..d817de6 100644 --- a/src/lib/engine/types.ts +++ b/src/lib/engine/types.ts @@ -1,9 +1,15 @@ import type { OS } from "@/lib/types"; +export interface PathHistory { + os: OS; + available: boolean; +} + export interface Engine { listOS(): Promise; getPaths(build: string): Promise; getBinaryXML(build: string, path: string): Promise; getKeys(build: string): Promise; getPathsForKey(build: string, key: string): Promise; + getPathHistory(path: string): Promise; } diff --git a/src/lib/engine/wasm.ts b/src/lib/engine/wasm.ts index 78fadf5..c00a2a9 100644 --- a/src/lib/engine/wasm.ts +++ b/src/lib/engine/wasm.ts @@ -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 { + 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), + })); + } } diff --git a/src/lib/plist.ts b/src/lib/plist.ts new file mode 100644 index 0000000..0e7a6b2 --- /dev/null +++ b/src/lib/plist.ts @@ -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 = [ + '', + '', + "", + ...entries.map((e) => `${e.key}\n${e.value}`), + "", + "", + ]; + + 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 { + const doc = parsePlist(xml); + const keys = new Set(); + 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(/]*>/i, ""); + + const xmlDoc = new DOMParser().parseFromString(cleanSrc, "application/xml"); + if (xmlDoc.querySelector("parsererror")) { + return src; + } + + const xsltDoc = new DOMParser().parseFromString( + ` + + + + + `, + "application/xml", + ); + + try { + const xsltProcessor = new XSLTProcessor(); + xsltProcessor.importStylesheet(xsltDoc); + const resultDoc = xsltProcessor.transformToDocument(xmlDoc); + return new XMLSerializer().serializeToString(resultDoc); + } catch { + return src; + } +}