| 1 |
|
- |
/**
|
| 2 |
|
- |
* Shared Tauri OTA Update UI
|
| 3 |
|
- |
*
|
| 4 |
|
- |
* Listens for update-available events from the Rust backend and shows
|
| 5 |
|
- |
* a notification banner with Install / Dismiss buttons. The user must
|
| 6 |
|
- |
* explicitly consent before any download or installation happens.
|
| 7 |
|
- |
*
|
| 8 |
|
- |
* Usage:
|
| 9 |
|
- |
* initUpdater({ ui, escapeHtml, namespace })
|
| 10 |
|
- |
*
|
| 11 |
|
- |
* - ui: object with showToast(msg, level?) method
|
| 12 |
|
- |
* - escapeHtml: function(str) -> safe string (optional, falls back to identity)
|
| 13 |
|
- |
* - namespace: object to attach { showUpdateBanner } onto (e.g. GoingsOn.updater)
|
| 14 |
|
- |
* - bannerClass: CSS class name for the banner (optional, uses inline styles if omitted)
|
| 15 |
|
- |
* - cssVars: override CSS variable names (optional)
|
| 16 |
|
- |
* - renderMarkdown: function(body) -> safe HTML string (optional). When supplied,
|
| 17 |
|
- |
* release notes render as HTML and the 120-char preview truncation is dropped.
|
| 18 |
|
- |
* The caller is responsible for sanitizing the output.
|
| 19 |
|
- |
*/
|
| 20 |
|
- |
|
| 21 |
|
- |
// eslint-disable-next-line no-unused-vars
|
| 22 |
|
- |
function initUpdater(opts) {
|
| 23 |
|
- |
'use strict';
|
| 24 |
|
- |
|
| 25 |
|
- |
const ui = opts.ui;
|
| 26 |
|
- |
const esc = opts.escapeHtml || ((s) => s);
|
| 27 |
|
- |
const vars = Object.assign({
|
| 28 |
|
- |
accent: 'var(--action)',
|
| 29 |
|
- |
border: 'var(--border)',
|
| 30 |
|
- |
textSecondary: 'var(--content-secondary)',
|
| 31 |
|
- |
bgSecondary: 'var(--surface-overlay)',
|
| 32 |
|
- |
fontBody: 'var(--font-body, sans-serif)',
|
| 33 |
|
- |
}, opts.cssVars || {});
|
| 34 |
|
- |
|
| 35 |
|
- |
let updateBannerShown = false;
|
| 36 |
|
- |
let pendingUpdate = null;
|
| 37 |
|
- |
|
| 38 |
|
- |
const BTN_STYLE = [
|
| 39 |
|
- |
'padding: 0.25rem 0.5rem',
|
| 40 |
|
- |
'border-radius: 4px',
|
| 41 |
|
- |
'cursor: pointer',
|
| 42 |
|
- |
'font-size: 0.8rem',
|
| 43 |
|
- |
].join(';');
|
| 44 |
|
- |
|
| 45 |
|
- |
function showUpdateBanner(version, body, update) {
|
| 46 |
|
- |
if (updateBannerShown) return;
|
| 47 |
|
- |
updateBannerShown = true;
|
| 48 |
|
- |
pendingUpdate = update || null;
|
| 49 |
|
- |
|
| 50 |
|
- |
const banner = document.createElement('div');
|
| 51 |
|
- |
banner.id = 'update-banner';
|
| 52 |
|
- |
|
| 53 |
|
- |
if (opts.bannerClass) {
|
| 54 |
|
- |
banner.className = opts.bannerClass;
|
| 55 |
|
- |
} else {
|
| 56 |
|
- |
banner.style.cssText = [
|
| 57 |
|
- |
'position: fixed',
|
| 58 |
|
- |
'bottom: 1rem',
|
| 59 |
|
- |
'right: 1rem',
|
| 60 |
|
- |
'background: ' + vars.bgSecondary,
|
| 61 |
|
- |
'border: 1px solid ' + vars.border,
|
| 62 |
|
- |
'border-radius: 8px',
|
| 63 |
|
- |
'padding: 0.75rem 1rem',
|
| 64 |
|
- |
'z-index: 9999',
|
| 65 |
|
- |
'max-width: 320px',
|
| 66 |
|
- |
'box-shadow: 0 4px 12px rgba(0,0,0,0.3)',
|
| 67 |
|
- |
'font-family: ' + vars.fontBody,
|
| 68 |
|
- |
'font-size: 0.875rem',
|
| 69 |
|
- |
].join(';');
|
| 70 |
|
- |
}
|
| 71 |
|
- |
|
| 72 |
|
- |
const title = document.createElement('div');
|
| 73 |
|
- |
title.style.cssText = 'font-weight: 600; margin-bottom: 0.25rem;';
|
| 74 |
|
- |
title.textContent = 'Update Available: v' + esc(version);
|
| 75 |
|
- |
banner.appendChild(title);
|
| 76 |
|
- |
|
| 77 |
|
- |
if (body) {
|
| 78 |
|
- |
const notes = document.createElement('div');
|
| 79 |
|
- |
notes.style.cssText = 'color: ' + vars.textSecondary + '; margin-bottom: 0.5rem; max-height: 8rem; overflow-y: auto;';
|
| 80 |
|
- |
if (typeof opts.renderMarkdown === 'function') {
|
| 81 |
|
- |
notes.innerHTML = opts.renderMarkdown(body);
|
| 82 |
|
- |
} else {
|
| 83 |
|
- |
notes.textContent = body.length > 120 ? body.substring(0, 120) + '...' : body;
|
| 84 |
|
- |
}
|
| 85 |
|
- |
banner.appendChild(notes);
|
| 86 |
|
- |
}
|
| 87 |
|
- |
|
| 88 |
|
- |
const buttons = document.createElement('div');
|
| 89 |
|
- |
buttons.style.cssText = 'display: flex; gap: 0.5rem; margin-top: 0.5rem;';
|
| 90 |
|
- |
|
| 91 |
|
- |
const install = document.createElement('button');
|
| 92 |
|
- |
install.textContent = 'Install & Restart';
|
| 93 |
|
- |
install.style.cssText = BTN_STYLE + ';background: ' + vars.accent + '; border: none; color: #fff; font-weight: 600;';
|
| 94 |
|
- |
install.onclick = () => installUpdate(banner);
|
| 95 |
|
- |
buttons.appendChild(install);
|
| 96 |
|
- |
|
| 97 |
|
- |
const dismiss = document.createElement('button');
|
| 98 |
|
- |
dismiss.textContent = 'Not Now';
|
| 99 |
|
- |
dismiss.style.cssText = BTN_STYLE + ';background: none; border: 1px solid ' + vars.border + '; color: ' + vars.textSecondary + ';';
|
| 100 |
|
- |
dismiss.onclick = () => {
|
| 101 |
|
- |
banner.remove();
|
| 102 |
|
- |
updateBannerShown = false;
|
| 103 |
|
- |
pendingUpdate = null;
|
| 104 |
|
- |
};
|
| 105 |
|
- |
buttons.appendChild(dismiss);
|
| 106 |
|
- |
|
| 107 |
|
- |
banner.appendChild(buttons);
|
| 108 |
|
- |
document.body.appendChild(banner);
|
| 109 |
|
- |
}
|
| 110 |
|
- |
|
| 111 |
|
- |
async function installUpdate(banner) {
|
| 112 |
|
- |
if (!pendingUpdate) {
|
| 113 |
|
- |
try {
|
| 114 |
|
- |
const { check } = window.__TAURI_PLUGIN_UPDATER__;
|
| 115 |
|
- |
pendingUpdate = await check();
|
| 116 |
|
- |
} catch (err) {
|
| 117 |
|
- |
ui.showToast('Update check failed: ' + err, 'error');
|
| 118 |
|
- |
return;
|
| 119 |
|
- |
}
|
| 120 |
|
- |
}
|
| 121 |
|
- |
|
| 122 |
|
- |
if (!pendingUpdate) {
|
| 123 |
|
- |
ui.showToast('No update available.', 'error');
|
| 124 |
|
- |
return;
|
| 125 |
|
- |
}
|
| 126 |
|
- |
|
| 127 |
|
- |
banner.innerHTML = '';
|
| 128 |
|
- |
const status = document.createElement('div');
|
| 129 |
|
- |
status.textContent = 'Starting download...';
|
| 130 |
|
- |
status.style.cssText = 'margin-bottom: 0.5rem;';
|
| 131 |
|
- |
banner.appendChild(status);
|
| 132 |
|
- |
|
| 133 |
|
- |
const progressWrap = document.createElement('div');
|
| 134 |
|
- |
progressWrap.style.cssText = 'height: 4px; background: ' + vars.border + '; border-radius: 2px; overflow: hidden;';
|
| 135 |
|
- |
const progressBar = document.createElement('div');
|
| 136 |
|
- |
progressBar.style.cssText = 'height: 100%; width: 0%; background: ' + vars.accent + '; transition: width 120ms linear;';
|
| 137 |
|
- |
progressWrap.appendChild(progressBar);
|
| 138 |
|
- |
banner.appendChild(progressWrap);
|
| 139 |
|
- |
|
| 140 |
|
- |
let total = 0;
|
| 141 |
|
- |
let received = 0;
|
| 142 |
|
- |
const onEvent = (e) => {
|
| 143 |
|
- |
if (!e || !e.event) return;
|
| 144 |
|
- |
if (e.event === 'Started') {
|
| 145 |
|
- |
total = (e.data && e.data.contentLength) || 0;
|
| 146 |
|
- |
received = 0;
|
| 147 |
|
- |
status.textContent = total > 0
|
| 148 |
|
- |
? 'Downloading update (0%)...'
|
| 149 |
|
- |
: 'Downloading update...';
|
| 150 |
|
- |
} else if (e.event === 'Progress') {
|
| 151 |
|
- |
received += (e.data && e.data.chunkLength) || 0;
|
| 152 |
|
- |
if (total > 0) {
|
| 153 |
|
- |
const pct = Math.min(100, Math.round((received / total) * 100));
|
| 154 |
|
- |
progressBar.style.width = pct + '%';
|
| 155 |
|
- |
status.textContent = 'Downloading update (' + pct + '%)...';
|
| 156 |
|
- |
}
|
| 157 |
|
- |
} else if (e.event === 'Finished') {
|
| 158 |
|
- |
progressBar.style.width = '100%';
|
| 159 |
|
- |
status.textContent = 'Installing...';
|
| 160 |
|
- |
}
|
| 161 |
|
- |
};
|
| 162 |
|
- |
|
| 163 |
|
- |
try {
|
| 164 |
|
- |
await pendingUpdate.downloadAndInstall(onEvent);
|
| 165 |
|
- |
status.textContent = 'Update installed. Restarting...';
|
| 166 |
|
- |
// Relaunch into the new version. Best-effort: requires the host app
|
| 167 |
|
- |
// to register the process plugin; if it's absent the install still
|
| 168 |
|
- |
// succeeded and the user can restart manually.
|
| 169 |
|
- |
try {
|
| 170 |
|
- |
await window.__TAURI__.process.relaunch();
|
| 171 |
|
- |
} catch (e) {
|
| 172 |
|
- |
status.textContent = 'Update installed. Please quit and reopen the app.';
|
| 173 |
|
- |
}
|
| 174 |
|
- |
} catch (err) {
|
| 175 |
|
- |
status.textContent = 'Update failed: ' + err;
|
| 176 |
|
- |
ui.showToast('Update failed: ' + err, 'error');
|
| 177 |
|
- |
updateBannerShown = false;
|
| 178 |
|
- |
}
|
| 179 |
|
- |
}
|
| 180 |
|
- |
|
| 181 |
|
- |
// Listen for automatic update check results from Rust backend
|
| 182 |
|
- |
if (window.__TAURI__) {
|
| 183 |
|
- |
const { listen } = window.__TAURI__.event;
|
| 184 |
|
- |
|
| 185 |
|
- |
listen('update-available', (event) => {
|
| 186 |
|
- |
const { version, body } = event.payload;
|
| 187 |
|
- |
showUpdateBanner(version, body, null);
|
| 188 |
|
- |
});
|
| 189 |
|
- |
|
| 190 |
|
- |
listen('menu:check_updates', async () => {
|
| 191 |
|
- |
ui.showToast('Checking for updates...');
|
| 192 |
|
- |
try {
|
| 193 |
|
- |
const { check } = window.__TAURI_PLUGIN_UPDATER__;
|
| 194 |
|
- |
const update = await check();
|
| 195 |
|
- |
if (update) {
|
| 196 |
|
- |
showUpdateBanner(update.version, update.body || '', update);
|
| 197 |
|
- |
} else {
|
| 198 |
|
- |
ui.showToast('You are running the latest version.');
|
| 199 |
|
- |
}
|
| 200 |
|
- |
} catch (err) {
|
| 201 |
|
- |
ui.showToast('Update check failed: ' + err, 'error');
|
| 202 |
|
- |
}
|
| 203 |
|
- |
});
|
| 204 |
|
- |
}
|
| 205 |
|
- |
|
| 206 |
|
- |
// Attach to caller's namespace
|
| 207 |
|
- |
if (opts.namespace) {
|
| 208 |
|
- |
opts.namespace.showUpdateBanner = showUpdateBanner;
|
| 209 |
|
- |
}
|
| 210 |
|
- |
|
| 211 |
|
- |
return { showUpdateBanner };
|
| 212 |
|
- |
}
|