synabun 2.0.5 → 2.0.7

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.
Files changed (233) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +2 -1
  3. package/THIRD-PARTY-LICENSES.md +13 -0
  4. package/docs/whatsapp.md +151 -16
  5. package/hooks/claude-code/shared.mjs +6 -3
  6. package/lib/child-path.js +303 -0
  7. package/lib/claude-hooks.js +2 -1
  8. package/lib/data-home-migration.js +2 -0
  9. package/lib/desktop-app.js +265 -0
  10. package/lib/external-tools.js +7 -3
  11. package/lib/log-redact.js +102 -0
  12. package/lib/packaged-runtime.js +138 -11
  13. package/mcp-server/dist/http.d.ts +3 -1
  14. package/mcp-server/dist/http.d.ts.map +1 -1
  15. package/mcp-server/dist/http.js +15 -4
  16. package/mcp-server/dist/http.js.map +1 -1
  17. package/mcp-server/dist/index.d.ts.map +1 -1
  18. package/mcp-server/dist/index.js +9 -6
  19. package/mcp-server/dist/index.js.map +1 -1
  20. package/mcp-server/dist/services/budget-judgments.d.ts +108 -0
  21. package/mcp-server/dist/services/budget-judgments.d.ts.map +1 -0
  22. package/mcp-server/dist/services/budget-judgments.js +153 -0
  23. package/mcp-server/dist/services/budget-judgments.js.map +1 -0
  24. package/mcp-server/dist/services/codex-config-heal.d.ts +5 -1
  25. package/mcp-server/dist/services/codex-config-heal.d.ts.map +1 -1
  26. package/mcp-server/dist/services/codex-config-heal.js +41 -4
  27. package/mcp-server/dist/services/codex-config-heal.js.map +1 -1
  28. package/mcp-server/dist/services/identity.d.ts +11 -0
  29. package/mcp-server/dist/services/identity.d.ts.map +1 -1
  30. package/mcp-server/dist/services/identity.js +24 -0
  31. package/mcp-server/dist/services/identity.js.map +1 -1
  32. package/mcp-server/dist/services/neural-interface.d.ts +13 -0
  33. package/mcp-server/dist/services/neural-interface.d.ts.map +1 -1
  34. package/mcp-server/dist/services/neural-interface.js +34 -2
  35. package/mcp-server/dist/services/neural-interface.js.map +1 -1
  36. package/mcp-server/dist/services/typesafe-config.d.ts +1 -1
  37. package/mcp-server/dist/services/typesafe-config.d.ts.map +1 -1
  38. package/mcp-server/dist/services/typesafe-config.js +4 -1
  39. package/mcp-server/dist/services/typesafe-config.js.map +1 -1
  40. package/mcp-server/dist/tools/agents.d.ts +10 -7
  41. package/mcp-server/dist/tools/agents.d.ts.map +1 -1
  42. package/mcp-server/dist/tools/agents.js +34 -13
  43. package/mcp-server/dist/tools/agents.js.map +1 -1
  44. package/mcp-server/dist/tools/whatsapp-send-file.d.ts +17 -0
  45. package/mcp-server/dist/tools/whatsapp-send-file.d.ts.map +1 -0
  46. package/mcp-server/dist/tools/whatsapp-send-file.js +44 -0
  47. package/mcp-server/dist/tools/whatsapp-send-file.js.map +1 -0
  48. package/mcp-server/dist/tui.js +2 -1
  49. package/mcp-server/dist/tui.js.map +1 -1
  50. package/mcp-server/dist/version.d.ts +5 -0
  51. package/mcp-server/dist/version.d.ts.map +1 -0
  52. package/mcp-server/dist/version.js +21 -0
  53. package/mcp-server/dist/version.js.map +1 -0
  54. package/mcp-server/package-lock.json +2 -2
  55. package/mcp-server/package.json +1 -1
  56. package/mcp-server/run.mjs +15 -9
  57. package/mcp-server/src/http.ts +18 -3
  58. package/mcp-server/src/index.ts +9 -6
  59. package/mcp-server/src/services/budget-judgments.ts +183 -0
  60. package/mcp-server/src/services/codex-config-heal.ts +35 -4
  61. package/mcp-server/src/services/identity.ts +27 -0
  62. package/mcp-server/src/services/neural-interface.ts +34 -2
  63. package/mcp-server/src/services/typesafe-config.ts +4 -1
  64. package/mcp-server/src/tools/agents.ts +33 -14
  65. package/mcp-server/src/tools/whatsapp-send-file.ts +51 -0
  66. package/mcp-server/src/tui.ts +2 -1
  67. package/mcp-server/src/version.ts +22 -0
  68. package/neural-interface/i18n/en.json +627 -57
  69. package/neural-interface/i18n/pt-BR.json +626 -56
  70. package/neural-interface/lib/account-usage.js +163 -0
  71. package/neural-interface/lib/assistant-api.js +255 -52
  72. package/neural-interface/lib/assistant-brains/claude.js +15 -2
  73. package/neural-interface/lib/assistant-brains/codex.js +3 -0
  74. package/neural-interface/lib/assistant-brains/opencode.js +17 -4
  75. package/neural-interface/lib/assistant-budget-planner.js +390 -0
  76. package/neural-interface/lib/assistant-budget-split.js +408 -0
  77. package/neural-interface/lib/assistant-budget.js +94 -95
  78. package/neural-interface/lib/assistant-config.js +62 -14
  79. package/neural-interface/lib/assistant-dispatch.js +432 -125
  80. package/neural-interface/lib/assistant-jev.js +26 -3
  81. package/neural-interface/lib/assistant-persona.js +68 -11
  82. package/neural-interface/lib/assistant-plan-permissions.js +8 -1
  83. package/neural-interface/lib/assistant-route-accounts.js +14 -0
  84. package/neural-interface/lib/assistant-route-gate.js +19 -3
  85. package/neural-interface/lib/assistant-router.js +373 -42
  86. package/neural-interface/lib/assistant-runtime.js +307 -47
  87. package/neural-interface/lib/assistant-user-writer.js +4 -2
  88. package/neural-interface/lib/augmented-path.js +317 -53
  89. package/neural-interface/lib/claude-accounts.js +342 -31
  90. package/neural-interface/lib/claude-agent-bridge.js +90 -3
  91. package/neural-interface/lib/claude-executable.js +6 -1
  92. package/neural-interface/lib/claude-model-catalog.js +3 -3
  93. package/neural-interface/lib/claude-panel-auth.js +221 -0
  94. package/neural-interface/lib/claude-profile-api-key.js +62 -0
  95. package/neural-interface/lib/cli-install-api.js +137 -0
  96. package/neural-interface/lib/cli-install.js +797 -0
  97. package/neural-interface/lib/cli-path.js +130 -0
  98. package/neural-interface/lib/cli-resolver.js +744 -0
  99. package/neural-interface/lib/cli-update-planner.js +143 -7
  100. package/neural-interface/lib/codex-account-auth.js +178 -0
  101. package/neural-interface/lib/codex-accounts.js +489 -0
  102. package/neural-interface/lib/codex-capabilities.js +2 -1
  103. package/neural-interface/lib/codex-compat-rpc.js +8 -2
  104. package/neural-interface/lib/codex-mcp-approvals.js +5 -1
  105. package/neural-interface/lib/codex-panel-auth.js +62 -0
  106. package/neural-interface/lib/codex-session-settings.js +1 -1
  107. package/neural-interface/lib/codex-settings-schema.js +120 -0
  108. package/neural-interface/lib/desktop/config.js +3 -0
  109. package/neural-interface/lib/git-bash/bzip2.js +339 -0
  110. package/neural-interface/lib/git-bash/claude-settings.js +73 -0
  111. package/neural-interface/lib/git-bash/extract-worker.js +34 -0
  112. package/neural-interface/lib/git-bash/install.js +529 -0
  113. package/neural-interface/lib/git-bash/licenses/COPYING +360 -0
  114. package/neural-interface/lib/git-bash/licenses/LGPL-2.1 +511 -0
  115. package/neural-interface/lib/git-bash/manager.js +268 -0
  116. package/neural-interface/lib/git-bash/pins.js +206 -0
  117. package/neural-interface/lib/git-bash/pins.json +34 -0
  118. package/neural-interface/lib/git-bash/resolver.js +194 -0
  119. package/neural-interface/lib/git-read.js +34 -2
  120. package/neural-interface/lib/install-details.js +145 -0
  121. package/neural-interface/lib/mcp-installer.js +14 -2
  122. package/neural-interface/lib/native-loop-providers.js +16 -3
  123. package/neural-interface/lib/native-loop-runtime.js +9 -4
  124. package/neural-interface/lib/os-path.js +319 -0
  125. package/neural-interface/lib/provider-panel-api-key-auth.js +92 -0
  126. package/neural-interface/lib/provider-panel-terminal-auth.js +95 -0
  127. package/neural-interface/lib/pty-host/core.js +2 -1
  128. package/neural-interface/lib/pty-host/manager.js +27 -2
  129. package/neural-interface/lib/remote-policy.js +20 -11
  130. package/neural-interface/lib/session-title-generator.js +4 -3
  131. package/neural-interface/lib/system-about.js +340 -0
  132. package/neural-interface/lib/system-activity.js +66 -0
  133. package/neural-interface/lib/terminal-env.js +11 -3
  134. package/neural-interface/lib/terminal-path.js +55 -0
  135. package/neural-interface/lib/virtual-socket.js +12 -1
  136. package/neural-interface/lib/whatsapp/baileys-adapter.js +165 -45
  137. package/neural-interface/lib/whatsapp/bridge.js +78 -34
  138. package/neural-interface/lib/whatsapp/commands.js +13 -14
  139. package/neural-interface/lib/whatsapp/config.js +134 -20
  140. package/neural-interface/lib/whatsapp/fake-baileys.js +99 -14
  141. package/neural-interface/lib/whatsapp/host-core.js +168 -35
  142. package/neural-interface/lib/whatsapp/inbound.js +5 -3
  143. package/neural-interface/lib/whatsapp/local-file.js +524 -0
  144. package/neural-interface/lib/whatsapp/manager.js +92 -32
  145. package/neural-interface/lib/whatsapp/paths.js +11 -1
  146. package/neural-interface/lib/whatsapp/protocol.js +65 -5
  147. package/neural-interface/lib/whatsapp/redact.js +63 -0
  148. package/neural-interface/lib/whatsapp/service.js +343 -91
  149. package/neural-interface/lib/whatsapp/transcription.js +463 -0
  150. package/neural-interface/package-lock.json +2 -2
  151. package/neural-interface/package.json +1 -1
  152. package/neural-interface/public/index.html +2 -0
  153. package/neural-interface/public/index2d.html +2 -0
  154. package/neural-interface/public/offline.html +16 -1
  155. package/neural-interface/public/onboarding.html +96 -2
  156. package/neural-interface/public/shared/account-i18n.js +7 -0
  157. package/neural-interface/public/shared/account-menu.js +539 -0
  158. package/neural-interface/public/shared/account-usage.js +19 -0
  159. package/neural-interface/public/shared/account-view.js +335 -0
  160. package/neural-interface/public/shared/agent-runtime-options.js +28 -8
  161. package/neural-interface/public/shared/assistant/asst-accounts.js +182 -184
  162. package/neural-interface/public/shared/assistant/asst-api.js +32 -2
  163. package/neural-interface/public/shared/assistant/asst-brain-picker.js +124 -25
  164. package/neural-interface/public/shared/assistant/asst-budget.js +259 -303
  165. package/neural-interface/public/shared/assistant/asst-mascot.js +126 -7
  166. package/neural-interface/public/shared/assistant/asst-menu.js +15 -3
  167. package/neural-interface/public/shared/assistant/asst-panel.js +48 -14
  168. package/neural-interface/public/shared/assistant/asst-render.js +38 -7
  169. package/neural-interface/public/shared/assistant/asst-route.js +72 -14
  170. package/neural-interface/public/shared/assistant/asst-routes-editor.js +45 -6
  171. package/neural-interface/public/shared/assistant/asst-slash.js +1 -1
  172. package/neural-interface/public/shared/assistant/asst-state.js +1 -1
  173. package/neural-interface/public/shared/assistant/asst-styles.js +99 -17
  174. package/neural-interface/public/shared/cdx/cdx-commands.js +4 -1
  175. package/neural-interface/public/shared/cdx/cdx-features.js +22 -14
  176. package/neural-interface/public/shared/cdx/cdx-icons.js +2 -0
  177. package/neural-interface/public/shared/cdx/cdx-panel.js +48 -107
  178. package/neural-interface/public/shared/cdx/cdx-settings.js +231 -0
  179. package/neural-interface/public/shared/cdx/cdx-styles.js +128 -107
  180. package/neural-interface/public/shared/cdx/cdx-tabs.js +261 -664
  181. package/neural-interface/public/shared/cdx/cdx-ws.js +2 -0
  182. package/neural-interface/public/shared/claude-account-view.js +3 -0
  183. package/neural-interface/public/shared/cli-install-view.js +392 -0
  184. package/neural-interface/public/shared/cli-status.js +282 -22
  185. package/neural-interface/public/shared/cp/cp-events.js +2 -1
  186. package/neural-interface/public/shared/cp/cp-styles.js +46 -0
  187. package/neural-interface/public/shared/desktop-bridge.js +39 -0
  188. package/neural-interface/public/shared/desktop-titlebar-page.js +59 -0
  189. package/neural-interface/public/shared/desktop-titlebar-view.js +122 -0
  190. package/neural-interface/public/shared/desktop-titlebar.js +153 -0
  191. package/neural-interface/public/shared/desktop-update-view.js +462 -0
  192. package/neural-interface/public/shared/editor/editor-panel.js +1 -1
  193. package/neural-interface/public/shared/explorer/fx-main.js +1 -1
  194. package/neural-interface/public/shared/git-bash-view.js +145 -0
  195. package/neural-interface/public/shared/html-shell.js +1 -0
  196. package/neural-interface/public/shared/i18n.js +19 -1
  197. package/neural-interface/public/shared/install-details-view.js +86 -0
  198. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +32 -0
  199. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +17 -0
  200. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +118 -0
  201. package/neural-interface/public/shared/pwa-install-page.js +3 -1
  202. package/neural-interface/public/shared/settings/settings-about-pane.js +271 -0
  203. package/neural-interface/public/shared/settings/settings-about.js +254 -0
  204. package/neural-interface/public/shared/settings/settings-ia.js +15 -0
  205. package/neural-interface/public/shared/showcase-privacy.js +489 -0
  206. package/neural-interface/public/shared/storage.js +30 -3
  207. package/neural-interface/public/shared/styles.css +278 -31
  208. package/neural-interface/public/shared/synabun-mascot.js +48 -2
  209. package/neural-interface/public/shared/ui-claude-panel.js +212 -213
  210. package/neural-interface/public/shared/ui-cli-banner.js +355 -0
  211. package/neural-interface/public/shared/ui-git-bash.js +124 -0
  212. package/neural-interface/public/shared/ui-judgments.js +10 -1
  213. package/neural-interface/public/shared/ui-loading.js +30 -6
  214. package/neural-interface/public/shared/ui-menubar.js +13 -3
  215. package/neural-interface/public/shared/ui-navbar.js +3 -1
  216. package/neural-interface/public/shared/ui-provider-auth.js +104 -0
  217. package/neural-interface/public/shared/ui-provider-terminal-auth.js +223 -0
  218. package/neural-interface/public/shared/ui-sessions.js +9 -3
  219. package/neural-interface/public/shared/ui-settings.js +55 -3
  220. package/neural-interface/public/shared/ui-sidepanel-sessions.js +3 -1
  221. package/neural-interface/public/shared/ui-sync.js +6 -0
  222. package/neural-interface/public/shared/ui-terminal.js +17 -0
  223. package/neural-interface/public/shared/ui-tool-updates.js +5 -0
  224. package/neural-interface/public/shared/ui-update.js +594 -5
  225. package/neural-interface/public/shared/ui-whatsapp.js +21 -6
  226. package/neural-interface/public/shared/whatsapp/wa-view.js +51 -8
  227. package/neural-interface/public/shared/worker-end.js +2 -0
  228. package/neural-interface/public/sw.js +1 -1
  229. package/neural-interface/public/variant/2d/main.js +10 -1
  230. package/neural-interface/public/variant/3d/main.js +10 -1
  231. package/neural-interface/server.js +1219 -608
  232. package/package.json +4 -1
  233. package/setup.js +9 -8
@@ -2,9 +2,22 @@
2
2
  // SynaBun Neural Interface — Update Alert
3
3
  // Checks for npm updates on page load, shows toolbar badge,
4
4
  // and provides a 3-step modal wizard with blocking backup gate.
5
+ //
6
+ // Inside the SynaBun desktop app the updater is the app's own: it downloads and
7
+ // installs by itself, and the same badge, drawer item and dialog, plus a notice
8
+ // (the countdown before the restart, "updated", a failed install), show what it
9
+ // reports through window.synabunDesktop (desktop-bridge.js,
10
+ // desktop-update-view.js). The npm/GitHub check above is never asked.
5
11
  // ═══════════════════════════════════════════
6
12
 
7
13
  import { showUpdateToast } from './ui-tool-updates.js';
14
+ import { emit } from './state.js';
15
+ import { getLocale, t } from './i18n.js';
16
+ import { desktopBridge } from './desktop-bridge.js';
17
+ import {
18
+ desktopCheckOutcome, desktopUpdateMeta, desktopUpdateNotice, desktopUpdateTooltip, desktopUpdateView, fillDesktopCopy, readDesktopUpdateState,
19
+ versionDataFromDesktop,
20
+ } from './desktop-update-view.js';
8
21
 
9
22
  const $ = (id) => document.getElementById(id);
10
23
  const SYNABUN_GITHUB_URL = 'https://github.com/danilokhury/Synabun';
@@ -12,9 +25,15 @@ const SYNABUN_GITHUB_URL = 'https://github.com/danilokhury/Synabun';
12
25
  let _versionData = null;
13
26
  let _btnWired = false;
14
27
  let _pollWired = false;
28
+ // Inside the desktop app: the updater's last report as it came, and the update
29
+ // dialog while it is open ({ overlay, update, close }).
30
+ let _desktopState = null;
31
+ let _desktopDialog = null;
15
32
 
16
- function sourceLabel(data) {
33
+ /** Where an update comes from, as the badge, the toast and the menu name it. */
34
+ export function synabunUpdateSourceLabel(data) {
17
35
  const src = data?.source;
36
+ if (data?.desktop || src === 'app') return desktopT('update.source');
18
37
  if (src === 'both') return 'npm + GitHub';
19
38
  if (src === 'github') return 'GitHub';
20
39
  if (src === 'npm') return 'npm';
@@ -34,8 +53,10 @@ function applyTopRightButton(data) {
34
53
  else badge.removeAttribute('aria-label');
35
54
  }
36
55
  if (has) {
37
- const srcLabel = sourceLabel(data);
38
- btn.dataset.tooltip = `SynaBun update available: v${data.current} → v${data.latest} (${srcLabel})`;
56
+ const srcLabel = synabunUpdateSourceLabel(data);
57
+ btn.dataset.tooltip = data.desktop
58
+ ? desktopUpdateTooltip(_desktopState, { t: desktopT })
59
+ : `SynaBun update available: v${data.current} → v${data.latest} (${srcLabel})`;
39
60
  }
40
61
  if (!_btnWired) {
41
62
  _btnWired = true;
@@ -53,7 +74,7 @@ function applyTopRightButton(data) {
53
74
 
54
75
  function maybeShowUpdateAlert(data, { once = true } = {}) {
55
76
  if (!data?.updateAvailable) return;
56
- const key = `synabun-update-alert:${data.current}->${data.latest}:${sourceLabel(data)}`;
77
+ const key = `synabun-update-alert:${data.current}->${data.latest}:${synabunUpdateSourceLabel(data)}`;
57
78
  try {
58
79
  if (once && sessionStorage.getItem(key) === '1') return;
59
80
  sessionStorage.setItem(key, '1');
@@ -61,7 +82,7 @@ function maybeShowUpdateAlert(data, { once = true } = {}) {
61
82
 
62
83
  setTimeout(() => {
63
84
  showUpdateToast({
64
- updates: [{ label: `SynaBun v${data.current} → v${data.latest}`, source: sourceLabel(data) }],
85
+ updates: [{ label: `SynaBun v${data.current} → v${data.latest}`, source: synabunUpdateSourceLabel(data) }],
65
86
  errors: [],
66
87
  onClick: () => openSynabunUpdateModal(),
67
88
  });
@@ -69,6 +90,8 @@ function maybeShowUpdateAlert(data, { once = true } = {}) {
69
90
  }
70
91
 
71
92
  export async function initUpdate() {
93
+ const desktop = desktopBridge();
94
+ if (desktop) return initDesktopUpdate(desktop);
72
95
  try {
73
96
  const res = await fetch('/api/system/version');
74
97
  if (!res.ok) return;
@@ -89,6 +112,17 @@ export async function initUpdate() {
89
112
  // re-applies the top-right button + badge state. Returns the latest
90
113
  // _synabunUpdateCache shape so callers can compose toasts.
91
114
  export async function forceCheckSynabunUpdate({ alert = false } = {}) {
115
+ // Inside the desktop app the app checks its own release feed; an error is
116
+ // null here, as a failed request is, so callers list it under the errors.
117
+ const desktop = desktopBridge();
118
+ if (desktop) {
119
+ try {
120
+ const data = applyDesktopState(await desktop.updates.check(), { alert });
121
+ return data.status === 'error' ? null : data;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
92
126
  try {
93
127
  const res = await fetch('/api/system/version?force=1');
94
128
  if (!res.ok) return null;
@@ -101,6 +135,21 @@ export async function forceCheckSynabunUpdate({ alert = false } = {}) {
101
135
  }
102
136
  }
103
137
 
138
+ /**
139
+ * The update state for a surface that refreshes often (Notifications): the
140
+ * desktop app's last report, which costs no check, or a fresh check of the
141
+ * server for an npm or Git install, as before.
142
+ */
143
+ export async function syncSynabunUpdateData() {
144
+ const desktop = desktopBridge();
145
+ if (!desktop) return forceCheckSynabunUpdate();
146
+ try {
147
+ return applyDesktopState(await desktop.updates.state(), { alert: false });
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
152
+
104
153
  export function openSynabunUpdateModal() {
105
154
  if (!_versionData?.updateAvailable) return;
106
155
  openUpdateModal();
@@ -112,10 +161,550 @@ export function getSynabunUpdateData() {
112
161
  return _versionData;
113
162
  }
114
163
 
164
+ /** The line under the SynaBun item of Notifications > Updates. */
165
+ export function synabunUpdateMeta(data) {
166
+ if (data?.desktop) return desktopUpdateMeta(_desktopState, { t: desktopT });
167
+ return data?.source ? `Source: ${data.source}` : '';
168
+ }
169
+
170
+ // ── Desktop app ──
171
+ //
172
+ // window.synabunDesktop.updates is the app's updater (packaging/desktop). It
173
+ // checks about 30 s after the app opens and every 6 h, downloads what it finds,
174
+ // and installs it by itself once SynaBun is idle, after a one-minute countdown
175
+ // (the notice below, with Later); it reports each change here, and the menu bar
176
+ // and the tray of the app say so through onOpen. The page shows what it reports
177
+ // and passes on Install now and Later, never more.
178
+
179
+ /** A `desktop.*` string in the page's language; DESKTOP_COPY when the dictionary lacks it. */
180
+ function desktopT(key, params) {
181
+ const full = `desktop.${key}`;
182
+ const value = t(full, params);
183
+ return value === full ? fillDesktopCopy(key, params) : value;
184
+ }
185
+
186
+ /** A time of day (an ISO time), written the way the page's language writes it. */
187
+ function desktopTime(iso) {
188
+ try {
189
+ return new Date(iso).toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' });
190
+ } catch {
191
+ return new Date(iso).toISOString().slice(11, 16);
192
+ }
193
+ }
194
+
195
+ /** A number with `digits` decimals, written the way the page's language writes it. */
196
+ function desktopNumber(value, digits) {
197
+ try {
198
+ return value.toLocaleString(getLocale(), { minimumFractionDigits: digits, maximumFractionDigits: digits });
199
+ } catch {
200
+ return value.toFixed(digits);
201
+ }
202
+ }
203
+
204
+ /** Take one report of the app's updater: the badge, the drawer, an open dialog and the notice follow it. */
205
+ function applyDesktopState(state, { alert = true } = {}) {
206
+ _desktopState = state && typeof state === 'object' ? state : null;
207
+ _versionData = versionDataFromDesktop(_desktopState);
208
+ applyTopRightButton(_versionData);
209
+ // An offer that waits for the user (macOS: a copy to move to Applications, or one in a folder SynaBun
210
+ // cannot write): one toast per version and session. Every other update downloads and installs by
211
+ // itself, and the notice speaks when there is something to say.
212
+ if (alert && _versionData.updateAvailable && _versionData.status === 'available' && _versionData.install !== 'restart') maybeShowUpdateAlert(_versionData);
213
+ emit('synabun-update:changed', _versionData);
214
+ _desktopDialog?.update(_desktopState);
215
+ renderDesktopNotice();
216
+ return _versionData;
217
+ }
218
+
219
+ // ── The notice (bottom right): the countdown before the restart, "updated", a failed install ──
220
+
221
+ let _notice = null; // { el, key, kind, timer, hold }
222
+
223
+ const NOTICE_ICONS = { up: '↑', ok: '✓', err: '!' };
224
+
225
+ function hideDesktopNotice() {
226
+ const current = _notice;
227
+ if (!current) return;
228
+ _notice = null;
229
+ clearInterval(current.timer);
230
+ clearTimeout(current.timer);
231
+ current.el.classList.remove('show');
232
+ setTimeout(() => current.el.remove(), 250);
233
+ }
234
+
235
+ /** What the notice says now: drawn from the last report, again every second while it counts down. */
236
+ function renderDesktopNotice() {
237
+ const notice = desktopBridge() ? desktopUpdateNotice(_desktopState, { t: desktopT }) : null;
238
+ if (!notice) {
239
+ // A notice that says something of its own for a while (Later's answer, "updated") stays until it is done.
240
+ if (_notice && !_notice.hold) hideDesktopNotice();
241
+ return;
242
+ }
243
+ if (_notice && _notice.key === notice.key) {
244
+ const line = _notice.el.querySelector('#desktop-update-notice-line');
245
+ if (line && line.textContent !== notice.line) line.textContent = notice.line;
246
+ return;
247
+ }
248
+ if (notice.kind === 'failed') {
249
+ // A failed update is said once per session; the badge and the dialog keep it.
250
+ const mark = `synabun-update-notice:${notice.key}`;
251
+ try {
252
+ if (sessionStorage.getItem(mark) === '1') { if (_notice && !_notice.hold) hideDesktopNotice(); return; }
253
+ sessionStorage.setItem(mark, '1');
254
+ } catch {}
255
+ }
256
+ showDesktopNotice(notice);
257
+ }
258
+
259
+ function showDesktopNotice(notice) {
260
+ hideDesktopNotice();
261
+ document.querySelectorAll('.update-notice').forEach(el => el.remove());
262
+ const el = document.createElement('div');
263
+ el.className = 'update-check-toast update-notice';
264
+ el.id = 'desktop-update-notice';
265
+ el.dataset.kind = notice.kind;
266
+ el.setAttribute('role', 'status');
267
+ const link = notice.link ? `<a class="update-notice-link" id="desktop-update-notice-link" href="${esc(notice.link.href)}" target="_blank" rel="noopener noreferrer">${esc(notice.link.label)}</a>` : '';
268
+ const buttons = notice.actions.map(item => `<button class="action-btn action-btn--ghost update-notice-btn" type="button" data-notice-action="${esc(item.id)}" id="desktop-update-notice-${esc(item.id)}">${esc(item.label)}</button>`).join('');
269
+ el.innerHTML = `
270
+ <div class="utc-icon utc-icon--${notice.tone}" aria-hidden="true">${NOTICE_ICONS[notice.tone] || '↑'}</div>
271
+ <div class="utc-body">
272
+ <div class="utc-title" id="desktop-update-notice-title">${esc(notice.title)}</div>
273
+ <div class="utc-sub" id="desktop-update-notice-line"${notice.ticking ? ' aria-live="off"' : ''}>${esc(notice.line)}</div>
274
+ <div class="update-notice-actions">${link}${buttons}</div>
275
+ </div>`;
276
+ el.querySelectorAll('[data-notice-action]').forEach((btn) => {
277
+ btn.addEventListener('click', (event) => { event.stopPropagation(); noticeAction(btn.dataset.noticeAction); });
278
+ });
279
+ document.body.appendChild(el);
280
+ requestAnimationFrame(() => el.classList.add('show'));
281
+ const current = { el, key: notice.key, kind: notice.kind, timer: null, hold: false };
282
+ _notice = current;
283
+ if (notice.ticking) {
284
+ current.timer = setInterval(() => { if (_notice === current) renderDesktopNotice(); }, 1000);
285
+ } else if (notice.kind === 'updated') {
286
+ // Said once: the app forgets it now, and the notice stays a little while on its own.
287
+ current.hold = true;
288
+ current.timer = setTimeout(() => { if (_notice === current) hideDesktopNotice(); }, 12000);
289
+ try { desktopBridge()?.updates.acknowledge?.()?.catch?.(() => {}); } catch {}
290
+ } else {
291
+ current.timer = setTimeout(() => { if (_notice === current) hideDesktopNotice(); }, 15000);
292
+ }
293
+ }
294
+
295
+ async function noticeAction(id) {
296
+ const desktop = desktopBridge();
297
+ const current = _notice;
298
+ if (!desktop || !current) return;
299
+ if (id === 'dismiss') { hideDesktopNotice(); return; }
300
+ if (id === 'details') { hideDesktopNotice(); openDesktopUpdateModal(); return; }
301
+ if (id !== 'later') return;
302
+ // Later: Later's answer takes the notice's place for a moment.
303
+ clearInterval(current.timer);
304
+ current.hold = true;
305
+ current.el.querySelectorAll('[data-notice-action]').forEach(btn => { btn.disabled = true; });
306
+ let answer = null;
307
+ try { answer = await desktop.updates.later(); } catch {}
308
+ if (_notice !== current) return;
309
+ const line = current.el.querySelector('#desktop-update-notice-line');
310
+ if (line) line.textContent = desktopT('notice.postponed');
311
+ current.el.querySelector('.update-notice-actions')?.remove();
312
+ current.timer = setTimeout(() => { if (_notice === current) hideDesktopNotice(); }, 5000);
313
+ if (answer) applyDesktopState(answer, { alert: false });
314
+ }
315
+
316
+ async function initDesktopUpdate(desktop) {
317
+ if (!_pollWired) {
318
+ _pollWired = true;
319
+ // No polling of our own: the app checks on its schedule and says what changed.
320
+ try { desktop.updates.onChange((state) => applyDesktopState(state)); } catch {}
321
+ try { desktop.updates.onOpen?.((state) => handleDesktopMenuCheck(state)); } catch {}
322
+ }
323
+ try {
324
+ applyDesktopState(await desktop.updates.state());
325
+ } catch { /* silent — no update UI if the app does not answer */ }
326
+ }
327
+
328
+ /** Check for Updates… in the app's own menu or tray finished: the dialog for an offer, a short toast otherwise. */
329
+ function handleDesktopMenuCheck(state) {
330
+ applyDesktopState(state, { alert: false });
331
+ const outcome = desktopCheckOutcome(state);
332
+ if (outcome === 'offer') openUpdateModal();
333
+ else if (outcome === 'error') showUpdateToast({ updates: [], errors: [readDesktopUpdateState(state).error || desktopT('update.errorFallback')] });
334
+ else if (outcome === 'up-to-date') showUpdateToast({ updates: [], errors: [] });
335
+ }
336
+
337
+ function prefersLessMotion() {
338
+ try { return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true; }
339
+ catch { return false; }
340
+ }
341
+
342
+ const DESKTOP_ARROW = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>';
343
+ const DESKTOP_BACKUP_ICON = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
344
+
345
+ /** One page of the desktop dialog, from desktopUpdateView(). Everything the app or the feed said goes in as text. */
346
+ function renderDesktopStep(view) {
347
+ const pill = view.testFeed ? ` <span class="update-channel-pill">${esc(desktopT('update.testFeed'))}</span>` : '';
348
+ const working = view.step === 'installing' ? '<span class="update-spinner"></span>' : '';
349
+ const diff = view.latest ? `
350
+ <div class="update-version-diff">
351
+ <span class="update-ver update-ver--old">v${esc(view.current)}</span>
352
+ ${DESKTOP_ARROW}
353
+ <span class="update-ver update-ver--new">v${esc(view.latest)}</span>
354
+ </div>` : '';
355
+ const body = view.body.map(line => `<p class="update-desktop-line" style="font-size:12px;color:var(--t-muted);margin:12px 0 6px">${esc(line)}</p>`).join('');
356
+ const notes = view.notes !== null ? `
357
+ <div class="update-notes-label">${esc(view.notesLabel)}</div>
358
+ <div class="update-notes" id="desktop-update-notes" tabindex="0">${esc(view.notes)}</div>` : '';
359
+ const unknown = view.progress?.percent === null;
360
+ const progress = view.progress ? `
361
+ <div class="update-progress" id="desktop-update-progress" role="progressbar" aria-label="${esc(view.title)}" aria-valuemin="0" aria-valuemax="100"${unknown ? '' : ` aria-valuenow="${view.progress.percent}"`}>
362
+ <div class="update-progress-bar${unknown ? ' update-progress-bar--unknown' : ''}" id="desktop-update-progress-bar" style="width:${unknown ? 100 : view.progress.percent}%"></div>
363
+ </div>
364
+ <div class="update-progress-text" id="desktop-update-progress-text">${esc(view.progress.text)}</div>` : '';
365
+ const file = view.file ? `<p class="update-desktop-file" id="desktop-update-file" style="font-size:11px;color:var(--t-muted);opacity:.75;margin:0 0 6px;overflow-wrap:anywhere">${esc(view.file)}</p>` : '';
366
+ const link = view.link ? `<p class="update-desktop-link" style="margin:2px 0 6px"><a class="update-notice-link" id="desktop-update-link" href="${esc(view.link.href)}" target="_blank" rel="noopener noreferrer">${esc(view.link.label)}</a></p>` : '';
367
+ const backup = view.backup ? `
368
+ <button class="action-btn action-btn--ghost" type="button" data-desktop-action="${view.backup.id}" id="desktop-update-backup" style="width:100%;margin:10px 0 0">${DESKTOP_BACKUP_ICON}${esc(view.backup.label)}</button>
369
+ <div class="update-backup-status" id="desktop-update-backup-status" style="display:none">
370
+ <span class="update-backup-icon" id="desktop-update-backup-icon"></span>
371
+ <span id="desktop-update-backup-text"></span>
372
+ </div>` : '';
373
+ const actions = view.actions.length ? `
374
+ <div class="tag-delete-modal-actions" style="margin-top:14px">
375
+ ${view.actions.map(item => `<button class="action-btn action-btn--${item.kind}" type="button" data-desktop-action="${item.id}" id="desktop-update-${item.id}">${esc(item.label)}</button>`).join('')}
376
+ </div>` : '';
377
+ return `
378
+ <h3 class="update-modal-title" id="desktop-update-title">${working}${esc(view.title)}${pill}</h3>
379
+ ${diff}
380
+ ${body}
381
+ ${notes}
382
+ ${progress}
383
+ ${file}
384
+ ${link}
385
+ ${backup}
386
+ ${actions}`;
387
+ }
388
+
389
+ /**
390
+ * The update dialog inside the desktop app: the same shell and liquid step
391
+ * transition as the npm wizard below, one page per desktopUpdateView() step,
392
+ * kept current by every report while it is open. A progress report moves the
393
+ * bar in place; a new step slides in.
394
+ */
395
+ function openDesktopUpdateModal() {
396
+ const desktop = desktopBridge();
397
+ if (!desktop) return;
398
+ // One dialog: a second request (the badge, the menu, the tray) leaves the open one as it is.
399
+ if (_desktopDialog) return;
400
+ const viewOf = (state) => desktopUpdateView(state, { t: desktopT, number: desktopNumber, time: desktopTime });
401
+ const first = viewOf(_desktopState);
402
+ if (first.step === 'none') return;
403
+
404
+ let view = null;
405
+ let isAnimating = false;
406
+ let pending = null;
407
+ let working = false;
408
+ // A page whose sentence counts down (the restart's seconds) is drawn again every second.
409
+ let tick = null;
410
+
411
+ const overlay = document.createElement('div');
412
+ overlay.className = 'tag-delete-overlay';
413
+ overlay.id = 'desktop-update-overlay';
414
+ overlay.style.zIndex = '300100';
415
+
416
+ const modal = document.createElement('div');
417
+ modal.className = 'tag-delete-modal update-modal update-modal--desktop';
418
+ modal.id = 'desktop-update-modal';
419
+ modal.style.maxWidth = '460px';
420
+ modal.style.textAlign = 'left';
421
+ modal.setAttribute('role', 'dialog');
422
+ modal.setAttribute('aria-modal', 'true');
423
+ modal.setAttribute('aria-labelledby', 'desktop-update-title');
424
+
425
+ const dotsRow = document.createElement('div');
426
+ dotsRow.className = 'update-steps';
427
+ dotsRow.innerHTML = [1, 2, 3].map(() => `<span class="update-step-dot"></span>`).join('');
428
+
429
+ const stepWrap = document.createElement('div');
430
+ stepWrap.className = 'update-step-content';
431
+
432
+ modal.appendChild(dotsRow);
433
+ modal.appendChild(stepWrap);
434
+ overlay.appendChild(modal);
435
+
436
+ // The page that stays: during a transition the one leaving is still there for a moment.
437
+ const liveStep = () => [...stepWrap.children].find(el => el.classList.contains('update-step') && !el.classList.contains('is-leaving')) || stepWrap;
438
+
439
+ function syncDots(dot) {
440
+ dotsRow.style.visibility = dot ? '' : 'hidden';
441
+ dotsRow.querySelectorAll('.update-step-dot').forEach((el, i) => {
442
+ el.classList.toggle('active', i + 1 === dot);
443
+ el.classList.toggle('done', i + 1 < dot);
444
+ });
445
+ }
446
+
447
+ function buildStepNode(next) {
448
+ const node = document.createElement('div');
449
+ node.className = 'update-step';
450
+ node.dataset.step = next.step;
451
+ node.innerHTML = renderDesktopStep(next);
452
+ return node;
453
+ }
454
+
455
+ function bindStepEvents(scope) {
456
+ scope.querySelectorAll('[data-desktop-action]').forEach((btn) => {
457
+ btn.addEventListener('click', () => act(btn.dataset.desktopAction));
458
+ });
459
+ // The step's own answer takes the focus, so Enter does what the dialog offers.
460
+ const primary = scope.querySelector('.action-btn--primary');
461
+ if (primary) { try { primary.focus({ preventScroll: true }); } catch {} }
462
+ }
463
+
464
+ // What changes inside one step: the bar and its words, a sentence, the file line.
465
+ function patchStep(next) {
466
+ const scope = liveStep();
467
+ if (next.progress) {
468
+ const meter = scope.querySelector('#desktop-update-progress');
469
+ const bar = scope.querySelector('#desktop-update-progress-bar');
470
+ const words = scope.querySelector('#desktop-update-progress-text');
471
+ const unknown = next.progress.percent === null;
472
+ if (bar) {
473
+ bar.style.width = `${unknown ? 100 : next.progress.percent}%`;
474
+ bar.classList.toggle('update-progress-bar--unknown', unknown);
475
+ }
476
+ if (meter) {
477
+ if (unknown) meter.removeAttribute('aria-valuenow');
478
+ else meter.setAttribute('aria-valuenow', String(next.progress.percent));
479
+ }
480
+ if (words && words.textContent !== next.progress.text) words.textContent = next.progress.text;
481
+ }
482
+ const lines = scope.querySelectorAll('.update-desktop-line');
483
+ next.body.forEach((line, i) => { if (lines[i] && lines[i].textContent !== line) lines[i].textContent = line; });
484
+ const file = scope.querySelector('#desktop-update-file');
485
+ if (file && next.file && file.textContent !== next.file) file.textContent = next.file;
486
+ }
487
+
488
+ // The same liquid transition as setStep() in the npm wizard below.
489
+ function setStep(next) {
490
+ if (isAnimating) { pending = next; return; }
491
+ view = next;
492
+ syncDots(next.dot);
493
+
494
+ const outgoing = stepWrap.firstElementChild;
495
+ const incoming = buildStepNode(next);
496
+
497
+ if (!outgoing) {
498
+ stepWrap.appendChild(incoming);
499
+ bindStepEvents(incoming);
500
+ return;
501
+ }
502
+
503
+ if (prefersLessMotion()) {
504
+ stepWrap.replaceChild(incoming, outgoing);
505
+ bindStepEvents(incoming);
506
+ return;
507
+ }
508
+
509
+ isAnimating = true;
510
+ const oldH = outgoing.offsetHeight;
511
+ const pinW = modal.offsetWidth;
512
+ stepWrap.style.height = oldH + 'px';
513
+ modal.style.width = pinW + 'px';
514
+
515
+ incoming.style.position = 'absolute';
516
+ incoming.style.left = '0';
517
+ incoming.style.right = '0';
518
+ incoming.style.top = '0';
519
+ incoming.style.visibility = 'hidden';
520
+ stepWrap.appendChild(incoming);
521
+ bindStepEvents(incoming);
522
+ const newH = incoming.offsetHeight;
523
+
524
+ outgoing.style.position = 'absolute';
525
+ outgoing.style.left = '0';
526
+ outgoing.style.right = '0';
527
+ outgoing.style.top = '0';
528
+ outgoing.classList.add('is-leaving');
529
+
530
+ incoming.style.visibility = '';
531
+ incoming.classList.add('is-entering');
532
+
533
+ requestAnimationFrame(() => {
534
+ stepWrap.style.height = newH + 'px';
535
+ });
536
+
537
+ let settled = false;
538
+ const finalize = () => {
539
+ if (settled) return;
540
+ settled = true;
541
+ incoming.removeEventListener('animationend', onIncomingEnd);
542
+ if (outgoing.parentNode === stepWrap) stepWrap.removeChild(outgoing);
543
+ incoming.classList.remove('is-entering');
544
+ incoming.style.position = '';
545
+ incoming.style.left = '';
546
+ incoming.style.right = '';
547
+ incoming.style.top = '';
548
+ incoming.style.visibility = '';
549
+ stepWrap.style.height = '';
550
+ modal.style.width = '';
551
+ isAnimating = false;
552
+ // A report that came while the step slid in: a new step slides in next, the same one is patched.
553
+ const later = pending;
554
+ pending = null;
555
+ if (later && later.step !== view.step) setStep(later);
556
+ else if (later) { view = later; patchStep(later); }
557
+ };
558
+ const onIncomingEnd = (ev) => {
559
+ if (ev.target === incoming && ev.animationName === 'synabunStepIn') finalize();
560
+ };
561
+ incoming.addEventListener('animationend', onIncomingEnd);
562
+ setTimeout(finalize, 480);
563
+ }
564
+
565
+ const shapeOf = (page) => `${page.step}|${page.dot}|${page.body.length}|${page.actions.map(item => item.id).join(',')}|${page.link ? 1 : 0}`;
566
+
567
+ function update(state) {
568
+ // A check this dialog asked for (Try again): its page stays until the answer.
569
+ if (readDesktopUpdateState(state).status === 'checking') return;
570
+ const next = viewOf(state);
571
+ if (next.step === 'none') { close(); return; }
572
+ keepTicking(next.ticking);
573
+ if (isAnimating) { pending = next; return; }
574
+ // The two pages of the manual install (before and after the download) are one step with two faces;
575
+ // a ready update draws again when what it waits for, or its buttons, change.
576
+ if (!view || shapeOf(next) !== shapeOf(view)) { setStep(next); return; }
577
+ view = next;
578
+ patchStep(next);
579
+ }
580
+
581
+ function keepTicking(on) {
582
+ if (on && !tick) tick = setInterval(() => update(_desktopState), 1000);
583
+ else if (!on && tick) { clearInterval(tick); tick = null; }
584
+ }
585
+
586
+ function setBusy(busy) {
587
+ working = busy;
588
+ liveStep().querySelectorAll('[data-desktop-action]').forEach((btn) => { btn.disabled = busy; });
589
+ }
590
+
591
+ async function act(id) {
592
+ if (working) return;
593
+ // Later on a ready update postpones its restart; everywhere else it only closes the dialog.
594
+ if (id === 'later' && view?.step === 'downloaded') {
595
+ close();
596
+ try { const answer = await desktop.updates.later?.(); if (answer) applyDesktopState(answer, { alert: false }); } catch {}
597
+ return;
598
+ }
599
+ if (id === 'later' || id === 'hide' || id === 'close') { close(); return; }
600
+ if (id === 'backup') { downloadBackup(); return; }
601
+ // Install now: at once when SynaBun is idle, otherwise as soon as it is (the app decides).
602
+ const call = id === 'download' || id === 'manual-download' ? 'download'
603
+ : id === 'install-now' || id === 'move' || id === 'show-file' ? 'install'
604
+ : id === 'retry' ? (view?.retry === 'download' ? 'download' : 'check')
605
+ : null;
606
+ if (!call) return;
607
+ setBusy(true);
608
+ let answer = null;
609
+ try {
610
+ answer = await desktop.updates[call]();
611
+ } catch (err) {
612
+ // The app did not take the request: this dialog says so, the badge keeps the app's last report.
613
+ setBusy(false);
614
+ update({ ...(_desktopState || {}), status: 'error', error: String(err?.message || '') || null });
615
+ return;
616
+ }
617
+ // The answer is a report like any other.
618
+ if (answer) applyDesktopState(answer, { alert: false });
619
+ if (call === 'check' && desktopCheckOutcome(answer) === 'up-to-date') {
620
+ close();
621
+ showUpdateToast({ updates: [], errors: [] });
622
+ return;
623
+ }
624
+ if (_desktopDialog && _desktopDialog.overlay === overlay) setBusy(false);
625
+ }
626
+
627
+ async function downloadBackup() {
628
+ const scope = liveStep();
629
+ const btn = scope.querySelector('#desktop-update-backup');
630
+ const status = scope.querySelector('#desktop-update-backup-status');
631
+ const icon = scope.querySelector('#desktop-update-backup-icon');
632
+ const text = scope.querySelector('#desktop-update-backup-text');
633
+ if (!btn || !status || !icon || !text) return;
634
+
635
+ btn.disabled = true;
636
+ status.style.display = 'flex';
637
+ icon.className = 'update-backup-icon spin';
638
+ icon.textContent = '⟳';
639
+ icon.style.color = '';
640
+ text.textContent = desktopT('update.backupWorking');
641
+
642
+ try {
643
+ const res = await fetch('/api/system/backup');
644
+ if (!res.ok) {
645
+ let errMsg = `HTTP ${res.status}`;
646
+ try { const body = await res.json(); errMsg = body.error || errMsg; } catch {}
647
+ throw new Error(errMsg);
648
+ }
649
+ const blob = await res.blob();
650
+ const disposition = res.headers.get('content-disposition') || '';
651
+ const match = disposition.match(/filename="?([^"]+)"?/);
652
+ const filename = match ? match[1] : 'synabun-backup.zip';
653
+
654
+ const url = URL.createObjectURL(blob);
655
+ const a = document.createElement('a');
656
+ a.href = url; a.download = filename;
657
+ document.body.appendChild(a); a.click(); a.remove();
658
+ URL.revokeObjectURL(url);
659
+
660
+ icon.className = 'update-backup-icon';
661
+ icon.textContent = '✓';
662
+ icon.style.color = '#34c759';
663
+ text.textContent = desktopT('update.backupDone', { file: filename, size: desktopNumber(blob.size / 1024 / 1024, 1) });
664
+ } catch (err) {
665
+ icon.className = 'update-backup-icon';
666
+ icon.textContent = '✗';
667
+ icon.style.color = 'var(--accent-red)';
668
+ text.textContent = desktopT('update.backupFailed', { reason: err.message });
669
+ } finally {
670
+ btn.disabled = false;
671
+ }
672
+ }
673
+
674
+ // While it installs there is nothing to dismiss: SynaBun is closing.
675
+ const dismissable = () => view?.step !== 'installing';
676
+
677
+ function onKey(event) {
678
+ if (event.key !== 'Escape' || !dismissable()) return;
679
+ event.stopPropagation();
680
+ event.preventDefault();
681
+ close();
682
+ }
683
+
684
+ function close() {
685
+ if (!_desktopDialog || _desktopDialog.overlay !== overlay) return;
686
+ _desktopDialog = null;
687
+ keepTicking(false);
688
+ window.removeEventListener('keydown', onKey, true);
689
+ overlay.classList.add('closing');
690
+ setTimeout(() => overlay.remove(), 150);
691
+ }
692
+
693
+ overlay.addEventListener('click', (e) => {
694
+ if (e.target === overlay && dismissable()) close();
695
+ });
696
+ window.addEventListener('keydown', onKey, true);
697
+ _desktopDialog = { overlay, update, close };
698
+ document.body.appendChild(overlay);
699
+ setStep(first);
700
+ keepTicking(first.ticking);
701
+ }
702
+
115
703
  // ── Modal Wizard ──
116
704
 
117
705
  function openUpdateModal() {
118
706
  if (!_versionData) return;
707
+ if (_versionData.desktop) { openDesktopUpdateModal(); return; }
119
708
 
120
709
  let step = 1;
121
710
  let backupDone = false;