gloomberb 0.9.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/chart/comparison-stock-chart/helpers.ts +0 -32
- package/src/components/chart/comparison-stock-chart/layout-metrics.ts +6 -6
- package/src/components/chart/comparison-stock-chart/legend.tsx +41 -44
- package/src/components/chart/comparison-stock-chart/view.tsx +46 -6
- package/src/components/layout/detached-pane-shell.tsx +11 -4
- package/src/components/layout/header.tsx +25 -10
- package/src/components/layout/titlebar-overlay.ts +16 -0
- package/src/components/layout/window-controls.tsx +99 -0
- package/src/components/price-performance.tsx +175 -0
- package/src/market-data/performance.test.ts +54 -0
- package/src/market-data/performance.ts +109 -0
- package/src/plugins/builtin/comparison-chart/index.test.tsx +88 -9
- package/src/plugins/builtin/congress-trades/detail.tsx +204 -68
- package/src/plugins/builtin/congress-trades/footer.ts +14 -8
- package/src/plugins/builtin/congress-trades/keyboard.ts +4 -23
- package/src/plugins/builtin/congress-trades/model.ts +29 -0
- package/src/plugins/builtin/congress-trades/pane.tsx +8 -13
- package/src/plugins/builtin/congress-trades/table.tsx +10 -1
- package/src/plugins/builtin/ticker-detail/overview/model.ts +0 -14
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -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 +51 -3
- package/src/renderers/electrobun/bun/desktop/host-requests.ts +31 -0
- package/src/renderers/electrobun/bun/desktop/initialization.ts +1 -0
- package/src/renderers/electrobun/bun/desktop/window-controls.test.ts +53 -0
- package/src/renderers/electrobun/bun/desktop/window-controls.ts +73 -0
- package/src/renderers/electrobun/bun/desktop/window-style.ts +31 -0
- package/src/renderers/electrobun/bun/desktop/windows-custom-chrome.ts +58 -0
- package/src/renderers/electrobun/bun/desktop/windows-icons.ts +70 -0
- package/src/renderers/electrobun/bun/desktop/windows-native.ts +75 -0
- package/src/renderers/electrobun/bun/index.ts +36 -7
- 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/shared/protocol.ts +1 -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/main.tsx +2 -1
- package/src/renderers/electrobun/view/styles.css +29 -8
- package/src/renderers/electrobun/view/ui-host.tsx +113 -74
- 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
|
@@ -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,16 @@ import {
|
|
|
22
23
|
type WindowResizeEvent,
|
|
23
24
|
} from "./window-events";
|
|
24
25
|
import type { DesktopStateBroadcaster, DesktopStateRpc } from "./state-broadcaster";
|
|
26
|
+
import { applyWindowsCustomChrome } from "./windows-custom-chrome";
|
|
27
|
+
import { applyWindowsWindowIcon } from "./windows-icons";
|
|
28
|
+
import {
|
|
29
|
+
desktopTitleBarStyle,
|
|
30
|
+
desktopWindowRenderer,
|
|
31
|
+
desktopWindowStyleMask,
|
|
32
|
+
} from "./window-style";
|
|
33
|
+
|
|
34
|
+
const INITIAL_DOCK_SUPPRESSION_MS = 800;
|
|
35
|
+
const WINDOW_CONTROL_DOCK_SUPPRESSION_MS = 5_000;
|
|
25
36
|
|
|
26
37
|
interface DesktopDetachedWindowManagerOptions<Rpc extends DesktopStateRpc> {
|
|
27
38
|
createRpc: (key: string) => Rpc;
|
|
@@ -45,6 +56,8 @@ export class DesktopDetachedWindowManager<Rpc extends DesktopStateRpc> {
|
|
|
45
56
|
private readonly dockTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
46
57
|
private readonly closingPanes = new Set<string>();
|
|
47
58
|
private readonly pendingMoveFlush = new Set<string>();
|
|
59
|
+
private readonly suppressDockUntil = new Map<string, number>();
|
|
60
|
+
private readonly windowControlDockSuppressed = new Set<string>();
|
|
48
61
|
|
|
49
62
|
constructor(private readonly options: DesktopDetachedWindowManagerOptions<Rpc>) {}
|
|
50
63
|
|
|
@@ -56,6 +69,19 @@ export class DesktopDetachedWindowManager<Rpc extends DesktopStateRpc> {
|
|
|
56
69
|
return focusWindowForRpcKey(rpcKey, this.options.getMainWindow(), this.windows);
|
|
57
70
|
}
|
|
58
71
|
|
|
72
|
+
getWindowForRpcKey(rpcKey: string | undefined): BrowserWindow | null {
|
|
73
|
+
const paneId = paneIdFromDetachedRpcKey(rpcKey);
|
|
74
|
+
return paneId ? this.windows.get(paneId) ?? null : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
suppressAutoDockForRpcKey(rpcKey: string | undefined): void {
|
|
78
|
+
const paneId = paneIdFromDetachedRpcKey(rpcKey);
|
|
79
|
+
if (!paneId || !this.windows.has(paneId)) return;
|
|
80
|
+
this.windowControlDockSuppressed.add(paneId);
|
|
81
|
+
this.suppressDockUntil.set(paneId, Date.now() + WINDOW_CONTROL_DOCK_SUPPRESSION_MS);
|
|
82
|
+
this.options.stateBroadcaster.clearDockPreview(paneId);
|
|
83
|
+
}
|
|
84
|
+
|
|
59
85
|
resolveFrame(instanceId: string): WindowFrame {
|
|
60
86
|
const detachedEntry = this.options.getConfig().layout.detached.find((entry) => entry.instanceId === instanceId);
|
|
61
87
|
if (detachedEntry) {
|
|
@@ -151,6 +177,8 @@ export class DesktopDetachedWindowManager<Rpc extends DesktopStateRpc> {
|
|
|
151
177
|
this.clearTimer(this.frameTimers, instanceId);
|
|
152
178
|
this.clearTimer(this.dockTimers, instanceId);
|
|
153
179
|
this.pendingMoveFlush.delete(instanceId);
|
|
180
|
+
this.suppressDockUntil.delete(instanceId);
|
|
181
|
+
this.windowControlDockSuppressed.delete(instanceId);
|
|
154
182
|
this.options.unregisterWindowRpc(detachedRpcKey(instanceId));
|
|
155
183
|
}
|
|
156
184
|
|
|
@@ -160,18 +188,23 @@ export class DesktopDetachedWindowManager<Rpc extends DesktopStateRpc> {
|
|
|
160
188
|
): BrowserWindow {
|
|
161
189
|
const rpc = this.options.createRpc(detachedRpcKey(instanceId));
|
|
162
190
|
const initialFrame = normalizeWindowFrameWithMinimum(frame, DEFAULT_WINDOW_FRAME, DETACHED_WINDOW_MIN_SIZE);
|
|
191
|
+
const title = this.resolveTitle(instanceId);
|
|
163
192
|
const window = new BrowserWindow({
|
|
164
|
-
title
|
|
193
|
+
title,
|
|
165
194
|
frame: initialFrame,
|
|
166
195
|
url: "views://mainview/index.html",
|
|
167
|
-
renderer:
|
|
196
|
+
renderer: desktopWindowRenderer(),
|
|
168
197
|
rpc: rpc as never,
|
|
169
|
-
|
|
198
|
+
styleMask: desktopWindowStyleMask(),
|
|
199
|
+
titleBarStyle: desktopTitleBarStyle(),
|
|
170
200
|
navigationRules: JSON.stringify(["views://*"]),
|
|
171
201
|
sandbox: false,
|
|
172
202
|
});
|
|
203
|
+
applyWindowsWindowIcon(title);
|
|
204
|
+
applyWindowsCustomChrome(title);
|
|
173
205
|
updateWindowFrameCache(window, initialFrame, DETACHED_WINDOW_MIN_SIZE);
|
|
174
206
|
this.windows.set(instanceId, window);
|
|
207
|
+
this.suppressDockUntil.set(instanceId, Date.now() + INITIAL_DOCK_SUPPRESSION_MS);
|
|
175
208
|
|
|
176
209
|
(window as any).on?.("close", () => {
|
|
177
210
|
const shouldIgnore = this.closingPanes.delete(instanceId);
|
|
@@ -230,6 +263,21 @@ export class DesktopDetachedWindowManager<Rpc extends DesktopStateRpc> {
|
|
|
230
263
|
}, 120));
|
|
231
264
|
|
|
232
265
|
this.clearTimer(this.dockTimers, instanceId);
|
|
266
|
+
if (this.windowControlDockSuppressed.has(instanceId)) {
|
|
267
|
+
this.options.stateBroadcaster.clearDockPreview(instanceId);
|
|
268
|
+
if (!edge) {
|
|
269
|
+
this.windowControlDockSuppressed.delete(instanceId);
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const suppressDockUntil = this.suppressDockUntil.get(instanceId) ?? 0;
|
|
275
|
+
if (Date.now() < suppressDockUntil) {
|
|
276
|
+
this.options.stateBroadcaster.clearDockPreview(instanceId);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
this.suppressDockUntil.delete(instanceId);
|
|
280
|
+
|
|
233
281
|
if (!edge) {
|
|
234
282
|
this.options.stateBroadcaster.clearDockPreview(instanceId);
|
|
235
283
|
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.");
|
|
@@ -91,6 +91,7 @@ function buildInitializationPayload(
|
|
|
91
91
|
desktopThemePreview: options.desktopThemePreview,
|
|
92
92
|
pluginState: loadDesktopPluginState(services.pluginRegistry),
|
|
93
93
|
capabilityManifests: services.pluginRegistry.capabilities.manifests({ rendererOnly: true }),
|
|
94
|
+
desktopPlatform: process.platform,
|
|
94
95
|
windowKind: windowTarget.kind,
|
|
95
96
|
paneId: windowTarget.paneId,
|
|
96
97
|
};
|
|
@@ -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,73 @@
|
|
|
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 (
|
|
26
|
+
typeof x !== "number"
|
|
27
|
+
|| typeof y !== "number"
|
|
28
|
+
|| typeof width !== "number"
|
|
29
|
+
|| typeof height !== "number"
|
|
30
|
+
|| !Number.isFinite(x)
|
|
31
|
+
|| !Number.isFinite(y)
|
|
32
|
+
|| !Number.isFinite(width)
|
|
33
|
+
|| !Number.isFinite(height)
|
|
34
|
+
) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return { x, y, width, height };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readWindowFrame(window: ControllableDesktopWindow): DesktopWindowFrame | null {
|
|
41
|
+
return normalizeFrame(window.getFrame?.() ?? window.frame);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function restoreWindowFrame(window: ControllableDesktopWindow, frame: DesktopWindowFrame | null): void {
|
|
45
|
+
if (!frame) return;
|
|
46
|
+
window.setFrame?.(frame.x, frame.y, frame.width, frame.height);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function applyDesktopWindowControl(
|
|
50
|
+
window: ControllableDesktopWindow,
|
|
51
|
+
action: DesktopWindowControlAction,
|
|
52
|
+
): void {
|
|
53
|
+
if (action === "close") {
|
|
54
|
+
maximizedWindowRestoreFrames.delete(window);
|
|
55
|
+
window.close?.();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (action === "minimize") {
|
|
60
|
+
window.minimize?.();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const restoreFrame = maximizedWindowRestoreFrames.get(window);
|
|
65
|
+
if (maximizedWindowRestoreFrames.has(window)) {
|
|
66
|
+
maximizedWindowRestoreFrames.delete(window);
|
|
67
|
+
window.unmaximize?.();
|
|
68
|
+
restoreWindowFrame(window, restoreFrame ?? null);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
maximizedWindowRestoreFrames.set(window, readWindowFrame(window));
|
|
72
|
+
window.maximize?.();
|
|
73
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
type DesktopTitleBarStyle = "default" | "hidden" | "hiddenInset";
|
|
2
|
+
type DesktopWindowRenderer = "native" | "cef";
|
|
3
|
+
type DesktopWindowStyleMask = {
|
|
4
|
+
Borderless?: boolean;
|
|
5
|
+
Closable?: boolean;
|
|
6
|
+
Miniaturizable?: boolean;
|
|
7
|
+
Titled?: boolean;
|
|
8
|
+
FullSizeContentView?: boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function desktopTitleBarStyle(): DesktopTitleBarStyle {
|
|
12
|
+
if (process.platform === "darwin") return "hiddenInset";
|
|
13
|
+
if (process.platform === "win32") return "hidden";
|
|
14
|
+
return "hidden";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function desktopWindowRenderer(): DesktopWindowRenderer {
|
|
18
|
+
if (process.platform === "win32") return "cef";
|
|
19
|
+
return "native";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function desktopWindowStyleMask(): DesktopWindowStyleMask {
|
|
23
|
+
if (process.platform !== "win32") return {};
|
|
24
|
+
return {
|
|
25
|
+
Borderless: true,
|
|
26
|
+
Closable: false,
|
|
27
|
+
Miniaturizable: false,
|
|
28
|
+
Titled: false,
|
|
29
|
+
FullSizeContentView: true,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Pointer } from "bun:ffi";
|
|
2
|
+
import {
|
|
3
|
+
findCurrentProcessWindow,
|
|
4
|
+
WINDOWS_HANDLE_MAX_ATTEMPTS,
|
|
5
|
+
WINDOWS_HANDLE_RETRY_DELAY_MS,
|
|
6
|
+
win32OrNull,
|
|
7
|
+
} from "./windows-native";
|
|
8
|
+
|
|
9
|
+
const GWL_STYLE = -16;
|
|
10
|
+
const WS_CAPTION = 0x00c00000n;
|
|
11
|
+
const WS_SYSMENU = 0x00080000n;
|
|
12
|
+
const WS_THICKFRAME = 0x00040000n;
|
|
13
|
+
const WS_MINIMIZEBOX = 0x00020000n;
|
|
14
|
+
const WS_MAXIMIZEBOX = 0x00010000n;
|
|
15
|
+
const WINDOWS_NATIVE_CONTROL_STYLE_MASK = WS_CAPTION | WS_SYSMENU | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX;
|
|
16
|
+
const SWP_NOSIZE = 0x0001;
|
|
17
|
+
const SWP_NOMOVE = 0x0002;
|
|
18
|
+
const SWP_NOZORDER = 0x0004;
|
|
19
|
+
const SWP_NOACTIVATE = 0x0010;
|
|
20
|
+
const SWP_FRAMECHANGED = 0x0020;
|
|
21
|
+
const CUSTOM_CHROME_SET_WINDOW_POS_FLAGS = SWP_NOSIZE | SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED;
|
|
22
|
+
|
|
23
|
+
type Win32 = NonNullable<ReturnType<typeof win32OrNull>>;
|
|
24
|
+
|
|
25
|
+
function styleValue(value: number | bigint): bigint {
|
|
26
|
+
if (typeof value === "bigint") return value;
|
|
27
|
+
return BigInt(value >>> 0);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function applyCustomChromeToWindow(win32: Win32, windowHandle: Pointer): boolean {
|
|
31
|
+
const currentStyle = styleValue(win32.symbols.GetWindowLongPtrW(windowHandle, GWL_STYLE));
|
|
32
|
+
const nextStyle = currentStyle & ~WINDOWS_NATIVE_CONTROL_STYLE_MASK;
|
|
33
|
+
if (nextStyle !== currentStyle) {
|
|
34
|
+
win32.symbols.SetWindowLongPtrW(windowHandle, GWL_STYLE, nextStyle);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return win32.symbols.SetWindowPos(
|
|
38
|
+
windowHandle,
|
|
39
|
+
null,
|
|
40
|
+
0,
|
|
41
|
+
0,
|
|
42
|
+
0,
|
|
43
|
+
0,
|
|
44
|
+
CUSTOM_CHROME_SET_WINDOW_POS_FLAGS,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function applyWindowsCustomChrome(title: string, attempt = 1): void {
|
|
49
|
+
const win32 = win32OrNull();
|
|
50
|
+
if (!win32) return;
|
|
51
|
+
|
|
52
|
+
const windowHandle = findCurrentProcessWindow(win32, title);
|
|
53
|
+
if (windowHandle && applyCustomChromeToWindow(win32, windowHandle)) return;
|
|
54
|
+
|
|
55
|
+
if (attempt < WINDOWS_HANDLE_MAX_ATTEMPTS) {
|
|
56
|
+
setTimeout(() => applyWindowsCustomChrome(title, attempt + 1), WINDOWS_HANDLE_RETRY_DELAY_MS);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
import { ptr, type Pointer } from "bun:ffi";
|
|
4
|
+
import {
|
|
5
|
+
findCurrentProcessWindow,
|
|
6
|
+
WINDOWS_HANDLE_MAX_ATTEMPTS,
|
|
7
|
+
WINDOWS_HANDLE_RETRY_DELAY_MS,
|
|
8
|
+
wideString,
|
|
9
|
+
win32OrNull,
|
|
10
|
+
} from "./windows-native";
|
|
11
|
+
|
|
12
|
+
const WM_SETICON = 0x0080;
|
|
13
|
+
const ICON_SMALL = 0;
|
|
14
|
+
const ICON_BIG = 1;
|
|
15
|
+
const IMAGE_ICON = 1;
|
|
16
|
+
const LR_LOADFROMFILE = 0x0010;
|
|
17
|
+
const LR_DEFAULTSIZE = 0x0040;
|
|
18
|
+
const GCLP_HICON = -14;
|
|
19
|
+
const GCLP_HICONSM = -34;
|
|
20
|
+
type Win32 = NonNullable<ReturnType<typeof win32OrNull>>;
|
|
21
|
+
let smallIconHandle: Pointer | null = null;
|
|
22
|
+
let bigIconHandle: Pointer | null = null;
|
|
23
|
+
|
|
24
|
+
function loadIconHandle(win32: Win32, iconPath: string, size: number): Pointer | null {
|
|
25
|
+
const iconPathBuffer = wideString(iconPath);
|
|
26
|
+
const iconHandle = win32.symbols.LoadImageW(
|
|
27
|
+
null,
|
|
28
|
+
ptr(iconPathBuffer),
|
|
29
|
+
IMAGE_ICON,
|
|
30
|
+
size,
|
|
31
|
+
size,
|
|
32
|
+
LR_LOADFROMFILE | LR_DEFAULTSIZE,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
return iconHandle || null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function ensureIconHandles(win32: Win32): boolean {
|
|
39
|
+
if (smallIconHandle && bigIconHandle) return true;
|
|
40
|
+
|
|
41
|
+
const iconPath = resolve("../Resources/app.ico");
|
|
42
|
+
if (!existsSync(iconPath)) return false;
|
|
43
|
+
|
|
44
|
+
smallIconHandle = smallIconHandle ?? loadIconHandle(win32, iconPath, 16);
|
|
45
|
+
bigIconHandle = bigIconHandle ?? loadIconHandle(win32, iconPath, 32);
|
|
46
|
+
|
|
47
|
+
return Boolean(smallIconHandle && bigIconHandle);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function setWindowIcon(win32: Win32, windowHandle: Pointer): boolean {
|
|
51
|
+
if (!ensureIconHandles(win32) || !smallIconHandle || !bigIconHandle) return false;
|
|
52
|
+
|
|
53
|
+
win32.symbols.SendMessageW(windowHandle, WM_SETICON, ICON_SMALL, smallIconHandle);
|
|
54
|
+
win32.symbols.SendMessageW(windowHandle, WM_SETICON, ICON_BIG, bigIconHandle);
|
|
55
|
+
win32.symbols.SetClassLongPtrW(windowHandle, GCLP_HICONSM, smallIconHandle);
|
|
56
|
+
win32.symbols.SetClassLongPtrW(windowHandle, GCLP_HICON, bigIconHandle);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function applyWindowsWindowIcon(title: string, attempt = 1): void {
|
|
61
|
+
const win32 = win32OrNull();
|
|
62
|
+
if (!win32) return;
|
|
63
|
+
|
|
64
|
+
const windowHandle = findCurrentProcessWindow(win32, title);
|
|
65
|
+
if (windowHandle && setWindowIcon(win32, windowHandle)) return;
|
|
66
|
+
|
|
67
|
+
if (attempt < WINDOWS_HANDLE_MAX_ATTEMPTS) {
|
|
68
|
+
setTimeout(() => applyWindowsWindowIcon(title, attempt + 1), WINDOWS_HANDLE_RETRY_DELAY_MS);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { dlopen, FFIType, ptr, type Pointer } from "bun:ffi";
|
|
2
|
+
|
|
3
|
+
export const WINDOWS_HANDLE_MAX_ATTEMPTS = 20;
|
|
4
|
+
export const WINDOWS_HANDLE_RETRY_DELAY_MS = 100;
|
|
5
|
+
|
|
6
|
+
type Win32 = ReturnType<typeof loadWin32>;
|
|
7
|
+
|
|
8
|
+
let cachedWin32: Win32 | null | undefined;
|
|
9
|
+
|
|
10
|
+
function loadWin32() {
|
|
11
|
+
return dlopen("user32.dll", {
|
|
12
|
+
FindWindowW: {
|
|
13
|
+
args: [FFIType.ptr, FFIType.ptr],
|
|
14
|
+
returns: FFIType.ptr,
|
|
15
|
+
},
|
|
16
|
+
GetWindowThreadProcessId: {
|
|
17
|
+
args: [FFIType.ptr, FFIType.ptr],
|
|
18
|
+
returns: FFIType.u32,
|
|
19
|
+
},
|
|
20
|
+
LoadImageW: {
|
|
21
|
+
args: [FFIType.ptr, FFIType.ptr, FFIType.u32, FFIType.int, FFIType.int, FFIType.u32],
|
|
22
|
+
returns: FFIType.ptr,
|
|
23
|
+
},
|
|
24
|
+
SendMessageW: {
|
|
25
|
+
args: [FFIType.ptr, FFIType.u32, FFIType.u64, FFIType.ptr],
|
|
26
|
+
returns: FFIType.ptr,
|
|
27
|
+
},
|
|
28
|
+
SetClassLongPtrW: {
|
|
29
|
+
args: [FFIType.ptr, FFIType.int, FFIType.ptr],
|
|
30
|
+
returns: FFIType.ptr,
|
|
31
|
+
},
|
|
32
|
+
GetWindowLongPtrW: {
|
|
33
|
+
args: [FFIType.ptr, FFIType.int],
|
|
34
|
+
returns: FFIType.i64,
|
|
35
|
+
},
|
|
36
|
+
SetWindowLongPtrW: {
|
|
37
|
+
args: [FFIType.ptr, FFIType.int, FFIType.i64],
|
|
38
|
+
returns: FFIType.i64,
|
|
39
|
+
},
|
|
40
|
+
SetWindowPos: {
|
|
41
|
+
args: [FFIType.ptr, FFIType.ptr, FFIType.int, FFIType.int, FFIType.int, FFIType.int, FFIType.u32],
|
|
42
|
+
returns: FFIType.bool,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export 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
|
+
export function wideString(value: string): Uint8Array {
|
|
60
|
+
return Uint8Array.from(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
|
+
export 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
|
+
}
|
|
@@ -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,14 @@ 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 { applyWindowsCustomChrome } from "./desktop/windows-custom-chrome";
|
|
51
|
+
import { applyWindowsWindowIcon } from "./desktop/windows-icons";
|
|
52
|
+
import {
|
|
53
|
+
desktopTitleBarStyle,
|
|
54
|
+
desktopWindowRenderer,
|
|
55
|
+
desktopWindowStyleMask,
|
|
56
|
+
} from "./desktop/window-style";
|
|
57
|
+
import { applyDesktopWindowControl, type DesktopWindowControlAction } from "./desktop/window-controls";
|
|
50
58
|
|
|
51
59
|
type DesktopRpc = ReturnType<typeof BrowserView.defineRPC<ElectrobunDesktopRpcSchema>>;
|
|
52
60
|
|
|
@@ -273,6 +281,18 @@ function closeAllDetachedWindows(): void {
|
|
|
273
281
|
detachedWindowManager.closeAll();
|
|
274
282
|
}
|
|
275
283
|
|
|
284
|
+
function controlWindowForRpcKey(windowKey: string | undefined, action: DesktopWindowControlAction): boolean {
|
|
285
|
+
const targetWindow = windowKey === MAIN_WINDOW_RPC_KEY
|
|
286
|
+
? mainWindow
|
|
287
|
+
: detachedWindowManager.getWindowForRpcKey(windowKey);
|
|
288
|
+
if (!targetWindow) return false;
|
|
289
|
+
if (action !== "close") {
|
|
290
|
+
detachedWindowManager.suppressAutoDockForRpcKey(windowKey);
|
|
291
|
+
}
|
|
292
|
+
applyDesktopWindowControl(targetWindow, action);
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
|
|
276
296
|
async function initialize(
|
|
277
297
|
rpc: DesktopRpc,
|
|
278
298
|
payload: Record<string, unknown>,
|
|
@@ -343,6 +363,7 @@ async function handleBackendRequest(
|
|
|
343
363
|
restartDesktopApp,
|
|
344
364
|
rpc,
|
|
345
365
|
teardownServices,
|
|
366
|
+
controlWindowForRpcKey,
|
|
346
367
|
trackContextMenuRequest: (requestId, targetRpc) => {
|
|
347
368
|
contextMenuRequestRpcs.clear();
|
|
348
369
|
contextMenuRequestRpcs.set(requestId, targetRpc);
|
|
@@ -383,7 +404,7 @@ async function handleBackendRequest(
|
|
|
383
404
|
}
|
|
384
405
|
|
|
385
406
|
function installApplicationMenu() {
|
|
386
|
-
ApplicationMenu.setApplicationMenu(
|
|
407
|
+
ApplicationMenu.setApplicationMenu(buildDesktopApplicationMenu());
|
|
387
408
|
}
|
|
388
409
|
|
|
389
410
|
function createWindowRpc(key: string): DesktopRpc {
|
|
@@ -432,18 +453,26 @@ ApplicationMenu.on("application-menu-clicked", (event: unknown) => {
|
|
|
432
453
|
installApplicationMenu();
|
|
433
454
|
|
|
434
455
|
const mainRpc = createWindowRpc(MAIN_WINDOW_RPC_KEY);
|
|
456
|
+
const initialMainWindowFrame = normalizeWindowFrameWithMinimum(
|
|
457
|
+
defaultMainWindowFrame(),
|
|
458
|
+
defaultMainWindowFrame(),
|
|
459
|
+
MAIN_WINDOW_MIN_SIZE,
|
|
460
|
+
);
|
|
435
461
|
|
|
436
462
|
mainWindow = new BrowserWindow({
|
|
437
463
|
title: "Gloomberb",
|
|
438
|
-
frame:
|
|
464
|
+
frame: initialMainWindowFrame,
|
|
439
465
|
url: "views://mainview/index.html",
|
|
440
|
-
renderer:
|
|
466
|
+
renderer: desktopWindowRenderer(),
|
|
441
467
|
rpc: mainRpc,
|
|
442
|
-
|
|
468
|
+
styleMask: desktopWindowStyleMask(),
|
|
469
|
+
titleBarStyle: desktopTitleBarStyle(),
|
|
443
470
|
navigationRules: JSON.stringify(["views://*"]),
|
|
444
471
|
sandbox: false,
|
|
445
472
|
});
|
|
446
|
-
|
|
473
|
+
applyWindowsWindowIcon("Gloomberb");
|
|
474
|
+
applyWindowsCustomChrome("Gloomberb");
|
|
475
|
+
updateWindowFrameCache(mainWindow, initialMainWindowFrame, MAIN_WINDOW_MIN_SIZE);
|
|
447
476
|
detachedWindowManager.focusWindowForRpcKey(MAIN_WINDOW_RPC_KEY);
|
|
448
477
|
(mainWindow as any).on?.("move", (event: WindowMoveEvent) => {
|
|
449
478
|
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,
|