From 0f0a3ab28e387f21e4f1615568136e35e6499f7a Mon Sep 17 00:00:00 2001 From: cc Date: Fri, 10 Apr 2026 20:08:02 +0000 Subject: [PATCH] initial: refactor web frontend from monorepo with WASM SQLite3 engine --- .github/workflows/build.yml | 75 +++ .gitignore | 36 ++ .nojekyll | 0 README.md | 24 + components.json | 21 + eslint.config.mjs | 1 + next.config.ts | 7 + package.json | 48 ++ postcss.config.mjs | 5 + src/app/favicon.ico | Bin 0 -> 25931 bytes src/app/globals.css | 146 ++++++ src/app/layout.tsx | 43 ++ src/app/os/bin/page.tsx | 161 +++++++ src/app/os/files/page.tsx | 58 +++ src/app/os/find/page.tsx | 53 +++ src/app/os/keys/page.tsx | 88 ++++ src/app/os/layout.tsx | 57 +++ src/app/os/page.tsx | 15 + src/app/page.tsx | 14 + src/components/autocomplete.tsx | 145 ++++++ src/components/copy-button.tsx | 21 + src/components/filesystem.tsx | 63 +++ src/components/navtop.tsx | 31 ++ src/components/oslist.tsx | 163 +++++++ src/components/ui/accordion.tsx | 66 +++ src/components/ui/badge.tsx | 46 ++ src/components/ui/breadcrumb.tsx | 109 +++++ src/components/ui/button.tsx | 59 +++ src/components/ui/checkbox.tsx | 32 ++ src/components/ui/collapsible.tsx | 33 ++ src/components/ui/command.tsx | 184 ++++++++ src/components/ui/dialog.tsx | 143 ++++++ src/components/ui/input.tsx | 21 + src/components/ui/popover.tsx | 48 ++ src/components/ui/separator.tsx | 28 ++ src/components/ui/sheet.tsx | 139 ++++++ src/components/ui/sidebar.tsx | 726 ++++++++++++++++++++++++++++++ src/components/ui/skeleton.tsx | 13 + src/components/ui/sonner.tsx | 25 + src/components/ui/tabs.tsx | 66 +++ src/components/ui/tooltip.tsx | 61 +++ src/hooks/use-mobile.ts | 19 + src/lib/client.ts | 15 + src/lib/engine/index.ts | 36 ++ src/lib/engine/kv.ts | 113 +++++ src/lib/engine/types.ts | 9 + src/lib/engine/wasm.ts | 106 +++++ src/lib/env.ts | 11 + src/lib/tree.ts | 63 +++ src/lib/types.ts | 11 + src/lib/utils.ts | 6 + tsconfig.json | 27 ++ 52 files changed, 3490 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 .nojekyll create mode 100644 README.md create mode 100644 components.json create mode 100644 eslint.config.mjs create mode 100644 next.config.ts create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 src/app/favicon.ico create mode 100644 src/app/globals.css create mode 100644 src/app/layout.tsx create mode 100644 src/app/os/bin/page.tsx create mode 100644 src/app/os/files/page.tsx create mode 100644 src/app/os/find/page.tsx create mode 100644 src/app/os/keys/page.tsx create mode 100644 src/app/os/layout.tsx create mode 100644 src/app/os/page.tsx create mode 100644 src/app/page.tsx create mode 100644 src/components/autocomplete.tsx create mode 100644 src/components/copy-button.tsx create mode 100644 src/components/filesystem.tsx create mode 100644 src/components/navtop.tsx create mode 100644 src/components/oslist.tsx create mode 100644 src/components/ui/accordion.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/breadcrumb.tsx create mode 100644 src/components/ui/button.tsx create mode 100644 src/components/ui/checkbox.tsx create mode 100644 src/components/ui/collapsible.tsx create mode 100644 src/components/ui/command.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/popover.tsx create mode 100644 src/components/ui/separator.tsx create mode 100644 src/components/ui/sheet.tsx create mode 100644 src/components/ui/sidebar.tsx create mode 100644 src/components/ui/skeleton.tsx create mode 100644 src/components/ui/sonner.tsx create mode 100644 src/components/ui/tabs.tsx create mode 100644 src/components/ui/tooltip.tsx create mode 100644 src/hooks/use-mobile.ts create mode 100644 src/lib/client.ts create mode 100644 src/lib/engine/index.ts create mode 100644 src/lib/engine/kv.ts create mode 100644 src/lib/engine/types.ts create mode 100644 src/lib/engine/wasm.ts create mode 100644 src/lib/env.ts create mode 100644 src/lib/tree.ts create mode 100644 src/lib/types.ts create mode 100644 src/lib/utils.ts create mode 100644 tsconfig.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..e344856 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,75 @@ +name: Build and Deploy + +on: + push: + branches: [main] + + repository_dispatch: + types: [data-updated] + + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Setup Pages + uses: actions/configure-pages@v5 + id: configure-pages + with: + static_site_generator: next + + - name: Install dependencies + run: npm ci + + - name: Build with Next.js + run: npm run build + env: + NEXT_PUBLIC_BASE_PATH: ${{ steps.configure-pages.outputs.base_path }} + + - name: Download latest data release + run: | + curl -sL https://api.github.com/repos/ChiChou/entdb-data/releases/latest \ + | jq -r '.assets[] | select(.name == "data.tar.gz") | .browser_download_url' \ + | xargs -r curl -L -o data.tar.gz + mkdir -p out/data + tar -xzf data.tar.gz -C out/data + + - name: Download SQLite database + run: | + curl -sL https://api.github.com/repos/ChiChou/entdb-data/releases/latest \ + | jq -r '.assets[] | select(.name == "ent.db") | .browser_download_url' \ + | xargs -r curl -L -o out/data/ent.db + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ./out + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + needs: build + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9a9c9f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +data/ +public/data + +*.tar.gz +*.db + +node_modules/ +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +/coverage + +/.next/ +/out/ + +/build + +.DS_Store +*.pem + +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +.env* + +.vercel + +*.tsbuildinfo +next-env.d.ts diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c33aa7 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# entdb-web + +Web frontend for the Entitlement Database. + +Uses a WASM SQLite3 query engine as the primary data source for rich queries, +with a KV-based fallback for browsers that don't support WebAssembly. + +Built as a static Next.js site deployed to GitHub Pages. + +## Data Sources + +The frontend uses a dual-engine approach: + +1. **WASM Engine** (primary) — Loads `ent.db` SQLite database into the browser + via `@sqlite.org/sqlite-wasm`. Supports arbitrary SQL queries for rich data + views and cross-version analysis. + +2. **KV Engine** (fallback) — Uses pre-built static KV files (index + blob) + with HTTP Range requests. Used when WebAssembly is not available. + +## Related Repos + +- [entdb-indexer](https://github.com/ChiChou/entdb-indexer) — Crontab workflow to discover and index firmware +- [entdb-data](https://github.com/ChiChou/entdb-data) — Raw entitlement data repository diff --git a/components.json b/components.json new file mode 100644 index 0000000..3289f23 --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..b0e644d --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1 @@ +import rootConfig from "../../eslint.config.mjs"; diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..65c102b --- /dev/null +++ b/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "export", +}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..0b01b62 --- /dev/null +++ b/package.json @@ -0,0 +1,48 @@ +{ + "name": "entdb-web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build --turbopack", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@sqlite.org/sqlite-wasm": "^3.49.1-build3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "lucide-react": "^0.541.0", + "next": "15.5.0", + "next-themes": "^0.4.6", + "react": "19.1.0", + "react-dom": "19.1.0", + "react-syntax-highlighter": "^15.6.3", + "sonner": "^2.0.7", + "tailwind-merge": "^3.3.1", + "use-debounce": "^10.0.5" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "@types/react-syntax-highlighter": "^15.5.13", + "eslint": "^9", + "eslint-config-next": "15.5.0", + "tailwindcss": "^4", + "tw-animate-css": "^1.3.7", + "typescript": "^5" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..c7bcb4b --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/src/app/favicon.ico b/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..a56193e --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,146 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +@layer base { + :root { + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); + } + + .dark { + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.439 0 0); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..fb8c6b0 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,43 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; + +import { NavTop } from "@/components/navtop"; +import { Toaster } from "@/components/ui/sonner"; +import { Suspense } from "react"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Entitlement Database", + description: + "Open source entitlement database for iOS and macOS binaries that you can host yourself.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + + +
{children}
+
+ + + ); +} diff --git a/src/app/os/bin/page.tsx b/src/app/os/bin/page.tsx new file mode 100644 index 0000000..add5501 --- /dev/null +++ b/src/app/os/bin/page.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { redirect, useSearchParams } from "next/navigation"; +import { + createElement, + Prism as SyntaxHighlighter, +} from "react-syntax-highlighter"; +import { tomorrow } from "react-syntax-highlighter/dist/esm/styles/prism"; + +import { CopyButton } from "@/components/copy-button"; + +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; +} + +export default function BinaryDetail() { + const params = useSearchParams(); + const os = params.get("os"); + const path = params.get("path"); + + const [group, build] = os ? os.split("/") : ["", ""]; + + useEffect(() => { + if (os && path) { + document.title = `${path} | ${os} - Entitlement Database`; + } + }); + + if (typeof os !== "string" || typeof path !== "string") { + redirect("/404"); + } + + const [loading, setLoading] = useState(false); + const [xml, setXML] = useState(""); + const [xmlKeys, setXMLKeys] = useState>(new Set()); + + useEffect(() => { + async function load() { + const engine = await createEngine(group); + const rawXml = await engine.getBinaryXML(build, path!); + + try { + const prettified = prettifyXml(rawXml); + setXML(prettified); + + const parser = new DOMParser(); + const doc = parser.parseFromString(rawXml, "application/xml"); + const keys = new Set(); + const keyElements = doc.querySelectorAll("dict > key"); + keyElements.forEach((el) => keys.add(el.textContent || "")); + setXMLKeys(keys); + } catch { + setXML(rawXml); + } + } + + setLoading(true); + load().finally(() => setLoading(false)); + }, [group, build, path]); + + return ( +
+
+
+
+

Entitlements of

+

+ + {path} + +

+
+ {!loading && xml && } +
+ + {loading &&

Loading...

} + {!loading && xml && ( + { + function addLink(node: rendererNode) { + if (node.type === "text" && xmlKeys.has(node.value as string)) { + return { + type: "element", + tagName: "span", + children: [ + { + type: "element", + tagName: "a", + children: [ + { + type: "text", + value: node.value as string, + } as rendererNode, + ], + properties: { + className: ["text-blue-200", "hover:underline"], + href: addBasePath( + `/os/find?key=${encodeURIComponent( + node.value as string, + )}&os=${encodeURIComponent(os!)}`, + ), + }, + } as rendererNode, + ], + properties: { className: ["linked-key"] }, + } as rendererNode; + } + + if (node.children) { + node.children = node.children.map(addLink); + } + return node; + } + + return rows.map((row, i) => { + return createElement({ + node: addLink(row), + stylesheet, + useInlineStyles, + key: `code-segment-${i}`, + }); + }); + }} + > + {xml} + + )} +
+
+ ); +} diff --git a/src/app/os/files/page.tsx b/src/app/os/files/page.tsx new file mode 100644 index 0000000..6af1427 --- /dev/null +++ b/src/app/os/files/page.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useSearchParams } from "next/navigation"; + +import FileSystem from "@/components/filesystem"; +import { createEngine } from "@/lib/engine"; + +export default function Files() { + const params = useSearchParams(); + const os = params.get("os") as string; + const [group, build] = os ? os.split("/") : ["", ""]; + + const [loading, setLoading] = useState(true); + const [files, setFiles] = useState([]); + + useEffect(() => { + setLoading(true); + createEngine(group) + .then((engine) => engine.getPaths(build)) + .then(setFiles) + .finally(() => setLoading(false)); + }, [group, build]); + + return ( +
+ {loading ? ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : ( + + )} +
+ ); +} diff --git a/src/app/os/find/page.tsx b/src/app/os/find/page.tsx new file mode 100644 index 0000000..9588f53 --- /dev/null +++ b/src/app/os/find/page.tsx @@ -0,0 +1,53 @@ +"use client"; + +import FileSystem from "@/components/filesystem"; + +import { createEngine } from "@/lib/engine"; + +import { redirect, useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; + +export default function FindByKey() { + const params = useSearchParams(); + const os = params.get("os"); + const key = params.get("key"); + + const [group, build] = os ? os.split("/") : ["", ""]; + + useEffect(() => { + if (os && key) { + document.title = `Find "${key}" in ${os} - Entitlement Database`; + } + }); + + if (typeof os !== "string" || typeof key !== "string") { + redirect("/404"); + } + + const [paths, setPaths] = useState([]); + + useEffect(() => { + async function fetchPaths() { + if (!key) return; + + const engine = await createEngine(group); + const result = await engine.getPathsForKey(build, key); + setPaths(result); + } + fetchPaths(); + }, [group, build, key]); + + return ( +
+
+

+ Binaries that have the following entitlement: +

+

+ {key} +

+
+ +
+ ); +} diff --git a/src/app/os/keys/page.tsx b/src/app/os/keys/page.tsx new file mode 100644 index 0000000..698ba45 --- /dev/null +++ b/src/app/os/keys/page.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useSearchParams } from "next/navigation"; +import { useDebounce } from "use-debounce"; +import Link from "next/link"; + +import { addBasePath } from "@/lib/env"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; + +import { createEngine } from "@/lib/engine"; + +export default function Keys() { + const params = useSearchParams(); + const os = params.get("os") as string; + const [group, build] = os ? os.split("/") : ["", ""]; + + const [loading, setLoading] = useState(true); + const [keys, setKeys] = useState([]); + const [filtered, setFiltered] = useState([]); + const [keyword, setKeyword] = useState(""); + + const [value] = useDebounce(keyword, 200); + + useEffect(() => { + async function load() { + const engine = await createEngine(group); + const allKeys = await engine.getKeys(build); + setKeys(allKeys); + } + + setLoading(true); + load().finally(() => setLoading(false)); + }, [group, build]); + + useEffect(() => { + setFiltered( + keys.filter((key) => key.toLowerCase().includes(value.toLowerCase())), + ); + }, [value, keys]); + + return ( +
+
+ setKeyword(e.target.value)} + className="p-2 border rounded w-full inset-shadow-accent pr-10" + /> + {keyword && ( + + )} +
+ + {loading ? ( +
+ {Array.from({ length: 8 }).map((_, index) => ( +
+ ))} +
+ ) : ( +
+ {filtered.map((key, index) => ( + + {key} + + ))} +
+ )} +
+ ); +} diff --git a/src/app/os/layout.tsx b/src/app/os/layout.tsx new file mode 100644 index 0000000..acea7b6 --- /dev/null +++ b/src/app/os/layout.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; + +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; + +import { addBasePath } from "@/lib/env"; +import { useEffect } from "react"; + +export default function OSDetailLayout({ + children, +}: { + children: React.ReactNode; +}) { + const params = useSearchParams(); + const os = params.get("os"); + + useEffect(() => { + if (os) document.title = `${os || ""} - Entitlement Database`; + }, [os]); + + return ( +
+
+ + + + Home + + + + + {os} + + | + + Search Keys + + | + + Search Paths + + + + +
+ +
{children}
+
+ ); +} diff --git a/src/app/os/page.tsx b/src/app/os/page.tsx new file mode 100644 index 0000000..fccd10e --- /dev/null +++ b/src/app/os/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { addBasePath } from "@/lib/env"; +import { useSearchParams, redirect } from "next/navigation"; + +export default function OSDetail() { + const params = useSearchParams(); + const os = params.get("os"); + + if (typeof os !== "string") { + return
Invalid OS
; + } + + redirect(addBasePath(`/os/keys?os=${os}`)); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..1136ca1 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,14 @@ +import OSList from "@/components/oslist"; + +export default async function Home() { + return ( +
+

+ Entitlement Database +

+
+ +
+
+ ); +} diff --git a/src/components/autocomplete.tsx b/src/components/autocomplete.tsx new file mode 100644 index 0000000..1fc518c --- /dev/null +++ b/src/components/autocomplete.tsx @@ -0,0 +1,145 @@ +import { cn } from "@/lib/utils"; +import { Command as CommandPrimitive } from "cmdk"; +import { Check } from "lucide-react"; +import { useMemo, useState } from "react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandItem, + CommandList, +} from "./ui/command"; +import { Input } from "./ui/input"; +import { Popover, PopoverAnchor, PopoverContent } from "./ui/popover"; +import { Skeleton } from "./ui/skeleton"; + +type Props = { + selectedValue: T; + onSelectedValueChange: (value: T) => void; + searchValue: string; + onSearchValueChange: (value: string) => void; + items: { value: T; label: string }[]; + isLoading?: boolean; + emptyMessage?: string; + placeholder?: string; +}; + +export function AutoComplete({ + selectedValue, + onSelectedValueChange, + searchValue, + onSearchValueChange, + items, + isLoading, + emptyMessage = "No items.", + placeholder = "Search...", +}: Props) { + const [open, setOpen] = useState(false); + + const labels = useMemo( + () => + items.reduce( + (acc, item) => { + acc[item.value] = item.label; + return acc; + }, + {} as Record, + ), + [items], + ); + + const reset = () => { + onSelectedValueChange("" as T); + onSearchValueChange(""); + }; + + const onInputBlur = (e: React.FocusEvent) => { + if ( + !e.relatedTarget?.hasAttribute("cmdk-list") && + labels[selectedValue] !== searchValue + ) { + reset(); + } + }; + + const onSelectItem = (inputValue: string) => { + if (inputValue === selectedValue) { + reset(); + } else { + onSelectedValueChange(inputValue as T); + onSearchValueChange(labels[inputValue] ?? ""); + } + setOpen(false); + }; + + return ( +
+ + + + setOpen(e.key !== "Escape")} + onMouseDown={() => setOpen((open) => !!searchValue || !open)} + onFocus={() => setOpen(true)} + onBlur={onInputBlur} + > + + + + {!open && + +
+ ); +} diff --git a/src/components/copy-button.tsx b/src/components/copy-button.tsx new file mode 100644 index 0000000..f4eafa4 --- /dev/null +++ b/src/components/copy-button.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { Copy } from "lucide-react"; +import { Button } from "./ui/button"; +import { toast } from "sonner"; + +export function CopyButton({ text }: { text: string }) { + return ( +
+ +
+ ); +} diff --git a/src/components/filesystem.tsx b/src/components/filesystem.tsx new file mode 100644 index 0000000..306be4f --- /dev/null +++ b/src/components/filesystem.tsx @@ -0,0 +1,63 @@ +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; + +import { Button } from "@/components/ui/button"; + +import filesToTree, { type TreeWithFullPath } from "@/lib/tree"; +import Link from "next/link"; + +function Tree({ item, os }: { item: TreeWithFullPath; os: string }) { + return ( +
    + {Object.entries(item).map(([key, value]) => { + if (typeof value === "string") { + return ( +
  • + + /{key} + +
  • + ); + } else { + return ( +
  • +
      + + + + + + + + +
    +
  • + ); + } + })} +
+ ); +} + +export default function FileSystem({ + list, + os, +}: { + list: string[]; + os: string; +}) { + const tree = filesToTree(list); + return ( +
+ +
+ ); +} diff --git a/src/components/navtop.tsx b/src/components/navtop.tsx new file mode 100644 index 0000000..54f152f --- /dev/null +++ b/src/components/navtop.tsx @@ -0,0 +1,31 @@ +import Link from "next/link"; + +export function NavTop() { + return ( +
+

+ + entdb + +

+ +
+ ); +} diff --git a/src/components/oslist.tsx b/src/components/oslist.tsx new file mode 100644 index 0000000..b9ac00e --- /dev/null +++ b/src/components/oslist.tsx @@ -0,0 +1,163 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; + +import { Group, OS } from "@/lib/types"; +import { addBasePath } from "@/lib/env"; +import { Skeleton } from "./ui/skeleton"; +import { Checkbox } from "./ui/checkbox"; + +function responseOK(r: Response) { + if (!r.ok) { + throw new Error(`Failed to fetch resource at ${r.url}`); + } + return r; +} + +function compareVersion(a: string, b: string) { + const l1 = a.split(".").map(Number); + const l2 = b.split(".").map(Number); + const len = Math.max(l1.length, l2.length); + + for (let i = 0; i < len; i++) { + const v1 = l1[i] || 0; + const v2 = l2[i] || 0; + if (v1 !== v2) return v1 - v2; + } + + return 0; +} + +export default function OSList() { + const [showLess, setShowLess] = useState(true); + const [loading, setLoading] = useState(true); + const [groups, setGroups] = useState([]); + const [highlights, setHighlights] = useState>(new Set()); + + useEffect(() => { + const set: Set = new Set(); + for (const group of groups) { + group.list.sort((a, b) => compareVersion(b.version, a.version)); + + if (group.name === "osx") { + group.list.forEach((item) => set.add(item.build)); + } else { + const bucket: Map = new Map(); + group.list.forEach((item) => { + const [major] = item.version.split(".", 1); + const key = major.toString(); + if (!bucket.has(key)) { + bucket.set(key, [item]); + } else { + bucket.get(key)!.push(item); + } + }); + bucket.values().forEach((items) => { + items.sort((a, b) => compareVersion(b.version, a.version)); + const [first] = items; + set.add(first?.build); + }); + } + } + setHighlights(set); + }, [groups]); + + useEffect(() => { + setLoading(true); + + fetch(addBasePath("/data/groups.json")) + .then(responseOK) + .then((r) => r.json() as Promise) + .then(async (groupList: string[]) => + Promise.all( + groupList.map(async (group) => { + const response = await fetch( + addBasePath(`/data/${group}/list.json`), + ).then(responseOK); + + const data = await response.json(); + + return { + name: group, + list: data, + }; + }), + ), + ) + .then((groups) => { + setGroups(groups); + }) + .finally(() => setLoading(false)); + }, []); + + return ( +
+ {loading && ( +
+
+ + +
+ + {[1, 2, 3].map((group) => ( +
+ +
+ {[1, 2, 3, 4, 5, 6, 7, 8].map((item) => ( +
+
+ + +
+
+ ))} +
+
+ ))} +
+ )} + + {!loading && groups.length === 0 && ( +
Failed to fetch OS list
+ )} + + {!loading && ( +
+ setShowLess(Boolean(checked))} + /> + +
+ )} + + {groups.map((group) => ( +
+

{group.name}

+
    + {group.list + .filter((os) => !showLess || highlights.has(os.build)) + .map((os, index) => ( +
  • + +
    +

    {os.name}

    +
    {os.build}
    +
    + +
  • + ))} +
+
+ ))} +
+ ); +} diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 0000000..4a8cca4 --- /dev/null +++ b/src/components/ui/accordion.tsx @@ -0,0 +1,66 @@ +"use client" + +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDownIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Accordion({ + ...props +}: React.ComponentProps) { + return +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..0205413 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/breadcrumb.tsx b/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..eb88f32 --- /dev/null +++ b/src/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return