ARRFLIX/docs/10-spa-runtime-shim.md
s8n d41aaa04fd shim: nukeSettings() drops drawer Settings link for non-admins
CSS selectors in CustomCss (a[href*=mypreferencesmenu], :has(...) wrappers)
weren't reliably hiding the entry — bundle renders it via MUI ListItemButton
+ React Router NavLink and the rendered DOM didn't match the wrapper rules.

Add nukeSettings() to the runtime shim: queries any
a[href*=mypreferencesmenu] / [to*=mypreferencesmenu], walks up to closest
li/.MuiListItem-root/[role=menuitem] and sets display:none. Wired into
start(), a new MutationObserver on document.body, and the existing 1s
setInterval. CustomCss rules left in place as belt-and-braces.

Doc: extend 10-spa-runtime-shim.md with the diagnosis, the bind-mount inode
gotcha (single-file binds + os.replace orphans the container's view), and
the nsenter-based recovery path.
2026-05-08 03:51:48 +01:00

15 KiB

10 - SPA Runtime Branding Shim

Why the static <title>ARRFLIX</title> patch wasn't enough, what the shim does, and how to extend it when Jellyfin starts overwriting more state on upgrade.

Last verified: 2026-05-08 against Jellyfin 10.10.3 web bundle.


TL;DR

/web/index.html already says <title>ARRFLIX</title> and embeds our logo as the favicon (data URL). The browser tab still showed "Jellyfin" + the Jellyfin teal triangle because Jellyfin's SPA overwrites document.title at runtime as it hydrates. Two compounding effects:

  1. SPA runtime overwrite (primary cause). Bundle code does document.title = d.Ay.translateHtml(document.title ...) and per-route updates via LibraryMenu.setTitle / Page.setTitle. Whatever the static <title> is, the SPA will replace it on first hydrate and again on every navigation.
  2. Service worker churn (secondary). serviceworker.js registers and calls self.clients.claim(). The shipped SW does NOT actually cache index.html (it's a notification-only worker), but its presence still pins old clients to whatever they had at first paint. Killing the SW once forces a clean reload.

Fix: a tiny self-contained shim in <head>, INLINE, BEFORE the bundle scripts. It enforces the title and favicon both on hydrate and on every later DOM mutation.


What the shim does

Lives in web-overrides/index.html between <!-- ARRFLIX-SHIM-BEGIN --> / -END markers. Insertion is idempotent via bin/inject-shim.py (re-running the script REPLACES the existing block instead of stacking duplicates).

Behaviour:

Step When What
1 Page parse <script> runs first thing inside <head>, before bundle.
2 DOM ready lockTitle() strips/replaces "Jellyfin"; lockFavicon() re-pins icon hrefs to the data URL already in the page.
3 Mutation A MutationObserver on document.head re-runs both lockers when the SPA tries to change <title> text or any <link rel*="icon"> href.
4 Interval A 1s setInterval is the safety net for late-binding navigations / route changes that bypass the head observer.
5 Once serviceWorker.getRegistrations() finds anything pointing at serviceworker.js and calls r.unregister() so old clients reload fresh. caches.keys() is also flushed.
6 Drawer nukeSettings() finds any a[href*="mypreferencesmenu"] / [to*="mypreferencesmenu"] node, walks up to the closest li / .MuiListItem-root / [role="menuitem"] wrapper and sets display:none. Wired into start(), the head observer body-twin, and the 1s setInterval.

The shim does NOT re-encode the logo. It reads the existing data:image/png;base64,... href from the <link rel="shortcut icon"> already in the page. So there's exactly one copy of the logo data URL in the file.


Files in play

/tmp/ARRFLIX/
  bin/inject-shim.py          # idempotent injector; the source of truth for shim content
  web-overrides/index.html    # bind-mounted to /jellyfin/jellyfin-web/index.html in the container
  docs/10-spa-runtime-shim.md # this file

Server bind-mount (compose):

volumes:
  - /opt/docker/jellyfin/web-overrides/index.html:/jellyfin/jellyfin-web/index.html:ro

The container reads the bind-mounted file fresh on each request - no docker restart needed when you re-deploy index.html.


Deploying changes

# 1. Edit bin/inject-shim.py (NOT index.html directly - the script is the source of truth)
# 2. Re-run injector locally
python3 /tmp/ARRFLIX/bin/inject-shim.py

# 3. Copy to nullstone
scp /tmp/ARRFLIX/web-overrides/index.html user@192.168.0.100:/opt/docker/jellyfin/web-overrides/index.html

# 4. Verify
curl -ks https://arrflix.s8n.ru/web/index.html | grep -oE "ARRFLIX-SHIM-(BEGIN|END)"
curl -ks https://arrflix.s8n.ru/web/index.html | grep -oE "<title>[^<]*</title>"

No container recreate is needed; the file is read on every HTTP request.

Single-file bind mount inode gotcha

The compose volume binds a SINGLE file (not a directory). Docker resolves the bind once at container start and pins to that inode. If your editor or a python os.replace() writes via temp-file-and-rename, the canonical path on the host now points to a NEW inode while the container still holds the OLD inode (visible as Links: 0 in stat inside the container, and as a //deleted suffix in /proc/<pid>/mountinfo on the host).

Symptom: you scp a new file, the host shows the new content, but curl -ks https://arrflix.s8n.ru/web/index.html still returns the OLD content.

Two safe fixes:

  1. In-place truncate (preferred). Edit with cat > /opt/.../index.html redirection or truncate -s 0 then cat >>. This reuses the existing inode, so the bind mount keeps showing the new content. The scp command in step 3 above DOES truncate-in-place (not rename), so the standard workflow is safe.

  2. Re-bind via nsenter (if you accidentally orphaned the inode). Requires privileged container + nsenter into the jellyfin mount namespace:

    # Copy current host file into the container's writable /tmp first
    docker cp /opt/docker/jellyfin/web-overrides/index.html \
        jellyfin:/tmp/arrflix-index.html
    CPID=$(docker inspect jellyfin --format "{{.State.Pid}}")
    docker run --rm --privileged --userns=host --pid=host -v /:/host \
        -e CPID=$CPID debian:stable-slim sh -c '
          nsenter -t $CPID -m -p -- umount /jellyfin/jellyfin-web/index.html
          nsenter -t $CPID -m -p -- mount --bind -o ro \
            /tmp/arrflix-index.html /jellyfin/jellyfin-web/index.html
        '
    

    This relies on the docker-group sudo-bypass trick. The new bind survives until the next container restart, after which Docker re-resolves from the host path natively.


First-deploy: forcing existing clients to reload

The shim's r.unregister() block runs unconditionally. The first time a user with an old SW + old index.html cached visits the site after this deploy:

  1. They get the new index.html (bind-mount serves it).
  2. The shim runs, unregisters the SW, flushes caches.
  3. The current page still shows the old behaviour (because the SW already intercepted this navigation). They need ONE hard reload (Ctrl+Shift+R or Cmd+Shift+R) to get a fully clean session.
  4. Subsequent visits are clean.

If you want to STOP unregistering the SW after the wide deploy (e.g. to let Jellyfin reinstate notification-click handling), edit the script section in bin/inject-shim.py:

if ('serviceWorker' in navigator) {
  // disabled after 2026-05-08 wide rollout
  // navigator.serviceWorker.getRegistrations().then(...);
}

Then re-run the injector and redeploy. There is no harm in leaving it on - the shipped Jellyfin SW only handles notificationclick, which we don't use on a private invite-only service.


Why the JS shim handles drawer Settings (not CustomCss)

The CustomCss block in /Branding/Configuration already includes belt-and-braces selectors for hiding the drawer Settings entry:

a[href*="mypreferencesmenu"],
li:has(> a[href*="mypreferencesmenu"]),
.MuiListItem-root:has(a[href*="mypreferencesmenu"]),
.MuiListItemButton-root:has(a[href*="mypreferencesmenu"]),
[to="/mypreferencesmenu.html"],
.MuiListItem-root:has([to="/mypreferencesmenu.html"]) { display: none !important; }

In testing as a non-admin user (marco), the entry STILL rendered in the drawer despite all of those rules. Root cause investigation:

  1. The bundle source (main.jellyfin.bundle.js) renders the Settings link as (0,a.jsxs)(T.A,{component:C.N_,to:"/mypreferencesmenu.html",...}) where T.A is MUI ListItemButton and C.N_ is React Router NavLink.
  2. NavLink in hash-router mode renders <a href="#/mypreferencesmenu.html">.
  3. The a[href*="mypreferencesmenu"] selector SHOULD match. It does at the link level, but MUI's ListItemButton puts the click target on the wrapping li which is what the user actually sees as the drawer row.
  4. The :has() selectors targeting that wrapper depend on browser support for CSS :has() (Chromium 105+, Firefox 121+, Safari 15.4+) AND on the bundle's class names matching .MuiListItem-root — which the production build may minify or restructure across MUI versions.
  5. CustomCss is fetched AFTER the SPA hydrates (see "Pre-bundle critical-path styles" above). Even when matching, there's a brief flash where the entry is visible.

The JS shim's nukeSettings() sidesteps all of that. It runs in <head> before the bundle, on every body mutation, and once per second. It walks up via closest() so it finds the wrapper regardless of MUI class minification, and it doesn't rely on :has() browser support.

The CustomCss rules are kept in place as belt; the JS shim is the suspenders. Both the server-side EnableUserPreferenceAccess=false policy AND the drawer hide are needed: the policy stops /mypreferencesmenu.html from rendering its controls, the shim stops the link from showing in the first place.


Caveats

  • Owner's first browser session must hard-reload once after deploy to evict the previously-registered service worker. Subsequent reloads, and all first-time visitors, get the clean experience.
  • CustomCss (logo replacement on the in-app drawer) is owned by a sibling agent / /Branding/Configuration. The shim does NOT touch CustomCss. If the in-app drawer logo regresses, that's the CSS branch, not this one.
  • The shim must remain self-contained. No external src=. If it ever needs more code, add it to the IIFE in bin/inject-shim.py and re-run.
  • First-paint flash. Because the SPA still loads its own bundle, you may briefly see "Jellyfin" in the tab title before the observer kicks in. Sub-100ms on a fast connection - acceptable. For the colour flash (dark blue / grey before Cineplex CSS arrives), see "Pre-bundle critical-path styles" below.

Pre-bundle critical-path styles

A second inline block — a <style> tag — sits immediately AFTER <head> and BEFORE the shim <script>. It exists to kill the ~500ms-1s flash of Jellyfin default chrome (dark blue + grey + MUI blue submit button) that was visible on first paint at https://arrflix.s8n.ru before the Cineplex @import from /Branding/Configuration (CustomCss) finished arriving.

Why CustomCss alone is too slow

CustomCss is fetched via the SPA bundle's call to /Branding/Configuration — i.e. AFTER the JS bundle parses, executes, and hydrates. So the network sequence is:

  1. HTML parses → first paint uses Jellyfin built-in CSS (dark blue / grey).
  2. JS bundle downloads, parses, executes.
  3. Bundle calls /Branding/Configuration, gets CustomCss body.
  4. CustomCss does @import url("...jsdelivr.net/...cineplex.css").
  5. jsDelivr round-trip → Cineplex CSS arrives → re-paint to ARRFLIX brand.

Steps 1-4 are dead time. The inline <style> runs at step 1 and pre-paints the black background + Netflix-red submit button so the gap is invisible.

What's in the block

Lives between <head> and the <script> shim. Contents:

  • :root overrides for --primary-background-color / --background-color to #000000.
  • html, body, .preload, .skinBody, .skinHeader, #reactRoot, .mainAnimatedPages forced to black bg / white text.
  • .raised, .button-submit, .emby-button[type=submit], button[type=submit] forced to Netflix red #E50914 so the login submit button doesn't flash MUI blue before the bundle skins it.

It is INTENTIONALLY tiny. It only handles the "kill the wrong colours" critical-path goal. All real branding (logos, fonts, posters, header layout, hover states, etc.) still comes from Cineplex + ARRFLIX CustomCss as before.

Maintenance warning on Jellyfin upgrade

This block targets specific Jellyfin class names. If a future Jellyfin web release renames .skinBody, .skinHeader, .preload, #reactRoot, .mainAnimatedPages, .emby-button, or the --primary-background-color CSS variable, the pre-bundle paint will regress to defaults until the selectors here are updated.

After every jellyfin/jellyfin image bump:

  1. Open https://arrflix.s8n.ru in incognito with throttled 3G in DevTools.
  2. Confirm the page goes from blank → black (not blue/grey).
  3. Confirm the login submit button is red (not blue) before bundle finishes.
  4. If either regresses, inspect the new bundle's body class names and update web-overrides/index.html selectors accordingly.

The block does NOT live in bin/inject-shim.py — it's a static <style>, not a script. Edit web-overrides/index.html directly and redeploy via the scp step in "Deploying changes" above.


Extending the shim on Jellyfin upgrade

If a future Jellyfin version starts overwriting MORE state (e.g. the manifest href, the apple-touch-icon, the theme-color meta), extend start() in bin/inject-shim.py:

function lockManifest(){
  var m = document.querySelector('link[rel="manifest"]');
  if (m && m.href.indexOf('fd4301fdc170fd202474.json') === -1) {
    m.href = 'fd4301fdc170fd202474.json';
  }
}
function lockMeta(){
  var n = document.querySelector('meta[name="application-name"]');
  if (n && n.content !== 'ARRFLIX') n.content = 'ARRFLIX';
  var t = document.querySelector('meta[id="themeColor"]');
  if (t && t.content !== '#202020') t.content = '#202020';
}

Then call them in start() and from the MutationObserver callback.

The MutationObserver is already configured with attributes:true, attributeFilter:['href'], so href changes on existing nodes trigger a re-lock. For new attributes, extend the filter list.


Why not just patch the bundle?

Tried. The minified bundle has 30+ document.title= sites, plus per-page pageManager.setTitle() calls. Patching all of them would produce a brittle diff that breaks on every Jellyfin upgrade (bundle hashes change). A 70-line runtime shim sitting on top of the unmodified bundle is the lower-maintenance path.


Verification checklist

  • curl -ks https://arrflix.s8n.ru/web/index.html | grep ARRFLIX-SHIM-BEGIN returns 1 hit
  • curl -ks https://arrflix.s8n.ru/web/index.html | grep -oE "<title>[^<]*</title>" returns <title>ARRFLIX</title>
  • In a fresh incognito browser, tab title is "ARRFLIX" on /web/index.html
  • After login, navigate to a library or item page - tab title stays "ARRFLIX" (or " - ARRFLIX") and never reverts to bare "Jellyfin"
  • DevTools > Application > Service Workers shows no active worker for arrflix.s8n.ru after the first hard reload
  • curl -ks https://arrflix.s8n.ru/web/index.html | grep -c nukeSettings returns 4 (function definition + 3 call-sites: start, body-observer, setInterval)
  • As non-admin user (marco), the drawer (hamburger menu) does NOT show a "Settings" entry between Profile and Quick Connect / About