diff --git a/CLAUDE.md b/CLAUDE.md
index 87ef68f4..b5d30889 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -65,7 +65,8 @@ how it gets built. Concretely:
`downloads/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`,
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `ratelimit`, `bodylimit`, `progress`, `collation`,
`security`, `logs`, `logsink`, `diagnostics`, `debugsession`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
-Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → module
+Frontend: `main.tsx` provider tree → `App.tsx` shell (a shared `/watch` link boots from its own
+entry, `watch.html` → `watchMain.tsx`, held app-free by a contract test) → `moduleRegistry` route-chunks → module
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That
boundary is one-way and **lint-enforced**, not just described: a platform component may not import a
diff --git a/backend/app/downloads/og.py b/backend/app/downloads/og.py
index 29d1e9aa..2bc7bf47 100644
--- a/backend/app/downloads/og.py
+++ b/backend/app/downloads/og.py
@@ -147,13 +147,14 @@ def _watch_tags(token: str, db: Session) -> str | None:
return "\n ".join(tags)
-def render_watch_html(token: str, index_html: Path, db: Session) -> str | None:
- """Return index.html with the OG block replaced for this watch token, or None to serve the
- generic page unchanged (invalid/expired/not-ready link, or a template without the markers)."""
+def render_watch_html(token: str, page_html: Path, db: Session) -> str | None:
+ """Return the watch page (watch.html) with the OG block replaced for this token, or None to
+ serve the generic page unchanged (invalid/expired/not-ready link, or a template without the
+ markers)."""
tags = _watch_tags(token, db)
if tags is None:
return None
- text = index_html.read_text(encoding="utf-8")
+ text = page_html.read_text(encoding="utf-8")
if "" not in text:
return None
# A function replacement avoids re.sub interpreting backslashes/group refs in `tags`.
diff --git a/backend/app/main.py b/backend/app/main.py
index 14a2dafa..e06746b9 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -339,6 +339,8 @@ STATIC_DIR = Path(__file__).parent / "static_spa"
# stay cached but forces revalidation (cheap 304 via the FileResponse ETag) on every load. The
# hashed bundles under /assets can cache forever (a new build changes their filename).
INDEX_HTML = STATIC_DIR / "index.html"
+# A public share link's own page: the player alone, without the app's bundle (frontend/watch.html).
+WATCH_HTML = STATIC_DIR / "watch.html"
INDEX_HEADERS = {"Cache-Control": "no-cache"}
app.mount(
"/assets",
@@ -354,16 +356,16 @@ async def index() -> FileResponse:
@app.get("/watch/{token}")
async def watch_page(token: str):
- """Serve the SPA for a public share link, but with per-video Open Graph tags injected so the
+ """Serve the public player page for a share link, with per-video Open Graph tags injected so the
link unfurls richly (title/channel/thumbnail) in Messenger/social crawlers. A real browser
- ignores the extra tags and hydrates WatchPage as usual; invalid/expired/password links fall
- back to the generic card."""
+ ignores the extra tags and boots WatchPage; invalid/expired/password links fall back to the
+ generic card."""
from app.downloads import og
with SessionLocal() as db:
- rendered = og.render_watch_html(token, INDEX_HTML, db)
+ rendered = og.render_watch_html(token, WATCH_HTML, db)
if rendered is None:
- return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)
+ return FileResponse(WATCH_HTML, headers=INDEX_HEADERS)
return HTMLResponse(rendered, headers=INDEX_HEADERS)
@@ -381,4 +383,8 @@ async def spa_fallback(full_path: str) -> FileResponse:
# Real SPA-root assets (welcome images, favicon, robots.txt) rarely change and have
# stable names, so a moderate cache is safe and satisfies the cache-lifetime audit.
return FileResponse(candidate, headers={"Cache-Control": "public, max-age=604800"})
+ # A /watch/ path the route above does not match (a trailing segment, no token) is still the
+ # player's: WatchPage reads the token off the path and says when a link is gone.
+ if full_path.startswith("watch/"):
+ return FileResponse(WATCH_HTML, headers=INDEX_HEADERS)
return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)
diff --git a/backend/tests/test_spa_entries.py b/backend/tests/test_spa_entries.py
new file mode 100644
index 00000000..7fab4d24
--- /dev/null
+++ b/backend/tests/test_spa_entries.py
@@ -0,0 +1,67 @@
+"""Which HTML page the server hands a path (main.py), DB-lane.
+
+The frontend builds two entries: the app (index.html) and a shared link's player alone (watch.html), so
+the friend a /watch link was sent to never downloads the app. Every /watch/ path must get the player's
+page — with the link's Open Graph tags when the token names a playable link — and nothing else may.
+"""
+
+import pytest
+
+from app import main
+from app.config import settings
+from app.downloads import probe as probemod
+
+_PAGE = """
+
+"""
+
+
+@pytest.fixture
+def pages(tmp_path, monkeypatch):
+ index, watch = tmp_path / "index.html", tmp_path / "watch.html"
+ index.write_text(_PAGE.format(entry="app"), encoding="utf-8")
+ watch.write_text(_PAGE.format(entry="watch"), encoding="utf-8")
+ monkeypatch.setattr(main, "INDEX_HTML", index)
+ monkeypatch.setattr(main, "WATCH_HTML", watch)
+
+
+@pytest.fixture
+def link_token(as_user, make_user, tmp_path, monkeypatch, upload_headers):
+ monkeypatch.setattr(settings, "download_root", str(tmp_path / "media"))
+ video = {"codec_type": "video", "codec_name": "h264", "pix_fmt": "yuv420p"}
+ audio = {"codec_type": "audio", "codec_name": "aac"}
+ data = {"streams": [video, audio], "format": {"format_name": "mov,mp4", "duration": "5"}}
+ monkeypatch.setattr(probemod, "probe_json", lambda _p: data)
+ owner = as_user(make_user())
+ job = owner.post(
+ "/api/downloads/upload",
+ content=b"fake-video",
+ headers=upload_headers("clip.mp4", display_name="Holiday clip"),
+ )
+ assert job.status_code == 200, job.text
+ link = owner.post(f"/api/downloads/{job.json()['id']}/links", json={})
+ assert link.status_code == 200, link.text
+ return link.json()["token"]
+
+
+@pytest.mark.parametrize("path", ["/", "/feed", "/downloads/42"])
+def test_an_app_path_gets_the_app(pages, client, path):
+ r = client.get(path)
+ assert r.status_code == 200
+ assert "/assets/app.js" in r.text
+
+
+@pytest.mark.parametrize("path", ["/watch/no-such-token", "/watch/no-such-token/extra"])
+def test_every_watch_path_gets_the_player_alone(pages, client, path):
+ r = client.get(path)
+ assert r.status_code == 200
+ assert "/assets/watch.js" in r.text and "/assets/app.js" not in r.text
+ assert r.headers["cache-control"] == "no-cache"
+
+
+def test_a_live_link_unfurls_from_the_players_page(pages, client, link_token):
+ r = client.get(f"/watch/{link_token}")
+ assert r.status_code == 200
+ assert "/assets/watch.js" in r.text
+ assert 'content="Holiday clip"' in r.text
+ assert '' not in r.text # replaced, not appended
diff --git a/frontend/src/components/AdminUsers.dom.test.tsx b/frontend/src/components/AdminUsers.dom.test.tsx
index ef024bcf..7dc77a69 100644
--- a/frontend/src/components/AdminUsers.dom.test.tsx
+++ b/frontend/src/components/AdminUsers.dom.test.tsx
@@ -91,7 +91,7 @@ describe("the last sign-in column", () => {
,
);
- expect(await screen.findByText(relativeTime(at, "en"))).toBeTruthy();
+ expect(await screen.findByText(relativeTime(at, "en", i18n.t))).toBeTruthy();
expect(screen.getAllByText("—")).toHaveLength(1);
});
});
diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx
index da8953c7..5f8d3689 100644
--- a/frontend/src/components/AdminUsers.tsx
+++ b/frontend/src/components/AdminUsers.tsx
@@ -316,7 +316,7 @@ function UsersRoles({ me }: { me: Me }) {
}
>
- {u.last_login_at ? relativeTime(u.last_login_at, lang) : "—"}
+ {u.last_login_at ? relativeTime(u.last_login_at, lang, t) : "—"}
CrashCopy;
+ /** What the entry does beyond the log line (the app keeps a fatal notice in its center). */
+ onCrash?: () => void;
+}
+interface State {
+ crashed: boolean;
+}
+
+// Catches render-time crashes anywhere in the tree, logs them and offers a reload. Free of the app's
+// i18n and notification store, so the public /watch entry wraps its page in it without loading
+// either; `ErrorBoundary` is the app's wrapper around it.
+export class CrashBoundary extends Component {
+ state: State = { crashed: false };
+
+ static getDerivedStateFromError(): State {
+ return { crashed: true };
+ }
+
+ componentDidCatch(error: Error, info: ErrorInfo) {
+ // The tab is already reloading onto a newer build (lib/staleBuild): this is the chunk it could not
+ // load, not a crash — no fatal notice, which would outlive the reload.
+ if (reloadingForNewBuild()) return;
+ log.fatal("ui.crash", "the app crashed while rendering", {
+ ...errorAttrs(error),
+ ...(info.componentStack ? { component_stack: info.componentStack } : {}),
+ });
+ this.props.onCrash?.();
+ }
+
+ render() {
+ if (this.state.crashed) {
+ if (reloadingForNewBuild()) return null;
+ const copy = this.props.copy();
+ return (
+
+
+
{copy.title}
+
{copy.subtitle}
+
+
+
+ );
+ }
+ return this.props.children;
+ }
+}
diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx
index ce94e769..727e808d 100644
--- a/frontend/src/components/ErrorBoundary.tsx
+++ b/frontend/src/components/ErrorBoundary.tsx
@@ -1,61 +1,30 @@
-import { Component, type ErrorInfo, type ReactNode } from "react";
+import type { ReactNode } from "react";
import i18n from "../i18n";
import { notify } from "../lib/notifications";
-import { errorAttrs, log } from "../lib/log";
-import { reloadingForNewBuild } from "../lib/staleBuild";
-import { Button } from "./ui/Button";
+import { CrashBoundary, type CrashCopy } from "./CrashBoundary";
-interface Props {
- children: ReactNode;
-}
-interface State {
- crashed: boolean;
-}
+const copy = (): CrashCopy => ({
+ title: i18n.t("errors.boundary.title"),
+ subtitle: i18n.t("errors.boundary.subtitle"),
+ reload: i18n.t("errors.boundary.reload"),
+});
-// Catches render-time crashes anywhere in the tree, logs a fatal notification (kept
-// in the center across the reload) and offers a recovery action.
-export default class ErrorBoundary extends Component {
- state: State = { crashed: false };
+// A fatal notification, kept in the center across the reload.
+const onCrash = () =>
+ notify({
+ level: "fatal",
+ title: i18n.t("errors.boundary.notifTitle"),
+ // Never the raw `error.message`: a JS engine's English ("Cannot read properties of undefined…")
+ // is no message for the reader — the `ui.crash` log carries it for diagnosis.
+ message: i18n.t("errors.boundary.notifMessage"),
+ requiresInteraction: true,
+ });
- static getDerivedStateFromError(): State {
- return { crashed: true };
- }
-
- componentDidCatch(error: Error, info: ErrorInfo) {
- // The tab is already reloading onto a newer build (lib/staleBuild): this is the chunk it could not
- // load, not a crash — no fatal notice, which would outlive the reload.
- if (reloadingForNewBuild()) return;
- log.fatal("ui.crash", "the app crashed while rendering", {
- ...errorAttrs(error),
- ...(info.componentStack ? { component_stack: info.componentStack } : {}),
- });
- notify({
- level: "fatal",
- title: i18n.t("errors.boundary.notifTitle"),
- // Never the raw `error.message`: a JS engine's English ("Cannot read properties of undefined…")
- // is no message for the reader — the `ui.crash` log above carries it for diagnosis.
- message: i18n.t("errors.boundary.notifMessage"),
- requiresInteraction: true,
- });
- }
-
- render() {
- if (this.state.crashed) {
- if (reloadingForNewBuild()) return null;
- return (
-