privateboard 0.1.23 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2413,61 +2413,29 @@
2413
2413
  * route · short label rendered as the right-edge pill. */
2414
2414
  function pickerEntries() {
2415
2415
  const cache = modelsSnapshot();
2416
- const out = [];
2416
+ // Multi-SIM credential model · the user has exactly one active
2417
+ // LLM provider at a time, so each reachable model maps to one
2418
+ // pickable row (no carrier fork). `cache.reachable` is filtered
2419
+ // server-side based on `prefs.active_llm_credential_id`, so the
2420
+ // picker naturally collapses to the active provider's family.
2417
2421
  if (cache && Array.isArray(cache.reachable) && cache.reachable.length > 0) {
2418
- for (const m of cache.reachable) {
2419
- const directOk = !!(m.routes && m.routes.direct);
2420
- const orOk = !!(m.routes && m.routes.openrouter);
2421
- const baiOk = !!(m.routes && m.routes.bai);
2422
- const provider = providerLabel(m.provider);
2423
- // For each carrier that can serve this model, emit a separate
2424
- // pickable row · users can pin EITHER carrier explicitly. Order
2425
- // mirrors the adapter's default precedence so the natural top
2426
- // pick when the user just clicks "the model" is the same one
2427
- // the orchestrator would resolve unpinned. When only ONE
2428
- // carrier serves the model, we collapse to a single row with
2429
- // `carrier: null` so the agent uses default routing (and
2430
- // automatically follows the carrier if the user later
2431
- // reconfigures keys).
2432
- const reachableCarriers = [];
2433
- if (directOk) reachableCarriers.push({ carrier: m.provider, route: "via " + provider + " direct" });
2434
- if (baiOk) reachableCarriers.push({ carrier: "bai", route: "via B.AI" });
2435
- if (orOk) reachableCarriers.push({ carrier: "openrouter", route: "via OpenRouter" });
2436
- if (reachableCarriers.length === 0) continue;
2437
- if (reachableCarriers.length === 1) {
2438
- // Only one carrier serves this model · use the bare modelV
2439
- // id and `carrier: null` so the saved agent record uses
2440
- // default routing rather than a sticky pin to a carrier the
2441
- // user might later swap.
2442
- const only = reachableCarriers[0];
2443
- out.push({
2444
- id: m.modelV,
2445
- v: m.modelV,
2446
- carrier: null,
2447
- name: m.displayName,
2448
- provider,
2449
- deck: m.deck || "",
2450
- route: only.carrier === m.provider ? "direct" : only.route,
2451
- });
2452
- continue;
2453
- }
2454
- // Multi-carrier · one row per carrier · composite id
2455
- // `${modelV}@${carrier}` distinguishes them in the picker.
2456
- for (const { carrier, route } of reachableCarriers) {
2457
- out.push({
2458
- id: m.modelV + "@" + carrier,
2459
- v: m.modelV,
2460
- carrier,
2461
- name: m.displayName,
2462
- provider,
2463
- deck: m.deck || "",
2464
- route,
2465
- });
2466
- }
2467
- }
2468
- if (out.length > 0) return out;
2422
+ return cache.reachable.map((m) => ({
2423
+ id: m.modelV,
2424
+ v: m.modelV,
2425
+ carrier: null,
2426
+ name: m.displayName,
2427
+ provider: providerLabel(m.provider),
2428
+ deck: m.deck || "",
2429
+ route: "",
2430
+ }));
2469
2431
  }
2470
- return PROFILE_MODELS.map((m) => ({ ...m, id: m.v, carrier: null, route: "" }));
2432
+ // Cache not yet loaded · return an empty list. The renderer will
2433
+ // show whatever stale info the agent's saved modelV resolves to
2434
+ // and re-fetch the cache. We deliberately AVOID falling back to
2435
+ // the hardcoded PROFILE_MODELS catalog here — that would surface
2436
+ // models the user's credential can't reach (e.g. showing Claude
2437
+ // when the active credential is OpenAI direct).
2438
+ return [];
2471
2439
  }
2472
2440
 
2473
2441
  /** Look up a single entry by composite id (`${v}@${carrier}` or
@@ -3092,7 +3060,14 @@
3092
3060
  * to the provider's billing/pricing page so the user is one click
3093
3061
  * from resolving it. */
3094
3062
  function openVoicePaidOverlay(opts) {
3095
- closeVoicePaidOverlay();
3063
+ // Idempotent · if an overlay is already showing, leave it alone.
3064
+ // Repeated billing-error events (each director's failed TTS in a
3065
+ // single round; replay + live-room hitting the same backend tag)
3066
+ // would otherwise tear down + rebuild the DOM on every call,
3067
+ // producing a visible "flash" as the panel reappears. The user
3068
+ // only needs to see the upgrade prompt ONCE — every subsequent
3069
+ // identical error is the same actionable item.
3070
+ if (document.getElementById("ap-voice-paid-overlay")) return;
3096
3071
  const provider = (opts && opts.provider) || "";
3097
3072
  const upgradeUrl = (opts && opts.upgradeUrl) || "";
3098
3073
  const message = (opts && opts.message) || "";
@@ -4759,6 +4734,11 @@
4759
4734
  * events from whichever copy is live. */
4760
4735
  window.AgentProfileVoice = {
4761
4736
  renderVoiceBlock,
4737
+ // Public hook so the room SSE handler + voice-replay can open
4738
+ // the same upgrade overlay when a TTS billing error fires
4739
+ // mid-room (insufficient balance / paid plan required) instead
4740
+ // of only when the user is on the agent-profile voice picker.
4741
+ openPaidOverlay: openVoicePaidOverlay,
4762
4742
  };
4763
4743
 
4764
4744
  document.addEventListener("boardroom:locale", () => {
@@ -0,0 +1,318 @@
1
+ /* ─────────────── App auto-update overlay ───────────────
2
+ Consent-driven update flow for the Electron build. Three
3
+ states: prompt (new version available) → downloading
4
+ (progress bar) → ready (restart). Wired from
5
+ public/app-updater.js against window.privateboard.updater.
6
+
7
+ Chrome (classification strip · lime corner brackets ·
8
+ topbar · dashed-rule foot) mirrors voice-onboarding.css
9
+ so the overlay reads as native to the rest of the app's
10
+ modal vocabulary. */
11
+
12
+ .upd-overlay {
13
+ position: fixed;
14
+ inset: 0;
15
+ background: rgba(0, 0, 0, 0.78);
16
+ -webkit-backdrop-filter: blur(4px);
17
+ backdrop-filter: blur(4px);
18
+ z-index: 9500;
19
+ display: none;
20
+ align-items: center;
21
+ justify-content: center;
22
+ padding: 24px;
23
+ overflow: hidden;
24
+ font-family: var(--mono, "Inter", system-ui, sans-serif);
25
+ }
26
+ .upd-overlay.open {
27
+ display: flex;
28
+ animation: upd-fade 0.14s ease-out;
29
+ }
30
+ @keyframes upd-fade { from { opacity: 0; } to { opacity: 1; } }
31
+
32
+ .upd-backdrop {
33
+ position: absolute;
34
+ inset: 0;
35
+ }
36
+
37
+ .upd-modal {
38
+ position: relative;
39
+ width: 100%;
40
+ max-width: 480px;
41
+ background: var(--panel);
42
+ border: 0.5px solid var(--line-strong);
43
+ color: var(--text);
44
+ animation: upd-rise 0.18s ease-out;
45
+ display: flex;
46
+ flex-direction: column;
47
+ min-height: 0;
48
+ }
49
+ @keyframes upd-rise {
50
+ from { transform: translateY(10px); opacity: 0; }
51
+ to { transform: translateY(0); opacity: 1; }
52
+ }
53
+ /* Lime corner brackets · same vocabulary as the vonb overlay. */
54
+ .upd-modal::before, .upd-modal::after {
55
+ content: "";
56
+ position: absolute;
57
+ width: 10px;
58
+ height: 10px;
59
+ border: 1.5px solid var(--lime);
60
+ pointer-events: none;
61
+ }
62
+ .upd-modal::before { top: -1px; left: -1px; border-right: none; border-bottom: none; }
63
+ .upd-modal::after { bottom: -1px; right: -1px; border-left: none; border-top: none; }
64
+
65
+ /* ─── Classification strip ─── */
66
+ .upd-classification {
67
+ background: var(--panel-2);
68
+ border-bottom: 0.5px solid var(--line-bright);
69
+ padding: 5px 14px;
70
+ font-size: 8px;
71
+ letter-spacing: 0.22em;
72
+ text-transform: uppercase;
73
+ color: var(--lime);
74
+ font-weight: 700;
75
+ display: flex;
76
+ justify-content: space-between;
77
+ align-items: center;
78
+ }
79
+ .upd-classification .dot { display: inline-block; margin-right: 4px; }
80
+ .upd-classification .right {
81
+ color: var(--text-faint);
82
+ letter-spacing: 0.12em;
83
+ }
84
+
85
+ /* ─── Topbar · meta + title + close ─── */
86
+ .upd-head {
87
+ display: grid;
88
+ grid-template-columns: 1fr auto;
89
+ gap: 12px;
90
+ align-items: start;
91
+ padding: 14px 16px 12px;
92
+ border-bottom: 0.5px dashed var(--line-bright);
93
+ }
94
+ .upd-head-text { min-width: 0; }
95
+ .upd-head .meta {
96
+ font-family: var(--mono);
97
+ font-size: 9px;
98
+ color: var(--text-dim);
99
+ text-transform: uppercase;
100
+ letter-spacing: 0.18em;
101
+ margin-bottom: 4px;
102
+ font-weight: 700;
103
+ display: flex;
104
+ gap: 6px;
105
+ align-items: center;
106
+ }
107
+ .upd-head .meta .live {
108
+ color: var(--lime);
109
+ font-weight: 700;
110
+ }
111
+ .upd-head .meta .live::before {
112
+ content: "● ";
113
+ animation: upd-pulse 1.6s ease-in-out infinite;
114
+ }
115
+ @keyframes upd-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } }
116
+ .upd-head .title {
117
+ font-size: 16px;
118
+ font-weight: 700;
119
+ color: var(--text);
120
+ letter-spacing: -0.01em;
121
+ line-height: 1.3;
122
+ font-family: var(--font-human, system-ui, sans-serif);
123
+ }
124
+ .upd-head .title::before {
125
+ content: "▸ ";
126
+ color: var(--lime);
127
+ font-family: var(--mono);
128
+ }
129
+ .upd-head .close-btn {
130
+ width: 24px; height: 24px;
131
+ background: transparent;
132
+ border: 0.5px solid var(--line-bright);
133
+ color: var(--text-dim);
134
+ font-size: 12px;
135
+ cursor: pointer;
136
+ font-family: var(--mono);
137
+ border-radius: 3px;
138
+ transition: color 0.12s, border-color 0.12s;
139
+ }
140
+ .upd-head .close-btn:hover {
141
+ border-color: var(--lime);
142
+ color: var(--lime);
143
+ }
144
+
145
+ /* ─── Body ─── */
146
+ .upd-body {
147
+ padding: 16px;
148
+ display: flex;
149
+ flex-direction: column;
150
+ gap: 14px;
151
+ }
152
+
153
+ /* Version block · "v0.1.22 → v0.1.23" treatment. */
154
+ .upd-version {
155
+ display: flex;
156
+ align-items: baseline;
157
+ gap: 10px;
158
+ font-family: var(--mono);
159
+ font-size: 12px;
160
+ color: var(--text-dim);
161
+ }
162
+ .upd-version .from { color: var(--text-faint); text-decoration: line-through; }
163
+ .upd-version .arrow { color: var(--lime); }
164
+ .upd-version .to {
165
+ color: var(--lime);
166
+ font-weight: 700;
167
+ letter-spacing: 0.02em;
168
+ font-size: 16px;
169
+ }
170
+
171
+ .upd-deck {
172
+ font-family: var(--font-human, system-ui, sans-serif);
173
+ font-size: 13px;
174
+ line-height: 1.55;
175
+ color: var(--text-soft);
176
+ margin: 0;
177
+ }
178
+
179
+ /* ─── Progress block (downloading state) ─── */
180
+ .upd-progress-card {
181
+ display: flex;
182
+ flex-direction: column;
183
+ gap: 10px;
184
+ padding: 14px;
185
+ border: 0.5px solid var(--line);
186
+ background: var(--panel-2);
187
+ }
188
+ .upd-progress-head {
189
+ display: flex;
190
+ align-items: baseline;
191
+ justify-content: space-between;
192
+ gap: 12px;
193
+ font-family: var(--mono);
194
+ }
195
+ .upd-progress-pct {
196
+ font-size: 22px;
197
+ font-weight: 700;
198
+ color: var(--lime);
199
+ letter-spacing: -0.01em;
200
+ }
201
+ .upd-progress-rate {
202
+ font-size: 10px;
203
+ letter-spacing: 0.14em;
204
+ text-transform: uppercase;
205
+ color: var(--text-faint);
206
+ }
207
+ .upd-progress-bar {
208
+ height: 4px;
209
+ background: var(--bg);
210
+ border: 0.5px solid var(--line);
211
+ overflow: hidden;
212
+ }
213
+ .upd-progress-bar > span {
214
+ display: block;
215
+ height: 100%;
216
+ background: var(--lime);
217
+ transition: width 0.3s ease-out;
218
+ width: 0%;
219
+ }
220
+ /* Indeterminate sweep · before the first download-progress event
221
+ lands (the .pkg is being negotiated). */
222
+ .upd-progress-bar.indeterminate > span {
223
+ width: 30%;
224
+ animation: upd-sweep 1.2s ease-in-out infinite;
225
+ }
226
+ @keyframes upd-sweep {
227
+ 0% { transform: translateX(-100%); }
228
+ 100% { transform: translateX(333%); }
229
+ }
230
+ .upd-progress-bytes {
231
+ font-family: var(--mono);
232
+ font-size: 10px;
233
+ color: var(--text-dim);
234
+ letter-spacing: 0.08em;
235
+ }
236
+
237
+ /* ─── Error block ─── */
238
+ .upd-error {
239
+ font-family: var(--mono);
240
+ font-size: 11px;
241
+ line-height: 1.5;
242
+ color: var(--red, #B5706A);
243
+ padding: 12px;
244
+ border: 0.5px solid var(--red, #B5706A);
245
+ background: rgba(181, 112, 106, 0.06);
246
+ }
247
+
248
+ /* ─── Foot · CTAs ─── */
249
+ .upd-foot {
250
+ padding: 12px 16px 16px;
251
+ display: flex;
252
+ justify-content: flex-end;
253
+ align-items: center;
254
+ gap: 10px;
255
+ border-top: 0.5px dashed var(--line-bright);
256
+ }
257
+ .upd-btn {
258
+ padding: 8px 18px;
259
+ background: transparent;
260
+ border: 0.5px solid var(--line-bright);
261
+ color: var(--text-soft);
262
+ font-family: var(--mono);
263
+ font-size: 11px;
264
+ letter-spacing: 0.14em;
265
+ text-transform: uppercase;
266
+ cursor: pointer;
267
+ transition: color 0.12s, border-color 0.12s, background 0.12s, filter 0.12s;
268
+ }
269
+ .upd-btn:hover {
270
+ border-color: var(--lime);
271
+ color: var(--lime);
272
+ }
273
+ .upd-btn.primary {
274
+ background: var(--lime);
275
+ border-color: var(--lime);
276
+ color: var(--bg);
277
+ font-weight: 700;
278
+ }
279
+ .upd-btn.primary:hover {
280
+ filter: brightness(1.06);
281
+ color: var(--bg);
282
+ }
283
+ .upd-btn[disabled] {
284
+ opacity: 0.4;
285
+ cursor: not-allowed;
286
+ }
287
+ .upd-btn[disabled]:hover {
288
+ border-color: var(--line-bright);
289
+ color: var(--text-soft);
290
+ filter: none;
291
+ }
292
+
293
+ /* State-driven visibility. The overlay carries one of
294
+ `state-available` / `state-downloading` / `state-ready` /
295
+ `state-error` and the modal swaps which subtree is shown. */
296
+ .upd-modal .upd-state { display: none; }
297
+ .upd-overlay.state-available .upd-modal .upd-state-available { display: flex; flex-direction: column; gap: 14px; }
298
+ .upd-overlay.state-downloading .upd-modal .upd-state-downloading { display: flex; flex-direction: column; gap: 14px; }
299
+ .upd-overlay.state-ready .upd-modal .upd-state-ready { display: flex; flex-direction: column; gap: 14px; }
300
+ .upd-overlay.state-error .upd-modal .upd-state-error { display: flex; flex-direction: column; gap: 14px; }
301
+
302
+ /* Buttons swap per state too. */
303
+ .upd-modal .upd-foot-state { display: none; }
304
+ .upd-overlay.state-available .upd-foot-state-available { display: contents; }
305
+ .upd-overlay.state-downloading .upd-foot-state-downloading { display: contents; }
306
+ .upd-overlay.state-ready .upd-foot-state-ready { display: contents; }
307
+ .upd-overlay.state-error .upd-foot-state-error { display: contents; }
308
+
309
+ /* Body scroll lock while overlay open. */
310
+ body.upd-locked {
311
+ overflow: hidden;
312
+ }
313
+
314
+ @media (max-width: 560px) {
315
+ .upd-overlay { padding: 14px; }
316
+ .upd-head { padding: 12px 14px; }
317
+ .upd-body { padding: 14px; }
318
+ }
@@ -0,0 +1,247 @@
1
+ /* ─────────────── App auto-update controller ───────────────
2
+ Renderer side of the Electron auto-updater flow. The
3
+ main process (electron/main.ts) pushes every state
4
+ transition over the `updater:state` IPC channel; the
5
+ preload bridge surfaces it as
6
+ `window.privateboard.updater.{onState,getState,…}`.
7
+
8
+ Lifecycle:
9
+ 1. On script load (browser fallback or pre-Electron build),
10
+ the IPC bridge is absent · we no-op.
11
+ 2. In Electron, we `getState()` to rehydrate (covers refresh
12
+ / devtools reload that lands after `update-available`
13
+ already fired) and subscribe via `onState`.
14
+ 3. On every non-idle state, the overlay opens (or stays
15
+ open) and paints the matching subtree. The user's
16
+ "Later"/"Hide" button only closes the modal — it does
17
+ NOT cancel the download; clicking the dock icon or
18
+ waiting for the next 4-hour re-check re-opens it.
19
+ */
20
+
21
+ (function () {
22
+ "use strict";
23
+
24
+ const bridge = (typeof window !== "undefined" && window.privateboard && window.privateboard.updater) || null;
25
+ if (!bridge) return; // Browser preview / non-Electron build · do nothing.
26
+
27
+ let overlayEl = null;
28
+ let lastState = null;
29
+ let userDismissed = false; // Cleared whenever a NEW state arrives.
30
+ let appVersion = ""; // Resolved once via window.privateboard.getAppVersion().
31
+
32
+ function $(sel, root) { return (root || document).querySelector(sel); }
33
+
34
+ function applyI18n() {
35
+ if (!overlayEl) return;
36
+ const I18n = window.I18n;
37
+ overlayEl.querySelectorAll("[data-i18n]").forEach((el) => {
38
+ const key = el.getAttribute("data-i18n");
39
+ if (!key) return;
40
+ let val = null;
41
+ if (I18n && typeof I18n.t === "function") {
42
+ val = I18n.t(key);
43
+ if (val === key) val = null;
44
+ }
45
+ if (val) el.textContent = val;
46
+ });
47
+ }
48
+
49
+ function fmtBytes(n) {
50
+ if (!Number.isFinite(n) || n <= 0) return "0 MB";
51
+ const mb = n / (1024 * 1024);
52
+ if (mb < 10) return mb.toFixed(1) + " MB";
53
+ return Math.round(mb) + " MB";
54
+ }
55
+ function fmtRate(bps) {
56
+ if (!Number.isFinite(bps) || bps <= 0) return "—";
57
+ const mb = bps / (1024 * 1024);
58
+ if (mb >= 1) return mb.toFixed(1) + " MB/s";
59
+ const kb = bps / 1024;
60
+ return Math.max(1, Math.round(kb)) + " KB/s";
61
+ }
62
+
63
+ function currentVersion() {
64
+ // Resolved lazily at init via `window.privateboard.getAppVersion()`.
65
+ // If the IPC roundtrip hasn't landed yet (race against the first
66
+ // `update-available` event), the version delta degrades to just
67
+ // "→ v0.1.23" until the next state event re-paints.
68
+ return appVersion;
69
+ }
70
+
71
+ function setStateClass(kind) {
72
+ if (!overlayEl) return;
73
+ overlayEl.classList.remove(
74
+ "state-available",
75
+ "state-downloading",
76
+ "state-ready",
77
+ "state-error",
78
+ );
79
+ if (kind === "available" || kind === "downloading" || kind === "ready" || kind === "error") {
80
+ overlayEl.classList.add("state-" + kind);
81
+ }
82
+ }
83
+
84
+ function openModal() {
85
+ if (!overlayEl) return;
86
+ if (overlayEl.classList.contains("open")) return;
87
+ overlayEl.classList.add("open");
88
+ overlayEl.setAttribute("aria-hidden", "false");
89
+ document.body.classList.add("upd-locked");
90
+ }
91
+ function closeModal() {
92
+ if (!overlayEl) return;
93
+ overlayEl.classList.remove("open");
94
+ overlayEl.setAttribute("aria-hidden", "true");
95
+ document.body.classList.remove("upd-locked");
96
+ }
97
+
98
+ function paint(state) {
99
+ if (!overlayEl || !state) return;
100
+ const from = currentVersion();
101
+ const to = state.version ? ("v" + state.version) : "";
102
+ overlayEl.querySelectorAll("[data-upd-from-version], [data-upd-from-version-d], [data-upd-from-version-r]").forEach((el) => {
103
+ el.textContent = from ? ("v" + from.replace(/^v/, "")) : "";
104
+ el.style.display = from ? "" : "none";
105
+ });
106
+ overlayEl.querySelectorAll("[data-upd-to-version], [data-upd-to-version-d], [data-upd-to-version-r]").forEach((el) => {
107
+ el.textContent = to;
108
+ });
109
+
110
+ if (state.kind === "downloading") {
111
+ const pct = Math.max(0, Math.min(100, Math.round(state.percent || 0)));
112
+ const pctEl = $("[data-upd-pct]", overlayEl);
113
+ if (pctEl) pctEl.textContent = pct + "%";
114
+ const bar = $("[data-upd-bar]", overlayEl);
115
+ if (bar) {
116
+ bar.classList.remove("indeterminate");
117
+ const span = bar.querySelector("span");
118
+ if (span) span.style.width = pct + "%";
119
+ }
120
+ const bytes = $("[data-upd-bytes]", overlayEl);
121
+ if (bytes) {
122
+ bytes.textContent = fmtBytes(state.transferred) + " / " + fmtBytes(state.total);
123
+ }
124
+ const rate = $("[data-upd-rate]", overlayEl);
125
+ if (rate) rate.textContent = fmtRate(state.bytesPerSecond);
126
+ }
127
+
128
+ if (state.kind === "error") {
129
+ const errEl = $("[data-upd-error-message]", overlayEl);
130
+ if (errEl) errEl.textContent = state.message || "—";
131
+ }
132
+
133
+ setStateClass(state.kind);
134
+ }
135
+
136
+ function shouldAutoOpenFor(state) {
137
+ if (!state) return false;
138
+ if (state.kind === "available") return true; // First prompt on launch.
139
+ if (state.kind === "ready") return true; // Always surface the restart prompt.
140
+ if (state.kind === "downloading") return false; // User asked to hide · don't pop it back.
141
+ if (state.kind === "error") return false; // Errors don't steal focus.
142
+ return false;
143
+ }
144
+
145
+ function applyState(state) {
146
+ if (!state || state.kind === "idle") {
147
+ // Idle (no update / cleared) · keep modal closed.
148
+ lastState = state || { kind: "idle" };
149
+ return;
150
+ }
151
+ const isNewKind = !lastState || lastState.kind !== state.kind;
152
+ if (isNewKind) userDismissed = false; // A new transition re-prompts.
153
+ lastState = state;
154
+ paint(state);
155
+ if (overlayEl.classList.contains("open")) {
156
+ // Already open — repaint in place; downloading→ready transitions
157
+ // flow without the modal flickering.
158
+ return;
159
+ }
160
+ if (!userDismissed && shouldAutoOpenFor(state)) openModal();
161
+ }
162
+
163
+ function wireEvents() {
164
+ overlayEl.addEventListener("click", (e) => {
165
+ const close = e.target.closest("[data-upd-close], [data-upd-dismiss]");
166
+ if (close) {
167
+ e.preventDefault();
168
+ userDismissed = true;
169
+ closeModal();
170
+ bridge.dismiss();
171
+ return;
172
+ }
173
+ const dl = e.target.closest("[data-upd-download]");
174
+ if (dl) {
175
+ e.preventDefault();
176
+ // Optimistic switch to the downloading state so the user sees
177
+ // the progress card immediately; the first real
178
+ // `download-progress` event will replace the indeterminate
179
+ // sweep with a percentage.
180
+ const v = (lastState && lastState.kind === "available") ? lastState.version : "";
181
+ applyState({ kind: "downloading", version: v, percent: 0, transferred: 0, total: 0, bytesPerSecond: 0 });
182
+ const bar = $("[data-upd-bar]", overlayEl);
183
+ if (bar) bar.classList.add("indeterminate");
184
+ bridge.startDownload();
185
+ return;
186
+ }
187
+ const inst = e.target.closest("[data-upd-install]");
188
+ if (inst) {
189
+ e.preventDefault();
190
+ // Disable the button to prevent a double-click between the
191
+ // IPC roundtrip and the actual app quit.
192
+ inst.setAttribute("disabled", "true");
193
+ bridge.installNow();
194
+ return;
195
+ }
196
+ });
197
+ document.addEventListener("keydown", (e) => {
198
+ if (!overlayEl.classList.contains("open")) return;
199
+ if (e.key !== "Escape") return;
200
+ // Escape never installs · only dismisses. During a download or
201
+ // ready state, this is the same as the "Hide"/"Later" button.
202
+ e.preventDefault();
203
+ userDismissed = true;
204
+ closeModal();
205
+ bridge.dismiss();
206
+ });
207
+ document.addEventListener("boardroom:locale", applyI18n);
208
+ }
209
+
210
+ function init() {
211
+ overlayEl = document.getElementById("upd-overlay");
212
+ if (!overlayEl) return;
213
+ applyI18n();
214
+ wireEvents();
215
+ // Dev preview · expose state injection so the modal can be auditioned
216
+ // without a packaged build + real GitHub release. From devtools:
217
+ // __updaterDev.show({ kind: "available", version: "0.1.99" })
218
+ // __updaterDev.show({ kind: "downloading", version: "0.1.99",
219
+ // percent: 42, transferred: 5_300_000,
220
+ // total: 12_500_000, bytesPerSecond: 850_000 })
221
+ // __updaterDev.show({ kind: "ready", version: "0.1.99" })
222
+ // __updaterDev.show({ kind: "error", message: "Could not connect" })
223
+ // __updaterDev.close()
224
+ window.__updaterDev = {
225
+ show: (s) => { userDismissed = false; applyState(s); openModal(); },
226
+ close: () => { userDismissed = false; closeModal(); },
227
+ };
228
+ // Resolve the app version once · used for the "v_old → v_new"
229
+ // version delta in the modal header.
230
+ if (typeof window.privateboard.getAppVersion === "function") {
231
+ window.privateboard.getAppVersion().then((v) => {
232
+ appVersion = v || "";
233
+ if (lastState && lastState.kind !== "idle") paint(lastState);
234
+ }).catch(() => {});
235
+ }
236
+ bridge.onState((s) => applyState(s));
237
+ // Re-hydrate · covers the case where update-available already
238
+ // fired before this script's defer-run completed.
239
+ bridge.getState().then((s) => { if (s) applyState(s); }).catch(() => {});
240
+ }
241
+
242
+ if (document.readyState === "loading") {
243
+ document.addEventListener("DOMContentLoaded", init);
244
+ } else {
245
+ init();
246
+ }
247
+ })();