gloomberb 0.9.0 → 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 (30) hide show
  1. package/package.json +6 -2
  2. package/patches/electrobun@1.18.1.patch +85 -0
  3. package/src/app/global-shortcuts.test.tsx +12 -0
  4. package/src/app/global-shortcuts.ts +1 -1
  5. package/src/assets/gloomberb-logo-windows.ico +0 -0
  6. package/src/assets/gloomberb-logo-windows.png +0 -0
  7. package/src/assets/gloomberb-logo.ico +0 -0
  8. package/src/components/layout/detached-pane-shell.tsx +8 -4
  9. package/src/components/layout/header.tsx +13 -6
  10. package/src/components/layout/titlebar-overlay.ts +16 -0
  11. package/src/components/layout/window-controls.tsx +90 -0
  12. package/src/renderers/electrobun/bun/application-menu/index.test.ts +12 -0
  13. package/src/renderers/electrobun/bun/application-menu/index.ts +4 -0
  14. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +43 -2
  15. package/src/renderers/electrobun/bun/desktop/host-requests.ts +31 -0
  16. package/src/renderers/electrobun/bun/desktop/window-controls.test.ts +53 -0
  17. package/src/renderers/electrobun/bun/desktop/window-controls.ts +64 -0
  18. package/src/renderers/electrobun/bun/desktop/window-style.ts +5 -0
  19. package/src/renderers/electrobun/bun/desktop/windows-icons.ts +123 -0
  20. package/src/renderers/electrobun/bun/index.ts +28 -6
  21. package/src/renderers/electrobun/bun/window/frame.test.ts +7 -0
  22. package/src/renderers/electrobun/bun/window/frame.ts +5 -0
  23. package/src/renderers/electrobun/view/build-assets.ts +2 -1
  24. package/src/renderers/electrobun/view/host/text.tsx +29 -9
  25. package/src/renderers/electrobun/view/styles.css +20 -0
  26. package/src/renderers/electrobun/view/ui-host.tsx +14 -0
  27. package/src/ui/host.tsx +2 -0
  28. package/src/updater.test.ts +47 -2
  29. package/src/updater.ts +67 -10
  30. package/src/version.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Market research and portfolio tracker for the terminal",
5
5
  "module": "src/index.tsx",
6
6
  "type": "module",
@@ -21,6 +21,7 @@
21
21
  "desktop:dev": "electrobun dev --watch",
22
22
  "desktop:build": "electrobun build",
23
23
  "desktop:release": "electrobun build --env=stable",
24
+ "desktop:release:windows": "electrobun build --env=stable --targets=win-x64",
24
25
  "test": "bun test",
25
26
  "release": "./scripts/release.sh"
26
27
  },
@@ -40,6 +41,8 @@
40
41
  "@types/bun": "^1.3.14",
41
42
  "@types/react-dom": "^19.2.3",
42
43
  "electrobun": "^1.18.1",
44
+ "png-to-ico": "^2.1.8",
45
+ "rcedit": "^4.0.1",
43
46
  "react-dom": "19.2.7"
44
47
  },
45
48
  "peerDependencies": {
@@ -59,6 +62,7 @@
59
62
  "web-tree-sitter": "0.25.10"
60
63
  },
61
64
  "patchedDependencies": {
62
- "@opentui/core@0.3.2": "patches/@opentui%2Fcore@0.3.2.patch"
65
+ "@opentui/core@0.3.2": "patches/@opentui%2Fcore@0.3.2.patch",
66
+ "electrobun@1.18.1": "patches/electrobun@1.18.1.patch"
63
67
  }
64
68
  }
@@ -0,0 +1,85 @@
1
+ diff --git a/dist/api/bun/core/BrowserWindow.ts b/dist/api/bun/core/BrowserWindow.ts
2
+ index c26c2c977ee1fc25a8dc144b85bac8626a45f572..8920a2905380228c23cba66c20d9bfd19ee13e02 100644
3
+ --- a/dist/api/bun/core/BrowserWindow.ts
4
+ +++ b/dist/api/bun/core/BrowserWindow.ts
5
+ @@ -8,9 +8,18 @@ import { type RPCWithTransport } from "../../shared/rpc.js";
6
+ import { getNextWindowId } from "./windowIds";
7
+ import { GpuWindowMap } from "./GpuWindow";
8
+ import { WGPUView } from "./WGPUView";
9
+ +import { existsSync } from "fs";
10
+ +import { resolve } from "path";
11
+
12
+ const buildConfig = await BuildConfig.get();
13
+
14
+ +function defaultWindowIconPath(): string | null {
15
+ + if (process.platform !== "win32") return null;
16
+ +
17
+ + const iconPath = resolve("../Resources/app.ico");
18
+ + return existsSync(iconPath) ? iconPath : null;
19
+ +}
20
+ +
21
+ export type WindowOptionsType<T = undefined> = {
22
+ trafficLightOffset?: {
23
+ x: number;
24
+ @@ -28,6 +37,7 @@ export type WindowOptionsType<T = undefined> = {
25
+ html: string | null;
26
+ preload: string | null;
27
+ viewsRoot: string | null;
28
+ + iconPath?: string | null;
29
+ renderer: "native" | "cef";
30
+ rpc?: T;
31
+ styleMask?: {};
32
+ @@ -124,6 +134,7 @@ export class BrowserWindow<T extends RPCWithTransport = RPCWithTransport> {
33
+ html: string | null = null;
34
+ preload: string | null = null;
35
+ viewsRoot: string | null = null;
36
+ + iconPath: string | null = null;
37
+ renderer: "native" | "cef" = "native";
38
+ transparent: boolean = false;
39
+ passthrough: boolean = false;
40
+ @@ -155,6 +166,7 @@ export class BrowserWindow<T extends RPCWithTransport = RPCWithTransport> {
41
+ this.html = options.html || null;
42
+ this.preload = options.preload || null;
43
+ this.viewsRoot = options.viewsRoot || null;
44
+ + this.iconPath = options.iconPath ?? defaultWindowIconPath();
45
+ this.renderer = options.renderer || defaultOptions.renderer;
46
+ this.transparent = options.transparent ?? false;
47
+ this.passthrough = options.passthrough ?? false;
48
+ @@ -181,6 +193,7 @@ export class BrowserWindow<T extends RPCWithTransport = RPCWithTransport> {
49
+ id: this.id,
50
+ title: this.title,
51
+ url: this.url || "",
52
+ + iconPath: this.iconPath,
53
+ frame: {
54
+ width: this.frame.width,
55
+ height: this.frame.height,
56
+ diff --git a/dist/api/bun/proc/native.ts b/dist/api/bun/proc/native.ts
57
+ index f0f6c2532ba2eb467ec1d452a25c835b6db72661..ae77cf58d32392ad4cc8e2103ac7ed27baae8bea 100644
58
+ --- a/dist/api/bun/proc/native.ts
59
+ +++ b/dist/api/bun/proc/native.ts
60
+ @@ -829,6 +829,7 @@ const _ffiImpl = {
61
+ id: number;
62
+ url: string | null;
63
+ title: string;
64
+ + iconPath?: string | null;
65
+ frame: {
66
+ width: number;
67
+ height: number;
68
+ @@ -862,6 +863,7 @@ const _ffiImpl = {
69
+ id,
70
+ url: _url,
71
+ title,
72
+ + iconPath,
73
+ frame: { x, y, width, height },
74
+ styleMask: {
75
+ Borderless,
76
+ @@ -926,6 +928,9 @@ const _ffiImpl = {
77
+ }
78
+
79
+ native_.symbols.setWindowTitle(windowPtr, toCString(title));
80
+ + if (iconPath) {
81
+ + native_.symbols.setWindowIcon(windowPtr, toCString(iconPath));
82
+ + }
83
+ if (!hidden) {
84
+ native_.symbols.showWindow(windowPtr, activate);
85
+ }
@@ -135,6 +135,18 @@ describe("useAppGlobalShortcuts", () => {
135
135
  expect(event.propagationStopped).toBe(true);
136
136
  });
137
137
 
138
+ test("toggles the command bar with Ctrl-K", async () => {
139
+ const actions: AppAction[] = [];
140
+ const state = createInitialState(createDefaultConfig("/tmp/gloomberb-global-shortcuts"));
141
+ await renderHarness(state, createRegistry(), (action) => actions.push(action));
142
+
143
+ const event = await emitKeypress({ name: "k", ctrl: true });
144
+
145
+ expect(actions).toEqual([{ type: "TOGGLE_COMMAND_BAR" }]);
146
+ expect(event.defaultPrevented).toBe(true);
147
+ expect(event.propagationStopped).toBe(true);
148
+ });
149
+
138
150
  test("opens ticker search with backtick", async () => {
139
151
  const actions: AppAction[] = [];
140
152
  const state = createInitialState(createDefaultConfig("/tmp/gloomberb-global-shortcuts"));
@@ -52,7 +52,7 @@ export function useAppGlobalShortcuts({
52
52
 
53
53
  if (!isDetachedWindow && (
54
54
  (event.name === "p" && event.ctrl)
55
- || (event.name === "k" && (event.meta || event.super))
55
+ || (event.name === "k" && (event.ctrl || event.meta || event.super))
56
56
  )) {
57
57
  event.preventDefault();
58
58
  event.stopPropagation();
Binary file
@@ -12,7 +12,8 @@ import { PaneBodyFrame, getPaneWindowAttributes } from "./pane/frame";
12
12
  import { PaneContent } from "./pane/content";
13
13
  import { resolvePaneBodyFrame } from "./pane/sizing";
14
14
  import { getPaneDisplayTitle } from "./pane/title";
15
- import { TITLEBAR_OVERLAY_HEIGHT_PX, TITLEBAR_TRAFFIC_LIGHT_WIDTH } from "./titlebar-overlay";
15
+ import { TITLEBAR_OVERLAY_HEIGHT_PX, getTitlebarLeadingInset } from "./titlebar-overlay";
16
+ import { WindowControls } from "./window-controls";
16
17
  import {
17
18
  createDoubleEscapeCloseState,
18
19
  recordDoubleEscapeClose,
@@ -41,7 +42,9 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
41
42
  typeof document === "undefined" ? true : document.hasFocus()
42
43
  ));
43
44
  const { width, height } = useViewport();
44
- const { cellHeightPx = 18, nativePaneChrome, titleBarOverlay } = useUiCapabilities();
45
+ const { cellHeightPx = 18, nativePaneChrome, titleBarOverlay, windowControls } = useUiCapabilities();
46
+ const showWindowControls = windowControls === "windows";
47
+ const titlebarLeadingInset = titleBarOverlay ? getTitlebarLeadingInset() : 0;
45
48
  const instance = useAppSelector((state) => findPaneInstance(state.config.layout, desktopWindowBridge.paneId) ?? null);
46
49
  const paneDef = instance ? pluginRegistry.panes.get(instance.paneId) ?? null : null;
47
50
  const hasPaneSettings = !!instance && pluginRegistry.hasPaneSettings(instance.instanceId);
@@ -171,8 +174,8 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
171
174
  alignItems="center"
172
175
  flexGrow={1}
173
176
  minWidth={0}
174
- paddingLeft={titleBarOverlay ? TITLEBAR_TRAFFIC_LIGHT_WIDTH : 1}
175
- paddingRight={1}
177
+ paddingLeft={titleBarOverlay ? titlebarLeadingInset : 1}
178
+ paddingRight={showWindowControls ? 0 : 1}
176
179
  >
177
180
  <Box flexGrow={1} minWidth={0} overflow="hidden">
178
181
  <Text fg={paneTitleText(focused, true)} selectable={false} data-gloom-role="pane-title">{title}</Text>
@@ -189,6 +192,7 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
189
192
  {" ... "}
190
193
  </Text>
191
194
  )}
195
+ {showWindowControls ? <WindowControls /> : null}
192
196
  </Box>
193
197
  </Box>
194
198
  <PaneBodyFrame layoutProps={bodyFrame.layoutProps} backgroundColor={background}>
@@ -17,7 +17,8 @@ import { formatPercentRaw } from "../../utils/format";
17
17
  import { formatMarketPrice } from "../../market-data/market/format";
18
18
  import { marketStateLabel, marketStateColor, getActiveQuoteDisplay } from "../../market-data/market/status";
19
19
  import { VERSION } from "../../version";
20
- import { TITLEBAR_TRAFFIC_LIGHT_WIDTH } from "./titlebar-overlay";
20
+ import { getTitlebarLeadingInset } from "./titlebar-overlay";
21
+ import { WindowControls } from "./window-controls";
21
22
 
22
23
  const SPY_REFRESH_MS = 5 * 60_000; // 5 min
23
24
  const UPDATE_NOTICE_DURATION_MS = 5_000;
@@ -125,7 +126,9 @@ export function Header() {
125
126
  useThemeColors();
126
127
  const baseCurrency = useAppSelector(selectBaseCurrency);
127
128
  const appActive = useAppActive();
128
- const { titleBarOverlay } = useUiCapabilities();
129
+ const { titleBarOverlay, windowControls } = useUiCapabilities();
130
+ const showWindowControls = windowControls === "windows";
131
+ const titlebarLeadingInset = titleBarOverlay ? getTitlebarLeadingInset() : 0;
129
132
  const spyQuoteEntry = useQuoteEntry("SPY", null);
130
133
  const spyQuote = useResolvedEntryValue(spyQuoteEntry);
131
134
 
@@ -165,10 +168,10 @@ export function Header() {
165
168
  style={{
166
169
  boxShadow: `0 -1px 0 ${colors.header}, inset 0 1px 0 ${colors.header}`,
167
170
  paddingLeft: 8,
168
- paddingRight: 12,
171
+ paddingRight: showWindowControls ? 0 : 12,
169
172
  }}
170
173
  >
171
- <Box paddingLeft={TITLEBAR_TRAFFIC_LIGHT_WIDTH} flexDirection="row" alignItems="center" gap={1}>
174
+ <Box paddingLeft={titlebarLeadingInset} flexDirection="row" alignItems="center" gap={1}>
172
175
  <Text attributes={TextAttributes.BOLD} fg={colors.headerText}>
173
176
  Gloomberb
174
177
  </Text>
@@ -195,7 +198,10 @@ export function Header() {
195
198
  <Box paddingRight={1}>
196
199
  <Text fg={spyColor}>{spyText}</Text>
197
200
  </Box>
198
- <Text fg={colors.headerText}>{baseCurrency}</Text>
201
+ <Box paddingRight={showWindowControls ? 1 : 0}>
202
+ <Text fg={colors.headerText}>{baseCurrency}</Text>
203
+ </Box>
204
+ {showWindowControls ? <WindowControls /> : null}
199
205
  </Box>
200
206
  );
201
207
  }
@@ -209,7 +215,7 @@ export function Header() {
209
215
  data-titlebar-overlay={titleBarOverlay ? "true" : undefined}
210
216
  className={titleBarOverlay ? "electrobun-webkit-app-region-drag" : undefined}
211
217
  >
212
- <Box paddingLeft={titleBarOverlay ? TITLEBAR_TRAFFIC_LIGHT_WIDTH : 1}>
218
+ <Box paddingLeft={titleBarOverlay ? titlebarLeadingInset : 1}>
213
219
  <Text attributes={TextAttributes.BOLD} fg={colors.headerText}>
214
220
  Gloomberb v{VERSION}
215
221
  </Text>
@@ -230,6 +236,7 @@ export function Header() {
230
236
  {baseCurrency}
231
237
  </Text>
232
238
  </Box>
239
+ {showWindowControls ? <WindowControls /> : null}
233
240
  </Box>
234
241
  );
235
242
  }
@@ -1,2 +1,18 @@
1
1
  export const TITLEBAR_TRAFFIC_LIGHT_WIDTH = 8;
2
2
  export const TITLEBAR_OVERLAY_HEIGHT_PX = 28;
3
+
4
+ function currentPlatform(): string {
5
+ const globalWithNavigator = globalThis as typeof globalThis & {
6
+ navigator?: {
7
+ platform?: string;
8
+ userAgentData?: { platform?: string };
9
+ };
10
+ };
11
+ return globalWithNavigator.navigator?.userAgentData?.platform
12
+ ?? globalWithNavigator.navigator?.platform
13
+ ?? "";
14
+ }
15
+
16
+ export function getTitlebarLeadingInset(platform = currentPlatform()): number {
17
+ return /mac/i.test(platform) ? TITLEBAR_TRAFFIC_LIGHT_WIDTH : 0;
18
+ }
@@ -0,0 +1,90 @@
1
+ import { Box, useRendererHost } from "../../ui";
2
+ import { useCallback } from "react";
3
+
4
+ const WINDOWS_CONTROL_WIDTH_PX = 30;
5
+ const WINDOWS_CONTROL_TRAILING_PADDING_PX = 10;
6
+
7
+ type WindowControlAction = "minimize" | "toggle-maximize" | "close";
8
+
9
+ const WINDOWS_CONTROLS: Array<{
10
+ action: WindowControlAction;
11
+ label: string;
12
+ }> = [
13
+ { action: "minimize", label: "Minimize" },
14
+ { action: "toggle-maximize", label: "Maximize" },
15
+ { action: "close", label: "Close" },
16
+ ];
17
+
18
+ function stopMouse(event: { stopPropagation?: () => void; preventDefault?: () => void }) {
19
+ event.stopPropagation?.();
20
+ event.preventDefault?.();
21
+ }
22
+
23
+ function WindowControlIcon({ action }: { action: WindowControlAction }) {
24
+ if (action === "minimize") {
25
+ return (
26
+ <svg viewBox="0 0 12 12" width="10" height="10" fill="none" aria-hidden="true">
27
+ <path d="M2.5 6.5H9.5" stroke="currentColor" strokeWidth="1.25" strokeLinecap="square" />
28
+ </svg>
29
+ );
30
+ }
31
+
32
+ if (action === "toggle-maximize") {
33
+ return (
34
+ <svg viewBox="0 0 12 12" width="10" height="10" fill="none" aria-hidden="true">
35
+ <rect x="3" y="3" width="6" height="6" stroke="currentColor" strokeWidth="1.25" />
36
+ </svg>
37
+ );
38
+ }
39
+
40
+ return (
41
+ <svg viewBox="0 0 12 12" width="10" height="10" fill="none" aria-hidden="true">
42
+ <path d="M3 3L9 9M9 3L3 9" stroke="currentColor" strokeWidth="1.35" strokeLinecap="round" />
43
+ </svg>
44
+ );
45
+ }
46
+
47
+ export function WindowControls() {
48
+ const rendererHost = useRendererHost();
49
+
50
+ const controlWindow = useCallback((action: WindowControlAction, event: { stopPropagation?: () => void; preventDefault?: () => void }) => {
51
+ stopMouse(event);
52
+ void rendererHost.controlWindow?.(action);
53
+ }, [rendererHost]);
54
+
55
+ return (
56
+ <Box
57
+ flexDirection="row"
58
+ alignItems="stretch"
59
+ flexShrink={0}
60
+ className="electrobun-webkit-app-region-no-drag"
61
+ data-gloom-role="window-controls"
62
+ aria-hidden={false}
63
+ style={{
64
+ paddingRight: WINDOWS_CONTROL_TRAILING_PADDING_PX,
65
+ }}
66
+ >
67
+ {WINDOWS_CONTROLS.map((control) => (
68
+ <Box
69
+ key={control.action}
70
+ alignItems="center"
71
+ justifyContent="center"
72
+ data-gloom-role="window-control"
73
+ data-window-control-action={control.action}
74
+ data-gloom-interactive="true"
75
+ role="button"
76
+ aria-label={control.label}
77
+ title={control.label}
78
+ className="electrobun-webkit-app-region-no-drag"
79
+ onMouseDown={(event: { stopPropagation?: () => void; preventDefault?: () => void }) => controlWindow(control.action, event)}
80
+ style={{
81
+ width: WINDOWS_CONTROL_WIDTH_PX,
82
+ height: "100%",
83
+ }}
84
+ >
85
+ <WindowControlIcon action={control.action} />
86
+ </Box>
87
+ ))}
88
+ </Box>
89
+ );
90
+ }
@@ -0,0 +1,12 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildApplicationMenu, buildDesktopApplicationMenu } from "./index";
3
+
4
+ describe("desktop application menu", () => {
5
+ test("hides the application menu on Windows to preserve vertical space", () => {
6
+ expect(buildDesktopApplicationMenu("win32")).toEqual([]);
7
+ });
8
+
9
+ test("keeps the native application menu on macOS", () => {
10
+ expect(buildDesktopApplicationMenu("darwin")).toEqual(buildApplicationMenu());
11
+ });
12
+ });
@@ -121,3 +121,7 @@ export function buildApplicationMenu(): ApplicationMenuItemConfig[] {
121
121
  },
122
122
  ];
123
123
  }
124
+
125
+ export function buildDesktopApplicationMenu(platform = process.platform): ApplicationMenuItemConfig[] {
126
+ return platform === "win32" ? [] : buildApplicationMenu();
127
+ }
@@ -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,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
+ }