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 - - - - +
+
+
+ + + + Home + + + + + {os?.split("/")[0]} + + + + + +
+ + + + Entitlement Keys + Browse Files + {currentTab === "find" && ( + Search Results + )} + {currentTab === "bin" && ( + Binary Detail + )} + +
{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 ( + + ); + })} +
+
+
+ ); +}