Lumi/plugins/lumi_transcription/backend/companion/package_service.js
2026-07-24 14:44:27 +02:00

104 lines
6.0 KiB
JavaScript

const fs = require("fs");
const path = require("path");
const AdmZip = require("adm-zip");
const { ArtifactManager } = require("../models/artifact_manager");
class CompanionPackageService {
constructor(root, manifest, options = {}) {
this.root = root;
this.manifestPath = typeof manifest === "string" ? manifest : null;
this.manifest = typeof manifest === "string" ? null : manifest;
this.artifacts = new ArtifactManager(root, { fetch: options.fetch });
fs.mkdirSync(root, { recursive: true });
}
currentManifest() { return this.manifestPath ? JSON.parse(fs.readFileSync(this.manifestPath, "utf8")) : this.manifest; }
entry(manifest = this.currentManifest()) { return manifest?.artifacts?.find((entry) => entry.platform === "win32" && entry.architecture === "x64") || null; }
installerEntry(manifest = this.currentManifest()) { return manifest?.installer || null; }
status() {
const manifest = this.currentManifest();
const entry = this.entry(manifest);
if (!entry) return { available: false, installed: false, valid: false, reason: "No Windows Companion artifact is configured." };
const artifact = this.artifacts.status(entry);
return { available: true, version: manifest.version, artifact: entry.id, ...artifact };
}
async build(pairing) {
const manifest = this.currentManifest();
const entry = this.entry(manifest);
if (!entry) throw new Error("No Windows Companion artifact is configured.");
const output = new AdmZip();
const installer = this.installerEntry(manifest);
if (installer) {
let installerStatus = this.artifacts.status(installer);
if (!installerStatus.valid) installerStatus = await this.artifacts.download(installer, { confirmed: true });
const installerName = safeArchivePath(installer.filename || "Lumi.Companion-Setup.exe");
if (!installerName.toLowerCase().endsWith(".exe")) throw new Error("The Companion installer entrypoint is invalid.");
output.addFile(installerName, fs.readFileSync(installerStatus.path));
} else {
let status = this.artifacts.status(entry);
if (!status.valid) status = await this.artifacts.download(entry, { confirmed: true });
const source = new AdmZip(status.path);
let expandedBytes = 0;
for (const item of source.getEntries()) {
const name = safeArchivePath(item.entryName);
if (!name || item.isDirectory) continue;
const body = item.getData();
expandedBytes += body.length;
if (expandedBytes > 300 * 1024 * 1024) throw new Error("The Companion artifact exceeds its expanded size limit.");
output.addFile(name, body);
}
if (!output.getEntry(entry.entrypoint)) throw new Error("The Companion artifact is missing its expected application entrypoint.");
}
const pairingName = `lumi-companion-${pairing.pairing_id}.lumi-pairing.json`;
output.addFile(pairingName, Buffer.from(`${JSON.stringify(pairing.bootstrap, null, 2)}\n`, "utf8"));
output.addFile("HOST-OPERATOR-NOTICE.txt", Buffer.from(hostOperatorNotice(pairing.bootstrap), "utf8"));
const host = safeNoticeValue(pairing.bootstrap?.host, "the Lumi host identified by the pairing package", 500);
output.addFile("START-HERE.txt", Buffer.from([
"Lumi Companion", "",
"1. Extract all files in this ZIP to a temporary folder on the Windows streaming computer.",
"2. Read HOST-OPERATOR-NOTICE.txt. The installer will also display the paired host and legal notices.",
`3. Start ${installer?.filename || entry.entrypoint} within 15 minutes.`,
"4. Setup installs Companion in your Windows account, imports the adjacent one-time pairing package, and launches the durable installed copy.",
"5. After setup completes, this extracted folder can be deleted. Use the Start menu to open Lumi Companion.", "",
`Paired Lumi host: ${host}`,
"Do not share this ZIP. Its pairing package works once and expires after 15 minutes.",
"Windows may warn because this release is not code-signed yet.", ""
].join("\r\n"), "utf8"));
return { buffer: output.toBuffer(), filename: `Lumi-Companion-${manifest.version}-paired.zip`, pairingName };
}
}
function safeNoticeValue(value, fallback, maxLength = 300) {
const clean = String(value || "").replace(/[\r\n\0]/g, " ").trim().slice(0, maxLength);
return clean || fallback;
}
function hostOperatorNotice(bootstrap = {}) {
const host = safeNoticeValue(bootstrap.host, "Not identified", 500);
const operatorName = safeNoticeValue(bootstrap.operator_name, "Not supplied by this host", 200);
const operatorContact = safeNoticeValue(bootstrap.operator_contact, `Use the Lumi WebUI or administrator at ${host}`, 300);
const privacyUrl = safeNoticeValue(bootstrap.privacy_url, "Not supplied by this host", 500);
return [
"LUMI COMPANION — HOST OPERATOR NOTICE", "",
`Paired Lumi host: ${host}`,
`Operator name: ${operatorName}`,
`Operator contact: ${operatorContact}`,
`Privacy information: ${privacyUrl}`, "",
"This package connects Companion to the Lumi installation at the host shown above. The person or organisation controlling that installation is responsible for its Hosted Service, including server configuration, user access, server-side processing, retention, integrations, notices, and compliance obligations.", "",
"OokamiKunTV is the developer of Lumi Companion and is not automatically the operator of an independently hosted Lumi installation. Responsibility follows the actual facts and applicable law. The host URL is a technical identifier and may not by itself identify the operator's legal name.", ""
].join("\r\n");
}
function safeArchivePath(value) {
const portable = String(value || "").replace(/\\/g, "/");
const normalized = path.posix.normalize(portable).replace(/^\/+/, "");
if (!normalized || normalized === ".." || normalized.startsWith("../") || /^[A-Za-z]:/.test(normalized)) throw new Error("The Companion artifact contains an unsafe path.");
return normalized;
}
module.exports = { CompanionPackageService, safeArchivePath };