diff --git a/bin/inject-shim.py b/bin/inject-shim.py index 7fd7430..ab128da 100644 --- a/bin/inject-shim.py +++ b/bin/inject-shim.py @@ -38,19 +38,35 @@ SHIM = MARKER_BEGIN + r""" for (var i=0;i" + re.escape(MARKER_BEGIN) + r".*?" + re.escape(MARKER_END) + r"", re.DOTALL) - new_html, n = pattern.subn(WRAPPED, html, count=1) + # Use a callable replacement to bypass backreference parsing in WRAPPED + new_html, n = pattern.subn(lambda _m: WRAPPED, html, count=1) if n != 1: print("ERROR: shim markers present but could not match for replacement", file=sys.stderr) sys.exit(2) diff --git a/docs/10-spa-runtime-shim.md b/docs/10-spa-runtime-shim.md index 03bcfa0..559ca05 100644 --- a/docs/10-spa-runtime-shim.md +++ b/docs/10-spa-runtime-shim.md @@ -43,6 +43,7 @@ Behaviour: | 3 | Mutation | A `MutationObserver` on `document.head` re-runs both lockers when the SPA tries to change `` 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 @@ -88,6 +89,45 @@ curl -ks https://arrflix.s8n.ru/web/index.html | grep -oE "<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//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: + + ```bash + # 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 @@ -119,6 +159,50 @@ 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: + +```css +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 ``. +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 `` 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 @@ -245,3 +329,7 @@ path. (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 diff --git a/web-overrides/index.html b/web-overrides/index.html index 4df0aec..f096d31 100644 --- a/web-overrides/index.html +++ b/web-overrides/index.html @@ -41,19 +41,35 @@ html, body, .preload, .skinBody, .skinHeader, #reactRoot, .mainAnimatedPages { for (var i=0;i