fix: add involves edge from task to agent:nomos at creation
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

Plus sync vendor directory for Docker build compatibility.
This commit is contained in:
2026-08-11 22:03:12 +02:00
parent 7d6a3320d4
commit febc153b7f
3384 changed files with 945212 additions and 2 deletions

View File

@@ -0,0 +1,706 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Software Update</title>
<style>
:root {
/* Light palette — Apple/Microsoft system defaults */
--bg: #f8f8fa;
--surface: #ffffff;
--surface-2: #f0f0f3;
--fg: #1d1d1f;
--fg-dim: #6b6b73;
--fg-faint: #99999f;
--border: #d6d6dc;
--accent: #0a84ff;
--accent-fg: #ffffff;
--accent-dim:#3a99ff33;
--success: #34c759;
--error: #ff3b30;
--warning: #ff9500;
--radius: 10px;
--radius-sm: 6px;
--pad: 20px;
--gap: 14px;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI Variable", "Segoe UI",
"Inter", "Helvetica Neue", Arial, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1a1c;
--surface: #232326;
--surface-2: #2c2c30;
--fg: #f5f5f7;
--fg-dim: #b0b0b8;
--fg-faint: #7a7a82;
--border: #3a3a3e;
--accent: #0a84ff;
--accent-dim:#0a84ff44;
}
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
font: 13px/1.4 var(--font);
background: var(--bg);
color: var(--fg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden;
}
/* === Layout === */
.u {
display: flex;
flex-direction: column;
height: 100%;
padding: var(--pad);
gap: var(--gap);
}
.u__hero {
display: flex;
align-items: flex-start;
gap: 14px;
}
.u__icon {
flex: 0 0 auto;
width: 44px;
height: 44px;
border-radius: 12px;
background: var(--accent-dim);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
line-height: 1;
user-select: none;
}
.u--ready .u__icon { background: rgba(52, 199, 89, 0.15); color: var(--success); }
.u--up-to-date .u__icon { background: rgba(52, 199, 89, 0.15); color: var(--success); }
.u--error .u__icon { background: rgba(255, 59, 48, 0.15); color: var(--error); }
/* === Up-to-date compact layout ===
Same hero shape as Update Available (left-aligned icon + title + version
pill) but with the green check and a "this is the latest version" suffix
in the pill. The Go side resizes the window via WindowSizer to ~210px
tall when entering this state so the layout doesn't show a wasted
bottom half. No notes panel here. */
.u__head { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.u__title {
margin: 0;
font-size: 16px;
font-weight: 600;
letter-spacing: -0.01em;
}
.u__subtitle {
font-size: 12px;
color: var(--fg-dim);
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.u__ver-from {
text-decoration: line-through;
color: var(--fg-faint);
}
.u__ver-arrow { color: var(--fg-faint); }
.u__ver-to {
font-weight: 600;
color: var(--fg);
font-variant-numeric: tabular-nums;
}
.u__size {
color: var(--fg-faint);
font-variant-numeric: tabular-nums;
}
/* === Notes ===
Block-flow content (Markdown rendered to HTML by renderMarkdown). */
.u__notes {
flex: 1 1 auto;
min-height: 60px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 14px;
overflow-y: auto;
color: var(--fg);
line-height: 1.5;
font-size: 12.5px;
}
.u__notes:empty::before {
content: "No release notes provided.";
color: var(--fg-faint);
font-style: italic;
}
.u__notes::-webkit-scrollbar { width: 10px; }
.u__notes::-webkit-scrollbar-thumb { background: var(--border); border: 2px solid var(--surface); border-radius: 5px; }
/* Markdown subset rendered by renderMarkdown() inside notes */
.u__notes p { margin: 0 0 8px 0; }
.u__notes p:last-child { margin-bottom: 0; }
.u__notes strong { font-weight: 600; }
.u__notes em { font-style: italic; }
.u__notes code {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 11.5px;
padding: 1px 5px;
background: var(--surface-2);
border-radius: 3px;
}
.u__notes pre {
background: var(--surface-2);
padding: 8px 10px;
border-radius: 6px;
overflow-x: auto;
margin: 6px 0;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 11.5px;
white-space: pre;
}
.u__notes pre code { background: none; padding: 0; font-size: inherit; }
.u__notes ul, .u__notes ol { margin: 4px 0 8px 0; padding-left: 22px; }
.u__notes li { margin: 2px 0; }
.u__notes h1, .u__notes h2, .u__notes h3 {
margin: 10px 0 6px 0;
font-weight: 600;
font-size: 13px;
}
.u__notes h1 { font-size: 14px; }
.u__notes table {
border-collapse: collapse;
margin: 6px 0;
font-size: 11.5px;
border: 1px solid var(--border);
}
.u__notes th, .u__notes td {
padding: 3px 8px;
border: 1px solid var(--border);
text-align: left;
}
.u__notes th { background: var(--surface-2); font-weight: 600; }
.u__notes a { color: var(--accent); text-decoration: none; }
.u__notes a:hover { text-decoration: underline; }
/* === Progress === */
.u__progress-box {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
flex: 0 0 auto;
}
.u__progress-row {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 12px;
color: var(--fg-dim);
}
.u__progress-state { font-weight: 500; color: var(--fg); }
.u__progress-rate { font-variant-numeric: tabular-nums; }
.u__bar {
appearance: none;
-webkit-appearance: none;
width: 100%;
height: 4px;
border: none;
border-radius: 2px;
background: var(--surface-2);
}
.u__bar::-webkit-progress-bar { background: var(--surface-2); border-radius: 2px; }
.u__bar::-webkit-progress-value { background: var(--accent); border-radius: 2px; transition: width 80ms linear; }
.u__bar::-moz-progress-bar { background: var(--accent); border-radius: 2px; }
.u__bar:indeterminate {
background: linear-gradient(90deg, var(--surface-2) 25%, var(--accent-dim) 50%, var(--surface-2) 75%);
background-size: 200% 100%;
animation: u-pulse 1.4s linear infinite;
}
@keyframes u-pulse {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* === Spinner === */
.u__spinner {
flex: 1 1 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
min-height: 80px;
color: var(--fg-dim);
}
.u__spinner::before {
content: "";
width: 28px;
height: 28px;
border-radius: 50%;
border: 3px solid var(--surface-2);
border-top-color: var(--accent);
animation: u-spin 0.9s linear infinite;
}
@keyframes u-spin { to { transform: rotate(360deg); } }
/* === Error banner === */
.u__error {
background: rgba(255, 59, 48, 0.12);
border: 1px solid var(--error);
color: var(--error);
padding: 10px 14px;
border-radius: var(--radius);
font-size: 12.5px;
flex: 0 0 auto;
}
/* === Buttons === */
.u__footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
flex: 0 0 auto;
}
.u__secondary-actions {
display: flex;
gap: 4px;
}
.u__primary-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
.u__btn {
font: inherit;
font-size: 12.5px;
font-weight: 500;
border: 1px solid var(--border);
background: var(--surface);
color: var(--fg);
padding: 7px 16px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 120ms ease, transform 80ms ease;
user-select: none;
white-space: nowrap;
}
.u__btn:hover:not(:disabled) { background: var(--surface-2); }
.u__btn:active:not(:disabled) { transform: scale(0.98); }
.u__btn:disabled { opacity: 0.4; cursor: default; }
.u__btn--ghost {
border-color: transparent;
background: transparent;
color: var(--fg-dim);
padding: 7px 10px;
}
.u__btn--ghost:hover:not(:disabled) { background: var(--surface-2); color: var(--fg); }
.u__btn--primary {
background: var(--accent);
color: var(--accent-fg);
border-color: transparent;
}
.u__btn--primary:hover:not(:disabled) { filter: brightness(1.08); }
.u__btn--primary:active:not(:disabled) { filter: brightness(0.94); }
/* === State-driven visibility ===
Each state shows only what's relevant. Hide everything by default
under the data-state attribute, then opt-in per state. Default display
value when shown is `flex` (matches the hero, footer, progress-box,
spinner layouts); .u__notes overrides to `block` because its children
are HTML flow content (paragraphs, lists, tables). */
.u [data-show] { display: none; }
.u[data-state="checking"] [data-show~="checking"] { display: flex; }
.u[data-state="available"] [data-show~="available"] { display: flex; }
.u[data-state="downloading"] [data-show~="downloading"] { display: flex; }
.u[data-state="verifying"] [data-show~="verifying"] { display: flex; }
.u[data-state="installing"] [data-show~="installing"] { display: flex; }
.u[data-state="ready"] [data-show~="ready"] { display: flex; }
.u[data-state="up-to-date"] [data-show~="up-to-date"] { display: flex; }
.u[data-state="error"] [data-show~="error"] { display: flex; }
/* Notes are block-flow content (markdown-rendered); never a flex container.
No up-to-date entry — that state uses the compact centered-hero layout
(see .u--up-to-date rules above) and renders no notes panel. */
.u[data-state="available"] .u__notes[data-show~="available"],
.u[data-state="ready"] .u__notes[data-show~="ready"] {
display: block;
}
/* Buttons default to inline-flex when shown */
.u[data-state] .u__btn[data-show] { display: none; }
.u[data-state="available"] .u__btn[data-show~="available"] { display: inline-flex; }
.u[data-state="downloading"] .u__btn[data-show~="downloading"] { display: inline-flex; }
.u[data-state="ready"] .u__btn[data-show~="ready"] { display: inline-flex; }
.u[data-state="up-to-date"] .u__btn[data-show~="up-to-date"] { display: inline-flex; }
.u[data-state="error"] .u__btn[data-show~="error"] { display: inline-flex; }
/* The hero subtitle (version line) only appears when there's content for it */
.u__subtitle:empty { display: none; }
</style>
</head>
<body>
<main class="u wails-updater" id="root" data-state="checking" data-wails-updater-window="1">
<header class="u__hero">
<div class="u__icon" id="state-icon" aria-hidden="true"></div>
<div class="u__head">
<h1 class="u__title" id="title">Checking for Updates…</h1>
<div class="u__subtitle" id="subtitle"></div>
</div>
</header>
<!-- Checking / Verifying / Installing all share a spinner -->
<div class="u__spinner" data-show="checking verifying installing" id="spinner-text">Contacting update server…</div>
<!-- Release notes (Available + Ready + Up-to-date) -->
<div class="u__notes" data-show="available ready" id="notes"></div>
<!-- Download progress -->
<div class="u__progress-box" data-show="downloading">
<div class="u__progress-row">
<span class="u__progress-state" id="progress-state">Downloading…</span>
<span class="u__progress-rate" id="progress-rate"></span>
</div>
<progress class="u__bar" id="bar" max="100" value="0"></progress>
</div>
<!-- Error banner -->
<div class="u__error" data-show="error" id="error-text">An error occurred.</div>
<!-- Up-to-date message body -->
<!-- Up-to-Date state has no notes panel — the compact centered hero (.u--up-to-date) carries everything. -->
<footer class="u__footer">
<div class="u__secondary-actions">
<button class="u__btn u__btn--ghost" data-show="available" id="btn-skip" type="button">Skip This Version</button>
<button class="u__btn u__btn--ghost" data-show="available" id="btn-remind" type="button">Remind Me Later</button>
</div>
<div class="u__primary-actions">
<button class="u__btn" data-show="available downloading ready error up-to-date" id="btn-cancel" type="button">Close</button>
<button class="u__btn u__btn--primary" data-show="available" id="btn-install" type="button">Install Update</button>
<button class="u__btn u__btn--primary" data-show="ready" id="btn-restart" type="button">Restart &amp; Apply</button>
<button class="u__btn u__btn--primary" data-show="error" id="btn-retry" type="button">Try Again</button>
</div>
</footer>
</main>
<script>
/* ===============================================================
Updater window controller
===============================================================
The framework auto-injects window.wails.Events.On / Emit for any
window opened with AllowSimpleEventEmit=true and InitialHTML set —
see pkg/application/inline_event_shim.go for the shim source and
the threat-model note. This file just uses those bindings. */
(function () {
var Events = window.wails.Events;
var els = {
root: document.getElementById("root"),
icon: document.getElementById("state-icon"),
title: document.getElementById("title"),
subtitle: document.getElementById("subtitle"),
notes: document.getElementById("notes"),
spinnerText: document.getElementById("spinner-text"),
progress: document.getElementById("bar"),
progressRate:document.getElementById("progress-rate"),
progressState: document.getElementById("progress-state"),
error: document.getElementById("error-text"),
btnInstall: document.getElementById("btn-install"),
btnSkip: document.getElementById("btn-skip"),
btnRemind: document.getElementById("btn-remind"),
btnCancel: document.getElementById("btn-cancel"),
btnRestart: document.getElementById("btn-restart"),
btnRetry: document.getElementById("btn-retry"),
};
/* Per-state visual identity for the hero icon */
var ICONS = {
"checking": "↻",
"available": "↓",
"downloading": "↓",
"verifying": "✓",
"installing": "↓",
"ready": "✓",
"up-to-date": "✓",
"error": "!",
};
/* Wails' event bus dispatches each Emit on a fresh goroutine, so
events arrive out of source order under load (we have seen
installing → verifying → update-ready → download-complete).
Rank monotonically so a late event can't downgrade the UI. */
var RANK = {
"": 0,
"checking": 1,
"available": 2,
"downloading": 3,
"verifying": 4,
"installing": 5,
"ready": 6,
"up-to-date": 6,
"error": 99,
};
var rank = 0;
var errored = false;
var currentRelease = null;
var currentVersion = null;
var skippedVersion = "";
function setState(name) {
if (errored && name !== "error") return false;
if (name === "error") {
errored = true;
} else {
if ((RANK[name] || 0) < rank) return false;
rank = RANK[name] || 0;
}
els.root.setAttribute("data-state", name);
els.root.classList.toggle("u--ready", name === "ready");
els.root.classList.toggle("u--up-to-date", name === "up-to-date");
els.root.classList.toggle("u--error", name === "error");
if (els.icon) els.icon.textContent = ICONS[name] || "";
return true;
}
/* Minimal Markdown→HTML renderer for release notes. Handles the subset
real release notes actually contain — paragraphs, bold, italic, inline
code, fenced code blocks, bullet/numbered lists, GFM tables, and
headings. Source is escaped before any HTML substitution so a
compromised release note can't inject script tags (links use a fixed
rel="noopener" + target="_blank"). Anything outside this subset falls
through as literal text. */
function renderMarkdown(src) {
if (!src) return "";
function escapeHtml(s) {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function inline(s) {
s = s.replace(/`([^`]+)`/g, function (_, c) { return "<code>" + c + "</code>"; });
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
s = s.replace(/(^|[^*])\*([^*]+)\*/g, "$1<em>$2</em>");
s = s.replace(/\[([^\]]+)\]\((https?:[^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
return s;
}
var lines = escapeHtml(src).split(/\r?\n/);
var out = [];
var i = 0;
while (i < lines.length) {
var L = lines[i];
// Fenced code block
if (/^```/.test(L)) {
var buf = [];
i++;
while (i < lines.length && !/^```/.test(lines[i])) { buf.push(lines[i]); i++; }
out.push("<pre><code>" + buf.join("\n") + "</code></pre>");
i++; continue;
}
// Heading
var h = /^(#{1,6})\s+(.+)$/.exec(L);
if (h) {
var lvl = Math.min(h[1].length, 3); // cap at h3 visual-weight-wise
out.push("<h" + lvl + ">" + inline(h[2]) + "</h" + lvl + ">");
i++; continue;
}
// GFM-style table
if (/^\|.*\|$/.test(L) && i + 1 < lines.length && /^\|[\s:|-]+\|$/.test(lines[i+1])) {
var rows = [L];
i += 2; // skip header + separator
while (i < lines.length && /^\|.*\|$/.test(lines[i])) { rows.push(lines[i]); i++; }
var html = "<table>";
rows.forEach(function (r, idx) {
var cells = r.replace(/^\||\|$/g, "").split("|").map(function (c) { return inline(c.trim()); });
var tag = idx === 0 ? "th" : "td";
html += "<tr><" + tag + ">" + cells.join("</" + tag + "><" + tag + ">") + "</" + tag + "></tr>";
});
html += "</table>";
out.push(html);
continue;
}
// List (bullet or numbered)
var ulRe = /^[-*]\s+(.+)$/;
var olRe = /^\d+\.\s+(.+)$/;
if (ulRe.test(L) || olRe.test(L)) {
var isOL = olRe.test(L);
var re = isOL ? olRe : ulRe;
var items = [];
while (i < lines.length && re.test(lines[i])) {
items.push("<li>" + inline(re.exec(lines[i])[1]) + "</li>");
i++;
}
out.push("<" + (isOL ? "ol" : "ul") + ">" + items.join("") + "</" + (isOL ? "ol" : "ul") + ">");
continue;
}
// Blank line — paragraph break
if (L.trim() === "") { i++; continue; }
// Paragraph (collect contiguous non-empty lines)
var para = [L];
i++;
while (i < lines.length && lines[i].trim() !== "" && !/^[-*]\s/.test(lines[i]) && !/^\d+\.\s/.test(lines[i]) && !/^\|.*\|$/.test(lines[i]) && !/^#{1,6}\s/.test(lines[i]) && !/^```/.test(lines[i])) {
para.push(lines[i]); i++;
}
out.push("<p>" + inline(para.join(" ")) + "</p>");
}
return out.join("\n");
}
function fmtBytes(n) {
if (!n || n <= 0) return "";
var u = ["B", "KB", "MB", "GB"], k = 1024;
var i = Math.min(u.length - 1, Math.floor(Math.log(n) / Math.log(k)));
return (n / Math.pow(k, i)).toFixed(i === 0 ? 0 : 1) + " " + u[i];
}
function fmtRate(bps) {
var s = fmtBytes(bps);
return s ? s + "/s" : "";
}
function renderSubtitle(rel) {
if (!rel) { els.subtitle.textContent = ""; return; }
els.subtitle.innerHTML = "";
if (currentVersion) {
var from = document.createElement("span"); from.className = "u__ver-from"; from.textContent = currentVersion;
var arr = document.createElement("span"); arr.className = "u__ver-arrow"; arr.textContent = "→";
els.subtitle.appendChild(from);
els.subtitle.appendChild(arr);
}
if (rel.version) {
var to = document.createElement("span"); to.className = "u__ver-to"; to.textContent = "v" + rel.version;
els.subtitle.appendChild(to);
}
var size = (rel.artifact && rel.artifact.size) || 0;
if (size > 0) {
var sz = document.createElement("span"); sz.className = "u__size"; sz.textContent = "· " + fmtBytes(size);
els.subtitle.appendChild(sz);
}
}
/* === State handlers === */
function onCheckStarted() {
if (!setState("checking")) return;
els.title.textContent = "Checking for Updates…";
els.spinnerText.textContent = "Contacting update server…";
}
function onUpdateAvailable(rel) {
currentRelease = rel || null;
if (!setState("available")) return;
els.title.textContent = "Update Available";
renderSubtitle(rel);
els.notes.innerHTML = (rel && rel.notes) ? renderMarkdown(rel.notes) : "";
}
function onNoUpdate() {
if (!setState("up-to-date")) return;
els.title.textContent = "You're Up to Date";
// The title already conveys "this is the latest version"; the subtitle
// just carries the current version number so the user knows WHICH
// version they're on. Keeping it to a single version token (no
// explanatory suffix) leaves room for long versions like
// 1.0.0-rc.4+build.20260522.x86_64 without the line wrapping.
els.subtitle.innerHTML = "";
if (currentVersion) {
var v = document.createElement("span");
v.className = "u__ver-to";
v.textContent = "v" + currentVersion;
els.subtitle.appendChild(v);
}
}
function onDownloadStarted(rel) {
if (!setState("downloading")) return;
els.title.textContent = "Downloading Update";
els.progressState.textContent = "Starting download…";
els.progressRate.textContent = "";
els.progress.removeAttribute("value");
}
function onDownloadProgress(p) {
if (!p) return;
if (rank !== RANK.downloading) return;
if (p.total > 0) {
els.progress.max = p.total;
els.progress.value = p.written;
var pct = Math.round((p.written / p.total) * 100);
els.progressState.textContent = pct + "% · " + fmtBytes(p.written) + " of " + fmtBytes(p.total);
} else {
els.progress.removeAttribute("value");
els.progressState.textContent = "Downloaded " + fmtBytes(p.written);
}
els.progressRate.textContent = p.rate ? fmtRate(p.rate) : "";
}
function onVerifying() {
if (!setState("verifying")) return;
els.title.textContent = "Verifying Update";
els.spinnerText.textContent = "Checking signature…";
}
function onInstalling() {
if (!setState("installing")) return;
els.title.textContent = "Installing Update";
els.spinnerText.textContent = "Unpacking and staging…";
}
function onUpdateReady() {
if (!setState("ready")) return;
els.title.textContent = "Update Ready";
if (currentRelease) renderSubtitle(currentRelease);
if (currentRelease && currentRelease.notes && els.notes) {
els.notes.innerHTML = renderMarkdown(currentRelease.notes);
}
}
function onError(info) {
setState("error");
els.title.textContent = "Update Failed";
var msg = (info && info.message) ? info.message : "An unexpected error occurred.";
if (info && info.stage) msg = "During " + info.stage + ": " + msg;
els.error.textContent = msg;
}
/* === Wire up === */
Events.On("wails:updater:meta", function (e) {
var m = e && (e.data != null ? e.data : e);
if (m && typeof m.currentVersion === "string") currentVersion = m.currentVersion;
if (m && typeof m.skippedVersion === "string") skippedVersion = m.skippedVersion;
});
Events.On("wails:updater:check-started", function () { onCheckStarted(); });
Events.On("wails:updater:update-available", function (e) { onUpdateAvailable(e && (e.data != null ? e.data : e)); });
Events.On("wails:updater:no-update", function () { onNoUpdate(); });
Events.On("wails:updater:download-started", function (e) { onDownloadStarted(e && (e.data != null ? e.data : e)); });
Events.On("wails:updater:download-progress", function (e) { onDownloadProgress(e && (e.data != null ? e.data : e)); });
Events.On("wails:updater:download-complete", function () {});
Events.On("wails:updater:verifying", function () { onVerifying(); });
Events.On("wails:updater:installing", function () { onInstalling(); });
Events.On("wails:updater:update-ready", function (e) { onUpdateReady(); });
Events.On("wails:updater:error", function (e) { onError(e && (e.data != null ? e.data : e)); });
if (els.btnInstall) els.btnInstall.addEventListener("click", function () { Events.Emit("wails:updater:user:install"); });
if (els.btnSkip) els.btnSkip.addEventListener ("click", function () { Events.Emit("wails:updater:user:skip"); });
if (els.btnRemind) els.btnRemind.addEventListener ("click", function () { Events.Emit("wails:updater:user:remind"); });
if (els.btnCancel) els.btnCancel.addEventListener ("click", function () { Events.Emit("wails:updater:user:cancel"); });
if (els.btnRestart) els.btnRestart.addEventListener("click", function () { Events.Emit("wails:updater:user:restart"); });
if (els.btnRetry) els.btnRetry.addEventListener ("click", function () { Events.Emit("wails:updater:user:install"); });
/* Wails queues ExecJS calls (including dispatchWailsEvent pushes) until
the page invokes "wails:runtime:ready" — see HandleMessage in
pkg/application/webview_window.go. Without this handshake every state
event the framework dispatched while we were still loading sits in the
pendingJS list forever and the UI stays on its initial HTML. Poll for
invoke (set by runtime Core after WebViewDidFinishNavigation) and
trigger the flush. */
(function announce() {
if (window._wails && typeof window._wails.invoke === "function") {
window._wails.invoke("wails:runtime:ready");
Events.Emit("wails:updater:window:ready");
} else {
setTimeout(announce, 30);
}
})();
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,99 @@
package updater
import (
"errors"
"time"
)
// Config configures the Updater. Pass it to Updater.Init.
type Config struct {
// CurrentVersion is the version currently running. Required.
// Pass the same string you use to tag releases (e.g. "1.2.3" — no "v" prefix).
CurrentVersion string
// Providers are the update sources, tried in order. The first to return
// a release is used; if a provider returns (nil, nil) the Updater treats
// the application as up to date and stops walking the chain (fallback is
// for "primary unreachable", not "providers disagree"). Required.
Providers []Provider
// PublicKey is the trust root used to verify release signatures. It is
// the ONLY trust anchor for signature verification — the release source
// cannot substitute its own key, since the whole point of pinning a key
// here is to bind verification to a value the application developer set
// at build time and the release feed cannot influence.
//
// When unset, releases that carry only a Digest still install (the digest
// is checked against the streaming hash), but any release that carries a
// Signature is rejected. Strongly recommended for any app whose
// distribution channel might be compromised or proxied.
PublicKey []byte
// CheckInterval, when non-zero, makes the Updater poll providers on a
// timer in the background. Pop-up-on-found behaviour is the same as a
// manual Check finding an update.
CheckInterval time.Duration
// Platform / Arch / Channel override the per-platform defaults passed to
// each Provider's Check. Leave empty to use runtime.GOOS / runtime.GOARCH
// and the provider's default channel.
Platform string
Arch string
Channel string
// Window controls how the update UI is rendered. Not yet wired in v1 of
// the package; see the upcoming Window option types.
Window WindowOption
}
// WindowOption is the marker interface for the Window slot on Config. The set
// of concrete types is small and closed: BuiltinWindow, application.Window
// (BYO), or the sentinel WindowNone. The full window machinery is implemented
// in a follow-up commit; the interface is declared here so Config does not
// change shape between commits.
type WindowOption interface {
isWindowOption()
}
// validate returns an error describing the first problem found in c, or nil.
func (c *Config) validate() error {
if c == nil {
return errors.New("updater: Config is nil")
}
if c.CurrentVersion == "" {
return errors.New("updater: Config.CurrentVersion is required")
}
if len(c.Providers) == 0 {
return errors.New("updater: Config.Providers must contain at least one Provider")
}
for i, p := range c.Providers {
if p == nil {
return errors.New("updater: Config.Providers contains a nil entry at index " + itoa(i))
}
}
return nil
}
// itoa is a tiny dependency-free int→string for error messages, scoped to
// this package so we don't drag in strconv just for validation strings.
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}

21
vendor/github.com/wailsapp/wails/v3/pkg/updater/doc.go generated vendored Normal file
View File

@@ -0,0 +1,21 @@
// Package updater provides an in-app self-update facility for Wails v3
// applications.
//
// The package exposes a single Updater that is reachable as `app.Updater`.
// Configure it once via Init, then call Check / DownloadAndInstall /
// CheckAndInstall to drive the update flow.
//
// Update sources are pluggable through the Provider interface. The Updater
// owns verification, atomic writes, the binary swap and the default window;
// providers only describe how to look up and stream a release.
//
// Subscribe to lifecycle events through the standard Wails event system —
// both Go and JavaScript subscribe the same way:
//
// app.Event.On(updater.EventDownloadProgress, func(e *application.CustomEvent) {
// var p updater.Progress
// _ = json.Unmarshal(e.JSON(), &p)
// })
//
// wails.Events.On("wails:updater:download-progress", (e) => { /* ... */ })
package updater

View File

@@ -0,0 +1,202 @@
package updater
import (
"context"
"errors"
"fmt"
"hash"
"io"
"os"
"path/filepath"
"time"
)
// download streams the artifact for rel from p into a temp file, computing
// the verification digest while the bytes flow past. Returns the temp file
// path and the temp directory enclosing it; the caller is responsible for
// verifying then renaming or removing the directory (RemoveAll on dir tears
// the file down with it).
//
// On any error every artifact created by this function (file + enclosing
// directory) is removed before returning.
//
// Progress is emitted via the application event bus at ~10/sec.
func (u *Updater) download(ctx context.Context, p Provider, rel *Release) (path, dir string, err error) {
u.transition(StateDownloading)
u.host.Emit(EventDownloadStarted, rel)
dir, err = os.MkdirTemp("", "wails-update-*")
if err != nil {
return "", "", fmt.Errorf("updater: temp dir: %w", err)
}
tmpPath := filepath.Join(dir, ".artifact")
// Track success — on every error path we tear the directory down.
success := false
defer func() {
if !success {
_ = os.RemoveAll(dir)
}
}()
f, err := os.Create(tmpPath)
if err != nil {
return "", "", fmt.Errorf("updater: create temp: %w", err)
}
closed := false
defer func() {
if !closed {
_ = f.Close()
}
}()
// Set up a hasher in parallel only when the release will need it.
var hasher hash.Hash
if rel.Verification != nil {
algo := rel.Verification.DigestAlgo
// Ed25519ph requires SHA-512 regardless of what DigestAlgo says.
if rel.Verification.SignatureAlgo == "ed25519ph" {
algo = "sha512"
}
hasher, err = digestHasher(algo)
if err != nil {
return "", "", err
}
}
var dst io.Writer = f
if hasher != nil {
dst = io.MultiWriter(f, hasher)
}
prov := p.Name()
emit := u.host.Emit
tracker := newProgressTracker(emit, prov)
progressFn := func(written, total int64) {
tracker.tick(written, total)
}
wrappedDst := &countingWriter{w: dst, onWrite: tracker.add}
if err := p.Download(ctx, rel, wrappedDst, progressFn); err != nil {
_ = f.Close()
closed = true
return "", "", err
}
if err := f.Sync(); err != nil {
_ = f.Close()
closed = true
return "", "", fmt.Errorf("updater: fsync: %w", err)
}
if err := f.Close(); err != nil {
closed = true
return "", "", fmt.Errorf("updater: close: %w", err)
}
closed = true
// Final progress tick so subscribers see written == total.
tracker.flush()
u.host.Emit(EventDownloadComplete, rel)
// Stash the digest for verify() so we don't re-read the file.
if hasher != nil {
u.mu.Lock()
u.lastDigest = hasher.Sum(nil)
u.mu.Unlock()
}
success = true
return tmpPath, dir, nil
}
// verify runs the configured verification rules against the digest computed
// during download. It does NOT re-read the file from disk — the streaming
// hash is the authoritative computation.
func (u *Updater) verify(_ string, rel *Release) error {
if rel.Verification == nil {
return nil
}
u.mu.RLock()
digest := u.lastDigest
cfgKey := u.cfg.PublicKey
u.mu.RUnlock()
if digest == nil {
return errors.New("updater: no digest available; download path bug")
}
return runVerification(digest, rel.Verification, cfgKey)
}
// progressTracker debounces progress callbacks from the provider and
// computes a smoothed bytes/sec rate for the EventDownloadProgress payload.
type progressTracker struct {
emit func(string, ...any) bool
provider string
written int64
total int64
start time.Time
lastEmit time.Time
}
func newProgressTracker(emit func(string, ...any) bool, provider string) *progressTracker {
now := time.Now()
return &progressTracker{emit: emit, provider: provider, start: now, lastEmit: now}
}
// add is called by the countingWriter every Write to update the running total.
func (p *progressTracker) add(n int) {
p.written += int64(n)
}
// tick is called by the provider's onProgress callback. The caller may pass
// a total; if so, we let it set our total even on the first tick. Throttled
// emits keep the event bus calm during large downloads.
func (p *progressTracker) tick(written, total int64) {
if written > p.written {
p.written = written
}
if total > 0 {
p.total = total
}
if time.Since(p.lastEmit) < 100*time.Millisecond {
return
}
p.emitNow()
}
func (p *progressTracker) flush() {
p.emitNow()
}
func (p *progressTracker) emitNow() {
p.lastEmit = time.Now()
elapsed := p.lastEmit.Sub(p.start).Seconds()
var rate float64
if elapsed > 0 {
rate = float64(p.written) / elapsed
}
p.emit(EventDownloadProgress, Progress{
Written: p.written,
Total: p.total,
Rate: rate,
Provider: p.provider,
})
}
// countingWriter wraps an io.Writer and invokes onWrite with the byte count
// for each write that passes through.
type countingWriter struct {
w io.Writer
onWrite func(int)
}
func (c *countingWriter) Write(p []byte) (int, error) {
n, err := c.w.Write(p)
if n > 0 && c.onWrite != nil {
c.onWrite(n)
}
return n, err
}

View File

@@ -0,0 +1,55 @@
package updater
// Event names emitted by the Updater. Subscribe in Go via app.Event.On(name, ...)
// or in JavaScript via wails.Events.On(name, ...). Payload types are documented
// inline next to each constant.
const (
// EventCheckStarted fires before a Check round trip. Payload: nil.
EventCheckStarted = "wails:updater:check-started"
// EventUpdateAvailable fires when Check returns a newer release. Payload: *Release.
EventUpdateAvailable = "wails:updater:update-available"
// EventNoUpdate fires when Check confirms the caller is up to date. Payload: nil.
EventNoUpdate = "wails:updater:no-update"
// EventDownloadStarted fires when the Updater begins streaming bytes from
// a provider. Payload: *Release.
EventDownloadStarted = "wails:updater:download-started"
// EventDownloadProgress fires periodically during download (~10/sec). Payload: Progress.
EventDownloadProgress = "wails:updater:download-progress"
// EventDownloadComplete fires once all bytes are on disk and the file has
// been closed, but BEFORE verification. Payload: *Release.
EventDownloadComplete = "wails:updater:download-complete"
// EventVerifying fires when the Updater begins verifying the downloaded
// artifact. Payload: *Release.
EventVerifying = "wails:updater:verifying"
// EventInstalling fires when the Updater begins swapping the binary.
// Payload: *Release.
EventInstalling = "wails:updater:installing"
// EventUpdateReady fires when an update is installed and a restart is
// pending. Payload: *Release.
EventUpdateReady = "wails:updater:update-ready"
// EventError fires whenever any stage fails. Payload: ErrorInfo.
EventError = "wails:updater:error"
// EventMeta fires once per session before the first state-snapshot
// replay, carrying host-side context the page can't derive from any
// Release: the version currently running, and the version the user
// has marked skipped (or "" if none). Payload: Meta.
EventMeta = "wails:updater:meta"
)
// Meta is the payload of EventMeta — host-side context the default window
// template uses to render the "from" version in the update pill and the
// "v1.2.3 · This is the latest version" pill in the up-to-date state.
type Meta struct {
CurrentVersion string `json:"currentVersion"`
SkippedVersion string `json:"skippedVersion,omitempty"`
}

View File

@@ -0,0 +1,402 @@
package updater
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// Limits applied to archive extraction. Apps ship code, not blobs — a single
// macOS .app bundle is typically <100 MiB and a few thousand entries. These
// caps give comfortable headroom while keeping malformed / hostile archives
// from exhausting disk or file descriptors.
const (
maxArchiveEntries = 50_000
maxArchiveTotalSize = 2 << 30 // 2 GiB total uncompressed
)
// archiveKind is the set of archive formats the framework can unpack inline
// before handing the artifact to the helper for swap.
type archiveKind int
const (
archiveNone archiveKind = iota
archiveZip
archiveTarGz
)
// detectArchive classifies path by filename extension. Magic-byte sniffing
// would be more robust but every shipping CDN preserves extensions, and a
// false-positive on detection silently breaks the swap — so we prefer the
// conservative reading: only extract when the extension says so.
func detectArchive(path string) archiveKind {
lower := strings.ToLower(path)
switch {
case strings.HasSuffix(lower, ".zip"):
return archiveZip
case strings.HasSuffix(lower, ".tar.gz"), strings.HasSuffix(lower, ".tgz"):
return archiveTarGz
}
return archiveNone
}
// maybeExtractInto unpacks an archive at stagedPath in place: the archive is
// expanded into a scratch subdirectory of its parent, the original archive is
// removed, and the single top-level entry of the archive is moved up to where
// the archive used to live. The returned newPath either equals stagedPath
// (non-archive — nothing was done) or names the unpacked entry.
//
// Constraints enforced:
// - Exactly one top-level entry. Archives with multiple top-level files or
// directories are rejected: the helper has nothing meaningful to swap
// into a single target path. Distribute one .app bundle (or one binary)
// per artifact.
// - No path traversal. Entries whose normalised path escapes the extraction
// root are rejected (zip-slip).
// - No escaping symlinks. Symlinks whose target resolves outside the
// extraction root are rejected; in-archive symlinks pointing to sibling
// entries are preserved.
// - Total uncompressed size and entry count are capped (see constants).
func maybeExtractInto(stagedPath string) (newPath string, didExtract bool, err error) {
kind := detectArchive(stagedPath)
if kind == archiveNone {
return stagedPath, false, nil
}
parent := filepath.Dir(stagedPath)
scratch, err := os.MkdirTemp(parent, ".payload-*")
if err != nil {
return "", false, fmt.Errorf("updater: extract scratch dir: %w", err)
}
cleanup := func() { _ = os.RemoveAll(scratch) }
switch kind {
case archiveZip:
err = extractZip(stagedPath, scratch)
case archiveTarGz:
err = extractTarGz(stagedPath, scratch)
}
if err != nil {
cleanup()
return "", false, err
}
entries, err := os.ReadDir(scratch)
if err != nil {
cleanup()
return "", false, fmt.Errorf("updater: extract: %w", err)
}
if len(entries) != 1 {
cleanup()
return "", false, fmt.Errorf("updater: archive must contain exactly one top-level entry, got %d", len(entries))
}
entry := entries[0]
// Remove the original archive so the staging dir holds only the extracted
// payload — the helper's post-swap cleanup walks one level up.
if err := os.Remove(stagedPath); err != nil {
cleanup()
return "", false, fmt.Errorf("updater: remove staged archive: %w", err)
}
src := filepath.Join(scratch, entry.Name())
dst := filepath.Join(parent, entry.Name())
if err := os.Rename(src, dst); err != nil {
cleanup()
return "", false, fmt.Errorf("updater: promote extracted entry: %w", err)
}
cleanup()
return dst, true, nil
}
// extractZip unpacks src into dst. Path traversal, symlink escape, entry
// count, and total-size caps are all enforced.
//
// Symlinks are deferred to a second pass: planting a symlink first and then
// writing through it (e.g. archive contains "link → /etc" followed by
// "link/passwd") is the standard zip-slip-via-symlink escalation. Two
// passes mean every file write happens before any symlinks exist in the
// destination tree, so writes can't be redirected through them.
func extractZip(src, dst string) error {
zr, err := zip.OpenReader(src)
if err != nil {
return fmt.Errorf("updater: zip open: %w", err)
}
defer zr.Close()
if len(zr.File) > maxArchiveEntries {
return fmt.Errorf("updater: zip has %d entries (cap %d)", len(zr.File), maxArchiveEntries)
}
rootClean := filepath.Clean(dst)
// Pass 1 — directories and regular files. Symlinks recorded for pass 2.
type pendingLink struct{ src *zip.File; target string }
var symlinks []pendingLink
var written int64
for _, f := range zr.File {
target, err := safeJoin(rootClean, f.Name)
if err != nil {
return err
}
mode := f.Mode()
switch {
case mode&os.ModeSymlink != 0:
// Reject escapes up front so we don't waste pass 1 on a doomed archive.
if err := validateSymlinkTarget(f, target, rootClean); err != nil {
return err
}
symlinks = append(symlinks, pendingLink{src: f, target: target})
case f.FileInfo().IsDir():
if err := os.MkdirAll(target, dirModeFrom(mode)); err != nil {
return fmt.Errorf("updater: zip mkdir: %w", err)
}
default:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("updater: zip mkdir: %w", err)
}
n, err := writeArchiveFile(f, target, mode, &written)
if err != nil {
return err
}
written = n
}
}
// Pass 2 — symlinks only. By the time any of these are created, the file
// tree on disk matches what was in the archive (sans symlinks), so a link
// that resolves to a directory under root can never have been used to
// redirect a write from pass 1.
for _, sl := range symlinks {
if err := writeArchiveSymlink(sl.src, sl.target, rootClean); err != nil {
return err
}
}
return nil
}
// extractTarGz unpacks src (a gzipped tar) into dst, enforcing the same
// invariants as extractZip. See that function for the rationale behind the
// two-pass extraction (regular files + dirs first, symlinks last).
func extractTarGz(src, dst string) error {
f, err := os.Open(src)
if err != nil {
return fmt.Errorf("updater: tar.gz open: %w", err)
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return fmt.Errorf("updater: gzip: %w", err)
}
defer gz.Close()
tr := tar.NewReader(gz)
rootClean := filepath.Clean(dst)
type pendingLink struct{ linkname, target string }
var symlinks []pendingLink
var entries int
var written int64
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return fmt.Errorf("updater: tar: %w", err)
}
entries++
if entries > maxArchiveEntries {
return fmt.Errorf("updater: tar has more than %d entries", maxArchiveEntries)
}
target, err := safeJoin(rootClean, hdr.Name)
if err != nil {
return err
}
mode := os.FileMode(hdr.Mode)
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, dirModeFrom(mode)); err != nil {
return fmt.Errorf("updater: tar mkdir: %w", err)
}
case tar.TypeSymlink:
// Reject escapes up front; defer creation until pass 2 so no
// file write can be redirected through a planted symlink.
if err := validateSymlinkPath(hdr.Linkname, target, rootClean); err != nil {
return err
}
symlinks = append(symlinks, pendingLink{linkname: hdr.Linkname, target: target})
case tar.TypeReg, tar.TypeRegA:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("updater: tar mkdir: %w", err)
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, fileModeFrom(mode))
if err != nil {
return fmt.Errorf("updater: tar create: %w", err)
}
n, copyErr := io.CopyN(out, tr, maxArchiveTotalSize-written+1)
closeErr := out.Close()
if copyErr != nil && !errors.Is(copyErr, io.EOF) {
return fmt.Errorf("updater: tar copy: %w", copyErr)
}
if closeErr != nil {
return fmt.Errorf("updater: tar close: %w", closeErr)
}
written += n
if written > maxArchiveTotalSize {
return fmt.Errorf("updater: tar uncompressed size exceeds %d bytes", maxArchiveTotalSize)
}
default:
// Block-special, char-special, FIFO, etc. — skip silently. App
// bundles never contain these.
}
}
// Pass 2 — symlinks last (see extractZip rationale).
for _, sl := range symlinks {
if err := writeSymlinkPath(sl.linkname, sl.target, rootClean); err != nil {
return err
}
}
return nil
}
// writeArchiveFile streams one zip entry to disk, accumulating into the
// running total-uncompressed-size budget.
func writeArchiveFile(f *zip.File, target string, mode os.FileMode, written *int64) (int64, error) {
rc, err := f.Open()
if err != nil {
return *written, fmt.Errorf("updater: zip read: %w", err)
}
defer rc.Close()
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, fileModeFrom(mode))
if err != nil {
return *written, fmt.Errorf("updater: zip create: %w", err)
}
// Limit each copy so a single bomb entry can't blow past the budget.
remaining := maxArchiveTotalSize - *written + 1
n, err := io.CopyN(out, rc, remaining)
closeErr := out.Close()
if err != nil && !errors.Is(err, io.EOF) {
return *written, fmt.Errorf("updater: zip copy: %w", err)
}
if closeErr != nil {
return *written, fmt.Errorf("updater: zip close: %w", closeErr)
}
total := *written + n
if total > maxArchiveTotalSize {
return total, fmt.Errorf("updater: zip uncompressed size exceeds %d bytes", maxArchiveTotalSize)
}
return total, nil
}
// validateSymlinkTarget reads a zip entry's symlink body and validates that
// the link target stays inside the extraction root. Used in pass 1 so we can
// reject malicious archives before any file write happens.
func validateSymlinkTarget(f *zip.File, target, root string) error {
rc, err := f.Open()
if err != nil {
return fmt.Errorf("updater: zip symlink read: %w", err)
}
defer rc.Close()
link, err := io.ReadAll(io.LimitReader(rc, 4096))
if err != nil {
return fmt.Errorf("updater: zip symlink body: %w", err)
}
return validateSymlinkPath(string(link), target, root)
}
// validateSymlinkPath checks that creating target → link would not escape
// root. Returns nil if safe.
func validateSymlinkPath(link, target, root string) error {
if filepath.IsAbs(link) {
return fmt.Errorf("updater: archive symlink has absolute target: %s", link)
}
resolved := filepath.Join(filepath.Dir(target), link)
rel, err := filepath.Rel(root, resolved)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return fmt.Errorf("updater: archive symlink escapes root: %s -> %s", target, link)
}
return nil
}
// writeArchiveSymlink writes a previously-validated zip symlink entry. Called
// only from pass 2 of extractZip, after all regular files and directories
// have been written, so a planted symlink can't be used to redirect any
// earlier write.
func writeArchiveSymlink(f *zip.File, target, root string) error {
rc, err := f.Open()
if err != nil {
return fmt.Errorf("updater: zip symlink read: %w", err)
}
defer rc.Close()
link, err := io.ReadAll(io.LimitReader(rc, 4096))
if err != nil {
return fmt.Errorf("updater: zip symlink body: %w", err)
}
return writeSymlinkPath(string(link), target, root)
}
// writeSymlinkPath creates target as a symlink to link. Re-validates the
// path even though pass 1 already checked it — defence in depth.
func writeSymlinkPath(link, target, root string) error {
if err := validateSymlinkPath(link, target, root); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("updater: archive symlink mkdir: %w", err)
}
// Remove any existing entry — happens on re-extraction in tests.
_ = os.Remove(target)
if err := os.Symlink(link, target); err != nil {
return fmt.Errorf("updater: archive symlink create: %w", err)
}
return nil
}
// safeJoin resolves name relative to root and returns the cleaned absolute
// path, rejecting any entry whose normalised path escapes root (zip-slip).
func safeJoin(root, name string) (string, error) {
// Normalise separators so a Windows-produced zip with "Foo\\bar" doesn't
// slip past the prefix check on POSIX hosts.
clean := filepath.Clean(strings.ReplaceAll(name, `\`, "/"))
if clean == "" || clean == "." {
return root, nil
}
if filepath.IsAbs(clean) || strings.HasPrefix(clean, "..") {
return "", fmt.Errorf("updater: archive entry escapes root: %s", name)
}
target := filepath.Join(root, clean)
rel, err := filepath.Rel(root, target)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("updater: archive entry escapes root: %s", name)
}
return target, nil
}
// fileModeFrom returns a safe file mode for an extracted regular file.
// Archives produced on Windows often report mode 0, which would create a
// file with no permission bits; substitute 0644 in that case. The executable
// bit is preserved when the archive provides it (e.g. the binary inside a
// macOS .app bundle).
func fileModeFrom(m os.FileMode) os.FileMode {
perm := m.Perm()
if perm == 0 {
return 0o644
}
return perm
}
// dirModeFrom is the directory analogue of fileModeFrom.
func dirModeFrom(m os.FileMode) os.FileMode {
perm := m.Perm()
if perm == 0 {
return 0o755
}
return perm
}

View File

@@ -0,0 +1,362 @@
package updater
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
// Helper-mode protocol.
//
// To avoid shipping a separate binary, the Updater re-executes the running
// application with a sentinel environment variable set. The helper-mode
// process waits for the parent (the application that just initiated the
// update) to exit, swaps the on-disk binary with the downloaded artifact,
// and then relaunches the (now-replaced) application. The helper exits
// when its work is done.
//
// All communication is via environment variables so user-supplied
// command-line flags are never confused with helper plumbing.
const (
envHelperMode = "WAILS_UPDATER_HELPER" // "1" to enter helper mode
envHelperTarget = "WAILS_UPDATER_HELPER_TARGET" // path of the running app to be replaced
envHelperNew = "WAILS_UPDATER_HELPER_NEW" // path of the verified new artifact
envHelperPID = "WAILS_UPDATER_HELPER_PID" // parent PID to wait for
envHelperLog = "WAILS_UPDATER_HELPER_LOG" // optional log file path
)
// HandleHelperMode returns immediately when the current process was not
// spawned as an updater helper. When it WAS spawned as a helper it performs
// the swap, relaunches the application, and calls os.Exit — it never returns
// in that case.
//
// The Wails application package calls this from application.New so that
// `app.Updater.Restart` works without users wiring anything by hand.
func HandleHelperMode() {
if os.Getenv(envHelperMode) != "1" {
return
}
target := os.Getenv(envHelperTarget)
newPath := os.Getenv(envHelperNew)
if target == "" || newPath == "" {
os.Exit(2)
}
pid, _ := strconv.Atoi(os.Getenv(envHelperPID))
logPath := os.Getenv(envHelperLog)
code := runHelperSwap(target, newPath, pid, logPath, waitForPID, osLauncher{})
os.Exit(code)
}
// processWaiter abstracts "wait until pid exits" so unit tests can drive the
// swap logic without spawning real processes.
type processWaiter func(pid int, timeout time.Duration) error
// launcher abstracts how the helper kicks off the replaced binary. Tests
// substitute a recorder; production uses osLauncher.
type launcher interface {
launch(path string) error
}
type osLauncher struct{}
func (osLauncher) launch(path string) error {
var cmd *exec.Cmd
if runtime.GOOS == "darwin" && filepath.Ext(path) == ".app" {
cmd = exec.Command("open", "-n", path)
} else {
cmd = exec.Command(path)
}
// Detach: we are about to exit; the new process must not depend on us.
cmd.Stdin = nil
cmd.Stdout = nil
cmd.Stderr = nil
return cmd.Start()
}
// runHelperSwap implements the actual swap logic. It is unexported and
// dependency-injected so unit tests can drive every branch without process
// spawning. Returns the exit code the helper should use.
func runHelperSwap(target, newPath string, parentPID int, logPath string, wait processWaiter, l launcher) int {
lg := openHelperLog(logPath)
defer lg.Close()
lg.logf("helper start: target=%s new=%s pid=%d", target, newPath, parentPID)
if _, err := os.Stat(target); err != nil {
lg.logf("stat target failed: %v", err)
return 10
}
if _, err := os.Stat(newPath); err != nil {
lg.logf("stat new failed: %v", err)
return 11
}
// Wait for the parent (the running app) to exit so the file is no longer
// locked. On Windows this is the critical step.
//
// If the parent never exits — most commonly because a webview dialog is
// blocking shutdown — we must NOT proceed: on Windows the swap would
// grind against the lock; on macOS the subsequent `open -n` would launch
// a second instance alongside the still-running one, leaving the user
// with two copies of the app racing on shared state.
if parentPID > 0 {
if err := wait(parentPID, 30*time.Second); err != nil {
lg.logf("parent did not exit within timeout: %v — aborting swap", err)
return 17
}
}
backup := target + ".bak"
_ = os.RemoveAll(backup)
lg.logf("backing up %s → %s", target, backup)
if err := copyAny(target, backup); err != nil {
lg.logf("backup failed: %v", err)
return 12
}
// Snapshot the original mode before the swap. The downloaded artifact
// was created via os.Create which sets 0o666-minus-umask — on Unix
// that drops the executable bit, so a direct rename would leave the
// new binary non-executable and exec() would fail with EACCES.
var origMode os.FileMode
if origInfo, err := os.Stat(target); err == nil {
origMode = origInfo.Mode()
}
// Retry the swap up to 20 times in case the OS still holds a transient
// lock on the target. The actual replace strategy is platform-specific —
// Unix unlinks and renames in place (open file handles keep working
// because the inode lives on); Windows moves the target aside because
// it can't delete a recently-running executable but it CAN rename it,
// even with the loader still mapping its image.
swapped := false
for i := 0; i < 20; i++ {
if err := replaceTarget(target, newPath); err != nil {
lg.logf("replace (attempt %d): %v", i+1, err)
time.Sleep(500 * time.Millisecond)
continue
}
swapped = true
lg.logf("swap succeeded on attempt %d", i+1)
break
}
// Restore the original executable bits on the new binary.
if swapped && origMode != 0 {
if err := os.Chmod(target, origMode.Perm()); err != nil {
lg.logf("chmod restored mode: %v (non-fatal)", err)
}
}
if !swapped {
lg.logf("all swap attempts exhausted — restoring backup")
if err := restoreFromBackup(backup, target, l); err != nil {
lg.logf("restore failed: %v", err)
return 14
}
return 13
}
// Strip our helper-mode sentinels from the environment before launching
// the new binary. exec.Command inherits the parent's env when cmd.Env is
// unset, so without this the relaunched app would see WAILS_UPDATER_HELPER
// still set, call HandleHelperMode at start-up, try to perform another
// swap against a path we've already cleaned up, and exit with code 11 —
// the visible effect being "user clicks Restart, app dies, never reopens."
// Discovered against wailsapp/updater-demo on macOS arm64.
clearHelperEnv()
if err := l.launch(target); err != nil {
lg.logf("launch new failed: %v — restoring backup", err)
if err := restoreFromBackup(backup, target, l); err != nil {
lg.logf("restore failed: %v", err)
return 16
}
return 15
}
// Best-effort backup cleanup. The replaced app is now running.
if err := os.RemoveAll(backup); err != nil {
lg.logf("backup cleanup: %v (non-fatal)", err)
}
// Tear down the staging directory we received newPath from. The
// download created it as `wails-update-*` under os.TempDir; after the
// rename above the directory is empty, but absent this step it would
// accumulate across update attempts. Guarded by the prefix so we never
// recursively delete a caller-supplied path that happened to live in a
// non-temp location.
stagingDir := filepath.Dir(newPath)
if strings.HasPrefix(filepath.Base(stagingDir), "wails-update-") {
if err := os.RemoveAll(stagingDir); err != nil {
lg.logf("staging cleanup: %v (non-fatal)", err)
}
}
lg.logf("helper done")
return 0
}
func restoreFromBackup(backup, target string, l launcher) error {
if err := os.RemoveAll(target); err != nil {
return fmt.Errorf("remove broken target: %w", err)
}
if err := os.Rename(backup, target); err != nil {
return fmt.Errorf("restore: %w", err)
}
return l.launch(target)
}
// copyAny dispatches between file and directory copies. macOS .app bundles
// are directories under the hood so this naturally handles them.
func copyAny(src, dst string) error {
info, err := os.Stat(src)
if err != nil {
return err
}
if info.IsDir() {
return copyTree(src, dst)
}
return copyFile(src, dst, info.Mode())
}
func copyFile(src, dst string, mode os.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return err
}
if err := out.Sync(); err != nil {
_ = out.Close()
return err
}
return out.Close()
}
func copyTree(src, dst string) error {
info, err := os.Stat(src)
if err != nil {
return err
}
if err := os.MkdirAll(dst, info.Mode()); err != nil {
return err
}
entries, err := os.ReadDir(src)
if err != nil {
return err
}
for _, e := range entries {
srcPath := filepath.Join(src, e.Name())
dstPath := filepath.Join(dst, e.Name())
ei, err := e.Info()
if err != nil {
return err
}
switch {
case ei.Mode()&os.ModeSymlink != 0:
link, err := os.Readlink(srcPath)
if err != nil {
return err
}
if err := os.Symlink(link, dstPath); err != nil {
return err
}
case ei.IsDir():
if err := copyTree(srcPath, dstPath); err != nil {
return err
}
default:
if err := copyFile(srcPath, dstPath, ei.Mode()); err != nil {
return err
}
}
}
return nil
}
// waitForPID polls until the named process is no longer alive or the timeout
// elapses. This is portable and avoids platform-specific process-handle APIs.
func waitForPID(pid int, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if !isAlive(pid) {
return nil
}
time.Sleep(100 * time.Millisecond)
}
return fmt.Errorf("pid %d still alive after %s", pid, timeout)
}
func isAlive(pid int) bool {
if pid <= 0 {
return false
}
return platformIsAlive(pid)
}
// helperLog is a tiny log writer that tolerates missing destinations and
// always writes to stderr too. Failures to log are never fatal.
type helperLog struct {
w io.Writer
file *os.File
}
func openHelperLog(path string) *helperLog {
if path == "" {
path = filepath.Join(os.TempDir(), fmt.Sprintf("wails-update-%d.log", os.Getpid()))
}
f, err := os.Create(path)
if err != nil {
return &helperLog{w: os.Stderr}
}
return &helperLog{w: io.MultiWriter(os.Stderr, f), file: f}
}
func (h *helperLog) logf(format string, args ...any) {
if h == nil || h.w == nil {
return
}
fmt.Fprintf(h.w, "%s: %s\n", time.Now().Format(time.RFC3339), fmt.Sprintf(format, args...))
}
func (h *helperLog) Close() {
if h != nil && h.file != nil {
_ = h.file.Close()
}
}
// clearHelperEnv unsets every WAILS_UPDATER_HELPER_* variable in the current
// process. Called by runHelperSwap immediately before launching the new
// binary so the launched process boots in normal mode instead of inheriting
// our helper-mode sentinels.
func clearHelperEnv() {
for _, k := range []string{envHelperMode, envHelperTarget, envHelperNew, envHelperPID, envHelperLog} {
_ = os.Unsetenv(k)
}
}
// errors
var (
// ErrNotReady is returned by Restart when there is no installed update
// staged for launch.
ErrNotReady = errors.New("updater: nothing to restart into (call DownloadAndInstall first)")
)

View File

@@ -0,0 +1,31 @@
//go:build !windows
package updater
import (
"os"
"syscall"
)
// platformIsAlive reports whether pid names a running process. Sending the
// no-op signal 0 to a pid either succeeds (process is running and we have
// permission) or fails (process is gone / no permission).
func platformIsAlive(pid int) bool {
proc, err := os.FindProcess(pid)
if err != nil {
return false
}
return proc.Signal(syscall.Signal(0)) == nil
}
// replaceTarget removes the existing file or directory at target and renames
// newPath into its place. On Unix this is straightforward: open file handles
// remain valid against the unlinked inode, so we can delete a running binary
// and immediately put a new one at its path. macOS .app bundles are
// directories, hence RemoveAll rather than Remove.
func replaceTarget(target, newPath string) error {
if err := os.RemoveAll(target); err != nil {
return err
}
return os.Rename(newPath, target)
}

View File

@@ -0,0 +1,167 @@
//go:build windows
package updater
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"syscall"
"time"
)
// processQueryLimitedInformation grants only enough access to ask whether a
// process is still running — the minimum permissions required to call
// GetExitCodeProcess. Defined directly here rather than depending on
// golang.org/x/sys/windows so the updater stays on the standard library.
const processQueryLimitedInformation = 0x1000
// stillActive is the exit code Windows returns from GetExitCodeProcess while
// the process is still running. (Defined in MSDN as STILL_ACTIVE = 259.)
const stillActive = 259
// errNotSameDevice is the Windows error code returned by os.Rename when src
// and dst reside on different volumes (ERROR_NOT_SAME_DEVICE = 17 / 0x11).
// Defined locally to avoid a dependency on golang.org/x/sys/windows.
const errNotSameDevice = syscall.Errno(17)
// platformIsAlive reports whether pid names a running process. On Windows
// the previous os.Process.Signal(nil) probe always returned an error
// (syscall.EWINDOWS for live processes, ErrProcessDone for dead ones), so
// waitForPID short-circuited and the helper attempted to swap the binary
// while the parent still held it open — causing the swap-retry loop to grind
// against the lock instead of waiting.
//
// Open the process with PROCESS_QUERY_LIMITED_INFORMATION and ask the
// kernel for its exit code directly; STILL_ACTIVE distinguishes a live
// process from one that has already terminated.
func platformIsAlive(pid int) bool {
h, err := syscall.OpenProcess(processQueryLimitedInformation, false, uint32(pid))
if err != nil {
return false
}
defer syscall.CloseHandle(h)
var code uint32
if err := syscall.GetExitCodeProcess(h, &code); err != nil {
return false
}
return code == stillActive
}
// renameOrCopy attempts to move a file via os.Rename. If it fails specifically
// because src and dst are on different volumes (ERROR_NOT_SAME_DEVICE / errno 17),
// it transparently falls back to a secure copy-and-delete strategy.
//
// All other os.Rename errors (permission denied, target locked, etc.) are
// returned as-is so the caller sees the real failure reason.
func renameOrCopy(src, dst string) error {
err := os.Rename(src, dst)
if err == nil {
return nil
}
var linkErr *os.LinkError
if !errors.As(err, &linkErr) || !errors.Is(linkErr.Err, errNotSameDevice) {
return err
}
if err := copyFileExec(src, dst); err != nil {
return fmt.Errorf("cross-volume copy %s -> %s: %w", src, dst, err)
}
_ = os.Remove(src)
return nil
}
// copyFileExec duplicates the executable from src to dst. It preserves the
// original file mode, flushes data to the storage device, and ensures that
// any partially written destination file is deleted if an error occurs.
func copyFileExec(src, dst string) (err error) {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
info, err := in.Stat()
if err != nil {
return err
}
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
if err != nil {
return err
}
// Deferred cleanup to handle failures gracefully. If an error occurs during
// copying or syncing, the partial file at dst will be removed.
defer func() {
_ = out.Close()
if err != nil {
_ = os.Remove(dst)
}
}()
if _, err = io.Copy(out, in); err != nil {
return err
}
// Ensure all dirty pages are flushed to disk before heading back.
return out.Sync()
}
// replaceTarget puts the file at newPath into target's slot. On Windows the
// kernel keeps an executable's image file held for some time after the
// process that ran it exits — long enough that os.Remove(target) fails with
// "Access is denied" through multiple retry attempts. Discovered against
// wailsapp/updater-demo on Windows 11 amd64 (helper logged 20 consecutive
// unlinkat failures, ~10s total).
//
// Windows does, however, allow renaming a file whose image is still mapped.
// So we rename the target aside (giving the new file a free slot at target)
// and let the stale .old file be cleaned up later — best-effort delete here,
// and a sweep in maybeCleanReplacedAsides on the next helper run takes care
// of any leftovers once the kernel has finally released them.
func replaceTarget(target, newPath string) error {
// Best-effort first try: if nothing's actually holding it, a normal
// remove + rename is cleaner (no .old file left behind).
if err := os.RemoveAll(target); err == nil {
// Sweep any .old files leftover from prior updates — by now the
// kernel has released them.
sweepRenameAsides(target)
return renameOrCopy(newPath, target)
}
aside := fmt.Sprintf("%s.old.%d", target, time.Now().UnixNano())
if err := os.Rename(target, aside); err != nil {
return fmt.Errorf("rename-aside %s -> %s: %w", target, aside, err)
}
if err := renameOrCopy(newPath, target); err != nil {
// Revert the original file back to its target slot to prevent half-states.
_ = os.Rename(aside, target)
return err
}
// The just-created aside is probably still mapped by the kernel; this
// remove will fail. Sweep grabs older asides whose owning processes are
// long gone.
_ = os.Remove(aside)
sweepRenameAsides(target)
return nil
}
// sweepRenameAsides best-effort-deletes any "<target>.old.*" siblings.
// Without this, a Windows app updated N times accumulates N stale
// executables in its install directory.
func sweepRenameAsides(target string) {
matches, err := filepath.Glob(target + ".old.*")
if err != nil {
return
}
for _, m := range matches {
_ = os.Remove(m)
}
}

View File

@@ -0,0 +1,28 @@
package updater
import (
"os"
"os/exec"
)
// selfExecutable returns the path of the running executable. Held in a
// package-level var so tests can override without poking at os.Executable.
var selfExecutable = func() (string, error) {
return os.Executable()
}
// newDetachedCommand builds an exec.Cmd for the helper invocation. Stdio is
// disconnected from the parent so the helper survives the parent's exit on
// every platform. Held in a package-level var so tests can substitute a
// command that's safe to actually Start() (e.g. /usr/bin/true) without
// re-execing the test binary.
var newDetachedCommand = func(path string) *exec.Cmd {
cmd := exec.Command(path)
cmd.Stdin = nil
cmd.Stdout = nil
cmd.Stderr = nil
// Platform-specific session/process-group detachment is handled in
// spawn_unix.go and spawn_windows.go.
applyDetachAttrs(cmd)
return cmd
}

View File

@@ -0,0 +1,17 @@
//go:build !windows
package updater
import (
"os/exec"
"syscall"
)
// applyDetachAttrs puts the child in its own session so it isn't reaped or
// signalled along with the parent.
func applyDetachAttrs(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setsid = true
}

View File

@@ -0,0 +1,23 @@
//go:build windows
package updater
import (
"os/exec"
"syscall"
)
// applyDetachAttrs marks the child as a detached process so it survives the
// parent's exit and does not flash a console window.
func applyDetachAttrs(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
const (
detachedProcess = 0x00000008
createNoWindow = 0x08000000
createNewProcGroup = 0x00000200
)
cmd.SysProcAttr.CreationFlags |= detachedProcess | createNoWindow | createNewProcGroup
cmd.SysProcAttr.HideWindow = true
}

View File

@@ -0,0 +1,121 @@
package updater
import (
"context"
"io"
"time"
)
// State is the high-level lifecycle phase the Updater is currently in.
type State string
const (
StateUnconfigured State = "unconfigured"
StateIdle State = "idle"
StateChecking State = "checking"
StateUpToDate State = "up-to-date"
StateAvailable State = "available"
StateDownloading State = "downloading"
StateVerifying State = "verifying"
StateInstalling State = "installing"
StateReady State = "ready"
StateError State = "error"
)
// Stage describes which phase of the update flow produced an error.
type Stage string
const (
StageCheck Stage = "check"
StageDownload Stage = "download"
StageVerify Stage = "verify"
StageInstall Stage = "install"
)
// Release is what a Provider returns from Check when there is something newer.
type Release struct {
Version string `json:"version"`
Channel string `json:"channel,omitempty"`
Name string `json:"name,omitempty"`
Notes string `json:"notes,omitempty"`
PublishedAt time.Time `json:"publishedAt,omitempty"`
Artifact Artifact `json:"artifact"`
Verification *Verification `json:"verification,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
// Provider is set by the Updater after Check; provider implementations
// should leave it empty. Used to route a follow-up Download back to the
// same source.
Provider string `json:"provider,omitempty"`
}
// Artifact describes the file to download for a Release on the running platform.
type Artifact struct {
Filename string `json:"filename"`
Filetype string `json:"filetype,omitempty"`
Size int64 `json:"size,omitempty"`
Platform string `json:"platform,omitempty"`
Arch string `json:"arch,omitempty"`
}
// Verification carries everything the Updater needs to authenticate the
// downloaded bytes. A Provider populates this from the release source; the
// Updater verifies against it using its configured trust root.
//
// Either Digest+DigestAlgo, Signature+SignatureAlgo, or both may be present.
// When both are present, both are checked; either failing fails the update.
//
// Signature verification always uses Config.PublicKey as the trust root. The
// release source has no say in which key authenticates it — that is the entire
// point of pinning a key out-of-band at build time. Releases that ship a
// Signature without a configured Config.PublicKey fail closed.
type Verification struct {
DigestAlgo string `json:"digestAlgo,omitempty"` // "sha256", "sha512"
Digest []byte `json:"digest,omitempty"` // raw digest bytes
SignatureAlgo string `json:"signatureAlgo,omitempty"` // "ed25519", "ed25519ph", "ecdsa-p256"
Signature []byte `json:"signature,omitempty"` // raw signature bytes
}
// Progress is the payload of EventDownloadProgress.
type Progress struct {
Written int64 `json:"written"`
Total int64 `json:"total"`
Rate float64 `json:"rate"` // bytes/sec smoothed over the last ~1s
Provider string `json:"provider,omitempty"`
}
// ErrorInfo is the payload of EventError.
type ErrorInfo struct {
Stage Stage `json:"stage"`
Message string `json:"message"`
Provider string `json:"provider,omitempty"`
}
// CheckRequest carries platform context to a Provider's Check method. The
// Updater fills in defaults from runtime.GOOS / runtime.GOARCH when the user
// does not supply them via Config.
type CheckRequest struct {
CurrentVersion string
Platform string
Arch string
}
// Provider abstracts an update source. Implementations are typically tiny:
// resolve the next release for the running platform, then stream the bytes.
// Everything else (verification, atomic write, swap, restart, events) is the
// Updater's job.
type Provider interface {
// Name identifies the provider in logs and in the `provider` field of
// progress / error event payloads. Should be stable and short
// (e.g. "github", "keygen.sh", "appcast").
Name() string
// Check returns the available upgrade for the running platform. Return
// (nil, nil) when the source confirms the caller is already up to date.
// Errors here put the Updater into the fallback chain.
Check(ctx context.Context, req CheckRequest) (*Release, error)
// Download streams the artifact bytes for r to dst. Providers should
// invoke onProgress periodically; it is never nil.
Download(ctx context.Context, r *Release, dst io.Writer, onProgress func(written, total int64)) error
}

View File

@@ -0,0 +1,558 @@
package updater
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
)
// Updater is the singleton exposed as app.Updater. It is constructed during
// application initialisation but does nothing useful until Init is called.
type Updater struct {
mu sync.RWMutex
// host is the bridge to the parent application — used to emit lifecycle
// events without introducing a circular import on the application package.
host Host
cfg *Config
state State
current string // CurrentVersion, snapshot for State()
pending *Release
resolved string // resolved download path (after install)
stagingDir string // os.MkdirTemp parent of resolved, removed on Restart / re-Check
lastDigest []byte // digest computed streaming during the last successful download
skipped string // version recorded by SkipVersion / the default window Skip button
dlMu sync.Mutex // serialises concurrent DownloadAndInstall calls
sessMu sync.Mutex // protects session pointer separately from u.mu
session *windowSession // current window session, if any
periodicCtx context.Context
periodicCancel context.CancelFunc
periodicDone chan struct{}
}
// Host is the minimal surface the Updater needs from the application that
// owns it. The application package implements this via an adapter; tests
// stub it.
type Host interface {
// Emit a custom event with the supplied data.
Emit(name string, data ...any) bool
// OnEvent registers a callback for a custom event. The returned function
// removes the listener.
OnEvent(name string, callback func(payload any)) func()
// OpenWindow creates and shows an update window. Implementations build
// a real Wails webview window from opts; tests return a recorder.
OpenWindow(opts WindowOptions) WindowHandle
// Quit asks the host application to begin its normal shutdown sequence.
// Called by Restart after the helper has been spawned so the helper's
// "wait for parent to exit" step actually completes.
Quit()
}
// WindowHandle is the minimal API the Updater drives once a window is open.
// The application adapter satisfies this around a *WebviewWindow.
//
// SetHTML is deliberately omitted — loading HTML after construction puts it
// on about:blank where the Wails runtime isn't injected (so JS event emits
// no-op on webkit2gtk and in some WebView2 configurations). Templates are
// passed via WindowOptions.InitialHTML at construction time instead.
type WindowHandle interface {
EmitEvent(name string, data ...any) bool
Show()
Close()
}
// WindowSizer is an optional capability a WindowHandle may implement to
// allow the Updater to resize the window in response to state changes.
// The default window template uses this to shrink the Up-to-Date state
// to a compact card; the framework adapter for *application.WebviewWindow
// implements it transparently. BYO windows can opt in by adding the
// SetSize method themselves; if they don't, the Updater silently skips
// the resize and the window stays whatever size the host opened it at.
type WindowSizer interface {
SetSize(width, height int)
}
// WindowOptions describes the chrome and starting content for a window the
// Updater asks the host to open. Maps to (a subset of)
// application.WebviewWindowOptions on the host side.
type WindowOptions struct {
Title string
Width, Height int
Frameless bool
AlwaysOnTop bool
DisableResize bool
InitialHTML string
}
// New is for internal use by the application package and tests. End users
// obtain an Updater via app.Updater (or app.Updater.Init).
func New(host Host) *Updater {
return &Updater{host: host, state: StateUnconfigured}
}
// Init configures the Updater. Returns ErrAlreadyConfigured if Init has
// already been called, or a validation error if cfg is malformed.
func (u *Updater) Init(cfg Config) error {
u.mu.Lock()
defer u.mu.Unlock()
if u.cfg != nil {
return ErrAlreadyConfigured
}
if err := cfg.validate(); err != nil {
return err
}
if cfg.Platform == "" {
cfg.Platform = runtime.GOOS
}
if cfg.Arch == "" {
cfg.Arch = runtime.GOARCH
}
u.cfg = &cfg
u.current = cfg.CurrentVersion
u.state = StateIdle
if cfg.CheckInterval > 0 {
u.periodicCtx, u.periodicCancel = context.WithCancel(context.Background())
u.periodicDone = make(chan struct{})
go u.periodicCheckLoop(cfg.CheckInterval)
}
return nil
}
// periodicCheckLoop polls the provider chain on the configured interval.
// Each tick runs CheckAndInstall so the default UI (or the user's headless
// subscribers) sees the found update. Ticks that arrive while another flow
// is already in progress are dropped — concurrent state machines are not
// supported and CheckAndInstall's own session-setup lock would defer the
// second call anyway.
func (u *Updater) periodicCheckLoop(d time.Duration) {
defer close(u.periodicDone)
t := time.NewTicker(d)
defer t.Stop()
for {
select {
case <-u.periodicCtx.Done():
return
case <-t.C:
s := u.State()
if s == StateChecking || s == StateDownloading || s == StateVerifying || s == StateInstalling {
continue
}
_ = u.CheckAndInstall(u.periodicCtx)
}
}
}
// StopPeriodicCheck cancels the timer started by Init when
// Config.CheckInterval > 0 and blocks until the polling goroutine has
// returned (so callers can safely inspect provider state afterward). Safe
// to call when no periodic check was configured.
func (u *Updater) StopPeriodicCheck() {
u.mu.Lock()
cancel := u.periodicCancel
done := u.periodicDone
u.periodicCancel = nil
u.periodicDone = nil
u.mu.Unlock()
if cancel != nil {
cancel()
}
if done != nil {
<-done
}
}
// State returns the Updater's current high-level lifecycle phase.
func (u *Updater) State() State {
u.mu.RLock()
defer u.mu.RUnlock()
return u.state
}
// CurrentVersion returns the version supplied at Init time, or "" if not configured.
func (u *Updater) CurrentVersion() string {
u.mu.RLock()
defer u.mu.RUnlock()
return u.current
}
// Check walks the provider chain looking for an upgrade. Returns:
// - (rel, nil): newer release found; payload of EventUpdateAvailable.
// - (nil, nil): caller is up to date; payload of EventNoUpdate.
// - (nil, err): all providers errored; err wraps every failure.
//
// Fallback semantics: a provider returning (nil, nil) short-circuits to
// up-to-date — fallback exists for "primary unreachable", not "providers
// disagree about what's latest." A provider returning an error advances to
// the next one. If every provider errors, Check returns an error built from
// the chain.
func (u *Updater) Check(ctx context.Context) (*Release, error) {
u.mu.RLock()
cfg := u.cfg
u.mu.RUnlock()
if cfg == nil {
return nil, ErrNotConfigured
}
u.transition(StateChecking)
u.host.Emit(EventCheckStarted)
req := CheckRequest{
CurrentVersion: cfg.CurrentVersion,
Platform: cfg.Platform,
Arch: cfg.Arch,
}
var failures []error
for _, p := range cfg.Providers {
rel, err := p.Check(ctx, req)
if err != nil {
failures = append(failures, fmt.Errorf("%s: %w", p.Name(), err))
continue
}
if rel == nil {
u.transition(StateUpToDate)
u.host.Emit(EventNoUpdate)
return nil, nil
}
if u.shouldSkip(rel.Version) {
// User has explicitly skipped this version — surface as up-to-date.
u.transition(StateUpToDate)
u.host.Emit(EventNoUpdate)
return nil, nil
}
rel.Provider = p.Name()
u.mu.Lock()
u.pending = rel
u.state = StateAvailable
u.mu.Unlock()
u.host.Emit(EventUpdateAvailable, rel)
return rel, nil
}
err := joinErrors("all providers failed", failures)
u.transition(StateError)
u.host.Emit(EventError, ErrorInfo{Stage: StageCheck, Message: err.Error()})
return nil, err
}
// DownloadAndInstall downloads the pending release (set by a previous Check),
// verifies it, and stages it for swap. Returns ErrNoPendingRelease if Check
// did not produce one. Returns ErrDownloadInProgress if another download is
// already running. The actual binary swap is performed in a follow-up
// commit; v1 of this branch stages the verified file and reports
// StateReady + EventUpdateReady.
func (u *Updater) DownloadAndInstall(ctx context.Context) error {
if !u.dlMu.TryLock() {
return ErrDownloadInProgress
}
defer u.dlMu.Unlock()
u.mu.RLock()
cfg := u.cfg
pending := u.pending
u.mu.RUnlock()
if cfg == nil {
return ErrNotConfigured
}
if pending == nil {
return ErrNoPendingRelease
}
provider, err := findProvider(cfg.Providers, pending.Provider)
if err != nil {
return err
}
// Drop any stale staging dir from a previous DownloadAndInstall the
// caller didn't follow up with Restart.
u.discardStaging()
tmpPath, tmpDir, err := u.download(ctx, provider, pending)
if err != nil {
u.transition(StateError)
u.host.Emit(EventError, ErrorInfo{Stage: StageDownload, Message: err.Error(), Provider: provider.Name()})
return err
}
u.transition(StateVerifying)
u.host.Emit(EventVerifying, pending)
if err := u.verify(tmpPath, pending); err != nil {
_ = os.RemoveAll(tmpDir)
u.transition(StateError)
u.host.Emit(EventError, ErrorInfo{Stage: StageVerify, Message: err.Error(), Provider: provider.Name()})
return err
}
u.transition(StateInstalling)
u.host.Emit(EventInstalling, pending)
finalPath, err := finaliseDownload(tmpPath, pending.Artifact.Filename)
if err != nil {
_ = os.RemoveAll(tmpDir)
u.transition(StateError)
u.host.Emit(EventError, ErrorInfo{Stage: StageInstall, Message: err.Error(), Provider: provider.Name()})
return err
}
// If the artifact is an archive (.zip / .tar.gz), unpack it now so the
// helper has a real binary or .app bundle to rename into place. Most
// macOS distributions ship the .app inside a .zip; without this step the
// helper would replace /Applications/MyApp.app (a directory) with the
// downloaded .zip (a file). Non-archive artifacts pass through unchanged.
finalPath, _, err = maybeExtractInto(finalPath)
if err != nil {
_ = os.RemoveAll(tmpDir)
u.transition(StateError)
u.host.Emit(EventError, ErrorInfo{Stage: StageInstall, Message: err.Error(), Provider: provider.Name()})
return err
}
u.mu.Lock()
u.resolved = finalPath
u.stagingDir = tmpDir
u.state = StateReady
u.mu.Unlock()
u.host.Emit(EventUpdateReady, pending)
return nil
}
// CheckAndInstall is the convenience method: it opens the update window
// (unless Config.Window == WindowNone) and runs Check + DownloadAndInstall.
// Returns nil with no side effects if the application is already up to date.
//
// The window stays open for the duration of the flow AND across the
// "up-to-date" / error terminal states — the user dismisses it via the
// Close button. Opening + immediately closing on the no-update branch
// produced a perceptible flicker on every check; keeping the window up so
// the "You're up to date" panel actually renders matches what users expect
// from system-style updaters.
//
// Apps that want silent background polling should use Config.Window =
// updater.WindowNone (no window ever opens) or invoke Check() directly and
// subscribe to EventNoUpdate / EventUpdateAvailable themselves.
func (u *Updater) CheckAndInstall(ctx context.Context) error {
// Tear down any session from a previous CheckAndInstall before opening a
// fresh one — otherwise its listeners and window leak and stale callbacks
// still fire on user-action events. Hold sessMu across the close → open →
// assign sequence so concurrent callers can't orphan each other's
// listeners (caller A opens, caller B closes A then opens its own, and
// A's assignment lands on top, leaking B's listeners).
u.sessMu.Lock()
if u.session != nil {
u.session.close()
u.session = nil
}
sess := u.openSession(ctx)
u.session = sess
u.sessMu.Unlock()
rel, err := u.Check(ctx)
if err != nil {
// Window stays open showing the error; the user can dismiss it via
// the Cancel button which fires updater:user:cancel.
return err
}
if rel == nil {
// "Up to date" — leave the window open showing the up-to-date panel
// (window.html's onNoUpdate handler renders "You're Up to Date" and
// the current version). Closing here caused a flicker on every
// check that found nothing.
return nil
}
return u.DownloadAndInstall(ctx)
}
// Restart performs the full restart-into-the-new-version dance: it spawns a
// helper-mode child (the same binary with sentinel env vars set) and then
// asks the host application to begin its shutdown sequence via Host.Quit.
// Once the running process exits, the helper performs the binary swap and
// relaunches the (now-replaced) application.
//
// Returns ErrNotReady if DownloadAndInstall has not produced an installed
// artifact yet. If the helper spawn fails the error is surfaced and Quit is
// not called — the caller's process stays alive on the old binary.
//
// On success Restart returns once the helper has started and Quit has been
// dispatched. The caller's process will exit asynchronously as the host's
// normal shutdown unwinds.
func (u *Updater) Restart(_ context.Context) error {
u.mu.RLock()
staged := u.resolved
u.mu.RUnlock()
if staged == "" {
return ErrNotReady
}
self, err := selfExecutable()
if err != nil {
return fmt.Errorf("updater: resolve self: %w", err)
}
target := bundleTarget(self)
// Include PID so concurrent helpers (e.g. test runs, multiple installed
// Wails apps updating at the same time) don't truncate each other's logs.
logPath := filepath.Join(os.TempDir(), fmt.Sprintf("wails-update-%d.log", os.Getpid()))
env := append(os.Environ(),
envHelperMode+"=1",
envHelperTarget+"="+target,
envHelperNew+"="+staged,
envHelperPID+"="+itoa(os.Getpid()),
envHelperLog+"="+logPath,
)
cmd := newDetachedCommand(self)
cmd.Env = env
if err := cmd.Start(); err != nil {
return fmt.Errorf("updater: spawn helper: %w", err)
}
// Helper is detached and now blocking on waitForPID(os.Getpid()). Hand
// off to the host's shutdown sequence so the wait completes and the
// swap proceeds.
u.host.Quit()
return nil
}
// DownloadedPath returns the on-disk path of the last successfully-installed
// (staged) update, or "" if none.
func (u *Updater) DownloadedPath() string {
u.mu.RLock()
defer u.mu.RUnlock()
return u.resolved
}
// --- internals ---
// Window-sizing constants for the built-in template:
//
// * upToDateWidth/Height — the small "compact" card used for Checking,
// Up-to-Date, and Error states. The default window opens at this size,
// so the most common flow (Check → Up-to-Date) involves zero visible
// resize: the window is already its target size the moment it appears.
//
// * availableWidth/Height — the larger "full-flow" card with room for
// Markdown-rendered release notes, the progress bar, and the Restart
// & Apply primary action. The Updater grows the window into this size
// via WindowSizer when state transitions to Available / Downloading /
// Verifying / Installing / Ready, then shrinks back if a fresh check
// later returns Up-to-Date.
const (
upToDateWidth = 348
upToDateHeight = 161
availableWidth = 520
availableHeight = 540
)
// statesNeedingFullSize lists the states whose layout requires the larger
// window (notes panel, progress bar, or per-button row). Any state not in
// this set fits the compact upToDateWidth×upToDateHeight card.
func stateWantsFullSize(s State) bool {
switch s {
case StateAvailable, StateDownloading, StateVerifying, StateInstalling, StateReady:
return true
}
return false
}
func (u *Updater) transition(s State) {
u.mu.Lock()
u.state = s
u.mu.Unlock()
// Resize the default window for states that need more (or less) room.
// The window opens at the compact size and grows when it has to; if a
// later transition takes us back to a compact-sized state the window
// shrinks again. Handles that don't implement WindowSizer (e.g. BYO
// windows whose owners didn't add SetSize) are silently skipped.
u.sessMu.Lock()
sess := u.session
u.sessMu.Unlock()
if sess == nil {
return
}
sizer, ok := sess.handle.(WindowSizer)
if !ok {
return
}
if stateWantsFullSize(s) {
sizer.SetSize(availableWidth, availableHeight)
} else {
sizer.SetSize(upToDateWidth, upToDateHeight)
}
}
// discardStaging removes any temp directory the previous DownloadAndInstall
// left behind. Called before a new download begins and on Check when an old
// pending release becomes stale. The helper process is responsible for
// cleaning up its own staging dir post-swap; this is for the cases the
// helper never starts.
func (u *Updater) discardStaging() {
u.mu.Lock()
dir := u.stagingDir
u.stagingDir = ""
u.resolved = ""
u.mu.Unlock()
if dir != "" {
_ = os.RemoveAll(dir)
}
}
func findProvider(providers []Provider, name string) (Provider, error) {
for _, p := range providers {
if p.Name() == name {
return p, nil
}
}
return nil, fmt.Errorf("updater: provider %q is no longer registered", name)
}
func joinErrors(prefix string, errs []error) error {
if len(errs) == 0 {
return errors.New(prefix)
}
parts := make([]string, 0, len(errs))
for _, e := range errs {
parts = append(parts, e.Error())
}
return fmt.Errorf("updater: %s: %s", prefix, strings.Join(parts, "; "))
}
func finaliseDownload(tmpPath, filename string) (string, error) {
base := filepath.Base(filename)
// filepath.Base normalises away directory components, but it still passes
// through "." and "..", and on Windows ":" / drive prefixes get reduced to
// the suffix. Neutralise the ones that would otherwise resolve outside
// the staging dir or that filepath.Join would not handle sanely.
if base == "" || base == "." || base == ".." || base == "/" || strings.ContainsAny(base, `\/`) {
base = "wails-update.bin"
}
final := filepath.Join(filepath.Dir(tmpPath), base)
if err := os.Rename(tmpPath, final); err != nil {
return "", fmt.Errorf("updater: finalise: %w", err)
}
return final, nil
}
// errors
var (
ErrAlreadyConfigured = errors.New("updater: Init already called")
ErrNotConfigured = errors.New("updater: Init has not been called")
ErrNoPendingRelease = errors.New("updater: no pending release (call Check first)")
ErrDownloadInProgress = errors.New("updater: download already in progress")
)

View File

@@ -0,0 +1,21 @@
//go:build darwin && !ios
package updater
import (
"os"
"path/filepath"
"strings"
)
// bundleTarget returns the .app bundle path when exe lives inside one, or exe
// unchanged when it does not.
func bundleTarget(exe string) string {
parts := strings.Split(filepath.Clean(exe), string(os.PathSeparator))
for i, p := range parts {
if strings.HasSuffix(p, ".app") {
return string(os.PathSeparator) + filepath.Join(parts[1:i+1]...)
}
}
return exe
}

View File

@@ -0,0 +1,5 @@
//go:build !darwin || ios
package updater
func bundleTarget(exe string) string { return exe }

View File

@@ -0,0 +1,224 @@
package updater
import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/sha256"
"crypto/sha512"
"crypto/subtle"
"crypto/x509"
"encoding/asn1"
"encoding/pem"
"errors"
"fmt"
"hash"
"math/big"
)
// verifier authenticates a downloaded artifact. The interface is unexported
// in v1: callers configure verification by populating Release.Verification
// (which the Updater's built-in registry maps to the concrete verifier).
// The interface stays unexported until there is a real third-party need for
// a custom algorithm — at which point we promote it to a public
// RegisterVerifier without breaking existing callers.
type verifier interface {
// verify checks digest+/or signature against publicKey. The Updater
// always computes the digest in a streaming pass during download and
// passes it here, so verifiers do not re-hash. publicKey is nil when
// the caller did not configure one.
verify(digest []byte, v *Verification, publicKey []byte) error
}
// digestHasher returns a fresh hash.Hash for the digest algorithm named by
// algo. Unknown algorithms return a nil hash and an error.
func digestHasher(algo string) (hash.Hash, error) {
switch algo {
case "", "sha256":
return sha256.New(), nil
case "sha512":
return sha512.New(), nil
}
return nil, fmt.Errorf("updater: unknown digest algorithm %q", algo)
}
// verifierFor returns the registered verifier for v.SignatureAlgo, or nil if
// v carries no signature (in which case only digest comparison applies).
func verifierFor(algo string) (verifier, error) {
if algo == "" {
return nil, nil
}
if vf, ok := verifierRegistry[algo]; ok {
return vf, nil
}
return nil, fmt.Errorf("updater: unsupported signature algorithm %q", algo)
}
var verifierRegistry = map[string]verifier{
"ed25519": ed25519Verifier{},
"ed25519ph": ed25519phVerifier{},
"ecdsa-p256": ecdsaP256Verifier{},
}
// runVerification is the single entry point used by the Updater. It enforces
// the contract: if Verification has a digest, it must match; if it has a
// signature, the signature must verify under configKey. Returns nil only when
// every present check passed.
//
// Signature verification uses configKey (Config.PublicKey) and nothing else.
// The release source does not get to choose its own trust anchor — that would
// defeat the purpose of pinning a key out-of-band at build time. Releases that
// carry a Signature without a configured key fail closed.
func runVerification(computedDigest []byte, v *Verification, configKey []byte) error {
if v == nil {
return nil // nothing to check
}
if len(v.Digest) > 0 {
if !constantTimeEqual(computedDigest, v.Digest) {
return errors.New("updater: digest mismatch")
}
}
if len(v.Signature) > 0 && v.SignatureAlgo == "" {
// Fail closed: silently downgrading to digest-only mode would let a
// manifest that omits (or garbles) signatureAlgo bypass signature
// verification the publisher intended.
return errors.New("updater: signature present but signatureAlgo missing")
}
if len(v.Signature) == 0 {
return nil // digest-only mode
}
vf, err := verifierFor(v.SignatureAlgo)
if err != nil {
return err
}
if len(configKey) == 0 {
return errors.New("updater: signature requires a public key but none configured")
}
return vf.verify(computedDigest, v, configKey)
}
func constantTimeEqual(a, b []byte) bool {
return subtle.ConstantTimeCompare(a, b) == 1
}
// --- ed25519 (raw, payload-signing) ---
type ed25519Verifier struct{}
func (ed25519Verifier) verify(digest []byte, v *Verification, publicKey []byte) error {
pub, err := parseEd25519Public(publicKey)
if err != nil {
return err
}
// Raw Ed25519 signs the message (here, the digest). Callers that want to
// sign full-file payloads should use ed25519ph instead and let the
// Updater compute the digest for them.
if !ed25519.Verify(pub, digest, v.Signature) {
return errors.New("updater: ed25519 signature did not verify")
}
return nil
}
func parseEd25519Public(raw []byte) (ed25519.PublicKey, error) {
if len(raw) == ed25519.PublicKeySize {
return ed25519.PublicKey(raw), nil
}
if block, _ := pem.Decode(raw); block != nil {
raw = block.Bytes
}
pubAny, err := x509.ParsePKIXPublicKey(raw)
if err != nil {
return nil, fmt.Errorf("updater: ed25519 public key parse: %w", err)
}
pub, ok := pubAny.(ed25519.PublicKey)
if !ok {
return nil, fmt.Errorf("updater: ed25519 public key has wrong type %T", pubAny)
}
return pub, nil
}
// --- ed25519ph (pre-hash) ---
type ed25519phVerifier struct{}
func (ed25519phVerifier) verify(digest []byte, v *Verification, publicKey []byte) error {
pub, err := parseEd25519Public(publicKey)
if err != nil {
return err
}
// Ed25519ph signs SHA-512(message). The Updater always streams a SHA-512
// hash in parallel when verification is configured with this algo.
if len(digest) != sha512.Size {
return fmt.Errorf("updater: ed25519ph requires sha512 digest, got %d bytes", len(digest))
}
if err := ed25519.VerifyWithOptions(pub, digest, v.Signature, &ed25519.Options{Hash: crypto.SHA512}); err != nil {
return fmt.Errorf("updater: ed25519ph signature did not verify: %w", err)
}
return nil
}
// --- ecdsa P-256 over SHA-256 ---
type ecdsaP256Verifier struct{}
func (ecdsaP256Verifier) verify(digest []byte, v *Verification, publicKey []byte) error {
pub, err := parseECDSAPublic(publicKey)
if err != nil {
return err
}
if pub.Curve != elliptic.P256() {
return fmt.Errorf("updater: ecdsa-p256 requires P-256 key, got %s", pub.Curve.Params().Name)
}
// Accept either raw r||s (64 bytes for P-256) or ASN.1 DER.
r, s, err := splitECDSASig(v.Signature)
if err != nil {
return err
}
if !ecdsa.Verify(pub, digest, r, s) {
return errors.New("updater: ecdsa-p256 signature did not verify")
}
return nil
}
func parseECDSAPublic(raw []byte) (*ecdsa.PublicKey, error) {
if block, _ := pem.Decode(raw); block != nil {
raw = block.Bytes
}
pubAny, err := x509.ParsePKIXPublicKey(raw)
if err != nil {
return nil, fmt.Errorf("updater: ecdsa public key parse: %w", err)
}
pub, ok := pubAny.(*ecdsa.PublicKey)
if !ok {
return nil, fmt.Errorf("updater: ecdsa public key has wrong type %T", pubAny)
}
return pub, nil
}
func splitECDSASig(sig []byte) (r, s *big.Int, err error) {
// Raw r||s, 64 bytes for P-256.
if len(sig) == 64 {
r = new(big.Int).SetBytes(sig[:32])
s = new(big.Int).SetBytes(sig[32:])
return r, s, nil
}
// ASN.1 DER fallback. Reject signatures with trailing data — accepting
// them is a path to signature-malleability bugs and they are never
// produced by conforming signers.
var seq struct{ R, S *big.Int }
rest, err := asn1.Unmarshal(sig, &seq)
if err != nil {
return nil, nil, fmt.Errorf("updater: ecdsa signature format unrecognised: %w", err)
}
if len(rest) != 0 {
return nil, nil, errors.New("updater: ecdsa signature has trailing data")
}
if seq.R == nil || seq.S == nil {
return nil, nil, errors.New("updater: ecdsa signature missing r/s")
}
return seq.R, seq.S, nil
}

View File

@@ -0,0 +1,154 @@
package updater
import (
_ "embed"
"strings"
)
//go:embed assets/window.html
var defaultWindowHTML string
// BuiltinWindow customises the framework's default update window. Embed it
// in Config.Window when you want to override the HTML, layer extra CSS, or
// change the window chrome (frameless, size, etc.).
type BuiltinWindow struct {
// HTML, if non-empty, replaces the default template entirely. The
// replacement is expected to listen to `updater:*` events and emit
// `updater:user:*` events using the standard Wails runtime — exactly
// what the default template does.
HTML string
// CSS, if non-empty, is appended to the default template inside a final
// <style> tag. Ignored when HTML overrides the template entirely.
CSS string
// Options overrides the chrome of the built-in window. Zero values fall
// back to the framework's sensible defaults (small, centred, resizable).
Options WindowOptions
}
func (*BuiltinWindow) isWindowOption() {}
// windowNoneType is the concrete singleton type behind WindowNone. Declared
// as a named type so a user-supplied value can be checked with ==.
type windowNoneType struct{}
func (windowNoneType) isWindowOption() {}
// WindowNone selects headless mode: the Updater drives the flow but never
// asks the host to open a window. Subscribe to events from your own UI.
var WindowNone WindowOption = windowNoneType{}
// BYOWindow wraps a caller-owned WindowHandle (typically an
// *application.WebviewWindow created outside the updater) so it can be
// passed via Config.Window. The Updater drives Show / Close / EmitEvent on
// the wrapped handle instead of creating its own window.
//
// The WindowOption interface has an unexported marker method that prevents
// arbitrary types from being assigned to Config.Window directly; this
// constructor is the bridge — call it once at Init time:
//
// myWin := app.Window.NewWithOptions(application.WebviewWindowOptions{...})
// app.Updater.Init(updater.Config{
// Window: updater.BYOWindow(myWin),
// ...,
// })
func BYOWindow(w WindowHandle) WindowOption {
return &byoWindow{handle: w}
}
type byoWindow struct{ handle WindowHandle }
func (*byoWindow) isWindowOption() {}
// defaultBuiltinOptions returns the chrome the framework uses when the user
// doesn't override Options.
//
// The dimensions match the compact Checking / Up-to-Date / Error states so
// the window opens at its smallest natural size. If the Updater finds an
// available release (or downloads / installs / verifies one), transition()
// grows the window to availableWidth × availableHeight via WindowSizer.
// Opening small and *growing* feels like the window adapting to fit richer
// content; opening big and *shrinking* when the answer is "nothing to do"
// reads as a janky deflate after the window's already been shown — which
// is the artifact Lea flagged during interactive testing.
func defaultBuiltinOptions() WindowOptions {
return WindowOptions{
Title: "Software Update",
Width: upToDateWidth,
Height: upToDateHeight,
Frameless: false,
AlwaysOnTop: false,
DisableResize: false,
}
}
// composeHTML builds the HTML for the built-in window. Callers may supply a
// BuiltinWindow override; nil means "use defaults."
func composeHTML(bw *BuiltinWindow) string {
if bw != nil && bw.HTML != "" {
return bw.HTML
}
html := defaultWindowHTML
if bw != nil && bw.CSS != "" {
injected := "<style>" + bw.CSS + "</style>\n</head>"
if updated := strings.Replace(html, "</head>", injected, 1); updated != html {
html = updated
} else {
// No </head> (custom HTML?) — fall back to the document end.
html += "\n<style>" + bw.CSS + "</style>"
}
}
return html
}
// composeWindowOptions returns the WindowOptions the Updater asks its host
// to open the built-in window with. Caller-supplied overrides on a
// BuiltinWindow take precedence over framework defaults, but framework
// defaults fill any zero values.
func composeWindowOptions(bw *BuiltinWindow) WindowOptions {
opts := defaultBuiltinOptions()
if bw == nil {
return opts
}
o := bw.Options
if o.Title != "" {
opts.Title = o.Title
}
if o.Width > 0 {
opts.Width = o.Width
}
if o.Height > 0 {
opts.Height = o.Height
}
opts.Frameless = o.Frameless
opts.AlwaysOnTop = o.AlwaysOnTop
opts.DisableResize = o.DisableResize
return opts
}
// classifyWindowOption walks a user-supplied Config.Window value and returns
// a small enum describing the runtime mode + any concrete BuiltinWindow
// configuration. Falls back to "builtin defaults" for nil input.
func classifyWindowOption(opt WindowOption) (mode windowMode, bw *BuiltinWindow, byo WindowHandle) {
if opt == nil {
return windowModeBuiltin, nil, nil
}
switch v := opt.(type) {
case *BuiltinWindow:
return windowModeBuiltin, v, nil
case windowNoneType:
return windowModeNone, nil, nil
case *byoWindow:
return windowModeBYO, nil, v.handle
}
return windowModeBuiltin, nil, nil
}
type windowMode int
const (
windowModeBuiltin windowMode = iota
windowModeBYO
windowModeNone
)

View File

@@ -0,0 +1,187 @@
package updater
import "context"
// User-action event names emitted by the default window and any custom
// template that follows the same contract. The Updater listens to these to
// drive the flow without ever calling into the window directly.
const (
EventWindowReady = "wails:updater:window:ready"
EventUserInstall = "wails:updater:user:install"
EventUserSkip = "wails:updater:user:skip"
EventUserRemind = "wails:updater:user:remind"
EventUserCancel = "wails:updater:user:cancel"
EventUserRestart = "wails:updater:user:restart"
)
// windowSession captures the window the Updater opened for the current
// CheckAndInstall flow, plus all the event-subscription cancel funcs that
// need to be invoked when the session ends.
type windowSession struct {
mode windowMode
handle WindowHandle
cancel []func()
}
func (s *windowSession) close() {
if s == nil {
return
}
for _, c := range s.cancel {
if c != nil {
c()
}
}
s.cancel = nil
if s.handle != nil {
s.handle.Close()
s.handle = nil
}
}
// openSession opens (or attaches to) a window for the current flow and
// wires up the user-action listeners. The returned session must be Closed
// when the flow ends (success, error, or user dismiss).
func (u *Updater) openSession(ctx context.Context) *windowSession {
u.mu.RLock()
cfg := u.cfg
u.mu.RUnlock()
if cfg == nil {
return &windowSession{mode: windowModeNone}
}
mode, bw, byo := classifyWindowOption(cfg.Window)
sess := &windowSession{mode: mode}
switch mode {
case windowModeNone:
// No window. Listeners still wire up so user-action events fired
// from a custom-built UI continue to work.
case windowModeBYO:
sess.handle = byo
// User-managed window — we don't show/hide it. We do load the same
// default HTML so the bindings work out of the box, but only when
// the user has not already populated it themselves. We can't detect
// that, so we leave the contents alone.
case windowModeBuiltin:
opts := composeWindowOptions(bw)
opts.InitialHTML = composeHTML(bw)
sess.handle = u.host.OpenWindow(opts)
}
sess.cancel = append(sess.cancel,
u.host.OnEvent(EventUserInstall, func(any) {
go func() { _ = u.DownloadAndInstall(ctx) }()
}),
u.host.OnEvent(EventUserCancel, func(any) {
u.closeWindow()
}),
u.host.OnEvent(EventUserSkip, func(any) {
u.handleSkip()
}),
u.host.OnEvent(EventUserRemind, func(any) {
u.closeWindow()
}),
u.host.OnEvent(EventUserRestart, func(any) {
go func() { _ = u.Restart(ctx) }()
}),
// The default window template fires updater:window:ready on load to
// rehydrate from current state — e.g. when a periodic-check timer
// already advanced the flow before the window opened. Re-emit the
// state-appropriate lifecycle event so the same handlers that drive
// live updates also drive initial paint.
u.host.OnEvent(EventWindowReady, func(any) {
u.replayStateSnapshot()
}),
)
return sess
}
// replayStateSnapshot re-emits the lifecycle event corresponding to the
// current state. The default window subscribes to these events for normal
// updates; replaying the latest one whenever the window asks for a snapshot
// is enough for it to render correctly on (re)open.
//
// EventMeta is emitted first so the page has the host-side context
// (currentVersion, skipped version) before the state-specific event lands —
// the default template's renderSubtitle uses currentVersion to draw the
// "from" version in the Update Available pill and the "v1.2.3 · This is
// the latest version" pill in the Up-to-Date state.
func (u *Updater) replayStateSnapshot() {
u.mu.RLock()
state := u.state
pending := u.pending
currentVersion := ""
if u.cfg != nil {
currentVersion = u.cfg.CurrentVersion
}
skipped := u.skipped
u.mu.RUnlock()
u.host.Emit(EventMeta, Meta{
CurrentVersion: currentVersion,
SkippedVersion: skipped,
})
switch state {
case StateChecking:
u.host.Emit(EventCheckStarted)
case StateAvailable:
u.host.Emit(EventUpdateAvailable, pending)
case StateDownloading:
u.host.Emit(EventDownloadStarted, pending)
case StateVerifying:
u.host.Emit(EventVerifying, pending)
case StateInstalling:
u.host.Emit(EventInstalling, pending)
case StateReady:
u.host.Emit(EventUpdateReady, pending)
case StateUpToDate:
u.host.Emit(EventNoUpdate)
}
// StateIdle / StateUnconfigured / StateError: nothing useful to replay.
}
func (u *Updater) closeWindow() {
u.sessMu.Lock()
sess := u.session
u.session = nil
u.sessMu.Unlock()
sess.close()
}
func (u *Updater) handleSkip() {
u.mu.Lock()
if u.cfg != nil && u.pending != nil {
u.skipped = u.pending.Version
}
u.mu.Unlock()
u.closeWindow()
}
// SkipVersion records the supplied version as skipped — subsequent Checks
// will treat that version as "no update." Used by the default window's Skip
// This Version button and by callers driving headless flows.
func (u *Updater) SkipVersion(v string) {
u.mu.Lock()
defer u.mu.Unlock()
u.skipped = v
}
// SkippedVersion returns the currently-skipped version, if any.
func (u *Updater) SkippedVersion() string {
u.mu.RLock()
defer u.mu.RUnlock()
return u.skipped
}
// shouldSkip returns whether the supplied release should be ignored because
// its version has been marked skipped.
func (u *Updater) shouldSkip(version string) bool {
if version == "" {
return false
}
u.mu.RLock()
defer u.mu.RUnlock()
return u.skipped != "" && u.skipped == version
}