feat(web): fix mobile scaling regressions and ship installable PWA

Mobile fixes (all in the embedded SPA):
- Move the mobile media queries to the bottom of the stylesheet — the
  cinematheque design layer was appended after them, so its base rules
  (header padding, video heights) silently overrode the phone layout by
  source order. A comment now pins the ordering invariant.
- The design layer's uiDrop entry animation (fill-mode:both) permanently
  overrode the sidebar's translateX(-100%), leaving the drawer stuck open
  over the content on phones; the mobile block now disables it.
- Lower the single-column breakpoint 380px -> 350px: it was catching
  360/375px phones and blowing every card up to full width.
- viewport-fit=cover so the env(safe-area-inset-*) paddings actually
  resolve on iPhones; notch-safe padding on header/content/modals.
- 16px inputs on small screens (kills iOS Safari's focus auto-zoom),
  same for the login page's password field.
- Mobile rules for the design-layer components that had none: full-height
  command-palette sheet, clamped stats numerals, coarse-pointer touch
  targets for the custom player (taller scrub track, always-visible
  thumb, no volume slider).
- CSP font-src now allows data: — the embedded base64 woff2 fonts were
  being blocked and silently fell back to system faces.

PWA:
- manifest.webmanifest, minimal service worker, and a new Catacomb arch
  icon set (SVG source + rendered PNGs incl. maskable + apple-touch),
  all include_str!/include_bytes!-embedded like the HTML.
- sw.js only intercepts GET navigations to "/" (network-first, cached
  offline fallback) and the static assets; /api, /ws, /files,
  /music-files, /feed are never touched, and it's served no-store so
  binary upgrades keep propagating.
- Routes + auth_middleware allowlist so the browser can fetch the
  statics pre-login; theme-color meta tracks the active theme's panel.
- tests/api.rs covers the new endpoints incl. the ungated-when-password
  invariant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-07-08 00:04:17 -07:00
parent 52c561b47b
commit 891b1e0d3c
No known key found for this signature in database
11 changed files with 435 additions and 70 deletions

View file

@ -0,0 +1,75 @@
# Mobile scaling fixes + PWA support — design
Date: 2026-07-05. Scope: `src/web_ui/` + `src/web.rs` + `tests/api.rs`.
## Problem
1. **Mobile scaling regressions.** The "private cinematheque" design layer was
appended *after* the `@media(max-width:640px)` / `@media(max-width:380px)`
blocks in `src/web_ui/index.html`. Media queries add no specificity, so the
design layer's base rules (header padding, `.vid-wrap video` max-height, …)
override the mobile rules by source order. The design layer also introduced
whole components with **no** mobile rules at all: the custom player
(`.pl-*`), command palette (`.cp-*`), stats dashboard (`.sx-*`) and
maintenance console (`.mx-*`).
2. The viewport meta lacks `viewport-fit=cover`, so every
`env(safe-area-inset-*)` the CSS relies on evaluates to 0 on iPhones.
3. Text inputs are 13px, which triggers iOS Safari's automatic focus-zoom —
the classic "the page scales itself when I tap search" bug.
4. No PWA support: no manifest, no service worker, no installability, and the
bundled `icon.png` is a YouTube-logo lookalike unsuitable for an installed
app icon.
## Approach chosen
**Mobile fixes (index.html only, no JS-visible id/class changes):**
- Add `viewport-fit=cover` to the viewport meta.
- Move both mobile media-query blocks to the **end** of the stylesheet, after
the design layer, with a comment stating the ordering invariant. This is the
structural fix; alternatives (raising specificity, `!important`) were
rejected as fragile.
- Inside the ≤640px block: bump text inputs/selects to 16px (kills iOS
focus-zoom), and add rules for the new components — full-bleed command
palette with `dvh` sizing, clamped stats-hero numerals, safe-area padding.
- New `@media(pointer:coarse)` block for touch ergonomics independent of
width: taller scrub track, always-visible seek thumb, ≥40px player buttons,
hide the hover-oriented volume slider (phones use hardware volume).
**PWA (additive, all static assets embedded like the existing HTML):**
- `src/web_ui/manifest.webmanifest` — name/short_name Catacomb, `display:
standalone`, `start_url: /`, `id: /`, theme/background `#1a1a2e`, icons
192/512 + maskable 512.
- New Catacomb-branded icon: `src/web_ui/icon.svg` (crimson catacomb arch on
the app's dark palette) rendered to `icon-192.png`, `icon-512.png`,
`icon-maskable-512.png`, `apple-touch-icon.png` (180px, opaque). SVG source
checked in; PNGs regenerated via `rsvg-convert`.
- `src/web_ui/sw.js` — deliberately minimal service worker. Intercepts **only**
GET navigations to `/` (network-first, falling back to a cached copy when
offline) and the static icon/manifest paths (cache-first). It never touches
`/api/*`, `/ws/*`, `/files/*`, `/music-files/*`, or `/feed*` — no interference
with auth, streaming ranges, or WebSockets. Served `no-store` so upgrades
propagate on next load, matching the existing "no stale UI" policy.
- `src/web.rs``include_str!`/`include_bytes!` consts + routes for
`/manifest.webmanifest`, `/sw.js`, `/icons/*`, `/apple-touch-icon.png`;
these paths are allowlisted (GET, static, nothing sensitive) in
`auth_middleware` so the browser can fetch them pre-login.
- `index.html` head: manifest link, `theme-color` meta (kept in sync with the
active theme's `--panel` by `applyTheme`), apple-touch-icon link, iOS
standalone metas; SW registration guarded by `'serviceWorker' in navigator`.
## Testing
- `tests/api.rs`: new test asserting `/manifest.webmanifest`, `/sw.js`, and an
icon route return 200 with sane content types, and that they are reachable
without auth when a password is set (follows the existing curl harness).
- `node --check` on the extracted inline script and on `sw.js`.
- Headless Chromium screenshots at phone viewports (375×812, 360×740) before
and after, against a real `--web` instance.
## Out of scope
Offline caching of library data/media, push notifications, background sync,
replacing `icon.png` used by the desktop `.desktop` entry (flagged for a
follow-up since it's a YouTube trademark lookalike).

View file

@ -1160,7 +1160,7 @@ async fn security_headers(req: Request, next: Next) -> Response {
img-src 'self' data: blob: https:; \
media-src 'self' blob:; \
connect-src 'self'; \
font-src 'self'; \
font-src 'self' data:; \
object-src 'none'; \
base-uri 'self'; \
frame-ancestors 'none'";
@ -1187,6 +1187,19 @@ async fn auth_middleware(
if path == "/api/login" {
return next.run(req).await;
}
// PWA static assets. Browsers fetch the manifest/icons during install
// (not always with credentials) and must reach the service worker script
// to register it from the login page's origin. All of these are
// compile-time constants with nothing user- or library-specific, so
// serving them pre-auth leaks nothing.
if req.method().as_str() == "GET"
&& (path == "/manifest.webmanifest"
|| path == "/sw.js"
|| path == "/apple-touch-icon.png"
|| path.starts_with("/icons/"))
{
return next.run(req).await;
}
if is_authed(&state, req.headers()) {
return next.run(req).await;
}
@ -3303,6 +3316,10 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
let app = Router::new()
.route("/", get(get_index))
.route("/manifest.webmanifest", get(get_manifest))
.route("/sw.js", get(get_sw))
.route("/icons/:name", get(get_icon))
.route("/apple-touch-icon.png", get(get_apple_touch_icon))
.route("/api/library", get(get_library))
.route("/api/progress", get(get_progress))
.route("/ws/progress", get(ws_progress))
@ -3439,3 +3456,60 @@ const LOGIN_HTML: &str = include_str!("web_ui/login.html");
/// Main library UI. Single-page app with embedded styles and JS — see the
/// architecture notes at the top of `web_ui/index.html`.
const HTML_UI: &str = include_str!("web_ui/index.html");
/// PWA assets, embedded like the HTML. The icon PNGs are rendered from
/// `web_ui/icon.svg` — see the comment there for the regen commands.
const MANIFEST: &str = include_str!("web_ui/manifest.webmanifest");
const SW_JS: &str = include_str!("web_ui/sw.js");
const ICON_192: &[u8] = include_bytes!("web_ui/icon-192.png");
const ICON_512: &[u8] = include_bytes!("web_ui/icon-512.png");
const APPLE_TOUCH_ICON: &[u8] = include_bytes!("web_ui/apple-touch-icon.png");
async fn get_manifest() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "application/manifest+json"),
(header::CACHE_CONTROL, "public, max-age=3600"),
],
MANIFEST,
)
}
async fn get_sw() -> impl IntoResponse {
// no-store, same reasoning as the HTML: binary upgrades change the
// embedded worker without changing the URL, and a stale SW would pin
// stale behavior across upgrades.
(
[
(header::CONTENT_TYPE, "text/javascript; charset=utf-8"),
(header::CACHE_CONTROL, "no-store"),
],
SW_JS,
)
}
async fn get_icon(Path(name): Path<String>) -> Response {
let bytes: &'static [u8] = match name.as_str() {
"icon-192.png" => ICON_192,
"icon-512.png" => ICON_512,
_ => return StatusCode::NOT_FOUND.into_response(),
};
(
[
(header::CONTENT_TYPE, "image/png"),
(header::CACHE_CONTROL, "public, max-age=86400"),
],
bytes,
)
.into_response()
}
async fn get_apple_touch_icon() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "image/png"),
(header::CACHE_CONTROL, "public, max-age=86400"),
],
APPLE_TOUCH_ICON,
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

BIN
src/web_ui/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

BIN
src/web_ui/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

31
src/web_ui/icon.svg Normal file
View file

@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!-- Catacomb app icon: a crypt archway sheltering a play wedge, in the
UI's default palette (bg #1a1a2e / accent #e94560). Full-bleed
background with all strokes inside the central maskable safe zone,
so the same art serves purpose:any and purpose:maskable.
Regenerate the PNGs after editing:
rsvg-convert -w 512 -h 512 icon.svg -o icon-512.png
rsvg-convert -w 192 -h 192 icon.svg -o icon-192.png
rsvg-convert -w 180 -h 180 icon.svg -o apple-touch-icon.png -->
<defs>
<radialGradient id="bg" cx="30%" cy="12%" r="130%">
<stop offset="0" stop-color="#272747"/>
<stop offset="1" stop-color="#131322"/>
</radialGradient>
<linearGradient id="arch" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ff5f7d"/>
<stop offset="1" stop-color="#cc2b4d"/>
</linearGradient>
</defs>
<rect width="512" height="512" fill="url(#bg)"/>
<!-- doorway interior: faint accent glow so the opening reads as depth -->
<path d="M192 396 V240 a64 64 0 0 1 128 0 V396 Z" fill="#e94560" opacity="0.16"/>
<!-- arch -->
<path d="M154 396 V240 a102 102 0 0 1 204 0 V396" fill="none"
stroke="url(#arch)" stroke-width="28" stroke-linecap="round"/>
<!-- floor (solid colour: a zero-height bounding box degenerates the
objectBoundingBox gradient and rsvg drops the stroke entirely) -->
<path d="M118 396 H394" stroke="#cc2b4d" stroke-width="28" stroke-linecap="round"/>
<!-- play wedge in the doorway -->
<path d="M233 282 l60 34 -60 34 Z" fill="#ff5f7d"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -1,8 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>Catacomb</title>
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#16213e">
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Catacomb">
<!-- Embedded UI fonts (base64 below, offline-safe — no CDN): Instrument Serif
by Rodrigo Fuenzalida and Hanken Grotesk by Alfredo Marco Pradil, both
under the SIL Open Font License 1.1. -->
@ -167,71 +175,9 @@
.dl-new-flags label{display:flex;align-items:center;gap:4px;cursor:pointer;white-space:nowrap}
.preview-thumb{width:100%;max-width:280px;aspect-ratio:16/9;object-fit:cover;border-radius:4px;background:#000;flex-shrink:0}
.empty{grid-column:1/-1;text-align:center;color:var(--muted);padding:40px;font-size:13px}
@media(max-width:640px){
body{padding-bottom:calc(env(safe-area-inset-bottom) + 4px)}
#menu-btn{display:block}
aside{position:fixed;top:0;left:0;height:100%;z-index:50;transform:translateX(-100%);width:240px}
aside.open{transform:translateX(0)}
#sidebar-overlay.open{display:block}
/* Hide non-essential header chrome on mobile. The remaining
buttons — menu, search, ⟳ rescan, 🎲 shuffle, ⚙ settings —
are the ones users actually reach for. */
#hdr-stats,#sort,#agpl-notice{display:none}
header h1{display:none}
header{padding:6px 8px;gap:6px;position:sticky;top:0;z-index:20}
#search{order:3;flex-basis:100%;width:100%;min-width:0}
header button{min-width:34px;min-height:34px;padding:4px 7px;font-size:14px}
.filters{overflow-x:auto;padding-bottom:2px;scrollbar-width:none}
.filters::-webkit-scrollbar{display:none}
.toolbar{gap:6px}
.toolbar > *{min-width:0}
/* Bigger tap targets on cards. The five-button card-foot reflows
to a comfortable height instead of cramming icons together. */
.grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px}
.card-foot{padding:6px 6px 8px;gap:4px;flex-wrap:wrap}
.card-foot button{font-size:13px;padding:6px 8px;min-height:34px}
.card-foot button:not(.play){min-width:34px}
.card-title{min-height:0}
.card-meta{font-size:10px;line-height:1.3}
/* Modals go full-screen so the cramped 10px page-margin we use on
desktop doesn't eat the iPhone safe area. */
.modal-bg{padding:0}
.modal{max-width:100vw;max-height:100dvh;height:100dvh;border-radius:0;border:none;overflow-y:auto;padding:12px 12px calc(12px + env(safe-area-inset-bottom))}
.modal-body{flex-direction:column;gap:8px;overflow-y:auto}
.modal video{max-height:55vh}
.vctrl{padding:6px;gap:6px}
.vctrl .vseek{order:3;flex-basis:100%;min-width:0}
.vctrl .vtime{margin-left:auto}
.settings-row{flex-direction:column;align-items:flex-start;gap:4px}
.meta-grid{grid-template-columns:1fr}
.det-head{flex-direction:column;gap:4px}
.det-actions{width:100%}
.det-actions button{flex:1 1 calc(50% - 4px)}
#details{max-height:min(45vh,220px);padding:8px 10px}
.chapters-pane{display:none}
.transcript-pane{display:none !important}
section#content{padding:8px}
/* Modal "new download" form stacks vertically on small screens so
the URL + button + quality picker don't squeeze each other. */
.dl-new-row{flex-direction:column;align-items:stretch}
.dl-new-row input[type="url"]{width:100%;min-width:0}
.dl-new-row select{width:100%}
/* Bulk-mode actions stack vertically when active. */
.toolbar{flex-wrap:wrap}
#bulk-actions{flex-wrap:wrap;gap:4px}
#bulk-actions button{padding:6px 8px;min-height:32px;font-size:12px}
/* Job rows: hide the last-line preview on small screens; the
progress bar + label are the only things that fit anyway. */
.job{padding:4px 10px;font-size:11px}
.job .last-line{display:none}
}
@media(max-width:380px){
/* Single-column on very narrow phones. */
.grid{grid-template-columns:1fr;gap:6px}
.card-foot{flex-direction:column;align-items:stretch}
.card-foot button{width:100%;min-width:0}
.det-actions button{flex-basis:100%}
}
/* Mobile layout rules live at the BOTTOM of this stylesheet (after the
design layer) — media queries add no specificity, so they must come
last in source order to override the design layer's base rules. */
/* ───────────────────────────────────────────────────────────────────────
Design layer — "private cinematheque" reskin. Appended on purpose so it
@ -543,6 +489,121 @@
.cp-action-btn{background:color-mix(in srgb,var(--card) 55%,transparent);border:1px solid var(--hair);color:var(--text);padding:5px 10px;border-radius:var(--radius-sm);cursor:pointer;font-size:11px;transition:background .12s,border-color .12s}
.cp-action-btn:hover{background:color-mix(in srgb,var(--accent) 15%,transparent);border-color:var(--accent);color:var(--accent)}
/* ───────────────────────────────────────────────────────────────────────
Mobile layout. MUST stay at the bottom of the stylesheet: media queries
add no specificity, so these only beat the design-layer rules above by
source order. (They used to sit before the design layer, which silently
re-broke header padding, video heights, etc. on phones.)
─────────────────────────────────────────────────────────────────────── */
@media(max-width:640px){
body{padding-bottom:calc(env(safe-area-inset-bottom) + 4px)}
#menu-btn{display:block}
/* animation:none is load-bearing: the design layer's uiDrop entry
animation has fill-mode:both, and animation fill overrides the static
transform below — leaving the drawer stuck open over the content. */
aside{position:fixed;top:0;left:0;height:100%;z-index:50;transform:translateX(-100%);width:240px;padding-left:env(safe-area-inset-left);animation:none}
aside.open{transform:translateX(0)}
#sidebar-overlay.open{display:block}
/* Hide non-essential header chrome on mobile. The remaining
buttons — menu, search, ⟳ rescan, 🎲 shuffle, ⚙ settings —
are the ones users actually reach for. */
#hdr-stats,#sort,#agpl-notice{display:none}
header h1{display:none}
header{padding:6px max(8px,env(safe-area-inset-right)) 6px max(8px,env(safe-area-inset-left));gap:6px;position:sticky;top:0;z-index:20}
#search{order:3;flex-basis:100%;width:100%;min-width:0}
header button{min-width:34px;min-height:34px;padding:4px 7px;font-size:14px}
/* 16px inputs: anything smaller makes iOS Safari zoom the whole page
when the field is focused — the classic mobile "scaling bug". */
#search,#sort,select,textarea,.t-search,.cp-input,
input[type="url"],input[type="text"],input[type="number"],input[type="search"],input[type="password"]{font-size:16px}
.filters{overflow-x:auto;padding-bottom:2px;scrollbar-width:none}
.filters::-webkit-scrollbar{display:none}
.toolbar{gap:6px}
.toolbar > *{min-width:0}
/* Bigger tap targets on cards. The five-button card-foot reflows
to a comfortable height instead of cramming icons together. */
.grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px}
.card-foot{padding:6px 6px 8px;gap:4px;flex-wrap:wrap}
.card-foot button{font-size:13px;padding:6px 8px;min-height:34px}
.card-foot button:not(.play){min-width:34px}
.card-title{min-height:0}
.card-meta{font-size:10px;line-height:1.3}
/* Modals go full-screen so the cramped 10px page-margin we use on
desktop doesn't eat the iPhone safe area. */
.modal-bg{padding:0}
.modal{max-width:100vw;max-height:100dvh;height:100dvh;border-radius:0;border:none;overflow-y:auto;
padding:12px calc(12px + env(safe-area-inset-right)) calc(12px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left))}
.modal-body{flex-direction:column;gap:8px;overflow-y:auto}
.modal video{max-height:55vh}
.modal-hdr h2,.modal h2{font-size:1.2rem}
.vid-wrap{flex:0 0 auto}
.vctrl{padding:6px;gap:6px}
.vctrl .vseek{order:3;flex-basis:100%;min-width:0}
.vctrl .vtime{margin-left:auto}
.settings-row{flex-direction:column;align-items:flex-start;gap:4px}
.meta-grid{grid-template-columns:1fr}
.det-head{flex-direction:column;gap:4px}
.det-actions{width:100%}
.det-actions button{flex:1 1 calc(50% - 4px)}
#details{max-height:min(45vh,220px);padding:8px 10px}
.chapters-pane{display:none}
.transcript-pane{display:none !important}
section#content{padding:8px max(8px,env(safe-area-inset-right)) 8px max(8px,env(safe-area-inset-left))}
/* Modal "new download" form stacks vertically on small screens so
the URL + button + quality picker don't squeeze each other. */
.dl-new-row{flex-direction:column;align-items:stretch}
.dl-new-row input[type="url"]{width:100%;min-width:0}
.dl-new-row select{width:100%}
/* Bulk-mode actions stack vertically when active. */
.toolbar{flex-wrap:wrap}
#bulk-actions{flex-wrap:wrap;gap:4px}
#bulk-actions button{padding:6px 8px;min-height:32px;font-size:12px}
/* Job rows: hide the last-line preview on small screens; the
progress bar + label are the only things that fit anyway. */
.job{padding:4px 10px;font-size:11px}
.job .last-line{display:none}
/* Command palette becomes a full-height sheet instead of a floating
card 60px down a 640px column. */
.cp-container{max-width:none;margin:0;height:100dvh;border-radius:0;border:none;
padding-top:env(safe-area-inset-top)}
.cp-results{flex:1 1 auto;max-height:none}
.cp-input-wrap{padding:12px 14px}
.cp-actions{padding-bottom:calc(10px + env(safe-area-inset-bottom))}
/* Stats dashboard: the display-serif numerals are sized for desktop
and overflow 150px metric cards on phones. */
.sx-hero{grid-column:1/-1}
.sx-hero .sx-v{font-size:2.6rem}
.sx-card .sx-v{font-size:1.7rem}
.sx-cols{gap:4px}
.mx-verdict{font-size:1.6rem}
.mx-hero{padding:12px 14px;gap:12px}
/* Custom player chrome: phones have hardware volume buttons and no
hover — drop the slider, keep the rest. */
.pl-vol{display:none}
.pl-time{font-size:12px;margin:0 3px}
}
@media(max-width:350px){
/* Single-column on genuinely narrow phones (~320px class). Anything
wider — 360px Androids, 375pt iPhones — fits two 150px+ columns;
a 380px breakpoint used to catch ALL of those and blow the cards
up to comical full-width. */
.grid{grid-template-columns:1fr;gap:6px}
.card-foot{flex-direction:column;align-items:stretch}
.card-foot button{width:100%;min-width:0}
.det-actions button{flex-basis:100%}
.sx-metrics{grid-template-columns:1fr 1fr}
}
@media(pointer:coarse){
/* Touch ergonomics regardless of viewport width (tablets too). The
hover-revealed seek thumb is always shown and the scrub strip grows
to a comfortable finger target. */
.pl-controls button{font-size:18px;padding:8px 9px}
.pl-track{height:30px}
.pl-hover{bottom:26px}
.pl-thumb{transform:translateY(-50%) scale(1)}
.vctrl .vbtn{font-size:18px;padding:8px 10px}
.chip,.chip-toggle,.chip-clear{padding:6px 10px}
}
@media(prefers-reduced-motion:reduce){*{animation:none!important;transition-duration:.001ms!important}}
</style>
</head>
@ -2242,7 +2303,11 @@ function switchMetaTab(tab,which){tab.parentElement.querySelectorAll('.meta-tab'
/* ── Settings ───────────────────────────────────────────────────── */
const THEMES=[['dark','Dark'],['light','Light'],['dracula','Dracula'],['trans','Trans'],['emo-nocturnal','Emo: Nocturnal'],['emo-coffin','Emo: Coffin'],['emo-scene-queen','Emo: Scene Queen'],['solarized','Solarized'],['nord','Nord'],['amoled','AMOLED']];
function applyTheme(t){document.body.className=t==='dark'?'':'theme-'+t;localStorage.setItem('theme',t)}
function applyTheme(t){document.body.className=t==='dark'?'':'theme-'+t;localStorage.setItem('theme',t);
// Keep the browser/OS chrome (Android status bar, installed-PWA title bar)
// in step with the active theme's panel colour.
const tc=document.querySelector('meta[name=theme-color]');
if(tc)tc.content=getComputedStyle(document.body).getPropertyValue('--panel').trim()||'#16213e';}
async function logout(){try{await fetch('/api/logout',{method:'POST'});location.reload()}catch{}}
@ -3490,6 +3555,10 @@ loadFilterPresets();
})();
loadRemotes();
loadSourceNotice();
// PWA: register the (deliberately minimal) service worker — see sw.js for
// exactly what it does and does not intercept. Requires a secure context
// (https or localhost); elsewhere navigator.serviceWorker is undefined.
if('serviceWorker' in navigator)navigator.serviceWorker.register('/sw.js').catch(()=>{});
</script>
</body>
</html>

View file

@ -1,8 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>Catacomb — Sign in</title>
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#16213e">
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<style>
/* Standalone page (the SPA's embedded fonts aren't available here), so the
display face is a system serif stack — editorial without extra bytes. The
@ -34,7 +38,7 @@
.tag{font-family:var(--font-display);font-style:italic;font-size:.95rem;color:var(--muted);text-align:center;margin:-4px 0 6px}
.field{position:relative;display:flex;flex-direction:column;gap:0}
input{width:100%;background:color-mix(in srgb,var(--card) 70%,#000);border:1px solid var(--border);color:var(--text);
padding:11px 13px;border-radius:9px;font-size:14px;font-family:var(--font-body);transition:border-color .15s,box-shadow .15s}
padding:11px 13px;border-radius:9px;font-size:16px;font-family:var(--font-body);transition:border-color .15s,box-shadow .15s}
input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 45%,transparent)}
button{background:var(--accent);border:1px solid var(--accent);color:#fff;padding:11px;border-radius:9px;cursor:pointer;
font-size:14px;font-weight:600;font-family:var(--font-body);letter-spacing:.02em;

View file

@ -0,0 +1,17 @@
{
"name": "Catacomb",
"short_name": "Catacomb",
"description": "Self-hosted video archive — browse, stream and download your yt-dlp library.",
"id": "/",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#1a1a2e",
"theme_color": "#16213e",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}

55
src/web_ui/sw.js Normal file
View file

@ -0,0 +1,55 @@
// Catacomb service worker — deliberately minimal.
//
// Scope: installability + an offline shell, nothing else. It intercepts ONLY
// * GET navigations to "/" (network-first, cached copy as offline fallback)
// * the static PWA assets (manifest + icons, cache-first)
// Everything else — /api/*, /ws/*, /files/*, /music-files/*, /feed* — is left
// to the network untouched, so auth, byte-range video streaming and
// WebSockets behave exactly as they do without a service worker.
//
// The shell mirrors the server's no-store policy on "/": every online
// navigation refetches the HTML, and the cache is only consulted when the
// network is unreachable (offline launch of the installed app). Binary
// upgrades therefore reach clients on the next online load, same as before.
const CACHE = 'catacomb-shell-v1';
const STATIC = ['/manifest.webmanifest', '/icons/icon-192.png', '/icons/icon-512.png'];
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(CACHE).then((c) => c.addAll(STATIC)).then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (e) => {
const req = e.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
if (req.mode === 'navigate' && url.pathname === '/') {
e.respondWith(
fetch(req)
.then((res) => {
if (res.ok) {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put('/', copy));
}
return res;
})
.catch(() => caches.match('/').then((hit) => hit || Response.error()))
);
return;
}
if (STATIC.includes(url.pathname)) {
e.respondWith(caches.match(req).then((hit) => hit || fetch(req)));
}
});

View file

@ -473,3 +473,43 @@ fn perceptual_dedup_groups_reencodes() {
assert!(body.contains("bbb"), "group should contain the re-encode: {body}");
assert!(!body.contains("ccc"), "unrelated video must not be grouped: {body}");
}
#[test]
fn pwa_assets_served_and_ungated() {
if !have_curl() { eprintln!("skip: no curl"); return; }
let s = Server::start();
s.wait_ready();
// The SPA advertises the manifest + registers the service worker.
let (_, idx) = s.get("/");
assert!(idx.contains("manifest.webmanifest"), "index links the manifest");
assert!(idx.contains("serviceWorker"), "index registers the service worker");
let (code, body) = s.get("/manifest.webmanifest");
assert_eq!(code, 200);
assert!(body.contains("\"name\": \"Catacomb\""), "manifest body: {body}");
let (code, body) = s.get("/sw.js");
assert_eq!(code, 200);
assert!(body.contains("catacomb-shell"), "sw body: {body}");
assert_eq!(s.get("/icons/icon-192.png").0, 200);
assert_eq!(s.get("/icons/icon-512.png").0, 200);
assert_eq!(s.get("/apple-touch-icon.png").0, 200);
assert_eq!(s.get("/icons/nope.png").0, 404);
// Setting a password must NOT gate the PWA statics: the browser fetches
// the manifest/icons during install without a session, and the login
// page itself links them.
let (code, _) = s.post("/api/settings", &format!(
r#"{{"transcode":false,"scheduler_enabled":false,"scheduler_interval_hours":24,
"max_concurrent":3,"use_bundled_ytdlp":false,"use_pot_provider":false,
"subtitles_enabled":false,"subtitles_auto":false,"subtitles_embed":false,
"subtitle_langs":"","subtitle_format":"","youtube_player_clients":"",
"sponsorblock_mode":"mark","convert_mode":"","convert_crf":23,"convert_preset":"",
"convert_audio_format":"","convert_keep_original":false,
"new_download_password":"hunter2"}}"#));
assert_eq!(code, 200);
assert_eq!(s.get("/api/library").0, 401, "API must be gated once a password is set");
assert_eq!(s.get("/manifest.webmanifest").0, 200, "manifest stays public");
assert_eq!(s.get("/sw.js").0, 200, "service worker stays public");
assert_eq!(s.get("/icons/icon-512.png").0, 200, "icons stay public");
}