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
@@ -3,28 +3,40 @@
3
3
  // Detects whether Claude / Codex / OpenCode CLIs are installed by
4
4
  // querying /api/system/tool-versions. Caches the response, fans out
5
5
  // updates to subscribers, and auto-polls (every 30s) while at least
6
- // one subscribed tool reports installed=null.
6
+ // one subscribed tool is not installed (missing, or found but broken).
7
+ //
8
+ // Each tool's answer carries `state` ('installed' | 'missing' | 'broken'),
9
+ // the version, where it was found, the error of a CLI that would not start,
10
+ // and `install`: whether the server can install it with one click from the
11
+ // vendor's official source. installCli() runs that install (POST
12
+ // /api/cli/install/:tool, an NDJSON stream) and re-checks when it ends.
13
+ // What the panels show for all of it is cli-install-view.js.
7
14
  // ═══════════════════════════════════════════
8
15
 
16
+ import { cliStateOf, cliInstallOffer, INSTALLABLE_CLIS } from './cli-install-view.js';
17
+
18
+ export { cliStateOf, cliInstallOffer };
19
+
9
20
  const TOOL_KEYS = ['claude-code', 'codex', 'opencode', 'gemini'];
10
21
 
22
+ // The vendors' install pages. The server's `guideUrl` wins when it sends one.
11
23
  const CLI_DOC_URLS = {
12
- 'claude-code': 'https://docs.claude.com/en/docs/claude-code/setup',
13
- 'codex': 'https://developers.openai.com/codex/cli/',
24
+ 'claude-code': 'https://code.claude.com/docs/en/setup',
25
+ 'codex': 'https://learn.chatgpt.com/docs/codex/cli',
14
26
  'opencode': 'https://opencode.ai/docs/',
15
27
  'gemini': 'https://github.com/google-gemini/gemini-cli',
16
28
  };
17
29
 
18
- // Fallback only. The server computes the channel-correct command (see
19
- // buildInstallCommand in lib/cli-update-planner.js) and returns it as
20
- // `installCommand`, so a Homebrew user is told `brew install --cask codex`
21
- // rather than an npm command that would create a second conflicting install.
22
- // These npm defaults apply only before the first tool-versions response.
30
+ // Fallback only. The server computes the command that fits the machine (the
31
+ // vendor's recommended installer for its system, or the package manager the
32
+ // other CLIs came from: buildInstallCommand in lib/cli-update-planner.js) and
33
+ // returns it as `installCommand`. These apply only before the first
34
+ // tool-versions response.
23
35
  const CLI_INSTALL_CMDS = {
24
- 'claude-code': 'npm install -g @anthropic-ai/claude-code',
25
- 'codex': 'npm install -g @openai/codex',
26
- 'opencode': 'npm install -g opencode-ai',
27
- 'gemini': 'npm install -g @google/gemini-cli',
36
+ 'claude-code': { posix: 'curl -fsSL https://claude.ai/install.sh | bash', windows: 'irm https://claude.ai/install.ps1 | iex' },
37
+ 'codex': { posix: 'curl -fsSL https://chatgpt.com/codex/install.sh | sh', windows: 'powershell -ExecutionPolicy ByPass -c "irm https://chatgpt.com/codex/install.ps1 | iex"' },
38
+ 'opencode': { posix: 'curl -fsSL https://opencode.ai/install | bash', windows: 'npm install -g opencode-ai' },
39
+ 'gemini': { posix: 'npm install -g @google/gemini-cli', windows: 'npm install -g @google/gemini-cli' },
28
40
  };
29
41
 
30
42
  const CLI_LABELS = {
@@ -36,24 +48,67 @@ const CLI_LABELS = {
36
48
 
37
49
  const POLL_INTERVAL_MS = 30000;
38
50
  const CACHE_TTL_MS = 10000;
51
+ // How often an install another window (or an earlier page) started is asked
52
+ // about, and for how long at most.
53
+ const INSTALL_POLL_MS = 2000;
54
+ const INSTALL_FOLLOW_MAX_MS = 30 * 60 * 1000;
39
55
 
40
56
  let _cache = { fetchedAt: 0, tools: null };
57
+ // What a subscriber is handed for a CLI the answer says nothing about: not
58
+ // "missing" (nothing was said), and `installed: null` for the callers that only
59
+ // read that field.
60
+ const unknownInfo = () => ({ installed: null, state: 'unknown' });
41
61
  let _inflight = null;
42
62
  const _subscribers = new Map();
43
63
  let _pollTimer = null;
64
+ const _installs = new Map(); // toolKey → { promise, listeners: Set }
65
+
66
+ function isWindowsPage() {
67
+ const nav = globalThis.navigator;
68
+ if (!nav) return false;
69
+ if (nav.userAgentData?.platform && /windows/i.test(nav.userAgentData.platform)) return true;
70
+ return /windows/i.test(nav.userAgent || '');
71
+ }
44
72
 
45
73
  export function getCliDocUrl(toolKey) {
74
+ const fromServer = _cache.tools?.[toolKey]?.guideUrl;
75
+ if (typeof fromServer === 'string' && /^https:\/\/[^\s"'<>]+$/i.test(fromServer)) return fromServer;
46
76
  return CLI_DOC_URLS[toolKey] || null;
47
77
  }
48
78
 
49
79
  export function getCliInstallCommand(toolKey) {
50
- return _cache.tools?.[toolKey]?.installCommand || CLI_INSTALL_CMDS[toolKey] || '';
80
+ const fromServer = _cache.tools?.[toolKey]?.installCommand;
81
+ if (fromServer) return fromServer;
82
+ const fallback = CLI_INSTALL_CMDS[toolKey];
83
+ if (!fallback) return '';
84
+ return isWindowsPage() ? fallback.windows : fallback.posix;
51
85
  }
52
86
 
53
87
  export function getCliLabel(toolKey) {
54
88
  return CLI_LABELS[toolKey] || toolKey;
55
89
  }
56
90
 
91
+ /** This CLI in the latest answer, or null before the first one. */
92
+ export function getCliInfo(toolKey) {
93
+ return _cache.tools?.[toolKey] || null;
94
+ }
95
+
96
+ /** 'installed' | 'missing' | 'broken' | 'unknown' (no answer yet), from the latest answer. */
97
+ export function getCliState(toolKey) {
98
+ return cliStateOf(getCliInfo(toolKey));
99
+ }
100
+
101
+ /** The error a found-but-broken CLI gave when it was started, or null. */
102
+ export function getCliError(toolKey) {
103
+ const info = getCliInfo(toolKey);
104
+ return cliStateOf(info) === 'broken' && typeof info?.error === 'string' && info.error ? info.error : null;
105
+ }
106
+
107
+ /** The one-click install the server offers for this CLI, or null (none for this system, or an older server). */
108
+ export function getCliInstallOffer(toolKey) {
109
+ return cliInstallOffer(getCliInfo(toolKey));
110
+ }
111
+
57
112
  async function _fetchToolVersions(force) {
58
113
  if (!force && _cache.tools && (Date.now() - _cache.fetchedAt) < CACHE_TTL_MS) {
59
114
  return _cache.tools;
@@ -74,6 +129,26 @@ async function _fetchToolVersions(force) {
74
129
  return _inflight;
75
130
  }
76
131
 
132
+ // What a subscriber is told about: a change of state, version or place. A
133
+ // broken CLI and a missing one both have `installed: null`, so the version
134
+ // alone would hide a CLI that went from missing to broken (or back).
135
+ function _signature(info) {
136
+ if (!info) return 'none';
137
+ return [cliStateOf(info), info.installed ?? '', info.binPath ?? '', info.error ?? '', JSON.stringify(info.pathStatus || null)].join('|');
138
+ }
139
+
140
+ /** Explicit consent from Add to PATH. The server accepts only a tool key. */
141
+ export async function addCliToPath(toolKey) {
142
+ if (!INSTALLABLE_CLIS.includes(toolKey)) throw new Error('Unknown CLI');
143
+ const res = await fetch(`/api/cli/path/${encodeURIComponent(toolKey)}`, {
144
+ method: 'POST', headers: { 'Content-Type': 'application/json', 'X-SynaBun-UI': '1' }, body: '{}',
145
+ });
146
+ const result = await res.json();
147
+ if (!res.ok && !(res.status === 409 && result.code === 'NOT_NEEDED')) throw new Error(result.error || result.code || `HTTP ${res.status}`);
148
+ await recheckCliStatus(toolKey);
149
+ return result;
150
+ }
151
+
77
152
  // `force` makes a deliberate re-check notify subscribers even when the
78
153
  // version string is unchanged. Without it the Re-check button was a no-op by
79
154
  // construction: a panel that latched a "CLI not installed" banner after a
@@ -82,12 +157,10 @@ async function _fetchToolVersions(force) {
82
157
  // The background poller deliberately keeps the change-only behaviour.
83
158
  function _emitChange(prevTools, nextTools, { force = false } = {}) {
84
159
  for (const key of TOOL_KEYS) {
85
- const prev = prevTools?.[key]?.installed ?? null;
86
- const next = nextTools?.[key]?.installed ?? null;
87
160
  const subs = _subscribers.get(key);
88
161
  if (!subs || !subs.size) continue;
89
- if (force || prev !== next || prevTools == null) {
90
- const info = nextTools?.[key] || { installed: null };
162
+ if (force || prevTools == null || _signature(prevTools?.[key]) !== _signature(nextTools?.[key])) {
163
+ const info = nextTools?.[key] || unknownInfo();
91
164
  for (const cb of subs) {
92
165
  try { cb(info); } catch (e) { console.error('[cli-status] subscriber error:', e); }
93
166
  }
@@ -95,12 +168,18 @@ function _emitChange(prevTools, nextTools, { force = false } = {}) {
95
168
  }
96
169
  }
97
170
 
171
+ // Asked again every 30 s while a subscribed CLI is missing or broken: one
172
+ // installed (or repaired) meanwhile is found without a click. A CLI the answer
173
+ // says nothing about is not waited for.
98
174
  function _hasMissingSubscribed() {
99
175
  for (const key of _subscribers.keys()) {
100
176
  const subs = _subscribers.get(key);
101
177
  if (!subs?.size) continue;
102
- const installed = _cache.tools?.[key]?.installed ?? null;
103
- if (!installed) return true;
178
+ if (!_cache.tools) return true;
179
+ const info = _cache.tools[key];
180
+ if (!info) continue;
181
+ const state = cliStateOf(info);
182
+ if (state === 'missing' || state === 'broken') return true;
104
183
  }
105
184
  return false;
106
185
  }
@@ -132,7 +211,7 @@ function _stopPoller() {
132
211
 
133
212
  export async function getCliStatus(toolKey, { force = false } = {}) {
134
213
  const tools = await _fetchToolVersions(force);
135
- return tools?.[toolKey] || { installed: null, latest: null };
214
+ return tools?.[toolKey] || { ...unknownInfo(), latest: null };
136
215
  }
137
216
 
138
217
  export function subscribeCliStatus(toolKey, callback) {
@@ -147,7 +226,7 @@ export function subscribeCliStatus(toolKey, callback) {
147
226
  _maybeStartPoller();
148
227
  } else {
149
228
  _fetchToolVersions(false).then((tools) => {
150
- try { callback(tools?.[toolKey] || { installed: null }); } catch {}
229
+ try { callback(tools?.[toolKey] || unknownInfo()); } catch {}
151
230
  _maybeStartPoller();
152
231
  }).catch(() => {});
153
232
  }
@@ -165,5 +244,186 @@ export async function recheckCliStatus(toolKey) {
165
244
  const prev = _cache.tools;
166
245
  const next = await _fetchToolVersions(true);
167
246
  _emitChange(prev, next, { force: true });
168
- return next?.[toolKey] || { installed: null };
247
+ return next?.[toolKey] || unknownInfo();
248
+ }
249
+
250
+ // ── One-click install ──────────────────────────────────────────────────────
251
+
252
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
253
+
254
+ /** What a refusal before the stream (a JSON answer) means, as a `done` event. */
255
+ function _refusal(status, body) {
256
+ const code = String(body?.code || '');
257
+ let reason = 'internal';
258
+ if (code === 'UNSUPPORTED' || code === 'UNKNOWN_TOOL' || status === 422) reason = 'unsupported';
259
+ else if (status === 403 || status === 415) reason = 'refused';
260
+ return {
261
+ type: 'done', ok: false, code: reason,
262
+ error: typeof body?.error === 'string' && body.error ? body.error : (code || `HTTP ${status}`),
263
+ manualCommand: typeof body?.manualCommand === 'string' ? body.manualCommand : null,
264
+ guideUrl: typeof body?.guideUrl === 'string' ? body.guideUrl : null,
265
+ };
266
+ }
267
+
268
+ /**
269
+ * The installs the server knows about: { [tool]: { state: 'running' | 'done' | 'failed', step, code, error,
270
+ * log, startedAt, endedAt } }. An older server (no install route) answers {}.
271
+ */
272
+ export async function getCliInstallJobs() {
273
+ try {
274
+ const res = await fetch('/api/cli/install', { headers: { Accept: 'application/json' } });
275
+ if (!res.ok) return {};
276
+ const data = await res.json();
277
+ return data?.jobs && typeof data.jobs === 'object' ? data.jobs : {};
278
+ } catch {
279
+ return {};
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Follow an install that is already running on the server (started from another window, or before this page
285
+ * was loaded) through GET /api/cli/install: its steps and new log lines go to `onEvent`, and the end is a
286
+ * `done` event like the stream's. Resolves that event.
287
+ */
288
+ export async function followCliInstall(toolKey, { onEvent = () => {}, pollMs = INSTALL_POLL_MS, maxMs = INSTALL_FOLLOW_MAX_MS } = {}) {
289
+ const startedAt = Date.now();
290
+ let seenLines = 0;
291
+ let lastStep = null;
292
+ let sawJob = false;
293
+ for (;;) {
294
+ const job = (await getCliInstallJobs())[toolKey];
295
+ if (job && typeof job === 'object') {
296
+ sawJob = true;
297
+ if (job.step && job.step !== lastStep) {
298
+ lastStep = job.step;
299
+ onEvent({ type: 'step', step: job.step });
300
+ }
301
+ const log = Array.isArray(job.log) ? job.log : [];
302
+ if (log.length < seenLines) seenLines = 0; // a new job: its log starts again
303
+ for (const entry of log.slice(seenLines)) onEvent({ type: 'log', line: entry });
304
+ seenLines = log.length;
305
+ if (job.state && job.state !== 'running') {
306
+ if (job.state === 'done') {
307
+ const status = await recheckCliStatus(toolKey).catch(() => null);
308
+ const done = { type: 'done', ok: true, status, path: job.path && typeof job.path === 'object' ? job.path : null };
309
+ onEvent(done);
310
+ return done;
311
+ }
312
+ const failed = { type: 'done', ok: false, code: job.code || 'internal', error: job.error || null, manualCommand: job.manualCommand || null, guideUrl: job.guideUrl || null, log };
313
+ onEvent(failed);
314
+ return failed;
315
+ }
316
+ } else if (sawJob || Date.now() - startedAt > pollMs * 3) {
317
+ // The job is gone (a restarted server), or never showed up: ask about the CLI itself.
318
+ const status = await recheckCliStatus(toolKey).catch(() => null);
319
+ const done = cliStateOf(status) === 'installed'
320
+ ? { type: 'done', ok: true, status, path: null }
321
+ : { type: 'done', ok: false, code: 'connection', error: null };
322
+ onEvent(done);
323
+ return done;
324
+ }
325
+ if (Date.now() - startedAt > maxMs) {
326
+ const done = { type: 'done', ok: false, code: 'timeout', error: null };
327
+ onEvent(done);
328
+ return done;
329
+ }
330
+ await wait(pollMs);
331
+ }
332
+ }
333
+
334
+ async function _readNdjson(res, onEvent) {
335
+ let final = null;
336
+ const take = (text) => {
337
+ const trimmed = text.trim();
338
+ if (!trimmed) return;
339
+ let event;
340
+ try { event = JSON.parse(trimmed); } catch { return; }
341
+ if (!event || typeof event !== 'object') return;
342
+ onEvent(event);
343
+ if (event.type === 'done') final = event;
344
+ };
345
+ if (res.body && typeof res.body.getReader === 'function') {
346
+ const reader = res.body.getReader();
347
+ const decoder = new TextDecoder();
348
+ let buffer = '';
349
+ for (;;) {
350
+ const { value, done } = await reader.read();
351
+ if (done) break;
352
+ buffer += decoder.decode(value, { stream: true });
353
+ let at;
354
+ while ((at = buffer.indexOf('\n')) >= 0) {
355
+ take(buffer.slice(0, at));
356
+ buffer = buffer.slice(at + 1);
357
+ }
358
+ }
359
+ buffer += decoder.decode();
360
+ take(buffer);
361
+ } else {
362
+ for (const piece of String(await res.text()).split('\n')) take(piece);
363
+ }
364
+ return final;
365
+ }
366
+
367
+ /**
368
+ * Install a CLI with one click: the server runs the vendor's official installer for its system and streams what
369
+ * happens (`start`, `step`, `progress`, `log`, then one `done`). Every event goes to `onEvent`; the promise
370
+ * resolves the `done` event. After a successful install the status is asked again (with force), so every panel
371
+ * subscribed to this CLI hears of it. Never rejects.
372
+ *
373
+ * One install per CLI at a time in this page: a second call follows the first one.
374
+ * An install already running on the server (409 BUSY) is followed through GET /api/cli/install.
375
+ */
376
+ export function installCli(toolKey, { onEvent = () => {}, pollMs = INSTALL_POLL_MS } = {}) {
377
+ const running = _installs.get(toolKey);
378
+ if (running) {
379
+ running.listeners.add(onEvent);
380
+ return running.promise;
381
+ }
382
+ const listeners = new Set([onEvent]);
383
+ const emit = (event) => {
384
+ for (const listener of listeners) {
385
+ try { listener(event); } catch (e) { console.error('[cli-status] install listener error:', e); }
386
+ }
387
+ };
388
+ const promise = (async () => {
389
+ if (!INSTALLABLE_CLIS.includes(toolKey)) {
390
+ const refused = { type: 'done', ok: false, code: 'unsupported', error: null };
391
+ emit(refused);
392
+ return refused;
393
+ }
394
+ let final = null;
395
+ try {
396
+ const res = await fetch(`/api/cli/install/${encodeURIComponent(toolKey)}`, {
397
+ method: 'POST',
398
+ headers: { 'Content-Type': 'application/json', 'X-SynaBun-UI': '1', Accept: 'application/x-ndjson' },
399
+ body: '{}',
400
+ });
401
+ if (!res.ok) {
402
+ let body = {};
403
+ try { body = await res.json(); } catch { /* no body */ }
404
+ if (res.status === 409 && String(body?.code || '').toUpperCase() === 'BUSY') {
405
+ return await followCliInstall(toolKey, { onEvent: emit, pollMs });
406
+ }
407
+ final = _refusal(res.status, body);
408
+ emit(final);
409
+ return final;
410
+ }
411
+ final = await _readNdjson(res, emit);
412
+ } catch {
413
+ final = null;
414
+ }
415
+ if (!final) {
416
+ // The connection closed without an end: the server may still be at it.
417
+ return await followCliInstall(toolKey, { onEvent: emit, pollMs });
418
+ }
419
+ if (final.ok) await recheckCliStatus(toolKey).catch(() => null);
420
+ return final;
421
+ })().finally(() => { _installs.delete(toolKey); });
422
+ _installs.set(toolKey, { promise, listeners });
423
+ return promise;
424
+ }
425
+
426
+ /** Whether this page has an install of this CLI under way. */
427
+ export function isCliInstalling(toolKey) {
428
+ return _installs.has(toolKey);
169
429
  }
@@ -626,7 +626,8 @@ export function statusRows({ init = null, account = null, tab = {} } = {}) {
626
626
  add('Model', init.model);
627
627
  add('Claude Code', init.cliVersion + (tab.sdkVersion ? ` · SDK ${tab.sdkVersion}` : ''));
628
628
  add('Working directory', init.cwd);
629
- if (account?.email || account?.organization) add('Account', [account.email, account.organization, account.subscriptionType].filter(Boolean).join(' · '));
629
+ if (account?.authMethod === 'apiKey') add('Account', 'API key');
630
+ else if (account?.email || account?.organization) add('Account', [account.email, account.organization, account.subscriptionType].filter(Boolean).join(' · '));
630
631
  add('Credential', KEY_SOURCES[init.apiKeySource] || init.apiKeySource);
631
632
  add('Permission mode', init.permissionMode);
632
633
  if (init.effort) add('Effort', init.effort);
@@ -166,6 +166,10 @@ export const CP_STYLES = `
166
166
  #cp-mode .cp-dd-item-note { margin-left: 6px; font-size: 9.5px; color: rgba(255,255,255,0.35); }
167
167
  .cp-plan-btn-bypass { border-color: rgba(239,112,112,0.45) !important; color: #ef7070 !important; }
168
168
 
169
+ /* ═══ Claude accounts: the header account menu is account-menu.js (shared with the Codex panel, its own styles) ═══ */
170
+ /* Its trigger gives way before the session name does. */
171
+ .cp-header-actions:has(> .acct-menu-trigger) { flex-shrink: 1; min-width: 0; }
172
+
169
173
  /* ═══ Rewind ═══ */
170
174
  .msg-user { position: relative; }
171
175
  .cp-rewind-btn { position: absolute; top: -8px; right: 4px; opacity: 0; pointer-events: none; transition: opacity 0.15s; font: 600 9px/1 Inter, sans-serif; color: rgba(220,195,140,0.9); background: rgba(30,25,18,0.95); border: 1px solid rgba(212,168,72,0.35); border-radius: 8px; padding: 3px 7px; cursor: pointer; z-index: 3; }
@@ -361,4 +365,46 @@ a.cp-chip:hover { text-decoration: underline; }
361
365
  .cp-worker-end .post-plan-actions { margin-top: 8px; }
362
366
  .cp-worker-end .post-plan-action:disabled { opacity: 0.45; cursor: default; }
363
367
  .cp-worker-end .post-plan-action:focus-visible { outline: 1px solid rgba(255,255,255,0.45); outline-offset: 2px; }
368
+
369
+ /* ═══ CLI banner (ui-cli-banner.js): Claude Code not installed / installing / failed to start ═══ */
370
+ /* Above the transcript, warm like the panel; red once something failed, green for where an install put it. */
371
+ .cp-cli-banner { flex-shrink: 0; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px; align-items: start; margin: 6px 8px 0; padding: 10px 12px; border: 1px solid rgba(255,180,80,0.45); border-radius: 8px; background: rgba(255,180,80,0.08); color: rgba(255,235,200,0.92); font-size: 11px; line-height: 1.4; pointer-events: auto; position: relative; z-index: 5; }
372
+ .cp-cli-banner [hidden] { display: none !important; }
373
+ .cp-cli-banner--broken, .cp-cli-banner--failed { border-color: rgba(239,112,112,0.45); background: rgba(239,112,112,0.07); }
374
+ .cp-cli-banner--done { border-color: rgba(95,207,131,0.4); background: rgba(95,207,131,0.06); }
375
+ .cp-cli-banner-icon { width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: rgba(255,180,80,0.25); color: rgba(255,210,140,1); font-weight: 700; }
376
+ .cp-cli-banner--broken .cp-cli-banner-icon, .cp-cli-banner--failed .cp-cli-banner-icon { background: rgba(239,112,112,0.2); color: #ef7070; }
377
+ .cp-cli-banner--done .cp-cli-banner-icon { background: rgba(95,207,131,0.18); color: #5fcf83; }
378
+ .cp-cli-banner--installing .cp-cli-banner-icon { animation: cp-cli-working 1.2s ease-in-out infinite; }
379
+ @keyframes cp-cli-working { 0%, 100% { opacity: 0.45; } 50% { opacity: 1; } }
380
+ .cp-cli-banner--nudge { animation: cp-cli-nudge 0.5s ease; }
381
+ @keyframes cp-cli-nudge { 0%, 100% { transform: none; } 25% { transform: translateX(-3px); } 75% { transform: translateX(3px); } }
382
+ .cp-cli-banner-text { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
383
+ .cp-cli-banner-title { font-size: 12px; font-weight: 600; color: rgba(255,210,140,0.95); }
384
+ .cp-cli-banner--broken .cp-cli-banner-title, .cp-cli-banner--failed .cp-cli-banner-title { color: #ef7070; }
385
+ .cp-cli-banner--done .cp-cli-banner-title { color: #5fcf83; }
386
+ .cp-cli-banner-body, .cp-cli-banner-note, .cp-cli-banner-step { font-size: 11px; color: rgba(255,255,255,0.7); overflow-wrap: anywhere; }
387
+ .cp-cli-banner-command { display: flex; align-items: flex-start; gap: 6px; }
388
+ .cp-cli-banner-command code, .cp-cli-banner-error code { flex: 1; min-width: 0; padding: 3px 6px; background: rgba(0,0,0,0.35); border-radius: 4px; font: 10.5px/1.45 "SF Mono", Menlo, monospace; color: rgba(255,255,255,0.85); white-space: pre-wrap; word-break: break-all; user-select: text; }
389
+ .cp-cli-banner-error code { display: block; max-height: 96px; overflow: auto; color: rgba(255,200,200,0.92); }
390
+ .cp-cli-banner-copy { flex-shrink: 0; height: 22px; padding: 0 8px; border: 1px solid rgba(255,255,255,0.14); border-radius: 5px; background: transparent; color: rgba(255,255,255,0.7); font-family: inherit; font-size: 11px; cursor: pointer; }
391
+ .cp-cli-banner-copy:hover { color: rgba(255,255,255,0.92); border-color: rgba(255,255,255,0.3); }
392
+ .cp-cli-banner-progress { display: flex; flex-direction: column; gap: 4px; }
393
+ .cp-cli-banner-bar { height: 3px; border-radius: 2px; background: rgba(255,255,255,0.08); overflow: hidden; }
394
+ .cp-cli-banner-bar > span { display: block; height: 100%; width: 0; background: rgba(212,162,127,0.85); transition: width 0.2s ease; }
395
+ .cp-cli-banner-log { margin: 0; max-height: 84px; overflow: auto; padding: 4px 6px; background: rgba(0,0,0,0.3); border-radius: 4px; font: 10px/1.45 "SF Mono", Menlo, monospace; color: rgba(255,255,255,0.55); white-space: pre-wrap; word-break: break-all; }
396
+ .cp-cli-banner-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 2px; }
397
+ .cp-cli-banner-install, .cp-cli-banner-link, .cp-cli-banner-path, .cp-cli-banner-recheck, .cp-cli-banner-dismiss { display: inline-flex; align-items: center; gap: 4px; height: 22px; padding: 0 10px; font-family: inherit; font-size: 12px; font-weight: 500; line-height: 1; border-radius: 5px; border: 1px solid rgba(255,255,255,0.2); background: rgba(255,255,255,0.05); color: rgba(255,255,255,0.9); text-decoration: none; cursor: pointer; user-select: none; }
398
+ .cp-cli-banner-install { background: rgba(212,162,127,0.22); border-color: rgba(212,162,127,0.55); color: rgba(255,236,220,0.96); }
399
+ .cp-cli-banner-install:hover:not(:disabled) { background: rgba(212,162,127,0.34); border-color: rgba(212,162,127,0.75); }
400
+ .cp-cli-banner-link:hover, .cp-cli-banner-path:hover:not(:disabled), .cp-cli-banner-recheck:hover:not(:disabled), .cp-cli-banner-dismiss:hover { background: rgba(255,255,255,0.12); border-color: rgba(255,255,255,0.4); }
401
+ .cp-cli-banner-install:disabled, .cp-cli-banner-path:disabled, .cp-cli-banner-recheck:disabled { opacity: 0.5; cursor: default; }
402
+ .cp-cli-banner button:focus-visible, .cp-cli-banner a:focus-visible { outline: 2px solid rgba(212,162,127,0.9); outline-offset: 2px; }
403
+ @media (prefers-reduced-motion: reduce) {
404
+ .cp-cli-banner--installing .cp-cli-banner-icon, .cp-cli-banner--nudge { animation: none; }
405
+ .cp-cli-banner--nudge { box-shadow: 0 0 0 1px rgba(255,180,80,0.8); }
406
+ .cp-cli-banner-bar > span { transition: none; }
407
+ }
408
+ .claude-panel.cp-cli-blocked .cp-send { opacity: 0.4 !important; cursor: not-allowed !important; }
409
+ .claude-panel.cp-cli-blocked .cp-input { opacity: 0.7; }
364
410
  `;
@@ -0,0 +1,39 @@
1
+ // ═══════════════════════════════════════════
2
+ // SynaBun Neural Interface — the desktop app's bridge
3
+ // ═══════════════════════════════════════════
4
+ //
5
+ // Inside the SynaBun desktop app (packaging/desktop) the window's preload puts
6
+ // `window.synabunDesktop` on the pages of the app's own server: the app's
7
+ // version and system, its updater, and Quit. A browser tab has none, and every
8
+ // caller then keeps what an npm or Git install does.
9
+ //
10
+ // No imports and no DOM: `node --test` loads this file as it is
11
+ // (tests/desktop-update-view.test.mjs).
12
+
13
+ /** The bridge of the desktop app, or null in a browser. Only one with the updater the app exposes counts. */
14
+ export function desktopBridge(win = globalThis.window) {
15
+ let bridge;
16
+ try { bridge = win?.synabunDesktop; } catch { return null; }
17
+ if (!bridge || typeof bridge !== 'object') return null;
18
+ const updates = bridge.updates;
19
+ if (!updates || typeof updates !== 'object' || typeof updates.state !== 'function' || typeof updates.check !== 'function') return null;
20
+ return bridge;
21
+ }
22
+
23
+ /** True inside the window of the SynaBun desktop app. */
24
+ export function isDesktopApp(win = globalThis.window) {
25
+ return desktopBridge(win) !== null;
26
+ }
27
+
28
+ /**
29
+ * The title bar of the desktop app's main window (packaging/desktop/src/titlebar.mjs): its facts
30
+ * ({ style, platform, height, controls, appMenu }), its state, the app's menu and the caption buttons' colours.
31
+ * Null in a browser, in the installed PWA, and in any other window of the app (a detached session has a native frame).
32
+ */
33
+ export function desktopWindow(win = globalThis.window) {
34
+ const api = desktopBridge(win)?.window;
35
+ if (!api || typeof api !== 'object' || typeof api.state !== 'function' || typeof api.setColors !== 'function') return null;
36
+ const facts = api.titleBar;
37
+ if (!facts || facts.style !== 'overlay' || !['darwin', 'win32', 'linux'].includes(facts.platform)) return null;
38
+ return api;
39
+ }
@@ -0,0 +1,59 @@
1
+ // ═══════════════════════════════════════════
2
+ // SynaBun Neural Interface — the desktop app's title bar on a page without the navbar
3
+ // ═══════════════════════════════════════════
4
+ //
5
+ // The SynaBun desktop app's main window has no native title bar
6
+ // (packaging/desktop/src/titlebar.mjs). A page of it without #title-bar
7
+ // (onboarding.html) declares that it lays out its own top with
8
+ // <meta name="synabun-titlebar">, then calls initDesktopPageTitleBar(): a strip
9
+ // along its top drags the window (the system double-clicks it too) and the
10
+ // caption buttons (Windows, Linux) take the page's background. The strip comes
11
+ // first in the page, under what the page draws there, so a control drawn over
12
+ // it keeps its clicks when it says -webkit-app-region: no-drag.
13
+ //
14
+ // Imports only import-free modules: the wizard still loads nothing of the app.
15
+ // The map pages use desktop-titlebar.js.
16
+
17
+ import { desktopWindow } from './desktop-bridge.js';
18
+ import { colorsKey, overlayColorsFor } from './desktop-titlebar-view.js';
19
+
20
+ /** The bar's text tone for the caption symbols (DESIGN.md nav-bar: text-primary), as the page defines it now. */
21
+ export function textTone(doc, view) {
22
+ return view.getComputedStyle(doc.documentElement).getPropertyValue('--t-primary').trim() || 'rgba(255, 255, 255, 0.7)';
23
+ }
24
+
25
+ /** A sender of the caption buttons' colours that only sends a change; the page keeps the last pair on <html>. */
26
+ export function colorSender(api, doc) {
27
+ let last = '';
28
+ return (colors) => {
29
+ const key = colorsKey(colors);
30
+ if (!key || key === last) return;
31
+ last = key;
32
+ doc.documentElement.dataset.desktopTitlebarColors = key;
33
+ Promise.resolve(api.setColors(colors)).catch(() => {});
34
+ };
35
+ }
36
+
37
+ /** A page of the main window without the navbar: the strip, the classes, the caption colours. Null outside the app. */
38
+ export function initDesktopPageTitleBar({ doc = document, view = window } = {}) {
39
+ const api = desktopWindow(view);
40
+ if (!api || !doc.body) return null;
41
+ const facts = api.titleBar;
42
+ const html = doc.documentElement;
43
+ html.classList.add('desktop-titlebar', `desktop-titlebar--${facts.platform}`, 'desktop-titlebar--page');
44
+ let strip = doc.getElementById('synabun-desktop-drag-strip');
45
+ if (!strip) {
46
+ strip = doc.createElement('div');
47
+ strip.id = 'synabun-desktop-drag-strip';
48
+ strip.setAttribute('aria-hidden', 'true');
49
+ strip.style.cssText = `position:fixed;top:0;left:0;right:0;height:env(titlebar-area-height,${facts.height}px);`
50
+ + '-webkit-app-region:drag;app-region:drag;z-index:1;pointer-events:none;';
51
+ doc.body.prepend(strip);
52
+ }
53
+ const send = colorSender(api, doc);
54
+ const sendColors = () => {
55
+ try { send(overlayColorsFor({ bar: 'transparent', base: view.getComputedStyle(doc.body).backgroundColor, text: textTone(doc, view) })); } catch {}
56
+ };
57
+ sendColors();
58
+ return { facts, strip, refresh: sendColors };
59
+ }