fix: add involves edge from task to agent:nomos at creation
Plus sync vendor directory for Docker build compatibility.
This commit is contained in:
706
vendor/github.com/wailsapp/wails/v3/pkg/updater/assets/window.html
generated
vendored
Normal file
706
vendor/github.com/wailsapp/wails/v3/pkg/updater/assets/window.html
generated
vendored
Normal 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 & 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
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>
|
||||
99
vendor/github.com/wailsapp/wails/v3/pkg/updater/config.go
generated
vendored
Normal file
99
vendor/github.com/wailsapp/wails/v3/pkg/updater/config.go
generated
vendored
Normal 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
21
vendor/github.com/wailsapp/wails/v3/pkg/updater/doc.go
generated
vendored
Normal 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
|
||||
202
vendor/github.com/wailsapp/wails/v3/pkg/updater/download.go
generated
vendored
Normal file
202
vendor/github.com/wailsapp/wails/v3/pkg/updater/download.go
generated
vendored
Normal 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
|
||||
}
|
||||
55
vendor/github.com/wailsapp/wails/v3/pkg/updater/events.go
generated
vendored
Normal file
55
vendor/github.com/wailsapp/wails/v3/pkg/updater/events.go
generated
vendored
Normal 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"`
|
||||
}
|
||||
402
vendor/github.com/wailsapp/wails/v3/pkg/updater/extract.go
generated
vendored
Normal file
402
vendor/github.com/wailsapp/wails/v3/pkg/updater/extract.go
generated
vendored
Normal 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
|
||||
}
|
||||
362
vendor/github.com/wailsapp/wails/v3/pkg/updater/helper.go
generated
vendored
Normal file
362
vendor/github.com/wailsapp/wails/v3/pkg/updater/helper.go
generated
vendored
Normal 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)")
|
||||
)
|
||||
31
vendor/github.com/wailsapp/wails/v3/pkg/updater/helper_unix.go
generated
vendored
Normal file
31
vendor/github.com/wailsapp/wails/v3/pkg/updater/helper_unix.go
generated
vendored
Normal 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)
|
||||
}
|
||||
167
vendor/github.com/wailsapp/wails/v3/pkg/updater/helper_windows.go
generated
vendored
Normal file
167
vendor/github.com/wailsapp/wails/v3/pkg/updater/helper_windows.go
generated
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
28
vendor/github.com/wailsapp/wails/v3/pkg/updater/spawn.go
generated
vendored
Normal file
28
vendor/github.com/wailsapp/wails/v3/pkg/updater/spawn.go
generated
vendored
Normal 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
|
||||
}
|
||||
17
vendor/github.com/wailsapp/wails/v3/pkg/updater/spawn_unix.go
generated
vendored
Normal file
17
vendor/github.com/wailsapp/wails/v3/pkg/updater/spawn_unix.go
generated
vendored
Normal 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
|
||||
}
|
||||
23
vendor/github.com/wailsapp/wails/v3/pkg/updater/spawn_windows.go
generated
vendored
Normal file
23
vendor/github.com/wailsapp/wails/v3/pkg/updater/spawn_windows.go
generated
vendored
Normal 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
|
||||
}
|
||||
121
vendor/github.com/wailsapp/wails/v3/pkg/updater/types.go
generated
vendored
Normal file
121
vendor/github.com/wailsapp/wails/v3/pkg/updater/types.go
generated
vendored
Normal 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
|
||||
}
|
||||
558
vendor/github.com/wailsapp/wails/v3/pkg/updater/updater.go
generated
vendored
Normal file
558
vendor/github.com/wailsapp/wails/v3/pkg/updater/updater.go
generated
vendored
Normal 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")
|
||||
)
|
||||
21
vendor/github.com/wailsapp/wails/v3/pkg/updater/updater_darwin.go
generated
vendored
Normal file
21
vendor/github.com/wailsapp/wails/v3/pkg/updater/updater_darwin.go
generated
vendored
Normal 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
|
||||
}
|
||||
5
vendor/github.com/wailsapp/wails/v3/pkg/updater/updater_notdarwin.go
generated
vendored
Normal file
5
vendor/github.com/wailsapp/wails/v3/pkg/updater/updater_notdarwin.go
generated
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
//go:build !darwin || ios
|
||||
|
||||
package updater
|
||||
|
||||
func bundleTarget(exe string) string { return exe }
|
||||
224
vendor/github.com/wailsapp/wails/v3/pkg/updater/verify.go
generated
vendored
Normal file
224
vendor/github.com/wailsapp/wails/v3/pkg/updater/verify.go
generated
vendored
Normal 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
|
||||
}
|
||||
154
vendor/github.com/wailsapp/wails/v3/pkg/updater/window.go
generated
vendored
Normal file
154
vendor/github.com/wailsapp/wails/v3/pkg/updater/window.go
generated
vendored
Normal 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
|
||||
)
|
||||
187
vendor/github.com/wailsapp/wails/v3/pkg/updater/window_lifecycle.go
generated
vendored
Normal file
187
vendor/github.com/wailsapp/wails/v3/pkg/updater/window_lifecycle.go
generated
vendored
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user