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
@@ -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
  }
@@ -8,9 +8,20 @@ import {
8
8
  type TextProps,
9
9
  } from "../../../../ui/host";
10
10
  import { renderAsciiText } from "../../../../ui/ascii-font";
11
+ import { WEB_CELL_WIDTH } from "../input-host";
11
12
  import { hasDirectMouseHandler, mouseHandlers } from "./mouse";
12
13
  import { cleanDomProps, commonStyle, textStyle } from "./style";
13
14
 
15
+ const WEB_GLOOMBERB_WORDMARK = [
16
+ " ____ _ _ _ ",
17
+ " / ___| | ___ ___ _ __ ___ | |__ ___ _ __| |__ ",
18
+ "| | _| |/ _ \\ / _ \\| '_ ` _ \\| '_ \\ / _ \\ '__| '_ \\ ",
19
+ "| |_| | | (_) | (_) | | | | | | |_) | __/ | | |_) |",
20
+ " \\____|_|\\___/ \\___/|_| |_| |_|_.__/ \\___|_| |_.__/ ",
21
+ ];
22
+
23
+ const WEB_WORDMARK_CHAR_WIDTH_PX = Math.max(10, WEB_CELL_WIDTH);
24
+
14
25
  function isStyledTextContent(value: unknown): value is { chunks: StyledTextChunk[] } {
15
26
  return value instanceof StyledText
16
27
  || (!!value && typeof value === "object" && Array.isArray((value as { chunks?: unknown }).chunks));
@@ -91,31 +102,40 @@ export function WebAsciiText({
91
102
  selectable = false,
92
103
  ...props
93
104
  }: AsciiTextProps) {
94
- const lines = renderAsciiText(text, font);
105
+ const isWordmark = font === "wordmark";
106
+ const lines = isWordmark && text.trim().toLowerCase() === "gloomberb"
107
+ ? WEB_GLOOMBERB_WORDMARK
108
+ : renderAsciiText(text, font);
95
109
  const resolvedColor = color ?? fg;
96
110
  const resolvedBackground = bg ?? backgroundColor;
111
+ const lineHeightPx = isWordmark ? 16 : 12;
112
+ const wordmarkWidthPx = isWordmark
113
+ ? Math.max(...lines.map((line) => line.length)) * WEB_WORDMARK_CHAR_WIDTH_PX
114
+ : undefined;
97
115
  return (
98
116
  <div
99
117
  {...cleanDomProps(props)}
100
118
  data-gloom-role={(props["data-gloom-role"] as string | undefined) ?? "ascii-text"}
101
119
  style={{
102
120
  ...commonStyle({ ...props, fg: resolvedColor, bg: resolvedBackground }),
103
- display: "flex",
104
- flexDirection: "column",
121
+ display: "block",
105
122
  flexShrink: 0,
123
+ ...(wordmarkWidthPx != null && props.width == null
124
+ ? { width: `${wordmarkWidthPx}px`, minWidth: `${wordmarkWidthPx}px` }
125
+ : {}),
106
126
  color: resolvedColor,
107
127
  backgroundColor: resolvedBackground,
108
- lineHeight: "12px",
128
+ fontFamily: isWordmark ? "\"Cascadia Mono\", Consolas, \"Courier New\", monospace" : undefined,
129
+ fontSize: isWordmark ? "16px" : undefined,
130
+ fontVariantLigatures: "none",
131
+ lineHeight: `${lineHeightPx}px`,
109
132
  whiteSpace: "pre",
133
+ letterSpacing: 0,
110
134
  userSelect: selectable ? "text" : "none",
111
135
  ...(props.style as CSSProperties | undefined),
112
136
  }}
113
137
  >
114
- {lines.map((line, index) => (
115
- <span key={index} style={{ display: "block", height: "12px", lineHeight: "12px" }}>
116
- {line}
117
- </span>
118
- ))}
138
+ {lines.join("\n")}
119
139
  </div>
120
140
  );
121
141
  }
@@ -58,6 +58,26 @@ button, [data-gloom-interactive="true"] { cursor: pointer; }
58
58
  -webkit-app-region: no-drag;
59
59
  app-region: no-drag;
60
60
  }
61
+ [data-gloom-role="window-controls"] {
62
+ height: 100%;
63
+ color: var(--gloom-header-text, #f2f2f2);
64
+ }
65
+ [data-gloom-role="window-control"] {
66
+ color: color-mix(in srgb, var(--gloom-header-text, #f2f2f2) 86%, transparent);
67
+ transition: background-color 90ms linear, color 90ms linear;
68
+ }
69
+ [data-gloom-role="window-control"] svg {
70
+ display: block;
71
+ pointer-events: none;
72
+ }
73
+ [data-gloom-role="window-control"]:hover {
74
+ background: color-mix(in srgb, var(--gloom-header-text, #f2f2f2) 13%, transparent);
75
+ color: var(--gloom-header-text, #f2f2f2);
76
+ }
77
+ [data-gloom-role="window-control"][data-window-control-action="close"]:hover {
78
+ background: #c42b1c;
79
+ color: #ffffff;
80
+ }
61
81
  [data-gloom-role="pane-window"][data-floating="true"] {
62
82
  border: 0;
63
83
  border-radius: 6px;
@@ -27,6 +27,16 @@ import { cleanDomProps, commonStyle } from "./host/style";
27
27
  import { WebAsciiText, WebSpan, WebStrong, WebText, WebUnderline } from "./host/text";
28
28
  import { WebTabs } from "./host/tabs";
29
29
 
30
+ function currentDesktopPlatform(): string {
31
+ const navigatorWithUserAgentData = navigator as Navigator & {
32
+ userAgentData?: { platform?: string };
33
+ };
34
+ return navigatorWithUserAgentData.userAgentData?.platform ?? navigator.platform ?? "";
35
+ }
36
+
37
+ const DESKTOP_PLATFORM = currentDesktopPlatform();
38
+ const USES_WINDOWS_CONTROLS = /win/i.test(DESKTOP_PLATFORM);
39
+
30
40
  export const webUiHost: UiHost = {
31
41
  kind: "desktop-web",
32
42
  capabilities: {
@@ -39,6 +49,7 @@ export const webUiHost: UiHost = {
39
49
  pixelRatio: Math.min(window.devicePixelRatio || 1, 2),
40
50
  canvasCharts: true,
41
51
  nativeContextMenu: NATIVE_CONTEXT_MENU_SUPPORTED,
52
+ windowControls: USES_WINDOWS_CONTROLS ? "windows" : undefined,
42
53
  },
43
54
  Box: WebBox,
44
55
  Text: WebText,
@@ -115,6 +126,9 @@ export const webRendererHost: RendererHost = {
115
126
  startWindowDrag() {
116
127
  startElectrobunWindowDrag();
117
128
  },
129
+ async controlWindow(action) {
130
+ await backendRequest("host.windowControl", { action });
131
+ },
118
132
  async openExternal(url) {
119
133
  await backendRequest("host.openExternal", { url });
120
134
  },
package/src/ui/host.tsx CHANGED
@@ -260,6 +260,7 @@ export interface UiHost {
260
260
  canvasCharts?: boolean;
261
261
  nativeCharts?: boolean;
262
262
  nativeContextMenu?: boolean;
263
+ windowControls?: "windows";
263
264
  };
264
265
  Box: ComponentType<BoxProps>;
265
266
  Text: ComponentType<TextProps>;
@@ -289,6 +290,7 @@ export interface UiHost {
289
290
  export interface RendererHost {
290
291
  requestExit(): void;
291
292
  startWindowDrag?(): Promise<void> | void;
293
+ controlWindow?(action: "minimize" | "toggle-maximize" | "close"): Promise<void> | void;
292
294
  openExternal(url: string): Promise<void>;
293
295
  copyText(text: string): Promise<void>;
294
296
  copyPngImage?(pngBase64: string): Promise<void>;
@@ -8,6 +8,7 @@ import {
8
8
  checkForUpdate,
9
9
  checkForUpdateDetailed,
10
10
  detectUpdateAction,
11
+ getAssetBaseNameForRuntime,
11
12
  performUpdate,
12
13
  resolveSelfUpdateTargetPath,
13
14
  setUpdateHost,
@@ -23,11 +24,21 @@ afterEach(() => {
23
24
  });
24
25
 
25
26
  function expectedAssetName(compressed = false): string {
26
- const os = process.platform === "darwin" ? "darwin" : "linux";
27
+ const os = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "windows" : "linux";
27
28
  const arch = os === "darwin" || process.arch === "arm64" ? "arm64" : "x64";
28
- return compressed ? `gloomberb-${os}-${arch}.gz` : `gloomberb-${os}-${arch}`;
29
+ const extension = os === "windows" ? ".exe" : "";
30
+ return compressed ? `gloomberb-${os}-${arch}${extension}.gz` : `gloomberb-${os}-${arch}${extension}`;
29
31
  }
30
32
 
33
+ describe("getAssetBaseNameForRuntime", () => {
34
+ test("uses the Windows executable release asset name on win32", () => {
35
+ expect(getAssetBaseNameForRuntime({
36
+ platform: "win32",
37
+ arch: "x64",
38
+ })).toBe("gloomberb-windows-x64.exe");
39
+ });
40
+ });
41
+
31
42
  describe("detectUpdateAction", () => {
32
43
  test("uses self-update for standalone binaries", () => {
33
44
  expect(detectUpdateAction(
@@ -46,6 +57,16 @@ describe("detectUpdateAction", () => {
46
57
  });
47
58
  });
48
59
 
60
+ test("uses manual bun updates for Windows bun-managed installs", () => {
61
+ expect(detectUpdateAction(
62
+ "C:\\Program Files\\Bun\\bun.exe",
63
+ ["C:\\Program Files\\Bun\\bun.exe", "C:\\Users\\vince\\.bun\\install\\global\\node_modules\\gloomberb\\bin\\gloomberb"],
64
+ )).toEqual({
65
+ kind: "manual",
66
+ command: "bun install -g gloomberb@latest",
67
+ });
68
+ });
69
+
49
70
  test("uses manual npm updates for node-managed installs", () => {
50
71
  expect(detectUpdateAction(
51
72
  "/opt/homebrew/bin/node",
@@ -56,6 +77,13 @@ describe("detectUpdateAction", () => {
56
77
  });
57
78
  });
58
79
 
80
+ test("does not offer self-update for standalone Windows executables", () => {
81
+ expect(detectUpdateAction(
82
+ "C:\\Users\\vince\\Downloads\\gloomberb.exe",
83
+ ["C:\\Users\\vince\\Downloads\\gloomberb.exe"],
84
+ )).toBeNull();
85
+ });
86
+
59
87
  test("skips updates when launched from source under bun", () => {
60
88
  expect(detectUpdateAction(
61
89
  "/opt/homebrew/bin/bun",
@@ -76,6 +104,16 @@ describe("detectUpdateAction", () => {
76
104
  ["/Applications/Gloomberb.app/Contents/MacOS/bun", "/Applications/Gloomberb.app/Contents/Resources/gloomberb-tui/tui-entry.js"],
77
105
  )).toBeNull();
78
106
  });
107
+
108
+ test("does not suggest Bun-managed updates for the bundled Windows app TUI runtime", () => {
109
+ expect(detectUpdateAction(
110
+ "C:\\Users\\vince\\AppData\\Local\\Programs\\Gloomberb\\bin\\bun.exe",
111
+ [
112
+ "C:\\Users\\vince\\AppData\\Local\\Programs\\Gloomberb\\bin\\bun.exe",
113
+ "C:\\Users\\vince\\AppData\\Local\\Programs\\Gloomberb\\Resources\\gloomberb-tui\\tui-entry.js",
114
+ ],
115
+ )).toBeNull();
116
+ });
79
117
  });
80
118
 
81
119
  describe("resolveSelfUpdateTargetPath", () => {
@@ -93,6 +131,13 @@ describe("resolveSelfUpdateTargetPath", () => {
93
131
  )).toBeNull();
94
132
  });
95
133
 
134
+ it("rejects Windows Bun runtime paths", () => {
135
+ expect(resolveSelfUpdateTargetPath(
136
+ "C:\\Program Files\\Bun\\bun.exe",
137
+ ["C:\\Program Files\\Bun\\bun.exe", "dist\\index.js"],
138
+ )).toBeNull();
139
+ });
140
+
96
141
  it("accepts packaged gloomberb binaries", () => {
97
142
  expect(resolveSelfUpdateTargetPath(
98
143
  "/Applications/gloomberb",
package/src/updater.ts CHANGED
@@ -52,12 +52,22 @@ function compareSemver(a: string, b: string): number {
52
52
  return 0;
53
53
  }
54
54
 
55
- function getAssetBaseName(): string {
56
- const runtimeProcess = getRuntimeProcess();
57
- const os = runtimeProcess?.platform === "darwin" ? "darwin" : "linux";
55
+ export function getAssetBaseNameForRuntime(
56
+ runtimeProcess: Pick<NodeJS.Process, "platform" | "arch"> | null = getRuntimeProcess(),
57
+ ): string {
58
+ const os = runtimeProcess?.platform === "darwin"
59
+ ? "darwin"
60
+ : runtimeProcess?.platform === "win32"
61
+ ? "windows"
62
+ : "linux";
58
63
  // macOS x64 uses arm64 binary (runs via Rosetta 2)
59
64
  const arch = os === "darwin" || runtimeProcess?.arch === "arm64" ? "arm64" : "x64";
60
- return `gloomberb-${os}-${arch}`;
65
+ const extension = os === "windows" ? ".exe" : "";
66
+ return `gloomberb-${os}-${arch}${extension}`;
67
+ }
68
+
69
+ function getAssetBaseName(): string {
70
+ return getAssetBaseNameForRuntime();
61
71
  }
62
72
 
63
73
  function resolveReleaseAsset(
@@ -90,6 +100,29 @@ function basename(value: string): string {
90
100
  return normalized.slice(normalized.lastIndexOf("/") + 1);
91
101
  }
92
102
 
103
+ const runtimeExecutables = new Set([
104
+ "bun",
105
+ "bun.exe",
106
+ "bunx",
107
+ "bunx.exe",
108
+ "node",
109
+ "node.exe",
110
+ "nodejs",
111
+ "nodejs.exe",
112
+ "npm",
113
+ "npm.cmd",
114
+ "npm.exe",
115
+ "npx",
116
+ "npx.cmd",
117
+ "npx.exe",
118
+ "pnpm",
119
+ "pnpm.cmd",
120
+ "pnpm.exe",
121
+ "yarn",
122
+ "yarn.cmd",
123
+ "yarn.exe",
124
+ ]);
125
+
93
126
  function resolveEntrypointPath(argv = process.argv): string {
94
127
  return tryRealpath(argv[1] ?? "");
95
128
  }
@@ -104,6 +137,15 @@ function isMacAppBundleExecutable(execPath: string): boolean {
104
137
  return normalizePath(execPath).includes(".app/contents/macos/");
105
138
  }
106
139
 
140
+ function isBundledDesktopTuiRuntime(execPath: string, argv: string[]): boolean {
141
+ const normalizedExecPath = normalizePath(tryRealpath(execPath));
142
+ const entrypoint = normalizePath(resolveEntrypointPath(argv));
143
+ if (!entrypoint.endsWith("/resources/gloomberb-tui/tui-entry.js")) return false;
144
+ return normalizedExecPath.endsWith("/contents/macos/bun")
145
+ || normalizedExecPath.endsWith("/bin/bun")
146
+ || normalizedExecPath.endsWith("/bin/bun.exe");
147
+ }
148
+
107
149
  export function resolveSelfUpdateTargetPath(
108
150
  execPath = getRuntimeProcess()?.execPath ?? "",
109
151
  argv = getRuntimeProcess()?.argv ?? [],
@@ -113,7 +155,6 @@ export function resolveSelfUpdateTargetPath(
113
155
  if (isMacAppBundleExecutable(resolvedExecPath)) return null;
114
156
 
115
157
  const execBase = basename(normalizedExecPath);
116
- const runtimeExecutables = new Set(["bun", "bunx", "node", "nodejs", "npm", "npx", "pnpm", "yarn"]);
117
158
  if (runtimeExecutables.has(execBase)) return null;
118
159
  if (normalizedExecPath.includes("/.bun/bin/")) return null;
119
160
 
@@ -128,20 +169,24 @@ export function detectUpdateAction(
128
169
  argv = getRuntimeProcess()?.argv ?? [],
129
170
  ): UpdateAction | null {
130
171
  if (isMacAppBundleExecutable(execPath)) return null;
131
-
132
- if (resolveSelfUpdateTargetPath(execPath, argv)) {
133
- return { kind: "self" };
134
- }
172
+ if (isBundledDesktopTuiRuntime(execPath, argv)) return null;
135
173
 
136
174
  const normalizedExecPath = normalizePath(tryRealpath(execPath));
137
175
  const execBase = basename(normalizedExecPath);
138
176
  const entrypoint = normalizePath(resolveEntrypointPath(argv));
177
+ if (execBase === "gloomberb.exe") return null;
178
+
179
+ if (resolveSelfUpdateTargetPath(execPath, argv)) {
180
+ return { kind: "self" };
181
+ }
139
182
 
140
183
  if (!entrypoint || isSourceEntrypoint(entrypoint)) return null;
141
184
 
142
185
  if (
143
186
  execBase === "bun"
187
+ || execBase === "bun.exe"
144
188
  || execBase === "bunx"
189
+ || execBase === "bunx.exe"
145
190
  || normalizedExecPath.includes("/.bun/bin/")
146
191
  || entrypoint.includes("/.bun/install/")
147
192
  || entrypoint.includes("/install/global/")
@@ -151,11 +196,21 @@ export function detectUpdateAction(
151
196
 
152
197
  if (
153
198
  execBase === "node"
199
+ || execBase === "node.exe"
154
200
  || execBase === "nodejs"
201
+ || execBase === "nodejs.exe"
155
202
  || execBase === "npm"
203
+ || execBase === "npm.cmd"
204
+ || execBase === "npm.exe"
156
205
  || execBase === "npx"
206
+ || execBase === "npx.cmd"
207
+ || execBase === "npx.exe"
157
208
  || execBase === "pnpm"
209
+ || execBase === "pnpm.cmd"
210
+ || execBase === "pnpm.exe"
158
211
  || execBase === "yarn"
212
+ || execBase === "yarn.cmd"
213
+ || execBase === "yarn.exe"
159
214
  || entrypoint.includes("/lib/node_modules/")
160
215
  || entrypoint.includes("/node_modules/")
161
216
  ) {
@@ -283,6 +338,7 @@ export async function performUpdate(
283
338
 
284
339
  const updatePath = execPath + ".update";
285
340
  const oldPath = execPath + ".old";
341
+ let unlinkUpdatePath: ((path: string) => void) | null = null;
286
342
 
287
343
  try {
288
344
  const fsModulePath = "fs";
@@ -293,6 +349,7 @@ export async function performUpdate(
293
349
  unlinkSync,
294
350
  chmodSync,
295
351
  } = await import(fsModulePath) as typeof import("fs");
352
+ unlinkUpdatePath = unlinkSync;
296
353
  const { gunzipSync } = await import(zlibModulePath) as typeof import("zlib");
297
354
 
298
355
  onProgress({ phase: "downloading", percent: 0 });
@@ -343,7 +400,7 @@ export async function performUpdate(
343
400
  } catch (err: unknown) {
344
401
  // Clean up temp file on failure
345
402
  try {
346
- unlinkSync(updatePath);
403
+ unlinkUpdatePath?.(updatePath);
347
404
  } catch {}
348
405
  onProgress({
349
406
  phase: "error",
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.9.0";
1
+ export const VERSION = "0.9.1";