gloomberb 0.11.0 → 0.11.2

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 (392) hide show
  1. package/README.md +38 -15
  2. package/README.zh-CN.md +12 -12
  3. package/package.json +10 -3
  4. package/src/api-client/auth.ts +14 -10
  5. package/src/api-client/data.ts +54 -0
  6. package/src/api-client/index.test.ts +124 -0
  7. package/src/api-client/index.ts +139 -4
  8. package/src/api-client/paths.ts +89 -0
  9. package/src/api-client/request.ts +15 -2
  10. package/src/api-client/socket-health.test.ts +1 -0
  11. package/src/api-client/socket.ts +2 -1
  12. package/src/api-client/types.ts +163 -0
  13. package/src/app/pane-runtime/index.tsx +4 -1
  14. package/src/app/pane-runtime/plugin-bindings.test.ts +117 -0
  15. package/src/app/pane-runtime/plugin-bindings.ts +25 -4
  16. package/src/app/pane-runtime/ticker-inspector-runtime.ts +25 -14
  17. package/src/app/runtime/desktop-deeplink.test.ts +16 -0
  18. package/src/app/runtime/desktop-deeplink.ts +66 -2
  19. package/src/app/runtime/desktop-menu.ts +3 -0
  20. package/src/app/runtime/plugin-bindings.ts +30 -28
  21. package/src/app/runtime/update.ts +8 -6
  22. package/src/app.tsx +21 -7
  23. package/src/architecture/import-boundaries.test.ts +5 -1
  24. package/src/capabilities/factories.ts +4 -0
  25. package/src/cli/commands/automation.test.ts +9 -0
  26. package/src/cli/commands/automation.ts +22 -73
  27. package/src/cli/index.ts +10 -6
  28. package/src/cli/registry.ts +1 -1
  29. package/src/components/chart/composite/composite-chart.test.tsx +45 -2
  30. package/src/components/chart/composite/composite-chart.tsx +16 -12
  31. package/src/components/chart/composite/format.test.ts +34 -0
  32. package/src/components/chart/composite/format.ts +33 -10
  33. package/src/components/chart/composite/scene.ts +0 -21
  34. package/src/components/chart/composite/tools.ts +49 -0
  35. package/src/components/chart/core/axis-format.ts +13 -3
  36. package/src/components/chart/core/index.test.ts +7 -0
  37. package/src/components/chart/core/renderer.ts +3 -4
  38. package/src/components/command-bar/commands/direct/index.ts +4 -0
  39. package/src/components/command-bar/commands/registry.ts +8 -1
  40. package/src/components/command-bar/layout-items/current-layout.ts +1 -1
  41. package/src/components/command-bar/pane-templates/workflow.ts +4 -0
  42. package/src/components/command-bar/routes/root/shortcut-feedback.ts +3 -0
  43. package/src/components/command-bar/routes/root/shortcuts.test.ts +19 -0
  44. package/src/components/command-bar/routes/root/shortcuts.ts +3 -1
  45. package/src/components/command-bar/surface/index.test.tsx +6 -6
  46. package/src/components/command-bar/surface/index.tsx +4 -1
  47. package/src/components/command-bar/surface/pane.test.tsx +66 -2
  48. package/src/components/command-bar/view-model.test.ts +2 -1
  49. package/src/components/command-bar/workflow/ops.test.ts +54 -0
  50. package/src/components/command-bar/workflow/ops.ts +1 -1
  51. package/src/components/company-logo.test.ts +19 -0
  52. package/src/components/company-logo.tsx +60 -0
  53. package/src/components/feed-data-table/stack-view.tsx +7 -1
  54. package/src/components/index.ts +1 -0
  55. package/src/components/layout/detached-pane-shell.tsx +85 -15
  56. package/src/components/layout/header.tsx +5 -5
  57. package/src/components/layout/pane/title.ts +31 -3
  58. package/src/components/layout/shell/active-drag.ts +4 -11
  59. package/src/components/layout/shell/drag/index.ts +4 -20
  60. package/src/components/layout/shell/drag/runtime.ts +3 -4
  61. package/src/components/layout/shell/index.test.tsx +17 -3
  62. package/src/components/layout/shell/index.tsx +89 -31
  63. package/src/components/layout/shell/menu.test.ts +37 -1
  64. package/src/components/layout/shell/menu.ts +14 -0
  65. package/src/components/layout/shell/native/pointer-runtime.ts +22 -8
  66. package/src/components/layout/shell/pane/actions.ts +0 -5
  67. package/src/components/layout/shell/pane/management-shortcuts.ts +9 -4
  68. package/src/components/layout/shell/shortcuts.ts +6 -3
  69. package/src/components/layout/shell/ticker-link-menu.test.ts +82 -0
  70. package/src/components/layout/shell/ticker-link-menu.ts +78 -0
  71. package/src/components/layout/status-bar.test.tsx +62 -61
  72. package/src/components/layout/status-bar.tsx +53 -75
  73. package/src/components/onboarding/account-step/form-panel.tsx +4 -2
  74. package/src/components/onboarding/account-step/index.tsx +1 -1
  75. package/src/components/onboarding/account-step/signed-in-panel.tsx +1 -1
  76. package/src/components/onboarding/onboarding-wizard.test.tsx +44 -1
  77. package/src/components/onboarding/onboarding-wizard.tsx +13 -5
  78. package/src/components/onboarding/wizard-account.ts +4 -23
  79. package/src/components/table-view-shared.test.ts +11 -0
  80. package/src/components/table-view-shared.tsx +26 -0
  81. package/src/components/ui/data-table/index.tsx +19 -1
  82. package/src/components/ui/fields.tsx +5 -1
  83. package/src/core/state/app/layout-reducer.ts +36 -0
  84. package/src/core/state/app/layout.ts +10 -5
  85. package/src/core/state/app/state.ts +0 -13
  86. package/src/core/state/app/types.ts +6 -4
  87. package/src/data/config/chart-settings.ts +1 -1
  88. package/src/data/config/layout.ts +2 -0
  89. package/src/data/config/store/index.test.ts +27 -6
  90. package/src/data/config/store/normalize.ts +3 -5
  91. package/src/data/plugin-state-store.ts +11 -0
  92. package/src/data/sqlite/cache.test.ts +10 -0
  93. package/src/i18n/es.ts +13 -5
  94. package/src/i18n/ja.ts +13 -5
  95. package/src/i18n/ko.ts +13 -5
  96. package/src/i18n/zh-cn.ts +13 -5
  97. package/src/i18n/zh-tw.ts +13 -5
  98. package/src/layout-marketplace/api.test.ts +54 -0
  99. package/src/layout-marketplace/api.ts +65 -0
  100. package/src/layout-marketplace/gallery-desktop.test.tsx +262 -0
  101. package/src/layout-marketplace/gallery-desktop.tsx +375 -0
  102. package/src/layout-marketplace/gallery-terminal.tsx +376 -0
  103. package/src/layout-marketplace/gallery.test.tsx +84 -0
  104. package/src/layout-marketplace/gallery.tsx +323 -0
  105. package/src/layout-marketplace/geometry.test.ts +47 -0
  106. package/src/layout-marketplace/geometry.ts +80 -0
  107. package/src/layout-marketplace/mini-workspace.test.tsx +122 -0
  108. package/src/layout-marketplace/mini-workspace.tsx +428 -0
  109. package/src/layout-marketplace/model.test.ts +118 -0
  110. package/src/layout-marketplace/model.ts +171 -0
  111. package/src/layout-marketplace/name-dialog.tsx +60 -0
  112. package/src/layout-marketplace/pane-imagery.ts +85 -0
  113. package/src/layout-marketplace/pane.tsx +20 -0
  114. package/src/layout-marketplace/payload.test.ts +246 -0
  115. package/src/layout-marketplace/payload.ts +624 -0
  116. package/src/layout-marketplace/use-marketplace.ts +63 -0
  117. package/src/market-data/coordinator/index.test.ts +2 -2
  118. package/src/market-data/coordinator/index.ts +1 -1
  119. package/src/market-data/hooks.tsx +0 -41
  120. package/src/market-data/market/format.test.ts +7 -0
  121. package/src/market-data/market/freshness.ts +5 -2
  122. package/src/market-data/quotes/freshness.test.ts +16 -0
  123. package/src/news/aggregator.test.ts +92 -3
  124. package/src/news/aggregator.ts +98 -18
  125. package/src/news/hooks.test.tsx +2 -0
  126. package/src/news/hooks.ts +27 -1
  127. package/src/news/news-model.ts +5 -2
  128. package/src/news/types.ts +7 -0
  129. package/src/plugins/broker-sync/broker-sync.test.ts +58 -0
  130. package/src/plugins/broker-sync/normalize.ts +42 -5
  131. package/src/plugins/builtin/account-management/pane.tsx +5 -5
  132. package/src/plugins/builtin/ai/index.tsx +18 -2
  133. package/src/plugins/builtin/ai/screener/pane.test.tsx +47 -4
  134. package/src/plugins/builtin/ai/screener/pane.tsx +35 -12
  135. package/src/plugins/builtin/alerts/alert-engine.test.ts +53 -1
  136. package/src/plugins/builtin/alerts/alert-engine.ts +34 -1
  137. package/src/plugins/builtin/alerts/index.test.tsx +49 -6
  138. package/src/plugins/builtin/alerts/index.tsx +11 -6
  139. package/src/plugins/builtin/alerts/pane.tsx +51 -9
  140. package/src/plugins/builtin/alerts/quotes.ts +11 -3
  141. package/src/plugins/builtin/alerts/types.ts +1 -0
  142. package/src/plugins/builtin/analytics/index.tsx +11 -1
  143. package/src/plugins/builtin/analytics/pane-model.ts +6 -4
  144. package/src/plugins/builtin/broker-manager/index.tsx +3 -0
  145. package/src/plugins/builtin/buildout/model/index.ts +1 -1
  146. package/src/plugins/builtin/buildout/pane/index.tsx +17 -18
  147. package/src/plugins/builtin/cds/client.test.ts +83 -0
  148. package/src/plugins/builtin/cds/client.ts +67 -0
  149. package/src/plugins/builtin/cds/index.tsx +41 -0
  150. package/src/plugins/builtin/cds/model.test.ts +232 -0
  151. package/src/plugins/builtin/cds/model.ts +401 -0
  152. package/src/plugins/builtin/cds/pane.test.tsx +132 -0
  153. package/src/plugins/builtin/cds/pane.tsx +358 -0
  154. package/src/plugins/builtin/chart-composer/chart-spec.ts +31 -0
  155. package/src/plugins/builtin/chart-composer/index.tsx +88 -2
  156. package/src/plugins/builtin/chart-composer/pane-share.test.ts +71 -0
  157. package/src/plugins/builtin/chart-composer/pane.tsx +103 -15
  158. package/src/plugins/builtin/chat/content/index.tsx +4 -1
  159. package/src/plugins/builtin/chat/controller/channels.ts +7 -1
  160. package/src/plugins/builtin/chat/controller/index.ts +14 -9
  161. package/src/plugins/builtin/chat/controller/notifications.ts +13 -3
  162. package/src/plugins/builtin/chat/controller/persistence.ts +0 -3
  163. package/src/plugins/builtin/chat/controller/realtime.ts +3 -3
  164. package/src/plugins/builtin/chat/controller/send.ts +1 -3
  165. package/src/plugins/builtin/chat/controller/session-runtime.ts +18 -11
  166. package/src/plugins/builtin/chat/controller/state.ts +1 -3
  167. package/src/plugins/builtin/chat/controller/storage.ts +0 -2
  168. package/src/plugins/builtin/chat/controller/utils.ts +4 -6
  169. package/src/plugins/builtin/chat/controller/view.ts +2 -2
  170. package/src/plugins/builtin/chat/controller.test.ts +142 -14
  171. package/src/plugins/builtin/chat/message/desktop.test.tsx +88 -0
  172. package/src/plugins/builtin/chat/message/desktop.tsx +3 -1
  173. package/src/plugins/builtin/chat/message/profile-popover.test.tsx +1 -29
  174. package/src/plugins/builtin/chat/profile-popover.ts +53 -22
  175. package/src/plugins/builtin/chat/sidebar.test.tsx +81 -0
  176. package/src/plugins/builtin/chat/sidebar.tsx +63 -0
  177. package/src/plugins/builtin/chat/test-harness.tsx +29 -1
  178. package/src/plugins/builtin/cloud/auth-actions.tsx +14 -7
  179. package/src/plugins/builtin/cloud/auth-commands.ts +21 -51
  180. package/src/plugins/builtin/cloud/auth-dialog.tsx +296 -0
  181. package/src/{components/onboarding/account-step/model.test.ts → plugins/builtin/cloud/auth-model.test.ts} +1 -1
  182. package/src/{components/onboarding/account-step/model.ts → plugins/builtin/cloud/auth-model.ts} +37 -3
  183. package/src/plugins/builtin/cloud/browser.tsx +9 -0
  184. package/src/plugins/builtin/cloud/buildout-module.tsx +23 -0
  185. package/src/plugins/builtin/cloud/device-signin.ts +2 -8
  186. package/src/plugins/builtin/cloud/index.tsx +2 -0
  187. package/src/plugins/builtin/cloud/plugin.tsx +14 -24
  188. package/src/plugins/builtin/cloud/upgrade-command.ts +13 -6
  189. package/src/plugins/builtin/cloud-tweets/pane-share.test.ts +56 -0
  190. package/src/plugins/builtin/cloud-tweets/pane.tsx +20 -5
  191. package/src/plugins/builtin/cloud-tweets/registration.ts +50 -6
  192. package/src/plugins/builtin/composite-plugins.ts +3 -1
  193. package/src/plugins/builtin/congress-trades/detail.tsx +43 -1
  194. package/src/plugins/builtin/congress-trades/model.test.ts +85 -0
  195. package/src/plugins/builtin/congress-trades/model.ts +69 -1
  196. package/src/plugins/builtin/congress-trades/pane.tsx +51 -2
  197. package/src/plugins/builtin/connections/index.ts +3 -0
  198. package/src/plugins/builtin/dividend-yield/client.ts +18 -0
  199. package/src/plugins/builtin/dividend-yield/pane.tsx +7 -6
  200. package/src/plugins/builtin/earnings/index.tsx +5 -3
  201. package/src/plugins/builtin/earnings/model.test.ts +19 -1
  202. package/src/plugins/builtin/earnings/model.ts +17 -15
  203. package/src/plugins/builtin/econ/calendar-model.ts +0 -5
  204. package/src/plugins/builtin/econ/index.test.tsx +6 -1
  205. package/src/plugins/builtin/fear-greed/charts.tsx +12 -8
  206. package/src/plugins/builtin/help/index.tsx +2 -2
  207. package/src/plugins/builtin/holders/thirteenf-match.ts +1 -2
  208. package/src/plugins/builtin/insider/index.tsx +27 -11
  209. package/src/plugins/builtin/kelly-sizer/index.tsx +4 -1
  210. package/src/plugins/builtin/layout-manager/index.tsx +17 -4
  211. package/src/plugins/builtin/news/index.tsx +11 -6
  212. package/src/plugins/builtin/news/wire/breaking/notifications.test.ts +2 -0
  213. package/src/plugins/builtin/news/wire/breaking/pane.test.tsx +2 -0
  214. package/src/plugins/builtin/news/wire/breaking/pane.tsx +4 -1
  215. package/src/plugins/builtin/news/wire/feed-config.test.ts +15 -1
  216. package/src/plugins/builtin/news/wire/feed-config.ts +25 -5
  217. package/src/plugins/builtin/news/wire/index.tsx +3 -3
  218. package/src/plugins/builtin/news/wire/industry-pane.tsx +7 -2
  219. package/src/plugins/builtin/news/wire/news/footer.ts +34 -1
  220. package/src/plugins/builtin/news/wire/news/preset-pane.tsx +4 -1
  221. package/src/plugins/builtin/news/wire/news/table.tsx +8 -2
  222. package/src/plugins/builtin/news/wire/rss/source.test.ts +12 -0
  223. package/src/plugins/builtin/news/wire/rss/source.ts +1 -1
  224. package/src/plugins/builtin/notes/files.ts +80 -0
  225. package/src/plugins/builtin/notes/index.tsx +3 -0
  226. package/src/plugins/builtin/notes/sync.test.ts +49 -0
  227. package/src/plugins/builtin/notes/sync.ts +98 -0
  228. package/src/plugins/builtin/options/index.tsx +1 -0
  229. package/src/plugins/builtin/options/view.test.tsx +25 -3
  230. package/src/plugins/builtin/options/view.tsx +1 -0
  231. package/src/plugins/builtin/portfolio-list/account-metrics.test.ts +22 -0
  232. package/src/plugins/builtin/portfolio-list/account-metrics.ts +12 -7
  233. package/src/plugins/builtin/portfolio-list/cli/render.ts +5 -3
  234. package/src/plugins/builtin/portfolio-list/column-values.ts +15 -4
  235. package/src/plugins/builtin/portfolio-list/header.tsx +13 -3
  236. package/src/plugins/builtin/portfolio-list/index.tsx +8 -0
  237. package/src/plugins/builtin/portfolio-list/metrics.test.ts +31 -1
  238. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +1 -0
  239. package/src/plugins/builtin/portfolio-list/pane/index.tsx +10 -7
  240. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +6 -1
  241. package/src/plugins/builtin/portfolio-list/position-metrics.ts +25 -6
  242. package/src/plugins/builtin/portfolio-list/summary/index.tsx +35 -21
  243. package/src/plugins/builtin/portfolio-list/summary/totals.ts +20 -6
  244. package/src/plugins/builtin/research/corporate-actions-pane.tsx +5 -12
  245. package/src/plugins/builtin/research/equity-diagnostic-pane.test.tsx +329 -0
  246. package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +582 -0
  247. package/src/plugins/builtin/research/index.test.ts +14 -0
  248. package/src/plugins/builtin/research/index.tsx +29 -0
  249. package/src/plugins/builtin/sec/index.tsx +24 -9
  250. package/src/plugins/builtin/sectors/index.tsx +14 -0
  251. package/src/plugins/builtin/shared/cloud-upgrade.ts +18 -11
  252. package/src/plugins/builtin/shared/public-pane.test.ts +12 -0
  253. package/src/plugins/builtin/shared/public-pane.ts +9 -0
  254. package/src/plugins/builtin/shared/public-share.ts +23 -0
  255. package/src/plugins/builtin/shared/ticker-source.ts +32 -0
  256. package/src/plugins/builtin/shared/ticker-surface.test.ts +27 -0
  257. package/src/plugins/builtin/shared/ticker-surface.ts +17 -0
  258. package/src/plugins/builtin/substack/api/cache.ts +7 -3
  259. package/src/plugins/builtin/substack/api/loaders.ts +36 -12
  260. package/src/plugins/builtin/substack/api/types.ts +2 -0
  261. package/src/plugins/builtin/substack/pane-state.ts +1 -1
  262. package/src/plugins/builtin/substack/pane.tsx +55 -12
  263. package/src/plugins/builtin/thirteenf/api.test.ts +1 -1
  264. package/src/plugins/builtin/thirteenf/api.ts +40 -18
  265. package/src/plugins/builtin/thirteenf/data.ts +15 -7
  266. package/src/plugins/builtin/thirteenf/pane.tsx +45 -10
  267. package/src/plugins/builtin/ticker-detail/index.tsx +10 -0
  268. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +12 -4
  269. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +8 -2
  270. package/src/plugins/builtin/ticker-detail/settings.ts +12 -0
  271. package/src/plugins/builtin/treasury-auctions/index.tsx +2 -0
  272. package/src/plugins/builtin/world-indices/index.tsx +2 -0
  273. package/src/plugins/catalog-browser.test.ts +78 -0
  274. package/src/plugins/catalog-browser.ts +136 -0
  275. package/src/plugins/gridlock-notification.ts +2 -1
  276. package/src/plugins/ibkr/flex/index.test.ts +19 -0
  277. package/src/plugins/ibkr/flex/index.ts +1 -1
  278. package/src/plugins/pane-manager/gridlock.ts +87 -3
  279. package/src/plugins/pane-manager.test.ts +30 -0
  280. package/src/plugins/pane-manager.ts +6 -1
  281. package/src/plugins/prediction-markets/chart.tsx +2 -2
  282. package/src/plugins/prediction-markets/controller/catalog.ts +77 -2
  283. package/src/plugins/prediction-markets/controller/data.ts +7 -1
  284. package/src/plugins/prediction-markets/controller/index.ts +9 -2
  285. package/src/plugins/prediction-markets/controller/keyboard.ts +10 -14
  286. package/src/plugins/prediction-markets/detail/overview.tsx +21 -0
  287. package/src/plugins/prediction-markets/detail/pane.tsx +43 -34
  288. package/src/plugins/prediction-markets/detail/trades.tsx +34 -9
  289. package/src/plugins/prediction-markets/detail.test.tsx +29 -2
  290. package/src/plugins/prediction-markets/index.tsx +8 -4
  291. package/src/plugins/prediction-markets/navigation.ts +0 -1
  292. package/src/plugins/prediction-markets/pane.test.tsx +77 -0
  293. package/src/plugins/prediction-markets/pane.tsx +12 -0
  294. package/src/plugins/prediction-markets/plugin.test.tsx +36 -0
  295. package/src/plugins/prediction-markets/services/kalshi/adapter.ts +49 -9
  296. package/src/plugins/prediction-markets/services/polymarket/adapter.ts +31 -0
  297. package/src/plugins/prediction-markets/test-helpers.tsx +9 -0
  298. package/src/plugins/registry/index.ts +2 -0
  299. package/src/plugins/ticker-navigation.test.ts +24 -0
  300. package/src/plugins/ticker-navigation.ts +40 -0
  301. package/src/remote/schema.ts +1 -1
  302. package/src/renderers/browser/app-services.ts +61 -0
  303. package/src/renderers/browser/cloud-transport.test.ts +68 -0
  304. package/src/renderers/browser/cloud-transport.ts +48 -0
  305. package/src/renderers/browser/config-host.ts +58 -0
  306. package/src/renderers/browser/deeplink-bridge.test.ts +41 -0
  307. package/src/renderers/browser/deeplink-bridge.ts +22 -0
  308. package/src/renderers/browser/error-boundary.tsx +23 -0
  309. package/src/renderers/browser/main.tsx +64 -0
  310. package/src/renderers/browser/persistence.ts +74 -0
  311. package/src/renderers/browser/storage.test.ts +66 -0
  312. package/src/renderers/browser/storage.ts +50 -0
  313. package/src/renderers/browser/ticker-repository.ts +59 -0
  314. package/src/renderers/browser/ui-host.tsx +40 -0
  315. package/src/renderers/cloudflare/worker.test.ts +136 -0
  316. package/src/renderers/cloudflare/worker.ts +116 -0
  317. package/src/renderers/electrobun/bun/application-menu/index.ts +3 -2
  318. package/src/renderers/electrobun/bun/desktop/initialization.ts +1 -0
  319. package/src/renderers/electrobun/bun/desktop/plugin-state.ts +7 -1
  320. package/src/renderers/electrobun/view/app-services.ts +14 -3
  321. package/src/renderers/electrobun/view/build-assets.ts +12 -10
  322. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -1
  323. package/src/renderers/electrobun/view/data-table/index.tsx +10 -1
  324. package/src/renderers/electrobun/view/desktop/controls.tsx +2 -0
  325. package/src/renderers/electrobun/view/dom-ui-host.tsx +143 -0
  326. package/src/renderers/electrobun/view/host/box.test.tsx +33 -0
  327. package/src/renderers/electrobun/view/host/box.tsx +11 -6
  328. package/src/renderers/electrobun/view/host/chart-surface.tsx +7 -1
  329. package/src/renderers/electrobun/view/host/input.tsx +11 -7
  330. package/src/renderers/electrobun/view/host/native.ts +0 -5
  331. package/src/renderers/electrobun/view/main.tsx +2 -0
  332. package/src/renderers/electrobun/view/remote/persistence.ts +5 -0
  333. package/src/renderers/electrobun/view/styles.css +107 -7
  334. package/src/renderers/electrobun/view/toast-host.test.tsx +78 -0
  335. package/src/renderers/electrobun/view/toast-host.tsx +69 -43
  336. package/src/renderers/electrobun/view/ui-host.tsx +6 -137
  337. package/src/renderers/opentui/start.tsx +2 -0
  338. package/src/renderers/share/layout-view.test.tsx +58 -0
  339. package/src/renderers/share/layout-view.tsx +133 -0
  340. package/src/renderers/share/main.tsx +102 -0
  341. package/src/renderers/share/styles.css +54 -0
  342. package/src/renderers/share/view.test.tsx +100 -0
  343. package/src/renderers/share/view.tsx +150 -0
  344. package/src/shares/api.test.ts +105 -0
  345. package/src/shares/api.ts +127 -0
  346. package/src/shares/chart-snapshot.test.ts +34 -0
  347. package/src/shares/chart-snapshot.ts +28 -0
  348. package/src/shares/location.test.ts +19 -0
  349. package/src/shares/location.ts +11 -0
  350. package/src/shares/pane.test.ts +110 -0
  351. package/src/shares/pane.ts +54 -0
  352. package/src/shares/payload.ts +177 -0
  353. package/src/sources/gloomberb-cloud/index.test.ts +61 -1
  354. package/src/sources/gloomberb-cloud/index.ts +86 -14
  355. package/src/sources/gloomberb-cloud/normalizers.test.ts +34 -0
  356. package/src/sources/gloomberb-cloud/normalizers.ts +5 -5
  357. package/src/sources/provider-router/history.test.ts +2 -2
  358. package/src/sources/provider-router/news.test.ts +34 -0
  359. package/src/sources/provider-router/news.ts +15 -1
  360. package/src/sources/sec-edgar.test.ts +59 -0
  361. package/src/sources/sec-edgar.ts +63 -23
  362. package/src/sources/yahoo-finance/mappers.ts +3 -0
  363. package/src/sources/yahoo-finance/snapshots.ts +10 -2
  364. package/src/sources/yahoo-finance/symbols.test.ts +10 -0
  365. package/src/sources/yahoo-finance/symbols.ts +13 -8
  366. package/src/sources/yahoo-finance.test.ts +1 -1
  367. package/src/sources/yahoo-finance.ts +6 -2
  368. package/src/state/app/bootstrap.ts +6 -7
  369. package/src/state/app/context/index.test.ts +41 -1
  370. package/src/state/app/context/index.test.tsx +34 -16
  371. package/src/state/selectors-ui.ts +0 -8
  372. package/src/sync/baseline.ts +32 -0
  373. package/src/sync/controller.test.ts +124 -1
  374. package/src/sync/controller.ts +54 -12
  375. package/src/sync/core-contributors.test.ts +44 -0
  376. package/src/sync/core-contributors.ts +59 -10
  377. package/src/sync/react.ts +22 -2
  378. package/src/sync/types.ts +12 -0
  379. package/src/theme/font-scale.ts +0 -11
  380. package/src/tickers/search/index.test.ts +27 -0
  381. package/src/tickers/search/upsert.ts +10 -0
  382. package/src/types/capability-route-source.ts +2 -1
  383. package/src/types/config.ts +35 -5
  384. package/src/types/desktop-menu.ts +1 -0
  385. package/src/types/plugin.ts +36 -0
  386. package/src/ui/context-menu.tsx +1 -1
  387. package/src/ui/host.tsx +4 -0
  388. package/src/ui/toast.tsx +2 -0
  389. package/src/utils/back-navigation.ts +0 -4
  390. package/src/utils/price-history.ts +11 -10
  391. package/src/utils/sec.test.ts +6 -0
  392. package/src/utils/sec.ts +1 -1
@@ -0,0 +1,58 @@
1
+ import {
2
+ setConfigStoreHost,
3
+ type ConfigStoreHost,
4
+ } from "../../data/config/store";
5
+ import {
6
+ normalizeConfigForSave,
7
+ normalizeLoadedConfig,
8
+ } from "../../data/config/store/normalize";
9
+ import { createDefaultConfig, type AppConfig } from "../../types/config";
10
+ import { BROWSER_STORAGE_KEYS, SafeJsonStorage, type StorageLike } from "./storage";
11
+
12
+ export const BROWSER_DATA_DIR = "browser://local";
13
+
14
+ function browserReady(config: AppConfig): AppConfig {
15
+ return { ...config, onboardingComplete: true, onboardingProgress: undefined };
16
+ }
17
+
18
+ function createBrowserDefaultConfig(dataDir: string): AppConfig {
19
+ return browserReady(createDefaultConfig(dataDir));
20
+ }
21
+
22
+ export function createBrowserConfigStore(storage: StorageLike): ConfigStoreHost {
23
+ const data = new SafeJsonStorage<unknown>(storage, BROWSER_STORAGE_KEYS.config, null);
24
+ return {
25
+ async getDataDir() { return BROWSER_DATA_DIR; },
26
+ async loadConfig(dataDir) {
27
+ const saved = data.get();
28
+ if (!saved || typeof saved !== "object" || Array.isArray(saved)) {
29
+ return createBrowserDefaultConfig(dataDir);
30
+ }
31
+ return browserReady(normalizeLoadedConfig(saved as Record<string, unknown>, dataDir).config);
32
+ },
33
+ async saveConfig(config) {
34
+ data.set(normalizeConfigForSave(browserReady({ ...config, dataDir: BROWSER_DATA_DIR })));
35
+ },
36
+ async initDataDir(dataDir) {
37
+ const config = createBrowserDefaultConfig(dataDir);
38
+ data.set(config);
39
+ return config;
40
+ },
41
+ async resetAllData(dataDir) {
42
+ for (const key of Object.values(BROWSER_STORAGE_KEYS)) {
43
+ try { storage.removeItem(key); } catch {}
44
+ }
45
+ data.set(createBrowserDefaultConfig(dataDir));
46
+ },
47
+ async exportConfig() {
48
+ throw new Error("Config file export is unavailable in the browser.");
49
+ },
50
+ async importConfig() {
51
+ throw new Error("Config file import is unavailable in the browser.");
52
+ },
53
+ };
54
+ }
55
+
56
+ export function installBrowserConfigStore(storage: StorageLike = localStorage): void {
57
+ setConfigStoreHost(createBrowserConfigStore(storage));
58
+ }
@@ -0,0 +1,41 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { createBrowserDeepLinkBridge } from "./deeplink-bridge";
3
+
4
+ const originalWindow = globalThis.window;
5
+
6
+ afterEach(() => {
7
+ if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window;
8
+ else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
9
+ });
10
+
11
+ describe("browser social share handoff", () => {
12
+ test("maps a valid pane share query to the common deep-link runtime", () => {
13
+ const id = "0123456789abcdef0123456789abcdef";
14
+ Object.defineProperty(globalThis, "window", {
15
+ configurable: true,
16
+ value: {
17
+ location: { search: `?share=${id}` },
18
+ addEventListener() {},
19
+ removeEventListener() {},
20
+ },
21
+ });
22
+ const seen: string[] = [];
23
+ createBrowserDeepLinkBridge().subscribe((deeplink) => seen.push(deeplink.url));
24
+ expect(seen).toEqual([`gloomberb://share/${id}`]);
25
+ });
26
+
27
+ test("prefers a shared layout query", () => {
28
+ const id = "fedcba9876543210fedcba9876543210";
29
+ Object.defineProperty(globalThis, "window", {
30
+ configurable: true,
31
+ value: {
32
+ location: { search: `?layout=${id}&share=0123456789abcdef0123456789abcdef` },
33
+ addEventListener() {},
34
+ removeEventListener() {},
35
+ },
36
+ });
37
+ const seen: string[] = [];
38
+ createBrowserDeepLinkBridge().subscribe((deeplink) => seen.push(deeplink.url));
39
+ expect(seen).toEqual([`gloomberb://layout/${id}`]);
40
+ });
41
+ });
@@ -0,0 +1,22 @@
1
+ import { marketplaceLayoutIdFromSearch } from "../../layout-marketplace/api";
2
+ import { paneShareIdFromSearch } from "../../shares/location";
3
+ import type { DesktopDeepLinkBridge } from "../../types/desktop-deeplink";
4
+
5
+ export function createBrowserDeepLinkBridge(): DesktopDeepLinkBridge {
6
+ return {
7
+ subscribe(listener) {
8
+ const emit = () => {
9
+ const layoutId = marketplaceLayoutIdFromSearch(window.location.search);
10
+ if (layoutId) {
11
+ listener({ url: `gloomberb://layout/${layoutId}` });
12
+ return;
13
+ }
14
+ const shareId = paneShareIdFromSearch(window.location.search);
15
+ if (shareId) listener({ url: `gloomberb://share/${shareId}` });
16
+ };
17
+ emit();
18
+ window.addEventListener("popstate", emit);
19
+ return () => window.removeEventListener("popstate", emit);
20
+ },
21
+ };
22
+ }
@@ -0,0 +1,23 @@
1
+ /** @jsxImportSource react */
2
+ import { Component, type ErrorInfo, type ReactNode } from "react";
3
+
4
+ export class BrowserErrorBoundary extends Component<
5
+ { children: ReactNode },
6
+ { error: unknown }
7
+ > {
8
+ override state = { error: null as unknown };
9
+
10
+ static getDerivedStateFromError(error: unknown) {
11
+ return { error };
12
+ }
13
+
14
+ override componentDidCatch(error: Error, info: ErrorInfo): void {
15
+ console.error("Browser renderer crashed", error, info.componentStack);
16
+ }
17
+
18
+ override render() {
19
+ if (!this.state.error) return this.props.children;
20
+ const message = this.state.error instanceof Error ? this.state.error.message : String(this.state.error);
21
+ return <div className="gloom-fatal"><h1>Gloomberb crashed</h1><pre>{message}</pre><button onClick={() => window.location.reload()}>Reload</button></div>;
22
+ }
23
+ }
@@ -0,0 +1,64 @@
1
+ /** @jsxImportSource react */
2
+ import "../electrobun/view/styles.css";
3
+ import { createRoot } from "react-dom/client";
4
+ import { App } from "../../app";
5
+ import { loadConfig } from "../../data/config/store";
6
+ import { applyLanguageFromConfig } from "../../i18n";
7
+ import { getBrowserBuiltinPlugins } from "../../plugins/catalog-browser";
8
+ import { UiHostProvider } from "../../ui/host";
9
+ import { WebDialogHostProvider } from "../electrobun/view/dialog-host";
10
+ import { BrowserErrorBoundary } from "./error-boundary";
11
+ import { installFocusScopeRelease } from "../electrobun/view/host/focus-scope";
12
+ import { WebInputHostProvider } from "../electrobun/view/input-host";
13
+ import { webNativeRenderer } from "../electrobun/view/native-renderer";
14
+ import { WebToastHostProvider } from "../electrobun/view/toast-host";
15
+ import { createBrowserAppServices } from "./app-services";
16
+ import {
17
+ installBrowserFetchTransports,
18
+ restoreBrowserCloudSession,
19
+ } from "./cloud-transport";
20
+ import { BROWSER_DATA_DIR, installBrowserConfigStore } from "./config-host";
21
+ import { browserRendererHost, browserUiHost } from "./ui-host";
22
+ import { createBrowserDeepLinkBridge } from "./deeplink-bridge";
23
+
24
+ const rootElement = document.getElementById("root");
25
+ if (!rootElement) throw new Error("Missing root element");
26
+ const appRootElement = rootElement;
27
+ appRootElement.tabIndex = -1;
28
+ const root = createRoot(appRootElement);
29
+ root.render(<div className="gloom-loading">Starting Gloomberb...</div>);
30
+
31
+ async function boot(): Promise<void> {
32
+ installBrowserConfigStore();
33
+ installBrowserFetchTransports();
34
+ installFocusScopeRelease();
35
+ await restoreBrowserCloudSession();
36
+ const config = await loadConfig(BROWSER_DATA_DIR);
37
+ applyLanguageFromConfig(config);
38
+ const deepLinkBridge = createBrowserDeepLinkBridge();
39
+ root.render(
40
+ <BrowserErrorBoundary>
41
+ <UiHostProvider ui={browserUiHost} renderer={browserRendererHost} nativeRenderer={webNativeRenderer}>
42
+ <WebInputHostProvider>
43
+ <WebToastHostProvider>
44
+ <WebDialogHostProvider>
45
+ <App
46
+ config={config}
47
+ servicesFactory={createBrowserAppServices}
48
+ plugins={getBrowserBuiltinPlugins()}
49
+ desktopDeepLinkBridge={deepLinkBridge}
50
+ updatesEnabled={false}
51
+ />
52
+ </WebDialogHostProvider>
53
+ </WebToastHostProvider>
54
+ </WebInputHostProvider>
55
+ </UiHostProvider>
56
+ </BrowserErrorBoundary>,
57
+ );
58
+ appRootElement.focus({ preventScroll: true });
59
+ }
60
+
61
+ void boot().catch((error) => {
62
+ const message = error instanceof Error ? error.message : String(error);
63
+ root.render(<div className="gloom-fatal"><h1>Gloomberb failed to start</h1><pre>{message}</pre></div>);
64
+ });
@@ -0,0 +1,74 @@
1
+ import type { AppPersistencePort } from "../../core/app-service-ports";
2
+ import type { PluginStateRecord } from "../../data/plugin-state-store";
3
+ import type { SessionSnapshotRecord } from "../../data/session-store";
4
+ import { DesktopMemoryResourceStore } from "../electrobun/view/resource-store";
5
+ import { BROWSER_STORAGE_KEYS, SafeJsonStorage, type StorageLike } from "./storage";
6
+
7
+ type PluginState = Record<string, Record<string, PluginStateRecord>>;
8
+ type Sessions = Record<string, SessionSnapshotRecord>;
9
+
10
+ function isRecord(value: unknown): value is Record<string, unknown> {
11
+ return !!value && typeof value === "object" && !Array.isArray(value);
12
+ }
13
+
14
+ export class BrowserPersistence implements AppPersistencePort {
15
+ readonly resources = new DesktopMemoryResourceStore();
16
+ private readonly pluginStateData: SafeJsonStorage<PluginState>;
17
+ private readonly sessionData: SafeJsonStorage<Sessions>;
18
+
19
+ constructor(storage: StorageLike) {
20
+ this.pluginStateData = new SafeJsonStorage(storage, BROWSER_STORAGE_KEYS.pluginState, {}, (value): value is PluginState => isRecord(value));
21
+ this.sessionData = new SafeJsonStorage(storage, BROWSER_STORAGE_KEYS.session, {}, (value): value is Sessions => isRecord(value));
22
+ }
23
+
24
+ readonly pluginState = {
25
+ get: <T>(pluginId: string, key: string, schemaVersion = 1): PluginStateRecord<T> | null => {
26
+ const record = this.pluginStateData.get()[pluginId]?.[key];
27
+ if (!isRecord(record) || record.schemaVersion !== schemaVersion || !("value" in record)) return null;
28
+ return record as unknown as PluginStateRecord<T>;
29
+ },
30
+ set: (pluginId: string, key: string, value: unknown, schemaVersion = 1): void => {
31
+ const state = this.pluginStateData.get();
32
+ this.pluginStateData.set({
33
+ ...state,
34
+ [pluginId]: {
35
+ ...state[pluginId],
36
+ [key]: { value, schemaVersion, updatedAt: Date.now() },
37
+ },
38
+ });
39
+ },
40
+ delete: (pluginId: string, key: string): void => {
41
+ const state = this.pluginStateData.get();
42
+ const plugin = { ...state[pluginId] };
43
+ delete plugin[key];
44
+ this.pluginStateData.set({ ...state, [pluginId]: plugin });
45
+ },
46
+ keys: (pluginId: string): string[] => Object.keys(this.pluginStateData.get()[pluginId] ?? {}),
47
+ clear: (pluginId: string): void => {
48
+ const state = { ...this.pluginStateData.get() };
49
+ delete state[pluginId];
50
+ this.pluginStateData.set(state);
51
+ },
52
+ };
53
+
54
+ readonly sessions = {
55
+ get: <T>(sessionId = "app", schemaVersion = 1): SessionSnapshotRecord<T> | null => {
56
+ const record = this.sessionData.get()[sessionId];
57
+ if (!isRecord(record) || record.schemaVersion !== schemaVersion || !("value" in record)) return null;
58
+ return record as unknown as SessionSnapshotRecord<T>;
59
+ },
60
+ set: (sessionId: string, value: unknown, schemaVersion = 1): void => {
61
+ this.sessionData.set({
62
+ ...this.sessionData.get(),
63
+ [sessionId]: { sessionId, value, schemaVersion, updatedAt: Date.now() },
64
+ });
65
+ },
66
+ delete: (sessionId: string): void => {
67
+ const state = { ...this.sessionData.get() };
68
+ delete state[sessionId];
69
+ this.sessionData.set(state);
70
+ },
71
+ };
72
+
73
+ close(): void {}
74
+ }
@@ -0,0 +1,66 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createDefaultConfig } from "../../types/config";
3
+ import { createBrowserConfigStore, BROWSER_DATA_DIR } from "./config-host";
4
+ import { BrowserPersistence } from "./persistence";
5
+ import { BROWSER_STORAGE_KEYS, SafeJsonStorage, type StorageLike } from "./storage";
6
+ import { BrowserTickerRepository } from "./ticker-repository";
7
+
8
+ class MemoryStorage implements StorageLike {
9
+ readonly values = new Map<string, string>();
10
+ failWrites = false;
11
+ getItem(key: string) { return this.values.get(key) ?? null; }
12
+ setItem(key: string, value: string) {
13
+ if (this.failWrites) throw new DOMException("full", "QuotaExceededError");
14
+ this.values.set(key, value);
15
+ }
16
+ removeItem(key: string) { this.values.delete(key); }
17
+ }
18
+
19
+ describe("browser local persistence", () => {
20
+ test("falls back safely for malformed JSON and quota failures", () => {
21
+ const storage = new MemoryStorage();
22
+ storage.values.set("key", "{");
23
+ const data = new SafeJsonStorage(storage, "key", { count: 0 });
24
+ expect(data.get()).toEqual({ count: 0 });
25
+ storage.values.set("wrong-shape", "[]");
26
+ const shaped = new SafeJsonStorage(storage, "wrong-shape", { ok: true }, (value): value is { ok: boolean } => !!value && typeof value === "object" && !Array.isArray(value));
27
+ expect(shaped.get()).toEqual({ ok: true });
28
+ storage.failWrites = true;
29
+ data.set({ count: 2 });
30
+ expect(data.get()).toEqual({ count: 2 });
31
+ });
32
+
33
+ test("normalizes config and persists tickers, plugin state, and session state", async () => {
34
+ const storage = new MemoryStorage();
35
+ storage.values.set(BROWSER_STORAGE_KEYS.config, JSON.stringify({ theme: 42 }));
36
+ const configStore = createBrowserConfigStore(storage);
37
+ const config = await configStore.loadConfig(BROWSER_DATA_DIR);
38
+ expect(config.theme).toBe(createDefaultConfig(BROWSER_DATA_DIR).theme);
39
+ expect(config.onboardingComplete).toBe(true);
40
+ config.baseCurrency = "EUR";
41
+ await configStore.saveConfig(config);
42
+ expect((await configStore.loadConfig(BROWSER_DATA_DIR)).baseCurrency).toBe("EUR");
43
+
44
+ const tickers = new BrowserTickerRepository(storage);
45
+ await tickers.createTicker({
46
+ ticker: "AAPL",
47
+ exchange: "NASDAQ",
48
+ currency: "USD",
49
+ name: "Apple Inc.",
50
+ portfolios: [],
51
+ watchlists: [],
52
+ positions: [],
53
+ custom: {},
54
+ tags: [],
55
+ });
56
+ expect((await new BrowserTickerRepository(storage).loadTicker("aapl"))?.metadata.ticker).toBe("AAPL");
57
+
58
+ const persistence = new BrowserPersistence(storage);
59
+ persistence.pluginState.set("alerts", "draft", { enabled: true }, 2);
60
+ persistence.sessions.set("app", { focusedPaneId: "portfolio-list:main" }, 1);
61
+ const restored = new BrowserPersistence(storage);
62
+ expect(restored.pluginState.get("alerts", "draft", 2)?.value).toEqual({ enabled: true });
63
+ expect(restored.pluginState.get("alerts", "draft", 1)).toBeNull();
64
+ expect(restored.sessions.get("app", 1)?.value).toEqual({ focusedPaneId: "portfolio-list:main" });
65
+ });
66
+ });
@@ -0,0 +1,50 @@
1
+ export interface StorageLike {
2
+ getItem(key: string): string | null;
3
+ setItem(key: string, value: string): void;
4
+ removeItem(key: string): void;
5
+ }
6
+
7
+ /** localStorage wrapper that keeps the app usable when JSON or quota is bad. */
8
+ export class SafeJsonStorage<T> {
9
+ private value: T;
10
+
11
+ constructor(
12
+ private readonly storage: StorageLike,
13
+ readonly key: string,
14
+ fallback: T,
15
+ validate: (value: unknown) => value is T = (_value): _value is T => true,
16
+ ) {
17
+ this.value = fallback;
18
+ try {
19
+ const raw = storage.getItem(key);
20
+ if (raw !== null) {
21
+ const parsed: unknown = JSON.parse(raw);
22
+ if (!validate(parsed)) throw new Error("Invalid stored value");
23
+ this.value = parsed;
24
+ }
25
+ } catch {
26
+ try { storage.removeItem(key); } catch {}
27
+ }
28
+ }
29
+
30
+ get(): T {
31
+ return this.value;
32
+ }
33
+
34
+ set(value: T): void {
35
+ this.value = value;
36
+ try { this.storage.setItem(this.key, JSON.stringify(value)); } catch {}
37
+ }
38
+
39
+ clear(fallback: T): void {
40
+ this.value = fallback;
41
+ try { this.storage.removeItem(this.key); } catch {}
42
+ }
43
+ }
44
+
45
+ export const BROWSER_STORAGE_KEYS = {
46
+ config: "gloomberb.web.config.v1",
47
+ tickers: "gloomberb.web.tickers.v1",
48
+ pluginState: "gloomberb.web.plugin-state.v1",
49
+ session: "gloomberb.web.session.v1",
50
+ } as const;
@@ -0,0 +1,59 @@
1
+ import type { AppTickerRepositoryPort } from "../../core/app-service-ports";
2
+ import { hydrateTickerMetadata } from "../../tickers/metadata";
3
+ import type { TickerMetadata, TickerRecord } from "../../types/ticker";
4
+ import { BROWSER_STORAGE_KEYS, SafeJsonStorage, type StorageLike } from "./storage";
5
+
6
+ type StoredTickers = Record<string, TickerMetadata>;
7
+
8
+ function isRecord(value: unknown): value is Record<string, unknown> {
9
+ return !!value && typeof value === "object" && !Array.isArray(value);
10
+ }
11
+
12
+ function normalizeSymbol(symbol: string): string {
13
+ return symbol.trim().toUpperCase();
14
+ }
15
+
16
+ function hydrate(metadata: TickerMetadata): TickerRecord | null {
17
+ try {
18
+ return { metadata: hydrateTickerMetadata(metadata as unknown as Record<string, unknown>) };
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ export class BrowserTickerRepository implements AppTickerRepositoryPort {
25
+ private readonly data: SafeJsonStorage<StoredTickers>;
26
+
27
+ constructor(storage: StorageLike) {
28
+ this.data = new SafeJsonStorage(storage, BROWSER_STORAGE_KEYS.tickers, {}, (value): value is StoredTickers => isRecord(value));
29
+ }
30
+
31
+ async loadAllTickers(): Promise<TickerRecord[]> {
32
+ return Object.values(this.data.get()).flatMap((metadata) => {
33
+ const ticker = hydrate(metadata);
34
+ return ticker ? [ticker] : [];
35
+ });
36
+ }
37
+
38
+ async loadTicker(symbol: string): Promise<TickerRecord | null> {
39
+ const metadata = this.data.get()[normalizeSymbol(symbol)];
40
+ return metadata ? hydrate(metadata) : null;
41
+ }
42
+
43
+ async saveTicker(ticker: TickerRecord): Promise<void> {
44
+ const symbol = normalizeSymbol(ticker.metadata.ticker);
45
+ this.data.set({ ...this.data.get(), [symbol]: ticker.metadata });
46
+ }
47
+
48
+ async createTicker(metadata: TickerMetadata): Promise<TickerRecord> {
49
+ const ticker = { metadata };
50
+ await this.saveTicker(ticker);
51
+ return ticker;
52
+ }
53
+
54
+ async deleteTicker(symbol: string): Promise<void> {
55
+ const tickers = { ...this.data.get() };
56
+ delete tickers[normalizeSymbol(symbol)];
57
+ this.data.set(tickers);
58
+ }
59
+ }
@@ -0,0 +1,40 @@
1
+ /** @jsxImportSource react */
2
+ import type { RendererHost, UiHost } from "../../ui/host";
3
+ import { safeExternalUrl } from "../../utils/external-url";
4
+ import { createDomUiHost } from "../electrobun/view/dom-ui-host";
5
+
6
+ export const browserUiHost: UiHost = createDomUiHost("browser", {
7
+ nativePaneChrome: true,
8
+ titleBarOverlay: true,
9
+ nativeWindowChrome: false,
10
+ nativeContextMenu: false,
11
+ publicSharing: true,
12
+ });
13
+
14
+ export const browserRendererHost: RendererHost = {
15
+ supportsNativeDesktopNotifications: false,
16
+ requestExit() {},
17
+ notify() {},
18
+ async openExternal(rawUrl) {
19
+ const url = safeExternalUrl(rawUrl);
20
+ if (!url) return;
21
+ const opened = window.open(url, "_blank", "noopener,noreferrer");
22
+ if (opened) opened.opener = null;
23
+ },
24
+ async copyText(text) {
25
+ await navigator.clipboard.writeText(text);
26
+ },
27
+ async readText() {
28
+ return navigator.clipboard.readText();
29
+ },
30
+ async copyPngImage(pngBase64) {
31
+ const ClipboardItemCtor = globalThis.ClipboardItem;
32
+ if (!ClipboardItemCtor || !navigator.clipboard.write) {
33
+ throw new Error("Image clipboard is unavailable in this browser.");
34
+ }
35
+ const bytes = Uint8Array.from(atob(pngBase64), (char) => char.charCodeAt(0));
36
+ await navigator.clipboard.write([
37
+ new ClipboardItemCtor({ "image/png": new Blob([bytes], { type: "image/png" }) }),
38
+ ]);
39
+ },
40
+ };
@@ -0,0 +1,136 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { handleRequest, SECURITY_HEADERS, type WorkerEnv } from "./worker";
3
+
4
+ function fixture() {
5
+ const requests: Request[] = [];
6
+ const env: WorkerEnv = {
7
+ ASSETS: {
8
+ async fetch(request) {
9
+ requests.push(request);
10
+ return new Response("<!doctype html>", { headers: { "content-type": "text/html" } });
11
+ },
12
+ },
13
+ };
14
+ return { env, requests };
15
+ }
16
+
17
+ describe("static Cloudflare host", () => {
18
+ test("reports health without invoking static assets", async () => {
19
+ const { env, requests } = fixture();
20
+ const response = await handleRequest(new Request("https://term.example/health"), env);
21
+ expect(response.status).toBe(200);
22
+ expect(await response.json()).toEqual({ status: "ok" });
23
+ expect(requests).toHaveLength(0);
24
+ });
25
+
26
+ test("serves app assets with an enforced CSP and security headers", async () => {
27
+ const { env } = fixture();
28
+ const response = await handleRequest(new Request("https://term.example/"), env);
29
+ expect(response.status).toBe(200);
30
+ expect(response.headers.get("content-security-policy")).toBe(SECURITY_HEADERS["content-security-policy"]);
31
+ expect(response.headers.has("content-security-policy-report-only")).toBe(false);
32
+ expect(response.headers.get("x-frame-options")).toBe("DENY");
33
+ expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin");
34
+ expect(response.headers.get("cache-control")).toBe("no-store");
35
+ });
36
+
37
+ test("rewrites valid pane and layout share paths to the slim document", async () => {
38
+ const id = "0123456789abcdef0123456789abcdef";
39
+ for (const prefix of ["s", "l"]) {
40
+ const { env, requests } = fixture();
41
+ const response = await handleRequest(new Request(`https://term.example/${prefix}/${id}`), env);
42
+ expect(new URL(requests[0]!.url).pathname).toBe("/share.html");
43
+ expect(response.headers.get("x-robots-tag")).toBe("noindex, nofollow, noarchive");
44
+ }
45
+ });
46
+
47
+ test("serves the Apple app site association without touching static assets", async () => {
48
+ const { env, requests } = fixture();
49
+ const response = await handleRequest(
50
+ new Request("https://term.example/.well-known/apple-app-site-association"),
51
+ env,
52
+ );
53
+
54
+ // Apple's fetcher accepts none of: a redirect, an HTML body, a 404. Any of
55
+ // those disables universal links silently, so pin the exact contract.
56
+ expect(response.status).toBe(200);
57
+ expect(response.headers.get("content-type")).toContain("application/json");
58
+ expect(requests).toHaveLength(0);
59
+
60
+ const body = (await response.json()) as {
61
+ applinks: { details: { appIDs: string[]; components: { "/": string }[] }[] };
62
+ };
63
+ const detail = body.applinks.details[0]!;
64
+ expect(detail.appIDs).toEqual(["3XQML3UV65.sh.gloom.companion"]);
65
+ // Only share links are claimed; everything else must stay in the browser.
66
+ expect(detail.components[0]!["/"]).toBe("/s/*");
67
+ });
68
+
69
+ test("claims exactly the share paths the worker itself rewrites", async () => {
70
+ const { env } = fixture();
71
+ const response = await handleRequest(
72
+ new Request("https://term.example/.well-known/apple-app-site-association"),
73
+ env,
74
+ );
75
+ const body = (await response.json()) as {
76
+ applinks: { details: { components: { "/": string }[] }[] };
77
+ };
78
+ const claimed = body.applinks.details[0]!.components[0]!["/"];
79
+
80
+ // A real share id must match the pattern advertised to Apple, otherwise the
81
+ // app is claiming links the site does not serve, or missing ones it does.
82
+ const shareId = "0123456789abcdef0123456789abcdef";
83
+ const pattern = new RegExp(`^${claimed.replace("*", ".*")}$`);
84
+ expect(pattern.test(`/s/${shareId}`)).toBe(true);
85
+ expect(pattern.test("/settings")).toBe(false);
86
+ });
87
+
88
+ test("proxies only the same-origin API path to api.gloom.sh", async () => {
89
+ const { env, requests } = fixture();
90
+ let upstream: Request | undefined;
91
+ const response = await handleRequest(new Request("https://term.example/api/chat/channels/everyone/messages?limit=1", {
92
+ method: "POST",
93
+ headers: {
94
+ Cookie: "session=browser-cookie",
95
+ Origin: "https://term.example",
96
+ "Content-Type": "application/json",
97
+ },
98
+ body: JSON.stringify({ content: "hello" }),
99
+ }), env, async (request) => {
100
+ upstream = request;
101
+ return Response.json({ ok: true });
102
+ });
103
+
104
+ expect(response.status).toBe(200);
105
+ expect(requests).toHaveLength(0);
106
+ expect(upstream?.url).toBe("https://api.gloom.sh/chat/channels/everyone/messages?limit=1");
107
+ expect(upstream?.method).toBe("POST");
108
+ expect(upstream?.headers.get("cookie")).toBe("session=browser-cookie");
109
+ expect(upstream?.headers.get("origin")).toBe("https://term.example");
110
+ expect(await upstream?.text()).toBe('{"content":"hello"}');
111
+ });
112
+
113
+ test("rejects cross-origin API requests before proxying", async () => {
114
+ const { env } = fixture();
115
+ let proxied = false;
116
+ const response = await handleRequest(new Request("https://term.example/api/auth/sign-out", {
117
+ method: "POST",
118
+ headers: { Origin: "https://attacker.example" },
119
+ }), env, async () => {
120
+ proxied = true;
121
+ return new Response();
122
+ });
123
+ expect(response.status).toBe(403);
124
+ expect(proxied).toBe(false);
125
+ });
126
+
127
+ test("rejects every non-API mutation without invoking assets or outbound fetch", async () => {
128
+ const { env, requests } = fixture();
129
+ const response = await handleRequest(new Request("https://term.example/shares", {
130
+ method: "POST",
131
+ headers: { Origin: "https://term.example" },
132
+ }), env);
133
+ expect(response.status).toBe(405);
134
+ expect(requests).toHaveLength(0);
135
+ });
136
+ });