Redeploy game em
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
{ config, lib, pkgs, homeyConfig, ... }:
|
||||
|
||||
# Aim — ML experiment tracking server.
|
||||
#
|
||||
# Auth model: none on the server itself.
|
||||
# - Web UI: protected by Caddy → Authelia two_factor (admins only).
|
||||
# - Python SDK: connects via aim://192.168.1.100:53800 directly on LAN;
|
||||
# the firewall restricts port 53800 to 192.168.1.0/24 only.
|
||||
#
|
||||
# Two containers share one volume:
|
||||
# aim-server — tracking protocol server (aim:// SDK connections)
|
||||
# aim-ui — web UI + REST API (behind Caddy)
|
||||
#
|
||||
# Volume layout:
|
||||
# <dataDir>/aim/ → /aim in both containers (shared .aim data store)
|
||||
|
||||
let
|
||||
cfg = config.homey.aim;
|
||||
dataDir = config.homey.storage.mountPoint;
|
||||
domain = homeyConfig.domain;
|
||||
in
|
||||
{
|
||||
options.homey.aim = {
|
||||
enable = lib.mkEnableOption "Aim experiment tracking server";
|
||||
|
||||
image = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "aimstack/aim:latest";
|
||||
};
|
||||
|
||||
uiPort = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 43800;
|
||||
description = "Host port for the Aim web UI (bound to 127.0.0.1, Caddy proxy).";
|
||||
};
|
||||
|
||||
serverPort = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 53800;
|
||||
description = "Host port for the Aim tracking server (aim:// protocol, LAN-accessible).";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
# -------------------------------------------------------------------------
|
||||
# Tracking server — aim:// SDK protocol, LAN only
|
||||
# -------------------------------------------------------------------------
|
||||
virtualisation.oci-containers.containers.aim-server = {
|
||||
image = cfg.image;
|
||||
# Bound to 0.0.0.0 so LAN training scripts can reach it directly.
|
||||
# Port 53800 is restricted to 192.168.1.0/24 via extraCommands below.
|
||||
ports = [ "0.0.0.0:${toString cfg.serverPort}:53800" ];
|
||||
volumes = [ "${dataDir}/aim:/aim" ];
|
||||
extraOptions = [ "--network=homey" ];
|
||||
cmd = [ "aim" "server" "--host" "0.0.0.0" "--port" "53800" "/aim" ];
|
||||
};
|
||||
|
||||
systemd.services."podman-aim-server" = {
|
||||
after = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
|
||||
requires = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
|
||||
};
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Web UI — Caddy proxy, Authelia-protected
|
||||
# -------------------------------------------------------------------------
|
||||
virtualisation.oci-containers.containers.aim-ui = {
|
||||
image = cfg.image;
|
||||
ports = [ "127.0.0.1:${toString cfg.uiPort}:43800" ];
|
||||
volumes = [ "${dataDir}/aim:/aim" ];
|
||||
extraOptions = [ "--network=homey" ];
|
||||
cmd = [ "aim" "ui" "--host" "0.0.0.0" "--port" "43800" "/aim" ];
|
||||
};
|
||||
|
||||
systemd.services."podman-aim-ui" = {
|
||||
after = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" "podman-aim-server.service" ];
|
||||
requires = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
|
||||
};
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Firewall — restrict tracking port to LAN subnet only
|
||||
# -------------------------------------------------------------------------
|
||||
networking.firewall.extraCommands = lib.mkAfter ''
|
||||
iptables -A nixos-fw -p tcp --dport ${toString cfg.serverPort} -s 192.168.1.0/24 -j nixos-fw-accept
|
||||
'';
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Caddy virtual host — UI behind Authelia forward_auth
|
||||
# -------------------------------------------------------------------------
|
||||
homey.caddy.virtualHosts = [{
|
||||
subdomain = "aim";
|
||||
port = cfg.uiPort;
|
||||
auth = true;
|
||||
}];
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Authelia — two_factor + deny, admins only (priority 27–28)
|
||||
# -------------------------------------------------------------------------
|
||||
homey.authelia.accessControlRules = [
|
||||
{ priority = 27; domain = [ "aim.${domain}" ]; policy = "two_factor"; subject = [ "group:admins" ]; }
|
||||
{ priority = 28; domain = [ "aim.${domain}" ]; policy = "deny"; }
|
||||
];
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Storage directory
|
||||
# -------------------------------------------------------------------------
|
||||
homey.storage.extraDirs = [
|
||||
{ path = "aim"; mode = "0755"; }
|
||||
];
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Backup — the .aim binary store holds all run data
|
||||
# -------------------------------------------------------------------------
|
||||
homey.backup.extraPaths = [ "${dataDir}/aim" ];
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Uptime Kuma monitor
|
||||
# -------------------------------------------------------------------------
|
||||
homey.monitoring.monitors = [{
|
||||
name = "Aim";
|
||||
url = "https://aim.${domain}";
|
||||
interval = 60;
|
||||
}];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,868 @@
|
||||
#!/usr/bin/env python3
|
||||
"""EmulatorJS launcher and per-user save-sync server.
|
||||
|
||||
Saves and favourites are keyed by the Remote-User header (set by Authelia
|
||||
via Caddy forward_auth), so each authenticated user gets their own data.
|
||||
|
||||
Offline mode: a Service Worker caches static assets and ROMs in the browser
|
||||
after first play. Saves made while offline are queued in IndexedDB and synced
|
||||
to the server automatically when connectivity is restored.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.parse
|
||||
import zipfile
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from string import Template
|
||||
|
||||
SAVE_DIR = os.environ.get("SAVE_DIR", "/mnt/data/emulatorjs/saves")
|
||||
ROM_DIR = os.environ.get("ROM_DIR", "/mnt/data/emulatorjs/roms")
|
||||
STATIC_DIR = os.environ.get("STATIC_DIR", "/mnt/data/emulatorjs/static")
|
||||
THUMBNAILS_DIR = os.environ.get("THUMBNAILS_DIR", "/mnt/data/emulatorjs/thumbnails")
|
||||
PORT = int(os.environ.get("PORT", "8085"))
|
||||
|
||||
SAFE_RE = re.compile(r"^[A-Za-z0-9 _\-\.\(\)\[\]',!?:&+#]+$")
|
||||
|
||||
ROM_SYSTEMS = {
|
||||
".gb": "gameboy",
|
||||
".gbc": "gameboy_color",
|
||||
".gba": "gba",
|
||||
".nes": "nes",
|
||||
".snes": "snes",
|
||||
".sfc": "snes",
|
||||
".md": "segaMD",
|
||||
".gen": "segaMD",
|
||||
".n64": "n64",
|
||||
".z64": "n64",
|
||||
}
|
||||
|
||||
RECOGNIZED_EXTS = set(ROM_SYSTEMS.keys()) | {".zip"}
|
||||
|
||||
_system_cache: dict = {}
|
||||
|
||||
def detect_system(filepath: str) -> str:
|
||||
"""Return the EmulatorJS core name for a ROM, peeking inside zips."""
|
||||
if filepath in _system_cache:
|
||||
return _system_cache[filepath]
|
||||
ext = os.path.splitext(filepath)[1].lower()
|
||||
if ext != ".zip":
|
||||
result = ROM_SYSTEMS.get(ext, "gameboy")
|
||||
else:
|
||||
result = "gameboy"
|
||||
try:
|
||||
with zipfile.ZipFile(filepath, "r") as zf:
|
||||
for inner in zf.namelist():
|
||||
inner_ext = os.path.splitext(inner)[1].lower()
|
||||
if inner_ext in ROM_SYSTEMS:
|
||||
result = ROM_SYSTEMS[inner_ext]
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
_system_cache[filepath] = result
|
||||
return result
|
||||
|
||||
MIME_TYPES = {
|
||||
".js": "application/javascript",
|
||||
".css": "text/css",
|
||||
".wasm": "application/wasm",
|
||||
".data": "application/octet-stream",
|
||||
".json": "application/json",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
}
|
||||
|
||||
# ── Service Worker ─────────────────────────────────────────────────────────────
|
||||
SW_JS = """\
|
||||
const CACHE_NAME = 'emulatorjs-v4';
|
||||
|
||||
self.addEventListener('install', function(event) {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then(function(cache) {
|
||||
return cache.addAll(['/static/loader.js', '/']);
|
||||
})
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', function(event) {
|
||||
event.waitUntil(
|
||||
caches.keys().then(function(keys) {
|
||||
return Promise.all(
|
||||
keys.filter(function(k) { return k !== CACHE_NAME; })
|
||||
.map(function(k) { return caches.delete(k); })
|
||||
);
|
||||
})
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', function(event) {
|
||||
var url = new URL(event.request.url);
|
||||
var path = url.pathname;
|
||||
|
||||
if (path === '/sw.js' || path === '/manifest.json') return;
|
||||
|
||||
if (path.startsWith('/saves/')) {
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(function() {
|
||||
return new Response('', { status: 503 });
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets and individual ROM files: cache-first.
|
||||
// Use arrayBuffer() instead of clone() to avoid an iOS Safari bug where
|
||||
// a cloned response body can be empty when the original stream is consumed.
|
||||
if (path.startsWith('/static/') || (path.startsWith('/roms/') && path !== '/roms/')) {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then(function(cached) {
|
||||
if (cached) return cached;
|
||||
return fetch(event.request).then(function(response) {
|
||||
if (!response.ok) return response;
|
||||
var status = response.status;
|
||||
var headers = new Headers(response.headers);
|
||||
return response.arrayBuffer().then(function(body) {
|
||||
caches.open(CACHE_NAME).then(function(cache) {
|
||||
cache.put(event.request,
|
||||
new Response(body, { status: status, headers: headers })
|
||||
).catch(function(e) {
|
||||
console.warn('[SW] cache.put failed (quota?):', event.request.url, e);
|
||||
});
|
||||
});
|
||||
return new Response(body, { status: status, headers: headers });
|
||||
});
|
||||
}).catch(function() {
|
||||
return new Response('', { status: 503 });
|
||||
});
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Player pages: network-first so new deployments always take effect.
|
||||
// Cache is used only as offline fallback.
|
||||
if (path.startsWith('/play/')) {
|
||||
event.respondWith(
|
||||
fetch(event.request).then(function(response) {
|
||||
var status = response.status, headers = new Headers(response.headers);
|
||||
return response.arrayBuffer().then(function(body) {
|
||||
caches.open(CACHE_NAME).then(function(cache) {
|
||||
cache.put(event.request, new Response(body, { status: status, headers: headers }));
|
||||
});
|
||||
return new Response(body, { status: status, headers: headers });
|
||||
});
|
||||
}).catch(function() {
|
||||
return caches.match(event.request).then(function(cached) {
|
||||
return cached || new Response('Offline', { status: 503 });
|
||||
});
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Thumbnails: cache on first successful fetch
|
||||
if (path.startsWith('/thumbnails/')) {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then(function(cached) {
|
||||
if (cached) return cached;
|
||||
return fetch(event.request).then(function(response) {
|
||||
if (!response.ok) return response;
|
||||
var status = response.status, headers = new Headers(response.headers);
|
||||
return response.arrayBuffer().then(function(body) {
|
||||
caches.open(CACHE_NAME).then(function(cache) {
|
||||
cache.put(event.request, new Response(body, { status: status, headers: headers }));
|
||||
});
|
||||
return new Response(body, { status: status, headers: headers });
|
||||
});
|
||||
}).catch(function() { return new Response('', { status: 404 }); });
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// ROM listing, favorites, launcher: network-first, cache fallback (small text responses)
|
||||
event.respondWith(
|
||||
fetch(event.request).then(function(response) {
|
||||
var status = response.status, headers = new Headers(response.headers);
|
||||
return response.arrayBuffer().then(function(body) {
|
||||
caches.open(CACHE_NAME).then(function(cache) {
|
||||
cache.put(event.request, new Response(body, { status: status, headers: headers }));
|
||||
});
|
||||
return new Response(body, { status: status, headers: headers });
|
||||
});
|
||||
}).catch(function() {
|
||||
return caches.match(event.request).then(function(cached) {
|
||||
return cached || new Response('', { status: 503 });
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
"""
|
||||
|
||||
# ── Shared JS: IndexedDB offline save queue ────────────────────────────────────
|
||||
_IDB_JS = """\
|
||||
function openIDB() {
|
||||
if (!window.indexedDB) return Promise.reject(new Error('no IDB'));
|
||||
return new Promise(function(resolve, reject) {
|
||||
var req = indexedDB.open('emulatorjs', 1);
|
||||
req.onupgradeneeded = function(e) {
|
||||
e.target.result.createObjectStore('pending-saves', { keyPath: 'name' });
|
||||
};
|
||||
req.onsuccess = function(e) { resolve(e.target.result); };
|
||||
req.onerror = reject;
|
||||
req.onblocked = function() { reject(new Error('IDB blocked')); };
|
||||
});
|
||||
}
|
||||
|
||||
async function idbPut(name, data) {
|
||||
const db = await openIDB();
|
||||
return new Promise(function(resolve, reject) {
|
||||
const tx = db.transaction('pending-saves', 'readwrite');
|
||||
tx.objectStore('pending-saves').put({ name: name, data: Array.from(data), ts: Date.now() });
|
||||
tx.oncomplete = resolve;
|
||||
tx.onerror = reject;
|
||||
});
|
||||
}
|
||||
|
||||
async function idbGet(name) {
|
||||
const db = await openIDB();
|
||||
return new Promise(function(resolve) {
|
||||
const tx = db.transaction('pending-saves', 'readonly');
|
||||
const req = tx.objectStore('pending-saves').get(name);
|
||||
req.onsuccess = function(e) {
|
||||
resolve(e.target.result ? new Uint8Array(e.target.result.data) : null);
|
||||
};
|
||||
req.onerror = function() { resolve(null); };
|
||||
});
|
||||
}
|
||||
|
||||
async function idbGetAll() {
|
||||
const db = await openIDB();
|
||||
return new Promise(function(resolve) {
|
||||
const tx = db.transaction('pending-saves', 'readonly');
|
||||
const req = tx.objectStore('pending-saves').getAll();
|
||||
req.onsuccess = function(e) { resolve(e.target.result || []); };
|
||||
req.onerror = function() { resolve([]); };
|
||||
});
|
||||
}
|
||||
|
||||
async function idbDelete(name) {
|
||||
const db = await openIDB();
|
||||
return new Promise(function(resolve) {
|
||||
const tx = db.transaction('pending-saves', 'readwrite');
|
||||
tx.objectStore('pending-saves').delete(name);
|
||||
tx.oncomplete = resolve;
|
||||
tx.onerror = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
async function syncPendingSaves() {
|
||||
if (!navigator.onLine) return;
|
||||
try {
|
||||
const pending = await idbGetAll();
|
||||
for (const entry of pending) {
|
||||
try {
|
||||
const r = await fetch('/saves/' + encodeURIComponent(entry.name), {
|
||||
method: 'PUT',
|
||||
body: new Uint8Array(entry.data),
|
||||
headers: { 'Content-Type': 'application/octet-stream' }
|
||||
});
|
||||
if (r.ok) await idbDelete(entry.name);
|
||||
} catch(e) { break; }
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
"""
|
||||
|
||||
# ── Shared JS: Service Worker registration ─────────────────────────────────────
|
||||
_SW_REG = """\
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(function(e) {
|
||||
console.warn('SW registration failed', e);
|
||||
});
|
||||
}
|
||||
"""
|
||||
|
||||
# ── Game launcher ──────────────────────────────────────────────────────────────
|
||||
LAUNCHER_HTML = ("""\
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Game Library</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: system-ui, sans-serif; background: #1a1a2e; color: #eee;
|
||||
max-width: 960px; margin: 0 auto; padding: 1.5rem 1rem; }
|
||||
h1 { color: #e94560; margin: 0 0 1rem; }
|
||||
#offline-banner {
|
||||
display: none; background: #c0392b; color: #fff; text-align: center;
|
||||
padding: .5rem; font-size: .9rem; margin: -1.5rem -1rem 1rem;
|
||||
}
|
||||
#dbg-bar {
|
||||
font-size: .7rem; color: #9ab; margin-bottom: .75rem; font-family: monospace;
|
||||
background: #0d1527; border-radius: 6px; padding: .4rem .75rem;
|
||||
}
|
||||
#search {
|
||||
width: 100%; padding: .7rem 1rem; font-size: 1rem;
|
||||
background: #16213e; border: 1px solid #0f3460; border-radius: 8px;
|
||||
color: #eee; outline: none; margin-bottom: 1.5rem; -webkit-appearance: none;
|
||||
}
|
||||
#search:focus { border-color: #e94560; }
|
||||
#search::placeholder { color: #555; }
|
||||
.section-label {
|
||||
grid-column: 1 / -1; font-size: .7rem; font-weight: 700; letter-spacing: .1em;
|
||||
text-transform: uppercase; color: #9ab; padding-bottom: .4rem;
|
||||
border-bottom: 1px solid #16213e; margin-top: .25rem;
|
||||
}
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: .875rem; }
|
||||
.card {
|
||||
background: #16213e; border-radius: 10px; overflow: hidden; position: relative;
|
||||
text-decoration: none; color: #eee; display: block;
|
||||
transition: transform .15s, background .15s;
|
||||
}
|
||||
.card:hover { background: #0f3460; transform: translateY(-2px); }
|
||||
.thumb-wrap { width: 100%; aspect-ratio: 3/4; background: #0d1527;
|
||||
display: flex; align-items: center; justify-content: center; overflow: hidden; }
|
||||
.thumb-wrap img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.no-thumb { font-size: 2.5rem; color: #222; }
|
||||
.card-body { padding: .6rem .75rem; }
|
||||
.card-title { font-weight: 600; font-size: .8rem; line-height: 1.3; word-break: break-word;
|
||||
display: -webkit-box; -webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical; overflow: hidden; }
|
||||
.card-sys { font-size: .65rem; color: #9ab; margin-top: .2rem; text-transform: uppercase; }
|
||||
.star {
|
||||
position: absolute; top: .4rem; right: .4rem; z-index: 1;
|
||||
background: rgba(0,0,0,.55); border: none; border-radius: 50%;
|
||||
width: 2rem; height: 2rem; font-size: 1rem; line-height: 1; cursor: pointer;
|
||||
color: #888; display: flex; align-items: center; justify-content: center; padding: 0;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.star:hover { background: rgba(0,0,0,.85); }
|
||||
.star.fav { color: #f5c518; }
|
||||
.cache-dot {
|
||||
display: none; position: absolute; bottom: .4rem; left: .4rem;
|
||||
width: .55rem; height: .55rem; border-radius: 50%; background: #2ecc71;
|
||||
title: "Cached for offline";
|
||||
}
|
||||
.empty { color: #555; grid-column: 1 / -1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="offline-banner">Offline — saves will sync when reconnected</div>
|
||||
<h1>Game Library</h1>
|
||||
<div id="dbg-bar">SW: checking… | Online: checking…</div>
|
||||
<input id="search" type="search" placeholder="Search…" autocomplete="off" spellcheck="false">
|
||||
<div class="grid" id="grid"><p class="empty">Loading…</p></div>
|
||||
<script>
|
||||
""" + _IDB_JS + """
|
||||
var allGames = [];
|
||||
var favSet = new Set();
|
||||
|
||||
function thumbErr(img) {
|
||||
img.parentNode.innerHTML = '<span class="no-thumb">\U0001f3ae</span>';
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function card(g) {
|
||||
var isFav = favSet.has(g.name);
|
||||
var thumb = '/thumbnails/' + encodeURIComponent(g.title);
|
||||
var play = '/play/' + encodeURIComponent(g.name);
|
||||
return '<a class="card" href="' + play + '" data-rom="' + esc(g.name) + '">'
|
||||
+ '<div class="thumb-wrap">'
|
||||
+ '<img src="' + thumb + '" alt="" loading="lazy" onerror="thumbErr(this)">'
|
||||
+ '</div>'
|
||||
+ '<div class="card-body">'
|
||||
+ '<div class="card-title">' + esc(g.title) + '</div>'
|
||||
+ '<div class="card-sys">' + esc(g.system) + '</div>'
|
||||
+ '</div>'
|
||||
+ '<button class="star' + (isFav ? ' fav' : '') + '"'
|
||||
+ ' data-name="' + esc(g.name) + '"'
|
||||
+ ' onclick="toggleFav(event,this)"'
|
||||
+ ' title="' + (isFav ? 'Remove from favourites' : 'Add to favourites') + '">'
|
||||
+ (isFav ? '★' : '☆')
|
||||
+ '</button>'
|
||||
+ '<span class="cache-dot" title="Cached for offline"></span>'
|
||||
+ '</a>';
|
||||
}
|
||||
|
||||
function render(q) {
|
||||
var term = q.trim().toLowerCase();
|
||||
var list = term ? allGames.filter(function(g) {
|
||||
return g.title.toLowerCase().indexOf(term) !== -1;
|
||||
}) : allGames;
|
||||
|
||||
if (!list.length) {
|
||||
document.getElementById('grid').innerHTML = '<p class="empty">No games found.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var faved = list.filter(function(g) { return favSet.has(g.name); });
|
||||
var others = list.filter(function(g) { return !favSet.has(g.name); });
|
||||
var html = '';
|
||||
|
||||
if (faved.length && others.length) html += '<div class="section-label">Favourites</div>';
|
||||
faved.forEach(function(g) { html += card(g); });
|
||||
if (faved.length && others.length) html += '<div class="section-label">All Games</div>';
|
||||
others.forEach(function(g) { html += card(g); });
|
||||
|
||||
document.getElementById('grid').innerHTML = html;
|
||||
updateCacheStatus();
|
||||
}
|
||||
|
||||
async function toggleFav(e, btn) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var name = btn.dataset.name;
|
||||
var adding = !favSet.has(name);
|
||||
if (adding) favSet.add(name); else favSet.delete(name);
|
||||
render(document.getElementById('search').value);
|
||||
await fetch('/favorites/' + encodeURIComponent(name), { method: adding ? 'POST' : 'DELETE' });
|
||||
}
|
||||
|
||||
document.getElementById('search').addEventListener('input', function() {
|
||||
render(this.value);
|
||||
});
|
||||
|
||||
async function updateCacheStatus() {
|
||||
if (!('caches' in window)) return;
|
||||
try {
|
||||
const cache = await caches.open('emulatorjs-v4');
|
||||
const keys = await cache.keys();
|
||||
const cached = new Set(
|
||||
keys.map(function(r) {
|
||||
return decodeURIComponent(new URL(r.url).pathname);
|
||||
}).filter(function(p) {
|
||||
return p.startsWith('/roms/');
|
||||
}).map(function(p) {
|
||||
return p.slice('/roms/'.length);
|
||||
})
|
||||
);
|
||||
document.querySelectorAll('.card[data-rom]').forEach(function(card) {
|
||||
if (cached.has(card.dataset.rom)) {
|
||||
card.querySelector('.cache-dot').style.display = 'block';
|
||||
}
|
||||
});
|
||||
return cached;
|
||||
} catch(e) { return new Set(); }
|
||||
}
|
||||
|
||||
async function updateDbgBar() {
|
||||
var swReg = 'none', swCtrl = 'NO';
|
||||
if ('serviceWorker' in navigator) {
|
||||
var reg = await navigator.serviceWorker.getRegistration('/');
|
||||
if (reg) swReg = reg.active ? 'active' : (reg.installing ? 'installing' : 'waiting');
|
||||
if (navigator.serviceWorker.controller) swCtrl = 'YES';
|
||||
}
|
||||
var cached = await updateCacheStatus();
|
||||
var dbg = document.getElementById('dbg-bar');
|
||||
dbg.textContent = 'SW reg: ' + swReg + ' ctrl: ' + swCtrl
|
||||
+ ' | Online: ' + (navigator.onLine ? 'yes' : 'NO')
|
||||
+ ' | Cached ROMs: ' + (cached ? cached.size : '?');
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
fetch('/roms/').then(function(r) { return r.json(); }),
|
||||
fetch('/favorites').then(function(r) { return r.json(); }).catch(function() { return []; })
|
||||
]).then(function(results) {
|
||||
allGames = results[0];
|
||||
favSet = new Set(results[1]);
|
||||
render(document.getElementById('search').value);
|
||||
updateDbgBar();
|
||||
});
|
||||
|
||||
window.addEventListener('online', function() {
|
||||
document.getElementById('offline-banner').style.display = 'none';
|
||||
syncPendingSaves();
|
||||
updateDbgBar();
|
||||
});
|
||||
window.addEventListener('offline', function() {
|
||||
document.getElementById('offline-banner').style.display = '';
|
||||
updateDbgBar();
|
||||
});
|
||||
if (!navigator.onLine) { document.getElementById('offline-banner').style.display = ''; }
|
||||
""" + _SW_REG + """\
|
||||
</script>
|
||||
</body>
|
||||
</html>""")
|
||||
|
||||
# ── Player page ────────────────────────────────────────────────────────────────
|
||||
PLAYER_TMPL = Template("""\
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>$title</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #000; }
|
||||
#game { width: 100vw; height: 100vh; }
|
||||
#back {
|
||||
position: fixed; top: .75rem; left: .75rem; z-index: 9999;
|
||||
color: #fff; text-decoration: none; background: rgba(0,0,0,.6);
|
||||
padding: .3rem .8rem; border-radius: 6px; font-family: system-ui;
|
||||
}
|
||||
#save-btn {
|
||||
position: fixed; top: .75rem; left: 7.5rem; z-index: 9999;
|
||||
color: #fff; background: rgba(0,0,0,.6); border: none;
|
||||
padding: .3rem .8rem; border-radius: 6px;
|
||||
font-family: system-ui; font-size: 1rem; cursor: pointer;
|
||||
}
|
||||
#save-btn:disabled { opacity: .6; cursor: default; }
|
||||
#offline-banner {
|
||||
display: none; position: fixed; top: 0; left: 0; right: 0; z-index: 9998;
|
||||
background: #c0392b; color: #fff; text-align: center;
|
||||
padding: .5rem; font-family: system-ui; font-size: .9rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="offline-banner">Offline — saves will sync when reconnected</div>
|
||||
<a id="back" href="/">← Library</a>
|
||||
<button id="save-btn" onclick="manualSave()">Save</button>
|
||||
<div id="game"></div>
|
||||
<script>
|
||||
const GAME = $game_json;
|
||||
const SAVE_URL = '/saves/' + encodeURIComponent(GAME.name);
|
||||
""" + _IDB_JS + """
|
||||
function showOfflineBanner(msg) {
|
||||
var b = document.getElementById('offline-banner');
|
||||
b.textContent = msg || 'Offline — saves will sync when reconnected';
|
||||
b.style.display = '';
|
||||
}
|
||||
function hideOfflineBanner() { document.getElementById('offline-banner').style.display = 'none'; }
|
||||
|
||||
async function preflightCheck() {
|
||||
void('1/4 start');
|
||||
try {
|
||||
var swReg = 'none', swCtrl = 'NO';
|
||||
if ('serviceWorker' in navigator) {
|
||||
void('2/4 SW reg…');
|
||||
const reg = await navigator.serviceWorker.getRegistration('/');
|
||||
if (reg) swReg = reg.active ? 'active' : (reg.installing ? 'installing' : 'waiting');
|
||||
if (navigator.serviceWorker.controller) swCtrl = 'YES';
|
||||
}
|
||||
|
||||
void('3/4 cache…');
|
||||
var romCached = false, romInfo = 'NOT in cache';
|
||||
if ('caches' in window) {
|
||||
const cache = await caches.open('emulatorjs-v4');
|
||||
const hit = await cache.match('/roms/' + encodeURIComponent(GAME.name));
|
||||
if (hit) {
|
||||
const cl = hit.headers.get('content-length');
|
||||
const sz = cl ? parseInt(cl) : -1;
|
||||
romCached = sz > 0;
|
||||
romInfo = sz > 0 ? 'cached ' + sz + 'B' : 'in cache but 0B (EMPTY!)';
|
||||
}
|
||||
}
|
||||
|
||||
void('4/4 done — SW: ' + swReg + '/' + swCtrl
|
||||
+ ' | ROM: ' + romInfo
|
||||
+ ' | Online: ' + (navigator.onLine ? 'yes' : 'NO')
|
||||
+ '\n' + GAME.name);
|
||||
|
||||
if (!navigator.onLine && !romCached) {
|
||||
showOfflineBanner('Not available offline — open this game while connected to download it.');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch(e) {
|
||||
void('preflight error: ' + e + '\n' + GAME.name);
|
||||
return true; // fail open — try to load EmulatorJS anyway
|
||||
}
|
||||
}
|
||||
|
||||
async function manualSave() {
|
||||
const btn = document.getElementById('save-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Saving...';
|
||||
await pushSave();
|
||||
btn.textContent = 'Saved!';
|
||||
setTimeout(function() { btn.textContent = 'Save'; btn.disabled = false; }, 2000);
|
||||
}
|
||||
|
||||
async function pushSave() {
|
||||
try {
|
||||
const data = window.EJS_emulator && window.EJS_emulator.gameManager.getSaveFile();
|
||||
if (!data || data.byteLength === 0) return;
|
||||
if (navigator.onLine) {
|
||||
const r = await fetch(SAVE_URL, { method: 'PUT', body: data,
|
||||
headers: { 'Content-Type': 'application/octet-stream' } });
|
||||
if (r.ok) return;
|
||||
}
|
||||
await idbPut(GAME.name, new Uint8Array(data));
|
||||
} catch(e) { console.warn('save failed, stored locally', e); }
|
||||
}
|
||||
|
||||
async function pullSave() {
|
||||
try {
|
||||
let sav;
|
||||
if (navigator.onLine) {
|
||||
const r = await fetch(SAVE_URL);
|
||||
if (r.ok) sav = new Uint8Array(await r.arrayBuffer());
|
||||
}
|
||||
if (!sav) sav = await idbGet(GAME.name);
|
||||
if (!sav || sav.byteLength === 0) return;
|
||||
const gm = window.EJS_emulator.gameManager;
|
||||
const path = gm.getSaveFilePath();
|
||||
const parts = path.split('/');
|
||||
let cp = '';
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (!parts[i]) continue;
|
||||
cp += '/' + parts[i];
|
||||
if (!gm.FS.analyzePath(cp).exists) gm.FS.mkdir(cp);
|
||||
}
|
||||
if (gm.FS.analyzePath(path).exists) gm.FS.unlink(path);
|
||||
gm.FS.writeFile(path, sav);
|
||||
gm.loadSaveFiles();
|
||||
} catch(e) { console.info('no save available', e); }
|
||||
}
|
||||
|
||||
window.EJS_player = '#game';
|
||||
window.EJS_gameUrl = '/roms/' + encodeURIComponent(GAME.name);
|
||||
window.EJS_core = GAME.system;
|
||||
window.EJS_pathtodata = '/static/';
|
||||
window.EJS_onGameStart = function() {
|
||||
// Sync offline-queued saves first so server has latest, then pull.
|
||||
// Runs after EmulatorJS is ready so IDB access never blocks startup.
|
||||
(navigator.onLine ? syncPendingSaves().catch(function(){}) : Promise.resolve())
|
||||
.then(function() { return pullSave(); })
|
||||
.then(function() {
|
||||
void('running | Online: ' + (navigator.onLine ? 'yes' : 'NO') + '\n' + GAME.name);
|
||||
setInterval(pushSave, 30000);
|
||||
window.addEventListener('pagehide', function() {
|
||||
const data = window.EJS_emulator && window.EJS_emulator.gameManager.getSaveFile();
|
||||
if (data && data.byteLength > 0)
|
||||
navigator.sendBeacon(SAVE_URL, new Blob([data], { type: 'application/octet-stream' }));
|
||||
});
|
||||
window.addEventListener('online', function() {
|
||||
hideOfflineBanner();
|
||||
syncPendingSaves().catch(function(){}).then(function() { pullSave(); });
|
||||
preflightCheck();
|
||||
});
|
||||
window.addEventListener('offline', function() { showOfflineBanner(); preflightCheck(); });
|
||||
if (!navigator.onLine) showOfflineBanner();
|
||||
});
|
||||
};
|
||||
|
||||
// Preflight: check cache and block if offline + ROM not cached.
|
||||
// loader.js loads immediately after — syncPendingSaves runs inside EJS_onGameStart
|
||||
// so IDB access never holds up the startup path.
|
||||
preflightCheck().then(function(ok) {
|
||||
if (!ok) return;
|
||||
var s = document.createElement('script');
|
||||
s.src = '/static/loader.js';
|
||||
document.body.appendChild(s);
|
||||
}).catch(function(e) {
|
||||
void('startup error: ' + e + '\n' + GAME.name);
|
||||
var s = document.createElement('script');
|
||||
s.src = '/static/loader.js';
|
||||
document.body.appendChild(s);
|
||||
});
|
||||
</script>
|
||||
""" + _SW_REG + """\
|
||||
</body>
|
||||
</html>""")
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *args):
|
||||
pass
|
||||
|
||||
def send_body(self, data: bytes, ct: str, status: int = 200):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", ct)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def err(self, code: int):
|
||||
self.send_response(code)
|
||||
self.end_headers()
|
||||
|
||||
def get_user(self):
|
||||
u = self.headers.get("Remote-User", "").strip()
|
||||
return u if (u and SAFE_RE.match(u)) else None
|
||||
|
||||
def safe_name(self, s: str):
|
||||
s = urllib.parse.unquote(s).strip()
|
||||
return s if (s and ".." not in s and "/" not in s and SAFE_RE.match(s)) else None
|
||||
|
||||
def decoded_path(self):
|
||||
return urllib.parse.unquote(self.path.split("?")[0])
|
||||
|
||||
# ── Favourites ─────────────────────────────────────────────────────────────
|
||||
|
||||
def favs_path(self, user: str) -> str:
|
||||
return os.path.join(SAVE_DIR, user, "favorites.json")
|
||||
|
||||
def read_favs(self, user: str) -> list:
|
||||
fp = self.favs_path(user)
|
||||
if not os.path.isfile(fp):
|
||||
return []
|
||||
try:
|
||||
with open(fp) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def write_favs(self, user: str, favs: list) -> None:
|
||||
fp = self.favs_path(user)
|
||||
os.makedirs(os.path.dirname(fp), exist_ok=True)
|
||||
tmp = fp + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(favs, f)
|
||||
os.replace(tmp, fp)
|
||||
|
||||
def handle_fav_write(self, adding: bool) -> None:
|
||||
p = self.decoded_path()
|
||||
user = self.get_user()
|
||||
if not user: self.err(401); return
|
||||
name = self.safe_name(p[len("/favorites/"):])
|
||||
if not name: self.err(400); return
|
||||
favs = self.read_favs(user)
|
||||
if adding and name not in favs:
|
||||
favs.append(name)
|
||||
elif not adding and name in favs:
|
||||
favs.remove(name)
|
||||
self.write_favs(user, favs)
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
|
||||
# ── Saves ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def write_save(self) -> None:
|
||||
p = self.decoded_path()
|
||||
if not p.startswith("/saves/"):
|
||||
self.err(404); return
|
||||
user = self.get_user()
|
||||
if not user: self.err(401); return
|
||||
name = self.safe_name(p[len("/saves/"):])
|
||||
if not name: self.err(400); return
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
data = self.rfile.read(length)
|
||||
user_dir = os.path.join(SAVE_DIR, user)
|
||||
os.makedirs(user_dir, exist_ok=True)
|
||||
with open(os.path.join(user_dir, name + ".sav"), "wb") as f:
|
||||
f.write(data)
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
|
||||
# ── HTTP verbs ─────────────────────────────────────────────────────────────
|
||||
|
||||
def do_GET(self):
|
||||
p = self.decoded_path()
|
||||
|
||||
if p == "/sw.js":
|
||||
body = SW_JS.encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/javascript")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Service-Worker-Allowed", "/")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
|
||||
if p in ("/", ""):
|
||||
self.send_body(LAUNCHER_HTML.encode(), "text/html; charset=utf-8")
|
||||
return
|
||||
|
||||
if p == "/roms/":
|
||||
roms = []
|
||||
if os.path.isdir(ROM_DIR):
|
||||
for f in sorted(os.listdir(ROM_DIR)):
|
||||
ext = os.path.splitext(f)[1].lower()
|
||||
if ext in RECOGNIZED_EXTS:
|
||||
roms.append({"name": f, "title": os.path.splitext(f)[0],
|
||||
"system": detect_system(os.path.join(ROM_DIR, f))})
|
||||
self.send_body(json.dumps(roms).encode(), "application/json")
|
||||
return
|
||||
|
||||
if p.startswith("/roms/"):
|
||||
name = self.safe_name(p[len("/roms/"):])
|
||||
if not name: self.err(400); return
|
||||
fp = os.path.join(ROM_DIR, name)
|
||||
if not os.path.isfile(fp): self.err(404); return
|
||||
with open(fp, "rb") as f:
|
||||
self.send_body(f.read(), "application/octet-stream")
|
||||
return
|
||||
|
||||
if p.startswith("/play/"):
|
||||
name = self.safe_name(p[len("/play/"):])
|
||||
if not name: self.err(400); return
|
||||
game = {"name": name, "title": os.path.splitext(name)[0],
|
||||
"system": detect_system(os.path.join(ROM_DIR, name))}
|
||||
html = PLAYER_TMPL.substitute(title=game["title"],
|
||||
game_json=json.dumps(game))
|
||||
self.send_body(html.encode(), "text/html; charset=utf-8")
|
||||
return
|
||||
|
||||
if p == "/favorites":
|
||||
user = self.get_user()
|
||||
if not user: self.err(401); return
|
||||
self.send_body(json.dumps(self.read_favs(user)).encode(), "application/json")
|
||||
return
|
||||
|
||||
if p.startswith("/thumbnails/"):
|
||||
title = self.safe_name(p[len("/thumbnails/"):])
|
||||
if not title: self.err(400); return
|
||||
fp = os.path.join(THUMBNAILS_DIR, title + ".png")
|
||||
if not os.path.isfile(fp): self.err(404); return
|
||||
with open(fp, "rb") as f:
|
||||
self.send_body(f.read(), "image/png")
|
||||
return
|
||||
|
||||
if p.startswith("/saves/"):
|
||||
user = self.get_user()
|
||||
if not user: self.err(401); return
|
||||
name = self.safe_name(p[len("/saves/"):])
|
||||
if not name: self.err(400); return
|
||||
fp = os.path.join(SAVE_DIR, user, name + ".sav")
|
||||
if not os.path.isfile(fp): self.err(404); return
|
||||
with open(fp, "rb") as f:
|
||||
self.send_body(f.read(), "application/octet-stream")
|
||||
return
|
||||
|
||||
if p.startswith("/static/"):
|
||||
rel = p[len("/static/"):]
|
||||
if ".." in rel or not rel: self.err(400); return
|
||||
fp = os.path.join(STATIC_DIR, rel)
|
||||
if not os.path.isfile(fp): self.err(404); return
|
||||
ext = os.path.splitext(rel)[1].lower()
|
||||
with open(fp, "rb") as f:
|
||||
self.send_body(f.read(), MIME_TYPES.get(ext, "application/octet-stream"))
|
||||
return
|
||||
|
||||
self.err(404)
|
||||
|
||||
def do_PUT(self):
|
||||
self.write_save()
|
||||
|
||||
def do_POST(self):
|
||||
p = self.decoded_path()
|
||||
if p.startswith("/saves/"):
|
||||
self.write_save()
|
||||
elif p.startswith("/favorites/"):
|
||||
self.handle_fav_write(adding=True)
|
||||
else:
|
||||
self.err(404)
|
||||
|
||||
def do_DELETE(self):
|
||||
p = self.decoded_path()
|
||||
if p.startswith("/favorites/"):
|
||||
self.handle_fav_write(adding=False)
|
||||
else:
|
||||
self.err(404)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
HTTPServer(("127.0.0.1", PORT), Handler).serve_forever()
|
||||
@@ -0,0 +1,139 @@
|
||||
{ config, lib, pkgs, homeyConfig, ... }:
|
||||
|
||||
# EmulatorJS — self-hosted web game emulator with per-user save sync.
|
||||
#
|
||||
# The Python sidecar (emulatorjs-server.py) serves:
|
||||
# / → game launcher (lists ROMs)
|
||||
# /roms/ → ROM listing (JSON) and ROM files
|
||||
# /play/<name> → EmulatorJS player page for a specific game
|
||||
# /saves/<name>→ GET/PUT save file (SRAM), keyed by Remote-User header
|
||||
# /static/ → EmulatorJS JS/CSS/WASM assets (served from Nix store)
|
||||
#
|
||||
# Multi-device sync: saves are stored server-side keyed by Authelia
|
||||
# Remote-User header, so any device the user logs into shares the same save.
|
||||
#
|
||||
# EmulatorJS assets are fetched from GitHub at build time — no manual
|
||||
# download step. To upgrade, change `rev` and replace `hash` with the
|
||||
# value Nix reports after a failed build (same pattern as caddy.nix).
|
||||
#
|
||||
# Setup after first enable:
|
||||
# 1. Drop ROM files into /mnt/data/emulatorjs/roms/
|
||||
# Supported: .gb .gbc .gba .nes .snes .sfc .md .gen .n64 .z64
|
||||
# 2. Visit https://games.<domain> — saves go to
|
||||
# /mnt/data/emulatorjs/saves/<username>/ automatically.
|
||||
|
||||
let
|
||||
cfg = config.homey.emulatorjs;
|
||||
dataDir = config.homey.storage.mountPoint;
|
||||
domain = homeyConfig.domain;
|
||||
port = 8085;
|
||||
|
||||
# EmulatorJS static assets fetched from GitHub at build time.
|
||||
# Pre-built WASM cores are committed to the repo so no build step is needed.
|
||||
#
|
||||
# To upgrade to a new version:
|
||||
# 1. Find the latest tag:
|
||||
# git ls-remote --tags https://github.com/EmulatorJS/EmulatorJS | tail -10
|
||||
# 2. Prefetch the tarball hash:
|
||||
# nix-prefetch-url --unpack "https://github.com/EmulatorJS/EmulatorJS/archive/refs/tags/vX.Y.Z.tar.gz"
|
||||
# 3. Convert to SRI format:
|
||||
# nix hash convert --hash-algo sha256 --to sri <base32-from-step-2>
|
||||
# 4. Update rev and hash below.
|
||||
emulatorjsAssets = pkgs.fetchFromGitHub {
|
||||
owner = "EmulatorJS";
|
||||
repo = "EmulatorJS";
|
||||
rev = "v4.2.3";
|
||||
hash = "sha256-hIgvcVNjl9qGJw0GgkkLQQyd2JhdUOVOQhLAeZuQcMk=";
|
||||
};
|
||||
in
|
||||
{
|
||||
options.homey.emulatorjs.enable =
|
||||
lib.mkEnableOption "EmulatorJS self-hosted game emulator";
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Dedicated system user so save-file ownership is stable across restarts
|
||||
# -------------------------------------------------------------------------
|
||||
users.users.emulatorjs = {
|
||||
isSystemUser = true;
|
||||
group = "emulatorjs";
|
||||
description = "EmulatorJS save-sync server";
|
||||
};
|
||||
users.groups.emulatorjs = {};
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Launcher + save-sync server
|
||||
# -------------------------------------------------------------------------
|
||||
systemd.services.emulatorjs = {
|
||||
description = "EmulatorJS launcher and save-sync server";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" "mnt-data.mount" "systemd-tmpfiles-setup.service" ];
|
||||
requires = [ "mnt-data.mount" ];
|
||||
|
||||
environment = {
|
||||
PORT = toString port;
|
||||
SAVE_DIR = "${dataDir}/emulatorjs/saves";
|
||||
ROM_DIR = "${dataDir}/emulatorjs/roms";
|
||||
# EmulatorJS assets live in the Nix store — read-only, no manual setup.
|
||||
# The data/ subdirectory contains loader.js and the emulator cores.
|
||||
STATIC_DIR = "${emulatorjsAssets}/data";
|
||||
THUMBNAILS_DIR = "${dataDir}/emulatorjs/thumbnails";
|
||||
};
|
||||
|
||||
serviceConfig = {
|
||||
ExecStart = "${pkgs.python3}/bin/python3 ${./emulatorjs-server.py}";
|
||||
User = "emulatorjs";
|
||||
Group = "emulatorjs";
|
||||
Restart = "on-failure";
|
||||
RestartSec = "5s";
|
||||
|
||||
NoNewPrivileges = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
PrivateTmp = true;
|
||||
ReadWritePaths = [ "${dataDir}/emulatorjs" ];
|
||||
};
|
||||
};
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Caddy — one_factor SSO; Remote-User header forwarded to the sidecar
|
||||
# -------------------------------------------------------------------------
|
||||
homey.caddy.virtualHosts = [{
|
||||
subdomain = "games";
|
||||
port = port;
|
||||
auth = true;
|
||||
}];
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Storage directories (owned by the service user)
|
||||
# -------------------------------------------------------------------------
|
||||
homey.storage.extraDirs = [
|
||||
{ path = "emulatorjs"; user = "emulatorjs"; group = "emulatorjs"; }
|
||||
{ path = "emulatorjs/saves"; user = "emulatorjs"; group = "emulatorjs"; }
|
||||
{ path = "emulatorjs/roms"; user = "emulatorjs"; group = "emulatorjs"; }
|
||||
{ path = "emulatorjs/thumbnails"; user = "emulatorjs"; group = "emulatorjs"; }
|
||||
];
|
||||
|
||||
# Only saves need backing up; ROMs and static assets are easy to restore
|
||||
homey.backup.extraPaths = [ "${dataDir}/emulatorjs/saves" ];
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Authelia access control — one_factor for all household users
|
||||
# -------------------------------------------------------------------------
|
||||
homey.authelia.accessControlRules = [{
|
||||
priority = 57;
|
||||
domain = [ "games.${domain}" ];
|
||||
policy = "one_factor";
|
||||
}];
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Uptime Kuma monitor
|
||||
# -------------------------------------------------------------------------
|
||||
homey.monitoring.monitors = [{
|
||||
name = "EmulatorJS";
|
||||
url = "https://games.${domain}";
|
||||
interval = 60;
|
||||
}];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
{ config, lib, pkgs, homeyConfig, ... }:
|
||||
|
||||
# MLflow — ML experiment tracking server.
|
||||
#
|
||||
# Auth model: MLflow's built-in basic-auth plugin (--app-name basic-auth).
|
||||
# - Web UI: login form (MLflow's own user database — separate from LDAP).
|
||||
# - Python SDK: set MLFLOW_TRACKING_TOKEN=<token> for bearer-token auth,
|
||||
# or MLFLOW_TRACKING_USERNAME/PASSWORD for basic auth.
|
||||
# - No Authelia forward_auth — MLflow manages its own user database.
|
||||
#
|
||||
# On first boot, basic-auth.ini seeds the initial admin account. After that,
|
||||
# credentials live in /mlflow/data/basic_auth.db and the ini is ignored for auth.
|
||||
#
|
||||
# Secrets consumed from sops:
|
||||
# mlflow/secret_key — Flask CSRF secret (generate: openssl rand -hex 32)
|
||||
# mlflow/admin_password — initial admin password (used once on first boot)
|
||||
#
|
||||
# Volume layout:
|
||||
# <dataDir>/mlflow/data/ → /mlflow/data (SQLite tracking DB, auth DB, basic-auth.ini)
|
||||
# <dataDir>/mlflow/artifacts/ → /mlflow/artifacts (model files, plots, etc.)
|
||||
|
||||
let
|
||||
cfg = config.homey.mlflow;
|
||||
dataDir = config.homey.storage.mountPoint;
|
||||
domain = homeyConfig.domain;
|
||||
in
|
||||
{
|
||||
options.homey.mlflow = {
|
||||
enable = lib.mkEnableOption "MLflow experiment tracking server";
|
||||
|
||||
image = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "ghcr.io/mlflow/mlflow:latest";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 5050;
|
||||
description = "Host port MLflow listens on (bound to 127.0.0.1, Caddy proxy).";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
# -------------------------------------------------------------------------
|
||||
# Secrets
|
||||
# -------------------------------------------------------------------------
|
||||
sops.secrets."mlflow/secret_key" = { owner = "root"; };
|
||||
sops.secrets."mlflow/admin_password" = { owner = "root"; };
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Container
|
||||
# -------------------------------------------------------------------------
|
||||
virtualisation.oci-containers.containers.mlflow = {
|
||||
image = cfg.image;
|
||||
ports = [ "127.0.0.1:${toString cfg.port}:5000" ];
|
||||
volumes = [
|
||||
"${dataDir}/mlflow/data:/mlflow/data"
|
||||
"${dataDir}/mlflow/artifacts:/mlflow/artifacts"
|
||||
];
|
||||
extraOptions = [ "--network=homey" ];
|
||||
environment = {
|
||||
# v3.x: auth config path is env var, not a CLI flag
|
||||
MLFLOW_AUTH_CONFIG_PATH = "/mlflow/data/basic-auth.ini";
|
||||
};
|
||||
environmentFiles = [ "/run/mlflow-secrets.env" ];
|
||||
cmd = [
|
||||
"mlflow" "server"
|
||||
"--host" "0.0.0.0"
|
||||
"--port" "5000"
|
||||
"--backend-store-uri" "sqlite:////mlflow/data/mlflow.db"
|
||||
"--default-artifact-root" "/mlflow/artifacts"
|
||||
"--app-name" "basic-auth"
|
||||
# v3.x security middleware: must explicitly allow the public hostname
|
||||
"--allowed-hosts" "mlflow.${domain}"
|
||||
# Allow browser UI (ajax-api) requests from the public origin.
|
||||
# Without this, fastapi_security blocks all cross-origin requests with 403,
|
||||
# breaking chart data and UI telemetry (SDK api/2.0 calls are unaffected).
|
||||
"--cors-allowed-origins" "https://mlflow.${domain}"
|
||||
];
|
||||
};
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# ExecStartPre: write secrets env file and seed basic-auth.ini on first boot
|
||||
# -------------------------------------------------------------------------
|
||||
systemd.services."podman-mlflow" = {
|
||||
serviceConfig.ExecStartPre = [
|
||||
(pkgs.writeShellScript "mlflow-write-secrets" ''
|
||||
set -euo pipefail
|
||||
|
||||
install -m 600 /dev/null /run/mlflow-secrets.env
|
||||
printf 'MLFLOW_FLASK_SERVER_SECRET_KEY=%s\n' \
|
||||
"$(cat ${config.sops.secrets."mlflow/secret_key".path})" \
|
||||
>> /run/mlflow-secrets.env
|
||||
|
||||
# Seed basic-auth.ini on first boot only.
|
||||
# After first run MLflow stores credentials in basic_auth.db.
|
||||
if [ ! -f "${dataDir}/mlflow/data/basic-auth.ini" ]; then
|
||||
printf '[mlflow]\ndefault_permission = NO_PERMISSIONS\nadmin_username = admin\nadmin_password = %s\ndatabase_uri = sqlite:////mlflow/data/basic_auth.db\n' \
|
||||
"$(cat ${config.sops.secrets."mlflow/admin_password".path})" \
|
||||
> "${dataDir}/mlflow/data/basic-auth.ini"
|
||||
fi
|
||||
'')
|
||||
];
|
||||
postStop = "rm -f /run/mlflow-secrets.env";
|
||||
after = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
|
||||
requires = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
|
||||
};
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Caddy virtual host — auth=false, MLflow handles its own login
|
||||
# -------------------------------------------------------------------------
|
||||
homey.caddy.virtualHosts = [{
|
||||
subdomain = "mlflow";
|
||||
port = cfg.port;
|
||||
auth = false;
|
||||
}];
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Storage directories on external HD
|
||||
# -------------------------------------------------------------------------
|
||||
homey.storage.extraDirs = [
|
||||
{ path = "mlflow"; }
|
||||
{ path = "mlflow/data"; mode = "0750"; }
|
||||
{ path = "mlflow/artifacts"; mode = "0750"; }
|
||||
];
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Backup
|
||||
# -------------------------------------------------------------------------
|
||||
homey.backup.extraPaths = [
|
||||
"${dataDir}/mlflow/data"
|
||||
"${dataDir}/mlflow/artifacts"
|
||||
];
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Monitoring
|
||||
# -------------------------------------------------------------------------
|
||||
homey.monitoring.monitors = [{
|
||||
name = "MLflow";
|
||||
url = "https://mlflow.${domain}";
|
||||
interval = 60;
|
||||
}];
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user