// Release / updater UI controller // Handles checking, applying, rollback, channel toggle, changelog modal, and log rendering. import { getReleaseStatus, checkRelease, applyRelease, rollbackRelease, setReleaseAutoCheck, setReleaseChannel, } from "./api.js"; function shorten(text, max = 90) { if (!text || typeof text !== "string") return text; return text.length > max ? `${text.slice(0, max - 3)}...` : text; } export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, logUi = () => {} }) { const releaseFlagTop = document.getElementById("releaseFlagTop"); const releaseBtn = document.getElementById("releaseBtn"); const releaseModal = document.getElementById("releaseModal"); const releaseClose = document.getElementById("releaseClose"); const releaseCurrent = document.getElementById("releaseCurrent"); const releaseLatest = document.getElementById("releaseLatest"); const releaseStatusMsg = document.getElementById("releaseStatusMsg"); const releaseProgress = document.getElementById("releaseProgress"); const releaseCheckBtn = document.getElementById("releaseCheckBtn"); const releaseApplyBtn = document.getElementById("releaseApplyBtn"); const releaseRollbackBtn = document.getElementById("releaseRollbackBtn"); const releaseAutoCheck = document.getElementById("releaseAutoCheck"); const releaseLog = document.getElementById("releaseLog"); const releaseLogStatus = document.getElementById("releaseLogStatus"); const releaseLogCopy = document.getElementById("releaseLogCopy"); const releaseChangelogBtn = window.__pikitReleaseChangelogBtn || document.getElementById("releaseChangelogBtn"); const releaseChannelToggle = window.__pikitReleaseChannelToggle || document.getElementById("releaseChannelToggle"); window.__pikitReleaseChangelogBtn = releaseChangelogBtn; window.__pikitReleaseChannelToggle = releaseChannelToggle; const changelogModal = document.getElementById("changelogModal"); const changelogTitle = document.getElementById("changelogTitle"); const changelogBody = document.getElementById("changelogBody"); const changelogClose = document.getElementById("changelogClose"); let releaseBusyActive = false; let releaseLogLines = []; let releaseLastFetched = 0; let lastReleaseLogKey = ""; let lastReleaseToastKey = null; let lastLogMessage = null; let changelogCache = { version: null, text: "" }; let lastChangelogUrl = null; let releaseChannel = "dev"; function logRelease(msg) { if (!msg) return; const plain = msg.trim(); if (plain === lastLogMessage) return; lastLogMessage = plain; const ts = new Date().toLocaleTimeString(); const line = `${ts} ${msg}`; releaseLogLines.unshift(line); releaseLogLines = releaseLogLines.slice(0, 120); if (releaseLog) { releaseLog.textContent = releaseLogLines.join("\n"); releaseLog.scrollTop = 0; // keep most recent in view } // Mirror into global diagnostics log (frontend side) const lvl = msg.toLowerCase().startsWith("error") ? "error" : "info"; logUi(`Update: ${msg}`, lvl); } function setReleaseChip(state) { if (!releaseFlagTop) return; releaseFlagTop.textContent = "Pi-Kit: n/a"; releaseFlagTop.className = "status-chip quiet"; if (!state) return; const { status, latest_version, current_version, message } = state; const label = status === "update_available" ? `Update → ${latest_version || "new"}` : status === "up_to_date" ? `Pi-Kit: ${current_version || "latest"}` : status === "checking" ? "Checking…" : status === "error" ? "Update error" : `Pi-Kit: ${current_version || "n/a"}`; releaseFlagTop.textContent = label; if (status === "update_available") releaseFlagTop.classList.add("chip-warm"); if (status === "error") releaseFlagTop.classList.add("chip-off"); const msg = shorten(message, 80) || ""; releaseFlagTop.title = msg || "Pi-Kit release status"; if (releaseStatusMsg) { releaseStatusMsg.textContent = status === "update_available" ? msg || "Update available" : ""; releaseStatusMsg.classList.remove("error"); } if (releaseLogStatus) { releaseLogStatus.textContent = status === "in_progress" ? "Running…" : status === "update_available" ? "Update available" : status === "error" ? "Error" : "Idle"; } if (releaseChangelogBtn) { releaseChangelogBtn.disabled = status === "checking"; releaseChangelogBtn.classList.toggle("ghost", status !== "update_available"); } } async function showChangelog(version, url) { if (!changelogBody || !changelogModal) { window.open(url, "_blank"); return; } try { if (changelogCache.version === version && changelogCache.text) { changelogBody.textContent = changelogCache.text; } else { changelogBody.textContent = "Loading changelog…"; const res = await fetch(`/api/update/changelog?${url ? `url=${encodeURIComponent(url)}` : ""}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); const text = data.text || "No changelog content."; changelogCache = { version, text }; changelogBody.textContent = text; } changelogTitle.textContent = version ? `Changelog — ${version}` : "Changelog"; releaseModal?.classList.add("hidden"); changelogModal.classList.remove("hidden"); } catch (e) { console.error("Changelog fetch failed", e); showToast("Failed to load changelog", "error"); } } async function loadReleaseStatus(force = false) { if (!releaseFlagTop) return; const now = Date.now(); if (!force && now - releaseLastFetched < 60000 && !releaseBusyActive) { return; } setReleaseChip({ status: "checking" }); try { const data = await getReleaseStatus(); const { current_version = "n/a", latest_version = "n/a", status = "unknown", message = "", auto_check = false, progress = null, channel = "dev", } = data || {}; releaseChannel = channel || "dev"; if (releaseChannelToggle) releaseChannelToggle.checked = releaseChannel === "dev"; window.__lastReleaseState = data; const key = [status, progress, message].join("|"); if (key !== lastReleaseLogKey) { logRelease(`Status: ${status}${progress ? " • " + progress : ""}${message ? " • " + message : ""}`); lastReleaseLogKey = key; } releaseLastFetched = now; if (status === "update_available" && message && message.startsWith("http")) { lastChangelogUrl = message; } else if (latest_version) { lastChangelogUrl = `https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v${latest_version}/CHANGELOG-${latest_version}.txt`; } setReleaseChip(data); if (releaseCurrent) releaseCurrent.textContent = current_version; if (releaseLatest) releaseLatest.textContent = latest_version; if (releaseAutoCheck) releaseAutoCheck.checked = !!auto_check; if (releaseProgress) releaseProgress.textContent = ""; if (status === "in_progress" && progress) { showBusy("Working on update…", progress || "This can take up to a minute."); pollReleaseStatus(); } } catch (e) { // During an update/rollback the API may restart; retry quietly. if (releaseBusyActive) { setTimeout(() => loadReleaseStatus(true), 1000); return; } console.error("Failed to load release status", e); setReleaseChip({ status: "error", message: "Failed to load" }); // surface via toast/log only once logRelease("Error: failed to load release status"); showToast("Failed to load release status", "error"); } } function pollReleaseStatus() { let attempts = 0; const maxAttempts = 30; // ~1 min at 1s const started = Date.now(); const minWaitMs = 3000; const tick = async () => { attempts += 1; await loadReleaseStatus(true); const state = window.__lastReleaseState || {}; const tooSoon = Date.now() - started < minWaitMs; if ((state.status === "in_progress" || tooSoon) && attempts < maxAttempts) { setTimeout(tick, 1000); } else { releaseBusyActive = false; hideBusy(); if (releaseProgress) releaseProgress.textContent = ""; // Only toast once per apply/rollback cycle if (state.status === "up_to_date" && releaseBusyActive === false) { const key = `ok-${state.current_version || ""}-${state.latest_version || ""}`; if (lastReleaseToastKey !== key) { lastReleaseToastKey = key; showToast(state.message || "Update complete", "success"); } logRelease("Update complete"); } else if (state.status === "error") { const key = `err-${state.message || ""}`; if (lastReleaseToastKey !== key) { lastReleaseToastKey = key; showToast(state.message || "Update failed", "error"); } logRelease(`Error: ${state.message || "Update failed"}`); } } }; tick(); } function wireReleaseControls() { releaseBtn?.addEventListener("click", () => { releaseModal?.classList.remove("hidden"); loadReleaseStatus(true); }); releaseClose?.addEventListener("click", () => releaseModal?.classList.add("hidden")); // Do not allow dismiss by clicking backdrop (consistency with other modals) releaseModal?.addEventListener("click", (e) => { if (e.target === releaseModal) { e.stopPropagation(); } }); releaseCheckBtn?.addEventListener("click", async () => { try { logRelease("Checking for updates…"); logUi("Update check requested"); await checkRelease(); await loadReleaseStatus(true); const state = window.__lastReleaseState || {}; logRelease( `Status: ${state.status || "unknown"}${state.message ? " • " + state.message : ""}`, ); showToast("Checked for updates", "success"); } catch (e) { showToast(e.error || "Check failed", "error"); logRelease(`Error: ${e.error || "Check failed"}`); } finally { if (releaseProgress) releaseProgress.textContent = ""; } }); releaseApplyBtn?.addEventListener("click", async () => { try { lastReleaseToastKey = null; logUi("Update apply requested"); const state = window.__lastReleaseState || {}; const { current_version, latest_version } = state; const sameVersion = current_version && latest_version && String(current_version) === String(latest_version); if (sameVersion) { const proceed = await confirmAction( "Reinstall same version?", `You are already on ${current_version}. Reinstall this version anyway?`, ); if (!proceed) { logRelease("Upgrade cancelled (same version)."); return; } } releaseBusyActive = true; showBusy("Updating Pi-Kit…", "Applying release. This can take up to a minute."); logRelease("Starting upgrade…"); await applyRelease(); pollReleaseStatus(); showToast("Update started", "success"); } catch (e) { showToast(e.error || "Update failed", "error"); logRelease(`Error: ${e.error || "Update failed"}`); } finally { if (releaseProgress) releaseProgress.textContent = ""; } }); releaseRollbackBtn?.addEventListener("click", async () => { try { lastReleaseToastKey = null; logUi("Rollback requested"); releaseBusyActive = true; showBusy("Rolling back…", "Restoring previous backup."); logRelease("Starting rollback…"); await rollbackRelease(); pollReleaseStatus(); showToast("Rollback started", "success"); } catch (e) { showToast(e.error || "Rollback failed", "error"); logRelease(`Error: ${e.error || "Rollback failed"}`); } finally { if (releaseProgress) releaseProgress.textContent = ""; } }); releaseAutoCheck?.addEventListener("change", async () => { try { await setReleaseAutoCheck(releaseAutoCheck.checked); showToast("Auto-check preference saved", "success"); } catch (e) { showToast(e.error || "Failed to save preference", "error"); releaseAutoCheck.checked = !releaseAutoCheck.checked; } }); releaseChannelToggle?.addEventListener("change", async () => { try { const chan = releaseChannelToggle.checked ? "dev" : "stable"; await setReleaseChannel(chan); releaseChannel = chan; logRelease(`Channel set to ${chan}`); await loadReleaseStatus(true); } catch (e) { showToast(e.error || "Failed to save channel", "error"); releaseChannelToggle.checked = releaseChannel === "dev"; } }); releaseChangelogBtn?.addEventListener("click", async () => { const state = window.__lastReleaseState || {}; const { latest_version, message } = state; const url = (message && message.startsWith("http") ? message : null) || lastChangelogUrl; if (!url) { showToast("No changelog URL available", "error"); return; } await showChangelog(latest_version, url); }); releaseLogCopy?.addEventListener("click", async () => { try { const text = releaseLogLines.join("\n") || "No log entries yet."; if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); } else { // Fallback for non-HTTPS contexts const ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); document.body.removeChild(ta); } showToast("Log copied", "success"); } catch (e) { console.error("Copy failed", e); showToast("Could not copy log", "error"); } }); changelogClose?.addEventListener("click", () => { changelogModal?.classList.add("hidden"); releaseModal?.classList.remove("hidden"); }); } wireReleaseControls(); return { refreshStatus: (force = false) => loadReleaseStatus(force), logRelease, }; }