gloomberb 0.8.2 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -7
- package/package.json +18 -13
- package/patches/{@opentui%2Fcore@0.1.90.patch → @opentui%2Fcore@0.3.2.patch} +175 -90
- package/patches/electrobun@1.18.1.patch +85 -0
- package/src/app/global-shortcuts.test.tsx +66 -4
- package/src/app/global-shortcuts.ts +12 -6
- 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/command-bar/action-runtime.ts +1 -0
- package/src/components/command-bar/layout-items/current-layout.test.ts +115 -0
- package/src/components/command-bar/layout-items/current-layout.ts +33 -0
- package/src/components/command-bar/list/view.tsx +3 -1
- package/src/components/command-bar/pane-templates/items.ts +4 -1
- package/src/components/command-bar/panel/body.tsx +1 -0
- package/src/components/command-bar/routes/root/results.ts +0 -30
- package/src/components/command-bar/routes/root/runtime.ts +2 -20
- package/src/components/command-bar/routes/ticker-search/actions.ts +42 -4
- package/src/components/command-bar/routes/ticker-search/route.ts +4 -1
- package/src/components/command-bar/routing/launch-request.ts +14 -7
- package/src/components/command-bar/routing/list-state.ts +1 -3
- package/src/components/command-bar/surface/index.test.tsx +76 -4
- package/src/components/command-bar/surface/index.tsx +0 -2
- package/src/components/command-bar/surface/pane.test.tsx +1 -0
- package/src/components/command-bar/surface/test-harness.tsx +1 -0
- package/src/components/command-bar/view-model.ts +3 -3
- package/src/components/layout/detached-pane-shell.tsx +8 -4
- package/src/components/layout/header.tsx +13 -6
- package/src/components/layout/shell/index.test.tsx +120 -2
- package/src/components/layout/shell/index.tsx +2 -0
- package/src/components/layout/shell/menu.ts +1 -7
- package/src/components/layout/shell/pane/actions.ts +8 -0
- package/src/components/layout/shell/pane/management-shortcuts.ts +12 -2
- package/src/components/layout/shell/shortcuts.ts +14 -6
- package/src/components/layout/shell/window-mode/index.ts +8 -3
- package/src/components/layout/titlebar-overlay.ts +16 -0
- package/src/components/layout/window-controls.tsx +90 -0
- package/src/components/layout/window-edit/presentation.ts +4 -4
- package/src/components/onboarding/onboarding-steps.tsx +3 -2
- package/src/components/ticker/list-table-view.tsx +6 -0
- package/src/components/ui/remote-image.tsx +1 -14
- package/src/components/ui/tabs.tsx +95 -80
- package/src/components/ui/ui.test.tsx +54 -0
- package/src/core/state/app/types.ts +12 -4
- package/src/plugins/builtin/chat/content/channel-navigation.ts +44 -11
- package/src/plugins/builtin/chat/content/index.tsx +5 -4
- package/src/plugins/builtin/chat/content/shortcuts.ts +7 -0
- package/src/plugins/builtin/chat/sidebar.test.tsx +71 -0
- package/src/plugins/builtin/help/components.tsx +1 -1
- package/src/plugins/builtin/help/index.test.tsx +105 -30
- package/src/plugins/builtin/help/index.tsx +324 -168
- package/src/plugins/builtin/help/shortcut-model.ts +5 -6
- package/src/plugins/builtin/portfolio-list/column-values.ts +304 -1
- package/src/plugins/builtin/portfolio-list/metrics.test.ts +93 -0
- package/src/plugins/builtin/portfolio-list/pane/data.ts +7 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +25 -2
- package/src/plugins/builtin/portfolio-list/pane/supplemental.ts +281 -0
- package/src/plugins/builtin/portfolio-list/settings.ts +27 -0
- package/src/plugins/builtin/portfolio-list/table.tsx +4 -2
- package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +10 -68
- package/src/plugins/builtin/substack/api/auth.ts +185 -0
- package/src/plugins/builtin/substack/api/cache.ts +78 -0
- package/src/plugins/builtin/substack/api/loaders.ts +146 -0
- package/src/plugins/builtin/substack/api/store.ts +186 -0
- package/src/plugins/builtin/substack/api/types.ts +40 -0
- package/src/plugins/builtin/substack/api.test.ts +298 -0
- package/src/plugins/builtin/substack/article-detail.tsx +359 -0
- package/src/plugins/builtin/substack/article-stack.tsx +129 -0
- package/src/plugins/builtin/substack/content.ts +478 -0
- package/src/plugins/builtin/substack/feed-tabs.tsx +41 -0
- package/src/plugins/builtin/substack/index.tsx +49 -0
- package/src/plugins/builtin/substack/login-view.tsx +126 -0
- package/src/plugins/builtin/substack/model.test.ts +155 -0
- package/src/plugins/builtin/substack/normalize.ts +343 -0
- package/src/plugins/builtin/substack/pane-footer.ts +71 -0
- package/src/plugins/builtin/substack/pane-state.ts +170 -0
- package/src/plugins/builtin/substack/pane.tsx +500 -0
- package/src/plugins/builtin/substack/table.ts +111 -0
- package/src/plugins/builtin/substack/types.ts +71 -0
- package/src/plugins/builtin/substack/utils.ts +131 -0
- package/src/plugins/builtin/ticker-detail/index.test.tsx +16 -2
- package/src/plugins/builtin/ticker-detail/index.tsx +1 -0
- package/src/plugins/builtin/ticker-detail/pane.tsx +13 -1
- package/src/plugins/catalog-ui.ts +2 -0
- package/src/plugins/pane-manager/layout-state.ts +11 -0
- package/src/plugins/pane-manager.ts +1 -1
- package/src/react/use-throttled-commit-value.ts +116 -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/http-fetch.test.ts +42 -0
- package/src/renderers/electrobun/bun/desktop/http-fetch.ts +5 -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/http-fetch.ts +10 -3
- package/src/renderers/electrobun/view/styles.css +20 -0
- package/src/renderers/electrobun/view/ui-host.tsx +14 -0
- package/src/state/app/context/index.test.tsx +16 -0
- package/src/tickers/search/index.ts +1 -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
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import {
|
|
13
13
|
detachedRpcKey,
|
|
14
14
|
focusWindowForRpcKey,
|
|
15
|
+
paneIdFromDetachedRpcKey,
|
|
15
16
|
} from "../window/focus";
|
|
16
17
|
import {
|
|
17
18
|
applyWindowMoveEvent,
|
|
@@ -22,6 +23,11 @@ import {
|
|
|
22
23
|
type WindowResizeEvent,
|
|
23
24
|
} from "./window-events";
|
|
24
25
|
import type { DesktopStateBroadcaster, DesktopStateRpc } from "./state-broadcaster";
|
|
26
|
+
import { applyWindowsWindowIcon } from "./windows-icons";
|
|
27
|
+
import { desktopTitleBarStyle } from "./window-style";
|
|
28
|
+
|
|
29
|
+
const INITIAL_DOCK_SUPPRESSION_MS = 800;
|
|
30
|
+
const WINDOW_CONTROL_DOCK_SUPPRESSION_MS = 5_000;
|
|
25
31
|
|
|
26
32
|
interface DesktopDetachedWindowManagerOptions<Rpc extends DesktopStateRpc> {
|
|
27
33
|
createRpc: (key: string) => Rpc;
|
|
@@ -45,6 +51,8 @@ export class DesktopDetachedWindowManager<Rpc extends DesktopStateRpc> {
|
|
|
45
51
|
private readonly dockTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
46
52
|
private readonly closingPanes = new Set<string>();
|
|
47
53
|
private readonly pendingMoveFlush = new Set<string>();
|
|
54
|
+
private readonly suppressDockUntil = new Map<string, number>();
|
|
55
|
+
private readonly windowControlDockSuppressed = new Set<string>();
|
|
48
56
|
|
|
49
57
|
constructor(private readonly options: DesktopDetachedWindowManagerOptions<Rpc>) {}
|
|
50
58
|
|
|
@@ -56,6 +64,19 @@ export class DesktopDetachedWindowManager<Rpc extends DesktopStateRpc> {
|
|
|
56
64
|
return focusWindowForRpcKey(rpcKey, this.options.getMainWindow(), this.windows);
|
|
57
65
|
}
|
|
58
66
|
|
|
67
|
+
getWindowForRpcKey(rpcKey: string | undefined): BrowserWindow | null {
|
|
68
|
+
const paneId = paneIdFromDetachedRpcKey(rpcKey);
|
|
69
|
+
return paneId ? this.windows.get(paneId) ?? null : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
suppressAutoDockForRpcKey(rpcKey: string | undefined): void {
|
|
73
|
+
const paneId = paneIdFromDetachedRpcKey(rpcKey);
|
|
74
|
+
if (!paneId || !this.windows.has(paneId)) return;
|
|
75
|
+
this.windowControlDockSuppressed.add(paneId);
|
|
76
|
+
this.suppressDockUntil.set(paneId, Date.now() + WINDOW_CONTROL_DOCK_SUPPRESSION_MS);
|
|
77
|
+
this.options.stateBroadcaster.clearDockPreview(paneId);
|
|
78
|
+
}
|
|
79
|
+
|
|
59
80
|
resolveFrame(instanceId: string): WindowFrame {
|
|
60
81
|
const detachedEntry = this.options.getConfig().layout.detached.find((entry) => entry.instanceId === instanceId);
|
|
61
82
|
if (detachedEntry) {
|
|
@@ -151,6 +172,8 @@ export class DesktopDetachedWindowManager<Rpc extends DesktopStateRpc> {
|
|
|
151
172
|
this.clearTimer(this.frameTimers, instanceId);
|
|
152
173
|
this.clearTimer(this.dockTimers, instanceId);
|
|
153
174
|
this.pendingMoveFlush.delete(instanceId);
|
|
175
|
+
this.suppressDockUntil.delete(instanceId);
|
|
176
|
+
this.windowControlDockSuppressed.delete(instanceId);
|
|
154
177
|
this.options.unregisterWindowRpc(detachedRpcKey(instanceId));
|
|
155
178
|
}
|
|
156
179
|
|
|
@@ -160,18 +183,21 @@ export class DesktopDetachedWindowManager<Rpc extends DesktopStateRpc> {
|
|
|
160
183
|
): BrowserWindow {
|
|
161
184
|
const rpc = this.options.createRpc(detachedRpcKey(instanceId));
|
|
162
185
|
const initialFrame = normalizeWindowFrameWithMinimum(frame, DEFAULT_WINDOW_FRAME, DETACHED_WINDOW_MIN_SIZE);
|
|
186
|
+
const title = this.resolveTitle(instanceId);
|
|
163
187
|
const window = new BrowserWindow({
|
|
164
|
-
title
|
|
188
|
+
title,
|
|
165
189
|
frame: initialFrame,
|
|
166
190
|
url: "views://mainview/index.html",
|
|
167
191
|
renderer: "native",
|
|
168
192
|
rpc: rpc as never,
|
|
169
|
-
titleBarStyle:
|
|
193
|
+
titleBarStyle: desktopTitleBarStyle(),
|
|
170
194
|
navigationRules: JSON.stringify(["views://*"]),
|
|
171
195
|
sandbox: false,
|
|
172
196
|
});
|
|
197
|
+
applyWindowsWindowIcon(title);
|
|
173
198
|
updateWindowFrameCache(window, initialFrame, DETACHED_WINDOW_MIN_SIZE);
|
|
174
199
|
this.windows.set(instanceId, window);
|
|
200
|
+
this.suppressDockUntil.set(instanceId, Date.now() + INITIAL_DOCK_SUPPRESSION_MS);
|
|
175
201
|
|
|
176
202
|
(window as any).on?.("close", () => {
|
|
177
203
|
const shouldIgnore = this.closingPanes.delete(instanceId);
|
|
@@ -230,6 +256,21 @@ export class DesktopDetachedWindowManager<Rpc extends DesktopStateRpc> {
|
|
|
230
256
|
}, 120));
|
|
231
257
|
|
|
232
258
|
this.clearTimer(this.dockTimers, instanceId);
|
|
259
|
+
if (this.windowControlDockSuppressed.has(instanceId)) {
|
|
260
|
+
this.options.stateBroadcaster.clearDockPreview(instanceId);
|
|
261
|
+
if (!edge) {
|
|
262
|
+
this.windowControlDockSuppressed.delete(instanceId);
|
|
263
|
+
}
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const suppressDockUntil = this.suppressDockUntil.get(instanceId) ?? 0;
|
|
268
|
+
if (Date.now() < suppressDockUntil) {
|
|
269
|
+
this.options.stateBroadcaster.clearDockPreview(instanceId);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
this.suppressDockUntil.delete(instanceId);
|
|
273
|
+
|
|
233
274
|
if (!edge) {
|
|
234
275
|
this.options.stateBroadcaster.clearDockPreview(instanceId);
|
|
235
276
|
return;
|
|
@@ -3,10 +3,13 @@ import { ContextMenu, Utils, type BrowserWindow } from "electrobun/bun";
|
|
|
3
3
|
import { buildSoundCommand } from "../../../../notifications/app-notifier";
|
|
4
4
|
import type { DesktopRestartMessage } from "../../shared/protocol";
|
|
5
5
|
import { getContextMenuRequestId, normalizeContextMenuItems } from "../context-menu/normalize";
|
|
6
|
+
import { MAIN_WINDOW_RPC_KEY } from "../window/focus";
|
|
7
|
+
import type { DesktopWindowControlAction } from "./window-controls";
|
|
6
8
|
|
|
7
9
|
interface DesktopHostRequestOptions<TRpc> {
|
|
8
10
|
clearMainWindow: () => void;
|
|
9
11
|
closeAllDetachedWindows: () => void;
|
|
12
|
+
controlWindowForRpcKey: (windowKey: string | undefined, action: DesktopWindowControlAction) => boolean;
|
|
10
13
|
focusWindowForRpcKey: (windowKey: string) => void;
|
|
11
14
|
getMainWindow: () => BrowserWindow | null;
|
|
12
15
|
getRpcWindowKey: (rpc: TRpc) => string | undefined;
|
|
@@ -37,9 +40,17 @@ function playNotificationSound(sound: string | undefined): void {
|
|
|
37
40
|
}
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
function normalizeWindowControlAction(action: unknown): DesktopWindowControlAction {
|
|
44
|
+
if (action === "minimize" || action === "toggle-maximize" || action === "close") {
|
|
45
|
+
return action;
|
|
46
|
+
}
|
|
47
|
+
throw new Error("host.windowControl requires a valid action.");
|
|
48
|
+
}
|
|
49
|
+
|
|
40
50
|
export function handleDesktopHostRequest<TRpc>({
|
|
41
51
|
clearMainWindow,
|
|
42
52
|
closeAllDetachedWindows,
|
|
53
|
+
controlWindowForRpcKey,
|
|
43
54
|
focusWindowForRpcKey,
|
|
44
55
|
getMainWindow,
|
|
45
56
|
getRpcWindowKey,
|
|
@@ -69,6 +80,26 @@ export function handleDesktopHostRequest<TRpc>({
|
|
|
69
80
|
Utils.quit();
|
|
70
81
|
return null;
|
|
71
82
|
}
|
|
83
|
+
case "host.windowControl": {
|
|
84
|
+
const action = normalizeWindowControlAction(payload.action);
|
|
85
|
+
const windowKey = getRpcWindowKey(rpc);
|
|
86
|
+
if (action === "close" && windowKey === MAIN_WINDOW_RPC_KEY) {
|
|
87
|
+
closeAllDetachedWindows();
|
|
88
|
+
teardownServices();
|
|
89
|
+
const mainWindow = getMainWindow();
|
|
90
|
+
if (mainWindow) {
|
|
91
|
+
mainWindow.close();
|
|
92
|
+
clearMainWindow();
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
Utils.quit();
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
if (!controlWindowForRpcKey(windowKey, action)) {
|
|
99
|
+
throw new Error("No desktop window is registered for this request.");
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
72
103
|
case "host.openExternal":
|
|
73
104
|
if (typeof payload.url !== "string") {
|
|
74
105
|
throw new Error("host.openExternal requires a URL.");
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { handleHttpFetch } from "./http-fetch";
|
|
3
|
+
|
|
4
|
+
let server: ReturnType<typeof Bun.serve> | null = null;
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
server?.stop(true);
|
|
8
|
+
server = null;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("handleHttpFetch", () => {
|
|
12
|
+
test("preserves manual redirects and Set-Cookie headers", async () => {
|
|
13
|
+
server = Bun.serve({
|
|
14
|
+
port: 0,
|
|
15
|
+
fetch(request) {
|
|
16
|
+
const url = new URL(request.url);
|
|
17
|
+
if (url.pathname === "/login") {
|
|
18
|
+
return new Response(null, {
|
|
19
|
+
status: 302,
|
|
20
|
+
headers: {
|
|
21
|
+
location: "/reader",
|
|
22
|
+
"set-cookie": "substack.sid=sid123; Path=/; HttpOnly",
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
if (url.pathname === "/reader") {
|
|
27
|
+
return new Response("reader");
|
|
28
|
+
}
|
|
29
|
+
return new Response("not found", { status: 404 });
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const response = await handleHttpFetch({
|
|
34
|
+
url: new URL("/login", server.url).toString(),
|
|
35
|
+
init: { redirect: "manual" },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(response.status).toBe(302);
|
|
39
|
+
expect(response.headers.location).toBe("/reader");
|
|
40
|
+
expect(response.setCookie).toEqual(["substack.sid=sid123; Path=/; HttpOnly"]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -26,6 +26,10 @@ export async function handleHttpFetch(payload: Record<string, unknown>) {
|
|
|
26
26
|
typeof init.method === "string" && init.method.trim().length > 0
|
|
27
27
|
? init.method.trim().toUpperCase()
|
|
28
28
|
: "GET";
|
|
29
|
+
const redirect =
|
|
30
|
+
init.redirect === "manual" || init.redirect === "error" || init.redirect === "follow"
|
|
31
|
+
? init.redirect
|
|
32
|
+
: undefined;
|
|
29
33
|
const body =
|
|
30
34
|
typeof init.body === "string" && method !== "GET" && method !== "HEAD"
|
|
31
35
|
? init.body
|
|
@@ -35,6 +39,7 @@ export async function handleHttpFetch(payload: Record<string, unknown>) {
|
|
|
35
39
|
method,
|
|
36
40
|
headers: normalizeHttpFetchHeaders(init.headers),
|
|
37
41
|
body,
|
|
42
|
+
redirect,
|
|
38
43
|
});
|
|
39
44
|
const responseHeaders: Record<string, string> = {};
|
|
40
45
|
response.headers.forEach((value, key) => {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { applyDesktopWindowControl } from "./window-controls";
|
|
3
|
+
|
|
4
|
+
type WindowCall =
|
|
5
|
+
| { type: "close" }
|
|
6
|
+
| { type: "minimize" }
|
|
7
|
+
| { type: "maximize" }
|
|
8
|
+
| { type: "unmaximize" }
|
|
9
|
+
| { type: "setFrame"; frame: { x: number; y: number; width: number; height: number } };
|
|
10
|
+
|
|
11
|
+
function createWindow() {
|
|
12
|
+
const calls: WindowCall[] = [];
|
|
13
|
+
const frame = { x: 120, y: 80, width: 640, height: 420 };
|
|
14
|
+
return {
|
|
15
|
+
calls,
|
|
16
|
+
window: {
|
|
17
|
+
frame,
|
|
18
|
+
close: () => calls.push({ type: "close" }),
|
|
19
|
+
minimize: () => calls.push({ type: "minimize" }),
|
|
20
|
+
maximize: () => calls.push({ type: "maximize" }),
|
|
21
|
+
unmaximize: () => calls.push({ type: "unmaximize" }),
|
|
22
|
+
getFrame: () => frame,
|
|
23
|
+
setFrame: (x: number, y: number, width: number, height: number) => calls.push({
|
|
24
|
+
type: "setFrame",
|
|
25
|
+
frame: { x, y, width, height },
|
|
26
|
+
}),
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("applyDesktopWindowControl", () => {
|
|
32
|
+
test("minimizes and closes windows", () => {
|
|
33
|
+
const target = createWindow();
|
|
34
|
+
|
|
35
|
+
applyDesktopWindowControl(target.window, "minimize");
|
|
36
|
+
applyDesktopWindowControl(target.window, "close");
|
|
37
|
+
|
|
38
|
+
expect(target.calls).toEqual([{ type: "minimize" }, { type: "close" }]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("toggles maximize from the custom control state", () => {
|
|
42
|
+
const target = createWindow();
|
|
43
|
+
|
|
44
|
+
applyDesktopWindowControl(target.window, "toggle-maximize");
|
|
45
|
+
applyDesktopWindowControl(target.window, "toggle-maximize");
|
|
46
|
+
|
|
47
|
+
expect(target.calls).toEqual([
|
|
48
|
+
{ type: "maximize" },
|
|
49
|
+
{ type: "unmaximize" },
|
|
50
|
+
{ type: "setFrame", frame: { x: 120, y: 80, width: 640, height: 420 } },
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export type DesktopWindowControlAction = "minimize" | "toggle-maximize" | "close";
|
|
2
|
+
|
|
3
|
+
interface DesktopWindowFrame {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ControllableDesktopWindow {
|
|
11
|
+
frame?: Partial<DesktopWindowFrame> | null;
|
|
12
|
+
close?: () => void;
|
|
13
|
+
minimize?: () => void;
|
|
14
|
+
maximize?: () => void;
|
|
15
|
+
unmaximize?: () => void;
|
|
16
|
+
getFrame?: () => Partial<DesktopWindowFrame> | null;
|
|
17
|
+
setFrame?: (x: number, y: number, width: number, height: number) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const maximizedWindowRestoreFrames = new WeakMap<ControllableDesktopWindow, DesktopWindowFrame | null>();
|
|
21
|
+
|
|
22
|
+
function normalizeFrame(frame: Partial<DesktopWindowFrame> | null | undefined): DesktopWindowFrame | null {
|
|
23
|
+
if (!frame) return null;
|
|
24
|
+
const { x, y, width, height } = frame;
|
|
25
|
+
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(width) || !Number.isFinite(height)) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
return { x, y, width, height };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readWindowFrame(window: ControllableDesktopWindow): DesktopWindowFrame | null {
|
|
32
|
+
return normalizeFrame(window.getFrame?.() ?? window.frame);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function restoreWindowFrame(window: ControllableDesktopWindow, frame: DesktopWindowFrame | null): void {
|
|
36
|
+
if (!frame) return;
|
|
37
|
+
window.setFrame?.(frame.x, frame.y, frame.width, frame.height);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function applyDesktopWindowControl(
|
|
41
|
+
window: ControllableDesktopWindow,
|
|
42
|
+
action: DesktopWindowControlAction,
|
|
43
|
+
): void {
|
|
44
|
+
if (action === "close") {
|
|
45
|
+
maximizedWindowRestoreFrames.delete(window);
|
|
46
|
+
window.close?.();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (action === "minimize") {
|
|
51
|
+
window.minimize?.();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const restoreFrame = maximizedWindowRestoreFrames.get(window);
|
|
56
|
+
if (maximizedWindowRestoreFrames.has(window)) {
|
|
57
|
+
maximizedWindowRestoreFrames.delete(window);
|
|
58
|
+
window.unmaximize?.();
|
|
59
|
+
restoreWindowFrame(window, restoreFrame);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
maximizedWindowRestoreFrames.set(window, readWindowFrame(window));
|
|
63
|
+
window.maximize?.();
|
|
64
|
+
}
|
|
@@ -0,0 +1,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
|
}
|