438 lines
17 KiB
JavaScript
438 lines
17 KiB
JavaScript
// Release / updater UI controller
|
|
// Handles checking, applying, rollback, channel toggle, changelog modal, and log rendering.
|
|
|
|
import {
|
|
getReleaseStatus,
|
|
checkRelease,
|
|
applyRelease,
|
|
applyReleaseVersion,
|
|
listReleases,
|
|
setReleaseAutoCheck,
|
|
setReleaseChannel,
|
|
} from "./api.js";
|
|
import { shorten, createReleaseLogger } from "./releases-utils.js";
|
|
|
|
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 releaseCurrentDate = document.getElementById("releaseCurrentDate");
|
|
const releaseLatestDate = document.getElementById("releaseLatestDate");
|
|
const releaseStatusMsg = document.getElementById("releaseStatusMsg");
|
|
const releaseProgress = document.getElementById("releaseProgress");
|
|
const releaseCheckBtn = document.getElementById("releaseCheckBtn");
|
|
const releaseApplyBtn = document.getElementById("releaseApplyBtn");
|
|
const releaseVersionSelect = document.getElementById("releaseVersionSelect");
|
|
const releaseApplyVersionBtn = document.getElementById("releaseApplyVersionBtn");
|
|
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 releaseLastFetched = 0;
|
|
let lastReleaseLogKey = "";
|
|
let lastReleaseToastKey = null;
|
|
let changelogCache = { version: null, text: "" };
|
|
let lastChangelogUrl = null;
|
|
let releaseChannel = "dev";
|
|
let releaseOptions = [];
|
|
const logger = createReleaseLogger(logUi);
|
|
logger.attach(releaseLog);
|
|
|
|
const fmtDate = (iso) => {
|
|
if (!iso) return "—";
|
|
try {
|
|
const d = new Date(iso);
|
|
if (Number.isNaN(d.getTime())) return "—";
|
|
return d.toLocaleDateString(undefined, {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
} catch (e) {
|
|
return "—";
|
|
}
|
|
};
|
|
|
|
async function loadReleaseList() {
|
|
if (!releaseVersionSelect) return;
|
|
try {
|
|
const data = await listReleases();
|
|
releaseOptions = data.releases || [];
|
|
releaseVersionSelect.innerHTML = "";
|
|
if (!releaseOptions.length) {
|
|
const opt = document.createElement("option");
|
|
opt.value = "";
|
|
opt.textContent = "No releases found";
|
|
releaseVersionSelect.appendChild(opt);
|
|
releaseVersionSelect.disabled = true;
|
|
releaseApplyVersionBtn && (releaseApplyVersionBtn.disabled = true);
|
|
return;
|
|
}
|
|
releaseVersionSelect.disabled = false;
|
|
releaseOptions.forEach((r) => {
|
|
const opt = document.createElement("option");
|
|
opt.value = r.version;
|
|
const tag = r.prerelease ? " (dev)" : "";
|
|
opt.textContent = `${r.version}${tag}${r.published_at ? ` — ${fmtDate(r.published_at)}` : ""}`;
|
|
releaseVersionSelect.appendChild(opt);
|
|
});
|
|
if (releaseApplyVersionBtn) releaseApplyVersionBtn.disabled = false;
|
|
} catch (e) {
|
|
releaseVersionSelect.innerHTML = "";
|
|
const opt = document.createElement("option");
|
|
opt.value = "";
|
|
opt.textContent = "Failed to load releases";
|
|
releaseVersionSelect.appendChild(opt);
|
|
releaseVersionSelect.disabled = true;
|
|
if (releaseApplyVersionBtn) releaseApplyVersionBtn.disabled = true;
|
|
}
|
|
}
|
|
|
|
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) {
|
|
const isUrlMsg = msg && /^https?:/i.test(msg);
|
|
const safeMsg = isUrlMsg ? "Update available" : msg;
|
|
releaseStatusMsg.textContent = status === "update_available" ? safeMsg || "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");
|
|
}
|
|
}
|
|
|
|
const logRelease = logger.log;
|
|
|
|
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",
|
|
current_release_date = null,
|
|
latest_release_date = null,
|
|
changelog_url = null,
|
|
} = 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;
|
|
lastChangelogUrl = changelog_url || null;
|
|
if (!lastChangelogUrl && status === "update_available" && message && message.startsWith("http")) {
|
|
lastChangelogUrl = message;
|
|
} else if (!lastChangelogUrl && 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 (releaseCurrentDate) releaseCurrentDate.textContent = fmtDate(current_release_date);
|
|
if (releaseLatestDate) releaseLatestDate.textContent = fmtDate(latest_release_date);
|
|
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);
|
|
loadReleaseList();
|
|
});
|
|
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 = "";
|
|
}
|
|
});
|
|
|
|
releaseApplyVersionBtn?.addEventListener("click", async () => {
|
|
if (!releaseVersionSelect || !releaseVersionSelect.value) {
|
|
showToast("Select a version first", "error");
|
|
return;
|
|
}
|
|
try {
|
|
lastReleaseToastKey = null;
|
|
const ver = releaseVersionSelect.value;
|
|
logUi(`Install version ${ver} requested`);
|
|
releaseBusyActive = true;
|
|
showBusy(`Installing ${ver}…`, "Applying selected release. This can take up to a minute.");
|
|
logRelease(`Installing ${ver}…`);
|
|
await applyReleaseVersion(ver);
|
|
pollReleaseStatus();
|
|
showToast(`Installing ${ver}`, "success");
|
|
} catch (e) {
|
|
showToast(e.error || "Install failed", "error");
|
|
logRelease(`Error: ${e.error || "Install 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);
|
|
await loadReleaseList();
|
|
} 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, changelog_url } = state;
|
|
const url =
|
|
changelog_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,
|
|
};
|
|
}
|