diff --git a/src/app/os/keys/page.tsx b/src/app/os/keys/page.tsx
index 81dbaa4..9bf3474 100644
--- a/src/app/os/keys/page.tsx
+++ b/src/app/os/keys/page.tsx
@@ -1,15 +1,135 @@
"use client";
-import { useState, useEffect } from "react";
+import { useState, useEffect, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { useDebounce } from "use-debounce";
import Link from "next/link";
+import { Search, X, ChevronRight, ChevronDown } from "lucide-react";
import { Input } from "@/components/ui/input";
-import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
import { createEngine } from "@/lib/engine";
+interface GroupedKeys {
+ [prefix: string]: string[];
+}
+
+function groupKeysByPrefix(keys: string[]): GroupedKeys {
+ const groups: GroupedKeys = {};
+
+ for (const key of keys) {
+ const parts = key.split(".");
+ // Use first 3 segments for com.apple.*, otherwise use the whole key
+ let prefix: string;
+ if (parts.length >= 3 && parts[0] === "com" && parts[1] === "apple") {
+ prefix = `${parts[0]}.${parts[1]}.${parts[2]}`;
+ } else if (parts.length >= 2) {
+ prefix = `${parts[0]}.${parts[1]}`;
+ } else {
+ prefix = key;
+ }
+
+ if (!groups[prefix]) {
+ groups[prefix] = [];
+ }
+ groups[prefix].push(key);
+ }
+
+ return groups;
+}
+
+function KeyBadge({
+ keyName,
+ prefix,
+ os,
+}: {
+ keyName: string;
+ prefix: string;
+ os: string;
+}) {
+ const suffix = keyName.startsWith(prefix + ".")
+ ? keyName.slice(prefix.length)
+ : keyName === prefix
+ ? ""
+ : keyName;
+
+ return (
+
+ {suffix ? (
+ <>
+
+ {prefix}
+
+ {suffix}
+ >
+ ) : (
+ {keyName}
+ )}
+
+ );
+}
+
+function KeyGroup({
+ prefix,
+ keys,
+ os,
+ defaultOpen,
+}: {
+ prefix: string;
+ keys: string[];
+ os: string;
+ defaultOpen: boolean;
+}) {
+ const [open, setOpen] = useState(defaultOpen);
+
+ // Single standalone key - just show it inline
+ if (keys.length === 1 && keys[0] === prefix) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {keys.map((key) => (
+
+ ))}
+
+
+
+ );
+}
+
export default function Keys() {
const params = useSearchParams();
const os = params.get("os") as string;
@@ -17,15 +137,15 @@ export default function Keys() {
const [loading, setLoading] = useState(true);
const [keys, setKeys] = useState([]);
- const [filtered, setFiltered] = useState([]);
const [keyword, setKeyword] = useState("");
- const [value] = useDebounce(keyword, 200);
+ const [debouncedKeyword] = useDebounce(keyword, 200);
useEffect(() => {
async function load() {
const engine = await createEngine(group);
const allKeys = await engine.getKeys(build);
+ allKeys.sort((a, b) => a.localeCompare(b));
setKeys(allKeys);
}
@@ -33,52 +153,98 @@ export default function Keys() {
load().finally(() => setLoading(false));
}, [group, build]);
- useEffect(() => {
- setFiltered(
- keys.filter((key) => key.toLowerCase().includes(value.toLowerCase())),
- );
- }, [value, keys]);
+ const filtered = useMemo(
+ () =>
+ keys.filter((key) =>
+ key.toLowerCase().includes(debouncedKeyword.toLowerCase())
+ ),
+ [debouncedKeyword, keys]
+ );
+
+ const grouped = useMemo(() => groupKeysByPrefix(filtered), [filtered]);
+ const sortedPrefixes = useMemo(
+ () => Object.keys(grouped).sort((a, b) => a.localeCompare(b)),
+ [grouped]
+ );
+
+ const isFiltering = debouncedKeyword.length > 0;
return (
-
-
setKeyword(e.target.value)}
- className="p-2 border rounded w-full inset-shadow-accent pr-10"
- />
- {keyword && (
-
+
+
+
+ setKeyword(e.target.value)}
+ className="pl-9 pr-9"
+ />
+ {keyword && (
+
+ )}
+
+ {!loading && (
+
+ {isFiltering ? (
+ <>
+ {filtered.length} of {keys.length} keys
+ >
+ ) : (
+ <>{keys.length} entitlement keys>
+ )}
+
)}
{loading ? (
-
+
{Array.from({ length: 8 }).map((_, index) => (
-
+
+
+
+ {Array.from({ length: 3 + Math.floor(Math.random() * 4) }).map(
+ (_, i) => (
+
+ )
+ )}
+
+
))}
+ ) : filtered.length === 0 ? (
+
+ {keys.length === 0 ? (
+
No entitlement keys found for this OS version.
+ ) : (
+
No keys match "{keyword}"
+ )}
+
) : (
-
- {filtered.map((key, index) => (
-
- {key}
-
+
+ {sortedPrefixes.map((prefix) => (
+
))}
)}
diff --git a/src/app/os/layout.tsx b/src/app/os/layout.tsx
index acea7b6..44f6f95 100644
--- a/src/app/os/layout.tsx
+++ b/src/app/os/layout.tsx
@@ -9,9 +9,12 @@ import {
BreadcrumbList,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { VersionSwitcher } from "@/components/version-switcher";
import { addBasePath } from "@/lib/env";
import { useEffect } from "react";
+import { usePathname, useRouter } from "next/navigation";
export default function OSDetailLayout({
children,
@@ -19,36 +22,59 @@ export default function OSDetailLayout({
children: React.ReactNode;
}) {
const params = useSearchParams();
- const os = params.get("os");
+ const pathname = usePathname();
+ const router = useRouter();
+ const os = params.get("os") || "";
+
+ const currentTab = pathname.includes("/files")
+ ? "files"
+ : pathname.includes("/bin")
+ ? "bin"
+ : pathname.includes("/find")
+ ? "find"
+ : "keys";
useEffect(() => {
if (os) document.title = `${os || ""} - Entitlement Database`;
}, [os]);
+ const handleTabChange = (tab: string) => {
+ if (tab === "bin") return;
+ router.push(addBasePath(`/os/${tab}?os=${os}`));
+ };
+
return (
-
-
-
-
-
- Home
-
-
-
-
- {os}
-
- |
-
- Search Keys
-
- |
-
- Search Paths
-
-
-
-
+
+
{children}
diff --git a/src/components/version-switcher.tsx b/src/components/version-switcher.tsx
new file mode 100644
index 0000000..2fd274f
--- /dev/null
+++ b/src/components/version-switcher.tsx
@@ -0,0 +1,138 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useRouter, usePathname, useSearchParams } from "next/navigation";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { ChevronDown, Check } from "lucide-react";
+
+import type { OS } from "@/lib/types";
+import { addBasePath } from "@/lib/env";
+
+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 v2 - v1;
+ }
+
+ return 0;
+}
+
+export function VersionSwitcher({ currentOs }: { currentOs: string }) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+
+ const [group, currentBuild] = currentOs ? currentOs.split("/") : ["", ""];
+ const [open, setOpen] = useState(false);
+ const [versions, setVersions] = useState
([]);
+ const [filter, setFilter] = useState("");
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ if (!group) return;
+
+ fetch(addBasePath(`/data/${group}/list.json`))
+ .then((r) => r.json())
+ .then((list: OS[]) => {
+ list.sort((a, b) => compareVersion(a.version, b.version));
+ setVersions(list);
+ })
+ .finally(() => setLoading(false));
+ }, [group]);
+
+ const currentVersion = versions.find(
+ (v) => v.build === currentBuild || `${v.version}_${v.build}` === currentBuild
+ );
+
+ const filteredVersions = versions.filter((v) =>
+ `${v.version} ${v.build} ${v.name}`.toLowerCase().includes(filter.toLowerCase())
+ );
+
+ const handleSelect = (os: OS) => {
+ const newTag = `${os.version}_${os.build}`;
+ const newParams = new URLSearchParams(searchParams.toString());
+ newParams.set("os", `${group}/${newTag}`);
+
+ router.push(`${pathname}?${newParams.toString()}`);
+ setOpen(false);
+ setFilter("");
+ };
+
+ return (
+
+
+
+
+
+
+ setFilter(e.target.value)}
+ className="h-8"
+ />
+
+
+ {filteredVersions.length === 0 && (
+
+ No versions found
+
+ )}
+ {filteredVersions.map((os) => {
+ const isSelected =
+ os.build === currentBuild ||
+ `${os.version}_${os.build}` === currentBuild;
+
+ return (
+
+ );
+ })}
+
+
+
+ );
+}