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.
This commit is contained in:
parent
0fa723e482
commit
d41aaa04fd
3 changed files with 124 additions and 3 deletions
|
|
@ -38,19 +38,35 @@ SHIM = MARKER_BEGIN + r"""
|
|||
for (var i=0;i<icons.length;i++){ if (icons[i].href !== fav) icons[i].href = fav; }
|
||||
} catch(e){}
|
||||
}
|
||||
function nukeSettings(){
|
||||
try {
|
||||
var nodes = document.querySelectorAll('a[href*="mypreferencesmenu"], [to*="mypreferencesmenu"]');
|
||||
for (var i=0;i<nodes.length;i++){
|
||||
var el = nodes[i];
|
||||
var p = el.closest && (el.closest('li, .MuiListItem-root, [role="menuitem"]')) || el;
|
||||
if (p && p.style && p.style.display !== 'none') p.style.display = 'none';
|
||||
}
|
||||
} catch(e){}
|
||||
}
|
||||
function start(){
|
||||
lockTitle(); lockFavicon();
|
||||
lockTitle(); lockFavicon(); nukeSettings();
|
||||
try {
|
||||
var head = document.head || document.querySelector('head');
|
||||
if (head && window.MutationObserver) {
|
||||
new MutationObserver(function(){ lockTitle(); lockFavicon(); }).observe(head, { childList:true, subtree:true, characterData:true, attributes:true, attributeFilter:['href'] });
|
||||
}
|
||||
} catch(e){}
|
||||
try {
|
||||
if (document.body && window.MutationObserver) {
|
||||
new MutationObserver(function(){ nukeSettings(); }).observe(document.body, { childList:true, subtree:true });
|
||||
}
|
||||
} catch(e){}
|
||||
setInterval(function(){
|
||||
var t = document.title || '';
|
||||
if (BARE_RE.test(t) || /Jellyfin/i.test(t)) lockTitle();
|
||||
var fav = getFavicon();
|
||||
if (fav && fav.indexOf('data:image') !== 0) lockFavicon();
|
||||
nukeSettings();
|
||||
}, 1000);
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
|
|
@ -81,7 +97,8 @@ def main():
|
|||
if MARKER_BEGIN in html:
|
||||
# Replace the existing shim block in place
|
||||
pattern = re.compile(r"<script>" + re.escape(MARKER_BEGIN) + r".*?" + re.escape(MARKER_END) + r"</script>", 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)
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ Behaviour:
|
|||
| 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
|
||||
|
|
@ -88,6 +89,45 @@ 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:
|
||||
|
||||
```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 `<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
|
||||
|
|
@ -245,3 +329,7 @@ path.
|
|||
(or "<page name> - 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
|
||||
|
|
|
|||
|
|
@ -41,19 +41,35 @@ html, body, .preload, .skinBody, .skinHeader, #reactRoot, .mainAnimatedPages {
|
|||
for (var i=0;i<icons.length;i++){ if (icons[i].href !== fav) icons[i].href = fav; }
|
||||
} catch(e){}
|
||||
}
|
||||
function nukeSettings(){
|
||||
try {
|
||||
var nodes = document.querySelectorAll('a[href*="mypreferencesmenu"], [to*="mypreferencesmenu"]');
|
||||
for (var i=0;i<nodes.length;i++){
|
||||
var el = nodes[i];
|
||||
var p = el.closest && (el.closest('li, .MuiListItem-root, [role="menuitem"]')) || el;
|
||||
if (p && p.style && p.style.display !== 'none') p.style.display = 'none';
|
||||
}
|
||||
} catch(e){}
|
||||
}
|
||||
function start(){
|
||||
lockTitle(); lockFavicon();
|
||||
lockTitle(); lockFavicon(); nukeSettings();
|
||||
try {
|
||||
var head = document.head || document.querySelector('head');
|
||||
if (head && window.MutationObserver) {
|
||||
new MutationObserver(function(){ lockTitle(); lockFavicon(); }).observe(head, { childList:true, subtree:true, characterData:true, attributes:true, attributeFilter:['href'] });
|
||||
}
|
||||
} catch(e){}
|
||||
try {
|
||||
if (document.body && window.MutationObserver) {
|
||||
new MutationObserver(function(){ nukeSettings(); }).observe(document.body, { childList:true, subtree:true });
|
||||
}
|
||||
} catch(e){}
|
||||
setInterval(function(){
|
||||
var t = document.title || '';
|
||||
if (BARE_RE.test(t) || /Jellyfin/i.test(t)) lockTitle();
|
||||
var fav = getFavicon();
|
||||
if (fav && fav.indexOf('data:image') !== 0) lockFavicon();
|
||||
nukeSettings();
|
||||
}, 1000);
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
|
|
|
|||
Loading…
Reference in a new issue