gloomberb 0.8.2 → 0.9.1

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 (111) hide show
  1. package/README.md +9 -7
  2. package/package.json +18 -13
  3. package/patches/{@opentui%2Fcore@0.1.90.patch → @opentui%2Fcore@0.3.2.patch} +175 -90
  4. package/patches/electrobun@1.18.1.patch +85 -0
  5. package/src/app/global-shortcuts.test.tsx +66 -4
  6. package/src/app/global-shortcuts.ts +12 -6
  7. package/src/assets/gloomberb-logo-windows.ico +0 -0
  8. package/src/assets/gloomberb-logo-windows.png +0 -0
  9. package/src/assets/gloomberb-logo.ico +0 -0
  10. package/src/components/command-bar/action-runtime.ts +1 -0
  11. package/src/components/command-bar/layout-items/current-layout.test.ts +115 -0
  12. package/src/components/command-bar/layout-items/current-layout.ts +33 -0
  13. package/src/components/command-bar/list/view.tsx +3 -1
  14. package/src/components/command-bar/pane-templates/items.ts +4 -1
  15. package/src/components/command-bar/panel/body.tsx +1 -0
  16. package/src/components/command-bar/routes/root/results.ts +0 -30
  17. package/src/components/command-bar/routes/root/runtime.ts +2 -20
  18. package/src/components/command-bar/routes/ticker-search/actions.ts +42 -4
  19. package/src/components/command-bar/routes/ticker-search/route.ts +4 -1
  20. package/src/components/command-bar/routing/launch-request.ts +14 -7
  21. package/src/components/command-bar/routing/list-state.ts +1 -3
  22. package/src/components/command-bar/surface/index.test.tsx +76 -4
  23. package/src/components/command-bar/surface/index.tsx +0 -2
  24. package/src/components/command-bar/surface/pane.test.tsx +1 -0
  25. package/src/components/command-bar/surface/test-harness.tsx +1 -0
  26. package/src/components/command-bar/view-model.ts +3 -3
  27. package/src/components/layout/detached-pane-shell.tsx +8 -4
  28. package/src/components/layout/header.tsx +13 -6
  29. package/src/components/layout/shell/index.test.tsx +120 -2
  30. package/src/components/layout/shell/index.tsx +2 -0
  31. package/src/components/layout/shell/menu.ts +1 -7
  32. package/src/components/layout/shell/pane/actions.ts +8 -0
  33. package/src/components/layout/shell/pane/management-shortcuts.ts +12 -2
  34. package/src/components/layout/shell/shortcuts.ts +14 -6
  35. package/src/components/layout/shell/window-mode/index.ts +8 -3
  36. package/src/components/layout/titlebar-overlay.ts +16 -0
  37. package/src/components/layout/window-controls.tsx +90 -0
  38. package/src/components/layout/window-edit/presentation.ts +4 -4
  39. package/src/components/onboarding/onboarding-steps.tsx +3 -2
  40. package/src/components/ticker/list-table-view.tsx +6 -0
  41. package/src/components/ui/remote-image.tsx +1 -14
  42. package/src/components/ui/tabs.tsx +95 -80
  43. package/src/components/ui/ui.test.tsx +54 -0
  44. package/src/core/state/app/types.ts +12 -4
  45. package/src/plugins/builtin/chat/content/channel-navigation.ts +44 -11
  46. package/src/plugins/builtin/chat/content/index.tsx +5 -4
  47. package/src/plugins/builtin/chat/content/shortcuts.ts +7 -0
  48. package/src/plugins/builtin/chat/sidebar.test.tsx +71 -0
  49. package/src/plugins/builtin/help/components.tsx +1 -1
  50. package/src/plugins/builtin/help/index.test.tsx +105 -30
  51. package/src/plugins/builtin/help/index.tsx +324 -168
  52. package/src/plugins/builtin/help/shortcut-model.ts +5 -6
  53. package/src/plugins/builtin/portfolio-list/column-values.ts +304 -1
  54. package/src/plugins/builtin/portfolio-list/metrics.test.ts +93 -0
  55. package/src/plugins/builtin/portfolio-list/pane/data.ts +7 -0
  56. package/src/plugins/builtin/portfolio-list/pane/index.tsx +25 -2
  57. package/src/plugins/builtin/portfolio-list/pane/supplemental.ts +281 -0
  58. package/src/plugins/builtin/portfolio-list/settings.ts +27 -0
  59. package/src/plugins/builtin/portfolio-list/table.tsx +4 -2
  60. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +10 -68
  61. package/src/plugins/builtin/substack/api/auth.ts +185 -0
  62. package/src/plugins/builtin/substack/api/cache.ts +78 -0
  63. package/src/plugins/builtin/substack/api/loaders.ts +146 -0
  64. package/src/plugins/builtin/substack/api/store.ts +186 -0
  65. package/src/plugins/builtin/substack/api/types.ts +40 -0
  66. package/src/plugins/builtin/substack/api.test.ts +298 -0
  67. package/src/plugins/builtin/substack/article-detail.tsx +359 -0
  68. package/src/plugins/builtin/substack/article-stack.tsx +129 -0
  69. package/src/plugins/builtin/substack/content.ts +478 -0
  70. package/src/plugins/builtin/substack/feed-tabs.tsx +41 -0
  71. package/src/plugins/builtin/substack/index.tsx +49 -0
  72. package/src/plugins/builtin/substack/login-view.tsx +126 -0
  73. package/src/plugins/builtin/substack/model.test.ts +155 -0
  74. package/src/plugins/builtin/substack/normalize.ts +343 -0
  75. package/src/plugins/builtin/substack/pane-footer.ts +71 -0
  76. package/src/plugins/builtin/substack/pane-state.ts +170 -0
  77. package/src/plugins/builtin/substack/pane.tsx +500 -0
  78. package/src/plugins/builtin/substack/table.ts +111 -0
  79. package/src/plugins/builtin/substack/types.ts +71 -0
  80. package/src/plugins/builtin/substack/utils.ts +131 -0
  81. package/src/plugins/builtin/ticker-detail/index.test.tsx +16 -2
  82. package/src/plugins/builtin/ticker-detail/index.tsx +1 -0
  83. package/src/plugins/builtin/ticker-detail/pane.tsx +13 -1
  84. package/src/plugins/catalog-ui.ts +2 -0
  85. package/src/plugins/pane-manager/layout-state.ts +11 -0
  86. package/src/plugins/pane-manager.ts +1 -1
  87. package/src/react/use-throttled-commit-value.ts +116 -0
  88. package/src/renderers/electrobun/bun/application-menu/index.test.ts +12 -0
  89. package/src/renderers/electrobun/bun/application-menu/index.ts +4 -0
  90. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +43 -2
  91. package/src/renderers/electrobun/bun/desktop/host-requests.ts +31 -0
  92. package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +42 -0
  93. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +5 -0
  94. package/src/renderers/electrobun/bun/desktop/window-controls.test.ts +53 -0
  95. package/src/renderers/electrobun/bun/desktop/window-controls.ts +64 -0
  96. package/src/renderers/electrobun/bun/desktop/window-style.ts +5 -0
  97. package/src/renderers/electrobun/bun/desktop/windows-icons.ts +123 -0
  98. package/src/renderers/electrobun/bun/index.ts +28 -6
  99. package/src/renderers/electrobun/bun/window/frame.test.ts +7 -0
  100. package/src/renderers/electrobun/bun/window/frame.ts +5 -0
  101. package/src/renderers/electrobun/view/build-assets.ts +2 -1
  102. package/src/renderers/electrobun/view/host/text.tsx +29 -9
  103. package/src/renderers/electrobun/view/http-fetch.ts +10 -3
  104. package/src/renderers/electrobun/view/styles.css +20 -0
  105. package/src/renderers/electrobun/view/ui-host.tsx +14 -0
  106. package/src/state/app/context/index.test.tsx +16 -0
  107. package/src/tickers/search/index.ts +1 -0
  108. package/src/ui/host.tsx +2 -0
  109. package/src/updater.test.ts +47 -2
  110. package/src/updater.ts +67 -10
  111. package/src/version.ts +1 -1
@@ -12,6 +12,7 @@ import {
12
12
  import {
13
13
  detachedRpcKey,
14
14
  focusWindowForRpcKey,
15
+ paneIdFromDetachedRpcKey,
15
16
  } from "../window/focus";
16
17
  import {
17
18
  applyWindowMoveEvent,
@@ -22,6 +23,11 @@ import {
22
23
  type WindowResizeEvent,
23
24
  } from "./window-events";
24
25
  import type { DesktopStateBroadcaster, DesktopStateRpc } from "./state-broadcaster";
26
+ import { applyWindowsWindowIcon } from "./windows-icons";
27
+ import { desktopTitleBarStyle } from "./window-style";
28
+
29
+ const INITIAL_DOCK_SUPPRESSION_MS = 800;
30
+ const WINDOW_CONTROL_DOCK_SUPPRESSION_MS = 5_000;
25
31
 
26
32
  interface DesktopDetachedWindowManagerOptions<Rpc extends DesktopStateRpc> {
27
33
  createRpc: (key: string) => Rpc;
@@ -45,6 +51,8 @@ export class DesktopDetachedWindowManager<Rpc extends DesktopStateRpc> {
45
51
  private readonly dockTimers = new Map<string, ReturnType<typeof setTimeout>>();
46
52
  private readonly closingPanes = new Set<string>();
47
53
  private readonly pendingMoveFlush = new Set<string>();
54
+ private readonly suppressDockUntil = new Map<string, number>();
55
+ private readonly windowControlDockSuppressed = new Set<string>();
48
56
 
49
57
  constructor(private readonly options: DesktopDetachedWindowManagerOptions<Rpc>) {}
50
58
 
@@ -56,6 +64,19 @@ export class DesktopDetachedWindowManager<Rpc extends DesktopStateRpc> {
56
64
  return focusWindowForRpcKey(rpcKey, this.options.getMainWindow(), this.windows);
57
65
  }
58
66
 
67
+ getWindowForRpcKey(rpcKey: string | undefined): BrowserWindow | null {
68
+ const paneId = paneIdFromDetachedRpcKey(rpcKey);
69
+ return paneId ? this.windows.get(paneId) ?? null : null;
70
+ }
71
+
72
+ suppressAutoDockForRpcKey(rpcKey: string | undefined): void {
73
+ const paneId = paneIdFromDetachedRpcKey(rpcKey);
74
+ if (!paneId || !this.windows.has(paneId)) return;
75
+ this.windowControlDockSuppressed.add(paneId);
76
+ this.suppressDockUntil.set(paneId, Date.now() + WINDOW_CONTROL_DOCK_SUPPRESSION_MS);
77
+ this.options.stateBroadcaster.clearDockPreview(paneId);
78
+ }
79
+
59
80
  resolveFrame(instanceId: string): WindowFrame {
60
81
  const detachedEntry = this.options.getConfig().layout.detached.find((entry) => entry.instanceId === instanceId);
61
82
  if (detachedEntry) {
@@ -151,6 +172,8 @@ export class DesktopDetachedWindowManager<Rpc extends DesktopStateRpc> {
151
172
  this.clearTimer(this.frameTimers, instanceId);
152
173
  this.clearTimer(this.dockTimers, instanceId);
153
174
  this.pendingMoveFlush.delete(instanceId);
175
+ this.suppressDockUntil.delete(instanceId);
176
+ this.windowControlDockSuppressed.delete(instanceId);
154
177
  this.options.unregisterWindowRpc(detachedRpcKey(instanceId));
155
178
  }
156
179
 
@@ -160,18 +183,21 @@ export class DesktopDetachedWindowManager<Rpc extends DesktopStateRpc> {
160
183
  ): BrowserWindow {
161
184
  const rpc = this.options.createRpc(detachedRpcKey(instanceId));
162
185
  const initialFrame = normalizeWindowFrameWithMinimum(frame, DEFAULT_WINDOW_FRAME, DETACHED_WINDOW_MIN_SIZE);
186
+ const title = this.resolveTitle(instanceId);
163
187
  const window = new BrowserWindow({
164
- title: this.resolveTitle(instanceId),
188
+ title,
165
189
  frame: initialFrame,
166
190
  url: "views://mainview/index.html",
167
191
  renderer: "native",
168
192
  rpc: rpc as never,
169
- titleBarStyle: "hiddenInset",
193
+ titleBarStyle: desktopTitleBarStyle(),
170
194
  navigationRules: JSON.stringify(["views://*"]),
171
195
  sandbox: false,
172
196
  });
197
+ applyWindowsWindowIcon(title);
173
198
  updateWindowFrameCache(window, initialFrame, DETACHED_WINDOW_MIN_SIZE);
174
199
  this.windows.set(instanceId, window);
200
+ this.suppressDockUntil.set(instanceId, Date.now() + INITIAL_DOCK_SUPPRESSION_MS);
175
201
 
176
202
  (window as any).on?.("close", () => {
177
203
  const shouldIgnore = this.closingPanes.delete(instanceId);
@@ -230,6 +256,21 @@ export class DesktopDetachedWindowManager<Rpc extends DesktopStateRpc> {
230
256
  }, 120));
231
257
 
232
258
  this.clearTimer(this.dockTimers, instanceId);
259
+ if (this.windowControlDockSuppressed.has(instanceId)) {
260
+ this.options.stateBroadcaster.clearDockPreview(instanceId);
261
+ if (!edge) {
262
+ this.windowControlDockSuppressed.delete(instanceId);
263
+ }
264
+ return;
265
+ }
266
+
267
+ const suppressDockUntil = this.suppressDockUntil.get(instanceId) ?? 0;
268
+ if (Date.now() < suppressDockUntil) {
269
+ this.options.stateBroadcaster.clearDockPreview(instanceId);
270
+ return;
271
+ }
272
+ this.suppressDockUntil.delete(instanceId);
273
+
233
274
  if (!edge) {
234
275
  this.options.stateBroadcaster.clearDockPreview(instanceId);
235
276
  return;
@@ -3,10 +3,13 @@ import { ContextMenu, Utils, type BrowserWindow } from "electrobun/bun";
3
3
  import { buildSoundCommand } from "../../../../notifications/app-notifier";
4
4
  import type { DesktopRestartMessage } from "../../shared/protocol";
5
5
  import { getContextMenuRequestId, normalizeContextMenuItems } from "../context-menu/normalize";
6
+ import { MAIN_WINDOW_RPC_KEY } from "../window/focus";
7
+ import type { DesktopWindowControlAction } from "./window-controls";
6
8
 
7
9
  interface DesktopHostRequestOptions<TRpc> {
8
10
  clearMainWindow: () => void;
9
11
  closeAllDetachedWindows: () => void;
12
+ controlWindowForRpcKey: (windowKey: string | undefined, action: DesktopWindowControlAction) => boolean;
10
13
  focusWindowForRpcKey: (windowKey: string) => void;
11
14
  getMainWindow: () => BrowserWindow | null;
12
15
  getRpcWindowKey: (rpc: TRpc) => string | undefined;
@@ -37,9 +40,17 @@ function playNotificationSound(sound: string | undefined): void {
37
40
  }
38
41
  }
39
42
 
43
+ function normalizeWindowControlAction(action: unknown): DesktopWindowControlAction {
44
+ if (action === "minimize" || action === "toggle-maximize" || action === "close") {
45
+ return action;
46
+ }
47
+ throw new Error("host.windowControl requires a valid action.");
48
+ }
49
+
40
50
  export function handleDesktopHostRequest<TRpc>({
41
51
  clearMainWindow,
42
52
  closeAllDetachedWindows,
53
+ controlWindowForRpcKey,
43
54
  focusWindowForRpcKey,
44
55
  getMainWindow,
45
56
  getRpcWindowKey,
@@ -69,6 +80,26 @@ export function handleDesktopHostRequest<TRpc>({
69
80
  Utils.quit();
70
81
  return null;
71
82
  }
83
+ case "host.windowControl": {
84
+ const action = normalizeWindowControlAction(payload.action);
85
+ const windowKey = getRpcWindowKey(rpc);
86
+ if (action === "close" && windowKey === MAIN_WINDOW_RPC_KEY) {
87
+ closeAllDetachedWindows();
88
+ teardownServices();
89
+ const mainWindow = getMainWindow();
90
+ if (mainWindow) {
91
+ mainWindow.close();
92
+ clearMainWindow();
93
+ return null;
94
+ }
95
+ Utils.quit();
96
+ return null;
97
+ }
98
+ if (!controlWindowForRpcKey(windowKey, action)) {
99
+ throw new Error("No desktop window is registered for this request.");
100
+ }
101
+ return null;
102
+ }
72
103
  case "host.openExternal":
73
104
  if (typeof payload.url !== "string") {
74
105
  throw new Error("host.openExternal requires a URL.");
@@ -0,0 +1,42 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { handleHttpFetch } from "./http-fetch";
3
+
4
+ let server: ReturnType<typeof Bun.serve> | null = null;
5
+
6
+ afterEach(() => {
7
+ server?.stop(true);
8
+ server = null;
9
+ });
10
+
11
+ describe("handleHttpFetch", () => {
12
+ test("preserves manual redirects and Set-Cookie headers", async () => {
13
+ server = Bun.serve({
14
+ port: 0,
15
+ fetch(request) {
16
+ const url = new URL(request.url);
17
+ if (url.pathname === "/login") {
18
+ return new Response(null, {
19
+ status: 302,
20
+ headers: {
21
+ location: "/reader",
22
+ "set-cookie": "substack.sid=sid123; Path=/; HttpOnly",
23
+ },
24
+ });
25
+ }
26
+ if (url.pathname === "/reader") {
27
+ return new Response("reader");
28
+ }
29
+ return new Response("not found", { status: 404 });
30
+ },
31
+ });
32
+
33
+ const response = await handleHttpFetch({
34
+ url: new URL("/login", server.url).toString(),
35
+ init: { redirect: "manual" },
36
+ });
37
+
38
+ expect(response.status).toBe(302);
39
+ expect(response.headers.location).toBe("/reader");
40
+ expect(response.setCookie).toEqual(["substack.sid=sid123; Path=/; HttpOnly"]);
41
+ });
42
+ });
@@ -26,6 +26,10 @@ export async function handleHttpFetch(payload: Record<string, unknown>) {
26
26
  typeof init.method === "string" && init.method.trim().length > 0
27
27
  ? init.method.trim().toUpperCase()
28
28
  : "GET";
29
+ const redirect =
30
+ init.redirect === "manual" || init.redirect === "error" || init.redirect === "follow"
31
+ ? init.redirect
32
+ : undefined;
29
33
  const body =
30
34
  typeof init.body === "string" && method !== "GET" && method !== "HEAD"
31
35
  ? init.body
@@ -35,6 +39,7 @@ export async function handleHttpFetch(payload: Record<string, unknown>) {
35
39
  method,
36
40
  headers: normalizeHttpFetchHeaders(init.headers),
37
41
  body,
42
+ redirect,
38
43
  });
39
44
  const responseHeaders: Record<string, string> = {};
40
45
  response.headers.forEach((value, key) => {
@@ -0,0 +1,53 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { applyDesktopWindowControl } from "./window-controls";
3
+
4
+ type WindowCall =
5
+ | { type: "close" }
6
+ | { type: "minimize" }
7
+ | { type: "maximize" }
8
+ | { type: "unmaximize" }
9
+ | { type: "setFrame"; frame: { x: number; y: number; width: number; height: number } };
10
+
11
+ function createWindow() {
12
+ const calls: WindowCall[] = [];
13
+ const frame = { x: 120, y: 80, width: 640, height: 420 };
14
+ return {
15
+ calls,
16
+ window: {
17
+ frame,
18
+ close: () => calls.push({ type: "close" }),
19
+ minimize: () => calls.push({ type: "minimize" }),
20
+ maximize: () => calls.push({ type: "maximize" }),
21
+ unmaximize: () => calls.push({ type: "unmaximize" }),
22
+ getFrame: () => frame,
23
+ setFrame: (x: number, y: number, width: number, height: number) => calls.push({
24
+ type: "setFrame",
25
+ frame: { x, y, width, height },
26
+ }),
27
+ },
28
+ };
29
+ }
30
+
31
+ describe("applyDesktopWindowControl", () => {
32
+ test("minimizes and closes windows", () => {
33
+ const target = createWindow();
34
+
35
+ applyDesktopWindowControl(target.window, "minimize");
36
+ applyDesktopWindowControl(target.window, "close");
37
+
38
+ expect(target.calls).toEqual([{ type: "minimize" }, { type: "close" }]);
39
+ });
40
+
41
+ test("toggles maximize from the custom control state", () => {
42
+ const target = createWindow();
43
+
44
+ applyDesktopWindowControl(target.window, "toggle-maximize");
45
+ applyDesktopWindowControl(target.window, "toggle-maximize");
46
+
47
+ expect(target.calls).toEqual([
48
+ { type: "maximize" },
49
+ { type: "unmaximize" },
50
+ { type: "setFrame", frame: { x: 120, y: 80, width: 640, height: 420 } },
51
+ ]);
52
+ });
53
+ });
@@ -0,0 +1,64 @@
1
+ export type DesktopWindowControlAction = "minimize" | "toggle-maximize" | "close";
2
+
3
+ interface DesktopWindowFrame {
4
+ x: number;
5
+ y: number;
6
+ width: number;
7
+ height: number;
8
+ }
9
+
10
+ export interface ControllableDesktopWindow {
11
+ frame?: Partial<DesktopWindowFrame> | null;
12
+ close?: () => void;
13
+ minimize?: () => void;
14
+ maximize?: () => void;
15
+ unmaximize?: () => void;
16
+ getFrame?: () => Partial<DesktopWindowFrame> | null;
17
+ setFrame?: (x: number, y: number, width: number, height: number) => void;
18
+ }
19
+
20
+ const maximizedWindowRestoreFrames = new WeakMap<ControllableDesktopWindow, DesktopWindowFrame | null>();
21
+
22
+ function normalizeFrame(frame: Partial<DesktopWindowFrame> | null | undefined): DesktopWindowFrame | null {
23
+ if (!frame) return null;
24
+ const { x, y, width, height } = frame;
25
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(width) || !Number.isFinite(height)) {
26
+ return null;
27
+ }
28
+ return { x, y, width, height };
29
+ }
30
+
31
+ function readWindowFrame(window: ControllableDesktopWindow): DesktopWindowFrame | null {
32
+ return normalizeFrame(window.getFrame?.() ?? window.frame);
33
+ }
34
+
35
+ function restoreWindowFrame(window: ControllableDesktopWindow, frame: DesktopWindowFrame | null): void {
36
+ if (!frame) return;
37
+ window.setFrame?.(frame.x, frame.y, frame.width, frame.height);
38
+ }
39
+
40
+ export function applyDesktopWindowControl(
41
+ window: ControllableDesktopWindow,
42
+ action: DesktopWindowControlAction,
43
+ ): void {
44
+ if (action === "close") {
45
+ maximizedWindowRestoreFrames.delete(window);
46
+ window.close?.();
47
+ return;
48
+ }
49
+
50
+ if (action === "minimize") {
51
+ window.minimize?.();
52
+ return;
53
+ }
54
+
55
+ const restoreFrame = maximizedWindowRestoreFrames.get(window);
56
+ if (maximizedWindowRestoreFrames.has(window)) {
57
+ maximizedWindowRestoreFrames.delete(window);
58
+ window.unmaximize?.();
59
+ restoreWindowFrame(window, restoreFrame);
60
+ return;
61
+ }
62
+ maximizedWindowRestoreFrames.set(window, readWindowFrame(window));
63
+ window.maximize?.();
64
+ }
@@ -0,0 +1,5 @@
1
+ type DesktopTitleBarStyle = "default" | "hidden" | "hiddenInset";
2
+
3
+ export function desktopTitleBarStyle(): DesktopTitleBarStyle {
4
+ return process.platform === "darwin" ? "hiddenInset" : "hidden";
5
+ }
@@ -0,0 +1,123 @@
1
+ import { existsSync } from "fs";
2
+ import { resolve } from "path";
3
+ import { dlopen, FFIType, ptr, type Pointer } from "bun:ffi";
4
+
5
+ const WM_SETICON = 0x0080;
6
+ const ICON_SMALL = 0;
7
+ const ICON_BIG = 1;
8
+ const IMAGE_ICON = 1;
9
+ const LR_LOADFROMFILE = 0x0010;
10
+ const LR_DEFAULTSIZE = 0x0040;
11
+ const GCLP_HICON = -14;
12
+ const GCLP_HICONSM = -34;
13
+ const MAX_ATTEMPTS = 20;
14
+ const RETRY_DELAY_MS = 100;
15
+
16
+ type Win32 = ReturnType<typeof loadWin32>;
17
+
18
+ let cachedWin32: Win32 | null | undefined;
19
+ let smallIconHandle: Pointer | null = null;
20
+ let bigIconHandle: Pointer | null = null;
21
+
22
+ function loadWin32() {
23
+ return dlopen("user32.dll", {
24
+ FindWindowW: {
25
+ args: [FFIType.ptr, FFIType.ptr],
26
+ returns: FFIType.ptr,
27
+ },
28
+ GetWindowThreadProcessId: {
29
+ args: [FFIType.ptr, FFIType.ptr],
30
+ returns: FFIType.u32,
31
+ },
32
+ LoadImageW: {
33
+ args: [FFIType.ptr, FFIType.ptr, FFIType.u32, FFIType.int, FFIType.int, FFIType.u32],
34
+ returns: FFIType.ptr,
35
+ },
36
+ SendMessageW: {
37
+ args: [FFIType.ptr, FFIType.u32, FFIType.u64, FFIType.ptr],
38
+ returns: FFIType.ptr,
39
+ },
40
+ SetClassLongPtrW: {
41
+ args: [FFIType.ptr, FFIType.int, FFIType.ptr],
42
+ returns: FFIType.ptr,
43
+ },
44
+ });
45
+ }
46
+
47
+ function win32OrNull(): Win32 | null {
48
+ if (process.platform !== "win32") return null;
49
+ if (cachedWin32 !== undefined) return cachedWin32;
50
+
51
+ try {
52
+ cachedWin32 = loadWin32();
53
+ } catch {
54
+ cachedWin32 = null;
55
+ }
56
+ return cachedWin32;
57
+ }
58
+
59
+ function wideString(value: string): Buffer {
60
+ return Buffer.from(`${value}\0`, "utf16le");
61
+ }
62
+
63
+ function readWindowProcessId(win32: Win32, windowHandle: Pointer): number {
64
+ const processIdBuffer = new Uint32Array(1);
65
+ win32.symbols.GetWindowThreadProcessId(windowHandle, ptr(processIdBuffer));
66
+ return processIdBuffer[0] ?? 0;
67
+ }
68
+
69
+ function findCurrentProcessWindow(win32: Win32, title: string): Pointer | null {
70
+ const titleBuffer = wideString(title);
71
+ const windowHandle = win32.symbols.FindWindowW(null, ptr(titleBuffer));
72
+ if (!windowHandle) return null;
73
+ if (readWindowProcessId(win32, windowHandle) !== process.pid) return null;
74
+ return windowHandle;
75
+ }
76
+
77
+ function loadIconHandle(win32: Win32, iconPath: string, size: number): Pointer | null {
78
+ const iconPathBuffer = wideString(iconPath);
79
+ const iconHandle = win32.symbols.LoadImageW(
80
+ null,
81
+ ptr(iconPathBuffer),
82
+ IMAGE_ICON,
83
+ size,
84
+ size,
85
+ LR_LOADFROMFILE | LR_DEFAULTSIZE,
86
+ );
87
+
88
+ return iconHandle || null;
89
+ }
90
+
91
+ function ensureIconHandles(win32: Win32): boolean {
92
+ if (smallIconHandle && bigIconHandle) return true;
93
+
94
+ const iconPath = resolve("../Resources/app.ico");
95
+ if (!existsSync(iconPath)) return false;
96
+
97
+ smallIconHandle = smallIconHandle ?? loadIconHandle(win32, iconPath, 16);
98
+ bigIconHandle = bigIconHandle ?? loadIconHandle(win32, iconPath, 32);
99
+
100
+ return Boolean(smallIconHandle && bigIconHandle);
101
+ }
102
+
103
+ function setWindowIcon(win32: Win32, windowHandle: Pointer): boolean {
104
+ if (!ensureIconHandles(win32) || !smallIconHandle || !bigIconHandle) return false;
105
+
106
+ win32.symbols.SendMessageW(windowHandle, WM_SETICON, ICON_SMALL, smallIconHandle);
107
+ win32.symbols.SendMessageW(windowHandle, WM_SETICON, ICON_BIG, bigIconHandle);
108
+ win32.symbols.SetClassLongPtrW(windowHandle, GCLP_HICONSM, smallIconHandle);
109
+ win32.symbols.SetClassLongPtrW(windowHandle, GCLP_HICON, bigIconHandle);
110
+ return true;
111
+ }
112
+
113
+ export function applyWindowsWindowIcon(title: string, attempt = 1): void {
114
+ const win32 = win32OrNull();
115
+ if (!win32) return;
116
+
117
+ const windowHandle = findCurrentProcessWindow(win32, title);
118
+ if (windowHandle && setWindowIcon(win32, windowHandle)) return;
119
+
120
+ if (attempt < MAX_ATTEMPTS) {
121
+ setTimeout(() => applyWindowsWindowIcon(title, attempt + 1), RETRY_DELAY_MS);
122
+ }
123
+ }
@@ -16,7 +16,7 @@ import { ELECTROBUN_CONTEXT_MENU_ACTION, type DesktopRestartMessage, type Electr
16
16
  import { decodeRpcValue, encodeRpcValue } from "../view/rpc-codec";
17
17
  import { contextMenuSelectionMessage } from "./context-menu/click";
18
18
  import type { DesktopWorkspace } from "./desktop/workspace";
19
- import { buildApplicationMenu } from "./application-menu";
19
+ import { buildDesktopApplicationMenu } from "./application-menu";
20
20
  import { applicationMenuCommand } from "./application-menu/click";
21
21
  import { registerElectrobunCoreCapabilities } from "./core-capabilities";
22
22
  import { setNativeIbkrGatewayModuleLoader } from "../../../plugins/ibkr/gateway/service";
@@ -25,8 +25,8 @@ import {
25
25
  } from "./desktop/update";
26
26
  import { DesktopCapabilityBridge } from "./desktop/capability-bridge";
27
27
  import {
28
- DEFAULT_WINDOW_FRAME,
29
28
  MAIN_WINDOW_MIN_SIZE,
29
+ defaultMainWindowFrame,
30
30
  normalizeWindowFrameWithMinimum,
31
31
  } from "./window/frame";
32
32
  import { MAIN_WINDOW_RPC_KEY } from "./window/focus";
@@ -47,6 +47,9 @@ import { handleDesktopHostRequest } from "./desktop/host-requests";
47
47
  import { handleDesktopWorkspaceRequest } from "./desktop/workspace/requests";
48
48
  import { handleDesktopBackendRequest } from "./desktop/backend-requests";
49
49
  import { initializeDesktopBackend } from "./desktop/initialization";
50
+ import { applyWindowsWindowIcon } from "./desktop/windows-icons";
51
+ import { desktopTitleBarStyle } from "./desktop/window-style";
52
+ import { applyDesktopWindowControl, type DesktopWindowControlAction } from "./desktop/window-controls";
50
53
 
51
54
  type DesktopRpc = ReturnType<typeof BrowserView.defineRPC<ElectrobunDesktopRpcSchema>>;
52
55
 
@@ -273,6 +276,18 @@ function closeAllDetachedWindows(): void {
273
276
  detachedWindowManager.closeAll();
274
277
  }
275
278
 
279
+ function controlWindowForRpcKey(windowKey: string | undefined, action: DesktopWindowControlAction): boolean {
280
+ const targetWindow = windowKey === MAIN_WINDOW_RPC_KEY
281
+ ? mainWindow
282
+ : detachedWindowManager.getWindowForRpcKey(windowKey);
283
+ if (!targetWindow) return false;
284
+ if (action !== "close") {
285
+ detachedWindowManager.suppressAutoDockForRpcKey(windowKey);
286
+ }
287
+ applyDesktopWindowControl(targetWindow, action);
288
+ return true;
289
+ }
290
+
276
291
  async function initialize(
277
292
  rpc: DesktopRpc,
278
293
  payload: Record<string, unknown>,
@@ -343,6 +358,7 @@ async function handleBackendRequest(
343
358
  restartDesktopApp,
344
359
  rpc,
345
360
  teardownServices,
361
+ controlWindowForRpcKey,
346
362
  trackContextMenuRequest: (requestId, targetRpc) => {
347
363
  contextMenuRequestRpcs.clear();
348
364
  contextMenuRequestRpcs.set(requestId, targetRpc);
@@ -383,7 +399,7 @@ async function handleBackendRequest(
383
399
  }
384
400
 
385
401
  function installApplicationMenu() {
386
- ApplicationMenu.setApplicationMenu(buildApplicationMenu());
402
+ ApplicationMenu.setApplicationMenu(buildDesktopApplicationMenu());
387
403
  }
388
404
 
389
405
  function createWindowRpc(key: string): DesktopRpc {
@@ -432,18 +448,24 @@ ApplicationMenu.on("application-menu-clicked", (event: unknown) => {
432
448
  installApplicationMenu();
433
449
 
434
450
  const mainRpc = createWindowRpc(MAIN_WINDOW_RPC_KEY);
451
+ const initialMainWindowFrame = normalizeWindowFrameWithMinimum(
452
+ defaultMainWindowFrame(),
453
+ defaultMainWindowFrame(),
454
+ MAIN_WINDOW_MIN_SIZE,
455
+ );
435
456
 
436
457
  mainWindow = new BrowserWindow({
437
458
  title: "Gloomberb",
438
- frame: normalizeWindowFrameWithMinimum(DEFAULT_WINDOW_FRAME, DEFAULT_WINDOW_FRAME, MAIN_WINDOW_MIN_SIZE),
459
+ frame: initialMainWindowFrame,
439
460
  url: "views://mainview/index.html",
440
461
  renderer: "native",
441
462
  rpc: mainRpc,
442
- titleBarStyle: "hiddenInset",
463
+ titleBarStyle: desktopTitleBarStyle(),
443
464
  navigationRules: JSON.stringify(["views://*"]),
444
465
  sandbox: false,
445
466
  });
446
- updateWindowFrameCache(mainWindow, DEFAULT_WINDOW_FRAME, MAIN_WINDOW_MIN_SIZE);
467
+ applyWindowsWindowIcon("Gloomberb");
468
+ updateWindowFrameCache(mainWindow, initialMainWindowFrame, MAIN_WINDOW_MIN_SIZE);
447
469
  detachedWindowManager.focusWindowForRpcKey(MAIN_WINDOW_RPC_KEY);
448
470
  (mainWindow as any).on?.("move", (event: WindowMoveEvent) => {
449
471
  applyWindowMoveEvent(mainWindow, event);
@@ -1,8 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  DEFAULT_WINDOW_FRAME,
4
+ DEFAULT_WINDOWS_WINDOW_FRAME,
4
5
  MAIN_WINDOW_MIN_SIZE,
5
6
  constrainWindowFrame,
7
+ defaultMainWindowFrame,
6
8
  normalizeWindowFrameWithMinimum,
7
9
  } from "./frame";
8
10
 
@@ -37,4 +39,9 @@ describe("electrobun window frames", () => {
37
39
  test("uses the default frame when no frame data is available", () => {
38
40
  expect(normalizeWindowFrameWithMinimum(null)).toEqual(DEFAULT_WINDOW_FRAME);
39
41
  });
42
+
43
+ test("uses a compact default main window frame on Windows", () => {
44
+ expect(defaultMainWindowFrame("win32")).toEqual(DEFAULT_WINDOWS_WINDOW_FRAME);
45
+ expect(defaultMainWindowFrame("darwin")).toEqual(DEFAULT_WINDOW_FRAME);
46
+ });
40
47
  });
@@ -11,11 +11,16 @@ export interface WindowMinimumSize {
11
11
  }
12
12
 
13
13
  export const DEFAULT_WINDOW_FRAME: WindowFrame = { x: 64, y: 48, width: 1440, height: 920 };
14
+ export const DEFAULT_WINDOWS_WINDOW_FRAME: WindowFrame = { x: 32, y: 32, width: 960, height: 680 };
14
15
  // Emergency fallback until Electrobun exposes stable native minimum-size handling:
15
16
  // https://github.com/blackboardsh/electrobun/issues/188
16
17
  export const MAIN_WINDOW_MIN_SIZE: WindowMinimumSize = { width: 640, height: 400 };
17
18
  export const DETACHED_WINDOW_MIN_SIZE: WindowMinimumSize = { width: 360, height: 240 };
18
19
 
20
+ export function defaultMainWindowFrame(platform = process.platform): WindowFrame {
21
+ return platform === "win32" ? DEFAULT_WINDOWS_WINDOW_FRAME : DEFAULT_WINDOW_FRAME;
22
+ }
23
+
19
24
  export function normalizeWindowFrame(
20
25
  frame: Partial<WindowFrame> | null | undefined,
21
26
  fallback: WindowFrame = DEFAULT_WINDOW_FRAME,
@@ -108,10 +108,11 @@ ${bootstrapScript}
108
108
  }
109
109
 
110
110
  function resolveAlias(args: { path: string; importer?: string }, aliasRules: AliasRule[]) {
111
+ const importer = args.importer?.replaceAll("\\", "/");
111
112
  for (const rule of aliasRules) {
112
113
  const target = rule.length === 2
113
114
  ? args.path.endsWith(rule[0]) && rule[1]
114
- : args.path === rule[0] && args.importer?.endsWith(rule[1]) && rule[2];
115
+ : args.path === rule[0] && importer?.endsWith(rule[1]) && rule[2];
115
116
  if (target) return { path: electrobunViewPath(target) };
116
117
  }
117
118
  }