Skip to main content

max / makenotwork

8.0 KB · 213 lines History Blame Raw
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(--accent-color, #6c5ce7)',
29 border: 'var(--border-color, #444)',
30 textSecondary: 'var(--text-secondary, #aaa)',
31 bgSecondary: 'var(--bg-secondary, #2d2d2d)',
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 }
213