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
+102 -25
View File
@@ -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(
`<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",
);
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<string>("");
const [xmlKeys, setXMLKeys] = useState<Set<string>>(new Set());
const [history, setHistory] = useState<PathHistory[]>([]);
const [compareXml, setCompareXml] = useState<string>("");
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 (
<div>
<main className="space-y-6">
@@ -87,7 +103,7 @@ export default function BinaryDetail() {
<div>
<h2 className="text-xl font-semibold">Entitlements of</h2>
<p>
<code className="text-red-800 break-all font-thin text-sm">
<code className="text-red-800 dark:text-red-400 break-all font-thin text-sm">
{path}
</code>
</p>
@@ -95,8 +111,67 @@ export default function BinaryDetail() {
{!loading && xml && <CopyButton text={xml} />}
</div>
{availableHistory.length > 1 && (
<div className="border rounded-lg p-4 bg-gray-50 dark:bg-gray-900">
<h3 className="font-semibold mb-2">Version History</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
This binary exists in {availableHistory.length} OS versions.
Select a version to compare:
</p>
<div className="flex flex-wrap gap-2">
{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 (
<span
key={h.os.build}
className="px-2 py-1 text-sm rounded bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 font-medium"
>
{h.os.version} (current)
</span>
);
}
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 (
<a
key={h.os.build}
href={href}
className={`px-2 py-1 text-sm rounded transition-colors ${
isComparing
? "bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200"
: "bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600"
}`}
>
{h.os.version}
{isComparing && " (comparing)"}
</a>
);
})}
</div>
</div>
)}
{loading && <p>Loading...</p>}
{!loading && xml && (
{!loading && compareWith && !compareLoading && normalizedCompareXml && (
<DiffViewer
oldXml={normalizedCompareXml}
newXml={normalizedXml}
oldLabel={`${compareWith}`}
newLabel={currentOs ? `${currentOs.os.version}_${currentOs.os.build}` : build}
/>
)}
{!loading && !compareWith && xml && (
<SyntaxHighlighter
language="xml"
showLineNumbers={true}
@@ -155,6 +230,8 @@ export default function BinaryDetail() {
{xml}
</SyntaxHighlighter>
)}
{compareLoading && <p>Loading comparison...</p>}
</main>
</div>
);
+188
View File
@@ -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 (
<div className="space-y-4">
<DiffSummary diff={diff} />
<div className="grid grid-cols-2 gap-2 font-mono text-xs">
<div className="bg-gray-100 dark:bg-gray-800 rounded-t px-3 py-2 font-semibold border-b">
{oldLabel}
</div>
<div className="bg-gray-100 dark:bg-gray-800 rounded-t px-3 py-2 font-semibold border-b">
{newLabel}
</div>
<div className="col-span-2">
<div className="grid grid-cols-2 gap-2">
{lineDiff.map((line, i) => (
<DiffLineRow key={i} line={line} />
))}
</div>
</div>
</div>
</div>
);
}
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 (
<>
<div className={`${baseClasses} bg-gray-50 dark:bg-gray-900`}>
{line.oldLine}
</div>
<div className={`${baseClasses} bg-gray-50 dark:bg-gray-900`}>
{line.newLine}
</div>
</>
);
case "removed":
return (
<>
<div className={`${baseClasses} bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200`}>
{line.oldLine}
</div>
<div className={`${baseClasses} bg-gray-100 dark:bg-gray-800`} />
</>
);
case "added":
return (
<>
<div className={`${baseClasses} bg-gray-100 dark:bg-gray-800`} />
<div className={`${baseClasses} bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200`}>
{line.newLine}
</div>
</>
);
case "changed":
return (
<>
<div className={`${baseClasses} bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200`}>
{line.oldLine}
</div>
<div className={`${baseClasses} bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200`}>
{line.newLine}
</div>
</>
);
}
}
function DiffSummary({ diff }: { diff: PlistDiff }) {
const hasChanges =
diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0;
if (!hasChanges) {
return (
<div className="text-sm text-gray-500 dark:text-gray-400">
No changes in root-level keys
</div>
);
}
return (
<div className="flex flex-wrap gap-4 text-sm">
{diff.added.length > 0 && (
<div className="flex items-center gap-2">
<span className="inline-block w-3 h-3 rounded bg-green-500" />
<span>
<strong>{diff.added.length}</strong> added:{" "}
<code className="text-xs">{diff.added.join(", ")}</code>
</span>
</div>
)}
{diff.removed.length > 0 && (
<div className="flex items-center gap-2">
<span className="inline-block w-3 h-3 rounded bg-red-500" />
<span>
<strong>{diff.removed.length}</strong> removed:{" "}
<code className="text-xs">{diff.removed.join(", ")}</code>
</span>
</div>
)}
{diff.changed.length > 0 && (
<div className="flex items-center gap-2">
<span className="inline-block w-3 h-3 rounded bg-yellow-500" />
<span>
<strong>{diff.changed.length}</strong> changed:{" "}
<code className="text-xs">{diff.changed.join(", ")}</code>
</span>
</div>
)}
</div>
);
}
+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;
}
}