gloomberb 0.5.0 → 0.6.0

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 (500) hide show
  1. package/README.md +91 -11
  2. package/package.json +12 -5
  3. package/src/app.tsx +414 -191
  4. package/src/architecture/import-boundaries.test.ts +118 -0
  5. package/src/assets/gloomberb-logo.png +0 -0
  6. package/src/assets/gloomberb-logo.svg +73 -0
  7. package/src/brokers/account-cache.ts +157 -0
  8. package/src/brokers/profile-form.test.ts +73 -0
  9. package/src/brokers/profile-form.ts +109 -0
  10. package/src/brokers/remote-broker-adapter.ts +91 -0
  11. package/src/brokers/sync-broker-instance.test.ts +124 -1
  12. package/src/brokers/sync-broker-instance.ts +113 -17
  13. package/src/capabilities/factories.ts +100 -0
  14. package/src/capabilities/index.ts +4 -0
  15. package/src/capabilities/registry.test.ts +137 -0
  16. package/src/capabilities/registry.ts +131 -0
  17. package/src/capabilities/service-capabilities.ts +27 -0
  18. package/src/capabilities/types.ts +84 -0
  19. package/src/cli/commands/ticker.ts +11 -3
  20. package/src/cli/context.ts +19 -13
  21. package/src/cli/helpers.ts +1 -0
  22. package/src/cli/types.ts +2 -2
  23. package/src/components/chart/chart-controller.ts +4 -24
  24. package/src/components/chart/chart-data.ts +3 -14
  25. package/src/components/chart/chart-indicator-selector.tsx +28 -6
  26. package/src/components/chart/chart-pane-settings.ts +15 -28
  27. package/src/components/chart/chart-renderer.ts +74 -99
  28. package/src/components/chart/chart-resolution.ts +10 -10
  29. package/src/components/chart/chart-scroll.test.ts +6 -5
  30. package/src/components/chart/chart-scroll.ts +2 -2
  31. package/src/components/chart/chart-types.ts +0 -1
  32. package/src/components/chart/chart-viewport.ts +2 -1
  33. package/src/components/chart/chart.test.ts +37 -0
  34. package/src/components/chart/comparison-chart-data.ts +3 -17
  35. package/src/components/chart/comparison-chart-renderer.test.ts +27 -4
  36. package/src/components/chart/comparison-chart-renderer.ts +1 -1
  37. package/src/components/chart/comparison-stock-chart.tsx +304 -108
  38. package/src/components/chart/cursor-motion.ts +3 -2
  39. package/src/components/chart/indicators/bands.test.ts +0 -14
  40. package/src/components/chart/indicators/moving-averages.test.ts +0 -15
  41. package/src/components/chart/indicators/options.ts +3 -3
  42. package/src/components/chart/indicators/oscillators.test.ts +0 -21
  43. package/src/components/chart/indicators/types.ts +1 -2
  44. package/src/components/chart/native/chart-rasterizer.ts +37 -44
  45. package/src/components/chart/native/kitty-adapter.ts +2 -5
  46. package/src/components/chart/native/kitty-manager.ts +1 -1
  47. package/src/components/chart/native/kitty-protocol.ts +0 -1
  48. package/src/components/chart/native/kitty-support.ts +1 -1
  49. package/src/components/chart/native/kitty.test.ts +30 -13
  50. package/src/components/chart/native/renderer-selection.ts +1 -1
  51. package/src/components/chart/native/surface-manager.ts +1 -1
  52. package/src/components/chart/static-chart-surface.test.tsx +51 -0
  53. package/src/components/chart/static-chart-surface.tsx +191 -0
  54. package/src/components/chart/stock-chart-renderer-switch.test.tsx +242 -35
  55. package/src/components/chart/stock-chart.tsx +438 -141
  56. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
  57. package/src/components/command-bar/command-bar.test.tsx +269 -20
  58. package/src/components/command-bar/command-bar.tsx +830 -353
  59. package/src/components/command-bar/command-registry.ts +10 -20
  60. package/src/components/command-bar/helpers.test.ts +1 -1
  61. package/src/components/command-bar/helpers.ts +9 -11
  62. package/src/components/command-bar/pane-template-display.ts +4 -8
  63. package/src/components/command-bar/root-shortcuts.ts +12 -11
  64. package/src/components/command-bar/view-model.test.ts +8 -42
  65. package/src/components/command-bar/view-model.ts +8 -36
  66. package/src/components/command-bar/workflow-ops.test.ts +76 -2
  67. package/src/components/command-bar/workflow-ops.ts +40 -9
  68. package/src/components/command-bar/workflow-types.ts +2 -1
  69. package/src/components/data-table-stack-view.test.tsx +206 -0
  70. package/src/components/data-table-stack-view.tsx +152 -0
  71. package/src/components/data-table-view.test.tsx +188 -0
  72. package/src/components/data-table-view.tsx +279 -0
  73. package/src/components/feed-data-table-stack-view.test.tsx +225 -0
  74. package/src/components/feed-data-table-stack-view.tsx +365 -0
  75. package/src/components/index.ts +26 -31
  76. package/src/components/layout/detached-pane-shell.tsx +185 -0
  77. package/src/components/layout/floating-pane.tsx +87 -13
  78. package/src/components/layout/header.tsx +139 -49
  79. package/src/components/layout/pane-content.tsx +40 -0
  80. package/src/components/layout/pane-footer.test.tsx +195 -0
  81. package/src/components/layout/pane-footer.tsx +410 -0
  82. package/src/components/layout/pane-header.test.tsx +39 -0
  83. package/src/components/layout/pane-header.tsx +192 -14
  84. package/src/components/layout/pane-sizing.ts +13 -4
  85. package/src/components/layout/pane-title.ts +35 -0
  86. package/src/components/layout/pane.tsx +56 -8
  87. package/src/components/layout/shell.test.tsx +322 -19
  88. package/src/components/layout/shell.tsx +1010 -366
  89. package/src/components/layout/status-bar.test.tsx +17 -10
  90. package/src/components/layout/status-bar.tsx +367 -100
  91. package/src/components/layout/titlebar-overlay.ts +2 -0
  92. package/src/components/markdown-editor.tsx +29 -14
  93. package/src/components/markdown-text.tsx +25 -24
  94. package/src/components/onboarding/onboarding-wizard.test.tsx +8 -3
  95. package/src/components/onboarding/onboarding-wizard.tsx +347 -473
  96. package/src/components/pane-settings-dialog.tsx +521 -89
  97. package/src/components/pane-template-wizard.test.tsx +1 -1
  98. package/src/components/pane-template-wizard.tsx +38 -37
  99. package/src/components/price-selector-dialog.tsx +22 -21
  100. package/src/components/ticker-badge-list.tsx +53 -0
  101. package/src/components/ticker-badge-text.test.tsx +44 -1
  102. package/src/components/ticker-badge-text.tsx +78 -38
  103. package/src/components/ticker-badge.tsx +28 -7
  104. package/src/components/ticker-list-table-view.tsx +248 -0
  105. package/src/components/ticker-list-table.test.tsx +47 -1
  106. package/src/components/ticker-list-table.tsx +212 -41
  107. package/src/components/toggle-list.tsx +114 -8
  108. package/src/components/ui/button.tsx +24 -16
  109. package/src/components/ui/choice-dialog.tsx +89 -0
  110. package/src/components/ui/data-table.tsx +313 -114
  111. package/src/components/ui/external-link.tsx +52 -13
  112. package/src/components/ui/fields.tsx +44 -30
  113. package/src/components/ui/frame.tsx +24 -57
  114. package/src/components/ui/index.ts +15 -50
  115. package/src/components/ui/list-view.tsx +65 -25
  116. package/src/components/ui/loading.tsx +5 -60
  117. package/src/components/ui/message-composer.test.tsx +81 -0
  118. package/src/components/ui/message-composer.tsx +139 -0
  119. package/src/components/ui/multi-select-dialog.tsx +100 -32
  120. package/src/components/ui/multi-select.ts +112 -0
  121. package/src/components/ui/native-select.tsx +120 -0
  122. package/src/components/ui/page-stack-view.tsx +49 -19
  123. package/src/components/ui/remote-image.tsx +75 -0
  124. package/src/components/ui/row-value-cache.test.ts +17 -0
  125. package/src/components/ui/row-value-cache.ts +41 -0
  126. package/src/components/ui/shortcut-hint.tsx +55 -0
  127. package/src/components/ui/status.tsx +12 -67
  128. package/src/components/ui/table-layout.ts +59 -0
  129. package/src/components/ui/tabs.tsx +249 -27
  130. package/src/components/ui/toggle.tsx +19 -127
  131. package/src/components/ui/ui.test.tsx +266 -53
  132. package/src/components/use-double-click-activation.ts +7 -2
  133. package/src/components/use-external-link-footer.ts +79 -0
  134. package/src/core/app-services.ts +95 -0
  135. package/src/core/state/app-state.ts +886 -0
  136. package/src/{state → core/state}/session-persistence.ts +7 -6
  137. package/src/data/config-layout.ts +225 -0
  138. package/src/data/config-store-node.ts +390 -0
  139. package/src/data/config-store.test.ts +72 -0
  140. package/src/data/config-store.ts +35 -478
  141. package/src/data/sqlite-json.ts +2 -5
  142. package/src/data/sqlite-retry.ts +1 -0
  143. package/src/index.tsx +2 -68
  144. package/src/market-data/coordinator-subscriptions.test.ts +125 -0
  145. package/src/market-data/coordinator.test.ts +73 -4
  146. package/src/market-data/coordinator.ts +190 -24
  147. package/src/market-data/hooks.test.tsx +1 -1
  148. package/src/market-data/hooks.tsx +109 -87
  149. package/src/market-data/query-store.ts +3 -3
  150. package/src/market-data/request-types.test.ts +0 -14
  151. package/src/market-data/result-types.ts +1 -0
  152. package/src/news/aggregator.test.ts +267 -0
  153. package/src/news/aggregator.ts +452 -0
  154. package/src/news/hooks.ts +39 -0
  155. package/src/news/ticker-symbols.ts +21 -0
  156. package/src/news/types.ts +77 -0
  157. package/src/notifications/app-notifier.ts +21 -10
  158. package/src/pane-settings.test.ts +0 -2
  159. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +124 -134
  160. package/src/plugins/builtin/ai/gloomberb-cli.ts +14 -13
  161. package/src/plugins/builtin/ai/providers.ts +1 -9
  162. package/src/plugins/builtin/ai/runner.ts +41 -5
  163. package/src/plugins/builtin/ai/screener-pane.test.tsx +37 -55
  164. package/src/plugins/builtin/ai/screener-pane.tsx +231 -276
  165. package/src/plugins/builtin/ai/settings.ts +0 -1
  166. package/src/plugins/builtin/ai/utils.ts +1 -71
  167. package/src/plugins/builtin/alerts/alert-engine.test.ts +1 -38
  168. package/src/plugins/builtin/alerts/index.test.tsx +61 -82
  169. package/src/plugins/builtin/alerts/index.tsx +263 -155
  170. package/src/plugins/builtin/alerts/types.ts +7 -0
  171. package/src/plugins/builtin/analytics/index.test.tsx +13 -8
  172. package/src/plugins/builtin/analytics/index.tsx +78 -120
  173. package/src/plugins/builtin/analytics/metrics.test.ts +0 -4
  174. package/src/plugins/builtin/ask-ai.test.tsx +55 -54
  175. package/src/plugins/builtin/broker-manager/index.test.tsx +168 -0
  176. package/src/plugins/builtin/broker-manager/index.tsx +759 -0
  177. package/src/plugins/builtin/broker-manager/model.test.ts +107 -0
  178. package/src/plugins/builtin/broker-manager/model.ts +142 -0
  179. package/src/plugins/builtin/chat-controller.test.ts +150 -0
  180. package/src/plugins/builtin/chat-controller.ts +88 -16
  181. package/src/plugins/builtin/chat.test.tsx +283 -36
  182. package/src/plugins/builtin/chat.tsx +373 -172
  183. package/src/plugins/builtin/cloud-auth-actions.tsx +72 -0
  184. package/src/plugins/builtin/cloud-tweets.tsx +896 -0
  185. package/src/plugins/builtin/comparison-chart.test.tsx +46 -57
  186. package/src/plugins/builtin/comparison-chart.tsx +11 -11
  187. package/src/plugins/builtin/correlation/compute.test.ts +0 -17
  188. package/src/plugins/builtin/correlation/index.tsx +51 -46
  189. package/src/plugins/builtin/correlation/settings.ts +1 -2
  190. package/src/plugins/builtin/debug.tsx +99 -70
  191. package/src/plugins/builtin/earnings/earnings-cache.test.ts +2 -2
  192. package/src/plugins/builtin/earnings/earnings-cache.ts +12 -3
  193. package/src/plugins/builtin/earnings/earnings-data.test.ts +42 -11
  194. package/src/plugins/builtin/earnings/earnings-data.ts +70 -5
  195. package/src/plugins/builtin/earnings/index.tsx +187 -94
  196. package/src/plugins/builtin/econ/calendar-source.ts +9 -116
  197. package/src/plugins/builtin/econ/fred-client.test.ts +1 -14
  198. package/src/plugins/builtin/econ/fred-series-map.ts +1 -0
  199. package/src/plugins/builtin/econ/index.tsx +236 -421
  200. package/src/plugins/builtin/econ/types.ts +1 -0
  201. package/src/plugins/builtin/fx-matrix/index.tsx +43 -45
  202. package/src/plugins/builtin/fx-matrix/pairs.ts +1 -0
  203. package/src/plugins/builtin/help.test.tsx +120 -60
  204. package/src/plugins/builtin/help.tsx +266 -82
  205. package/src/plugins/builtin/holders/index.test.ts +45 -0
  206. package/src/plugins/builtin/holders/index.tsx +949 -0
  207. package/src/plugins/builtin/insider/index.tsx +207 -253
  208. package/src/plugins/builtin/insider/insider-data.test.ts +1 -11
  209. package/src/plugins/builtin/insider/insider-data.ts +1 -0
  210. package/src/plugins/builtin/layout-manager.tsx +12 -40
  211. package/src/plugins/builtin/market-movers/index.tsx +70 -144
  212. package/src/plugins/builtin/market-movers/screener.test.ts +0 -18
  213. package/src/plugins/builtin/news-wire/breaking-notifications.test.ts +98 -0
  214. package/src/plugins/builtin/news-wire/breaking-notifications.ts +110 -0
  215. package/src/plugins/builtin/news-wire/breaking-pane.tsx +157 -0
  216. package/src/plugins/builtin/news-wire/categories.ts +114 -0
  217. package/src/plugins/builtin/news-wire/default-feeds.ts +3 -0
  218. package/src/plugins/builtin/news-wire/digest-store.ts +44 -0
  219. package/src/plugins/builtin/news-wire/feed-config.test.ts +90 -0
  220. package/src/plugins/builtin/news-wire/feed-config.ts +216 -0
  221. package/src/plugins/builtin/news-wire/feed-pane.tsx +21 -0
  222. package/src/plugins/builtin/news-wire/hash.ts +9 -0
  223. package/src/plugins/builtin/news-wire/index.tsx +92 -0
  224. package/src/plugins/builtin/news-wire/industry-pane.tsx +140 -0
  225. package/src/plugins/builtin/news-wire/news-detail-view.tsx +256 -0
  226. package/src/plugins/builtin/news-wire/news-footer.ts +28 -0
  227. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +94 -0
  228. package/src/plugins/builtin/news-wire/news-query-presets.ts +40 -0
  229. package/src/plugins/builtin/news-wire/news-table.test.tsx +206 -0
  230. package/src/plugins/builtin/news-wire/news-table.tsx +296 -0
  231. package/src/plugins/builtin/news-wire/persisted-articles.ts +92 -0
  232. package/src/plugins/builtin/news-wire/read-state.test.ts +30 -0
  233. package/src/plugins/builtin/news-wire/read-state.ts +76 -0
  234. package/src/plugins/builtin/news-wire/rss-parser.test.ts +184 -0
  235. package/src/plugins/builtin/news-wire/rss-parser.ts +213 -0
  236. package/src/plugins/builtin/news-wire/rss-source.test.ts +159 -0
  237. package/src/plugins/builtin/news-wire/rss-source.ts +181 -0
  238. package/src/plugins/builtin/news-wire/top-pane.tsx +21 -0
  239. package/src/plugins/builtin/news.tsx +98 -25
  240. package/src/plugins/builtin/notes-files.ts +51 -10
  241. package/src/plugins/builtin/notes.tsx +332 -131
  242. package/src/plugins/builtin/options.test.tsx +185 -0
  243. package/src/plugins/builtin/options.tsx +346 -206
  244. package/src/plugins/builtin/plugin-groups.ts +92 -0
  245. package/src/plugins/builtin/portfolio-list/header.tsx +72 -37
  246. package/src/plugins/builtin/portfolio-list/index.tsx +25 -42
  247. package/src/plugins/builtin/portfolio-list/metrics.ts +1 -1
  248. package/src/plugins/builtin/portfolio-list/pane.tsx +246 -164
  249. package/src/plugins/builtin/portfolio-list/settings.ts +19 -51
  250. package/src/plugins/builtin/portfolio-list/summary.tsx +21 -16
  251. package/src/plugins/builtin/portfolio-list/table.tsx +66 -25
  252. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +1 -1
  253. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +1 -1
  254. package/src/plugins/builtin/portfolio-list.test.ts +60 -1
  255. package/src/plugins/builtin/portfolio-list.test.tsx +122 -55
  256. package/src/plugins/builtin/quote-monitor.test.tsx +2 -27
  257. package/src/plugins/builtin/research/index.tsx +724 -0
  258. package/src/plugins/builtin/sec.tsx +67 -19
  259. package/src/plugins/builtin/sectors/index.tsx +42 -80
  260. package/src/plugins/builtin/sectors/sector-data.ts +1 -0
  261. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +25 -39
  262. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +108 -58
  263. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +383 -271
  264. package/src/plugins/builtin/ticker-detail/pane.tsx +135 -49
  265. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +43 -27
  266. package/src/plugins/builtin/ticker-detail/settings.ts +3 -6
  267. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +16 -10
  268. package/src/plugins/builtin/ticker-detail.test.tsx +156 -33
  269. package/src/plugins/builtin/ticker-surface.ts +68 -0
  270. package/src/plugins/builtin/world-indices/index.tsx +44 -82
  271. package/src/plugins/builtin/world-indices/indices.ts +1 -0
  272. package/src/plugins/builtin/yahoo.ts +55 -1
  273. package/src/plugins/builtin/yield-curve/index.tsx +87 -145
  274. package/src/plugins/builtin/yield-curve/treasury-data.ts +6 -77
  275. package/src/plugins/catalog-ui.test.ts +24 -0
  276. package/src/plugins/catalog-ui.ts +44 -0
  277. package/src/plugins/catalog.ts +2 -50
  278. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  279. package/src/plugins/ibkr/account-cache.test.ts +15 -14
  280. package/src/plugins/ibkr/account-cache.ts +15 -120
  281. package/src/plugins/ibkr/broker-adapter.ts +67 -0
  282. package/src/plugins/ibkr/config.test.ts +0 -18
  283. package/src/plugins/ibkr/dialogs.tsx +14 -56
  284. package/src/plugins/ibkr/flex.test.ts +57 -2
  285. package/src/plugins/ibkr/flex.ts +116 -12
  286. package/src/plugins/ibkr/gateway-helpers.ts +8 -3
  287. package/src/plugins/ibkr/gateway-service-native.ts +2381 -0
  288. package/src/plugins/ibkr/gateway-service.test.ts +47 -9
  289. package/src/plugins/ibkr/gateway-service.ts +264 -2235
  290. package/src/plugins/ibkr/gateway-types.ts +27 -0
  291. package/src/plugins/ibkr/index.test.tsx +3 -11
  292. package/src/plugins/ibkr/index.tsx +1 -0
  293. package/src/plugins/ibkr/price-normalization.test.ts +1 -6
  294. package/src/plugins/ibkr/price-normalization.ts +1 -0
  295. package/src/plugins/ibkr/trade-badge.tsx +6 -5
  296. package/src/plugins/ibkr/trade-tab.test.tsx +2 -2
  297. package/src/plugins/ibkr/trade-tab.tsx +164 -117
  298. package/src/plugins/ibkr/trading-pane.tsx +117 -83
  299. package/src/plugins/pane-manager.test.ts +3 -1
  300. package/src/plugins/pane-manager.ts +93 -19
  301. package/src/plugins/plugin-runtime.test.tsx +176 -74
  302. package/src/plugins/plugin-runtime.tsx +228 -10
  303. package/src/plugins/prediction-markets/cache.ts +49 -0
  304. package/src/plugins/prediction-markets/categories.ts +0 -10
  305. package/src/plugins/prediction-markets/chart.test.tsx +1 -1
  306. package/src/plugins/prediction-markets/chart.tsx +21 -20
  307. package/src/plugins/prediction-markets/cli.ts +1 -0
  308. package/src/plugins/prediction-markets/controller-data.ts +135 -89
  309. package/src/plugins/prediction-markets/controller.ts +37 -108
  310. package/src/plugins/prediction-markets/detail/book.tsx +23 -22
  311. package/src/plugins/prediction-markets/detail/outcomes.tsx +33 -32
  312. package/src/plugins/prediction-markets/detail/overview.tsx +17 -12
  313. package/src/plugins/prediction-markets/detail/pane.tsx +59 -45
  314. package/src/plugins/prediction-markets/detail/rules.tsx +22 -7
  315. package/src/plugins/prediction-markets/detail/shared.tsx +3 -2
  316. package/src/plugins/prediction-markets/detail/trades.tsx +12 -11
  317. package/src/plugins/prediction-markets/detail.test.tsx +1 -1
  318. package/src/plugins/prediction-markets/keyboard.ts +1 -0
  319. package/src/plugins/prediction-markets/launch.test.ts +1 -1
  320. package/src/plugins/prediction-markets/launch.ts +1 -1
  321. package/src/plugins/prediction-markets/metrics.ts +25 -12
  322. package/src/plugins/prediction-markets/pane.test.tsx +16 -7
  323. package/src/plugins/prediction-markets/pane.tsx +180 -82
  324. package/src/plugins/prediction-markets/plugin.test.tsx +37 -45
  325. package/src/plugins/prediction-markets/services/fetch.ts +27 -10
  326. package/src/plugins/prediction-markets/services/kalshi-adapter.ts +20 -3
  327. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  328. package/src/plugins/prediction-markets/settings.ts +0 -5
  329. package/src/plugins/prediction-markets/test-helpers.tsx +19 -44
  330. package/src/plugins/registry.test.ts +307 -0
  331. package/src/plugins/registry.ts +293 -82
  332. package/src/react/input.ts +53 -0
  333. package/src/react/plugins/plugin-slot.tsx +21 -0
  334. package/src/react/use-raf-callback.ts +44 -0
  335. package/src/renderers/electrobun/bun/application-menu-click.test.ts +41 -0
  336. package/src/renderers/electrobun/bun/application-menu-click.ts +55 -0
  337. package/src/renderers/electrobun/bun/application-menu.ts +118 -0
  338. package/src/renderers/electrobun/bun/context-menu-click.test.ts +67 -0
  339. package/src/renderers/electrobun/bun/context-menu-click.ts +61 -0
  340. package/src/renderers/electrobun/bun/context-menu-normalize.test.ts +52 -0
  341. package/src/renderers/electrobun/bun/context-menu-normalize.ts +67 -0
  342. package/src/renderers/electrobun/bun/core-capabilities.ts +328 -0
  343. package/src/renderers/electrobun/bun/desktop-workspace.test.ts +63 -0
  344. package/src/renderers/electrobun/bun/desktop-workspace.ts +162 -0
  345. package/src/renderers/electrobun/bun/index.ts +1149 -0
  346. package/src/renderers/electrobun/bun/window-frame.test.ts +40 -0
  347. package/src/renderers/electrobun/bun/window-frame.ts +46 -0
  348. package/src/renderers/electrobun/shared/protocol.ts +72 -0
  349. package/src/renderers/electrobun/view/ai-host.ts +93 -0
  350. package/src/renderers/electrobun/view/app-services.ts +348 -0
  351. package/src/renderers/electrobun/view/application-menu-bridge.ts +12 -0
  352. package/src/renderers/electrobun/view/backend-rpc.ts +173 -0
  353. package/src/renderers/electrobun/view/broker-remote-client.ts +154 -0
  354. package/src/renderers/electrobun/view/config-host.ts +35 -0
  355. package/src/renderers/electrobun/view/context-menu.test.ts +140 -0
  356. package/src/renderers/electrobun/view/context-menu.ts +164 -0
  357. package/src/renderers/electrobun/view/data-table.tsx +773 -0
  358. package/src/renderers/electrobun/view/desktop-controls.tsx +651 -0
  359. package/src/renderers/electrobun/view/desktop-window-bridge.ts +59 -0
  360. package/src/renderers/electrobun/view/desktop-window-snapshot.test.ts +57 -0
  361. package/src/renderers/electrobun/view/desktop-window-snapshot.ts +41 -0
  362. package/src/renderers/electrobun/view/dialog-host.tsx +62 -0
  363. package/src/renderers/electrobun/view/http-fetch.ts +113 -0
  364. package/src/renderers/electrobun/view/input-host.tsx +65 -0
  365. package/src/renderers/electrobun/view/key-event.test.ts +34 -0
  366. package/src/renderers/electrobun/view/key-event.ts +134 -0
  367. package/src/renderers/electrobun/view/main.tsx +104 -0
  368. package/src/renderers/electrobun/view/native-renderer.ts +110 -0
  369. package/src/renderers/electrobun/view/native-stubs/chart-kitty-support.ts +9 -0
  370. package/src/renderers/electrobun/view/native-stubs/chart-surface-manager.ts +25 -0
  371. package/src/renderers/electrobun/view/native-stubs/chart-surface-sync.ts +1 -0
  372. package/src/renderers/electrobun/view/notes-files.ts +56 -0
  373. package/src/renderers/electrobun/view/resource-store.test.ts +44 -0
  374. package/src/renderers/electrobun/view/resource-store.ts +106 -0
  375. package/src/renderers/electrobun/view/rpc-codec.ts +42 -0
  376. package/src/renderers/electrobun/view/scrollbar-activity.ts +23 -0
  377. package/src/renderers/electrobun/view/toast-host.tsx +65 -0
  378. package/src/renderers/electrobun/view/ui-host.tsx +1490 -0
  379. package/src/renderers/electrobun/view/update-host.ts +38 -0
  380. package/src/renderers/opentui/dialog-host.test.tsx +44 -0
  381. package/src/renderers/opentui/dialog-host.tsx +56 -0
  382. package/src/renderers/opentui/host.tsx +134 -0
  383. package/src/renderers/opentui/input-host.tsx +24 -0
  384. package/src/renderers/opentui/start.tsx +71 -0
  385. package/src/renderers/opentui/test-utils.tsx +76 -0
  386. package/src/renderers/opentui/toast-host.tsx +10 -0
  387. package/src/renderers/opentui/ui-host.tsx +97 -0
  388. package/src/sources/gloomberb-cloud.test.ts +288 -17
  389. package/src/sources/gloomberb-cloud.ts +176 -9
  390. package/src/sources/provider-errors.ts +1 -0
  391. package/src/sources/provider-router.test.ts +220 -81
  392. package/src/sources/provider-router.ts +294 -111
  393. package/src/sources/sec-edgar.ts +19 -6
  394. package/src/sources/yahoo-finance.test.ts +0 -5
  395. package/src/sources/yahoo-finance.ts +420 -11
  396. package/src/state/app-activity.ts +7 -7
  397. package/src/state/app-bootstrap.ts +114 -40
  398. package/src/state/app-context-selector.test.tsx +70 -1
  399. package/src/state/app-context.test.ts +71 -3
  400. package/src/state/app-context.test.tsx +14 -7
  401. package/src/state/app-context.tsx +205 -869
  402. package/src/state/config-save-scheduler.ts +35 -0
  403. package/src/state/persist-scheduler.test.ts +67 -0
  404. package/src/state/persist-scheduler.ts +69 -0
  405. package/src/state/selectors-ui.ts +55 -0
  406. package/src/state/selectors.ts +15 -71
  407. package/src/state/session-persistence.test.ts +1 -1
  408. package/src/state/ticker-refresh-queue.ts +52 -3
  409. package/src/state/use-inline-tickers.ts +18 -16
  410. package/src/state/use-quote-streaming.test.ts +0 -6
  411. package/src/state/use-quote-streaming.test.tsx +1 -1
  412. package/src/state/use-quote-streaming.ts +3 -2
  413. package/src/state/use-session-snapshot.ts +32 -17
  414. package/src/test-support/plugin-runtime.ts +149 -0
  415. package/src/theme/color-utils.ts +1 -0
  416. package/src/theme/colors.ts +16 -0
  417. package/src/types/broker.ts +16 -1
  418. package/src/types/capability-route-source.ts +23 -0
  419. package/src/types/config.test.ts +23 -0
  420. package/src/types/config.ts +145 -10
  421. package/src/types/context-menu.ts +95 -0
  422. package/src/types/data-provider.ts +34 -3
  423. package/src/types/desktop-menu.ts +13 -0
  424. package/src/types/desktop-window.ts +29 -0
  425. package/src/types/financials.ts +123 -2
  426. package/src/types/instrument.ts +1 -0
  427. package/src/types/news-source.ts +9 -0
  428. package/src/types/persistence.ts +1 -0
  429. package/src/types/plugin.ts +32 -8
  430. package/src/ui/ascii-font.ts +85 -0
  431. package/src/ui/context-menu.test.ts +94 -0
  432. package/src/ui/context-menu.tsx +433 -0
  433. package/src/ui/dialog.tsx +55 -0
  434. package/src/ui/host.tsx +361 -0
  435. package/src/ui/index.tsx +126 -0
  436. package/src/ui/toast.tsx +40 -0
  437. package/src/updater.test.ts +69 -0
  438. package/src/updater.ts +61 -18
  439. package/src/utils/api-client.test.ts +186 -6
  440. package/src/utils/api-client.ts +433 -33
  441. package/src/utils/back-navigation.ts +6 -0
  442. package/src/utils/cli-output.ts +1 -0
  443. package/src/utils/currency-units.test.ts +1 -6
  444. package/src/utils/currency-units.ts +1 -0
  445. package/src/utils/debug-log.ts +68 -0
  446. package/src/utils/dom-screenshot.ts +326 -0
  447. package/src/utils/exchange-rate-map.ts +1 -0
  448. package/src/utils/exchanges.ts +214 -0
  449. package/src/utils/format.test.ts +7 -0
  450. package/src/utils/format.ts +1 -0
  451. package/src/utils/fuzzy-search.ts +1 -0
  452. package/src/utils/html-entities.test.ts +8 -0
  453. package/src/utils/html-entities.ts +32 -0
  454. package/src/utils/http-transport.ts +11 -0
  455. package/src/utils/main-thread-monitor.ts +127 -0
  456. package/src/utils/perf-marks.ts +54 -0
  457. package/src/utils/quote-freshness.ts +27 -50
  458. package/src/utils/quote-resolution.test.ts +5 -5
  459. package/src/utils/quote-resolution.ts +0 -1
  460. package/src/utils/quote-time.ts +1 -5
  461. package/src/utils/sec.test.ts +0 -12
  462. package/src/utils/selection-clipboard.ts +14 -6
  463. package/src/utils/shortcut-labels.test.ts +20 -0
  464. package/src/utils/shortcut-labels.ts +67 -0
  465. package/src/utils/slugify.ts +1 -0
  466. package/src/utils/terminal-input-reset.ts +1 -0
  467. package/src/utils/text-wrap.ts +82 -0
  468. package/src/utils/throttled-fetch.test.ts +19 -0
  469. package/src/utils/throttled-fetch.ts +11 -9
  470. package/src/utils/ticker-list.test.ts +1 -7
  471. package/src/utils/ticker-list.ts +1 -0
  472. package/src/utils/timestamp.ts +29 -0
  473. package/src/utils/tweet-text.test.ts +13 -0
  474. package/src/utils/tweet-text.ts +18 -0
  475. package/src/utils/url.ts +11 -0
  476. package/src/version.ts +1 -1
  477. package/src/components/chart/chart-control-hint.tsx +0 -39
  478. package/src/components/detail-feed-view.test.tsx +0 -180
  479. package/src/components/detail-feed-view.tsx +0 -531
  480. package/src/components/layout/pixel-mouse-mode.test.ts +0 -122
  481. package/src/components/layout/pixel-mouse.test.ts +0 -40
  482. package/src/components/spinner.tsx +0 -2
  483. package/src/components/tab-bar.tsx +0 -17
  484. package/src/components/ui/multi-select-chips.tsx +0 -69
  485. package/src/plugins/builtin/ai/index.test.tsx +0 -139
  486. package/src/plugins/builtin/ask-ai.tsx +0 -9
  487. package/src/plugins/builtin/correlation/plugin.test.ts +0 -44
  488. package/src/plugins/builtin/correlation/settings.test.ts +0 -34
  489. package/src/plugins/builtin/econ/calendar-source.test.ts +0 -219
  490. package/src/plugins/builtin/econ/fred-client.ts +0 -79
  491. package/src/plugins/builtin/fred-settings.ts +0 -10
  492. package/src/plugins/builtin/options-availability.test.tsx +0 -179
  493. package/src/plugins/builtin/options-availability.ts +0 -158
  494. package/src/plugins/builtin/portfolio-list/metrics.test.ts +0 -61
  495. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +0 -212
  496. package/src/plugins/prediction-markets/status.ts +0 -24
  497. package/src/plugins/prediction-markets/table.tsx +0 -87
  498. package/src/utils/quote-time.test.ts +0 -22
  499. package/src/utils/selection-clipboard.test.ts +0 -43
  500. package/src/utils/terminal-input-reset.test.ts +0 -50
@@ -0,0 +1,1149 @@
1
+ import { existsSync, mkdirSync } from "fs";
2
+ import { join } from "path";
3
+ import { Buffer } from "node:buffer";
4
+ import Electrobun, { ApplicationMenu, BrowserView, BrowserWindow, Utils, ContextMenu, type UpdateStatusEntry } from "electrobun/bun";
5
+ import {
6
+ APP_SESSION_ID,
7
+ APP_SESSION_SCHEMA_VERSION,
8
+ reconcileAppSessionSnapshot,
9
+ } from "../../../core/state/session-persistence";
10
+ import { createAppServices, type AppServices } from "../../../core/app-services";
11
+ import { getDataDir, initDataDir, saveConfig, resetAllData, exportConfig, importConfig, setConfigStoreHost } from "../../../data/config-store";
12
+ import * as nodeConfigStoreHost from "../../../data/config-store-node";
13
+ import { cloneLayout, findPaneInstance, type AppConfig } from "../../../types/config";
14
+ import type { AppSessionSnapshot } from "../../../core/state/session-persistence";
15
+ import type { PaneRuntimeState } from "../../../core/state/app-state";
16
+ import type { DesktopDockPreviewState, DesktopSharedStateSnapshot } from "../../../types/desktop-window";
17
+ import type { ReleaseInfo, UpdateCheckResult, UpdateProgress } from "../../../updater";
18
+ import { buildSoundCommand } from "../../../notifications/app-notifier";
19
+ import { isPaneDetached } from "../../../plugins/pane-manager";
20
+ import { ELECTROBUN_CONTEXT_MENU_ACTION, type ElectrobunDesktopRpcSchema } from "../shared/protocol";
21
+ import { decodeRpcValue, encodeRpcValue } from "../view/rpc-codec";
22
+ import { startMainThreadMonitor } from "../../../utils/main-thread-monitor";
23
+ import { contextMenuSelectionMessage } from "./context-menu-click";
24
+ import { getContextMenuRequestId, normalizeContextMenuItems } from "./context-menu-normalize";
25
+ import { createDesktopWorkspace, type DesktopWorkspace } from "./desktop-workspace";
26
+ import { buildApplicationMenu } from "./application-menu";
27
+ import { applicationMenuCommand } from "./application-menu-click";
28
+ import { registerElectrobunCoreCapabilities } from "./core-capabilities";
29
+ import { setNativeIbkrGatewayModuleLoader } from "../../../plugins/ibkr/gateway-service";
30
+ import { apiClient, type PersistedAuthUser } from "../../../utils/api-client";
31
+ import {
32
+ DEFAULT_WINDOW_FRAME,
33
+ DETACHED_WINDOW_MIN_SIZE,
34
+ MAIN_WINDOW_MIN_SIZE,
35
+ normalizeWindowFrame,
36
+ normalizeWindowFrameWithMinimum,
37
+ type WindowFrame,
38
+ type WindowMinimumSize,
39
+ } from "./window-frame";
40
+
41
+ const MAIN_WINDOW_RPC_KEY = "main";
42
+
43
+ type DesktopRpc = ReturnType<typeof BrowserView.defineRPC<ElectrobunDesktopRpcSchema>>;
44
+ type WindowMoveEvent = { data?: { x?: number; y?: number } };
45
+ type WindowResizeEvent = { data?: Partial<WindowFrame> };
46
+ type PersistedCloudSession = {
47
+ sessionToken?: string | null;
48
+ user?: PersistedAuthUser | null;
49
+ };
50
+
51
+ console.log = (...args) => console.error(...args);
52
+ console.info = (...args) => console.error(...args);
53
+ console.warn = (...args) => console.error(...args);
54
+
55
+ startMainThreadMonitor("electrobun.bun", { mirrorToConsole: true });
56
+ setConfigStoreHost(nodeConfigStoreHost);
57
+ setNativeIbkrGatewayModuleLoader(() => import("../../../plugins/ibkr/gateway-service-native"));
58
+
59
+ let currentConfig: AppConfig | null = null;
60
+ let services: AppServices | null = null;
61
+ let mainWindow: BrowserWindow | null = null;
62
+ let desktopWorkspace: DesktopWorkspace | null = null;
63
+ let currentDockPreview: DesktopDockPreviewState = { paneId: null, edge: null };
64
+ let desktopUpdateInProgress = false;
65
+
66
+ const detachedWindows = new Map<string, BrowserWindow>();
67
+ const detachedFrameTimers = new Map<string, ReturnType<typeof setTimeout>>();
68
+ const detachedDockTimers = new Map<string, ReturnType<typeof setTimeout>>();
69
+ const detachedClosingPanes = new Set<string>();
70
+ const pendingDetachedMoveFlush = new Set<string>();
71
+ const windowRpcs = new Map<string, DesktopRpc>();
72
+ const readyWindowRpcs = new Set<string>();
73
+ const rpcWindowKeys = new Map<DesktopRpc, string>();
74
+ const contextMenuRequestRpcs = new Map<string, DesktopRpc>();
75
+
76
+ const capabilitySubscriptions = new Map<string, () => void>();
77
+
78
+ function summarizeError(error: unknown): string {
79
+ return error instanceof Error ? error.stack ?? error.message : String(error);
80
+ }
81
+
82
+ function requireServices(): AppServices {
83
+ if (!services) throw new Error("Backend services have not been initialized.");
84
+ return services;
85
+ }
86
+
87
+ function requireConfig(): AppConfig {
88
+ if (!currentConfig) throw new Error("Backend config has not been initialized.");
89
+ return currentConfig;
90
+ }
91
+
92
+ function registerCoreCapabilities(): void {
93
+ registerElectrobunCoreCapabilities({
94
+ getConfig: requireConfig,
95
+ getServices: requireServices,
96
+ });
97
+ }
98
+
99
+ function requireDesktopWorkspace(): DesktopWorkspace {
100
+ if (!desktopWorkspace) throw new Error("Desktop workspace has not been initialized.");
101
+ return desktopWorkspace;
102
+ }
103
+
104
+ function detachedRpcKey(instanceId: string): string {
105
+ return `detached:${instanceId}`;
106
+ }
107
+
108
+ function registerWindowRpc(key: string, rpc: DesktopRpc): void {
109
+ windowRpcs.set(key, rpc);
110
+ rpcWindowKeys.set(rpc, key);
111
+ }
112
+
113
+ function unregisterWindowRpc(key: string): void {
114
+ const rpc = windowRpcs.get(key);
115
+ if (rpc) {
116
+ rpcWindowKeys.delete(rpc);
117
+ }
118
+ windowRpcs.delete(key);
119
+ readyWindowRpcs.delete(key);
120
+ }
121
+
122
+ function getRpcWindowKey(rpc: DesktopRpc): string | undefined {
123
+ return rpcWindowKeys.get(rpc);
124
+ }
125
+
126
+ function markWindowRpcReady(rpc: DesktopRpc): void {
127
+ const key = getRpcWindowKey(rpc);
128
+ if (key) readyWindowRpcs.add(key);
129
+ }
130
+
131
+ function forEachReadyWindowRpc(callback: (rpc: DesktopRpc) => void): void {
132
+ for (const key of readyWindowRpcs) {
133
+ const rpc = windowRpcs.get(key);
134
+ if (rpc) callback(rpc);
135
+ }
136
+ }
137
+
138
+ function scopeClientId(rpc: DesktopRpc, id: string): string {
139
+ return `${getRpcWindowKey(rpc) ?? "window"}:${id}`;
140
+ }
141
+
142
+ function syncActiveLayout(config: AppConfig): AppConfig {
143
+ return {
144
+ ...config,
145
+ layouts: config.layouts.map((entry, index) => (
146
+ index === config.activeLayoutIndex ? { ...entry, layout: cloneLayout(config.layout) } : entry
147
+ )),
148
+ };
149
+ }
150
+
151
+ function normalizeInitWindowTarget(
152
+ rpc: DesktopRpc,
153
+ payload: Record<string, unknown>,
154
+ ): { kind: "main" | "detached"; paneId?: string } {
155
+ const rpcKey = getRpcWindowKey(rpc);
156
+ if (rpcKey === MAIN_WINDOW_RPC_KEY) {
157
+ return { kind: "main" };
158
+ }
159
+ if (rpcKey?.startsWith("detached:")) {
160
+ return {
161
+ kind: "detached",
162
+ paneId: rpcKey.slice("detached:".length) || undefined,
163
+ };
164
+ }
165
+ const kind = payload.kind === "detached" ? "detached" : "main";
166
+ return {
167
+ kind,
168
+ paneId: kind === "detached" && typeof payload.paneId === "string" && payload.paneId.length > 0
169
+ ? payload.paneId
170
+ : undefined,
171
+ };
172
+ }
173
+
174
+ function clearTimer(
175
+ timers: Map<string, ReturnType<typeof setTimeout>>,
176
+ key: string,
177
+ ): void {
178
+ const timer = timers.get(key);
179
+ if (timer) {
180
+ clearTimeout(timer);
181
+ timers.delete(key);
182
+ }
183
+ }
184
+
185
+ function getWindowFrame(window: BrowserWindow | null): WindowFrame | null {
186
+ if (!window) return null;
187
+ return normalizeWindowFrame(window.frame);
188
+ }
189
+
190
+ function updateWindowFrameCache(
191
+ window: BrowserWindow | null,
192
+ patch: Partial<WindowFrame>,
193
+ minimumSize?: WindowMinimumSize,
194
+ ): WindowFrame | null {
195
+ if (!window) return null;
196
+ const nextFrame = normalizeWindowFrameWithMinimum(patch, getWindowFrame(window) ?? DEFAULT_WINDOW_FRAME, minimumSize);
197
+ window.frame = nextFrame;
198
+ return nextFrame;
199
+ }
200
+
201
+ function applyWindowMoveEvent(window: BrowserWindow | null, event: WindowMoveEvent): WindowFrame | null {
202
+ return updateWindowFrameCache(window, {
203
+ x: event.data?.x,
204
+ y: event.data?.y,
205
+ });
206
+ }
207
+
208
+ function applyWindowResizeEvent(
209
+ window: BrowserWindow | null,
210
+ event: WindowResizeEvent,
211
+ minimumSize?: WindowMinimumSize,
212
+ ): WindowFrame | null {
213
+ if (!window) return null;
214
+ const previousFrame = getWindowFrame(window) ?? DEFAULT_WINDOW_FRAME;
215
+ const rawFrame = normalizeWindowFrame(event.data ?? {}, previousFrame);
216
+ const nextFrame = updateWindowFrameCache(window, rawFrame, minimumSize);
217
+ if (nextFrame && (nextFrame.width !== rawFrame.width || nextFrame.height !== rawFrame.height)) {
218
+ window.setFrame(nextFrame.x, nextFrame.y, nextFrame.width, nextFrame.height);
219
+ }
220
+ return nextFrame;
221
+ }
222
+
223
+ function setCurrentConfig(nextConfig: AppConfig): void {
224
+ currentConfig = syncActiveLayout(nextConfig);
225
+ syncConfigAccessors();
226
+ }
227
+
228
+ function normalizeHttpFetchHeaders(headers: unknown): Record<string, string> {
229
+ if (!headers || typeof headers !== "object" || Array.isArray(headers)) {
230
+ return {};
231
+ }
232
+ return Object.fromEntries(
233
+ Object.entries(headers as Record<string, unknown>)
234
+ .filter((entry): entry is [string, string] => typeof entry[1] === "string"),
235
+ );
236
+ }
237
+
238
+ function normalizeText(value: unknown): string | undefined {
239
+ return typeof value === "string" && value.length > 0 ? value : undefined;
240
+ }
241
+
242
+ function playNotificationSound(sound: string | undefined): void {
243
+ if (!sound) return;
244
+ const command = buildSoundCommand(sound);
245
+ if (!command) return;
246
+ try {
247
+ const child = Bun.spawn([command.command, ...command.args], { stdio: ["ignore", "ignore", "ignore"] });
248
+ child.unref();
249
+ } catch (error) {
250
+ console.warn("notification sound failed", {
251
+ command: command.command,
252
+ message: error instanceof Error ? error.message : String(error),
253
+ });
254
+ }
255
+ }
256
+
257
+ function desktopReleasePlatformPrefix(channel: string): string {
258
+ const os = process.platform === "darwin" ? "macos" : process.platform === "win32" ? "win" : "linux";
259
+ const arch = process.arch === "arm64" ? "arm64" : "x64";
260
+ return `${channel}-${os}-${arch}`;
261
+ }
262
+
263
+ async function desktopReleaseInfo(updateInfo: {
264
+ version?: string;
265
+ hash?: string;
266
+ }, currentVersion: string): Promise<ReleaseInfo> {
267
+ const [channel, baseUrl] = await Promise.all([
268
+ Electrobun.Updater.localInfo.channel(),
269
+ Electrobun.Updater.localInfo.baseUrl(),
270
+ ]);
271
+ const version = updateInfo.version || currentVersion;
272
+ return {
273
+ version,
274
+ tagName: `v${version}`,
275
+ downloadUrl: `${baseUrl.replace(/\/+$/, "")}/${desktopReleasePlatformPrefix(channel)}-update.json`,
276
+ publishedAt: "",
277
+ updateAction: { kind: "desktop" },
278
+ };
279
+ }
280
+
281
+ function mapDesktopUpdateStatus(entry: UpdateStatusEntry): UpdateProgress | null {
282
+ const progress = entry.details?.progress;
283
+ switch (entry.status) {
284
+ case "downloading":
285
+ case "download-starting":
286
+ case "checking-local-tar":
287
+ case "local-tar-found":
288
+ case "local-tar-missing":
289
+ case "fetching-patch":
290
+ case "patch-found":
291
+ case "patch-not-found":
292
+ case "downloading-patch":
293
+ case "downloading-full-bundle":
294
+ case "download-progress":
295
+ return {
296
+ phase: "downloading",
297
+ percent: typeof progress === "number" ? progress : undefined,
298
+ };
299
+ case "applying-patch":
300
+ case "patch-applied":
301
+ case "extracting-version":
302
+ case "patch-chain-complete":
303
+ case "decompressing":
304
+ case "download-complete":
305
+ case "applying":
306
+ case "extracting":
307
+ case "replacing-app":
308
+ case "launching-new-version":
309
+ return { phase: "replacing" };
310
+ case "complete":
311
+ return { phase: "done", message: "Update installed, restarting..." };
312
+ case "error":
313
+ return {
314
+ phase: "error",
315
+ error: entry.details?.errorMessage || entry.message,
316
+ };
317
+ default:
318
+ return null;
319
+ }
320
+ }
321
+
322
+ function sendUpdateProgress(rpc: DesktopRpc, progress: UpdateProgress): void {
323
+ try {
324
+ rpc.send["update.progress"]({
325
+ progress: encodeRpcValue(progress),
326
+ });
327
+ } catch (error) {
328
+ console.warn("update progress send failed", summarizeError(error));
329
+ }
330
+ }
331
+
332
+ async function checkDesktopUpdate(currentVersion: string): Promise<UpdateCheckResult> {
333
+ try {
334
+ const [channel, baseUrl] = await Promise.all([
335
+ Electrobun.Updater.localInfo.channel(),
336
+ Electrobun.Updater.localInfo.baseUrl(),
337
+ ]);
338
+ if (channel === "dev" || !baseUrl) {
339
+ return { kind: "disabled" };
340
+ }
341
+
342
+ const info = await Electrobun.Updater.checkForUpdate();
343
+ if (info.error) {
344
+ return { kind: "error", error: info.error };
345
+ }
346
+ if (!info.updateAvailable) {
347
+ return { kind: "current" };
348
+ }
349
+
350
+ return {
351
+ kind: "available",
352
+ release: await desktopReleaseInfo(info, currentVersion),
353
+ };
354
+ } catch (error) {
355
+ return {
356
+ kind: "error",
357
+ error: error instanceof Error ? error.message : "Desktop update check failed",
358
+ };
359
+ }
360
+ }
361
+
362
+ async function runDesktopUpdate(rpc: DesktopRpc, currentVersion: string): Promise<void> {
363
+ if (desktopUpdateInProgress) {
364
+ sendUpdateProgress(rpc, {
365
+ phase: "error",
366
+ error: "A desktop update is already in progress.",
367
+ });
368
+ return;
369
+ }
370
+
371
+ desktopUpdateInProgress = true;
372
+ Electrobun.Updater.clearStatusHistory();
373
+ Electrobun.Updater.onStatusChange((entry) => {
374
+ const progress = mapDesktopUpdateStatus(entry);
375
+ if (progress) sendUpdateProgress(rpc, progress);
376
+ });
377
+
378
+ try {
379
+ sendUpdateProgress(rpc, { phase: "downloading", percent: 0 });
380
+ const result = await checkDesktopUpdate(currentVersion);
381
+ if (result.kind === "error") {
382
+ sendUpdateProgress(rpc, { phase: "error", error: result.error });
383
+ return;
384
+ }
385
+ if (result.kind !== "available") {
386
+ sendUpdateProgress(rpc, {
387
+ phase: "done",
388
+ message: result.kind === "disabled" ? "Desktop updates are unavailable in this build" : "Already on the latest version",
389
+ });
390
+ return;
391
+ }
392
+
393
+ await Electrobun.Updater.downloadUpdate();
394
+ const updateInfo = Electrobun.Updater.updateInfo();
395
+ if (updateInfo?.error) {
396
+ sendUpdateProgress(rpc, { phase: "error", error: updateInfo.error });
397
+ return;
398
+ }
399
+ if (!updateInfo?.updateReady) {
400
+ sendUpdateProgress(rpc, { phase: "error", error: "Desktop update did not finish downloading." });
401
+ return;
402
+ }
403
+
404
+ sendUpdateProgress(rpc, { phase: "replacing" });
405
+ await Electrobun.Updater.applyUpdate();
406
+ } catch (error) {
407
+ sendUpdateProgress(rpc, {
408
+ phase: "error",
409
+ error: error instanceof Error ? error.message : "Desktop update failed",
410
+ });
411
+ } finally {
412
+ desktopUpdateInProgress = false;
413
+ Electrobun.Updater.onStatusChange(null);
414
+ }
415
+ }
416
+
417
+ function syncBackendCloudAuthState(pluginId: string, key: string, value: unknown): void {
418
+ if (pluginId !== "gloomberb-cloud" || (key !== "session" && key !== "resume:session")) return;
419
+
420
+ const session = value && typeof value === "object" ? value as PersistedCloudSession : null;
421
+ const token = typeof session?.sessionToken === "string" && session.sessionToken.length > 0
422
+ ? session.sessionToken
423
+ : null;
424
+
425
+ apiClient.setSessionToken(token);
426
+ apiClient.setWebSocketToken(null);
427
+ apiClient.restoreCachedUser(token ? session?.user ?? null : null);
428
+ }
429
+
430
+ async function handleHttpFetch(payload: Record<string, unknown>) {
431
+ if (typeof payload.url !== "string") {
432
+ throw new Error("http.fetch requires a URL.");
433
+ }
434
+
435
+ const url = new URL(payload.url);
436
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
437
+ throw new Error(`Unsupported http.fetch protocol: ${url.protocol}`);
438
+ }
439
+
440
+ const init =
441
+ payload.init && typeof payload.init === "object" && !Array.isArray(payload.init)
442
+ ? payload.init as Record<string, unknown>
443
+ : {};
444
+ const method =
445
+ typeof init.method === "string" && init.method.trim().length > 0
446
+ ? init.method.trim().toUpperCase()
447
+ : "GET";
448
+ const body =
449
+ typeof init.body === "string" && method !== "GET" && method !== "HEAD"
450
+ ? init.body
451
+ : undefined;
452
+
453
+ const response = await fetch(url, {
454
+ method,
455
+ headers: normalizeHttpFetchHeaders(init.headers),
456
+ body,
457
+ });
458
+ const responseHeaders: Record<string, string> = {};
459
+ response.headers.forEach((value, key) => {
460
+ responseHeaders[key] = value;
461
+ });
462
+ const setCookieHeaders = [...(response.headers.getSetCookie?.() ?? [])];
463
+ const fallbackSetCookie = response.headers.get("set-cookie");
464
+ if (fallbackSetCookie && setCookieHeaders.length === 0) {
465
+ setCookieHeaders.push(fallbackSetCookie);
466
+ }
467
+
468
+ return {
469
+ status: response.status,
470
+ statusText: response.statusText,
471
+ headers: responseHeaders,
472
+ setCookie: setCookieHeaders,
473
+ body: await response.text(),
474
+ };
475
+ }
476
+
477
+ function syncConfigAccessors() {
478
+ if (!services || !currentConfig) return;
479
+ services.pluginRegistry.getConfigFn = () => currentConfig!;
480
+ services.pluginRegistry.getLayoutFn = () => currentConfig!.layout;
481
+ services.pluginRegistry.updateBrokerInstanceFn = async (instanceId, values, options = {}) => {
482
+ const config = requireConfig();
483
+ let found = false;
484
+ const brokerInstances = config.brokerInstances.map((instance) => {
485
+ if (instance.id !== instanceId) return instance;
486
+ found = true;
487
+ const nextValues = options.replaceConfig ? values : { ...instance.config, ...values };
488
+ return {
489
+ ...instance,
490
+ label: options.label ?? instance.label,
491
+ enabled: options.enabled ?? instance.enabled,
492
+ connectionMode: typeof nextValues.connectionMode === "string" ? nextValues.connectionMode : instance.connectionMode,
493
+ config: nextValues,
494
+ };
495
+ });
496
+ if (!found) return;
497
+
498
+ const nextConfig = {
499
+ ...config,
500
+ brokerInstances,
501
+ };
502
+ if (desktopWorkspace) {
503
+ await commitDesktopSnapshot(desktopWorkspace.replaceConfig(nextConfig, { layoutChanged: false }));
504
+ return;
505
+ }
506
+ setCurrentConfig(nextConfig);
507
+ await saveConfig(requireConfig());
508
+ };
509
+ const configurableProvider = services.providerRouter as {
510
+ setConfigAccessor?: (accessor: () => AppConfig) => void;
511
+ };
512
+ configurableProvider.setConfigAccessor?.(() => currentConfig!);
513
+ }
514
+
515
+ function loadPluginState() {
516
+ const registry = requireServices().pluginRegistry;
517
+ const state: Record<string, Record<string, unknown>> = {};
518
+ for (const pluginId of registry.allPlugins.keys()) {
519
+ const keys = registry.persistence.pluginState.keys(pluginId);
520
+ if (keys.length === 0) continue;
521
+ state[pluginId] = {};
522
+ for (const key of keys) {
523
+ const record = registry.persistence.pluginState.get(pluginId, key);
524
+ if (record) state[pluginId]![key] = record.value;
525
+ }
526
+ }
527
+ return state;
528
+ }
529
+
530
+ function getSessionSnapshot(): AppSessionSnapshot | null {
531
+ if (!currentConfig || !services) return null;
532
+ const persisted = services.persistence.sessions.get<AppSessionSnapshot>(APP_SESSION_ID, APP_SESSION_SCHEMA_VERSION)?.value ?? null;
533
+ return reconcileAppSessionSnapshot(currentConfig, persisted);
534
+ }
535
+
536
+ function getDesktopSnapshot(): DesktopSharedStateSnapshot | null {
537
+ return desktopWorkspace?.getSnapshot() ?? null;
538
+ }
539
+
540
+ function sendDesktopState(snapshot: DesktopSharedStateSnapshot | null = getDesktopSnapshot()): void {
541
+ if (!snapshot) return;
542
+ const encodedSnapshot = encodeRpcValue(snapshot);
543
+ forEachReadyWindowRpc((rpc) => {
544
+ rpc.send["desktop.state"]({
545
+ snapshot: encodedSnapshot,
546
+ });
547
+ });
548
+ }
549
+
550
+ function sendDockPreview(preview: DesktopDockPreviewState): void {
551
+ if (currentDockPreview.paneId === preview.paneId && currentDockPreview.edge === preview.edge) {
552
+ return;
553
+ }
554
+ currentDockPreview = preview;
555
+ const encodedPreview = encodeRpcValue(preview);
556
+ forEachReadyWindowRpc((rpc) => {
557
+ rpc.send["desktop.dockPreview"]({
558
+ preview: encodedPreview,
559
+ });
560
+ });
561
+ }
562
+
563
+ function clearDockPreview(paneId?: string): void {
564
+ if (paneId && currentDockPreview.paneId && currentDockPreview.paneId !== paneId) return;
565
+ sendDockPreview({ paneId: null, edge: null });
566
+ }
567
+
568
+ function disposeSubscriptionMap(map: Map<string, () => void>): void {
569
+ for (const unsubscribe of map.values()) {
570
+ try {
571
+ unsubscribe();
572
+ } catch {
573
+ // ignore teardown failures
574
+ }
575
+ }
576
+ map.clear();
577
+ }
578
+
579
+ function disposeScopedSubscriptionMap(map: Map<string, () => void>, windowKey: string): void {
580
+ const scopedPrefix = `${windowKey}:`;
581
+ for (const [id, unsubscribe] of map) {
582
+ if (!id.startsWith(scopedPrefix)) continue;
583
+ try {
584
+ unsubscribe();
585
+ } catch {
586
+ // ignore teardown failures
587
+ }
588
+ map.delete(id);
589
+ }
590
+ }
591
+
592
+ function disposeWindowScopedResources(windowKey: string): void {
593
+ disposeScopedSubscriptionMap(capabilitySubscriptions, windowKey);
594
+ }
595
+
596
+ function teardownServices(): void {
597
+ disposeSubscriptionMap(capabilitySubscriptions);
598
+ services?.destroy();
599
+ services = null;
600
+ }
601
+
602
+ function resolveDetachedWindowTitle(instanceId: string): string {
603
+ const instance = currentConfig ? findPaneInstance(currentConfig.layout, instanceId) : null;
604
+ if (!instance) return "Gloomberb";
605
+ return instance.title?.trim() || instance.paneId;
606
+ }
607
+
608
+ function resolveDetachedWindowFrame(instanceId: string): { x: number; y: number; width: number; height: number } {
609
+ const detachedEntry = requireConfig().layout.detached.find((entry) => entry.instanceId === instanceId);
610
+ if (detachedEntry) {
611
+ return normalizeWindowFrameWithMinimum({
612
+ x: detachedEntry.x,
613
+ y: detachedEntry.y,
614
+ width: detachedEntry.width,
615
+ height: detachedEntry.height,
616
+ }, DEFAULT_WINDOW_FRAME, DETACHED_WINDOW_MIN_SIZE);
617
+ }
618
+ const remembered = findPaneInstance(requireConfig().layout, instanceId)?.placementMemory?.detached;
619
+ if (remembered) {
620
+ return normalizeWindowFrameWithMinimum(remembered, DEFAULT_WINDOW_FRAME, DETACHED_WINDOW_MIN_SIZE);
621
+ }
622
+ const mainFrame = getWindowFrame(mainWindow) ?? DEFAULT_WINDOW_FRAME;
623
+ return normalizeWindowFrameWithMinimum({
624
+ x: mainFrame.x + 72,
625
+ y: mainFrame.y + 72,
626
+ width: Math.max(720, Math.floor(mainFrame.width * 0.45)),
627
+ height: Math.max(420, Math.floor(mainFrame.height * 0.5)),
628
+ }, DEFAULT_WINDOW_FRAME, DETACHED_WINDOW_MIN_SIZE);
629
+ }
630
+
631
+ function resolveDetachedDockEdge(frame: { x: number; y: number; width: number; height: number }): "left" | "right" | "top" | "bottom" | null {
632
+ const mainFrame = getWindowFrame(mainWindow);
633
+ if (!mainFrame) return null;
634
+ const threshold = 72;
635
+ const headerMidX = frame.x + (frame.width / 2);
636
+ const headerMidY = frame.y + 16;
637
+ const overlapsVertically = headerMidY >= mainFrame.y - 32 && headerMidY <= mainFrame.y + mainFrame.height + 32;
638
+ const overlapsHorizontally = headerMidX >= mainFrame.x - 32 && headerMidX <= mainFrame.x + mainFrame.width + 32;
639
+
640
+ if (overlapsVertically && Math.abs(headerMidX - mainFrame.x) <= threshold) return "left";
641
+ if (overlapsVertically && Math.abs(headerMidX - (mainFrame.x + mainFrame.width)) <= threshold) return "right";
642
+ if (overlapsHorizontally && Math.abs(headerMidY - mainFrame.y) <= threshold) return "top";
643
+ if (overlapsHorizontally && Math.abs(headerMidY - (mainFrame.y + mainFrame.height)) <= threshold) return "bottom";
644
+ return null;
645
+ }
646
+
647
+ function cleanupDetachedWindowState(instanceId: string): void {
648
+ disposeWindowScopedResources(detachedRpcKey(instanceId));
649
+ detachedWindows.delete(instanceId);
650
+ clearTimer(detachedFrameTimers, instanceId);
651
+ clearTimer(detachedDockTimers, instanceId);
652
+ pendingDetachedMoveFlush.delete(instanceId);
653
+ unregisterWindowRpc(detachedRpcKey(instanceId));
654
+ }
655
+
656
+ async function commitDesktopSnapshot(
657
+ snapshot: DesktopSharedStateSnapshot,
658
+ options: { persistConfig?: boolean; reconcileWindows?: boolean } = {},
659
+ ): Promise<DesktopSharedStateSnapshot> {
660
+ const nextConfig = syncActiveLayout(snapshot.config);
661
+ setCurrentConfig(nextConfig);
662
+ desktopWorkspace = requireDesktopWorkspace();
663
+ desktopWorkspace.replaceConfig(nextConfig, { layoutChanged: snapshot.layoutChanged });
664
+ if (options.persistConfig !== false) {
665
+ await saveConfig(nextConfig);
666
+ }
667
+ if (options.reconcileWindows !== false) {
668
+ reconcileDetachedWindows();
669
+ }
670
+ sendDesktopState(requireDesktopWorkspace().getSnapshot());
671
+ return requireDesktopWorkspace().getSnapshot();
672
+ }
673
+
674
+ function createDetachedWindow(
675
+ instanceId: string,
676
+ frame: { x: number; y: number; width: number; height: number },
677
+ ): BrowserWindow {
678
+ const rpc = createWindowRpc(detachedRpcKey(instanceId));
679
+ const initialFrame = normalizeWindowFrameWithMinimum(frame, DEFAULT_WINDOW_FRAME, DETACHED_WINDOW_MIN_SIZE);
680
+ const window = new BrowserWindow({
681
+ title: resolveDetachedWindowTitle(instanceId),
682
+ frame: initialFrame,
683
+ url: "views://mainview/index.html",
684
+ renderer: "native",
685
+ rpc,
686
+ titleBarStyle: "hiddenInset",
687
+ navigationRules: JSON.stringify(["views://*"]),
688
+ sandbox: false,
689
+ });
690
+ updateWindowFrameCache(window, initialFrame, DETACHED_WINDOW_MIN_SIZE);
691
+ detachedWindows.set(instanceId, window);
692
+
693
+ (window as any).on?.("close", () => {
694
+ const shouldIgnore = detachedClosingPanes.delete(instanceId);
695
+ cleanupDetachedWindowState(instanceId);
696
+ if (shouldIgnore || !desktopWorkspace || !currentConfig || !isPaneDetached(currentConfig.layout, instanceId)) {
697
+ return;
698
+ }
699
+ void commitDesktopSnapshot(requireDesktopWorkspace().closeDetachedPane(instanceId));
700
+ });
701
+
702
+ (window as any).on?.("move", (event: WindowMoveEvent) => {
703
+ applyWindowMoveEvent(window, event);
704
+ scheduleDetachedWindowMove(instanceId);
705
+ });
706
+ (window as any).on?.("resize", (event: WindowResizeEvent) => {
707
+ applyWindowResizeEvent(window, event, DETACHED_WINDOW_MIN_SIZE);
708
+ scheduleDetachedWindowMove(instanceId);
709
+ });
710
+
711
+ return window;
712
+ }
713
+
714
+ function scheduleDetachedWindowMove(instanceId: string): void {
715
+ if (pendingDetachedMoveFlush.has(instanceId)) return;
716
+ pendingDetachedMoveFlush.add(instanceId);
717
+ setTimeout(() => {
718
+ pendingDetachedMoveFlush.delete(instanceId);
719
+ handleDetachedWindowMove(instanceId);
720
+ }, 0);
721
+ }
722
+
723
+ function reconcileDetachedWindows(): void {
724
+ const config = currentConfig;
725
+ if (!config) return;
726
+
727
+ const desiredEntries = new Map(config.layout.detached.map((entry) => [entry.instanceId, entry] as const));
728
+ for (const [instanceId, entry] of desiredEntries) {
729
+ const existingWindow = detachedWindows.get(instanceId);
730
+ if (!existingWindow) {
731
+ createDetachedWindow(instanceId, entry);
732
+ continue;
733
+ }
734
+
735
+ const currentFrame = getWindowFrame(existingWindow);
736
+ const hasLiveFrameUpdate = pendingDetachedMoveFlush.has(instanceId)
737
+ || detachedFrameTimers.has(instanceId)
738
+ || detachedDockTimers.has(instanceId);
739
+ const nextFrame = currentFrame
740
+ ? normalizeWindowFrameWithMinimum(entry, currentFrame, DETACHED_WINDOW_MIN_SIZE)
741
+ : null;
742
+ if (!hasLiveFrameUpdate
743
+ && currentFrame
744
+ && nextFrame
745
+ && (currentFrame.x !== nextFrame.x
746
+ || currentFrame.y !== nextFrame.y
747
+ || currentFrame.width !== nextFrame.width
748
+ || currentFrame.height !== nextFrame.height)) {
749
+ existingWindow.setFrame(nextFrame.x, nextFrame.y, nextFrame.width, nextFrame.height);
750
+ }
751
+ (existingWindow as any).setTitle?.(resolveDetachedWindowTitle(instanceId));
752
+ }
753
+
754
+ for (const [instanceId, window] of detachedWindows) {
755
+ if (desiredEntries.has(instanceId)) continue;
756
+ detachedClosingPanes.add(instanceId);
757
+ cleanupDetachedWindowState(instanceId);
758
+ (window as any).close?.();
759
+ }
760
+ }
761
+
762
+ function closeAllDetachedWindows(): void {
763
+ for (const [instanceId, window] of detachedWindows) {
764
+ detachedClosingPanes.add(instanceId);
765
+ cleanupDetachedWindowState(instanceId);
766
+ (window as any).close?.();
767
+ }
768
+ currentDockPreview = { paneId: null, edge: null };
769
+ }
770
+
771
+ function handleDetachedWindowMove(
772
+ instanceId: string,
773
+ ): void {
774
+ const window = detachedWindows.get(instanceId);
775
+ if (!window || !desktopWorkspace || !currentConfig || !isPaneDetached(currentConfig.layout, instanceId)) {
776
+ return;
777
+ }
778
+
779
+ const frame = getWindowFrame(window);
780
+ if (!frame) return;
781
+ const edge = resolveDetachedDockEdge(frame);
782
+
783
+ clearTimer(detachedFrameTimers, instanceId);
784
+ detachedFrameTimers.set(instanceId, setTimeout(() => {
785
+ detachedFrameTimers.delete(instanceId);
786
+ if (!currentConfig || !desktopWorkspace || !isPaneDetached(currentConfig.layout, instanceId)) return;
787
+ void commitDesktopSnapshot(
788
+ requireDesktopWorkspace().updateDetachedFrame(instanceId, frame),
789
+ { reconcileWindows: false },
790
+ );
791
+ }, 120));
792
+
793
+ clearTimer(detachedDockTimers, instanceId);
794
+ if (!edge) {
795
+ clearDockPreview(instanceId);
796
+ return;
797
+ }
798
+
799
+ sendDockPreview({ paneId: instanceId, edge });
800
+ detachedDockTimers.set(instanceId, setTimeout(() => {
801
+ detachedDockTimers.delete(instanceId);
802
+ if (!currentConfig || !desktopWorkspace || !isPaneDetached(currentConfig.layout, instanceId)) return;
803
+ if (currentDockPreview.paneId !== instanceId || currentDockPreview.edge !== edge) return;
804
+ clearDockPreview(instanceId);
805
+ void commitDesktopSnapshot(requireDesktopWorkspace().dockDetachedPane(instanceId, edge));
806
+ }, 120));
807
+ }
808
+
809
+ async function initialize(
810
+ rpc: DesktopRpc,
811
+ payload: Record<string, unknown>,
812
+ ) {
813
+ const windowTarget = normalizeInitWindowTarget(rpc, payload);
814
+ markWindowRpcReady(rpc);
815
+ if (services && currentConfig) {
816
+ if (!desktopWorkspace) {
817
+ desktopWorkspace = createDesktopWorkspace(currentConfig, getSessionSnapshot());
818
+ reconcileDetachedWindows();
819
+ }
820
+ return {
821
+ config: currentConfig,
822
+ sessionSnapshot: getSessionSnapshot(),
823
+ desktopSnapshot: getDesktopSnapshot(),
824
+ pluginState: loadPluginState(),
825
+ capabilityManifests: requireServices().pluginRegistry.capabilities.manifests({ rendererOnly: true }),
826
+ windowKind: windowTarget.kind,
827
+ paneId: windowTarget.paneId,
828
+ };
829
+ }
830
+
831
+ let dataDir = await getDataDir();
832
+ if (!dataDir) {
833
+ dataDir = join(process.env.HOME || "~", ".gloomberb");
834
+ }
835
+ if (!existsSync(dataDir)) {
836
+ mkdirSync(dataDir, { recursive: true });
837
+ }
838
+
839
+ setCurrentConfig(await initDataDir(dataDir));
840
+ services = createAppServices({ config: requireConfig(), externalPlugins: [] });
841
+ syncConfigAccessors();
842
+ registerCoreCapabilities();
843
+ desktopWorkspace = createDesktopWorkspace(requireConfig(), getSessionSnapshot());
844
+
845
+ reconcileDetachedWindows();
846
+
847
+ return {
848
+ config: requireConfig(),
849
+ sessionSnapshot: getSessionSnapshot(),
850
+ desktopSnapshot: getDesktopSnapshot(),
851
+ pluginState: loadPluginState(),
852
+ capabilityManifests: requireServices().pluginRegistry.capabilities.manifests({ rendererOnly: true }),
853
+ windowKind: windowTarget.kind,
854
+ paneId: windowTarget.paneId,
855
+ };
856
+ }
857
+
858
+ async function handleDesktop(
859
+ rpc: DesktopRpc,
860
+ method: string,
861
+ payload: Record<string, unknown>,
862
+ ) {
863
+ const workspace = requireDesktopWorkspace();
864
+ switch (method) {
865
+ case "desktop.syncMainState": {
866
+ const snapshot = workspace.syncMainState(payload.snapshot as DesktopSharedStateSnapshot);
867
+ setCurrentConfig(snapshot.config);
868
+ reconcileDetachedWindows();
869
+ sendDesktopState(snapshot);
870
+ return null;
871
+ }
872
+ case "desktop.replaceDetachedPaneState":
873
+ if (typeof payload.paneId !== "string") {
874
+ throw new Error("desktop.replaceDetachedPaneState requires paneId.");
875
+ }
876
+ sendDesktopState(workspace.replaceDetachedPaneState(payload.paneId, payload.paneState as PaneRuntimeState));
877
+ return null;
878
+ case "desktop.popOutPane": {
879
+ if (typeof payload.paneId !== "string") {
880
+ throw new Error("desktop.popOutPane requires paneId.");
881
+ }
882
+ const snapshot = workspace.popOutPane(payload.paneId, resolveDetachedWindowFrame(payload.paneId));
883
+ await commitDesktopSnapshot(snapshot);
884
+ (detachedWindows.get(payload.paneId) as any)?.focus?.();
885
+ return null;
886
+ }
887
+ case "desktop.dockDetachedPane": {
888
+ if (typeof payload.paneId !== "string") {
889
+ throw new Error("desktop.dockDetachedPane requires paneId.");
890
+ }
891
+ clearDockPreview(payload.paneId);
892
+ await commitDesktopSnapshot(
893
+ workspace.dockDetachedPane(
894
+ payload.paneId,
895
+ payload.edge === "left" || payload.edge === "right" || payload.edge === "top" || payload.edge === "bottom"
896
+ ? payload.edge
897
+ : undefined,
898
+ ),
899
+ );
900
+ return null;
901
+ }
902
+ case "desktop.closeDetachedPane": {
903
+ if (typeof payload.paneId !== "string") {
904
+ throw new Error("desktop.closeDetachedPane requires paneId.");
905
+ }
906
+ clearDockPreview(payload.paneId);
907
+ await commitDesktopSnapshot(workspace.closeDetachedPane(payload.paneId));
908
+ return null;
909
+ }
910
+ case "desktop.focusDetachedPane":
911
+ if (typeof payload.paneId !== "string") {
912
+ throw new Error("desktop.focusDetachedPane requires paneId.");
913
+ }
914
+ (detachedWindows.get(payload.paneId) as any)?.focus?.();
915
+ return null;
916
+ default:
917
+ throw new Error(`Unknown desktop method: ${method}`);
918
+ }
919
+ }
920
+
921
+ async function handleCapability(
922
+ rpc: DesktopRpc,
923
+ method: string,
924
+ payload: Record<string, unknown>,
925
+ ) {
926
+ const registry = requireServices().pluginRegistry.capabilities;
927
+ switch (method) {
928
+ case "capability.invoke":
929
+ return registry.invoke(
930
+ payload.capabilityId as string,
931
+ payload.operationId as string,
932
+ payload.payload,
933
+ { renderer: true },
934
+ );
935
+ case "capability.subscribe": {
936
+ const clientSubscriptionId = payload.subscriptionId as string;
937
+ const scopedSubscriptionId = scopeClientId(rpc, clientSubscriptionId);
938
+ capabilitySubscriptions.get(scopedSubscriptionId)?.();
939
+ await registry.subscribe(
940
+ payload.capabilityId as string,
941
+ payload.operationId as string,
942
+ payload.payload,
943
+ (event) => {
944
+ rpc.send["capability.event"]({
945
+ subscriptionId: clientSubscriptionId,
946
+ event: encodeRpcValue(event),
947
+ });
948
+ },
949
+ { renderer: true, subscriptionId: scopedSubscriptionId },
950
+ );
951
+ capabilitySubscriptions.set(scopedSubscriptionId, () => registry.unsubscribe(scopedSubscriptionId));
952
+ return null;
953
+ }
954
+ case "capability.unsubscribe": {
955
+ const scopedSubscriptionId = scopeClientId(rpc, payload.subscriptionId as string);
956
+ capabilitySubscriptions.get(scopedSubscriptionId)?.();
957
+ capabilitySubscriptions.delete(scopedSubscriptionId);
958
+ return null;
959
+ }
960
+ default:
961
+ throw new Error(`Unknown capability method: ${method}`);
962
+ }
963
+ }
964
+
965
+ async function handleBackendRequest(
966
+ rpc: DesktopRpc,
967
+ method: string,
968
+ rawPayload: unknown,
969
+ ) {
970
+ const payload = decodeRpcValue<Record<string, unknown>>(rawPayload ?? {});
971
+
972
+ if (method === "init") return initialize(rpc, payload);
973
+ if (method === "http.fetch") return handleHttpFetch(payload);
974
+ if (method.startsWith("capability.")) return handleCapability(rpc, method, payload);
975
+ if (method.startsWith("desktop.")) return handleDesktop(rpc, method, payload);
976
+
977
+ switch (method) {
978
+ case "update.check":
979
+ return checkDesktopUpdate(typeof payload.currentVersion === "string" ? payload.currentVersion : "");
980
+ case "update.start": {
981
+ const release = payload.release && typeof payload.release === "object"
982
+ ? payload.release as Partial<ReleaseInfo>
983
+ : null;
984
+ void runDesktopUpdate(
985
+ rpc,
986
+ typeof payload.currentVersion === "string" ? payload.currentVersion : release?.version ?? "",
987
+ );
988
+ return null;
989
+ }
990
+ case "ticker.loadAll":
991
+ return requireServices().tickerRepository.loadAllTickers();
992
+ case "ticker.load":
993
+ return requireServices().tickerRepository.loadTicker(payload.symbol as string);
994
+ case "ticker.save":
995
+ return requireServices().tickerRepository.saveTicker(payload.ticker as never);
996
+ case "ticker.delete":
997
+ return requireServices().tickerRepository.deleteTicker(payload.symbol as string);
998
+ case "config.save":
999
+ setCurrentConfig(payload.config as AppConfig);
1000
+ if (desktopWorkspace) {
1001
+ await commitDesktopSnapshot(desktopWorkspace.replaceConfig(requireConfig(), { layoutChanged: true }));
1002
+ return null;
1003
+ }
1004
+ return saveConfig(requireConfig());
1005
+ case "config.resetAllData":
1006
+ closeAllDetachedWindows();
1007
+ desktopWorkspace = null;
1008
+ teardownServices();
1009
+ currentConfig = null;
1010
+ return resetAllData(payload.dataDir as string);
1011
+ case "config.export":
1012
+ return exportConfig(payload.config as AppConfig, payload.destPath as string);
1013
+ case "config.import":
1014
+ closeAllDetachedWindows();
1015
+ desktopWorkspace = null;
1016
+ teardownServices();
1017
+ setCurrentConfig(await importConfig(payload.dataDir as string, payload.srcPath as string));
1018
+ services = createAppServices({ config: requireConfig(), externalPlugins: [] });
1019
+ syncConfigAccessors();
1020
+ registerCoreCapabilities();
1021
+ desktopWorkspace = createDesktopWorkspace(requireConfig(), getSessionSnapshot());
1022
+ reconcileDetachedWindows();
1023
+ sendDesktopState(requireDesktopWorkspace().getSnapshot());
1024
+ return requireConfig();
1025
+ case "session.set":
1026
+ requireServices().persistence.sessions.set(payload.sessionId as string, payload.value, payload.schemaVersion as number | undefined);
1027
+ return null;
1028
+ case "session.delete":
1029
+ requireServices().persistence.sessions.delete(payload.sessionId as string);
1030
+ return null;
1031
+ case "pluginState.set":
1032
+ requireServices().persistence.pluginState.set(payload.pluginId as string, payload.key as string, payload.value, payload.schemaVersion as number | undefined);
1033
+ syncBackendCloudAuthState(payload.pluginId as string, payload.key as string, payload.value);
1034
+ return null;
1035
+ case "pluginState.delete":
1036
+ requireServices().persistence.pluginState.delete(payload.pluginId as string, payload.key as string);
1037
+ syncBackendCloudAuthState(payload.pluginId as string, payload.key as string, null);
1038
+ return null;
1039
+ case "host.exit":
1040
+ closeAllDetachedWindows();
1041
+ teardownServices();
1042
+ if (mainWindow) {
1043
+ mainWindow.close();
1044
+ mainWindow = null;
1045
+ return null;
1046
+ }
1047
+ Utils.quit();
1048
+ return null;
1049
+ case "host.openExternal":
1050
+ if (typeof payload.url !== "string") {
1051
+ throw new Error("host.openExternal requires a URL.");
1052
+ }
1053
+ Utils.openExternal(payload.url);
1054
+ return null;
1055
+ case "host.copyText":
1056
+ Utils.clipboardWriteText(normalizeText(payload.text) ?? "");
1057
+ return null;
1058
+ case "host.copyPngImage": {
1059
+ const pngBase64 = normalizeText(payload.pngBase64);
1060
+ if (!pngBase64) throw new Error("host.copyPngImage requires PNG data.");
1061
+ Utils.clipboardWriteImage(new Uint8Array(Buffer.from(pngBase64, "base64")));
1062
+ return null;
1063
+ }
1064
+ case "host.readText":
1065
+ return Utils.clipboardReadText() ?? "";
1066
+ case "host.notify":
1067
+ playNotificationSound(normalizeText(payload.sound));
1068
+ Utils.showNotification({
1069
+ title: normalizeText(payload.title) ?? "Gloomberb",
1070
+ body: normalizeText(payload.body),
1071
+ subtitle: normalizeText(payload.subtitle),
1072
+ silent: true,
1073
+ });
1074
+ return null;
1075
+ case "host.showContextMenu": {
1076
+ const menu = normalizeContextMenuItems(payload.menu);
1077
+ if (menu.length === 0) return false;
1078
+ const requestId = getContextMenuRequestId(menu);
1079
+ if (requestId) {
1080
+ contextMenuRequestRpcs.clear();
1081
+ contextMenuRequestRpcs.set(requestId, rpc);
1082
+ }
1083
+ ContextMenu.showContextMenu(menu as never);
1084
+ return true;
1085
+ }
1086
+ default:
1087
+ throw new Error(`Unknown backend method: ${method}`);
1088
+ }
1089
+ }
1090
+
1091
+ function installApplicationMenu() {
1092
+ ApplicationMenu.setApplicationMenu(buildApplicationMenu());
1093
+ }
1094
+
1095
+ function createWindowRpc(key: string): DesktopRpc {
1096
+ let rpc!: DesktopRpc;
1097
+ rpc = BrowserView.defineRPC<ElectrobunDesktopRpcSchema>({
1098
+ handlers: {
1099
+ requests: {
1100
+ "backend.request": async ({ method, payload }) => encodeRpcValue(await handleBackendRequest(rpc, method, payload)),
1101
+ },
1102
+ messages: {},
1103
+ },
1104
+ });
1105
+ registerWindowRpc(key, rpc);
1106
+ return rpc;
1107
+ }
1108
+
1109
+ Electrobun.events.on("context-menu-clicked", (event: unknown) => {
1110
+ const message = contextMenuSelectionMessage(event, ELECTROBUN_CONTEXT_MENU_ACTION);
1111
+ if (!message) return;
1112
+ const targetRpc = contextMenuRequestRpcs.get(message.requestId);
1113
+ contextMenuRequestRpcs.delete(message.requestId);
1114
+ if (targetRpc) {
1115
+ targetRpc.send["context-menu.select"](message);
1116
+ return;
1117
+ }
1118
+ forEachReadyWindowRpc((windowRpc) => {
1119
+ windowRpc.send["context-menu.select"](message);
1120
+ });
1121
+ });
1122
+
1123
+ ApplicationMenu.on("application-menu-clicked", (event: unknown) => {
1124
+ const command = applicationMenuCommand(event);
1125
+ if (!command || !readyWindowRpcs.has(MAIN_WINDOW_RPC_KEY)) return;
1126
+ windowRpcs.get(MAIN_WINDOW_RPC_KEY)?.send["application-menu.select"]({ command });
1127
+ });
1128
+
1129
+ installApplicationMenu();
1130
+
1131
+ const mainRpc = createWindowRpc(MAIN_WINDOW_RPC_KEY);
1132
+
1133
+ mainWindow = new BrowserWindow({
1134
+ title: "Gloomberb",
1135
+ frame: normalizeWindowFrameWithMinimum(DEFAULT_WINDOW_FRAME, DEFAULT_WINDOW_FRAME, MAIN_WINDOW_MIN_SIZE),
1136
+ url: "views://mainview/index.html",
1137
+ renderer: "native",
1138
+ rpc: mainRpc,
1139
+ titleBarStyle: "hiddenInset",
1140
+ navigationRules: JSON.stringify(["views://*"]),
1141
+ sandbox: false,
1142
+ });
1143
+ updateWindowFrameCache(mainWindow, DEFAULT_WINDOW_FRAME, MAIN_WINDOW_MIN_SIZE);
1144
+ (mainWindow as any).on?.("move", (event: WindowMoveEvent) => {
1145
+ applyWindowMoveEvent(mainWindow, event);
1146
+ });
1147
+ (mainWindow as any).on?.("resize", (event: WindowResizeEvent) => {
1148
+ applyWindowResizeEvent(mainWindow, event, MAIN_WINDOW_MIN_SIZE);
1149
+ });