From bcbdb60d54588bdd96625ea71ea32ebd776f12d5 Mon Sep 17 00:00:00 2001 From: JP Hastings-Spital Date: Mon, 24 Aug 2026 04:41:12 +0100 Subject: [PATCH] feat: home page Server/CLI sections, /manage landing page, Docs in topbar (ATFS-juoe) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split the hero into "Server" (Docker, SBC) and "CLI" (atfs CLI, new Manage UI link) sections with heading labels. - New /manage/ page: a login prompt when signed out, straight-through redirect for an account with exactly one manageable instance (mirroring ManageMenu's own logic), a picker list for several. - Moved the hero's docs link into a persistent "Docs" button in the topbar, between Manage and Login/out (AccountMenu.svelte) — the inline hero note is gone now that every page carries the same link. Also, on the CLI downloads page (ATFS-vsa5, previous commit): - Removed the dev-only preview banner/fixture entirely. - Fixed rankPlatforms so an OS match with no detected arch and more than one candidate (e.g. macOS with arch undetected) is never double-badged "recommended" — it's now a three-tier match (exact/os/none) with a softer "Matches your OS" tag for the os-only case. - Added a WebGL-renderer-based Apple Silicon vs Intel heuristic (detectMacArchFromWebGL/parseGpuRendererArch) for Safari/Firefox, which have no userAgentData API to ask at all; Chromium's own architecture hint is trusted as-is where available. Filed ATFS-e3fz (sign & notarize the macOS binaries) as a separate, not-yet-actionable follow-up pending Apple Developer credentials. --- ...otarize-macos-atfs-cli-release-binaries.md | 34 +++ ...ervercli-sections-manage-ui-landing-pag.md | 46 ++++ web/src/lib/components/AccountMenu.svelte | 10 +- web/src/lib/platform.test.ts | 58 ++++- web/src/lib/platform.ts | 120 ++++++++--- web/src/routes/+page.svelte | 118 ++++++----- web/src/routes/downloads/cli/+page.svelte | 63 ++---- web/src/routes/manage/+page.svelte | 199 ++++++++++++++++++ 8 files changed, 508 insertions(+), 140 deletions(-) create mode 100644 .beans/ATFS-e3fz--sign-notarize-macos-atfs-cli-release-binaries.md create mode 100644 .beans/ATFS-juoe--home-page-servercli-sections-manage-ui-landing-pag.md create mode 100644 web/src/routes/manage/+page.svelte diff --git a/.beans/ATFS-e3fz--sign-notarize-macos-atfs-cli-release-binaries.md b/.beans/ATFS-e3fz--sign-notarize-macos-atfs-cli-release-binaries.md new file mode 100644 index 0000000..bb2b56b --- /dev/null +++ b/.beans/ATFS-e3fz--sign-notarize-macos-atfs-cli-release-binaries.md @@ -0,0 +1,34 @@ +--- +# ATFS-e3fz +title: Sign & notarize macOS atfs CLI release binaries +status: todo +type: feature +created_at: 2026-08-24T03:40:47Z +updated_at: 2026-08-24T03:40:47Z +parent: ATFS-qchs +--- + +The macOS CLI binaries hack/build-cli-binaries.sh produces are unsigned and +unnotarized, so Gatekeeper quarantines them on download — today's downloads +page tells the visitor to run `xattr -d com.apple.quarantine` or approve it +manually in System Settings. Code-signing with a Developer ID Application +certificate plus notarization would remove that friction entirely. + +Tangled's CI (.tangled/workflows/publish-sbc-images.yml) runs on Linux-only +engines (nixery/microvm) — there is no macOS runner, so this can't use Xcode's +own `codesign`/`notarytool` directly. The realistic path is `rcodesign` +(the `apple-codesign` Rust crate), which signs and submits for notarization +from Linux using just a Developer ID cert + an App Store Connect API key — +no Xcode or macOS host required. + +What it needs before it can be implemented: a Developer ID Application +certificate (.p12 + password) and an App Store Connect API key, both added +as Tangled repo secrets (same pattern as ATFS_APP_PASSWORD/WISP_APP_PASSWORD +— see CLAUDE.md's release-pipeline section), plus a new signing+notarization +step in hack/build-cli-binaries.sh or publish-sbc-images.yml, and updating +the downloads page copy to drop the quarantine-clearing instructions once +signing is live. + +JP offered to provide the Apple Developer credentials (2026-08-24) — not +yet supplied. Credentials belong directly in Tangled's secrets store, never +pasted through a chat transcript. diff --git a/.beans/ATFS-juoe--home-page-servercli-sections-manage-ui-landing-pag.md b/.beans/ATFS-juoe--home-page-servercli-sections-manage-ui-landing-pag.md new file mode 100644 index 0000000..d6554fe --- /dev/null +++ b/.beans/ATFS-juoe--home-page-servercli-sections-manage-ui-landing-pag.md @@ -0,0 +1,46 @@ +--- +# ATFS-juoe +title: 'Home page: Server/CLI sections, Manage UI landing page, Docs moved to topbar' +status: completed +type: feature +priority: normal +created_at: 2026-08-24T03:40:36Z +updated_at: 2026-08-24T03:40:58Z +parent: ATFS-qchs +--- + +Split the home page's download links into 'Server' (Docker, SBC) and 'CLI' (atfs CLI, Manage UI) sections. Add a bare /manage/ landing page: login-gated, lists the signed-in account's manageable instances (via manageableServers), redirects straight through when there's exactly one. Move the hero's 'Device not booting? See the setup docs' link into a persistent 'Docs' button in the topbar, between Manage and Login/out. Also fixes a bug in the CLI downloads page's platform-detection (macOS was showing both Intel and Apple Silicon as 'recommended' simultaneously when arch couldn't be determined) and adds a WebGL-renderer-based Apple Silicon vs Intel heuristic for Safari/Firefox, which have no userAgentData API at all. Removes the CLI downloads page's dev-only preview banner. + + +## Summary of Changes + +- web/src/lib/platform.ts: rankPlatforms now returns a three-tier `match` + ("exact"/"os"/"none") instead of a boolean `recommended` — an OS match + with undetected arch and >1 candidate for that OS (e.g. macOS with no + arch signal) is tagged "os", not "exact", so it never double-badges both + macOS builds as recommended. Added detectMacArchFromWebGL + + parseGpuRendererArch: a best-effort Apple Silicon vs Intel guess from the + WebGL unmasked-renderer string, used as a fallback wherever + userAgentData's architecture hint is unavailable (Safari, Firefox, or a + Chromium build that declines the hint) — Chromium's own hint is trusted + as-is where it exists (Google made it report the real CPU even under + Rosetta, specifically for this use case). +- web/src/routes/downloads/cli/+page.svelte: removed the DEV_PREVIEW_CLI + fixture and "Preview" banner entirely; badges now read the tri-state + match ("Recommended for you" for exact, a softer "Matches your OS" for + os-only). +- web/src/routes/+page.svelte: hero split into "Server" (Docker, SBC) and + "CLI" (atfs CLI, new Manage UI link) sections with heading labels; + removed the "Device not booting?" run-note (superseded by the topbar + Docs button below). +- web/src/lib/components/AccountMenu.svelte: added a "Docs" link between + Manage and Login/out, in both the signed-in and signed-out states. +- web/src/routes/manage/+page.svelte (new): bare /manage/ landing page — + a login prompt when signed out, a checking/redirect state that sends a + single-server account straight to /manage// (mirroring + ManageMenu's own topbar logic), and a picker list for multiple servers + (each labeled Owner/Uploader per Server.owned). + +Filed ATFS-e3fz (macOS binary signing/notarization) as a separate, +not-yet-actionable follow-up — needs Apple Developer credentials as +Tangled secrets before it can be implemented. diff --git a/web/src/lib/components/AccountMenu.svelte b/web/src/lib/components/AccountMenu.svelte index 1d89f3f..60a4e28 100644 --- a/web/src/lib/components/AccountMenu.svelte +++ b/web/src/lib/components/AccountMenu.svelte @@ -51,6 +51,7 @@
@{auth.handle} + @@ -82,9 +83,12 @@ {:else if auth.status === "signed-out" || auth.status === "unavailable"} - +
+ + +
{/if} {#if error || auth.error} diff --git a/web/src/lib/platform.test.ts b/web/src/lib/platform.test.ts index cb94871..737b2fe 100644 --- a/web/src/lib/platform.test.ts +++ b/web/src/lib/platform.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "vitest"; -import { humanSize, rankPlatforms } from "./platform.js"; +import { humanSize, parseGpuRendererArch, rankPlatforms } from "./platform.js"; const entries = [ { id: "linux-amd64", display: "Linux (x86_64)", os: "linux", arch: "amd64", url: "u1", filename: "atfs-linux-amd64", size: 1 }, @@ -9,28 +9,48 @@ const entries = [ { id: "darwin-arm64", display: "macOS (Apple Silicon)", os: "darwin", arch: "arm64", url: "u4", filename: "atfs-darwin-arm64", size: 1 }, ]; -test("an exact os+arch match is recommended and sorted first", () => { +test("an exact os+arch match is the sole 'exact' entry, sorted first", () => { const ranked = rankPlatforms(entries, { os: "darwin", arch: "arm64" }); expect(ranked[0].id).toBe("darwin-arm64"); - expect(ranked[0].recommended).toBe(true); - expect(ranked.filter((e) => e.recommended)).toHaveLength(1); + expect(ranked[0].match).toBe("exact"); + expect(ranked.filter((e) => e.match === "exact")).toHaveLength(1); }); -test("an os-only match (arch undetected) is still recommended", () => { - const ranked = rankPlatforms(entries, { os: "linux", arch: null }); - expect(ranked[0].os).toBe("linux"); - expect(ranked[0].recommended).toBe(true); +test("os matched but arch undetected, with two candidates: neither is 'exact'", () => { + // This is the bug report: macOS with no arch signal used to mark BOTH + // darwin builds "recommended" at once. + const ranked = rankPlatforms(entries, { os: "darwin", arch: null }); + const darwinEntries = ranked.filter((e) => e.os === "darwin"); + expect(darwinEntries.every((e) => e.match === "os")).toBe(true); + expect(ranked.some((e) => e.match === "exact")).toBe(false); + // Still sorted above the non-matching OS's entries. + expect(ranked.slice(0, 2).map((e) => e.os)).toEqual(["darwin", "darwin"]); +}); + +test("os matched, arch undetected, only one build for that OS: it's unambiguous", () => { + const oneLinuxBuild = entries.filter((e) => e.id !== "linux-arm64"); + const ranked = rankPlatforms(oneLinuxBuild, { os: "linux", arch: null }); + expect(ranked[0].id).toBe("linux-amd64"); + expect(ranked[0].match).toBe("exact"); +}); + +test("a detected arch that matches neither build for the OS ranks 'os', not 'exact'", () => { + // Shouldn't happen with real data (arch is one of the two this repo + // ships), but a mismatched arch must never fall back to "exact". + const ranked = rankPlatforms(entries, { os: "linux", arch: "arm64" }); + const amd64 = ranked.find((e) => e.id === "linux-amd64")!; + expect(amd64.match).toBe("os"); }); test("no detection leaves the manifest's own order untouched", () => { const ranked = rankPlatforms(entries, { os: null, arch: null }); expect(ranked.map((e) => e.id)).toEqual(entries.map((e) => e.id)); - expect(ranked.some((e) => e.recommended)).toBe(false); + expect(ranked.every((e) => e.match === "none")).toBe(true); }); -test("a detected platform with no matching build (e.g. windows) recommends nothing", () => { +test("a detected platform with no matching build (e.g. windows) matches nothing", () => { const ranked = rankPlatforms(entries, { os: "windows", arch: null }); - expect(ranked.some((e) => e.recommended)).toBe(false); + expect(ranked.every((e) => e.match === "none")).toBe(true); }); test("humanSize formats binary units", () => { @@ -38,3 +58,19 @@ test("humanSize formats binary units", () => { expect(humanSize(2048)).toBe("2.0 KiB"); expect(humanSize(11 * 1024 * 1024)).toBe("11.0 MiB"); }); + +test("parseGpuRendererArch reads Apple Silicon renderer strings", () => { + expect(parseGpuRendererArch("ANGLE (Apple, ANGLE Metal Renderer: Apple M2, Unspecified Version)")).toBe("arm64"); + expect(parseGpuRendererArch("Apple GPU")).toBe("arm64"); +}); + +test("parseGpuRendererArch reads Intel/AMD/Nvidia renderer strings as amd64", () => { + expect(parseGpuRendererArch("ANGLE (Intel, Intel(R) Iris(TM) Plus Graphics OpenGL Engine, OpenGL 4.1)")).toBe("amd64"); + expect(parseGpuRendererArch("AMD Radeon Pro 5500M OpenGL Engine")).toBe("amd64"); + expect(parseGpuRendererArch("NVIDIA GeForce GT 750M OpenGL Engine")).toBe("amd64"); +}); + +test("parseGpuRendererArch treats a masked/generic renderer as unknown", () => { + expect(parseGpuRendererArch("Generic Renderer")).toBeNull(); + expect(parseGpuRendererArch("Mozilla")).toBeNull(); +}); diff --git a/web/src/lib/platform.ts b/web/src/lib/platform.ts index 165fe42..00ed3d6 100644 --- a/web/src/lib/platform.ts +++ b/web/src/lib/platform.ts @@ -26,25 +26,43 @@ type UADataNavigator = Navigator & { export async function detectPlatform(nav: Navigator = navigator): Promise { const uaData = (nav as UADataNavigator).userAgentData; + let os: OS | null; + let arch: Arch | null = null; + if (uaData?.platform) { - const os = mapPlatformString(uaData.platform); - let arch: Arch | null = null; + os = mapPlatformString(uaData.platform); try { + // Chrome deliberately reports the real CPU architecture here even + // when the browser itself is running under Rosetta on Apple Silicon + // (this was a fix Google shipped specifically so download pages could + // recommend the right build) — so this is trustworthy where it's + // available, unlike navigator.platform/userAgent below. const hints = await uaData.getHighEntropyValues?.(["architecture", "bitness"]); arch = mapArchitecture(hints?.architecture); } catch { // Chromium can reject this (permission policy, older builds) — - // falling back to an OS-only match is still useful. + // falling back to an OS-only match, or the WebGL guess below, is + // still useful. } - return { os, arch }; + } else { + // Firefox and Safari carry no userAgentData at all, so this is OS-only + // from here — and deliberately so for arch: every browser lacking + // userAgentData reports "MacIntel" for navigator.platform on Apple + // Silicon too, so guessing an arch from IT would be confidently wrong + // as often as right. + os = mapPlatformString(nav.platform) ?? mapPlatformString(nav.userAgent); + } + + // The one place a further guess is worth making: userAgentData doesn't + // exist in Safari/Firefox at all, and can occasionally come back with no + // architecture hint even in Chromium. See detectMacArchFromWebGL's own + // comment for why this is trustworthy enough to try, and why it's + // Mac-only. + if (os === "darwin" && arch === null) { + arch = detectMacArchFromWebGL(); } - // Firefox and Safari carry no userAgentData at all, so this is OS-only — - // and deliberately so for arch: every browser lacking userAgentData - // reports "MacIntel" for navigator.platform on Apple Silicon too, so - // guessing an arch from it would be confidently wrong as often as right. - const os = mapPlatformString(nav.platform) ?? mapPlatformString(nav.userAgent); - return { os, arch: null }; + return { os, arch }; } function mapPlatformString(s: string | undefined): OS | null { @@ -64,6 +82,46 @@ function mapArchitecture(architecture: string | undefined): Arch | null { return null; } +// detectMacArchFromWebGL guesses Apple Silicon vs Intel from the WebGL +// renderer string — the one signal left once userAgentData is unavailable +// (Safari, Firefox) or declines to say (a Chromium build that rejected the +// high-entropy hint). ANGLE reports the actual GPU model, and every Mac's +// GPU name gives its CPU family away for free: Apple Silicon's integrated +// GPU always identifies as "Apple M"/"Apple GPU", while an Intel Mac +// reports an Intel/AMD/Nvidia GPU model instead (external GPUs on Intel +// Macs existed but are vanishingly rare next to the integrated case, and a +// wrong guess here is cosmetic, never a broken link — see this file's own +// header). Returns null — not a guess — when the renderer string is masked +// to something generic, which fingerprinting-resistant configurations +// (Firefox's privacy.resistFingerprinting, some content blockers) do +// deliberately; a masked string is treated as "still unknown" rather than +// guessed. +function detectMacArchFromWebGL(doc: Document | undefined = typeof document === "undefined" ? undefined : document): Arch | null { + if (!doc) return null; + try { + const canvas = doc.createElement("canvas"); + const gl = (canvas.getContext("webgl") ?? + canvas.getContext("experimental-webgl")) as WebGLRenderingContext | null; + if (!gl) return null; + const info = gl.getExtension("WEBGL_debug_renderer_info"); + if (!info) return null; + const renderer = gl.getParameter(info.UNMASKED_RENDERER_WEBGL); + return parseGpuRendererArch(String(renderer)); + } catch { + return null; + } +} + +// The string-matching half of detectMacArchFromWebGL, pulled out because +// it's the only part worth (or able to be) unit tested — the WebGL calls +// around it need a real browser. +export function parseGpuRendererArch(renderer: string): Arch | null { + const lower = renderer.toLowerCase(); + if (lower.includes("apple m") || lower.includes("apple gpu")) return "arm64"; + if (lower.includes("intel") || lower.includes("amd") || lower.includes("nvidia")) return "amd64"; + return null; +} + export type CliEntry = { id: string; display: string; @@ -74,26 +132,38 @@ export type CliEntry = { size: number; }; -export type RankedEntry = CliEntry & { recommended: boolean }; +// "exact": os and arch both matched — confident enough to badge as +// recommended. "os": the visitor's OS matched but arch didn't (or +// couldn't be narrowed further) — worth sorting to the top, never worth +// badging as though it were the one true answer, especially since more +// than one entry can carry this tier at once (e.g. both macOS builds when +// arch truly can't be told apart). "none": unrelated to the visitor. +export type Match = "exact" | "os" | "none"; +export type RankedEntry = CliEntry & { match: Match }; -// rankPlatforms puts the visitor's own platform first (exact os+arch match, -// or an os-only match when arch couldn't be detected) and marks it -// `recommended`; every other entry follows in the order the release -// manifest listed them, untouched. Ties (no detection at all) leave the -// list exactly as given, so it degrades to "sorted the way the release -// pipeline built it" rather than to any particular guess. +// rankPlatforms puts the visitor's own platform first and tags how +// confident that match is; every other entry follows in the order the +// release manifest listed them, untouched. Ties (no detection at all) +// leave the list exactly as given, so it degrades to "sorted the way the +// release pipeline built it" rather than to any particular guess. export function rankPlatforms(entries: CliEntry[], detected: DetectedPlatform): RankedEntry[] { - const score = (e: CliEntry): number => { - if (!detected.os || e.os !== detected.os) return 0; - if (detected.arch && e.arch === detected.arch) return 2; - if (detected.arch && e.arch !== detected.arch) return 1; - return 2; // os matched, arch undetected — the best guess available + const osMatchCount = entries.filter((e) => detected.os && e.os === detected.os).length; + + const match = (e: CliEntry): Match => { + if (!detected.os || e.os !== detected.os) return "none"; + if (detected.arch) return e.arch === detected.arch ? "exact" : "os"; + // Arch undetected: a single build for the OS is unambiguous — it's the + // only one that could possibly be theirs — but with several, singling + // one out would be a coin flip dressed up as a recommendation. + return osMatchCount === 1 ? "exact" : "os"; }; + const rank: Record = { exact: 2, os: 1, none: 0 }; + return entries - .map((e, i) => ({ e, i, s: score(e) })) - .sort((a, b) => b.s - a.s || a.i - b.i) - .map(({ e, s }) => ({ ...e, recommended: s === 2 })); + .map((e, i) => ({ e, i, m: match(e) })) + .sort((a, b) => rank[b.m] - rank[a.m] || a.i - b.i) + .map(({ e, m }) => ({ ...e, match: m })); } export function humanSize(bytes: number): string { diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 0ab2515..6c63f5a 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -12,27 +12,46 @@

Large file storage for the AT Protocol.

IPFS with an XRPC upload and admin API

-

- - - Docker: atcr.io/atfs.dev/atfs - - - - Single Board Computers - - - - atfs CLI: upload & manage from a terminal - - Device not booting? See the setup docs. -

+
+
+

Server

+

+ + + Docker: atcr.io/atfs.dev/atfs + + + + Single Board Computers + +

+
+ +
+

CLI

+

+ + + atfs CLI: upload & manage from a terminal + + + + Manage UI: administer an instance in the browser + +

+
+
@@ -76,9 +95,27 @@ color: var(--text-dim); } + .run-groups { + display: flex; + flex-direction: column; + gap: 1.4rem; + } + + /* Same small-caps vocabulary thead th uses in atfs.css, repurposed as a + section label rather than a table header. */ + .run-group-heading { + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-dim); + margin: 0 0 0.6rem; + } + .runs { - /* a single auto-sized grid column, centred as a block, makes both - rows share one left edge instead of each centring independently */ + /* a single auto-sized grid column, centred as a block, makes every + row in the group share one left edge instead of each centring + independently */ display: grid; justify-content: center; gap: 0.9rem; @@ -112,14 +149,14 @@ } /* Unlike the Docker/Pi marks (real brand artwork, fixed brand colors), - the CLI mark is atfs's own invention, so it uses the page's own theme - tokens rather than hardcoded hex, and follows light/dark like - everything else on the page. */ - .cli-mark rect { + atfs's own two CLI-group icons use the page's own theme tokens rather + than hardcoded hex, so they follow light/dark like everything else on + the page. */ + .house-mark rect { fill: var(--accent-strong); } - .cli-mark path, - .cli-mark line { + .house-mark path, + .house-mark line { stroke: var(--on-accent); } @@ -138,29 +175,6 @@ color: var(--text-dim); } - .run-note { - margin: -0.35rem 0 0; - /* Indents to sit under the rows' label text, not their icons — - i.e. past .run's fixed 1.8em icon column plus its gap. Written - in rem, not em: this rule's own font-size (0.85rem) differs from - the row's (1.05rem), so an em here would resolve against the - wrong one. 1.8em of a 1.05rem row is 1.89rem. */ - padding-left: calc(1.89rem + 0.55rem); - font-size: 0.85rem; - color: var(--text-dim); - } - - .run-note a { - color: inherit; - text-decoration: underline; - text-decoration-color: color-mix(in srgb, var(--text-dim) 50%, transparent); - text-underline-offset: 0.15em; - } - .run-note a:hover { - color: var(--accent-strong); - text-decoration-color: currentColor; - } - .run .run-label { text-align: left; } diff --git a/web/src/routes/downloads/cli/+page.svelte b/web/src/routes/downloads/cli/+page.svelte index 55b0cb7..b0e2f0c 100644 --- a/web/src/routes/downloads/cli/+page.svelte +++ b/web/src/routes/downloads/cli/+page.svelte @@ -1,25 +1,8 @@ + + + atfs — manage + + + +
+

Manage your instances

+ + {#if auth.status === "loading"} +

Loading…

+ {:else if auth.status === "signed-in"} + {#if redirecting} +

Taking you to your instance…

+ {:else if servers === null} +

Checking which instances @{auth.handle} can manage…

+ {:else if loadError} +

Couldn’t check which instances you manage: {loadError}

+ {:else if servers.length === 0} +
+

+ @{auth.handle} doesn’t manage any atfs instances yet. + Set one up, or ask an existing operator to + add your DID to their instance’s allowlist. +

+
+ {:else} +

Signed in as @{auth.handle}. Pick an instance:

+ + {/if} + {:else} +

+ Sign in with the atproto account you set up an instance with, or that + an operator added to their instance’s allowlist. +

+ + {#if signInError}

{signInError}

{/if} + {#if auth.status === "unavailable" && auth.error}

{auth.error}

{/if} + {/if} +
+ + -- 2.51.2