Lumi/src/services/development-updates.js
2026-07-24 14:44:27 +02:00

322 lines
13 KiB
JavaScript

const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { spawn } = require("child_process");
const DEFAULT_EXCLUDED_DIRECTORIES = new Set([
".git", ".vs", ".vscode", "bin", "obj", "node_modules", "data", "logs",
"dist", "build", "coverage", "output", "publish", "package", "tmp", "temp",
"tests", "docs"
]);
const DEFAULT_EXCLUDED_FILES = new Set([
".ds_store", "thumbs.db", "readme.md", "changelog.md"
]);
class DevelopmentUpdateService {
constructor(options = {}) {
this.repoRoot = path.resolve(options.repoRoot || path.join(__dirname, "..", ".."));
this.cacheRoot = path.resolve(options.cacheRoot || path.join(this.repoRoot, "data", "development-updates"));
this.runtime = options.runtime || global.lumiRuntime || null;
this.spawn = options.spawn || spawn;
this.platform = options.platform || process.platform;
this.activeBuilds = new Map();
}
enabled() {
return Boolean(this.runtime?.isDevelopment);
}
inventory() {
const companionRoot = path.join(this.repoRoot, "companion");
const components = [];
const relatedServerPluginIds = new Set();
const corePaths = [
path.join(companionRoot, "src"),
path.join(companionRoot, "legal"),
path.join(companionRoot, "Lumi.Companion.sln"),
path.join(companionRoot, "scripts", "publish-dev-update.ps1")
].filter((target) => fs.existsSync(target));
if (corePaths.length) {
components.push(componentRecord("companion:core", "companion-core", corePaths, this.repoRoot));
}
const companionPluginsRoot = path.join(companionRoot, "plugins");
for (const name of listDirectories(companionPluginsRoot)) {
const pluginRoot = path.join(companionPluginsRoot, name);
components.push(componentRecord(`companion-plugin:${name}`, "companion-plugin", [pluginRoot], this.repoRoot));
const declaredId = readPluginId(path.join(pluginRoot, "plugin.json"));
if (declaredId) relatedServerPluginIds.add(declaredId);
}
const serverPluginsRoot = path.join(this.repoRoot, "plugins");
for (const name of listDirectories(serverPluginsRoot)) {
const pluginRoot = path.join(serverPluginsRoot, name);
const related = relatedServerPluginIds.has(name) || fs.existsSync(path.join(pluginRoot, "companion_manifest.json"));
components.push({
...componentRecord(`lumi-plugin:${name}`, "lumi-plugin", [pluginRoot], this.repoRoot),
companion_related: related
});
}
components.sort((a, b) => a.id.localeCompare(b.id));
const packageComponents = components.filter((entry) => entry.kind === "companion-core" || entry.kind === "companion-plugin");
const synchronizedServerComponents = components.filter((entry) => entry.kind === "lumi-plugin" && entry.companion_related);
const trackedComponents = [...packageComponents, ...synchronizedServerComponents].sort((a, b) => a.id.localeCompare(b.id));
const aggregateChecksum = aggregateComponents(trackedComponents);
return {
generated_at: new Date().toISOString(),
aggregate_checksum: aggregateChecksum,
components,
package_components: packageComponents,
synchronized_server_components: synchronizedServerComponents,
tracked_components: trackedComponents,
updatable_components: trackedComponents
};
}
compare(current = {}) {
const inventory = this.inventory();
const currentAggregate = normalizeChecksum(current.aggregate_checksum || current.build_checksum);
const currentComponents = normalizeComponentMap(current.components || current.component_checksums);
const changed = inventory.updatable_components.filter((entry) => currentComponents[entry.id] !== entry.checksum);
return {
...inventory,
current_aggregate_checksum: currentAggregate || null,
update_available: currentAggregate !== inventory.aggregate_checksum,
changed_components: changed.map((entry) => ({ id: entry.id, kind: entry.kind, checksum: entry.checksum })),
server_components: inventory.synchronized_server_components.map((entry) => ({ id: entry.id, checksum: entry.checksum }))
};
}
async buildCompanionArtifact(state = this.inventory()) {
if (!this.enabled()) throw new Error("Local development updates are disabled in production mode.");
const buildId = normalizeChecksum(state.aggregate_checksum);
if (!buildId) throw new Error("The local Companion source checksum is invalid.");
const cached = this.readArtifact(buildId);
if (cached) return cached;
if (this.activeBuilds.has(buildId)) return this.activeBuilds.get(buildId);
const build = this.buildCompanionArtifactInternal(state).finally(() => this.activeBuilds.delete(buildId));
this.activeBuilds.set(buildId, build);
return build;
}
readArtifact(buildId) {
const safeId = normalizeChecksum(buildId);
if (!safeId) return null;
const archivePath = path.join(this.cacheRoot, "companion", safeId, "Lumi.Companion-win-x64.zip");
const metadataPath = path.join(this.cacheRoot, "companion", safeId, "artifact.json");
if (!fs.existsSync(archivePath) || !fs.existsSync(metadataPath)) return null;
try {
const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf8"));
const stat = fs.statSync(archivePath);
const sha256 = sha256File(archivePath);
if (stat.size !== metadata.bytes || sha256 !== metadata.sha256) return null;
return { ...metadata, path: archivePath };
} catch {
return null;
}
}
async buildCompanionArtifactInternal(state) {
const buildId = state.aggregate_checksum;
const buildRoot = path.join(this.cacheRoot, "companion", buildId);
const archivePath = path.join(buildRoot, "Lumi.Companion-win-x64.zip");
const manifestPath = path.join(buildRoot, ".lumi-dev-build.json");
fs.mkdirSync(buildRoot, { recursive: true });
const manifest = {
schema_version: 1,
mode: "localhost-development",
build_checksum: buildId,
generated_at: new Date().toISOString(),
components: Object.fromEntries(state.updatable_components.map((entry) => [entry.id, entry.checksum]))
};
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
const script = path.join(this.repoRoot, "companion", "scripts", "publish-dev-update.ps1");
if (!fs.existsSync(script)) throw new Error("The local Companion development publish script is missing.");
const shell = findPowerShell(this.platform);
await runProcess(this.spawn, shell, [
"-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
"-File", script,
"-OutputArchive", archivePath,
"-ManifestPath", manifestPath
], { cwd: this.repoRoot, timeoutMs: 15 * 60 * 1000 });
if (!fs.existsSync(archivePath)) throw new Error("The local Companion build completed without producing an update archive.");
const afterBuild = this.inventory();
if (afterBuild.aggregate_checksum !== buildId) {
try { fs.rmSync(buildRoot, { recursive: true, force: true }); } catch { }
return this.buildCompanionArtifact(afterBuild);
}
const metadata = {
build_id: buildId,
sha256: sha256File(archivePath),
bytes: fs.statSync(archivePath).size,
entrypoint: "Lumi.Companion.App.exe",
generated_at: new Date().toISOString(),
changed_components: state.updatable_components.map((entry) => entry.id)
};
fs.writeFileSync(path.join(buildRoot, "artifact.json"), `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
this.cleanupArtifacts(buildId);
return { ...metadata, path: archivePath };
}
cleanupArtifacts(keepBuildId, maximum = 5) {
const root = path.join(this.cacheRoot, "companion");
if (!fs.existsSync(root)) return;
const entries = fs.readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name !== keepBuildId)
.map((entry) => ({ name: entry.name, path: path.join(root, entry.name), mtime: fs.statSync(path.join(root, entry.name)).mtimeMs }))
.sort((a, b) => b.mtime - a.mtime);
for (const entry of entries.slice(Math.max(0, maximum - 1))) {
try { fs.rmSync(entry.path, { recursive: true, force: true }); } catch { }
}
}
}
function componentRecord(id, kind, targets, root) {
const checksum = hashTargets(targets, root);
return {
id,
kind,
checksum,
paths: targets.map((target) => path.relative(root, target).replace(/\\/g, "/"))
};
}
function hashTargets(targets, root) {
const files = [];
for (const target of targets) collectFiles(target, files);
files.sort((a, b) => a.localeCompare(b));
const hash = crypto.createHash("sha256");
for (const file of files) {
const relative = path.relative(root, file).replace(/\\/g, "/");
const stat = fs.statSync(file);
hash.update(relative, "utf8");
hash.update("\0");
hash.update(String(stat.size), "utf8");
hash.update("\0");
const fd = fs.openSync(file, "r");
const buffer = Buffer.alloc(1024 * 1024);
try {
let read;
while ((read = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, read));
} finally { fs.closeSync(fd); }
hash.update("\0");
}
return hash.digest("hex");
}
function collectFiles(target, output) {
if (!fs.existsSync(target)) return;
const stat = fs.lstatSync(target);
if (stat.isSymbolicLink()) return;
if (stat.isFile()) {
if (!DEFAULT_EXCLUDED_FILES.has(path.basename(target).toLowerCase())) output.push(target);
return;
}
if (!stat.isDirectory()) return;
for (const entry of fs.readdirSync(target, { withFileTypes: true })) {
if (entry.isDirectory() && DEFAULT_EXCLUDED_DIRECTORIES.has(entry.name)) continue;
if (entry.isFile() && DEFAULT_EXCLUDED_FILES.has(entry.name.toLowerCase())) continue;
collectFiles(path.join(target, entry.name), output);
}
}
function aggregateComponents(components) {
const hash = crypto.createHash("sha256");
for (const entry of components.slice().sort((a, b) => a.id.localeCompare(b.id))) {
hash.update(entry.id, "utf8");
hash.update("\0");
hash.update(entry.checksum, "utf8");
hash.update("\0");
}
return hash.digest("hex");
}
function normalizeChecksum(value) {
const normalized = String(value || "").trim().toLowerCase();
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : "";
}
function normalizeComponentMap(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const output = {};
for (const [key, checksum] of Object.entries(value)) {
const safe = normalizeChecksum(checksum);
if (safe && /^[-a-z0-9_.:]{1,160}$/i.test(key)) output[key] = safe;
}
return output;
}
function readPluginId(manifestPath) {
if (!fs.existsSync(manifestPath)) return "";
try {
const value = JSON.parse(fs.readFileSync(manifestPath, "utf8"))?.id;
return /^[-a-z0-9_.]{1,100}$/i.test(String(value || "")) ? String(value) : "";
} catch { return ""; }
}
function listDirectories(root) {
if (!fs.existsSync(root)) return [];
return fs.readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
}
function sha256File(target) {
const hash = crypto.createHash("sha256");
const fd = fs.openSync(target, "r");
const buffer = Buffer.alloc(1024 * 1024);
try {
let read;
while ((read = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, read));
} finally { fs.closeSync(fd); }
return hash.digest("hex");
}
function findPowerShell(platform = process.platform) {
if (platform !== "win32") return "pwsh";
return "powershell.exe";
}
function runProcess(spawnImpl, command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawnImpl(command, args, {
cwd: options.cwd,
env: { ...process.env, DOTNET_CLI_TELEMETRY_OPTOUT: "1" },
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"]
});
let stdout = "";
let stderr = "";
const limit = 256 * 1024;
child.stdout?.on("data", (chunk) => { stdout = `${stdout}${chunk}`.slice(-limit); });
child.stderr?.on("data", (chunk) => { stderr = `${stderr}${chunk}`.slice(-limit); });
const timer = setTimeout(() => {
child.kill();
reject(new Error("The local Companion build timed out."));
}, options.timeoutMs || 15 * 60 * 1000);
child.once("error", (error) => { clearTimeout(timer); reject(error); });
child.once("exit", (code) => {
clearTimeout(timer);
if (code === 0) return resolve({ stdout, stderr });
const detail = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
reject(new Error(`The local Companion build failed with exit code ${code}. ${detail}`.trim()));
});
});
}
let singleton = null;
function getDevelopmentUpdateService(options = {}) {
if (!singleton || options.reset) singleton = new DevelopmentUpdateService(options);
return singleton;
}
module.exports = {
DevelopmentUpdateService,
aggregateComponents,
getDevelopmentUpdateService,
hashTargets,
normalizeComponentMap,
normalizeChecksum,
sha256File
};