Redeploy game em
This commit is contained in:
+1
-1
@@ -18,6 +18,6 @@ creation_rules:
|
|||||||
- path_regex: secrets/secrets\.yaml$
|
- path_regex: secrets/secrets\.yaml$
|
||||||
key_groups:
|
key_groups:
|
||||||
- pgp:
|
- pgp:
|
||||||
- 076AA297579A0064
|
- A4EB2A1C815D21B028ABD5D4D2DE7AB86CC060CF
|
||||||
age:
|
age:
|
||||||
- age120j8ty7nn04l3s3kgph5ty3v9g4e52fknn8xtnmzwakq9nv2la3skgte0p
|
- age120j8ty7nn04l3s3kgph5ty3v9g4e52fknn8xtnmzwakq9nv2la3skgte0p
|
||||||
|
|||||||
@@ -81,6 +81,8 @@
|
|||||||
./modules/services/attic.nix
|
./modules/services/attic.nix
|
||||||
./modules/services/mealie.nix
|
./modules/services/mealie.nix
|
||||||
./modules/services/stirling-pdf.nix
|
./modules/services/stirling-pdf.nix
|
||||||
|
./modules/services/mlflow.nix
|
||||||
|
./modules/services/emulatorjs.nix
|
||||||
./modules/services/uptime-kuma.nix
|
./modules/services/uptime-kuma.nix
|
||||||
./modules/services/ntfy.nix
|
./modules/services/ntfy.nix
|
||||||
./modules/monitoring.nix
|
./modules/monitoring.nix
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
extraGroups = [ "wheel" "podman" ];
|
extraGroups = [ "wheel" "podman" ];
|
||||||
# Paste your SSH public key here
|
# Paste your SSH public key here
|
||||||
openssh.authorizedKeys.keys = [
|
openssh.authorizedKeys.keys = [
|
||||||
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDMdozJewuuqG4VuwGhI5Uw2xDIUrkQwyPGQ2q3tvnPq/VoV+zfdnou42I6XYEfOGd6xfLR0byOGCk1Ia1F/picBsxADEaJFBnTny5MWNA5MxwJIYUPW5+kFN1TmxdNGmj0WJ8qZeKkS3ffixdHlHTVu4Rzrlu8qAdw8TmgcvD7sHpNaBB+hwG9B/s5BAozfXvyXtW51lLLrEZdmUXJxAP51ubVgysZ985PA89o4G7LUE20IKAj3rgylLwq+Lz2PoGmVCptPTknLLzGlxTy2mU/tESJtS4iUvshSYiKak73BSen88xLWL/eZGFzOLW4TNem/qT0ykqK1s1BaGWCcQiDWN+Lpa3XqTbFN054MfhLdEMp+NjrdsCbZORzp9p2ZPDqi5jI1Hy7IN/8VzzB+T13MX6YhW7CBBqUZaivGhVQGsNjrFusPGhCaZrzNJ1l4qssLAlOh9qdaYzqDe+BIHsty55vw4Ph+Axou82babpLoiQzkCol2TWjPr7JSfHFkm6CmoTQ7mK8m7jRCodmxF+BEe4Usr+HrTKDc9fr5P4mdfL0O+ZPrlkPuG1zBTMal9MPxeCk4emzOZf7vBMNzb0aKHbOtSdVk/IcjatlDd2EIo34e5A6anLRMAdxb7901mnCAcjvCn6+ArNV8nr+NBk1+UvumZ+Q59LTrAkgRt3rBw== openpgp:0x13E0C25D"
|
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDMdozJewuuqG4VuwGhI5Uw2xDIUrkQwyPGQ2q3tvnPq/VoV+zfdnou42I6XYEfOGd6xfLR0byOGCk1Ia1F/picBsxADEaJFBnTny5MWNA5MxwJIYUPW5+kFN1TmxdNGmj0WJ8qZeKkS3ffixdHlHTVu4Rzrlu8qDdw8TmgcvD7sHpNaBB+hwG9B/s5BAozfXvyXtW51lLLrEZdmUXJxAP51ubVgysZ985PA89o4G7LUE20IKAj3rgylLwq+Lz2PoGmVCptPTknLLzGlxTy2mU/tESJtS4iUvshSYiKak73BSen88xLWL/eZGFzOLW4TNem/qT0ykqK1s1BaGWCcQiDWN+Lpa3XqTbFN054MfhLdEMp+NjrdsCbZORzp9p2ZPDqi5jI1Hy7IN/8VzzB+T13MX6YhW7CBBqUZaivGhVQGsNjrFusPGhCaZrzNJ1l4qssLAlOh9qdaYzqDe+BIHsty55vw4Ph+Axou82babpLoiQzkCol2TWjPr7JSfHFkm6CmoTQ7mK8m7jRCodmxF+BEe4Usr+HrTKDc9fr5P4mdfL0O+ZPrlkPuG1zBTMal9MPxeCk4emzOZf7vBMNzb0aKHbOtSdVk/IcjatlDd2EIo34e5A6anLRMAdxb7901mnCAcjvCn6+ArNV8nr+NBk1+UvumZ+Q59LTrAkgRt3rBw== openpgp:0x13E0C25D"
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -93,6 +93,12 @@
|
|||||||
homey.mealie.enable = true;
|
homey.mealie.enable = true;
|
||||||
homey.stirlingPdf.enable = true;
|
homey.stirlingPdf.enable = true;
|
||||||
|
|
||||||
|
# ML experiment tracking
|
||||||
|
homey.mlflow.enable = true;
|
||||||
|
|
||||||
|
# Game emulator
|
||||||
|
homey.emulatorjs.enable = true;
|
||||||
|
|
||||||
# Reverse proxy + Cloudflare
|
# Reverse proxy + Cloudflare
|
||||||
homey.caddy.enable = true;
|
homey.caddy.enable = true;
|
||||||
homey.cloudflared.enable = true;
|
homey.cloudflared.enable = true;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}];
|
||||||
|
};
|
||||||
|
}
|
||||||
Executable
+42
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
KEY_FILE="${1:?Usage: $0 <gpg-key-file> [user@host]}"
|
||||||
|
PI_HOST="${2:-admin@192.168.1.100}"
|
||||||
|
|
||||||
|
GNUPGHOME=$(mktemp -d)
|
||||||
|
chmod 700 "$GNUPGHOME"
|
||||||
|
export GNUPGHOME
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
gpgconf --kill gpg-agent 2>/dev/null || true
|
||||||
|
rm -rf "$GNUPGHOME"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
PINENTRY=$(which pinentry-curses 2>/dev/null || which pinentry-tty 2>/dev/null || which pinentry 2>/dev/null)
|
||||||
|
cat > "$GNUPGHOME/gpg-agent.conf" <<EOF
|
||||||
|
enable-ssh-support
|
||||||
|
pinentry-program $PINENTRY
|
||||||
|
EOF
|
||||||
|
|
||||||
|
gpg --import "$KEY_FILE" || true
|
||||||
|
|
||||||
|
KEYGRIP=$(gpg --with-keygrip -k 2>/dev/null | awk '
|
||||||
|
/\[A\]/ { found=1; next }
|
||||||
|
found && /Keygrip = / { print $3; found=0 }
|
||||||
|
')
|
||||||
|
|
||||||
|
if [[ -z "$KEYGRIP" ]]; then
|
||||||
|
echo "Error: no authentication subkey [A] found in $KEY_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$KEYGRIP" > "$GNUPGHOME/sshcontrol"
|
||||||
|
gpg-connect-agent reloadagent /bye >/dev/null
|
||||||
|
|
||||||
|
export SSH_AUTH_SOCK
|
||||||
|
SSH_AUTH_SOCK=$(gpgconf --list-dirs agent-ssh-socket)
|
||||||
|
|
||||||
|
echo "Connecting to $PI_HOST..."
|
||||||
|
ssh "$PI_HOST"
|
||||||
+26
-23
@@ -40,39 +40,42 @@ mealie:
|
|||||||
attic:
|
attic:
|
||||||
jwt_secret: ENC[AES256_GCM,data:6g1wDau2rEqrmirzamrE6q0Sf38tosCp7EM0EtMLHXANoEfUdK8aL2Jo6z+tWL5bhNTkHwOl55j2mbyUWDlFN3I9vtI9uPKjlP+SgGbSJoKv++UYIhBmcg==,iv:DBgrMPQG/V9g0vG6Ax/fb1xCpvTYSfvAhqojH84wgn8=,tag:9WJjMFuo9kSfxRI9DVpdlg==,type:str]
|
jwt_secret: ENC[AES256_GCM,data:6g1wDau2rEqrmirzamrE6q0Sf38tosCp7EM0EtMLHXANoEfUdK8aL2Jo6z+tWL5bhNTkHwOl55j2mbyUWDlFN3I9vtI9uPKjlP+SgGbSJoKv++UYIhBmcg==,iv:DBgrMPQG/V9g0vG6Ax/fb1xCpvTYSfvAhqojH84wgn8=,tag:9WJjMFuo9kSfxRI9DVpdlg==,type:str]
|
||||||
pull_token: ENC[AES256_GCM,data:FDMRf8El1APXdE1+CraGDKBk9PvAnLFNL9YqvDA++5keV/M7ynAdvAhzJV1dkQ2PcRJKalAkWY0zkoQsXzmWRdY/30WzhHa60GPRRfdX4Bc1N2DqK9mFfO4eWFSBRF5EgZkqWJ+XcijiKHTr3W6MNt8oD+YQ6XkKLvRs6tOep085g2ZdK9jmaQnWTsFMhYmUt//THscDPBq8Jh81Uh2WcLJYB4hEGxxIZZtsbdK6AsRjlMsxkzr+W4kwVKs8aGjqJ5LvUOCHPGY9DvdGtWMMvMs9aw20b05ViuKzemMfDd0=,iv:CzzhhbYtJhtrAIMkERGim+j0pvC5anHVwguV//VrJRQ=,tag:6uGz1f1w76Bk8bbZItYzDg==,type:str]
|
pull_token: ENC[AES256_GCM,data:FDMRf8El1APXdE1+CraGDKBk9PvAnLFNL9YqvDA++5keV/M7ynAdvAhzJV1dkQ2PcRJKalAkWY0zkoQsXzmWRdY/30WzhHa60GPRRfdX4Bc1N2DqK9mFfO4eWFSBRF5EgZkqWJ+XcijiKHTr3W6MNt8oD+YQ6XkKLvRs6tOep085g2ZdK9jmaQnWTsFMhYmUt//THscDPBq8Jh81Uh2WcLJYB4hEGxxIZZtsbdK6AsRjlMsxkzr+W4kwVKs8aGjqJ5LvUOCHPGY9DvdGtWMMvMs9aw20b05ViuKzemMfDd0=,iv:CzzhhbYtJhtrAIMkERGim+j0pvC5anHVwguV//VrJRQ=,tag:6uGz1f1w76Bk8bbZItYzDg==,type:str]
|
||||||
|
mlflow:
|
||||||
|
secret_key: ENC[AES256_GCM,data:2fw2/ROyGEVE9k9v4ULf05bUwccm/cVtFcn8WIeml7tGO2dw8R4YCg+noPJjQPJmN3kGhwgzp0zo0objCnkPhA==,iv:B/FUPvbd09pP6pL8P0pdGvf549orUOXs/6X/vj5xpPg=,tag:o6SKLTbFiHMnaPu0bhqK2g==,type:str]
|
||||||
|
admin_password: ENC[AES256_GCM,data:ficqSYVDdA2ztFlsmv1opkLeUEA3SEDoBQ==,iv:bbsTXg9dvZ7wX7sLVql3tDMUQfXs+hu4nH6djdcMyi0=,tag:d1KWoy/2g1eDWzL/R1lXTQ==,type:str]
|
||||||
sops:
|
sops:
|
||||||
age:
|
age:
|
||||||
- recipient: age120j8ty7nn04l3s3kgph5ty3v9g4e52fknn8xtnmzwakq9nv2la3skgte0p
|
- recipient: age120j8ty7nn04l3s3kgph5ty3v9g4e52fknn8xtnmzwakq9nv2la3skgte0p
|
||||||
enc: |
|
enc: |
|
||||||
-----BEGIN AGE ENCRYPTED FILE-----
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBZSGpPdTBIaTZ0TER2NkNO
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBObjloTm12dXc5NEhvanQ2
|
||||||
U2ZPKzNwelJHUEpyU2VBSmd5Yjd5bEtibFZzCjlZZTRFa2FHN1JtK2JUSm51a3By
|
cWhmcWpEY2hmT3pYeXdoTzFuSUVjTktYM2x3CmZKNjZvMXpBVzlHTkFXMkd4SFZx
|
||||||
QmFyV1ZZNWI0OGJVM1NNZERjd2hWcDAKLS0tIG9VSVFTSTJBMjk5ZzBSL0ZQV2Ev
|
b09KeWp3OE1ZQk41VU9vQ25Fd1ErdEkKLS0tIHcrSmpHSjJ5dytVRk9yaGxRci92
|
||||||
QXVkRlJHeW52NFZFYnVwaW8ycytDSzAKZt+p5QnZKcEOBghHA2xkH6d7NObtTEoE
|
b0RlaHcyNUJSUHRXRlhEUG1NWXBlb28KS5RrYXQL1Joc5I3IJGsa+mfWKtvtpVvc
|
||||||
wMwCYasnBHzy2unXRbZq/4v9NQ5HJd0Nu1iqbqKgIxMCD3dnxEdK7g==
|
w9uLPKkQDROeS8dBqWQf4YVqtIDHU81X+P5i0dVr7roDNzhN+3RLXQ==
|
||||||
-----END AGE ENCRYPTED FILE-----
|
-----END AGE ENCRYPTED FILE-----
|
||||||
lastmodified: "2026-05-30T09:31:03Z"
|
lastmodified: "2026-07-29T09:55:26Z"
|
||||||
mac: ENC[AES256_GCM,data:Mnu3wtu6gfGWtU+03KyTKa9n0uWsRCISRZJcZaF2n9wCD/GDikqUX6QFFZcHHoablXEqN6yu5u0wc7efX80PCnDlkr8C0gQF3i9+p9Kj+i+pfguG47sfqP3ITXIjJpwwZwiFlbCJ/Hj3bpIpUCwr3gb6KQjQZ2bm7SGDlNeV9Ys=,iv:SbFCyuMKaYA3yKvh/DcslA98/cBXTBI7sn3TJ3RZ+y4=,tag:Eh9kMKe4pT8H9O1UZWaTRA==,type:str]
|
mac: ENC[AES256_GCM,data:VgiX5YeFYFcanGzBRbnwJGcS1JcguoKU2MaBZOG/m9xI7lIO8BAJU9mwuGOWq2vRCq5lQIiWVlDZmHksyBntrcRlQGF4ByKZO5F76QlXJ4u6DynJ1mIl2CWt6+VIfJolaIoYf1TL3B1dDu9xThSDd3qakm0NU52NqiZoeUv9QNc=,iv:dsvJlzuoVH2xsx3AkIM7sy0GuXqvOe02/xbOVC5O/FU=,tag:YQEUsD4zDWFYBZGcuArKPg==,type:str]
|
||||||
pgp:
|
pgp:
|
||||||
- created_at: "2026-04-21T06:39:49Z"
|
- created_at: "2026-07-29T09:53:25Z"
|
||||||
enc: |-
|
enc: |-
|
||||||
-----BEGIN PGP MESSAGE-----
|
-----BEGIN PGP MESSAGE-----
|
||||||
|
|
||||||
hQIMAwdqopdXmgBkAQ//TzlOz/QYwiYAc6NGo2O8YJi5ERkS1+0qNpptD51g2dLF
|
hQIMAya4Ufgfj3xVAQ/+IfyGMI4+elF64+LmKlUZtoOYW8ad3DEL7t6bawVTV/is
|
||||||
V4iUx7400tc6IEEhZ0N54R7AO5mSX55XCWJxVQDTRJXmLDHcOR+9vThb4H571XBa
|
DP5JVVPQ9aauG+VuHFCvGNMtq60JNPLU+xy1f9rM2hdd+qyI+BdlhLwxcS3uqdxG
|
||||||
3mcmE8Dj3sN3a1K2RwajZJXl1o5d1oNvWJ83pVsCnrJegi92+GmvmOt4QZ1l5aCf
|
Uikc0PuYvgOLYDr7KsloS0x+5k+Oc7RUacGv/Ga2aNwWg2UEp2dj4adkRwA/KWxi
|
||||||
TGYgUXAz1RreqsGKjJsSXscZOvRnp+cslJ9xY8OXeKLbQvLg0Z3pSQG2QGgDmHPD
|
NlAhHEUiFpKGzrsfw86BBD+86tx+vDZQXBzJ7G2R7OnTOsPO/0jAqeSSbdCLnDPp
|
||||||
fRxYnlc2lKe32uoBlD2LXK+NoBnrRYEVrrwGf6P5GpTDpJbc0bR5BiRIYDhPxtqK
|
GbLdhuCj6pm4jDoLflMtJ77zWUoIhmAeCy4iZ2uxt3UFGtf0B+4m3/VK/O3UqLOX
|
||||||
SiXWHaebg73+kbWdcm+2kiac6hW6xW/iJL4eFBT1v/NgZmNoQCnJOIA7v2vjv9vl
|
OYom9OahZ/X9D4Z5F87cxbDwxw+VbaTHpHPnxrv5eL0/DTd03zhGHEvtq4TpmFqf
|
||||||
81Y1FM5MpIfwNiTwkJjVsgM2tHkANlbixBHJdbjlnKpo9pTS7RuttWtdCmFdmXr0
|
NBWAiKEKBn6Z1sH6pMbiDnp+cRIc3WKQjRQSD8nKrYuvt2kjErLrBLme3IAM3+0d
|
||||||
oiuKDDRPVGvykPqvHzvCLf/k5j1nYvqvb7Wn2Bycc5kIOjFYEDEeM0r37vOX9nDM
|
iaTWI3evNrRJ+CrAYiUMo/3ot+g3yTYmDNUCCLpsrbHBo/2OkanmaXiogkuTjotP
|
||||||
SW1HtaWoZuVceTJEit0WR63kmXYLZ/AHvXcmq6ucUw8Fmw79n+7brQiX2RMtCK1E
|
nUDrLTQPdaRXHktb1B35lFIgeLysu+Z+stGsHcQwbWN0uRspGdNHLExt5EurhIiP
|
||||||
pfrNey3EEqvPs2RDd6XdF4/73CdMDN5s3xiFAIfLGeZ6h0Eq27fazSZNmdh4MGYb
|
MI88IrcxnexDeAikP1GWNwvqlHaaxTh5kLLi8D+gvSMrRA0iJUruKQDLideGKZpy
|
||||||
Wzj81ur8dimoSP+W9eW1TjIfY4deH5FRnN19ldKPuHdazvikWWsdN05evNlSZsDS
|
h21Q3iocXNL1uF84+a5ZWtjMUIr/15ZWgoIDYDYBiUOik14RjMx7dB1UE7JExZXS
|
||||||
XgHafkhKiNSNZLw/VVzf+1SDLhN1H5QoxZ2YsxCc+psd5CFxU1x3llIDg4hXScAR
|
XgEjIM0ac61FHOUelOs+xFuVDfDel6iFQpYK0jPywN70h6v4CgzvyvsrLIw9BYeH
|
||||||
OQvRR1VjQOLFCwdFErW7sd6nQlkS7LnAskgT/0ZJGsxfkh1gJO3YqDnEKF7+P9w=
|
iQ13gqOJinTZGH7/M+t1bYHsMix4lsJlXcI2tRnv4QyjssLAIy1nIer8q5UpXEE=
|
||||||
=zKa+
|
=Tn6d
|
||||||
-----END PGP MESSAGE-----
|
-----END PGP MESSAGE-----
|
||||||
fp: 076AA297579A0064
|
fp: A4EB2A1C815D21B028ABD5D4D2DE7AB86CC060CF
|
||||||
unencrypted_suffix: _unencrypted
|
unencrypted_suffix: _unencrypted
|
||||||
version: 3.12.2
|
version: 3.12.2
|
||||||
|
|||||||
Reference in New Issue
Block a user