diff --git a/docs/secure-runtime-modes.en.md b/docs/secure-runtime-modes.en.md
new file mode 100644
index 0000000..c2d6fbf
--- /dev/null
+++ b/docs/secure-runtime-modes.en.md
@@ -0,0 +1,307 @@
+# Secure runtime modes
+
+This document summarizes all values you may need to configure for:
+
+- `dist` bundle obfuscation (`app.js` / `app.css` → `.dat`)
+- secure runtime assets (`renderer-config.json`, `ui-config.json`, `gamedata`)
+- secure runtime API (`/api/*`)
+- plain fallbacks when you want to disable the secure layer without removing the code
+
+## 1. `Nitro-V3/public/client-mode.json`
+
+This file controls everything at runtime.
+
+```json
+{
+ "distObfuscationEnabled": true,
+ "secureAssetsEnabled": true,
+ "secureApiEnabled": true,
+ "apiBaseUrl": "https://nitro.slogga.it:2096",
+ "plainConfigBaseUrl": "https://hotel.slogga.it/",
+ "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/"
+}
+```
+
+### Fields
+
+- `distObfuscationEnabled`
+ - `true`: `asset-loader.js` loads `app.css.dat` and `app.js.dat`
+ - `false`: it loads plain `assets/app.css` and `assets/app.js`
+
+- `secureAssetsEnabled`
+ - `true`: `bootstrap.ts` and `secure-assets.ts` use `/nitro-sec/file`
+ - `false`: `renderer-config.json`, `ui-config.json`, and gamedata are loaded in plain mode
+
+- `secureApiEnabled`
+ - `true`: the `fetch` wrapper encrypts `/api/*` requests
+ - `false`: `/api/*` requests stay plain
+
+- `apiBaseUrl`
+ - Nitro emulator / API base URL
+ - example: `https://nitro.slogga.it:2096`
+ - it is best to always set this explicitly, so you do not depend on the hardcoded fallback
+
+- `plainConfigBaseUrl`
+ - base URL for plain config files
+ - usually: `https://hotel.slogga.it/`
+
+- `plainGamedataBaseUrl`
+ - base URL for plain gamedata files
+ - usually: `https://hotel.slogga.it/client/nitro/gamedata/`
+
+## 2. `Nitro-V3/src/bootstrap.ts`
+
+`bootstrap.ts`:
+
+- installs the secure fetch wrapper
+- reads `window.__nitroClientMode`
+- builds `NitroConfig['config.urls']`
+
+### Current behavior
+
+- if `secureAssetsEnabled=true`
+ - it uses `secureUrl('config', 'renderer-config.json', true)`
+ - it uses `secureUrl('config', 'ui-config.json', true)`
+
+- if `secureAssetsEnabled=false`
+ - it uses plain files with cache busting (`?v=...`)
+
+### Important note
+
+The current fallback is:
+
+```ts
+(window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || 'http://192.168.1.52:2096/';
+```
+
+So in production it is better to always set `apiBaseUrl` inside `client-mode.json`.
+
+## 3. `Nitro-V3/src/secure-assets.ts`
+
+This file contains the runtime logic for:
+
+- ECDH bootstrap
+- asset decrypt/encrypt
+- secure `/api/*`
+- plain fallback when the toggles are disabled
+
+### In practice
+
+- it reads flags from `window.__nitroClientMode`
+- if `secureAssetsEnabled=false`
+ - it automatically rewrites `/nitro-sec/file?...` into plain URLs
+- if `secureApiEnabled=false`
+ - it does not encrypt `/api/*`
+
+Normally you should not need to touch it unless you want to change the secure protocol itself.
+
+## 4. `Nitro-V3/public/renderer-config.json`
+
+This file still defines the paths used by the renderer.
+
+### Things to check
+
+- `api.url`
+- `socket.url`
+- `gamedata.url`
+- `external.texts.url`
+- `external.texts.translation.url`
+- `furnidata.url`
+- `furnidata.translation.url`
+
+### With secure assets enabled
+
+You can use:
+
+```json
+"gamedata.url": "https://nitro.slogga.it:2096/nitro-sec/file?kind=gamedata&file="
+```
+
+and the equivalent secure URLs for the other gamedata resources.
+
+### With secure assets disabled
+
+You can use plain classic paths, for example:
+
+```json
+"gamedata.url": "https://hotel.slogga.it/client/nitro/gamedata"
+```
+
+or you can keep the renderer config as-is and let `secure-assets.ts` handle the fallback conversion.
+
+## 5. `Nitro-V3/public/ui-config.json`
+
+There is no secure logic here, but it is one of the files loaded through `config.urls`.
+
+If `secureAssetsEnabled=true`, it is served from `/nitro-sec/file`.
+If `secureAssetsEnabled=false`, it is loaded from the static file with `?v=...`.
+
+So you only need to maintain the content itself correctly.
+
+## 6. `Nitro-V3/scripts/write-asset-loader.mjs`
+
+This script generates `public/asset-loader.js`.
+
+### What it does now
+
+- renders the initial shell
+- reads `client-mode.json`
+- decides whether to load:
+ - `app.css.dat` / `app.js.dat`
+ - or `assets/app.css` / `assets/app.js`
+
+### Important
+
+If you modify this script, the updated loader is regenerated on the next:
+
+```bash
+yarn build
+```
+
+because `package.json` already contains:
+
+```json
+"prebuild": "node scripts/write-asset-loader.mjs"
+```
+
+## 7. `Nitro-V3/scripts/minify-dist.mjs`
+
+This script now:
+
+- generates the `.dat` files
+- keeps the original `app.css` and `app.js` files too
+
+This is required, otherwise `distObfuscationEnabled=false` would not have a working fallback.
+
+## 8. `Arcturus-Morningstar-Extended/Latest_Compiled_Version/config.ini.example`
+
+The current backend flags are:
+
+```ini
+nitro.secure.assets.enabled=true
+nitro.secure.api.enabled=true
+nitro.secure.config.root=
+nitro.secure.gamedata.root=
+nitro.secure.master_key=change-me-to-a-long-random-secret
+```
+
+### Meaning
+
+- `nitro.secure.assets.enabled`
+ - enables `/nitro-sec/bootstrap` and `/nitro-sec/file`
+
+- `nitro.secure.api.enabled`
+ - enables the secure layer for `/api/*`
+
+- `nitro.secure.config.root`
+ - folder used to read `renderer-config.json` and `ui-config.json`
+
+- `nitro.secure.gamedata.root`
+ - folder used to read live gamedata
+
+- `nitro.secure.master_key`
+ - persistent server-side secret
+ - especially important when running behind Cloudflare / multiple backend requests
+
+## 9. Example setups
+
+### Everything enabled
+
+`client-mode.json`
+
+```json
+{
+ "distObfuscationEnabled": true,
+ "secureAssetsEnabled": true,
+ "secureApiEnabled": true,
+ "apiBaseUrl": "https://nitro.slogga.it:2096",
+ "plainConfigBaseUrl": "https://hotel.slogga.it/",
+ "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/"
+}
+```
+
+`config.ini`
+
+```ini
+nitro.secure.assets.enabled=true
+nitro.secure.api.enabled=true
+nitro.secure.config.root=C:/inetpub/wwwroot/paxxo/nitro
+nitro.secure.gamedata.root=C:/inetpub/wwwroot/paxxo/nitro/client/nitro/gamedata
+nitro.secure.master_key=a-long-random-secret
+```
+
+### `.dat` only, no secure assets/API
+
+`client-mode.json`
+
+```json
+{
+ "distObfuscationEnabled": true,
+ "secureAssetsEnabled": false,
+ "secureApiEnabled": false,
+ "apiBaseUrl": "https://nitro.slogga.it:2096",
+ "plainConfigBaseUrl": "https://hotel.slogga.it/",
+ "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/"
+}
+```
+
+`config.ini`
+
+```ini
+nitro.secure.assets.enabled=false
+nitro.secure.api.enabled=false
+```
+
+### Everything plain
+
+`client-mode.json`
+
+```json
+{
+ "distObfuscationEnabled": false,
+ "secureAssetsEnabled": false,
+ "secureApiEnabled": false,
+ "apiBaseUrl": "https://nitro.slogga.it:2096",
+ "plainConfigBaseUrl": "https://hotel.slogga.it/",
+ "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/"
+}
+```
+
+## 10. When rebuild is required
+
+### No rebuild required
+
+For changes to:
+
+- `client-mode.json`
+- `renderer-config.json`
+- `ui-config.json`
+- live gamedata
+- `config.ini`
+
+### Rebuild required
+
+For changes to:
+
+- `src/bootstrap.ts`
+- `src/secure-assets.ts`
+- `scripts/write-asset-loader.mjs`
+- `scripts/minify-dist.mjs`
+
+## 11. Deployment note
+
+To make the toggles work properly:
+
+- always deploy both plain files and `.dat` files
+- make sure IIS / your host serves the `.dat` MIME type
+- if you disable secure mode on the client, disable it on the backend too for consistency
+
+## 12. Quick checklist
+
+- `client-mode.json` configured
+- `apiBaseUrl` correct
+- `nitro.secure.master_key` set
+- `nitro.secure.config.root` correct
+- `nitro.secure.gamedata.root` correct
+- both `.dat` and plain files deployed
+- `.dat` MIME type configured on the web server
diff --git a/docs/secure-runtime-modes.md b/docs/secure-runtime-modes.md
new file mode 100644
index 0000000..fc36fb5
--- /dev/null
+++ b/docs/secure-runtime-modes.md
@@ -0,0 +1,307 @@
+# Secure runtime modes
+
+Questa doc riassume tutti i dati da impostare per:
+
+- offuscamento bundle `dist` (`app.js` / `app.css` → `.dat`)
+- secure assets runtime (`renderer-config.json`, `ui-config.json`, `gamedata`)
+- secure API runtime (`/api/*`)
+- fallback plain quando vuoi spegnere tutto senza togliere il codice
+
+## 1. `Nitro-V3/public/client-mode.json`
+
+Questo file controlla tutto a runtime.
+
+```json
+{
+ "distObfuscationEnabled": true,
+ "secureAssetsEnabled": true,
+ "secureApiEnabled": true,
+ "apiBaseUrl": "https://nitro.slogga.it:2096",
+ "plainConfigBaseUrl": "https://hotel.slogga.it/",
+ "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/"
+}
+```
+
+### Campi
+
+- `distObfuscationEnabled`
+ - `true`: `asset-loader.js` carica `app.css.dat` e `app.js.dat`
+ - `false`: carica i file normali `assets/app.css` e `assets/app.js`
+
+- `secureAssetsEnabled`
+ - `true`: `bootstrap.ts` e `secure-assets.ts` usano `/nitro-sec/file`
+ - `false`: `renderer-config.json`, `ui-config.json` e gamedata vengono letti in plain
+
+- `secureApiEnabled`
+ - `true`: il wrapper `fetch` cifra le chiamate `/api/*`
+ - `false`: le chiamate `/api/*` restano normali
+
+- `apiBaseUrl`
+ - base URL dell’emulatore / API Nitro
+ - esempio: `https://nitro.slogga.it:2096`
+ - meglio valorizzarlo sempre, così non dipendi dal fallback hardcoded
+
+- `plainConfigBaseUrl`
+ - base URL dei file config plain
+ - normalmente: `https://hotel.slogga.it/`
+
+- `plainGamedataBaseUrl`
+ - base URL del gamedata plain
+ - normalmente: `https://hotel.slogga.it/client/nitro/gamedata/`
+
+## 2. `Nitro-V3/src/bootstrap.ts`
+
+`bootstrap.ts`:
+
+- installa il secure fetch wrapper
+- legge `window.__nitroClientMode`
+- costruisce `NitroConfig['config.urls']`
+
+### Comportamento attuale
+
+- se `secureAssetsEnabled=true`
+ - usa `secureUrl('config', 'renderer-config.json', true)`
+ - usa `secureUrl('config', 'ui-config.json', true)`
+
+- se `secureAssetsEnabled=false`
+ - usa i file plain con cache bust (`?v=...`)
+
+### Nota importante
+
+Il fallback attuale è:
+
+```ts
+(window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || 'http://192.168.1.52:2096/';
+```
+
+Quindi in produzione conviene sempre valorizzare `apiBaseUrl` dentro `client-mode.json`.
+
+## 3. `Nitro-V3/src/secure-assets.ts`
+
+Qui vive tutta la logica runtime:
+
+- bootstrap ECDH
+- decrypt/encrypt assets
+- secure `/api/*`
+- fallback plain quando i toggle sono spenti
+
+### In pratica
+
+- legge i flag da `window.__nitroClientMode`
+- se `secureAssetsEnabled=false`
+ - converte automaticamente `/nitro-sec/file?...` in URL plain
+- se `secureApiEnabled=false`
+ - non cifra `/api/*`
+
+Normalmente non serve toccarlo, a meno che tu non voglia cambiare il protocollo secure.
+
+## 4. `Nitro-V3/public/renderer-config.json`
+
+Questo file continua a definire i path usati dal renderer.
+
+### Da controllare
+
+- `api.url`
+- `socket.url`
+- `gamedata.url`
+- `external.texts.url`
+- `external.texts.translation.url`
+- `furnidata.url`
+- `furnidata.translation.url`
+
+### Con secure assets attivo
+
+Puoi usare:
+
+```json
+"gamedata.url": "https://nitro.slogga.it:2096/nitro-sec/file?kind=gamedata&file="
+```
+
+e gli altri URL secure equivalenti.
+
+### Con secure assets disattivo
+
+Conviene usare i path plain classici, per esempio:
+
+```json
+"gamedata.url": "https://hotel.slogga.it/client/nitro/gamedata"
+```
+
+oppure lasciare il renderer configurato com’è e demandare il fallback a `secure-assets.ts`.
+
+## 5. `Nitro-V3/public/ui-config.json`
+
+Qui non c’è logica secure, ma è uno dei file caricati da `config.urls`.
+
+Se `secureAssetsEnabled=true`, arriva da `/nitro-sec/file`.
+Se `secureAssetsEnabled=false`, arriva dal file statico con `?v=...`.
+
+Quindi basta mantenerlo corretto come contenuto, non serve altro.
+
+## 6. `Nitro-V3/scripts/write-asset-loader.mjs`
+
+Questo script genera `public/asset-loader.js`.
+
+### Cosa fa ora
+
+- mostra la shell iniziale
+- legge `client-mode.json`
+- decide se caricare:
+ - `app.css.dat` / `app.js.dat`
+ - oppure `assets/app.css` / `assets/app.js`
+
+### Importante
+
+Se modifichi questo script, il loader aggiornato viene rigenerato al prossimo:
+
+```bash
+yarn build
+```
+
+perché in `package.json` c’è:
+
+```json
+"prebuild": "node scripts/write-asset-loader.mjs"
+```
+
+## 7. `Nitro-V3/scripts/minify-dist.mjs`
+
+Adesso questo script:
+
+- genera i `.dat`
+- lascia anche i file originali `app.css` e `app.js`
+
+Questa parte è fondamentale, altrimenti il toggle `distObfuscationEnabled=false` non avrebbe fallback.
+
+## 8. `Arcturus-Morningstar-Extended/Latest_Compiled_Version/config.ini.example`
+
+I flag backend attuali sono:
+
+```ini
+nitro.secure.assets.enabled=true
+nitro.secure.api.enabled=true
+nitro.secure.config.root=
+nitro.secure.gamedata.root=
+nitro.secure.master_key=change-me-to-a-long-random-secret
+```
+
+### Significato
+
+- `nitro.secure.assets.enabled`
+ - abilita `/nitro-sec/bootstrap` e `/nitro-sec/file`
+
+- `nitro.secure.api.enabled`
+ - abilita il layer secure per `/api/*`
+
+- `nitro.secure.config.root`
+ - cartella dove leggere `renderer-config.json` e `ui-config.json`
+
+- `nitro.secure.gamedata.root`
+ - cartella dove leggere il gamedata live
+
+- `nitro.secure.master_key`
+ - segreto persistente lato server
+ - necessario soprattutto con Cloudflare / richieste multiple
+
+## 9. Esempi di configurazione
+
+### Tutto attivo
+
+`client-mode.json`
+
+```json
+{
+ "distObfuscationEnabled": true,
+ "secureAssetsEnabled": true,
+ "secureApiEnabled": true,
+ "apiBaseUrl": "https://nitro.slogga.it:2096",
+ "plainConfigBaseUrl": "https://hotel.slogga.it/",
+ "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/"
+}
+```
+
+`config.ini`
+
+```ini
+nitro.secure.assets.enabled=true
+nitro.secure.api.enabled=true
+nitro.secure.config.root=C:/inetpub/wwwroot/paxxo/nitro
+nitro.secure.gamedata.root=C:/inetpub/wwwroot/paxxo/nitro/client/nitro/gamedata
+nitro.secure.master_key=una-chiave-lunga-random
+```
+
+### Solo `.dat`, senza secure assets/api
+
+`client-mode.json`
+
+```json
+{
+ "distObfuscationEnabled": true,
+ "secureAssetsEnabled": false,
+ "secureApiEnabled": false,
+ "apiBaseUrl": "https://nitro.slogga.it:2096",
+ "plainConfigBaseUrl": "https://hotel.slogga.it/",
+ "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/"
+}
+```
+
+`config.ini`
+
+```ini
+nitro.secure.assets.enabled=false
+nitro.secure.api.enabled=false
+```
+
+### Tutto plain
+
+`client-mode.json`
+
+```json
+{
+ "distObfuscationEnabled": false,
+ "secureAssetsEnabled": false,
+ "secureApiEnabled": false,
+ "apiBaseUrl": "https://nitro.slogga.it:2096",
+ "plainConfigBaseUrl": "https://hotel.slogga.it/",
+ "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/"
+}
+```
+
+## 10. Quando serve rebuild
+
+### Non serve rebuild
+
+Per cambiare:
+
+- `client-mode.json`
+- `renderer-config.json`
+- `ui-config.json`
+- gamedata live
+- `config.ini`
+
+### Serve rebuild
+
+Per cambiare:
+
+- `src/bootstrap.ts`
+- `src/secure-assets.ts`
+- `scripts/write-asset-loader.mjs`
+- `scripts/minify-dist.mjs`
+
+## 11. Nota pratica deployment
+
+Per usare bene i toggle:
+
+- pubblica sempre sia i file plain sia i `.dat`
+- assicurati che IIS/host serva il MIME type per `.dat`
+- se spegni il secure mode nel client, spegnilo anche nel backend per coerenza
+
+## 12. Checklist veloce
+
+- `client-mode.json` configurato
+- `apiBaseUrl` corretto
+- `nitro.secure.master_key` valorizzata
+- `nitro.secure.config.root` corretto
+- `nitro.secure.gamedata.root` corretto
+- `.dat` e file plain entrambi deployati
+- MIME `.dat` presente sul web server
diff --git a/public/asset-loader.js b/public/asset-loader.js
index 569b19d..e02be4d 100644
--- a/public/asset-loader.js
+++ b/public/asset-loader.js
@@ -1 +1,183 @@
-(()=>{const h=()=>{try{const s=new URLSearchParams(location.search);return s.get("loaderDebug")==="1"||localStorage.getItem("nitro.loader.debug")==="1"}catch{return!1}},m=t=>{if(!h()){document.getElementById("nitro-loader-debug")?.remove();return}let n=document.getElementById("nitro-loader-debug");if(!n){n=document.createElement("div");n.id="nitro-loader-debug";n.style.cssText="position:fixed;left:8px;top:8px;z-index:2147483647;padding:6px 8px;max-width:70vw;background:rgba(0,0,0,.85);color:#fff;font:12px monospace;white-space:pre-wrap";document.body.appendChild(n)}n.textContent=t},n=()=>{const s=document.currentScript?.src||location.href;return new URL(".",s)},v=()=>{const r=document.getElementById("root");if(!r||r.firstChild)return;r.innerHTML='
'},k=new TextEncoder().encode("slogga-dist-assets-2026"),d=b=>{const o=new Uint8Array(b.length);for(let i=0;i{if(!("DecompressionStream" in self))throw new Error("gzip decompression unsupported");const s=new Blob([b]).stream().pipeThrough(new DecompressionStream("gzip"));return new Uint8Array(await new Response(s).arrayBuffer())},u=p=>{const b=n(),q=p.replace(/^\.\//,""),f=q.split("/").pop(),c=[new URL("./src/assets/"+f,b),new URL("./assets/"+f,b),new URL("/src/assets/"+f,b.origin),new URL("/assets/"+f,b.origin),new URL("/client/src/assets/"+f,b.origin),new URL("/client/assets/"+f,b.origin)];return[...new Map(c.map(x=>[x.href,x])).values()]},g=async p=>{let e=null;m("loader: fetching "+p);for(const a of u(p)){try{m("loader: try "+a.href);const r=await fetch(a,{cache:"no-store"});if(!r.ok){e=new Error("asset "+a.pathname+" "+r.status);continue}m("loader: ok "+a.href);return z(d(new Uint8Array(await r.arrayBuffer())))}catch(x){e=x}}throw e||new Error("asset "+p+" not found")},s=c=>{const l=document.createElement("style");l.textContent=new TextDecoder().decode(c);document.head.appendChild(l);m("loader: css injected")},j=async c=>{const u=URL.createObjectURL(new Blob([c],{type:"text/javascript"}));try{m("loader: importing app blob");await import(u);m("loader: app blob imported")}finally{URL.revokeObjectURL(u)}};(async()=>{m("loader: start");v();const[c,a]=await Promise.all([g("./assets/app.css.dat"),g("./assets/app.js.dat")]);s(c);await j(a)})().catch(e=>{console.error(e);m("loader: failed "+(e?.message||e));document.body.textContent="Unable to load client."})})();
\ No newline at end of file
+(() => {
+ const ASSET_KEY = new TextEncoder().encode("slogga-dist-assets-2026");
+ const MODE_DEFAULTS = {
+ distObfuscationEnabled: true,
+ secureAssetsEnabled: true,
+ secureApiEnabled: true
+ };
+
+ const isDebug = () => {
+ try {
+ const search = new URLSearchParams(location.search);
+ return search.get("loaderDebug") === "1" || localStorage.getItem("nitro.loader.debug") === "1";
+ } catch {
+ return false;
+ }
+ };
+
+ const debug = (message) => {
+ try {
+ window.__nitroLoaderDebug = message;
+ const log = Array.isArray(window.__nitroLoaderDebugLog) ? window.__nitroLoaderDebugLog : [];
+ log.push(message);
+ window.__nitroLoaderDebugLog = log.slice(-30);
+ if(!isDebug()) {
+ document.getElementById("nitro-loader-debug")?.remove();
+ return;
+ }
+ let node = document.getElementById("nitro-loader-debug");
+ if(!node) {
+ node = document.createElement("div");
+ node.id = "nitro-loader-debug";
+ node.style.cssText = "position:fixed;left:8px;top:8px;z-index:2147483647;padding:6px 8px;max-width:70vw;background:rgba(0,0,0,.85);color:#fff;font:12px monospace;white-space:pre-wrap";
+ document.body.appendChild(node);
+ }
+ node.textContent = window.__nitroLoaderDebugLog.slice(-10).join("\n");
+ } catch {}
+ };
+
+ const getBase = () => {
+ const source = document.currentScript?.src || location.href;
+ return new URL(".", source);
+ };
+
+ const withCacheBust = (url) => {
+ url.searchParams.set("v", Date.now().toString(36));
+ return url;
+ };
+
+ const renderShell = () => {
+ const root = document.getElementById("root");
+ if(!root || root.firstChild) return;
+ root.innerHTML = '';
+ };
+
+ const decodeAsset = (bytes) => {
+ const output = new Uint8Array(bytes.length);
+ for(let index = 0; index < bytes.length; index++) {
+ output[index] = bytes[index] ^ ASSET_KEY[index % ASSET_KEY.length] ^ ((index * 31) & 255);
+ }
+ return output;
+ };
+
+ const gunzip = async (bytes) => {
+ if(!("DecompressionStream" in self)) throw new Error("gzip decompression unsupported");
+ const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
+ return new Uint8Array(await new Response(stream).arrayBuffer());
+ };
+
+ const resolveAssetCandidates = (path) => {
+ const base = getBase();
+ const normalized = path.replace(/^\.\//, "");
+ const file = normalized.split("/").pop();
+ const urls = [
+ new URL("./src/assets/" + file, base),
+ new URL("./assets/" + file, base),
+ new URL("/src/assets/" + file, base.origin),
+ new URL("/assets/" + file, base.origin),
+ new URL("/client/src/assets/" + file, base.origin),
+ new URL("/client/assets/" + file, base.origin)
+ ];
+ return [...new Map(urls.map(url => [url.href, url])).values()];
+ };
+
+ const fetchBytes = async (path) => {
+ let error = null;
+ debug("loader: fetching " + path);
+ for(const candidate of resolveAssetCandidates(path)) {
+ try {
+ debug("loader: try " + candidate.href);
+ const response = await fetch(withCacheBust(candidate), { cache: "no-store" });
+ if(!response.ok) {
+ error = new Error("asset " + candidate.pathname + " " + response.status);
+ continue;
+ }
+ debug("loader: ok " + candidate.href);
+ return new Uint8Array(await response.arrayBuffer());
+ } catch(caught) {
+ error = caught;
+ }
+ }
+ throw error || new Error("asset " + path + " not found");
+ };
+
+ const loadDatAsset = async (path) => gunzip(decodeAsset(await fetchBytes(path)));
+
+ const injectCssText = (bytes) => {
+ const node = document.createElement("style");
+ node.textContent = new TextDecoder().decode(bytes);
+ document.head.appendChild(node);
+ debug("loader: css injected from dat");
+ };
+
+ const loadPlainCss = async (path) => {
+ const href = resolveAssetCandidates(path)[0];
+ href.searchParams.set("v", Date.now().toString(36));
+ await new Promise((resolve, reject) => {
+ const link = document.createElement("link");
+ link.rel = "stylesheet";
+ link.href = href.href;
+ link.onload = () => resolve();
+ link.onerror = () => reject(new Error("plain css failed"));
+ document.head.appendChild(link);
+ });
+ debug("loader: css linked");
+ };
+
+ const importBytes = async (bytes) => {
+ const blobUrl = URL.createObjectURL(new Blob([bytes], { type: "text/javascript" }));
+ try {
+ debug("loader: importing app blob");
+ await import(blobUrl);
+ debug("loader: app blob imported");
+ } finally {
+ URL.revokeObjectURL(blobUrl);
+ }
+ };
+
+ const importPlainJs = async (path) => {
+ const href = resolveAssetCandidates(path)[0];
+ href.searchParams.set("v", Date.now().toString(36));
+ debug("loader: importing plain js");
+ await import(href.href);
+ debug("loader: plain js imported");
+ };
+
+ const readClientMode = async () => {
+ try {
+ const url = withCacheBust(new URL("./client-mode.json", getBase()));
+ const response = await fetch(url, { cache: "no-store" });
+ if(!response.ok) throw new Error("client-mode " + response.status);
+ const payload = await response.json();
+ const mode = { ...MODE_DEFAULTS, ...(payload && typeof payload === "object" ? payload : {}) };
+ window.__nitroClientMode = mode;
+ debug("loader: client-mode loaded");
+ return mode;
+ } catch(error) {
+ window.__nitroClientMode = { ...MODE_DEFAULTS };
+ debug("loader: client-mode fallback " + (error?.message || error));
+ return window.__nitroClientMode;
+ }
+ };
+
+ (async () => {
+ debug("loader: start");
+ renderShell();
+ const mode = await readClientMode();
+ if(mode.distObfuscationEnabled) {
+ const [cssBytes, jsBytes] = await Promise.all([
+ loadDatAsset("./assets/app.css.dat"),
+ loadDatAsset("./assets/app.js.dat")
+ ]);
+ injectCssText(cssBytes);
+ await importBytes(jsBytes);
+ return;
+ }
+ await loadPlainCss("./assets/app.css");
+ await importPlainJs("./assets/app.js");
+ })().catch(error => {
+ console.error(error);
+ debug("loader: failed " + (error?.message || error));
+ document.body.textContent = "Unable to load client.";
+ });
+})();
\ No newline at end of file
diff --git a/public/client-mode.json b/public/client-mode.json
new file mode 100644
index 0000000..a738f14
--- /dev/null
+++ b/public/client-mode.json
@@ -0,0 +1,8 @@
+{
+ "distObfuscationEnabled": true,
+ "secureAssetsEnabled": true,
+ "secureApiEnabled": true,
+ "apiBaseUrl": "",
+ "plainConfigBaseUrl": "",
+ "plainGamedataBaseUrl": ""
+}
diff --git a/scripts/minify-dist.mjs b/scripts/minify-dist.mjs
index a5c7970..a9d3d18 100644
--- a/scripts/minify-dist.mjs
+++ b/scripts/minify-dist.mjs
@@ -35,7 +35,6 @@ const encryptFile = path =>
{
const bytes = gzipSync(readFileSync(path), { level: 9 });
writeFileSync(path + '.dat', encodeBytes(bytes));
- rmSync(path);
};
if(!existsSync(dist)) throw new Error('dist folder not found');
diff --git a/scripts/write-asset-loader.mjs b/scripts/write-asset-loader.mjs
index 995e49c..b33aa36 100644
--- a/scripts/write-asset-loader.mjs
+++ b/scripts/write-asset-loader.mjs
@@ -1,7 +1,190 @@
import { mkdirSync, writeFileSync } from 'fs';
import { dirname, resolve } from 'path';
-const loader = `(()=>{const h=()=>{try{const s=new URLSearchParams(location.search);return s.get("loaderDebug")==="1"||localStorage.getItem("nitro.loader.debug")==="1"}catch{return!1}},m=t=>{if(!h()){document.getElementById("nitro-loader-debug")?.remove();return}let n=document.getElementById("nitro-loader-debug");if(!n){n=document.createElement("div");n.id="nitro-loader-debug";n.style.cssText="position:fixed;left:8px;top:8px;z-index:2147483647;padding:6px 8px;max-width:70vw;background:rgba(0,0,0,.85);color:#fff;font:12px monospace;white-space:pre-wrap";document.body.appendChild(n)}n.textContent=t},n=()=>{const s=document.currentScript?.src||location.href;return new URL(".",s)},v=()=>{const r=document.getElementById("root");if(!r||r.firstChild)return;r.innerHTML=''},k=new TextEncoder().encode("slogga-dist-assets-2026"),d=b=>{const o=new Uint8Array(b.length);for(let i=0;i{if(!("DecompressionStream" in self))throw new Error("gzip decompression unsupported");const s=new Blob([b]).stream().pipeThrough(new DecompressionStream("gzip"));return new Uint8Array(await new Response(s).arrayBuffer())},u=p=>{const b=n(),q=p.replace(/^\\.\\//,""),f=q.split("/").pop(),c=[new URL("./src/assets/"+f,b),new URL("./assets/"+f,b),new URL("/src/assets/"+f,b.origin),new URL("/assets/"+f,b.origin),new URL("/client/src/assets/"+f,b.origin),new URL("/client/assets/"+f,b.origin)];return[...new Map(c.map(x=>[x.href,x])).values()]},g=async p=>{let e=null;m("loader: fetching "+p);for(const a of u(p)){try{m("loader: try "+a.href);const r=await fetch(a,{cache:"no-store"});if(!r.ok){e=new Error("asset "+a.pathname+" "+r.status);continue}m("loader: ok "+a.href);return z(d(new Uint8Array(await r.arrayBuffer())))}catch(x){e=x}}throw e||new Error("asset "+p+" not found")},s=c=>{const l=document.createElement("style");l.textContent=new TextDecoder().decode(c);document.head.appendChild(l);m("loader: css injected")},j=async c=>{const u=URL.createObjectURL(new Blob([c],{type:"text/javascript"}));try{m("loader: importing app blob");await import(u);m("loader: app blob imported")}finally{URL.revokeObjectURL(u)}};(async()=>{m("loader: start");v();const[c,a]=await Promise.all([g("./assets/app.css.dat"),g("./assets/app.js.dat")]);s(c);await j(a)})().catch(e=>{console.error(e);m("loader: failed "+(e?.message||e));document.body.textContent="Unable to load client."})})();`;
+const loader = `(() => {
+ const ASSET_KEY = new TextEncoder().encode("slogga-dist-assets-2026");
+ const MODE_DEFAULTS = {
+ distObfuscationEnabled: true,
+ secureAssetsEnabled: true,
+ secureApiEnabled: true
+ };
+
+ const isDebug = () => {
+ try {
+ const search = new URLSearchParams(location.search);
+ return search.get("loaderDebug") === "1" || localStorage.getItem("nitro.loader.debug") === "1";
+ } catch {
+ return false;
+ }
+ };
+
+ const debug = (message) => {
+ try {
+ window.__nitroLoaderDebug = message;
+ const log = Array.isArray(window.__nitroLoaderDebugLog) ? window.__nitroLoaderDebugLog : [];
+ log.push(message);
+ window.__nitroLoaderDebugLog = log.slice(-30);
+ if(!isDebug()) {
+ document.getElementById("nitro-loader-debug")?.remove();
+ return;
+ }
+ let node = document.getElementById("nitro-loader-debug");
+ if(!node) {
+ node = document.createElement("div");
+ node.id = "nitro-loader-debug";
+ node.style.cssText = "position:fixed;left:8px;top:8px;z-index:2147483647;padding:6px 8px;max-width:70vw;background:rgba(0,0,0,.85);color:#fff;font:12px monospace;white-space:pre-wrap";
+ document.body.appendChild(node);
+ }
+ node.textContent = window.__nitroLoaderDebugLog.slice(-10).join("\\n");
+ } catch {}
+ };
+
+ const getBase = () => {
+ const source = document.currentScript?.src || location.href;
+ return new URL(".", source);
+ };
+
+ const withCacheBust = (url) => {
+ url.searchParams.set("v", Date.now().toString(36));
+ return url;
+ };
+
+ const renderShell = () => {
+ const root = document.getElementById("root");
+ if(!root || root.firstChild) return;
+ root.innerHTML = '';
+ };
+
+ const decodeAsset = (bytes) => {
+ const output = new Uint8Array(bytes.length);
+ for(let index = 0; index < bytes.length; index++) {
+ output[index] = bytes[index] ^ ASSET_KEY[index % ASSET_KEY.length] ^ ((index * 31) & 255);
+ }
+ return output;
+ };
+
+ const gunzip = async (bytes) => {
+ if(!("DecompressionStream" in self)) throw new Error("gzip decompression unsupported");
+ const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
+ return new Uint8Array(await new Response(stream).arrayBuffer());
+ };
+
+ const resolveAssetCandidates = (path) => {
+ const base = getBase();
+ const normalized = path.replace(/^\\.\\//, "");
+ const file = normalized.split("/").pop();
+ const urls = [
+ new URL("./src/assets/" + file, base),
+ new URL("./assets/" + file, base),
+ new URL("/src/assets/" + file, base.origin),
+ new URL("/assets/" + file, base.origin),
+ new URL("/client/src/assets/" + file, base.origin),
+ new URL("/client/assets/" + file, base.origin)
+ ];
+ return [...new Map(urls.map(url => [url.href, url])).values()];
+ };
+
+ const fetchBytes = async (path) => {
+ let error = null;
+ debug("loader: fetching " + path);
+ for(const candidate of resolveAssetCandidates(path)) {
+ try {
+ debug("loader: try " + candidate.href);
+ const response = await fetch(withCacheBust(candidate), { cache: "no-store" });
+ if(!response.ok) {
+ error = new Error("asset " + candidate.pathname + " " + response.status);
+ continue;
+ }
+ debug("loader: ok " + candidate.href);
+ return new Uint8Array(await response.arrayBuffer());
+ } catch(caught) {
+ error = caught;
+ }
+ }
+ throw error || new Error("asset " + path + " not found");
+ };
+
+ const loadDatAsset = async (path) => gunzip(decodeAsset(await fetchBytes(path)));
+
+ const injectCssText = (bytes) => {
+ const node = document.createElement("style");
+ node.textContent = new TextDecoder().decode(bytes);
+ document.head.appendChild(node);
+ debug("loader: css injected from dat");
+ };
+
+ const loadPlainCss = async (path) => {
+ const href = resolveAssetCandidates(path)[0];
+ href.searchParams.set("v", Date.now().toString(36));
+ await new Promise((resolve, reject) => {
+ const link = document.createElement("link");
+ link.rel = "stylesheet";
+ link.href = href.href;
+ link.onload = () => resolve();
+ link.onerror = () => reject(new Error("plain css failed"));
+ document.head.appendChild(link);
+ });
+ debug("loader: css linked");
+ };
+
+ const importBytes = async (bytes) => {
+ const blobUrl = URL.createObjectURL(new Blob([bytes], { type: "text/javascript" }));
+ try {
+ debug("loader: importing app blob");
+ await import(blobUrl);
+ debug("loader: app blob imported");
+ } finally {
+ URL.revokeObjectURL(blobUrl);
+ }
+ };
+
+ const importPlainJs = async (path) => {
+ const href = resolveAssetCandidates(path)[0];
+ href.searchParams.set("v", Date.now().toString(36));
+ debug("loader: importing plain js");
+ await import(href.href);
+ debug("loader: plain js imported");
+ };
+
+ const readClientMode = async () => {
+ try {
+ const url = withCacheBust(new URL("./client-mode.json", getBase()));
+ const response = await fetch(url, { cache: "no-store" });
+ if(!response.ok) throw new Error("client-mode " + response.status);
+ const payload = await response.json();
+ const mode = { ...MODE_DEFAULTS, ...(payload && typeof payload === "object" ? payload : {}) };
+ window.__nitroClientMode = mode;
+ debug("loader: client-mode loaded");
+ return mode;
+ } catch(error) {
+ window.__nitroClientMode = { ...MODE_DEFAULTS };
+ debug("loader: client-mode fallback " + (error?.message || error));
+ return window.__nitroClientMode;
+ }
+ };
+
+ (async () => {
+ debug("loader: start");
+ renderShell();
+ const mode = await readClientMode();
+ if(mode.distObfuscationEnabled) {
+ const [cssBytes, jsBytes] = await Promise.all([
+ loadDatAsset("./assets/app.css.dat"),
+ loadDatAsset("./assets/app.js.dat")
+ ]);
+ injectCssText(cssBytes);
+ await importBytes(jsBytes);
+ return;
+ }
+ await loadPlainCss("./assets/app.css");
+ await importPlainJs("./assets/app.js");
+ })().catch(error => {
+ console.error(error);
+ debug("loader: failed " + (error?.message || error));
+ document.body.textContent = "Unable to load client.";
+ });
+})();`;
+
const target = resolve('public', 'asset-loader.js');
mkdirSync(dirname(target), { recursive: true });
diff --git a/src/bootstrap.ts b/src/bootstrap.ts
index 052bf70..077a6c9 100644
--- a/src/bootstrap.ts
+++ b/src/bootstrap.ts
@@ -1,4 +1,4 @@
-import { installSecureFetch, secureUrl } from './secure-assets';
+import { getClientMode, installSecureFetch, secureUrl } from './secure-assets';
installSecureFetch();
@@ -17,12 +17,22 @@ const setBootDebug = (message: string) =>
setBootDebug('boot: secure fetch installed');
const search = new URLSearchParams(window.location.search);
+const clientMode = getClientMode();
+const cacheBustUrl = (path: string): string =>
+{
+ const url = new URL(path.replace(/^\/+/, ''), `${ window.location.origin }/`);
-(window as any).NitroSecureApiUrl = 'http://192.168.1.52:2096/';
+ url.searchParams.set('v', Date.now().toString(36));
+
+ return url.toString();
+};
+
+(window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || 'http://192.168.1.52:2096/';
+(window as any).NitroClientMode = clientMode;
(window as any).NitroConfig = {
'config.urls': [
- secureUrl('config', 'renderer-config.json', true),
- secureUrl('config', 'ui-config.json', true)
+ clientMode.secureAssetsEnabled ? secureUrl('config', 'renderer-config.json', true) : cacheBustUrl('renderer-config.json'),
+ clientMode.secureAssetsEnabled ? secureUrl('config', 'ui-config.json', true) : cacheBustUrl('ui-config.json')
],
'sso.ticket': search.get('sso') || null,
'forward.type': search.get('room') ? 2 : -1,
diff --git a/src/secure-assets.ts b/src/secure-assets.ts
index 05acb46..f0af707 100644
--- a/src/secure-assets.ts
+++ b/src/secure-assets.ts
@@ -4,6 +4,21 @@ type SecureSession = {
fingerprint: string;
};
+export type NitroClientMode = {
+ distObfuscationEnabled: boolean;
+ secureAssetsEnabled: boolean;
+ secureApiEnabled: boolean;
+ apiBaseUrl?: string;
+ plainConfigBaseUrl?: string;
+ plainGamedataBaseUrl?: string;
+};
+
+const CLIENT_MODE_DEFAULTS: NitroClientMode = {
+ distObfuscationEnabled: true,
+ secureAssetsEnabled: true,
+ secureApiEnabled: true
+};
+
const isDebugEnabled = (): boolean =>
{
try
@@ -73,6 +88,29 @@ const REKEY_ENDPOINTS = new Set([
'/api/auth/logout'
]);
+export const getClientMode = (): NitroClientMode =>
+{
+ try
+ {
+ const configured = (window as any).__nitroClientMode;
+
+ if(configured && typeof configured === 'object')
+ {
+ return {
+ distObfuscationEnabled: configured.distObfuscationEnabled !== false,
+ secureAssetsEnabled: configured.secureAssetsEnabled !== false,
+ secureApiEnabled: configured.secureApiEnabled !== false,
+ apiBaseUrl: typeof configured.apiBaseUrl === 'string' ? configured.apiBaseUrl : '',
+ plainConfigBaseUrl: typeof configured.plainConfigBaseUrl === 'string' ? configured.plainConfigBaseUrl : '',
+ plainGamedataBaseUrl: typeof configured.plainGamedataBaseUrl === 'string' ? configured.plainGamedataBaseUrl : ''
+ };
+ }
+ }
+ catch {}
+
+ return { ...CLIENT_MODE_DEFAULTS };
+};
+
const bytesToBase64 = (bytes: ArrayBuffer): string =>
{
let binary = '';
@@ -149,6 +187,9 @@ const deriveAesKey = async (privateKey: CryptoKey, serverKeyBase64: string): Pro
const getApiBase = (): string =>
{
+ const mode = getClientMode();
+ if(typeof mode.apiBaseUrl === 'string' && mode.apiBaseUrl.length) return mode.apiBaseUrl.replace(/\/$/, '');
+
const configured = (window as any).NitroSecureApiUrl;
if(typeof configured === 'string' && configured.length) return configured.replace(/\/$/, '');
@@ -156,8 +197,42 @@ const getApiBase = (): string =>
return 'http://localhost:8443/';
};
+const getPlainAssetBase = (kind: 'config' | 'gamedata'): string =>
+{
+ const mode = getClientMode();
+ const configured = kind === 'config' ? mode.plainConfigBaseUrl : mode.plainGamedataBaseUrl;
+
+ if(typeof configured === 'string' && configured.length) return configured.endsWith('/') ? configured : `${ configured }/`;
+
+ if(kind === 'config') return `${ window.location.origin }/`;
+
+ return `${ window.location.origin }/nitro/gamedata/`;
+};
+
+const mapSecureAssetRequestToPlainUrl = (requestUrl: string): string =>
+{
+ const url = new URL(requestUrl, window.location.href);
+ const kind = (url.searchParams.get('kind') || 'config') as 'config' | 'gamedata';
+ const file = (url.searchParams.get('file') || '').replace(/^[\\/]+/, '');
+ const plainUrl = new URL(file, getPlainAssetBase(kind));
+ const cacheBust = url.searchParams.get('v');
+
+ if(cacheBust) plainUrl.searchParams.set('v', cacheBust);
+
+ return plainUrl.toString();
+};
+
export const secureUrl = (kind: 'config' | 'gamedata', file: string, cacheBust = false): string =>
{
+ if(!getClientMode().secureAssetsEnabled)
+ {
+ const plainUrl = new URL(file.replace(/^\/+/, ''), `${ window.location.origin }/`);
+
+ if(cacheBust) plainUrl.searchParams.set('v', Date.now().toString(36));
+
+ return plainUrl.toString();
+ }
+
const base = getApiBase();
const version = cacheBust ? `&v=${ encodeURIComponent(Date.now().toString(36)) }` : '';
@@ -364,6 +439,8 @@ export const installSecureFetch = (): void =>
if(requestUrl.includes('/nitro-sec/file'))
{
+ if(!getClientMode().secureAssetsEnabled) return nativeFetch(mapSecureAssetRequestToPlainUrl(requestUrl), init);
+
const method = init?.method || (input instanceof Request ? input.method : 'GET');
const cacheKey = method.toUpperCase() === 'GET' ? normalizeSecureCacheKey(requestUrl) : null;
@@ -405,7 +482,7 @@ export const installSecureFetch = (): void =>
return cloneCachedResponse(responsePromise);
}
- if(isApiUrl(requestUrl))
+ if(getClientMode().secureApiEnabled && isApiUrl(requestUrl))
{
const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase();
const session = await getSecureSession();