Files
Live_RC/server.js
larssand 2bbbd6e995 Fix Swedish åäö rendering as mojibake in PDF exports
buildSimplePdf's font declared no /Encoding at all, so PDF viewers fell
back to a standard font's built-in encoding, which has no å/ä/ö glyphs
- and separately, the content stream was written as UTF-8 instead of a
single-byte encoding, so even a correctly-encoded font would receive
the wrong bytes (a multi-byte UTF-8 sequence where PDF expects exactly
one byte per character): the classic "åäö" mojibake pattern.

Fixed by declaring /Encoding /WinAnsiEncoding and writing the content
stream through a proper WinAnsi (~CP1252) byte mapper instead of UTF-8.
This is the single shared renderer behind every PDF export (race
results, start lists, heat sheets, team reports, the results archive),
so the fix applies everywhere at once, matching what was reported.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 21:31:01 +02:00

1369 lines
47 KiB
JavaScript

const fs = require("fs");
const path = require("path");
const os = require("os");
const net = require("net");
const { spawn } = 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();
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/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
}
}