const fs = require("fs"); const path = require("path"); const os = require("os"); const net = require("net"); const { spawn, execFile } = require("child_process"); const express = require("express"); const QRCode = require("qrcode"); const Database = require("better-sqlite3"); const publicTrainingProfile = require("./server/public_training_profile"); const { createBackupService } = require("./server/backup_service"); const { createAuth, COOKIE_NAME: AUTH_COOKIE_NAME, TOKEN_MAX_AGE_MS } = require("./server/auth"); const PORT = Number(process.env.PORT || 8081); const HOST = process.env.HOST || "0.0.0.0"; const DB_DIR = process.env.RC_TIMING_DATA_DIR ? path.resolve(process.env.RC_TIMING_DATA_DIR) : path.join(__dirname, "data"); const DB_PATH = path.join(DB_DIR, "rc_timing.sqlite"); const AMMC_CONFIG_PATH = path.join(DB_DIR, "ammc_config.json"); const auth = createAuth({ dataDir: DB_DIR, allowedCidrs: process.env.RC_ADMIN_ALLOWED_CIDRS || "" }); const PID_FILE = process.env.RC_TIMING_PID_FILE || ""; const SRC_DIR = path.join(__dirname, "src"); const RACE_SOUND_DIR = path.join(__dirname, "rc-race-sound-pack"); const WATCHED_FILES = [ path.join(__dirname, "index.html"), path.join(__dirname, "public-training.html"), path.join(RACE_SOUND_DIR, "sound_manifest.json"), ...fs.readdirSync(SRC_DIR) .filter((fileName) => /\.(?:js|css)$/i.test(fileName)) .map((fileName) => path.join(SRC_DIR, fileName)), ]; fs.mkdirSync(DB_DIR, { recursive: true }); const db = new Database(DB_PATH); db.pragma("journal_mode = WAL"); db.pragma("foreign_keys = ON"); initSchema(); const backupService = createBackupService({ db, dataDir: DB_DIR }); const appVersion = { version: require("./package.json").version, revision: 1, updatedAt: new Date().toISOString(), }; watchAppFiles(); // Everything below supports the in-app "an update is available" check and the one-click // updater. It always shells out to the git binary already required for this deployment model // (clone + git pull) rather than any git-hosting-specific API, so it works the same way against // any remote (GitHub, GitLab, a self-hosted server) with no code that assumes which one is used. // Overridable so tests can point this at a throwaway local repo/remote instead of this // installation's real one - never at the real remote, and never touching the working tree. const UPDATE_GIT_DIR = process.env.RC_UPDATE_GIT_DIR ? path.resolve(process.env.RC_UPDATE_GIT_DIR) : __dirname; function runGitCommand(args, timeoutMs = 8000) { return new Promise((resolve) => { execFile("git", args, { cwd: UPDATE_GIT_DIR, timeout: timeoutMs }, (error, stdout, stderr) => { resolve({ ok: !error, stdout: String(stdout || "").trim(), stderr: String(stderr || "").trim() }); }); }); } const UPDATE_CHECK_CACHE_MS = 10 * 60 * 1000; let updateStatusCache = { checkedAt: 0, result: null }; async function checkForUpdate() { const branchResult = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"]); const branch = branchResult.ok && branchResult.stdout && branchResult.stdout !== "HEAD" ? branchResult.stdout : "main"; // A real network call (git fetch is one) can't be avoided for a genuine "is there something // new" check, so this is cached and only ever triggered by a background poll or an explicit // user click - never on every page load. const fetchResult = await runGitCommand(["fetch", "--quiet", "origin", branch], 15000); if (!fetchResult.ok) { return { checked: false, available: false, currentVersion: appVersion.version, error: "Could not reach the update server" }; } const localResult = await runGitCommand(["rev-parse", "HEAD"]); const remoteResult = await runGitCommand(["rev-parse", `origin/${branch}`]); if (!localResult.ok || !remoteResult.ok) { return { checked: false, available: false, currentVersion: appVersion.version, error: "Not a git installation" }; } const available = localResult.stdout !== remoteResult.stdout; let remoteVersion = ""; if (available) { const packageResult = await runGitCommand(["show", `origin/${branch}:package.json`]); if (packageResult.ok) { try { remoteVersion = String(JSON.parse(packageResult.stdout)?.version || ""); } catch { // leave remoteVersion empty if the remote package.json can't be parsed } } } return { checked: true, available, currentVersion: appVersion.version, remoteVersion, localCommit: localResult.stdout.slice(0, 10), remoteCommit: remoteResult.stdout.slice(0, 10), }; } function createAmmcChannelState() { return { process: null, pid: null, startedAt: null, stoppedAt: null, lastExitCode: null, lastExitSignal: null, lastError: "", lastOutput: [], }; } const ammcState = { config: loadAmmcConfig(), channels: { main: createAmmcChannelState(), split: createAmmcChannelState(), pitEntry: createAmmcChannelState(), pitExit: createAmmcChannelState(), }, }; const app = express(); const allowedCorsOrigins = new Set( String(process.env.RC_ALLOWED_ORIGINS || "") .split(",") .map((origin) => origin.trim().replace(/\/$/, "")) .filter(Boolean) ); app.use(express.json({ limit: "20mb" })); app.use((req, res, next) => { res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("Referrer-Policy", "no-referrer"); const requestOrigin = `${req.protocol}://${req.get("host")}`; const origin = String(req.get("origin") || "").replace(/\/$/, ""); const corsAllowed = !origin || origin === requestOrigin || allowedCorsOrigins.has(origin); if (!corsAllowed) { res.status(403).end(); return; } if (origin) { res.setHeader("Access-Control-Allow-Origin", origin); res.setHeader("Vary", "Origin"); res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type"); } if (req.method === "OPTIONS") { res.status(corsAllowed ? 204 : 403).end(); return; } next(); }); app.use(auth.middleware); function setAuthCookie(res, token) { const maxAge = token ? Math.floor(TOKEN_MAX_AGE_MS / 1000) : 0; res.setHeader("Set-Cookie", `${AUTH_COOKIE_NAME}=${token || ""}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}`); } app.get("/api/auth/status", (req, res) => { res.json({ enabled: auth.isEnabled(), authenticated: auth.isEnabled() ? auth.isAuthenticated(req) : true, ipAllowed: auth.isIpAllowed(req.ip) }); }); app.post("/api/auth/login", (req, res) => { if (auth.isLoginBlocked(req.ip)) return res.status(429).json({ error: "too_many_attempts" }); if (!auth.isEnabled()) return res.json({ ok: true, enabled: false }); if (!auth.verifyPin(req.body?.pin)) { auth.registerLoginFailure(req.ip); return res.status(401).json({ error: "wrong_pin" }); } setAuthCookie(res, auth.issueToken()); return res.json({ ok: true, enabled: true }); }); app.post("/api/auth/logout", (_req, res) => { setAuthCookie(res, ""); res.json({ ok: true }); }); // Protected by the middleware once a PIN exists; while auth is off, setting the first PIN // is open, which matches how every other admin setting behaves in that state. app.post("/api/auth/pin", (req, res) => { try { auth.setPin(req.body?.newPin); } catch (error) { return res.status(400).json({ error: error.message }); } setAuthCookie(res, auth.issueToken()); return res.json({ ok: true, enabled: true }); }); app.post("/api/auth/disable", (_req, res) => { auth.disable(); setAuthCookie(res, ""); res.json({ ok: true, enabled: false }); }); app.get("/api/health", (_req, res) => { res.json({ ok: true, dbPath: DB_PATH }); }); app.get("/api/app-version", (_req, res) => { res.json(appVersion); }); app.get("/api/update-status", async (req, res) => { const force = req.query.force === "1"; if (!force && updateStatusCache.result && Date.now() - updateStatusCache.checkedAt < UPDATE_CHECK_CACHE_MS) { res.json(updateStatusCache.result); return; } const result = await checkForUpdate(); updateStatusCache = { checkedAt: Date.now(), result }; res.json(result); }); app.post("/api/update/run", (req, res) => { res.json({ ok: true, message: "Update starting - the server will restart shortly." }); // A detached child outlives this response and this process: it stops the currently running // server (this one), pulls and installs the update, then starts a fresh server - the exact // same stop/start machinery "npm run restart" already uses, with git pull/npm install // sandwiched in between. See scripts/serverctl.js's "update" command. const child = spawn(process.execPath, [path.join(__dirname, "scripts", "serverctl.js"), "update"], { cwd: __dirname, detached: true, stdio: "ignore", }); child.unref(); }); app.get("/api/network/public-address", async (_req, res) => { try { const response = await fetch("https://api.ipify.org?format=json", { signal: AbortSignal.timeout(5000) }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const address = String((await response.json())?.ip || "").trim(); if (!net.isIP(address)) throw new Error("Invalid public address response"); res.setHeader("Cache-Control", "no-store"); res.json({ address, suggestedBaseUrl: `https://${address}` }); } catch (error) { res.status(502).json({ error: error instanceof Error ? error.message : String(error) }); } }); app.get("/api/backups", (_req, res) => { try { res.setHeader("Cache-Control", "no-store"); res.json({ backups: backupService.listBackups() }); } catch (error) { res.status(500).json({ error: error instanceof Error ? error.message : String(error) }); } }); app.post("/api/backups", async (req, res) => { try { const backup = await backupService.createBackup({ reason: req.body?.reason || "manual", sessionId: req.body?.sessionId || "", eventId: req.body?.eventId || "", }); res.status(201).json({ ok: true, backup }); } catch (error) { res.status(500).json({ error: error instanceof Error ? error.message : String(error) }); } }); app.get("/api/ammc/config", (_req, res) => { res.json({ config: ammcState.config, status: buildAmmcStatus(), }); }); app.post("/api/ammc/config", (req, res) => { if (!req.body || typeof req.body !== "object") { res.status(400).json({ error: "Expected JSON body" }); return; } try { ammcState.config = normalizeAmmcConfig(req.body, ammcState.config); saveAmmcConfig(ammcState.config); res.json({ ok: true, config: ammcState.config, status: buildAmmcStatus(), }); } catch (error) { res.status(400).json({ error: error instanceof Error ? error.message : String(error), status: buildAmmcStatus(), }); } }); app.get("/api/ammc/status", (_req, res) => { res.json(buildAmmcStatus()); }); app.post("/api/ammc/start", (_req, res) => { try { startAmmcProcesses(); res.json({ ok: true, status: buildAmmcStatus() }); } catch (error) { res.status(400).json({ error: error instanceof Error ? error.message : String(error), status: buildAmmcStatus(), }); } }); app.post("/api/ammc/stop", async (_req, res) => { try { await stopAmmcProcesses(); res.json({ ok: true, status: buildAmmcStatus() }); } catch (error) { res.status(500).json({ error: error instanceof Error ? error.message : String(error), status: buildAmmcStatus(), }); } }); // Two admin browsers can be open at once, and each pushes its own full in-memory copy of // state on every save - whichever save lands last silently overwrites everything else, // including any other browser's edits since its own last page load. That risk is most // severe for resultsBySession: a browser that's been idle for a while (so its copy is // stale) can revert live race results computed moments ago by a different, active browser. // /api/passings is the only route driven by live decoder data, so a recent write through it // is a reliable signal that a session's results are currently "hot" and worth protecting. const RESULT_PROTECTION_WINDOW_MS = Number(process.env.RC_RESULT_PROTECTION_WINDOW_MS || 15000); const recentSessionResultWrites = new Map(); function protectRecentSessionResults(incomingState) { const stored = readStoredAppState(); if (!stored?.state?.resultsBySession || typeof stored.state.resultsBySession !== "object") return incomingState; const now = Date.now(); for (const [sessionId, writtenAt] of recentSessionResultWrites) { if (now - writtenAt > RESULT_PROTECTION_WINDOW_MS) { recentSessionResultWrites.delete(sessionId); continue; } const storedResult = stored.state.resultsBySession[sessionId]; if (storedResult === undefined) continue; const incomingResult = incomingState.resultsBySession?.[sessionId]; if (JSON.stringify(incomingResult) === JSON.stringify(storedResult)) continue; if (!incomingState.resultsBySession || typeof incomingState.resultsBySession !== "object") incomingState.resultsBySession = {}; incomingState.resultsBySession[sessionId] = storedResult; } return incomingState; } app.get("/api/state", (_req, res) => { const row = db.prepare("SELECT state_json, updated_at FROM app_state WHERE id = 1").get(); if (!row) { res.json({ state: null, updatedAt: null }); return; } try { res.json({ state: JSON.parse(row.state_json), updatedAt: row.updated_at }); } catch { res.status(500).json({ error: "Stored app state is invalid JSON" }); } }); app.post("/api/state", (req, res) => { if (!req.body || typeof req.body !== "object") { res.status(400).json({ error: "Expected JSON body" }); return; } const stateJson = JSON.stringify(protectRecentSessionResults(req.body)); const nowIso = new Date().toISOString(); db.prepare( ` INSERT INTO app_state (id, state_json, updated_at) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json, updated_at = excluded.updated_at ` ).run(stateJson, nowIso); res.json({ ok: true, updatedAt: nowIso }); }); app.post("/api/passings", (req, res) => { const { sessionId, passing, sessionResult } = req.body || {}; if (!sessionId || !passing || typeof passing !== "object") { res.status(400).json({ error: "Expected { sessionId, passing }" }); return; } const stmt = db.prepare( ` INSERT INTO passings ( session_id, timestamp_ms, transponder, driver_id, driver_name, car_id, car_name, strength, loop_id, resend, raw_json, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` ); stmt.run( String(sessionId), Number(passing.timestamp || Date.now()), String(passing.transponder || ""), passing.driverId ? String(passing.driverId) : null, passing.driverName ? String(passing.driverName) : null, passing.carId ? String(passing.carId) : null, passing.carName ? String(passing.carName) : null, typeof passing.strength === "number" ? passing.strength : null, passing.loopId ? String(passing.loopId) : null, passing.resend ? 1 : 0, JSON.stringify(passing), new Date().toISOString() ); // Keep app_state hot for overlay clients that hydrate from backend instead of // having their own direct decoder socket. if (sessionResult && typeof sessionResult === "object") { const nowIso = new Date().toISOString(); const row = db.prepare("SELECT state_json FROM app_state WHERE id = 1").get(); if (row?.state_json) { try { const parsed = JSON.parse(row.state_json); if (!parsed.resultsBySession || typeof parsed.resultsBySession !== "object") { parsed.resultsBySession = {}; } parsed.resultsBySession[String(sessionId)] = sessionResult; recentSessionResultWrites.set(String(sessionId), Date.now()); db.prepare( ` INSERT INTO app_state (id, state_json, updated_at) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json, updated_at = excluded.updated_at ` ).run(JSON.stringify(parsed), nowIso); } catch { // leave app_state untouched if the stored JSON is invalid } } } res.json({ ok: true }); }); app.get("/api/passings", (req, res) => { const sessionId = req.query.sessionId ? String(req.query.sessionId) : null; const limit = Math.min(1000, Math.max(1, Number(req.query.limit || 200))); const rows = sessionId ? db .prepare( ` SELECT * FROM passings WHERE session_id = ? ORDER BY timestamp_ms DESC LIMIT ? ` ) .all(sessionId, limit) : db .prepare( ` SELECT * FROM passings ORDER BY timestamp_ms DESC LIMIT ? ` ) .all(limit); res.json({ rows }); }); // Deleting an event/session already removes it from every part of the UI (the event list, // resultsBySession, training aggregates all preserve their own independent copy of what they // need). The raw passing rows behind it are the one thing that isn't reachable through // /api/state at all, so a dedicated purge is how the client asks for them to be cleaned up too // instead of lingering in SQLite indefinitely with no UI ever pointing at them again. app.post("/api/passings/purge", (req, res) => { const sessionIds = Array.isArray(req.body?.sessionIds) ? [...new Set(req.body.sessionIds.map((id) => String(id || "")).filter(Boolean))] : []; if (!sessionIds.length) { res.json({ ok: true, deleted: 0 }); return; } const placeholders = sessionIds.map(() => "?").join(","); const result = db.prepare(`DELETE FROM passings WHERE session_id IN (${placeholders})`).run(...sessionIds); res.json({ ok: true, deleted: result.changes }); }); app.post("/api/export/pdf", (req, res) => { try { const payload = normalizePdfExportPayload(req.body || {}); const pdf = buildSimplePdf(payload); res.setHeader("Content-Type", "application/pdf"); res.setHeader("Content-Disposition", `attachment; filename="${payload.filename}"`); res.send(pdf); } catch (error) { res.status(400).json({ error: error instanceof Error ? error.message : String(error) }); } }); const PUBLIC_OVERLAY_TOKEN = String(process.env.PUBLIC_OVERLAY_TOKEN || "").trim(); app.get(["/public-overlay", "/public-overlay/:mode"], (req, res) => { if (PUBLIC_OVERLAY_TOKEN && String(req.query.token || "").trim() !== PUBLIC_OVERLAY_TOKEN) { res.status(403).type("text/plain").send("Forbidden"); return; } res.sendFile(path.join(__dirname, "index.html")); }); app.get("/public-training/api/:shareToken", (req, res) => { try { const stored = readStoredAppState(); const sinceTimestamp = publicTrainingProfile.normalizePublicTrainingSince(req.query.since, req.query.startedAgoMs); const windowMs = publicTrainingProfile.normalizePublicTrainingWindow(req.query.windowMs); // Separate "since" markers for the two guided exercises: a driver may run one, both, or // neither independently of the general practice timer above, each started at its own time. const evenLapsSince = publicTrainingProfile.normalizePublicTrainingSince(req.query.evenLapsSince, req.query.evenLapsStartedAgoMs); const stepTrainingSince = publicTrainingProfile.normalizePublicTrainingSince(req.query.stepTrainingSince, req.query.stepTrainingStartedAgoMs); const profile = stored ? publicTrainingProfile.buildPublicTrainingProfile(stored.state, String(req.params.shareToken || ""), stored.updatedAt, sinceTimestamp, windowMs, evenLapsSince, stepTrainingSince) : null; if (!profile) { res.status(404).json({ error: "Profile not found" }); return; } res.setHeader("Cache-Control", "no-store"); res.json(profile); } catch (error) { res.status(500).json({ error: error instanceof Error ? error.message : String(error) }); } }); app.post("/public-training/api/:shareToken/markers", (req, res) => { try { const stored = readStoredAppState(); const marker = stored ? publicTrainingProfile.addPublicTrainingMarker(stored.state, String(req.params.shareToken || ""), req.body || {}) : null; if (!marker) { res.status(404).json({ error: "Profile or class not found" }); return; } const nowIso = new Date().toISOString(); db.prepare("INSERT INTO app_state (id, state_json, updated_at) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json, updated_at = excluded.updated_at").run(JSON.stringify(stored.state), nowIso); res.status(201).json({ ok: true, marker: { type: marker.type, timestamp: marker.timestamp, note: marker.note } }); } catch (error) { res.status(500).json({ error: error instanceof Error ? error.message : String(error) }); } }); app.post("/public-training/api/:shareToken/even-laps", (req, res) => { try { const stored = readStoredAppState(); const entry = stored ? publicTrainingProfile.savePublicEvenLapsConfig(stored.state, String(req.params.shareToken || ""), req.body || {}) : null; if (!entry) { res.status(404).json({ error: "Profile, class, or configuration not found" }); return; } const nowIso = new Date().toISOString(); db.prepare("INSERT INTO app_state (id, state_json, updated_at) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json, updated_at = excluded.updated_at").run(JSON.stringify(stored.state), nowIso); res.status(201).json({ ok: true }); } catch (error) { res.status(500).json({ error: error instanceof Error ? error.message : String(error) }); } }); app.post("/public-training/api/:shareToken/step-training", (req, res) => { try { const stored = readStoredAppState(); const entry = stored ? publicTrainingProfile.savePublicStepTrainingConfig(stored.state, String(req.params.shareToken || ""), req.body || {}) : null; if (!entry) { res.status(404).json({ error: "Profile, class, or configuration not found" }); return; } const nowIso = new Date().toISOString(); db.prepare("INSERT INTO app_state (id, state_json, updated_at) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json, updated_at = excluded.updated_at").run(JSON.stringify(stored.state), nowIso); res.status(201).json({ ok: true }); } catch (error) { res.status(500).json({ error: error instanceof Error ? error.message : String(error) }); } }); app.post("/public-training/api/:shareToken/exercise-sessions", (req, res) => { try { const stored = readStoredAppState(); const record = stored ? publicTrainingProfile.recordPublicExerciseSession(stored.state, String(req.params.shareToken || ""), req.body || {}) : null; if (!record) { res.status(404).json({ error: "Profile, class, or configuration not found, or the exercise was not actually completed" }); return; } const nowIso = new Date().toISOString(); db.prepare("INSERT INTO app_state (id, state_json, updated_at) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json, updated_at = excluded.updated_at").run(JSON.stringify(stored.state), nowIso); res.status(201).json({ ok: true, session: { kind: record.kind, completedAt: record.completedAt, summary: record.summary } }); } catch (error) { res.status(500).json({ error: error instanceof Error ? error.message : String(error) }); } }); app.get("/public-training/qr/:shareToken", async (req, res) => { try { const stored = readStoredAppState(); const token = String(req.params.shareToken || ""); if (!stored || !publicTrainingProfile.hasPublicTrainingShare(stored.state, token)) { res.status(404).type("text/plain").send("Profile not found"); return; } const requestedBase = String(req.query.baseUrl || "").trim(); const configuredBase = String(stored.state?.settings?.publicBaseUrl || "").trim(); const requestBase = `${req.protocol}://${req.get("host")}`; const lanUrls = getLanUrls(PORT); const localBase = lanUrls.find((url) => /^http:\/\/(?:192\.168\.|10\.)/.test(url)) || lanUrls[0] || requestBase; let baseUrl; try { const parsed = new URL(requestedBase === "local" ? localBase : requestedBase || configuredBase || requestBase); if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("Unsupported protocol"); baseUrl = parsed.origin; } catch { baseUrl = `${req.protocol}://${req.get("host")}`; } const language = req.query.lang === "en" ? "en" : "sv"; const profileUrl = new URL(`/public-training/${encodeURIComponent(token)}`, baseUrl); profileUrl.searchParams.set("lang", language); const svg = await QRCode.toString(profileUrl.toString(), { type: "svg", errorCorrectionLevel: "M", margin: 2, width: 320 }); res.setHeader("Cache-Control", "no-store"); res.type("image/svg+xml").send(svg); } catch (error) { res.status(500).type("text/plain").send(error instanceof Error ? error.message : String(error)); } }); app.get("/public-training/:shareToken", (req, res) => { try { const stored = readStoredAppState(); if (!stored || !publicTrainingProfile.hasPublicTrainingShare(stored.state, String(req.params.shareToken || ""))) { res.status(404).type("text/plain").send("Profile not found"); return; } res.setHeader("Cache-Control", "no-store"); res.sendFile(path.join(__dirname, "public-training.html")); } catch { res.status(404).type("text/plain").send("Profile not found"); } }); app.use("/race-sounds", express.static(RACE_SOUND_DIR, { setHeaders: (res, filePath) => { res.setHeader("Cache-Control", filePath.endsWith(".wav") ? "public, max-age=86400" : "no-store"); }, })); app.use( "/src", express.static(SRC_DIR, { setHeaders: (res, filePath) => { if (filePath.endsWith(".html") || filePath.endsWith(".js") || filePath.endsWith(".css")) { res.setHeader("Cache-Control", "no-store"); } }, }) ); app.use("/docs", express.static(path.join(__dirname, "docs"))); app.use((_req, res) => { res.sendFile(path.join(__dirname, "index.html")); }); app.listen(PORT, HOST, () => { writePidFile(); console.log(`RC Timing server listening on http://${HOST}:${PORT}`); const lanUrls = getLanUrls(PORT); if (lanUrls.length) { console.log(`LAN access: ${lanUrls.join(" , ")}`); } console.log(`SQLite database: ${DB_PATH}`); console.log(`AMMC executable: ${buildAmmcStatus().resolvedExecutablePath}`); maybeStartManagedAmmc(); }); function initSchema() { db.exec(` CREATE TABLE IF NOT EXISTS app_state ( id INTEGER PRIMARY KEY CHECK (id = 1), state_json TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS passings ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, timestamp_ms INTEGER NOT NULL, transponder TEXT NOT NULL, driver_id TEXT, driver_name TEXT, car_id TEXT, car_name TEXT, strength REAL, loop_id TEXT, resend INTEGER NOT NULL DEFAULT 0, raw_json TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_passings_session ON passings(session_id); CREATE INDEX IF NOT EXISTS idx_passings_timestamp ON passings(timestamp_ms); `); } function readStoredAppState() { const row = db.prepare("SELECT state_json, updated_at FROM app_state WHERE id = 1").get(); if (!row?.state_json) return null; return { state: JSON.parse(row.state_json), updatedAt: row.updated_at }; } function normalizePdfExportPayload(input = {}) { const title = String(input.title || "RC RaceCore").trim(); const subtitle = String(input.subtitle || "").trim(); const brandName = String(input.brandName || "JMK RB").trim(); const brandTagline = String(input.brandTagline || "Live Event").trim(); const footer = String(input.footer || "").trim(); const logoDataUrl = String(input.logoDataUrl || "").trim(); const theme = ["classic", "minimal", "motorsport"].includes(String(input.theme || "").toLowerCase()) ? String(input.theme).toLowerCase() : "classic"; const filenameBase = String(input.filename || "export.pdf").trim() || "export.pdf"; const filename = filenameBase.toLowerCase().endsWith(".pdf") ? filenameBase : `${filenameBase}.pdf`; const sections = Array.isArray(input.sections) ? input.sections.map((section) => ({ title: String(section?.title || "").trim(), headers: Array.isArray(section?.headers) ? section.headers.map((value) => String(value || "").trim()) : [], rows: Array.isArray(section?.rows) ? section.rows.map((row) => (Array.isArray(row) ? row.map((value) => String(value ?? "").trim()) : [String(row ?? "").trim()])) : [], })) : []; return { title, subtitle, brandName, brandTagline, footer, theme, logoDataUrl, filename, sections }; } function buildSimplePdf(payload) { const pageWidth = 595; const pageHeight = 842; const marginLeft = 42; const marginTop = 56; const marginBottom = 56; const lineHeight = 15; const maxChars = 86; const logo = parsePdfLogo(payload.logoDataUrl); const headerOffset = logo ? logo.displayHeight + 18 : 0; const lines = buildPdfLines(payload, maxChars); const linesPerPage = Math.max(10, Math.floor((pageHeight - marginTop - marginBottom - headerOffset) / lineHeight)); const pages = []; for (let index = 0; index < lines.length; index += linesPerPage) { pages.push(lines.slice(index, index + linesPerPage)); } if (!pages.length) { pages.push(["No content"]); } const objects = []; const pageObjectIds = []; const contentObjectIds = []; let nextObjectId = 3; for (let index = 0; index < pages.length; index += 1) { pageObjectIds.push(nextObjectId++); contentObjectIds.push(nextObjectId++); } const fontObjectId = nextObjectId++; const imageObjectId = logo ? nextObjectId++ : null; objects.push(createPdfTextObject(1, "<< /Type /Catalog /Pages 2 0 R >>")); objects.push(createPdfTextObject(2, `<< /Type /Pages /Count ${pages.length} /Kids [${pageObjectIds.map((id) => `${id} 0 R`).join(" ")}] >>`)); objects.push(createPdfTextObject(fontObjectId, "<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>")); if (logo && imageObjectId) { objects.push( createPdfStreamObject( imageObjectId, `<< /Type /XObject /Subtype /Image /Width ${logo.width} /Height ${logo.height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode`, logo.data ) ); } for (let index = 0; index < pages.length; index += 1) { const resources = imageObjectId ? `<< /Font << /F1 ${fontObjectId} 0 R >> /XObject << /Im1 ${imageObjectId} 0 R >> >>` : `<< /Font << /F1 ${fontObjectId} 0 R >> >>`; const contentStream = pdfWinAnsiBuffer( buildPdfContentStream( pages[index], marginLeft, pageHeight - marginTop - headerOffset, lineHeight, logo ? { x: marginLeft, y: pageHeight - marginTop - logo.displayHeight, width: logo.displayWidth, height: logo.displayHeight, } : null ) ); objects.push( createPdfTextObject( pageObjectIds[index], `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageWidth} ${pageHeight}] /Resources ${resources} /Contents ${contentObjectIds[index]} 0 R >>` ) ); objects.push(createPdfStreamObject(contentObjectIds[index], "<<", contentStream)); } return assemblePdf(objects); } function createPdfTextObject(id, body) { return { id, buffer: Buffer.from(`${id} 0 obj\n${body}\nendobj\n`, "binary"), }; } function createPdfStreamObject(id, dictionaryPrefix, streamBuffer) { return { id, buffer: Buffer.concat([ Buffer.from(`${id} 0 obj\n${dictionaryPrefix} /Length ${streamBuffer.length} >>\nstream\n`, "binary"), streamBuffer, Buffer.from("\nendstream\nendobj\n", "binary"), ]), }; } function assemblePdf(objects) { const ordered = [...objects].sort((left, right) => left.id - right.id); const chunks = [Buffer.from("%PDF-1.4\n", "binary")]; const offsets = [0]; let position = chunks[0].length; ordered.forEach((object) => { offsets[object.id] = position; chunks.push(object.buffer); position += object.buffer.length; }); const xrefOffset = position; const size = ordered.length + 1; let xref = `xref\n0 ${size}\n0000000000 65535 f \n`; for (let id = 1; id < size; id += 1) { xref += `${String(offsets[id] || 0).padStart(10, "0")} 00000 n \n`; } chunks.push(Buffer.from(`${xref}trailer\n<< /Size ${size} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`, "binary")); return Buffer.concat(chunks); } function parsePdfLogo(dataUrl) { const match = /^data:image\/jpeg;base64,([A-Za-z0-9+/=\s]+)$/i.exec(String(dataUrl || "").trim()); if (!match) { return null; } const data = Buffer.from(match[1].replace(/\s+/g, ""), "base64"); const dimensions = getJpegDimensions(data); if (!dimensions) { return null; } const scale = Math.min(150 / dimensions.width, 52 / dimensions.height, 1); return { data, width: dimensions.width, height: dimensions.height, displayWidth: Math.max(1, Math.round(dimensions.width * scale)), displayHeight: Math.max(1, Math.round(dimensions.height * scale)), }; } function getJpegDimensions(buffer) { if (!Buffer.isBuffer(buffer) || buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) { return null; } let offset = 2; while (offset + 1 < buffer.length) { while (offset < buffer.length && buffer[offset] !== 0xff) { offset += 1; } while (offset < buffer.length && buffer[offset] === 0xff) { offset += 1; } if (offset >= buffer.length) { break; } const marker = buffer[offset]; offset += 1; if (marker === 0xd9 || marker === 0xda) { break; } if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { continue; } if (offset + 1 >= buffer.length) { break; } const length = buffer.readUInt16BE(offset); if (length < 2 || offset + length > buffer.length) { break; } if ( (marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) || (marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf) ) { if (length >= 7) { return { height: buffer.readUInt16BE(offset + 3), width: buffer.readUInt16BE(offset + 5), }; } break; } offset += length; } return null; } function buildPdfLines(payload, maxChars) { const lines = []; const separator = payload.theme === "minimal" ? "" : payload.theme === "motorsport" ? "=".repeat(Math.min(maxChars, 70)) : "-".repeat(Math.min(maxChars, 70)); if (payload.brandName) { lines.push(payload.brandName); } if (payload.brandTagline) { lines.push(payload.brandTagline); } if (separator) { lines.push(separator); } if (payload.brandName || payload.brandTagline) { lines.push(""); } lines.push(payload.title); if (payload.subtitle) { lines.push(payload.subtitle); } payload.sections.forEach((section) => { lines.push(""); if (section.title) { lines.push(payload.theme === "motorsport" ? `> ${section.title}` : section.title); } if (section.headers.length) { const widths = getColumnWidths(section.headers, section.rows, maxChars); lines.push(formatPdfRow(section.headers, widths)); lines.push(formatPdfRow(widths.map((width) => (payload.theme === "minimal" ? " ".repeat(Math.min(width, 1)) : "-".repeat(Math.min(width, 12)))), widths)); section.rows.forEach((row) => { lines.push(formatPdfRow(row, widths)); }); } else { section.rows.forEach((row) => { row.forEach((value) => { wrapPdfText(value, maxChars).forEach((line) => lines.push(line)); }); }); } }); if (payload.footer) { lines.push(""); lines.push(payload.footer); } return lines; } function getColumnWidths(headers, rows, maxChars) { const widths = headers.map((header, index) => { const columnValues = rows.map((row) => String(row[index] || "")); const longest = Math.max(header.length, ...columnValues.map((value) => value.length), 4); return Math.min(26, Math.max(4, longest)); }); let total = widths.reduce((sum, width) => sum + width, 0) + Math.max(0, (widths.length - 1) * 3); while (total > maxChars) { const widestIndex = widths.findIndex((width) => width === Math.max(...widths)); if (widths[widestIndex] <= 4) { break; } widths[widestIndex] -= 1; total -= 1; } return widths; } function formatPdfRow(values, widths) { return values .map((value, index) => String(value || "").slice(0, widths[index]).padEnd(widths[index], " ")) .join(" | "); } function wrapPdfText(text, maxChars) { const words = String(text || "").split(/\s+/).filter(Boolean); if (!words.length) { return [""]; } const lines = []; let current = ""; words.forEach((word) => { const next = current ? `${current} ${word}` : word; if (next.length > maxChars) { if (current) { lines.push(current); current = word; } else { lines.push(word.slice(0, maxChars)); current = word.slice(maxChars); } } else { current = next; } }); if (current) { lines.push(current); } return lines; } function buildPdfContentStream(lines, x, y, lineHeight, logoPlacement = null) { const escaped = lines.map((line) => escapePdfText(line)); const commands = []; if (logoPlacement) { commands.push("q"); commands.push(`${logoPlacement.width} 0 0 ${logoPlacement.height} ${logoPlacement.x} ${logoPlacement.y} cm`); commands.push("/Im1 Do"); commands.push("Q"); } commands.push( "BT", "/F1 11 Tf", `${lineHeight} TL`, `${x} ${y} Td`, ...escaped.map((line) => `(${line}) Tj\nT*`), "ET" ); return commands.join("\n"); } function escapePdfText(text) { return String(text || "") .replaceAll("\\", "\\\\") .replaceAll("(", "\\(") .replaceAll(")", "\\)"); } // The content stream is declared with /Encoding /WinAnsiEncoding (see buildSimplePdf), so every // character must become exactly one WinAnsi (~CP1252) byte or a PDF viewer renders garbage/boxes // for anything outside plain ASCII - accented letters most of all (å, ä, ö, ...). WinAnsi matches // Latin-1 one-for-one above 0x9F (where å/ä/ö/Å/Ä/Ö all live), so a plain code-point clamp covers // Swedish text; only the narrow 0x80-0x9F "smart punctuation" block needs its own mapping. const PDF_WIN_ANSI_OVERRIDES = { 0x20ac: 0x80, 0x201a: 0x82, 0x0192: 0x83, 0x201e: 0x84, 0x2026: 0x85, 0x2020: 0x86, 0x2021: 0x87, 0x02c6: 0x88, 0x2030: 0x89, 0x0160: 0x8a, 0x2039: 0x8b, 0x0152: 0x8c, 0x017d: 0x8e, 0x2018: 0x91, 0x2019: 0x92, 0x201c: 0x93, 0x201d: 0x94, 0x2022: 0x95, 0x2013: 0x96, 0x2014: 0x97, 0x02dc: 0x98, 0x2122: 0x99, 0x0161: 0x9a, 0x203a: 0x9b, 0x0153: 0x9c, 0x017e: 0x9e, 0x0178: 0x9f, }; function pdfWinAnsiBuffer(text) { const bytes = []; for (const char of String(text || "")) { const code = char.codePointAt(0); if (code < 0x80 || (code > 0x9f && code <= 0xff)) bytes.push(code); else if (PDF_WIN_ANSI_OVERRIDES[code] !== undefined) bytes.push(PDF_WIN_ANSI_OVERRIDES[code]); else bytes.push(0x3f); // "?" - outside what a standard PDF font/WinAnsiEncoding can represent } return Buffer.from(bytes); } function getLanUrls(port) { const interfaces = os.networkInterfaces(); const urls = []; Object.values(interfaces).forEach((records) => { (records || []).forEach((rec) => { if (rec && rec.family === "IPv4" && !rec.internal) { urls.push(`http://${rec.address}:${port}`); } }); }); return urls; } function watchAppFiles() { WATCHED_FILES.forEach((filePath) => { if (!fs.existsSync(filePath)) { return; } fs.watchFile(filePath, { interval: 1000 }, (curr, prev) => { if (curr.mtimeMs !== prev.mtimeMs) { bumpAppVersion(); } }); }); } function bumpAppVersion() { appVersion.revision += 1; appVersion.updatedAt = new Date().toISOString(); } function getDefaultAmmcExecutablePath(platform = process.platform) { if (platform === "win32") { return path.join(__dirname, "AMMC", "windows64", "ammc-amb.exe"); } if (platform === "darwin") { return path.join(__dirname, "AMMC", "apple_m", "ammc-amb"); } return path.join(__dirname, "AMMC", "linux_x86-64", "ammc-amb"); } function normalizeAmmcConfig(input = {}, previous = {}) { const decoderHost = String(input.decoderHost ?? previous.decoderHost ?? "").trim(); const splitDecoderHost = String(input.splitDecoderHost ?? previous.splitDecoderHost ?? "").trim(); const pitEntryDecoderHost = String(input.pitEntryDecoderHost ?? previous.pitEntryDecoderHost ?? "").trim(); const pitExitDecoderHost = String(input.pitExitDecoderHost ?? previous.pitExitDecoderHost ?? "").trim(); const executablePath = String(input.executablePath ?? previous.executablePath ?? getDefaultAmmcExecutablePath()).trim(); const workingDirectory = String(input.workingDirectory ?? previous.workingDirectory ?? "").trim(); const extraArgs = String(input.extraArgs ?? previous.extraArgs ?? "").trim(); const normalizePort = (value, fallback, label) => { const port = Number(value ?? fallback); if (!Number.isFinite(port) || port < 1 || port > 65535) { throw new Error(`${label} WebSocket port must be between 1 and 65535`); } return Math.round(port); }; const wsPort = normalizePort(input.wsPort ?? previous.wsPort, 9000, "Main decoder"); const splitWsPort = normalizePort(input.splitWsPort ?? previous.splitWsPort, 9001, "Split decoder"); const pitEntryWsPort = normalizePort(input.pitEntryWsPort ?? previous.pitEntryWsPort, 9002, "Pit entry decoder"); const pitExitWsPort = normalizePort(input.pitExitWsPort ?? previous.pitExitWsPort, 9003, "Pit exit decoder"); const splitEnabled = Boolean(input.splitEnabled ?? previous.splitEnabled ?? false); const pitEnabled = Boolean(input.pitEnabled ?? previous.pitEnabled ?? false); const enabledPorts = [wsPort, ...(splitEnabled ? [splitWsPort] : []), ...(pitEnabled ? [pitEntryWsPort, pitExitWsPort] : [])]; if (new Set(enabledPorts).size !== enabledPorts.length) { throw new Error("Every enabled AMMC decoder channel must use a unique WebSocket port"); } return { managedEnabled: Boolean(input.managedEnabled ?? previous.managedEnabled ?? false), autoStart: Boolean(input.autoStart ?? previous.autoStart ?? false), decoderHost, wsPort, splitEnabled, splitDecoderHost, splitWsPort, pitEnabled, pitEntryDecoderHost, pitEntryWsPort, pitExitDecoderHost, pitExitWsPort, executablePath, workingDirectory, extraArgs, updatedAt: new Date().toISOString(), }; } function loadAmmcConfig() { if (!fs.existsSync(AMMC_CONFIG_PATH)) { return normalizeAmmcConfig({ managedEnabled: false, autoStart: false, decoderHost: "", wsPort: 9000, splitEnabled: false, splitDecoderHost: "", splitWsPort: 9001, pitEnabled: false, pitEntryDecoderHost: "", pitEntryWsPort: 9002, pitExitDecoderHost: "", pitExitWsPort: 9003, executablePath: getDefaultAmmcExecutablePath(), workingDirectory: "", extraArgs: "", }); } try { const raw = fs.readFileSync(AMMC_CONFIG_PATH, "utf8"); return normalizeAmmcConfig(JSON.parse(raw)); } catch (error) { console.error(`Failed to load AMMC config: ${error instanceof Error ? error.message : String(error)}`); return normalizeAmmcConfig({ managedEnabled: false, autoStart: false, decoderHost: "", wsPort: 9000, splitEnabled: false, splitDecoderHost: "", splitWsPort: 9001, pitEnabled: false, pitEntryDecoderHost: "", pitEntryWsPort: 9002, pitExitDecoderHost: "", pitExitWsPort: 9003, executablePath: getDefaultAmmcExecutablePath(), workingDirectory: "", extraArgs: "", }); } } function saveAmmcConfig(config) { fs.writeFileSync(AMMC_CONFIG_PATH, JSON.stringify(config, null, 2)); } function resolveAmmcExecutable(executablePath) { const value = String(executablePath || "").trim(); if (!value) { return getDefaultAmmcExecutablePath(); } if (path.isAbsolute(value)) { return value; } return path.join(__dirname, value); } function resolveAmmcWorkingDirectory(config, executablePath) { const explicitDir = String(config.workingDirectory || "").trim(); if (!explicitDir) { return path.dirname(executablePath); } return path.isAbsolute(explicitDir) ? explicitDir : path.join(__dirname, explicitDir); } function parseExtraArgs(extraArgs) { return String(extraArgs || "") .split(/\s+/) .map((part) => part.trim()) .filter(Boolean); } const AMMC_CHANNEL_CONFIG = { main: { label: "Main", hostKey: "decoderHost", portKey: "wsPort" }, split: { label: "Split", hostKey: "splitDecoderHost", portKey: "splitWsPort", enabledKey: "splitEnabled" }, pitEntry: { label: "Pit entry", hostKey: "pitEntryDecoderHost", portKey: "pitEntryWsPort", enabledKey: "pitEnabled" }, pitExit: { label: "Pit exit", hostKey: "pitExitDecoderHost", portKey: "pitExitWsPort", enabledKey: "pitEnabled" }, }; function getAmmcChannel(role = "main") { return ammcState.channels[role] || ammcState.channels.main; } function isAmmcChannelEnabled(role, config = ammcState.config) { const descriptor = AMMC_CHANNEL_CONFIG[role] || AMMC_CHANNEL_CONFIG.main; return Boolean(config.managedEnabled && (!descriptor.enabledKey || config[descriptor.enabledKey])); } function isAmmcRunning(role = "main") { const channel = getAmmcChannel(role); return Boolean(channel.process && channel.process.exitCode == null && !channel.process.killed); } function buildAmmcChannelStatus(role, resolvedExecutablePath, workingDirectory) { const channel = getAmmcChannel(role); const descriptor = AMMC_CHANNEL_CONFIG[role]; return { role, label: descriptor.label, enabled: isAmmcChannelEnabled(role), running: isAmmcRunning(role), pid: channel.pid, startedAt: channel.startedAt, stoppedAt: channel.stoppedAt, lastExitCode: channel.lastExitCode, lastExitSignal: channel.lastExitSignal, lastError: channel.lastError, lastOutput: channel.lastOutput, decoderHost: ammcState.config[descriptor.hostKey] || "", wsPort: ammcState.config[descriptor.portKey], serverPlatform: process.platform, executableExists: fs.existsSync(resolvedExecutablePath), resolvedExecutablePath, workingDirectory, }; } function buildAmmcStatus() { const resolvedExecutablePath = resolveAmmcExecutable(ammcState.config.executablePath); const workingDirectory = resolveAmmcWorkingDirectory(ammcState.config, resolvedExecutablePath); const main = buildAmmcChannelStatus("main", resolvedExecutablePath, workingDirectory); const split = buildAmmcChannelStatus("split", resolvedExecutablePath, workingDirectory); const pitEntry = buildAmmcChannelStatus("pitEntry", resolvedExecutablePath, workingDirectory); const pitExit = buildAmmcChannelStatus("pitExit", resolvedExecutablePath, workingDirectory); const lastOutput = [main, split, pitEntry, pitExit] .flatMap((channel) => channel.lastOutput) .sort((a, b) => String(a.ts).localeCompare(String(b.ts))) .slice(-60); return { ...main, lastOutput, split, pitEntry, pitExit, }; } function appendAmmcOutput(role, stream, chunk) { const channel = getAmmcChannel(role); const lines = String(chunk || "") .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); lines.forEach((line) => { channel.lastOutput.push({ ts: new Date().toISOString(), role, stream, line, }); }); if (channel.lastOutput.length > 30) { channel.lastOutput.splice(0, channel.lastOutput.length - 30); } } function startAmmcProcess(role = "main") { if (isAmmcRunning(role)) return buildAmmcStatus(); const config = ammcState.config; const descriptor = AMMC_CHANNEL_CONFIG[role]; const channel = getAmmcChannel(role); if (!isAmmcChannelEnabled(role, config)) return buildAmmcStatus(); const decoderHost = config[descriptor.hostKey]; const wsPort = config[descriptor.portKey]; if (!decoderHost) throw new Error(`Set ${descriptor.label} decoder IP/host before starting AMMC`); const executablePath = resolveAmmcExecutable(config.executablePath); if (!fs.existsSync(executablePath)) { throw new Error(`AMMC executable not found: ${executablePath}`); } const args = ["-w", String(wsPort), decoderHost, ...parseExtraArgs(config.extraArgs)]; const workingDirectory = resolveAmmcWorkingDirectory(config, executablePath); channel.lastError = ""; appendAmmcOutput(role, "system", `Starting ${descriptor.label}: ${path.basename(executablePath)} ${args.join(" ")}`); const child = spawn(executablePath, args, { cwd: workingDirectory, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], }); channel.process = child; channel.pid = child.pid || null; channel.startedAt = new Date().toISOString(); channel.stoppedAt = null; channel.lastExitCode = null; channel.lastExitSignal = null; child.stdout.on("data", (chunk) => appendAmmcOutput(role, "stdout", chunk)); child.stderr.on("data", (chunk) => appendAmmcOutput(role, "stderr", chunk)); child.on("error", (error) => { channel.lastError = error instanceof Error ? error.message : String(error); appendAmmcOutput(role, "error", channel.lastError); }); child.on("exit", (code, signal) => { channel.lastExitCode = code; channel.lastExitSignal = signal; channel.stoppedAt = new Date().toISOString(); appendAmmcOutput(role, "system", `${descriptor.label} AMMC stopped (code=${code}, signal=${signal || "none"})`); channel.process = null; channel.pid = null; }); return buildAmmcStatus(); } function startAmmcProcesses() { const config = normalizeAmmcConfig(ammcState.config, ammcState.config); ammcState.config = config; saveAmmcConfig(config); if (!config.managedEnabled) throw new Error("Enable managed AMMC first"); Object.keys(AMMC_CHANNEL_CONFIG) .filter((role) => isAmmcChannelEnabled(role, config)) .forEach((role) => { const descriptor = AMMC_CHANNEL_CONFIG[role]; if (!config[descriptor.hostKey]) throw new Error(`Set ${descriptor.label} decoder IP/host before starting AMMC`); }); Object.keys(AMMC_CHANNEL_CONFIG).forEach((role) => startAmmcProcess(role)); return buildAmmcStatus(); } function stopAmmcProcess(role = "main") { const channel = getAmmcChannel(role); if (!isAmmcRunning(role) || !channel.process) { return Promise.resolve(buildAmmcStatus()); } const child = channel.process; return new Promise((resolve) => { let settled = false; const finish = () => { if (!settled) { settled = true; resolve(buildAmmcStatus()); } }; child.once("exit", finish); child.kill(); setTimeout(() => { if (!settled && isAmmcRunning(role)) { if (process.platform !== "win32") { child.kill("SIGKILL"); } finish(); } }, 3000); }); } async function stopAmmcProcesses() { await Promise.all(Object.keys(AMMC_CHANNEL_CONFIG).map((role) => stopAmmcProcess(role))); return buildAmmcStatus(); } function maybeStartManagedAmmc() { if (!ammcState.config.managedEnabled || !ammcState.config.autoStart) { return; } try { startAmmcProcesses(); console.log("Managed AMMC decoder channels started"); } catch (error) { const msg = error instanceof Error ? error.message : String(error); getAmmcChannel("main").lastError = msg; console.error(`Managed AMMC failed to start: ${msg}`); } } async function shutdown() { await stopAmmcProcesses(); removePidFile(); process.exit(0); } process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); process.on("exit", removePidFile); function writePidFile() { if (!PID_FILE) { return; } try { fs.writeFileSync(PID_FILE, `${process.pid}\n`); } catch (error) { console.error(`Failed to write pid file: ${error instanceof Error ? error.message : String(error)}`); } } function removePidFile() { if (!PID_FILE || !fs.existsSync(PID_FILE)) { return; } try { const raw = fs.readFileSync(PID_FILE, "utf8").trim(); if (String(process.pid) === raw) { fs.rmSync(PID_FILE, { force: true }); } } catch { // ignore pid cleanup errors on shutdown } }