138 lines
6.4 KiB
Markdown
138 lines
6.4 KiB
Markdown
|
|
# English Lockdown — Web-side Shim
|
||
|
|
|
||
|
|
> Browser-side belt for the per-user `UICulture` pin documented in
|
||
|
|
> `docs/15-force-english.md`. The server-side POST sets the authoritative
|
||
|
|
> value; this shim removes every escape hatch the SPA exposes to the user
|
||
|
|
> so they can't unpin it from the browser.
|
||
|
|
|
||
|
|
Last verified: 2026-05-08 against Jellyfin 10.10.3 web bundle, arrflix.s8n.ru.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## What this does
|
||
|
|
|
||
|
|
The English-lockdown logic lives **inside the existing ARRFLIX runtime
|
||
|
|
shim** (one self-contained IIFE per `docs/10-spa-runtime-shim.md` —
|
||
|
|
"the shim must remain self-contained"). Source of truth:
|
||
|
|
`bin/inject-shim.py`. Compiled output lands in `web-overrides/index.html`
|
||
|
|
between the `ARRFLIX-SHIM-BEGIN` / `-END` markers.
|
||
|
|
|
||
|
|
It runs **synchronously, before the Jellyfin bundle parses**, and pins
|
||
|
|
the UI to English by:
|
||
|
|
|
||
|
|
| # | Mechanism | Why |
|
||
|
|
|---|-----------|-----|
|
||
|
|
| 1 | `localStorage.setItem` for `appLanguage`, `selectedlanguage`, `selectedlocale`, `language`, `locale`, `culture` → all set to `en-US` | Belt-and-braces; covers every key Jellyfin web has shipped under across versions. |
|
||
|
|
| 2 | `Object.defineProperty(Navigator.prototype, 'language' / 'languages')` returning `'en-US'` / `['en-US','en']` | Jellyfin's pre-auth bundle reads `navigator.language` to pick the splash translation file. Overriding the prototype getter beats the bundle to it. |
|
||
|
|
| 3 | `fetch` wrapper — strips `Accept-Language` header on outbound requests; rewrites `POST /Users/{id}/Configuration` body to force `UICulture: 'en-US'` before send | Defensive: even if a future Jellyfin build offers a "save language" UI we don't know about, the POST gets rewritten in-flight. The user can't opt out. |
|
||
|
|
| 4 | `XMLHttpRequest` wrapper — same Accept-Language strip + same Configuration POST rewrite | Older Jellyfin bundle code paths use XHR rather than fetch. Belt for the fetch suspenders. |
|
||
|
|
| 5 | `pinLocale()` re-runs on every `start()` call AND on the existing 1s setInterval safety net | Re-pin storage keys if the SPA tries to clear/rewrite them. |
|
||
|
|
|
||
|
|
A companion CSS block in the existing critical-path `<style>` tag (top of
|
||
|
|
`web-overrides/index.html`) hides every language-switcher widget in the
|
||
|
|
UI: profile prefs dropdown, login page locale picker, header userMenu
|
||
|
|
locale flag, and (via `:has()`) any future `<select>` that contains a
|
||
|
|
`de-DE`/`fr-FR`/`es-ES` option.
|
||
|
|
|
||
|
|
## Where it gets injected from
|
||
|
|
|
||
|
|
```
|
||
|
|
bin/inject-shim.py # source of truth for the JS shim (run after edits)
|
||
|
|
web-overrides/index.html # the IIFE lives here, between ARRFLIX-SHIM-BEGIN/-END
|
||
|
|
# the CSS hide rules live in the <style> at the top
|
||
|
|
```
|
||
|
|
|
||
|
|
Container bind-mount (compose, unchanged from `docs/10`):
|
||
|
|
|
||
|
|
```yaml
|
||
|
|
volumes:
|
||
|
|
- /opt/docker/jellyfin/web-overrides/index.html:/jellyfin/jellyfin-web/index.html:ro
|
||
|
|
```
|
||
|
|
|
||
|
|
## Deploy workflow
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# 1. Edit bin/inject-shim.py (NOT the IIFE inside index.html directly)
|
||
|
|
# 2. Re-run injector locally
|
||
|
|
python3 bin/inject-shim.py
|
||
|
|
# 3. scp to nullstone
|
||
|
|
scp web-overrides/index.html user@192.168.0.100:/opt/docker/jellyfin/web-overrides/index.html
|
||
|
|
# 4. Hard-refresh a browser. No container restart needed (single-file bind mount).
|
||
|
|
```
|
||
|
|
|
||
|
|
CSS-only edits (the `<style>` block at the very top of `index.html`)
|
||
|
|
are edited directly — the injector only owns the `<script>` IIFE.
|
||
|
|
|
||
|
|
## Verification (operator runs after deploy)
|
||
|
|
|
||
|
|
In a fresh incognito browser at `https://arrflix.s8n.ru`, open DevTools
|
||
|
|
console and run:
|
||
|
|
|
||
|
|
```js
|
||
|
|
localStorage.getItem('appLanguage') // expect: "en-US"
|
||
|
|
localStorage.getItem('selectedlanguage') // expect: "en-US"
|
||
|
|
navigator.language // expect: "en-US"
|
||
|
|
navigator.languages // expect: ["en-US", "en"]
|
||
|
|
```
|
||
|
|
|
||
|
|
Curl-side the file is loading the new shim:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
curl -ks https://arrflix.s8n.ru/web/index.html | grep -c english-lockdown
|
||
|
|
# expect: 2 (one in CSS comment header, one in JS comment header)
|
||
|
|
curl -ks https://arrflix.s8n.ru/web/index.html | grep -c pinLocale
|
||
|
|
# expect: 4 (definition + 3 call-sites: start, setInterval, comment)
|
||
|
|
```
|
||
|
|
|
||
|
|
UI-side:
|
||
|
|
|
||
|
|
- [ ] User profile prefs page shows no "Display Language" dropdown.
|
||
|
|
- [ ] Login page shows no language picker.
|
||
|
|
- [ ] Header userMenu shows no locale flag/text.
|
||
|
|
- [ ] After auth, every Play / Resume / Settings label is English even
|
||
|
|
from a browser sending `Accept-Language: de-DE,de;q=0.9`.
|
||
|
|
|
||
|
|
## Known limitations
|
||
|
|
|
||
|
|
1. **First-paint flash on cold load.** The pre-bundle splash strings
|
||
|
|
(login form: "Sign In" / "Username" / "Password") are loaded by the
|
||
|
|
bundle from a static JSON file based on the browser's locale-detection
|
||
|
|
fallback BEFORE the IIFE's `Object.defineProperty` can intercept.
|
||
|
|
Modern Chromium / Firefox respect the prototype redefinition fast
|
||
|
|
enough that this is sub-50ms in practice — but on a slow connection
|
||
|
|
you may briefly see German login labels before the English bundle
|
||
|
|
replaces them. Acceptable; matches the existing first-paint flash
|
||
|
|
caveat in `docs/10`.
|
||
|
|
|
||
|
|
2. **`Accept-Language` strip is best-effort.** Most browsers prevent JS
|
||
|
|
from removing or modifying the `Accept-Language` request header on
|
||
|
|
outbound `fetch` / XHR. The wrapper attempts the delete; if the
|
||
|
|
browser silently ignores it, no harm done — the per-user `UICulture`
|
||
|
|
pin (server-side, see `docs/15`) wins regardless.
|
||
|
|
|
||
|
|
3. **`Object.defineProperty` may fail on some embedded WebViews** that
|
||
|
|
freeze `Navigator.prototype`. The shim has a fallback that retries
|
||
|
|
on the `navigator` instance directly. If both fail, the navigator
|
||
|
|
getters still return browser values, but the localStorage pin and
|
||
|
|
the user-config-save rewrite still hold the line.
|
||
|
|
|
||
|
|
4. **CSS `:has()`** has the same Chromium 105+ / Firefox 121+ /
|
||
|
|
Safari 15.4+ floor as the existing drawer-Settings rules. On older
|
||
|
|
browsers the `option[value="de-DE"]`-conditional hide degrades
|
||
|
|
silently — the simpler `select[name="language"]` rules still hide
|
||
|
|
the standard dropdown.
|
||
|
|
|
||
|
|
## Why this is layered with the per-user `UICulture` POST
|
||
|
|
|
||
|
|
The server-side fix (`docs/15-force-english.md`) is the authoritative
|
||
|
|
mechanism: when a user has `Configuration.UICulture = "en-US"`, the SPA
|
||
|
|
honours it on every login. This shim exists because:
|
||
|
|
|
||
|
|
- New users created outside the wrapper might land without the pin.
|
||
|
|
- A future Jellyfin web release might add a "change language" affordance
|
||
|
|
inside the player or a settings deeplink we haven't audited.
|
||
|
|
- The pre-auth splash bundle ignores `UICulture` (the user isn't logged
|
||
|
|
in yet) and reads `navigator.language` directly.
|
||
|
|
|
||
|
|
CSS hide + JS lockdown belt; per-user POST suspenders. Both are needed.
|