Add Token Tray NSIS installer hosting and /agent download page.

Serve the Windows setup via /install, ship deploy/agent in Master, and document volume upload for the gitignored exe.
This commit is contained in:
Jos Vooges | STH 2026-09-23 00:36:40 +02:00
parent fcc816fa33
commit 36d02cab10
13 changed files with 362 additions and 13 deletions

View file

@ -0,0 +1,151 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { Nav, useAuth } from "@/components/Nav";
const MASTER_URL = "https://master.vonas.nl";
type TrayMeta = {
available?: boolean;
version?: string | null;
filename?: string;
sizeBytes?: number;
builtAt?: string | null;
productName?: string;
downloadUrl?: string;
note?: string;
};
function formatBytes(n: number): string {
if (!n || n <= 0) return "—";
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
export default function AgentPage() {
useAuth();
const [meta, setMeta] = useState<TrayMeta | null>(null);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
setErr(null);
try {
const res = await fetch(`${MASTER_URL}/install/token-tray.json`, {
cache: "no-store",
});
if (!res.ok) throw new Error(`Meta ophalen mislukt (${res.status})`);
const data = (await res.json()) as TrayMeta;
setMeta(data);
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
setMeta(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const available = Boolean(meta?.available);
const downloadHref =
meta?.downloadUrl?.trim() || `${MASTER_URL}/install/token-tray-setup.exe`;
return (
<>
<Nav />
<main className="container">
<header className="page-header">
<div>
<p className="page-kicker">Windows agent</p>
<h1>Token Tray</h1>
<p className="page-lead">
Installeer de tray-app op een Windows-PC. Die vernieuwt Viaplay- en F1TV-tokens
stil via Chrome/Edge — hands-off zolang de PC aan staat.
</p>
</div>
<button type="button" onClick={() => void load()} disabled={loading}>
{loading ? "Laden…" : "Vernieuwen"}
</button>
</header>
{err && <div className="error">{err}</div>}
<div className="section-head">
<h2 className="section-title">Download</h2>
<p className="muted">NSIS-installer (x64). SmartScreen kan waarschuwen — lokaal gebouwd, niet Store-gesigned.</p>
</div>
<div className="card">
{loading && !meta ? (
<p className="muted">Status ophalen…</p>
) : available ? (
<>
<p style={{ marginTop: 0 }}>
<strong>{meta?.productName || "Media Cluster Token Tray"}</strong>
{meta?.version ? (
<span className="muted"> · v{meta.version}</span>
) : null}
</p>
<p className="muted" style={{ fontSize: "0.9rem" }}>
{formatBytes(meta?.sizeBytes ?? 0)}
{meta?.builtAt
? ` · gebouwd ${new Date(meta.builtAt).toLocaleString("nl-NL")}`
: ""}
</p>
<a className="btn" href={downloadHref} style={{ display: "inline-block", marginTop: 8 }}>
Download installer
</a>
</>
) : (
<>
<p style={{ marginTop: 0 }}>Installer nog niet beschikbaar op Master.</p>
<p className="muted" style={{ fontSize: "0.9rem" }}>
{meta?.note ||
"Bouw op Windows: apps/token-tray → pack:win → publish:installer, plaats token-tray-setup.exe in het agent-volume (of rebuild image met deploy/agent/), daarna deze pagina vernieuwen."}
</p>
</>
)}
</div>
<div className="section-head" style={{ marginTop: "1.5rem" }}>
<h2 className="section-title">Na installatie</h2>
</div>
<div className="card">
<ol className="install-ol" style={{ margin: 0, paddingLeft: "1.25rem" }}>
<li>Run de installer → tray-icoon in het systeemvak.</li>
<li>
Open <strong>Instellingen…</strong> in het tray-menu.
</li>
<li>
Plak <strong>API URL</strong> + <strong>Tray key</strong> uit{" "}
<Link href="/settings">Instellingen → Token Tray</Link>.
</li>
<li>
Zorg dat Viaplay/F1TV-accounts een <strong>wachtwoord</strong> hebben onder{" "}
<Link href="/scripts">Scripts</Link>.
</li>
<li>
Laat de tray draaien (start mee met Windows). Poll elke 10 min; tokens &lt; 90 min
worden stil vernieuwd.
</li>
</ol>
</div>
<div className="section-head" style={{ marginTop: "1.5rem" }}>
<h2 className="section-title">Vereisten</h2>
</div>
<div className="card">
<ul style={{ margin: 0, paddingLeft: "1.25rem" }}>
<li>Windows 10/11 x64</li>
<li>Google Chrome of Microsoft Edge (Playwright channel)</li>
<li>Netwerk naar {MASTER_URL}</li>
</ul>
</div>
</main>
</>
);
}

View file

@ -784,8 +784,9 @@ export default function SettingsPage() {
<div className="section-head" style={{ marginTop: "1.5rem" }}> <div className="section-head" style={{ marginTop: "1.5rem" }}>
<h2 className="section-title">Token Tray</h2> <h2 className="section-title">Token Tray</h2>
<p className="muted"> <p className="muted">
Key voor de Windows Token Tray-app (<code>apps/token-tray</code>). Plak API-URL + Key voor de Windows Token Tray-app. Download de installer op{" "}
key in de tray-instellingen. <Link href="/agent">/agent</Link>, plak daarna API-URL + key in de
tray-instellingen.
</p> </p>
</div> </div>
<div className="card"> <div className="card">

View file

@ -13,7 +13,9 @@ function installDir(): string {
if ( if (
fs.existsSync(path.join(dir, "unraid.sh")) || fs.existsSync(path.join(dir, "unraid.sh")) ||
fs.existsSync(path.join(dir, "synology.sh")) || fs.existsSync(path.join(dir, "synology.sh")) ||
fs.existsSync(path.join(dir, "upgrade-node.sh")) fs.existsSync(path.join(dir, "upgrade-node.sh")) ||
fs.existsSync(path.join(dir, "token-tray-setup.exe")) ||
fs.existsSync(path.join(dir, "agent", "token-tray-setup.exe"))
) { ) {
return dir; return dir;
} }
@ -21,6 +23,20 @@ function installDir(): string {
return candidates[0]; return candidates[0];
} }
function tokenTrayPaths(dir: string): { exe: string; meta: string } {
const nested = path.join(dir, "agent");
if (fs.existsSync(path.join(nested, "token-tray-setup.exe"))) {
return {
exe: path.join(nested, "token-tray-setup.exe"),
meta: path.join(nested, "token-tray.json"),
};
}
return {
exe: path.join(dir, "token-tray-setup.exe"),
meta: path.join(dir, "token-tray.json"),
};
}
function sendInstallScript( function sendInstallScript(
reply: { header: (k: string, v: string) => unknown; status: (c: number) => { send: (b: string) => unknown }; send: (b: string) => unknown }, reply: { header: (k: string, v: string) => unknown; status: (c: number) => { send: (b: string) => unknown }; send: (b: string) => unknown },
dir: string, dir: string,
@ -67,4 +83,53 @@ export async function registerInstallRoutes(app: FastifyInstance, config: Config
return reply.send(fs.createReadStream(file)); return reply.send(fs.createReadStream(file));
}); });
} }
app.get("/install/token-tray.json", async (_request, reply) => {
const { exe, meta } = tokenTrayPaths(dir);
const available = fs.existsSync(exe);
let body: Record<string, unknown> = {
available,
version: null,
filename: "token-tray-setup.exe",
sizeBytes: 0,
downloadUrl: `${publicUrl.replace(/\/$/, "")}/install/token-tray-setup.exe`,
productName: "Media Cluster Token Tray",
};
if (fs.existsSync(meta)) {
try {
const parsed = JSON.parse(fs.readFileSync(meta, "utf8")) as Record<string, unknown>;
body = { ...body, ...parsed, available };
} catch {
/* ignore */
}
}
if (available) {
body.sizeBytes = fs.statSync(exe).size;
body.available = true;
} else {
body.available = false;
body.sizeBytes = 0;
}
reply.header("Content-Type", "application/json; charset=utf-8");
reply.header("Cache-Control", "no-store");
return reply.send(body);
});
app.get("/install/token-tray-setup.exe", async (_request, reply) => {
const { exe } = tokenTrayPaths(dir);
if (!fs.existsSync(exe)) {
return reply
.status(404)
.send(
"Token Tray installer not found — build with apps/token-tray pack:win + publish:installer, then redeploy."
);
}
reply.header("Content-Type", "application/octet-stream");
reply.header(
"Content-Disposition",
'attachment; filename="Media-Cluster-Token-Tray-Setup.exe"'
);
reply.header("Cache-Control", "no-store");
return reply.send(fs.createReadStream(exe));
});
} }

5
apps/token-tray/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
dist/
release/
*.log
.DS_Store

View file

@ -2,22 +2,43 @@
Windows system-tray helper: **stille browser-UI login** voor Viaplay + F1TV (geen HTTP-login → geen cmd-CAPTCHA). Windows system-tray helper: **stille browser-UI login** voor Viaplay + F1TV (geen HTTP-login → geen cmd-CAPTCHA).
## Admin ## Gebruikers (productie)
1. **Instellingen → Admin → Token Tray** → genereer key + kopieer API URL 1. Open **https://admin.vonas.nl/agent** → download NSIS-installer
2. **Scripts → Viaplay / F1TV** → account met **e-mail + wachtwoord** 2. Installeer → tray start mee met Windows
3. Tokens plakken is optioneel (tray vult die) 3. **Instellingen → Token Tray** → kopieer API URL + key naar de tray
4. **Scripts → Viaplay / F1TV** → accounts met e-mail + wachtwoord
## Tray ## Dev (zonder installer)
```powershell ```powershell
cd apps\token-tray cd apps\token-tray
pnpm.cmd install pnpm.cmd install
pnpm.cmd exec playwright install chromium
pnpm.cmd run build pnpm.cmd run build
pnpm.cmd run start pnpm.cmd run start
``` ```
## Installer bouwen + publiceren
```powershell
cd apps\token-tray
pnpm.cmd install
pnpm.cmd run pack:win
pnpm.cmd run publish:installer
```
Dat schrijft:
- `deploy/agent/token-tray-setup.exe`
- `deploy/agent/token-tray.json`
Master serveert:
- `GET /install/token-tray-setup.exe`
- `GET /install/token-tray.json`
De `.exe` staat in `.gitignore`. Op Dokploy: mount/upload naar volume `agent_install` → `/app/install/agent/`, of force-add + rebuild image.
## Auto-modus (hands-off) ## Auto-modus (hands-off)
Zolang de tray op de Windows-PC draait: Zolang de tray op de Windows-PC draait:
@ -32,5 +53,6 @@ Handmatig blijft beschikbaar: *Nu checken* / *Alles vernieuwen*.
## API (tray-key) ## API (tray-key)
- `GET /api/v1/agent/accounts` — accounts + wachtwoorden - `GET /api/v1/agent/accounts` — accounts + wachtwoorden
- `GET /api/v1/agent/refresh-due`
- `POST /api/v1/agent/viaplay/tokens` - `POST /api/v1/agent/viaplay/tokens`
- `POST /api/v1/agent/f1tv/tokens` - `POST /api/v1/agent/f1tv/tokens`

View file

@ -3,13 +3,16 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"description": "Windows system tray helper to refresh Viaplay/F1TV session tokens", "description": "Windows system tray helper to refresh Viaplay/F1TV session tokens",
"author": "Vonas Media Cluster",
"main": "dist/main.js", "main": "dist/main.js",
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"dev": "tsc -p tsconfig.json && electron .", "dev": "tsc -p tsconfig.json && electron .",
"start": "electron .", "start": "electron .",
"postinstall": "node -e \"try{require('child_process').execSync('playwright install chromium',{stdio:'inherit'})}catch(e){console.warn('playwright chromium download skipped — tray gebruikt system Chrome/Edge')}\"", "postinstall": "node -e \"try{require('child_process').execSync('playwright install chromium',{stdio:'inherit'})}catch(e){console.warn('playwright chromium download skipped — tray gebruikt system Chrome/Edge')}\"",
"pack:win": "npm run build && electron-builder --win dir" "pack:win": "pnpm run build && electron-builder --win nsis",
"pack:win:dir": "pnpm run build && electron-builder --win dir",
"publish:installer": "node scripts/publish-installer.cjs"
}, },
"dependencies": { "dependencies": {
"playwright": "^1.49.1" "playwright": "^1.49.1"
@ -23,6 +26,7 @@
"build": { "build": {
"appId": "nl.vonas.mediacluster.token-tray", "appId": "nl.vonas.mediacluster.token-tray",
"productName": "Media Cluster Token Tray", "productName": "Media Cluster Token Tray",
"copyright": "Copyright © Vonas",
"directories": { "directories": {
"output": "release" "output": "release"
}, },
@ -30,9 +34,29 @@
"dist/**/*", "dist/**/*",
"package.json" "package.json"
], ],
"extraMetadata": {
"main": "dist/main.js"
},
"win": { "win": {
"target": ["dir"], "target": [
"signAndEditExecutable": false {
"target": "nsis",
"arch": ["x64"]
}
],
"signAndEditExecutable": false,
"artifactName": "token-tray-setup.${ext}"
},
"nsis": {
"oneClick": true,
"perMachine": false,
"allowToChangeInstallationDirectory": false,
"createDesktopShortcut": false,
"createStartMenuShortcut": true,
"shortcutName": "Media Cluster Token Tray",
"runAfterFinish": true,
"uninstallDisplayName": "Media Cluster Token Tray",
"deleteAppDataOnUninstall": false
} }
} }
} }

View file

@ -0,0 +1,40 @@
/**
* Copy NSIS installer → deploy/agent/ for Docker/Dokploy serving via /install/token-tray-setup.exe
*/
const fs = require("fs");
const path = require("path");
const root = path.join(__dirname, "..");
const releaseDir = path.join(root, "release");
const outDir = path.join(root, "..", "..", "deploy", "agent");
const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
const candidates = [
path.join(releaseDir, "token-tray-setup.exe"),
...fs
.readdirSync(releaseDir)
.filter((f) => /setup.*\.exe$/i.test(f) || /token-tray.*\.exe$/i.test(f))
.map((f) => path.join(releaseDir, f)),
];
const src = candidates.find((f) => fs.existsSync(f));
if (!src) {
console.error("Geen installer gevonden in apps/token-tray/release/ — run: pnpm pack:win");
process.exit(1);
}
fs.mkdirSync(outDir, { recursive: true });
const destExe = path.join(outDir, "token-tray-setup.exe");
fs.copyFileSync(src, destExe);
const stat = fs.statSync(destExe);
const meta = {
version: pkg.version,
filename: "token-tray-setup.exe",
sizeBytes: stat.size,
builtAt: new Date().toISOString(),
productName: "Media Cluster Token Tray",
available: true,
};
fs.writeFileSync(path.join(outDir, "token-tray.json"), JSON.stringify(meta, null, 2));
console.log("Published:", destExe);
console.log(meta);

View file

@ -254,6 +254,14 @@ function startPolling() {
} }
app.whenReady().then(() => { app.whenReady().then(() => {
try {
app.setLoginItemSettings({
openAtLogin: true,
openAsHidden: true,
});
} catch {
/* ignore — niet op alle platforms beschikbaar */
}
cfg = loadConfig(); cfg = loadConfig();
createTray(); createTray();
if (!cfg.trayKey) openSettings(); if (!cfg.trayKey) openSettings();

20
deploy/agent/.gitignore vendored Normal file
View file

@ -0,0 +1,20 @@
# Token Tray Windows installer (NSIS)
#
# Build on Windows:
# cd apps/token-tray
# pnpm.cmd install
# pnpm.cmd run pack:win
# pnpm.cmd run publish:installer
#
# That writes:
# deploy/agent/token-tray-setup.exe
# deploy/agent/token-tray.json
#
# Master serves:
# GET /install/token-tray-setup.exe
# GET /install/token-tray.json
# Admin page: https://admin.vonas.nl/agent
*.exe
*.blockmap
!.gitkeep

0
deploy/agent/.gitkeep Normal file
View file

View file

@ -0,0 +1,9 @@
{
"version": "0.0.0",
"filename": "token-tray-setup.exe",
"sizeBytes": 0,
"builtAt": null,
"productName": "Media Cluster Token Tray",
"available": false,
"note": "Run apps/token-tray pack:win + publish:installer on Windows, copy token-tray-setup.exe into the Dokploy agent_install volume (/app/install/agent/), then refresh /agent."
}

View file

@ -49,6 +49,7 @@ COPY --from=node-build /media-node-linux-arm64 /app/install/media-node-linux-arm
COPY deploy/node/install-unraid.sh /app/install/unraid.sh COPY deploy/node/install-unraid.sh /app/install/unraid.sh
COPY deploy/node/install-synology.sh /app/install/synology.sh COPY deploy/node/install-synology.sh /app/install/synology.sh
COPY deploy/node/upgrade-node.sh /app/install/upgrade-node.sh COPY deploy/node/upgrade-node.sh /app/install/upgrade-node.sh
COPY deploy/agent/ /app/install/agent/
COPY apps/master-api/scripts/odido_cdm.py /app/scripts/odido_cdm.py COPY apps/master-api/scripts/odido_cdm.py /app/scripts/odido_cdm.py
# Remove workspace protocol deps before npm install # Remove workspace protocol deps before npm install

View file

@ -72,6 +72,8 @@ services:
volumes: volumes:
# Persist Odido WVD.wvd (admin-upload → /data/odido/WVD.wvd) # Persist Odido WVD.wvd (admin-upload → /data/odido/WVD.wvd)
- odido_data:/data/odido - odido_data:/data/odido
# Optional: mount/upload Token Tray NSIS installer here as token-tray-setup.exe
- agent_install:/app/install/agent
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 15s interval: 15s
@ -106,6 +108,7 @@ volumes:
postgres_data: postgres_data:
redis_data: redis_data:
odido_data: odido_data:
agent_install:
networks: networks:
media-cluster: media-cluster: