/** * Shared Tauri OTA Update UI * * Listens for update-available events from the Rust backend and shows * a notification banner with Install / Dismiss buttons. The user must * explicitly consent before any download or installation happens. * * Usage: * initUpdater({ ui, escapeHtml, namespace }) * * - ui: object with showToast(msg, level?) method * - escapeHtml: function(str) -> safe string (optional, falls back to identity) * - namespace: object to attach { showUpdateBanner } onto (e.g. GoingsOn.updater) * - bannerClass: CSS class name for the banner (optional, uses inline styles if omitted) * - cssVars: override CSS variable names (optional) * - renderMarkdown: function(body) -> safe HTML string (optional). When supplied, * release notes render as HTML and the 120-char preview truncation is dropped. * The caller is responsible for sanitizing the output. */ // eslint-disable-next-line no-unused-vars function initUpdater(opts) { 'use strict'; const ui = opts.ui; const esc = opts.escapeHtml || ((s) => s); const vars = Object.assign({ accent: 'var(--accent-color, #6c5ce7)', border: 'var(--border-color, #444)', textSecondary: 'var(--text-secondary, #aaa)', bgSecondary: 'var(--bg-secondary, #2d2d2d)', fontBody: 'var(--font-body, sans-serif)', }, opts.cssVars || {}); let updateBannerShown = false; let pendingUpdate = null; const BTN_STYLE = [ 'padding: 0.25rem 0.5rem', 'border-radius: 4px', 'cursor: pointer', 'font-size: 0.8rem', ].join(';'); function showUpdateBanner(version, body, update) { if (updateBannerShown) return; updateBannerShown = true; pendingUpdate = update || null; const banner = document.createElement('div'); banner.id = 'update-banner'; if (opts.bannerClass) { banner.className = opts.bannerClass; } else { banner.style.cssText = [ 'position: fixed', 'bottom: 1rem', 'right: 1rem', 'background: ' + vars.bgSecondary, 'border: 1px solid ' + vars.border, 'border-radius: 8px', 'padding: 0.75rem 1rem', 'z-index: 9999', 'max-width: 320px', 'box-shadow: 0 4px 12px rgba(0,0,0,0.3)', 'font-family: ' + vars.fontBody, 'font-size: 0.875rem', ].join(';'); } const title = document.createElement('div'); title.style.cssText = 'font-weight: 600; margin-bottom: 0.25rem;'; title.textContent = 'Update Available: v' + esc(version); banner.appendChild(title); if (body) { const notes = document.createElement('div'); notes.style.cssText = 'color: ' + vars.textSecondary + '; margin-bottom: 0.5rem; max-height: 8rem; overflow-y: auto;'; if (typeof opts.renderMarkdown === 'function') { notes.innerHTML = opts.renderMarkdown(body); } else { notes.textContent = body.length > 120 ? body.substring(0, 120) + '...' : body; } banner.appendChild(notes); } const buttons = document.createElement('div'); buttons.style.cssText = 'display: flex; gap: 0.5rem; margin-top: 0.5rem;'; const install = document.createElement('button'); install.textContent = 'Install & Restart'; install.style.cssText = BTN_STYLE + ';background: ' + vars.accent + '; border: none; color: #fff; font-weight: 600;'; install.onclick = () => installUpdate(banner); buttons.appendChild(install); const dismiss = document.createElement('button'); dismiss.textContent = 'Not Now'; dismiss.style.cssText = BTN_STYLE + ';background: none; border: 1px solid ' + vars.border + '; color: ' + vars.textSecondary + ';'; dismiss.onclick = () => { banner.remove(); updateBannerShown = false; pendingUpdate = null; }; buttons.appendChild(dismiss); banner.appendChild(buttons); document.body.appendChild(banner); } async function installUpdate(banner) { if (!pendingUpdate) { try { const { check } = window.__TAURI_PLUGIN_UPDATER__; pendingUpdate = await check(); } catch (err) { ui.showToast('Update check failed: ' + err, 'error'); return; } } if (!pendingUpdate) { ui.showToast('No update available.', 'error'); return; } banner.innerHTML = ''; const status = document.createElement('div'); status.textContent = 'Starting download...'; status.style.cssText = 'margin-bottom: 0.5rem;'; banner.appendChild(status); const progressWrap = document.createElement('div'); progressWrap.style.cssText = 'height: 4px; background: ' + vars.border + '; border-radius: 2px; overflow: hidden;'; const progressBar = document.createElement('div'); progressBar.style.cssText = 'height: 100%; width: 0%; background: ' + vars.accent + '; transition: width 120ms linear;'; progressWrap.appendChild(progressBar); banner.appendChild(progressWrap); let total = 0; let received = 0; const onEvent = (e) => { if (!e || !e.event) return; if (e.event === 'Started') { total = (e.data && e.data.contentLength) || 0; received = 0; status.textContent = total > 0 ? 'Downloading update (0%)...' : 'Downloading update...'; } else if (e.event === 'Progress') { received += (e.data && e.data.chunkLength) || 0; if (total > 0) { const pct = Math.min(100, Math.round((received / total) * 100)); progressBar.style.width = pct + '%'; status.textContent = 'Downloading update (' + pct + '%)...'; } } else if (e.event === 'Finished') { progressBar.style.width = '100%'; status.textContent = 'Installing...'; } }; try { await pendingUpdate.downloadAndInstall(onEvent); status.textContent = 'Update installed. Restarting...'; // Relaunch into the new version. Best-effort: requires the host app // to register the process plugin; if it's absent the install still // succeeded and the user can restart manually. try { await window.__TAURI__.process.relaunch(); } catch (e) { status.textContent = 'Update installed. Please quit and reopen the app.'; } } catch (err) { status.textContent = 'Update failed: ' + err; ui.showToast('Update failed: ' + err, 'error'); updateBannerShown = false; } } // Listen for automatic update check results from Rust backend if (window.__TAURI__) { const { listen } = window.__TAURI__.event; listen('update-available', (event) => { const { version, body } = event.payload; showUpdateBanner(version, body, null); }); listen('menu:check_updates', async () => { ui.showToast('Checking for updates...'); try { const { check } = window.__TAURI_PLUGIN_UPDATER__; const update = await check(); if (update) { showUpdateBanner(update.version, update.body || '', update); } else { ui.showToast('You are running the latest version.'); } } catch (err) { ui.showToast('Update check failed: ' + err, 'error'); } }); } // Attach to caller's namespace if (opts.namespace) { opts.namespace.showUpdateBanner = showUpdateBanner; } return { showUpdateBanner }; }