Settings now shows when a newer version is available, with the real version number read from the remote's package.json (via the git binary already required for this deployment model, so it works against any remote, not just GitHub), and an "Uppdatera nu" button that stops the server, pulls, reinstalls dependencies and restarts automatically - warning explicitly if a race is currently active. The existing app-version poller already reloads the page once a restart is detected, so no new client-side polling was needed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1450 lines
50 KiB
JavaScript
1450 lines
50 KiB
JavaScript
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 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 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.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
|
|
}
|
|
}
|