gloomberb 0.19.0 → 0.19.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 (304) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/package.json +2 -1
  4. package/patches/electrobun@1.18.1.patch +23 -1
  5. package/src/api-client/app-attention.ts +3 -0
  6. package/src/api-client/beneficial-owners.ts +3 -0
  7. package/src/api-client/company-kpis.ts +3 -0
  8. package/src/api-client/data.ts +2 -1
  9. package/src/api-client/errors.ts +38 -0
  10. package/src/api-client/hiring.ts +3 -0
  11. package/src/api-client/index.ts +4 -0
  12. package/src/api-client/ipo.ts +24 -1
  13. package/src/api-client/mna.ts +3 -0
  14. package/src/api-client/paths.ts +9 -1
  15. package/src/api-client/roadmap.ts +85 -0
  16. package/src/api-client/types.ts +12 -0
  17. package/src/app/runtime/startup.ts +3 -1
  18. package/src/architecture/core-builtin-allowlist.json +2 -2
  19. package/src/capabilities/chart-series.ts +2 -1
  20. package/src/cli/commands/market.ts +44 -16
  21. package/src/cli/commands/news-search.ts +22 -0
  22. package/src/cli/commands/overview.ts +84 -19
  23. package/src/cli/commands/search.ts +13 -13
  24. package/src/cli/commands/ticker.ts +32 -2
  25. package/src/cli/context.ts +1 -0
  26. package/src/cli/errors.ts +17 -2
  27. package/src/cli/glossary.ts +8 -1
  28. package/src/cli/history-rows.ts +35 -7
  29. package/src/cli/index-roots.ts +0 -7
  30. package/src/cli/known-gaps.ts +11 -0
  31. package/src/cli/listing-arg.ts +74 -11
  32. package/src/cli/pane-functions/capabilities.ts +1 -0
  33. package/src/cli/pane-functions/discovery.ts +21 -1
  34. package/src/cli/pane-functions/freshness.ts +7 -1
  35. package/src/cli/pane-functions/function-help.ts +7 -0
  36. package/src/cli/pane-functions/index.ts +6 -3
  37. package/src/cli/quote-answer.ts +30 -7
  38. package/src/cli/saved-copy-persistence.ts +88 -0
  39. package/src/components/command-bar/list/model.ts +5 -0
  40. package/src/components/command-bar/routes/root/runtime.ts +29 -8
  41. package/src/components/command-bar/selection-runtime.ts +64 -32
  42. package/src/components/command-bar/surface/index.tsx +2 -0
  43. package/src/components/layout/detached-pane-shell.tsx +2 -2
  44. package/src/components/layout/header.tsx +2 -2
  45. package/src/components/layout/pane/capture-stamp.ts +2 -2
  46. package/src/components/layout/pane/footer/index.tsx +23 -6
  47. package/src/components/layout/pane/footer/model.ts +116 -16
  48. package/src/components/layout/pane/header.tsx +2 -2
  49. package/src/components/layout/pane/status-footer.ts +37 -3
  50. package/src/components/layout/status-widget-space.ts +9 -0
  51. package/src/components/onboarding/onboarding-frame.tsx +2 -1
  52. package/src/components/onboarding/onboarding-wizard.tsx +6 -3
  53. package/src/components/ticker/list-table-view.tsx +4 -20
  54. package/src/components/ui/display.tsx +29 -3
  55. package/src/components/ui/icon.tsx +12 -3
  56. package/src/core/app-services.ts +11 -2
  57. package/src/data/app-persistence.ts +9 -7
  58. package/src/data/plugin-cache.ts +20 -3
  59. package/src/data/plugin-state-store.ts +81 -24
  60. package/src/data/resource-store.ts +130 -58
  61. package/src/data/saved-copies.ts +8 -0
  62. package/src/data/session-store.ts +31 -8
  63. package/src/data/sqlite/access.ts +313 -0
  64. package/src/data/sqlite/checkpointer.ts +198 -0
  65. package/src/data/sqlite/database.ts +47 -4
  66. package/src/data/sqlite/retry.ts +50 -9
  67. package/src/data/ticker-store.ts +57 -23
  68. package/src/docs/snapshot.json +32 -5
  69. package/src/i18n/es.ts +2 -4
  70. package/src/i18n/ja.ts +2 -4
  71. package/src/i18n/ko.ts +2 -4
  72. package/src/i18n/zh-cn.ts +2 -4
  73. package/src/i18n/zh-tw.ts +2 -4
  74. package/src/market-data/depositary-pair.ts +25 -1
  75. package/src/market-data/quotes/metadata.ts +7 -0
  76. package/src/market-data/realized-volatility.ts +4 -2
  77. package/src/news/aggregator.ts +7 -1
  78. package/src/news/types.ts +6 -0
  79. package/src/plugins/auto-update-node.ts +10 -6
  80. package/src/plugins/builtin/alerts/calendar-events.ts +10 -4
  81. package/src/plugins/builtin/alerts/cli.ts +34 -11
  82. package/src/plugins/builtin/alerts/event-rows.ts +24 -3
  83. package/src/plugins/builtin/alerts/events-pane.tsx +15 -6
  84. package/src/plugins/builtin/alerts/headless.ts +10 -6
  85. package/src/plugins/builtin/cds/client.ts +142 -30
  86. package/src/plugins/builtin/cds/headless.ts +35 -8
  87. package/src/plugins/builtin/cds/model.ts +145 -18
  88. package/src/plugins/builtin/cds/pane.tsx +16 -5
  89. package/src/plugins/builtin/central-bank-rates/series.ts +2 -3
  90. package/src/plugins/builtin/chart-composer/cli-options.ts +16 -0
  91. package/src/plugins/builtin/chart-composer/follow-binding.ts +14 -2
  92. package/src/plugins/builtin/chart-composer/headless-schema.ts +5 -0
  93. package/src/plugins/builtin/chart-composer/index.tsx +4 -5
  94. package/src/plugins/builtin/chart-composer/pane.tsx +5 -1
  95. package/src/plugins/builtin/chart-composer/session-range.ts +5 -2
  96. package/src/plugins/builtin/chat/controller/index.ts +5 -1
  97. package/src/plugins/builtin/chat/controller/notifications.ts +33 -3
  98. package/src/plugins/builtin/chat/controller/state.ts +25 -2
  99. package/src/plugins/builtin/chat/controller/unread-people.ts +89 -23
  100. package/src/plugins/builtin/chat/controller/utils.ts +7 -2
  101. package/src/plugins/builtin/chat/status-preview.ts +79 -0
  102. package/src/plugins/builtin/chat/status-widget.tsx +147 -10
  103. package/src/plugins/builtin/chat/unread-badge.ts +51 -10
  104. package/src/plugins/builtin/clinical-trials/client.ts +49 -4
  105. package/src/plugins/builtin/clinical-trials/model.ts +86 -22
  106. package/src/plugins/builtin/clinical-trials/pane.tsx +46 -27
  107. package/src/plugins/builtin/clinical-trials/sponsor-listing.ts +90 -0
  108. package/src/plugins/builtin/clinical-trials/sponsor-tickers.ts +87 -9
  109. package/src/plugins/builtin/clinical-trials/trial-pages.ts +50 -0
  110. package/src/plugins/builtin/commodity-balances/client.ts +4 -1
  111. package/src/plugins/builtin/company-attention/headless.ts +2 -1
  112. package/src/plugins/builtin/company-attention/pane.tsx +3 -0
  113. package/src/plugins/builtin/company-kpis/headless.ts +8 -3
  114. package/src/plugins/builtin/company-kpis/model.ts +73 -5
  115. package/src/plugins/builtin/company-kpis/pane.tsx +15 -7
  116. package/src/plugins/builtin/composite-plugins.ts +2 -1
  117. package/src/plugins/builtin/cot/client.ts +28 -2
  118. package/src/plugins/builtin/cot/headless.ts +4 -1
  119. package/src/plugins/builtin/cot/index.tsx +4 -2
  120. package/src/plugins/builtin/cot/model.ts +58 -7
  121. package/src/plugins/builtin/cot/pane.tsx +6 -2
  122. package/src/plugins/builtin/cpi/client.ts +6 -5
  123. package/src/plugins/builtin/credit-boards/series.ts +3 -4
  124. package/src/plugins/builtin/credit-conditions/client.ts +22 -14
  125. package/src/plugins/builtin/credit-documents/headless.ts +3 -3
  126. package/src/plugins/builtin/crypto-board/client.ts +5 -5
  127. package/src/plugins/builtin/crypto-board/series.ts +4 -4
  128. package/src/plugins/builtin/debt-maturities/series.ts +3 -3
  129. package/src/plugins/builtin/doe/client.ts +6 -5
  130. package/src/plugins/builtin/doe/headless.ts +3 -2
  131. package/src/plugins/builtin/doe/pane.tsx +13 -3
  132. package/src/plugins/builtin/doe/prices.ts +2 -3
  133. package/src/plugins/builtin/doe/series.ts +4 -4
  134. package/src/plugins/builtin/earnings-calls/calendar.tsx +10 -2
  135. package/src/plugins/builtin/earnings-calls/data.ts +37 -17
  136. package/src/plugins/builtin/earnings-calls/headless.ts +13 -3
  137. package/src/plugins/builtin/earnings-calls/pane.tsx +34 -14
  138. package/src/plugins/builtin/econ/calendar-model.ts +48 -1
  139. package/src/plugins/builtin/econ/calendar-source.ts +4 -2
  140. package/src/plugins/builtin/econ/detail-view.tsx +18 -4
  141. package/src/plugins/builtin/econ/figures.ts +48 -0
  142. package/src/plugins/builtin/econ/index.tsx +14 -5
  143. package/src/plugins/builtin/econ/print-watch.ts +3 -1
  144. package/src/plugins/builtin/econ-statistics/client.ts +11 -0
  145. package/src/plugins/builtin/econ-statistics/headless.ts +10 -2
  146. package/src/plugins/builtin/econ-statistics/pane.tsx +8 -4
  147. package/src/plugins/builtin/econ-statistics/stats.ts +3 -0
  148. package/src/plugins/builtin/executives/data.ts +6 -1
  149. package/src/plugins/builtin/executives/pane.tsx +9 -21
  150. package/src/plugins/builtin/fear-greed/cache.ts +4 -0
  151. package/src/plugins/builtin/fear-greed/series.ts +6 -4
  152. package/src/plugins/builtin/gpu/client.ts +4 -1
  153. package/src/plugins/builtin/gpu/series.ts +2 -3
  154. package/src/plugins/builtin/help/index.tsx +61 -59
  155. package/src/plugins/builtin/help/shortcut-table.tsx +5 -6
  156. package/src/plugins/builtin/help/table-cells.tsx +13 -2
  157. package/src/plugins/builtin/holders/beneficial-client.ts +5 -1
  158. package/src/plugins/builtin/holders/beneficial-view.tsx +4 -2
  159. package/src/plugins/builtin/holders/headless.ts +4 -2
  160. package/src/plugins/builtin/holders/pane.tsx +3 -0
  161. package/src/plugins/builtin/holders/report-rows.ts +4 -1
  162. package/src/plugins/builtin/insider/headless.ts +2 -2
  163. package/src/plugins/builtin/ipo-calendar/client.ts +18 -2
  164. package/src/plugins/builtin/ipo-calendar/headless.ts +75 -11
  165. package/src/plugins/builtin/ipo-calendar/listing-ipo.ts +160 -0
  166. package/src/plugins/builtin/ipo-calendar/model.ts +98 -2
  167. package/src/plugins/builtin/jodi/client.ts +4 -1
  168. package/src/plugins/builtin/macro-day/headless.ts +12 -5
  169. package/src/plugins/builtin/macro-day/index.tsx +1 -1
  170. package/src/plugins/builtin/macro-day/model.ts +39 -5
  171. package/src/plugins/builtin/macro-day/pane.tsx +27 -19
  172. package/src/plugins/builtin/market-movers/client.ts +3 -0
  173. package/src/plugins/builtin/market-movers/headless.ts +3 -1
  174. package/src/plugins/builtin/market-movers/index.tsx +10 -5
  175. package/src/plugins/builtin/market-movers/screener.ts +27 -6
  176. package/src/plugins/builtin/market-movers/session-body.tsx +2 -1
  177. package/src/plugins/builtin/mna/headless.ts +2 -0
  178. package/src/plugins/builtin/mna/pane.tsx +3 -1
  179. package/src/plugins/builtin/money-markets/series.ts +3 -5
  180. package/src/plugins/builtin/news/headless.ts +3 -1
  181. package/src/plugins/builtin/news/index.tsx +3 -0
  182. package/src/plugins/builtin/portfolio-list/pane/index.tsx +2 -6
  183. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +0 -14
  184. package/src/plugins/builtin/portfolio-list/quick-add/resolution.ts +2 -1
  185. package/src/plugins/builtin/portfolio-list/table.tsx +4 -6
  186. package/src/plugins/builtin/power/gas-client.ts +2 -1
  187. package/src/plugins/builtin/power/prices-client.ts +3 -2
  188. package/src/plugins/builtin/realized-vol/client.ts +13 -5
  189. package/src/plugins/builtin/realized-vol/evidence.ts +29 -4
  190. package/src/plugins/builtin/realized-vol/headless.ts +8 -4
  191. package/src/plugins/builtin/realized-vol/model.ts +37 -6
  192. package/src/plugins/builtin/realized-vol/pane.tsx +9 -7
  193. package/src/plugins/builtin/research/event-model.ts +30 -4
  194. package/src/plugins/builtin/research/index.tsx +7 -3
  195. package/src/plugins/builtin/research/relative-valuation-headless.ts +8 -25
  196. package/src/plugins/builtin/research/relative-valuation-pane.tsx +52 -15
  197. package/src/plugins/builtin/research/relative-valuation-peers.ts +217 -20
  198. package/src/plugins/builtin/research/relative-valuation-settings.ts +44 -0
  199. package/src/plugins/builtin/risk-factors/data.ts +12 -6
  200. package/src/plugins/builtin/risk-factors/headless.ts +5 -2
  201. package/src/plugins/builtin/risk-factors/index.tsx +3 -1
  202. package/src/plugins/builtin/risk-factors/pane.tsx +5 -8
  203. package/src/plugins/builtin/roadmap/index.tsx +28 -0
  204. package/src/plugins/builtin/roadmap/model.ts +67 -0
  205. package/src/plugins/builtin/roadmap/pane.tsx +244 -0
  206. package/src/plugins/builtin/scanner/live-headless.ts +21 -2
  207. package/src/plugins/builtin/scanner/lock-note.ts +26 -3
  208. package/src/plugins/builtin/sec/headless.ts +2 -1
  209. package/src/plugins/builtin/sec/index.tsx +3 -0
  210. package/src/plugins/builtin/shared/cloud-resource.ts +20 -7
  211. package/src/plugins/builtin/shared/fred-points.ts +6 -1
  212. package/src/plugins/builtin/shared/headless-market-data.ts +2 -2
  213. package/src/plugins/builtin/shared/pro-lock-note.ts +23 -8
  214. package/src/plugins/builtin/shared/report-freshness.ts +8 -0
  215. package/src/plugins/builtin/shared/research-cells.tsx +1 -1
  216. package/src/plugins/builtin/shared/series-cache.ts +4 -4
  217. package/src/plugins/builtin/shared/series-warning.ts +13 -4
  218. package/src/plugins/builtin/short-volume/series.ts +2 -3
  219. package/src/plugins/builtin/social-mentions/client.ts +15 -10
  220. package/src/plugins/builtin/social-mentions/series.ts +2 -3
  221. package/src/plugins/builtin/thirteenf/api.ts +15 -9
  222. package/src/plugins/builtin/thirteenf/data.ts +39 -11
  223. package/src/plugins/builtin/thirteenf/headless.ts +31 -4
  224. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +5 -1
  225. package/src/plugins/builtin/thirteenf/pane.tsx +21 -5
  226. package/src/plugins/builtin/thirteenf/types.ts +2 -0
  227. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -2
  228. package/src/plugins/builtin/ticker-detail/financials/model.ts +35 -2
  229. package/src/plugins/builtin/ticker-detail/financials/ratios.ts +53 -18
  230. package/src/plugins/builtin/ticker-detail/headless.ts +59 -8
  231. package/src/plugins/builtin/ticker-detail/overview/model.ts +10 -2
  232. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +2 -1
  233. package/src/plugins/builtin/ticker-detail/pane.tsx +11 -6
  234. package/src/plugins/builtin/treasury-auctions/cache.ts +20 -8
  235. package/src/plugins/builtin/treasury-auctions/client.ts +3 -3
  236. package/src/plugins/builtin/treasury-auctions/headless.ts +24 -13
  237. package/src/plugins/builtin/treasury-auctions/pane.tsx +18 -6
  238. package/src/plugins/builtin/volatility/client.ts +43 -16
  239. package/src/plugins/builtin/volatility/headless.ts +5 -2
  240. package/src/plugins/builtin/volatility/model.ts +17 -6
  241. package/src/plugins/builtin/volatility/pane.tsx +11 -4
  242. package/src/plugins/builtin/volatility/series.ts +2 -1
  243. package/src/plugins/builtin/yield-curve/client.ts +59 -1
  244. package/src/plugins/builtin/yield-curve/headless.ts +9 -6
  245. package/src/plugins/builtin/yield-curve/index.tsx +34 -10
  246. package/src/plugins/builtin/yield-curve/swaps.tsx +9 -5
  247. package/src/plugins/catalog-browser.ts +2 -1
  248. package/src/plugins/host-link.ts +1 -1
  249. package/src/plugins/installer.ts +50 -39
  250. package/src/portfolio/column-values.ts +11 -7
  251. package/src/renderers/dom/desktop/icons.tsx +10 -2
  252. package/src/renderers/electrobun/bun/desktop/backend.ts +9 -10
  253. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +23 -1
  254. package/src/renderers/electrobun/bun/desktop/host-requests.ts +8 -0
  255. package/src/renderers/electrobun/bun/desktop/initialization.ts +9 -1
  256. package/src/renderers/electrobun/bun/desktop/installer-folder.ts +731 -0
  257. package/src/renderers/electrobun/bun/desktop/relaunch.ts +102 -10
  258. package/src/renderers/electrobun/bun/desktop/windows-script.ts +92 -0
  259. package/src/renderers/electrobun/bun/index.ts +26 -2
  260. package/src/renderers/electrobun/bun/window/attention.ts +54 -0
  261. package/src/renderers/electrobun/bun/window/create.ts +45 -4
  262. package/src/renderers/electrobun/bun/window/events.ts +20 -0
  263. package/src/renderers/electrobun/bun/window/windows-custom-chrome.ts +9 -2
  264. package/src/renderers/electrobun/bun/window/windows-native.ts +78 -0
  265. package/src/renderers/electrobun/bun/window/windows-quit.ts +57 -0
  266. package/src/renderers/electrobun/launcher/preload.ts +13 -0
  267. package/src/renderers/electrobun/launcher/windows-emulation.ts +50 -0
  268. package/src/renderers/electrobun/shared/protocol.ts +2 -0
  269. package/src/renderers/electrobun/view/news-source.ts +2 -0
  270. package/src/renderers/electrobun/view/ui-host.tsx +16 -1
  271. package/src/series/resolve.ts +2 -1
  272. package/src/series/types.ts +5 -0
  273. package/src/sources/gloomberb-cloud/fred-series.ts +8 -4
  274. package/src/sources/gloomberb-cloud/index.ts +42 -8
  275. package/src/telemetry/stall-reports-node.ts +13 -2
  276. package/src/telemetry/stall-reports.ts +189 -13
  277. package/src/test-support/outage.ts +8 -0
  278. package/src/tickers/company-name-listing.ts +110 -40
  279. package/src/tickers/company-name.ts +71 -2
  280. package/src/tickers/index-roots.ts +7 -0
  281. package/src/tickers/listing-fate.ts +2 -2
  282. package/src/tickers/search/home-listing.ts +93 -8
  283. package/src/tickers/search/index.ts +7 -3
  284. package/src/tickers/search/ranking.ts +5 -1
  285. package/src/tickers/search/upsert.ts +13 -0
  286. package/src/tickers/ticker-changes.ts +8 -4
  287. package/src/time-series/market.ts +5 -3
  288. package/src/time-series/price-comparison.ts +20 -2
  289. package/src/time-series/resolve.ts +20 -1
  290. package/src/time-series/types.ts +7 -0
  291. package/src/types/company-from.ts +40 -0
  292. package/src/types/data-provider.ts +7 -0
  293. package/src/types/financials.ts +45 -3
  294. package/src/types/headless.ts +6 -0
  295. package/src/ui/host.tsx +6 -1
  296. package/src/utils/crypto-pair.ts +121 -7
  297. package/src/utils/declared-dividends.ts +1 -1
  298. package/src/utils/financial-statements.ts +73 -16
  299. package/src/utils/fundamentals.ts +53 -10
  300. package/src/utils/lease-liabilities.ts +54 -0
  301. package/src/utils/main-thread-stall.ts +47 -2
  302. package/src/utils/perf-marks.ts +23 -11
  303. package/src/utils/price-history-integrity.ts +47 -1
  304. package/src/version.ts +2 -2
@@ -1,4 +1,7 @@
1
- import { dirname } from "path";
1
+ import { rm } from "fs/promises";
2
+ import { tmpdir } from "os";
3
+ import { dirname, win32 } from "path";
4
+ import { psString, runScriptAfterExit, writePowerShellScript } from "./windows-script";
2
5
 
3
6
  /**
4
7
  * The folders the relaunch script finds `sleep` in, after whatever PATH the
@@ -29,6 +32,8 @@ export interface RelaunchContext {
29
32
  cwd: string;
30
33
  /** PATH as the app sees it. */
31
34
  pathEnv: string;
35
+ /** Where the app keeps its data when GLOOMBERB_HOME moves it. */
36
+ home?: string;
32
37
  }
33
38
 
34
39
  interface RelaunchCommand {
@@ -47,15 +52,16 @@ function currentProcess(): RelaunchContext {
47
52
  args: process.argv.slice(1),
48
53
  cwd: process.cwd(),
49
54
  pathEnv: process.env.PATH ?? "",
55
+ home: process.env.GLOOMBERB_HOME || undefined,
50
56
  };
51
57
  }
52
58
 
53
59
  /**
54
- * What opens the app again once `context.pid` has exited. The script runs in
55
- * `/` with the shell and `open` named by their full paths: the launcher starts
56
- * the app inside its bundle's `Contents/MacOS`, which a rename to the app's
57
- * new name takes away, and a spawn into a folder that is gone fails as if the
58
- * shell were missing (`ENOENT, posix_spawn 'sh'`).
60
+ * What opens the app again once `context.pid` has exited, on a Mac or Linux.
61
+ * The script runs in `/` with the shell and `open` named by their full paths:
62
+ * the launcher starts the app inside its bundle's `Contents/MacOS`, which a
63
+ * rename to the app's new name takes away, and a spawn into a folder that is
64
+ * gone fails as if the shell were missing (`ENOENT, posix_spawn 'sh'`).
59
65
  */
60
66
  export function desktopRelaunchCommand(context: RelaunchContext, bundle?: string): RelaunchCommand {
61
67
  const pathEnv = context.pathEnv ? `${context.pathEnv}:${SYSTEM_PATH}` : SYSTEM_PATH;
@@ -70,8 +76,6 @@ export function desktopRelaunchCommand(context: RelaunchContext, bundle?: string
70
76
  };
71
77
  }
72
78
 
73
- if (context.platform === "win32") return { cmd: [context.execPath, ...context.args] };
74
-
75
79
  const args = context.args.map(shellQuote).join(" ");
76
80
  return {
77
81
  cmd: ["/bin/sh", "-c", `${waitForExit}; cd ${shellQuote(context.cwd)} 2>/dev/null; ${shellQuote(context.execPath)}${args ? ` ${args}` : ""}`],
@@ -80,12 +84,100 @@ export function desktopRelaunchCommand(context: RelaunchContext, bundle?: string
80
84
  };
81
85
  }
82
86
 
87
+ export interface WindowsRelaunch {
88
+ /** The app's process, which the script waits out. */
89
+ pid: number;
90
+ /** `bin\launcher.exe` beside the runtime: what the Start menu opens. */
91
+ launcher: string;
92
+ log: string;
93
+ cancel: string;
94
+ taskName: string;
95
+ /** GLOOMBERB_HOME for the app it opens; a task has the user's environment, not the app's. */
96
+ home?: string;
97
+ }
98
+
99
+ /**
100
+ * The PowerShell 5.1 script that opens the Windows app again. It answers
101
+ * `started` in its log, waits for the app's process to be gone (Chromium's
102
+ * profile stays held until then, and an app opened before hands itself to the
103
+ * closing one and crashes), then opens the launcher. An app that stopped
104
+ * waiting for it writes `cancel` and stays open, so the script opens nothing.
105
+ */
106
+ export function windowsRelaunchScript({ pid, launcher, log, cancel, taskName, home }: WindowsRelaunch): string {
107
+ return [
108
+ "$ErrorActionPreference = 'Stop'",
109
+ `$OldPid = ${pid}`,
110
+ `$Launcher = ${psString(launcher)}`,
111
+ `$Log = ${psString(log)}`,
112
+ `$Cancel = ${psString(cancel)}`,
113
+ `$TaskName = ${psString(taskName)}`,
114
+ `$DataHome = ${psString(home ?? "")}`,
115
+ String.raw`
116
+ $LogEncoding = New-Object System.Text.UTF8Encoding $false
117
+ function Write-Log([string]$Message) {
118
+ for ($Attempt = 0; $Attempt -lt 10; $Attempt += 1) {
119
+ try { [IO.File]::AppendAllText($Log, (Get-Date).ToString('o') + ' ' + $Message + [Environment]::NewLine, $LogEncoding); return } catch { Start-Sleep -Milliseconds 100 }
120
+ }
121
+ }
122
+
123
+ try {
124
+ if (-not (Test-Path -LiteralPath $Launcher)) { Write-Log ('declined: no ' + $Launcher); return }
125
+ Write-Log ('started ' + $PID)
126
+ $Deadline = (Get-Date).AddMinutes(5)
127
+ while ((Get-Process -Id $OldPid -ErrorAction SilentlyContinue) -and -not (Test-Path -LiteralPath $Cancel) -and (Get-Date) -lt $Deadline) {
128
+ Start-Sleep -Milliseconds 250
129
+ }
130
+ if (Test-Path -LiteralPath $Cancel) { Write-Log 'the app stopped waiting; it stays open'; return }
131
+ if (Get-Process -Id $OldPid -ErrorAction SilentlyContinue) { Write-Log 'the app did not quit'; return }
132
+ Start-Sleep -Milliseconds 500
133
+ if ($DataHome) { $env:GLOOMBERB_HOME = $DataHome }
134
+ Start-Process -FilePath $Launcher -WorkingDirectory (Split-Path -Parent $Launcher)
135
+ Write-Log ('opened ' + $Launcher)
136
+ } catch {
137
+ Write-Log ('could not open the app: ' + $_.Exception.Message)
138
+ } finally {
139
+ $ErrorActionPreference = 'Continue'
140
+ schtasks.exe /delete /tn $TaskName /f | Out-Null
141
+ Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue
142
+ }
143
+ `,
144
+ ].join("\n");
145
+ }
146
+
147
+ /**
148
+ * Electrobun puts the Windows app in a job that ends every process it starts
149
+ * when it exits, so the script runs from a scheduled task, which outlives it.
150
+ * Resolves once the script has started: the app may quit then.
151
+ */
152
+ async function scheduleWindowsRelaunch(context: RelaunchContext): Promise<void> {
153
+ const name = `gloom-restart-${context.pid}`;
154
+ const script = win32.join(tmpdir(), `${name}.ps1`);
155
+ const log = win32.join(tmpdir(), `${name}.log`);
156
+ const cancel = win32.join(tmpdir(), `${name}.cancel`);
157
+ await rm(log, { force: true });
158
+ await rm(cancel, { force: true });
159
+ await writePowerShellScript(script, windowsRelaunchScript({
160
+ pid: context.pid,
161
+ launcher: win32.join(win32.dirname(context.execPath), "launcher.exe"),
162
+ log,
163
+ cancel,
164
+ taskName: `GloomRestart-${context.pid}`,
165
+ home: context.home,
166
+ }));
167
+ const answer = await runScriptAfterExit(script, log, cancel, `GloomRestart-${context.pid}`);
168
+ if (answer.kind !== "started") throw new Error(`the restart script did not start: ${answer.reason}`);
169
+ }
170
+
83
171
  /**
84
172
  * Opens the app again once this process has exited. On a Mac, `bundle` opens
85
173
  * that bundle instead of the one this process runs from: the app's own, under
86
- * the name it was just given.
174
+ * the name it was just given. Resolves once this process may exit.
87
175
  */
88
- export function scheduleDesktopRelaunch(bundle?: string, context: RelaunchContext = currentProcess()): void {
176
+ export async function scheduleDesktopRelaunch(bundle?: string, context: RelaunchContext = currentProcess()): Promise<void> {
177
+ if (context.platform === "win32") {
178
+ await scheduleWindowsRelaunch(context);
179
+ return;
180
+ }
89
181
  const { cmd, cwd, pathEnv } = desktopRelaunchCommand(context, bundle);
90
182
  const child = Bun.spawn(cmd, {
91
183
  ...(cwd ? { cwd } : {}),
@@ -0,0 +1,92 @@
1
+ import { readFile, writeFile } from "fs/promises";
2
+
3
+ /**
4
+ * PowerShell scripts the Windows app leaves to finish what it cannot do while
5
+ * it runs: its own files are locked until it quits.
6
+ */
7
+
8
+ /** How long a script has to answer before the app stops waiting and keeps running. */
9
+ const SCRIPT_START_TIMEOUT_MS = 20_000;
10
+
11
+ /**
12
+ * `value` as a single-quoted PowerShell string. PowerShell also takes the
13
+ * curly single quotes U+2018 to U+201B for quotes; doubling escapes each.
14
+ */
15
+ export function psString(value: string): string {
16
+ return `'${value.replace(/['\u2018-\u201B]/g, "$&$&")}'`;
17
+ }
18
+
19
+ /** PowerShell 5.1, which every supported Windows has, running `script` hidden and without the user's profile. */
20
+ function powershellCommand(script: string): string[] {
21
+ return ["powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File", script];
22
+ }
23
+
24
+ /**
25
+ * Writes `text` as a script for PowerShell 5.1: with Windows line ends, and
26
+ * with a byte order mark, without which it reads the file in the system code
27
+ * page and garbles a path with an accent in it.
28
+ */
29
+ export async function writePowerShellScript(path: string, text: string): Promise<void> {
30
+ await writeFile(path, `\uFEFF${text.replaceAll(/\r?\n/g, "\r\n")}`, "utf8");
31
+ }
32
+
33
+ /** What a script that runs after the app quits answered before the app quit for it. */
34
+ export type ScriptAnswer =
35
+ /** It waits for the app to quit. */
36
+ | { kind: "started" }
37
+ /** It cannot run now, for a while: something it needs is in use. */
38
+ | { kind: "busy"; reason: string }
39
+ /** It cannot run. */
40
+ | { kind: "declined"; reason: string };
41
+
42
+ /**
43
+ * The script's answer in its log, a line `<time> started …`,
44
+ * `<time> busy: …` or `<time> declined: …`; null before it wrote one.
45
+ */
46
+ export function scriptAnswer(log: string): ScriptAnswer | null {
47
+ for (const line of log.replace(/^\uFEFF/, "").split(/\r?\n/)) {
48
+ const match = /^\S+ (started|busy|declined)\b:? ?(.*)$/.exec(line);
49
+ if (!match) continue;
50
+ if (match[1] === "started") return { kind: "started" };
51
+ return { kind: match[1] === "busy" ? "busy" : "declined", reason: match[2]?.trim() ?? "" };
52
+ }
53
+ return null;
54
+ }
55
+
56
+ function schtasks(...args: string[]): void {
57
+ const result = Bun.spawnSync(["schtasks.exe", ...args], { stdio: ["ignore", "ignore", "pipe"], windowsHide: true });
58
+ if (!result.success) throw new Error(`schtasks ${args[0]}: ${result.stderr.toString().trim()}`);
59
+ }
60
+
61
+ /**
62
+ * Runs `script` from the scheduled task `taskName`, which outlives the app (a
63
+ * child of its process dies with it), and resolves to the answer it writes to
64
+ * `log`: only on `started` may the app quit for it. The script removes the
65
+ * task. When it does not answer in time, the app writes `cancel`, which keeps
66
+ * a script that starts late from doing anything, and this throws with the
67
+ * task gone, as it does when the task could not be made.
68
+ */
69
+ export async function runScriptAfterExit(script: string, log: string, cancel: string, taskName: string): Promise<ScriptAnswer> {
70
+ const command = powershellCommand(script).map((part) => (part === script ? `"${part}"` : part)).join(" ");
71
+ try {
72
+ schtasks("/create", "/tn", taskName, "/tr", command, "/sc", "once", "/st", "00:00", "/f");
73
+ schtasks("/run", "/tn", taskName);
74
+ const deadline = Date.now() + SCRIPT_START_TIMEOUT_MS;
75
+ while (Date.now() < deadline) {
76
+ const answer = scriptAnswer(await readFile(log, "utf8").catch(() => ""));
77
+ if (answer) return answer;
78
+ await Bun.sleep(250);
79
+ }
80
+ await writeFile(cancel, "").catch(() => {});
81
+ throw new Error("the script did not start");
82
+ } catch (error) {
83
+ try { schtasks("/delete", "/tn", taskName, "/f"); } catch { /* Intentional ignore: never made, or the script removed it. */ }
84
+ throw error;
85
+ }
86
+ }
87
+
88
+ /** Runs `script` in the background, as a child of the app; resolves to its exit code. */
89
+ export async function runScriptInBackground(script: string): Promise<number> {
90
+ const child = Bun.spawn(powershellCommand(script), { stdio: ["ignore", "ignore", "ignore"], windowsHide: true });
91
+ return await child.exited;
92
+ }
@@ -25,9 +25,11 @@ import {
25
25
  type RememberedDesktopWindow,
26
26
  } from "./window/remembered-frame";
27
27
  import { reapStaleTerminalMedia } from "../../opentui/terminal-media";
28
+ import { quitWindowsEventLoopOnItsThread } from "./window/windows-quit";
28
29
  import { installProcessCrashListeners } from "../../../telemetry/crash-reports-node";
29
30
  import { renameLegacyMacApp } from "./desktop/app-name";
30
31
  import { scheduleDesktopRelaunch } from "./desktop/relaunch";
32
+ import { settleInstallerFolder } from "./desktop/installer-folder";
31
33
 
32
34
  console.log = (...args) => console.error(...args);
33
35
  console.info = (...args) => console.error(...args);
@@ -45,6 +47,25 @@ setConfigStoreHost(nodeConfigStoreHost);
45
47
  // before the config, and its off switch, has been read.
46
48
  installProcessCrashListeners();
47
49
 
50
+ // An install the Windows installer made lives in the installer's folder,
51
+ // where its shortcuts, the gloom command and its uninstall entry lead.
52
+ // Electrobun's updater installs an update into a folder of its own and opens
53
+ // it from there: this build moves into the installer's folder and opens again
54
+ // from it before any window shows.
55
+ const installerFolderMove = await settleInstallerFolder({
56
+ platform: process.platform,
57
+ execPath: process.execPath,
58
+ homeDir: homedir(),
59
+ localAppData: process.env.LOCALAPPDATA,
60
+ }).catch((error: unknown) => {
61
+ console.error("[desktop] could not move the app into the installer's folder", error instanceof Error ? error.message : String(error));
62
+ return null;
63
+ });
64
+ if (installerFolderMove?.kind === "relaunching") {
65
+ Utils.quit();
66
+ await new Promise<never>(() => {});
67
+ }
68
+
48
69
  // An install from before the rename runs from Gloomberb.app, where its updater
49
70
  // put this build. It takes the new name and opens again from there before any
50
71
  // window shows. Anywhere else this returns without touching the disk.
@@ -58,7 +79,7 @@ const appRename = await renameLegacyMacApp({
58
79
  return null;
59
80
  });
60
81
  if (appRename?.kind === "renamed") {
61
- scheduleDesktopRelaunch(appRename.bundle);
82
+ await scheduleDesktopRelaunch(appRename.bundle);
62
83
  Utils.quit();
63
84
  // The process is exiting; nothing below may open a window meanwhile.
64
85
  await new Promise<never>(() => {});
@@ -171,12 +192,15 @@ const mainWindow = createAppWindow({
171
192
  preload: desktopBootFramePreload(readDesktopBootFrame(desktopBootFramePath())),
172
193
  });
173
194
  backend.mainWindow = mainWindow;
195
+ quitWindowsEventLoopOnItsThread(mainWindow);
174
196
  // The window is loading its view; the services it will ask for start now
175
197
  // rather than when it asks. Its `init` reports a failure, and retries.
176
198
  startDesktopBackend(backend).catch((error) => {
177
199
  console.error("[desktop] startup failed", error instanceof Error ? error.stack ?? error.message : String(error));
178
200
  });
179
- if (rememberedWindow?.fullscreen) {
201
+ // Windows has no way into fullscreen here, and builds whose frameless window
202
+ // Windows took for fullscreen saved every window as fullscreen.
203
+ if (rememberedWindow?.fullscreen && process.platform !== "win32") {
180
204
  try { mainWindow.setFullScreen(true); } catch { /* the framed window is still the one they sized */ }
181
205
  } else if (rememberedWindow?.maximized) {
182
206
  try { mainWindow.maximize(); } catch { /* the framed window is still the one they sized */ }
@@ -0,0 +1,54 @@
1
+ import { setStallAttentionProbe, type StallAttention } from "../../../../telemetry/stall-reports";
2
+
3
+ /** What this needs of a window: its id, its minimized state and its events. */
4
+ interface AttentionWindow {
5
+ id: number;
6
+ isMinimized(): boolean;
7
+ on?(name: string, handler: (event: unknown) => void): void;
8
+ }
9
+
10
+ const openWindows = new Map<number, AttentionWindow>();
11
+ let focusedWindowId: number | null = null;
12
+ let sawFocusChange = false;
13
+
14
+ /**
15
+ * Whether anyone was looking when the desktop app's Bun process froze. The
16
+ * system slows a hidden app's timers, which reads like a freeze, so a stall
17
+ * report from this process says whether one of the app's windows had the
18
+ * keyboard (the app was in front) and whether one was open and not
19
+ * minimized. Read only for a stall about to be reported.
20
+ */
21
+ function desktopAttention(): StallAttention {
22
+ let visible: boolean | null = false;
23
+ try {
24
+ for (const window of openWindows.values()) {
25
+ if (!window.isMinimized()) {
26
+ visible = true;
27
+ break;
28
+ }
29
+ }
30
+ } catch {
31
+ visible = null;
32
+ }
33
+ return { active: sawFocusChange ? focusedWindowId !== null : null, visible };
34
+ }
35
+
36
+ /** Follows a window the app opened until it closes; the first one installs the probe. */
37
+ export function trackWindowAttention(window: AttentionWindow): void {
38
+ if (openWindows.size === 0) setStallAttentionProbe(desktopAttention);
39
+ openWindows.set(window.id, window);
40
+ window.on?.("focus", () => {
41
+ focusedWindowId = window.id;
42
+ sawFocusChange = true;
43
+ });
44
+ window.on?.("blur", () => {
45
+ // Focus moving between two of the app's windows can report the new
46
+ // window's focus before the old one's blur.
47
+ if (focusedWindowId === window.id) focusedWindowId = null;
48
+ sawFocusChange = true;
49
+ });
50
+ window.on?.("close", () => {
51
+ openWindows.delete(window.id);
52
+ if (focusedWindowId === window.id) focusedWindowId = null;
53
+ });
54
+ }
@@ -1,5 +1,7 @@
1
1
  import { BrowserWindow } from "electrobun/bun";
2
+ import { trackWindowAttention } from "./attention";
2
3
  import {
4
+ applyNativeWindowFrame,
3
5
  applyWindowMoveEvent,
4
6
  applyWindowResizeEvent,
5
7
  updateWindowFrameCache,
@@ -9,6 +11,7 @@ import {
9
11
  import type { WindowFrame, WindowMinimumSize } from "./frame";
10
12
  import { applyWindowsCustomChrome } from "./windows-custom-chrome";
11
13
  import { applyWindowsWindowIcon } from "./windows-icons";
14
+ import { fitPageToClientArea } from "./windows-native";
12
15
 
13
16
  type DesktopTitleBarStyle = "default" | "hidden" | "hiddenInset";
14
17
  type DesktopWindowRenderer = "native" | "cef";
@@ -20,8 +23,13 @@ type DesktopWindowStyleMask = {
20
23
  FullSizeContentView?: boolean;
21
24
  };
22
25
 
26
+ /**
27
+ * No native title bar: the header is the title bar. On Windows "hidden" is a
28
+ * bare popup with no frame to resize by, which Windows also takes for
29
+ * fullscreen; "hiddenInset" keeps the sizing frame and cuts out the caption.
30
+ */
23
31
  function desktopTitleBarStyle(): DesktopTitleBarStyle {
24
- return process.platform === "darwin" ? "hiddenInset" : "hidden";
32
+ return process.platform === "linux" ? "hidden" : "hiddenInset";
25
33
  }
26
34
 
27
35
  function desktopWindowRenderer(): DesktopWindowRenderer {
@@ -43,6 +51,22 @@ function desktopWindowStyleMask(): DesktopWindowStyleMask {
43
51
  };
44
52
  }
45
53
 
54
+ const APP_VIEW_URL = "views://mainview/index.html";
55
+
56
+ /**
57
+ * Electrobun's Windows webview takes a page load that starts while Ctrl is
58
+ * down for a Ctrl+click on a link: it cancels the load and asks for a new
59
+ * window instead. A window opened from a Ctrl chord, as Ctrl+Shift+O pops a
60
+ * pane out, stayed blank. Its page is loaded again, which Electrobun lets
61
+ * through within half a second of the one it cancelled.
62
+ */
63
+ function loadAgainWhenTakenForCtrlClick(window: BrowserWindow): void {
64
+ (window.webview as { on: (name: string, handler: (event: unknown) => void) => void }).on("new-window-open", (event) => {
65
+ const detail = (event as { data?: { detail?: { url?: unknown } } }).data?.detail;
66
+ if (detail?.url === APP_VIEW_URL) window.webview.loadURL(APP_VIEW_URL);
67
+ });
68
+ }
69
+
46
70
  interface AppWindowOptions {
47
71
  title: string;
48
72
  /** Already normalized to at least `minSize`. */
@@ -63,7 +87,7 @@ export function createAppWindow({ title, frame, rpc, minSize, onFrameChange, pre
63
87
  const window = new BrowserWindow({
64
88
  title,
65
89
  frame,
66
- url: "views://mainview/index.html",
90
+ url: APP_VIEW_URL,
67
91
  ...(preload ? { preload } : {}),
68
92
  renderer: desktopWindowRenderer(),
69
93
  rpc,
@@ -74,13 +98,30 @@ export function createAppWindow({ title, frame, rpc, minSize, onFrameChange, pre
74
98
  });
75
99
  applyWindowsWindowIcon(title);
76
100
  applyWindowsCustomChrome(title);
101
+ if (process.platform === "win32") {
102
+ loadAgainWhenTakenForCtrlClick(window);
103
+ // The page exists once it has loaded; until then there is nothing to fit.
104
+ window.webview.on("dom-ready", () => {
105
+ fitPageToClientArea(window.ptr);
106
+ });
107
+ }
77
108
  updateWindowFrameCache(window, frame, minSize);
109
+ trackWindowAttention(window);
110
+ const readsNativeFrame = process.platform === "win32";
78
111
  (window as any).on?.("move", (event: WindowMoveEvent) => {
79
- applyWindowMoveEvent(window, event);
112
+ if (readsNativeFrame) {
113
+ if (!applyNativeWindowFrame(window, minSize)) return;
114
+ } else {
115
+ applyWindowMoveEvent(window, event);
116
+ }
80
117
  onFrameChange?.();
81
118
  });
82
119
  (window as any).on?.("resize", (event: WindowResizeEvent) => {
83
- applyWindowResizeEvent(window, event, minSize);
120
+ if (readsNativeFrame) {
121
+ if (!applyNativeWindowFrame(window, minSize)) return;
122
+ } else {
123
+ applyWindowResizeEvent(window, event, minSize);
124
+ }
84
125
  onFrameChange?.();
85
126
  });
86
127
  return window;
@@ -47,3 +47,23 @@ export function applyWindowResizeEvent(
47
47
  }
48
48
  return nextFrame;
49
49
  }
50
+
51
+ type FramedWindow = Pick<BrowserWindow, "frame" | "getFrame" | "isMinimized" | "setFrame">;
52
+
53
+ /**
54
+ * Electrobun's Windows events do not carry the window's frame: a resize
55
+ * reports the client area's size at 0,0, a move the client area's corner as
56
+ * unsigned 16-bit values, and both come while the window is minimized. There
57
+ * the frame is read from the window itself, and a minimized window keeps the
58
+ * frame it had.
59
+ */
60
+ export function applyNativeWindowFrame(window: FramedWindow, minimumSize?: WindowMinimumSize): WindowFrame | null {
61
+ if (window.isMinimized()) return null;
62
+ const read = window.getFrame();
63
+ if (!(read.width > 0 && read.height > 0)) return null;
64
+ const nextFrame = updateWindowFrameCache(window as BrowserWindow, read, minimumSize);
65
+ if (nextFrame && (nextFrame.width !== read.width || nextFrame.height !== read.height)) {
66
+ window.setFrame(nextFrame.x, nextFrame.y, nextFrame.width, nextFrame.height);
67
+ }
68
+ return nextFrame;
69
+ }
@@ -7,7 +7,14 @@ const WS_SYSMENU = 0x00080000n;
7
7
  const WS_THICKFRAME = 0x00040000n;
8
8
  const WS_MINIMIZEBOX = 0x00020000n;
9
9
  const WS_MAXIMIZEBOX = 0x00010000n;
10
- const WINDOWS_NATIVE_CONTROL_STYLE_MASK = WS_CAPTION | WS_SYSMENU | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX;
10
+ /**
11
+ * Electrobun's "hiddenInset" window is captioned and sizable, with the caption
12
+ * cut out of its frame: its edges and corners resize it, it maximizes to the
13
+ * work area and keeps its shadow, while the header draws the buttons. Windows
14
+ * still reads the box bits: they let a click on the taskbar button minimize
15
+ * the window, and Win+Up, snapping and the window menu maximize it.
16
+ */
17
+ const WINDOWS_FRAME_STYLE = WS_CAPTION | WS_SYSMENU | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX;
11
18
  const SWP_NOSIZE = 0x0001;
12
19
  const SWP_NOMOVE = 0x0002;
13
20
  const SWP_NOZORDER = 0x0004;
@@ -22,7 +29,7 @@ function styleValue(value: number | bigint): bigint {
22
29
 
23
30
  function applyCustomChromeToWindow(win32: Win32, windowHandle: Pointer): boolean {
24
31
  const currentStyle = styleValue(win32.symbols.GetWindowLongPtrW(windowHandle, GWL_STYLE));
25
- const nextStyle = currentStyle & ~WINDOWS_NATIVE_CONTROL_STYLE_MASK;
32
+ const nextStyle = currentStyle | WINDOWS_FRAME_STYLE;
26
33
  if (nextStyle !== currentStyle) {
27
34
  win32.symbols.SetWindowLongPtrW(windowHandle, GWL_STYLE, nextStyle);
28
35
  }
@@ -41,6 +41,22 @@ function loadWin32() {
41
41
  args: [FFIType.ptr, FFIType.ptr, FFIType.int, FFIType.int, FFIType.int, FFIType.int, FFIType.u32],
42
42
  returns: FFIType.bool,
43
43
  },
44
+ GetCursorPos: {
45
+ args: [FFIType.ptr],
46
+ returns: FFIType.bool,
47
+ },
48
+ GetClientRect: {
49
+ args: [FFIType.ptr, FFIType.ptr],
50
+ returns: FFIType.bool,
51
+ },
52
+ PostMessageW: {
53
+ args: [FFIType.ptr, FFIType.u32, FFIType.u64, FFIType.i64],
54
+ returns: FFIType.bool,
55
+ },
56
+ GetGUIThreadInfo: {
57
+ args: [FFIType.u32, FFIType.ptr],
58
+ returns: FFIType.bool,
59
+ },
44
60
  });
45
61
  }
46
62
 
@@ -56,6 +72,68 @@ function win32OrNull(): Win32 | null {
56
72
  return cachedWin32;
57
73
  }
58
74
 
75
+ const WM_SIZE = 0x0005;
76
+
77
+ /**
78
+ * Electrobun opens a window's page at the size of the whole window and fits it
79
+ * to the client area on each WM_SIZE. A framed window's client area is smaller
80
+ * than the window, so until the window first changed size its page ran past
81
+ * the right and bottom edges, the Close button with it. Sends the window the
82
+ * WM_SIZE that fits the page. False off Windows.
83
+ */
84
+ export function fitPageToClientArea(windowHandle: Pointer): boolean {
85
+ const win32 = win32OrNull();
86
+ if (!win32) return false;
87
+ const client = new Int32Array(4);
88
+ if (!win32.symbols.GetClientRect(windowHandle, ptr(client))) return false;
89
+ const size = (((client[3]! - client[1]!) & 0xffff) << 16) | ((client[2]! - client[0]!) & 0xffff);
90
+ return win32.symbols.PostMessageW(windowHandle, WM_SIZE, 0n, BigInt(size >>> 0));
91
+ }
92
+
93
+ const WM_NCLBUTTONDOWN = 0x00a1;
94
+ const WM_CANCELMODE = 0x001f;
95
+ const HTCAPTION = 2;
96
+ /** GUITHREADINFO on 64-bit Windows: two DWORDs, six window handles, a RECT. */
97
+ const GUI_THREAD_INFO_SIZE = 72;
98
+ const GUI_THREAD_INFO_CAPTURE_OFFSET = 24;
99
+
100
+ /**
101
+ * The window holding the mouse on `windowHandle`'s thread, if any. Chromium
102
+ * takes the mouse on every press, and Windows will not start a move while
103
+ * another window holds it.
104
+ */
105
+ function mouseCaptureOnThreadOf(win32: Win32, windowHandle: Pointer): Pointer | null {
106
+ const thread = win32.symbols.GetWindowThreadProcessId(windowHandle, null);
107
+ if (!thread) return null;
108
+ const info = new DataView(new ArrayBuffer(GUI_THREAD_INFO_SIZE));
109
+ info.setUint32(0, GUI_THREAD_INFO_SIZE, true);
110
+ if (!win32.symbols.GetGUIThreadInfo(thread, ptr(info))) return null;
111
+ const capture = Number(info.getBigUint64(GUI_THREAD_INFO_CAPTURE_OFFSET, true));
112
+ return capture ? capture as Pointer : null;
113
+ }
114
+
115
+ /**
116
+ * Hands a press on the header to Windows as a press on a native title bar, from
117
+ * where the pointer is. Its move loop then moves the window until the button
118
+ * comes up: it snaps to the edges and corners of the screen and comes out of
119
+ * maximized, which a window moved by hand does not. Electrobun's own mover
120
+ * only moves. False off Windows.
121
+ *
122
+ * The page holds the mouse from the press, so it is asked to let go first, as
123
+ * a native title bar's window would have. Both messages go to the window's
124
+ * thread, which handles them in order.
125
+ */
126
+ export function startSystemWindowMove(windowHandle: Pointer): boolean {
127
+ const win32 = win32OrNull();
128
+ if (!win32) return false;
129
+ const point = new Int32Array(2);
130
+ if (!win32.symbols.GetCursorPos(ptr(point))) return false;
131
+ const capture = mouseCaptureOnThreadOf(win32, windowHandle);
132
+ if (capture && capture !== windowHandle) win32.symbols.PostMessageW(capture, WM_CANCELMODE, 0n, 0n);
133
+ const position = ((point[1]! & 0xffff) << 16) | (point[0]! & 0xffff);
134
+ return win32.symbols.PostMessageW(windowHandle, WM_NCLBUTTONDOWN, BigInt(HTCAPTION), BigInt(position >>> 0));
135
+ }
136
+
59
137
  export function wideString(value: string): Uint8Array {
60
138
  return Uint8Array.from(Buffer.from(`${value}\0`, "utf16le"));
61
139
  }
@@ -0,0 +1,57 @@
1
+ import { dlopen, FFIType, ptr, type Pointer } from "bun:ffi";
2
+ import Electrobun from "electrobun/bun";
3
+ import { wideString } from "./windows-native";
4
+
5
+ const WM_QUIT = 0x0012;
6
+ /** The class Electrobun registers for its windows. */
7
+ const WINDOW_CLASS = "BasicWindowClass";
8
+ /** How long a quit waits for this app's windows to be destroyed. */
9
+ const WINDOWS_CLOSED_TIMEOUT_MS = 1_000;
10
+
11
+ function loadUser32() {
12
+ return dlopen("user32.dll", {
13
+ GetWindowThreadProcessId: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.u32 },
14
+ FindWindowExW: { args: [FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr], returns: FFIType.ptr },
15
+ PostThreadMessageW: { args: [FFIType.u32, FFIType.u32, FFIType.u64, FFIType.i64], returns: FFIType.bool },
16
+ });
17
+ }
18
+
19
+ /**
20
+ * Electrobun ends its Windows event loop with PostQuitMessage, from the thread
21
+ * the app's code runs on rather than the one running the loop. The loop never
22
+ * ends: the app sits out a 5 s timeout with Chromium never shut down, and a
23
+ * Gloom opened meanwhile finds Chromium's profile still held, hands itself to
24
+ * the closing app and crashes. The quit message goes to the loop's own thread
25
+ * instead, once this app's windows are gone; with one still open, Electrobun's
26
+ * timeout ends the app as before. Does nothing off Windows.
27
+ */
28
+ export function quitWindowsEventLoopOnItsThread(window: { ptr: Pointer | null }): void {
29
+ if (process.platform !== "win32" || !window.ptr) return;
30
+ let user32: ReturnType<typeof loadUser32>;
31
+ try {
32
+ user32 = loadUser32();
33
+ } catch {
34
+ return;
35
+ }
36
+ const loopThread = user32.symbols.GetWindowThreadProcessId(window.ptr, null);
37
+ if (!loopThread) return;
38
+ const className = wideString(WINDOW_CLASS);
39
+ const processId = new Uint32Array(1);
40
+ const ownWindowOpen = (): boolean => {
41
+ let handle: Pointer | null = null;
42
+ while ((handle = user32.symbols.FindWindowExW(null, handle, ptr(className), null))) {
43
+ user32.symbols.GetWindowThreadProcessId(handle, ptr(processId));
44
+ if (processId[0] === process.pid) return true;
45
+ }
46
+ return false;
47
+ };
48
+ Electrobun.events.on("before-quit", () => {
49
+ // Quitting blocks this thread until the loop has ended, so the wait is here.
50
+ const deadline = Date.now() + WINDOWS_CLOSED_TIMEOUT_MS;
51
+ while (ownWindowOpen()) {
52
+ if (Date.now() > deadline) return;
53
+ Bun.sleepSync(16);
54
+ }
55
+ user32.symbols.PostThreadMessageW(loopThread, WM_QUIT, 0n, 0n);
56
+ });
57
+ }
@@ -0,0 +1,13 @@
1
+ // Runs first in Electrobun's launcher script (Resources/main.js) on Windows,
2
+ // before it starts the app's worker and the browser engine. The Windows build
3
+ // step puts it there (scripts/install-electrobun-tui-shim.ts).
4
+ import { leaveEmulationFaultsToWindows } from "./windows-emulation";
5
+
6
+ if (process.platform === "win32") {
7
+ try {
8
+ const result = leaveEmulationFaultsToWindows();
9
+ if (result !== "not emulated") console.log(`[launcher] x64 emulation: ${result}`);
10
+ } catch (error) {
11
+ console.error("[launcher] could not check for x64 emulation", error);
12
+ }
13
+ }