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.
- package/package.json +6 -2
- package/patches/electrobun@1.18.1.patch +85 -0
- package/src/app/global-shortcuts.test.tsx +12 -0
- package/src/app/global-shortcuts.ts +1 -1
- package/src/assets/gloomberb-logo-windows.ico +0 -0
- package/src/assets/gloomberb-logo-windows.png +0 -0
- package/src/assets/gloomberb-logo.ico +0 -0
- package/src/components/layout/detached-pane-shell.tsx +8 -4
- package/src/components/layout/header.tsx +13 -6
- package/src/components/layout/titlebar-overlay.ts +16 -0
- package/src/components/layout/window-controls.tsx +90 -0
- package/src/renderers/electrobun/bun/application-menu/index.test.ts +12 -0
- package/src/renderers/electrobun/bun/application-menu/index.ts +4 -0
- package/src/renderers/electrobun/bun/desktop/detached-windows.ts +43 -2
- package/src/renderers/electrobun/bun/desktop/host-requests.ts +31 -0
- package/src/renderers/electrobun/bun/desktop/window-controls.test.ts +53 -0
- package/src/renderers/electrobun/bun/desktop/window-controls.ts +64 -0
- package/src/renderers/electrobun/bun/desktop/window-style.ts +5 -0
- package/src/renderers/electrobun/bun/desktop/windows-icons.ts +123 -0
- package/src/renderers/electrobun/bun/index.ts +28 -6
- package/src/renderers/electrobun/bun/window/frame.test.ts +7 -0
- package/src/renderers/electrobun/bun/window/frame.ts +5 -0
- package/src/renderers/electrobun/view/build-assets.ts +2 -1
- package/src/renderers/electrobun/view/host/text.tsx +29 -9
- package/src/renderers/electrobun/view/styles.css +20 -0
- package/src/renderers/electrobun/view/ui-host.tsx +14 -0
- package/src/ui/host.tsx +2 -0
- package/src/updater.test.ts +47 -2
- package/src/updater.ts +67 -10
- package/src/version.ts +1 -1
|
@@ -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 {
|
|
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(
|
|
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:
|
|
459
|
+
frame: initialMainWindowFrame,
|
|
439
460
|
url: "views://mainview/index.html",
|
|
440
461
|
renderer: "native",
|
|
441
462
|
rpc: mainRpc,
|
|
442
|
-
titleBarStyle:
|
|
463
|
+
titleBarStyle: desktopTitleBarStyle(),
|
|
443
464
|
navigationRules: JSON.stringify(["views://*"]),
|
|
444
465
|
sandbox: false,
|
|
445
466
|
});
|
|
446
|
-
|
|
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] &&
|
|
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
|
|
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: "
|
|
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
|
-
|
|
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.
|
|
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>;
|
package/src/updater.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1
|
+
export const VERSION = "0.9.1";
|