gloomberb 0.9.1 → 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 +1 -1
- 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 +5 -2
- package/src/components/layout/header.tsx +13 -5
- package/src/components/layout/window-controls.tsx +19 -10
- 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/desktop/detached-windows.ts +9 -2
- package/src/renderers/electrobun/bun/desktop/initialization.ts +1 -0
- package/src/renderers/electrobun/bun/desktop/window-controls.ts +11 -2
- package/src/renderers/electrobun/bun/desktop/window-style.ts +27 -1
- package/src/renderers/electrobun/bun/desktop/windows-custom-chrome.ts +58 -0
- package/src/renderers/electrobun/bun/desktop/windows-icons.ts +11 -64
- package/src/renderers/electrobun/bun/desktop/windows-native.ts +75 -0
- package/src/renderers/electrobun/bun/index.ts +9 -2
- package/src/renderers/electrobun/shared/protocol.ts +1 -0
- package/src/renderers/electrobun/view/main.tsx +2 -1
- package/src/renderers/electrobun/view/styles.css +9 -8
- package/src/renderers/electrobun/view/ui-host.tsx +102 -77
- package/src/version.ts +1 -1
|
@@ -1,14 +1,12 @@
|
|
|
1
|
-
import { useCallback
|
|
1
|
+
import { useCallback } from "react";
|
|
2
2
|
import { type DataTableKeyEvent } from "../../../components";
|
|
3
3
|
import { useShortcut } from "../../../react/input";
|
|
4
|
-
import { type ScrollBoxRenderable } from "../../../ui";
|
|
5
4
|
import { isPlainKey } from "../../../utils/keyboard";
|
|
6
5
|
import type { CongressTab, DetailMode } from "./model";
|
|
7
6
|
|
|
8
7
|
export function useCongressTradesKeyboard({
|
|
9
8
|
activeTab,
|
|
10
9
|
detailMode,
|
|
11
|
-
detailScrollRef,
|
|
12
10
|
focused,
|
|
13
11
|
load,
|
|
14
12
|
openSelectedTicker,
|
|
@@ -18,7 +16,6 @@ export function useCongressTradesKeyboard({
|
|
|
18
16
|
}: {
|
|
19
17
|
activeTab: CongressTab;
|
|
20
18
|
detailMode: DetailMode;
|
|
21
|
-
detailScrollRef: RefObject<ScrollBoxRenderable | null>;
|
|
22
19
|
focused: boolean;
|
|
23
20
|
load: (refresh?: boolean) => void;
|
|
24
21
|
openSelectedTicker: () => void;
|
|
@@ -26,25 +23,9 @@ export function useCongressTradesKeyboard({
|
|
|
26
23
|
openSelectedTradeSource: () => void;
|
|
27
24
|
selectTab: (tab: string) => void;
|
|
28
25
|
}) {
|
|
29
|
-
const scrollDetailBy = useCallback((delta: number) => {
|
|
30
|
-
const scrollBox = detailScrollRef.current;
|
|
31
|
-
if (!scrollBox?.viewport) return;
|
|
32
|
-
const maxScrollTop = Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height);
|
|
33
|
-
scrollBox.scrollTop = Math.max(0, Math.min(maxScrollTop, scrollBox.scrollTop + delta));
|
|
34
|
-
}, [detailScrollRef]);
|
|
35
|
-
|
|
36
26
|
const handleDetailKeyDown = useCallback((event: DataTableKeyEvent) => {
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
event.stopPropagation?.();
|
|
40
|
-
scrollDetailBy(1);
|
|
41
|
-
return true;
|
|
42
|
-
}
|
|
43
|
-
if (isPlainKey(event, "k", "up")) {
|
|
44
|
-
event.preventDefault?.();
|
|
45
|
-
event.stopPropagation?.();
|
|
46
|
-
scrollDetailBy(-1);
|
|
47
|
-
return true;
|
|
27
|
+
if (detailMode?.kind === "member") {
|
|
28
|
+
return false;
|
|
48
29
|
}
|
|
49
30
|
if (isPlainKey(event, "o")) {
|
|
50
31
|
event.preventDefault?.();
|
|
@@ -65,7 +46,7 @@ export function useCongressTradesKeyboard({
|
|
|
65
46
|
return true;
|
|
66
47
|
}
|
|
67
48
|
return false;
|
|
68
|
-
}, [openSelectedTicker, openSelectedTradeMember, openSelectedTradeSource
|
|
49
|
+
}, [detailMode?.kind, openSelectedTicker, openSelectedTradeMember, openSelectedTradeSource]);
|
|
69
50
|
|
|
70
51
|
const handleRootKeyDown = useCallback((event: DataTableKeyEvent) => {
|
|
71
52
|
if (isPlainKey(event, "r")) {
|
|
@@ -8,6 +8,8 @@ import type {
|
|
|
8
8
|
export const CONGRESS_TRADES_PANE_ID = "congress-trades";
|
|
9
9
|
export const CONGRESS_TRADE_LIMIT = 200;
|
|
10
10
|
export const CONGRESS_FILING_LIMIT = 20;
|
|
11
|
+
export const CONGRESS_MEMBER_TRADE_LIMIT = 2000;
|
|
12
|
+
export const CONGRESS_MEMBER_FILING_LIMIT = 500;
|
|
11
13
|
|
|
12
14
|
export type CongressTab = "trades" | "members";
|
|
13
15
|
export type LoadStatus = "idle" | "loading" | "loaded" | "error";
|
|
@@ -25,6 +27,7 @@ export type TradeColumnId =
|
|
|
25
27
|
| "side"
|
|
26
28
|
| "ticker"
|
|
27
29
|
| "amount"
|
|
30
|
+
| "asset"
|
|
28
31
|
| "owner";
|
|
29
32
|
export type TradeColumn = DataTableColumn & { id: TradeColumnId };
|
|
30
33
|
export type MemberColumnId =
|
|
@@ -104,6 +107,8 @@ function compareTrade(
|
|
|
104
107
|
return compareText(left.ticker ?? "", right.ticker ?? "");
|
|
105
108
|
case "amount":
|
|
106
109
|
return (left.amountHigh ?? left.amountLow ?? 0) - (right.amountHigh ?? right.amountLow ?? 0);
|
|
110
|
+
case "asset":
|
|
111
|
+
return compareText(left.assetName, right.assetName);
|
|
107
112
|
case "owner":
|
|
108
113
|
return compareText(left.owner, right.owner);
|
|
109
114
|
}
|
|
@@ -172,6 +177,30 @@ export function buildTradeColumns(width: number): TradeColumn[] {
|
|
|
172
177
|
];
|
|
173
178
|
}
|
|
174
179
|
|
|
180
|
+
export function buildMemberTradeColumns(width: number): TradeColumn[] {
|
|
181
|
+
const filedWidth = 7;
|
|
182
|
+
const txWidth = 7;
|
|
183
|
+
const sideWidth = 8;
|
|
184
|
+
const tickerWidth = 12;
|
|
185
|
+
const amountWidth = 14;
|
|
186
|
+
const ownerWidth = 8;
|
|
187
|
+
const lagWidth = 5;
|
|
188
|
+
const assetWidth = Math.max(
|
|
189
|
+
18,
|
|
190
|
+
width - filedWidth - txWidth - sideWidth - tickerWidth - amountWidth - ownerWidth - lagWidth - 9,
|
|
191
|
+
);
|
|
192
|
+
return [
|
|
193
|
+
{ id: "filed", label: "FILED", width: filedWidth, align: "left" },
|
|
194
|
+
{ id: "tx", label: "TX", width: txWidth, align: "left" },
|
|
195
|
+
{ id: "side", label: "SIDE", width: sideWidth, align: "left" },
|
|
196
|
+
{ id: "ticker", label: "TICKER", width: tickerWidth, align: "left" },
|
|
197
|
+
{ id: "amount", label: "AMOUNT", width: amountWidth, align: "right" },
|
|
198
|
+
{ id: "asset", label: "ASSET", width: assetWidth, align: "left" },
|
|
199
|
+
{ id: "owner", label: "OWNER", width: ownerWidth, align: "left" },
|
|
200
|
+
{ id: "lag", label: "LAG", width: lagWidth, align: "right" },
|
|
201
|
+
];
|
|
202
|
+
}
|
|
203
|
+
|
|
175
204
|
export function buildMemberColumns(width: number): MemberColumn[] {
|
|
176
205
|
const districtWidth = 6;
|
|
177
206
|
const tradesWidth = 7;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
-
import { Box, useRendererHost
|
|
2
|
+
import { Box, useRendererHost } from "../../../ui";
|
|
3
3
|
import {
|
|
4
4
|
DataTableStackView,
|
|
5
5
|
EmptyState,
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
import type { PaneProps } from "../../../types/plugin";
|
|
18
18
|
import {
|
|
19
19
|
CONGRESS_FILING_LIMIT,
|
|
20
|
+
CONGRESS_MEMBER_FILING_LIMIT,
|
|
20
21
|
CONGRESS_TRADE_LIMIT,
|
|
21
22
|
CONGRESS_TRADES_PANE_ID,
|
|
22
23
|
buildMemberColumns,
|
|
@@ -34,7 +35,7 @@ import {
|
|
|
34
35
|
type TradeColumn,
|
|
35
36
|
type TradeColumnId,
|
|
36
37
|
} from "./model";
|
|
37
|
-
import {
|
|
38
|
+
import { MemberTradesDetail, TradeDetail } from "./detail";
|
|
38
39
|
import { useCongressTradesFooter } from "./footer";
|
|
39
40
|
import { useCongressTradesKeyboard } from "./keyboard";
|
|
40
41
|
import {
|
|
@@ -61,7 +62,6 @@ export function CongressTradesPane({ focused, width, height }: PaneProps) {
|
|
|
61
62
|
columnId: "trades",
|
|
62
63
|
direction: "desc",
|
|
63
64
|
});
|
|
64
|
-
const detailScrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
65
65
|
const fetchGenRef = useRef(0);
|
|
66
66
|
|
|
67
67
|
const load = useCallback((refresh = false) => {
|
|
@@ -142,12 +142,6 @@ export function CongressTradesPane({ focused, width, height }: PaneProps) {
|
|
|
142
142
|
if (detailMode?.kind === "member" && !detailMember) setDetailMode(null);
|
|
143
143
|
}, [detailMember, detailMode, detailTrade]);
|
|
144
144
|
|
|
145
|
-
useEffect(() => {
|
|
146
|
-
if (!detailMode) return;
|
|
147
|
-
const scrollBox = detailScrollRef.current;
|
|
148
|
-
if (scrollBox) scrollBox.scrollTop = 0;
|
|
149
|
-
}, [detailMode]);
|
|
150
|
-
|
|
151
145
|
const selectTab = useCallback((tab: string) => {
|
|
152
146
|
setActiveTab(tab === "members" ? "members" : "trades");
|
|
153
147
|
setDetailMode(null);
|
|
@@ -176,7 +170,6 @@ export function CongressTradesPane({ focused, width, height }: PaneProps) {
|
|
|
176
170
|
const { handleDetailKeyDown, handleRootKeyDown } = useCongressTradesKeyboard({
|
|
177
171
|
activeTab,
|
|
178
172
|
detailMode,
|
|
179
|
-
detailScrollRef,
|
|
180
173
|
focused,
|
|
181
174
|
load,
|
|
182
175
|
openSelectedTicker,
|
|
@@ -187,6 +180,7 @@ export function CongressTradesPane({ focused, width, height }: PaneProps) {
|
|
|
187
180
|
|
|
188
181
|
useCongressTradesFooter({
|
|
189
182
|
activeTab,
|
|
183
|
+
detailMode,
|
|
190
184
|
detailTrade,
|
|
191
185
|
error,
|
|
192
186
|
load,
|
|
@@ -201,11 +195,12 @@ export function CongressTradesPane({ focused, width, height }: PaneProps) {
|
|
|
201
195
|
const detailContent = detailTrade ? (
|
|
202
196
|
<TradeDetail trade={detailTrade} width={width} />
|
|
203
197
|
) : detailMember ? (
|
|
204
|
-
<
|
|
198
|
+
<MemberTradesDetail
|
|
199
|
+
focused={focused}
|
|
205
200
|
member={detailMember}
|
|
206
|
-
|
|
201
|
+
initialTrades={detailMemberTrades}
|
|
207
202
|
width={width}
|
|
208
|
-
|
|
203
|
+
filingLimit={payload?.filingCount ?? CONGRESS_MEMBER_FILING_LIMIT}
|
|
209
204
|
/>
|
|
210
205
|
) : null;
|
|
211
206
|
const detailTitle = detailTrade
|
|
@@ -12,7 +12,14 @@ import {
|
|
|
12
12
|
type MemberColumn,
|
|
13
13
|
type TradeColumn,
|
|
14
14
|
} from "./model";
|
|
15
|
-
|
|
15
|
+
|
|
16
|
+
export function sideColor(side: CloudCongressTradePayload["side"], selected: boolean): string {
|
|
17
|
+
if (selected) return colors.selectedText;
|
|
18
|
+
if (side === "BUY") return colors.positive;
|
|
19
|
+
if (side === "SELL") return colors.negative;
|
|
20
|
+
if (side === "EXCHANGE") return colors.warning;
|
|
21
|
+
return colors.textDim;
|
|
22
|
+
}
|
|
16
23
|
|
|
17
24
|
export function renderCongressTradeCell(
|
|
18
25
|
trade: CloudCongressTradePayload,
|
|
@@ -51,6 +58,8 @@ export function renderCongressTradeCell(
|
|
|
51
58
|
text: formatAmountRange(trade.amountLow, trade.amountHigh, trade.amount),
|
|
52
59
|
color: selectedColor ?? colors.textBright,
|
|
53
60
|
};
|
|
61
|
+
case "asset":
|
|
62
|
+
return { text: trade.assetName, color: selectedColor ?? colors.text };
|
|
54
63
|
case "owner":
|
|
55
64
|
return { text: trade.owner, color: selectedColor ?? colors.textDim };
|
|
56
65
|
}
|
|
@@ -90,20 +90,6 @@ export function buildOverviewStats({
|
|
|
90
90
|
valueColor: priceColor(fundamentals.revenueGrowth),
|
|
91
91
|
});
|
|
92
92
|
}
|
|
93
|
-
if (fundamentals?.return1Y != null) {
|
|
94
|
-
stats.push({
|
|
95
|
-
label: "1Y Return",
|
|
96
|
-
value: formatPercent(fundamentals.return1Y),
|
|
97
|
-
valueColor: priceColor(fundamentals.return1Y),
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
if (fundamentals?.return3Y != null) {
|
|
101
|
-
stats.push({
|
|
102
|
-
label: "3Y Return",
|
|
103
|
-
value: formatPercent(fundamentals.return3Y),
|
|
104
|
-
valueColor: priceColor(fundamentals.return3Y),
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
93
|
if (fundamentals?.enterpriseValue) {
|
|
108
94
|
stats.push({ label: "EV", value: formatCompact(fundamentals.enterpriseValue) });
|
|
109
95
|
}
|
|
@@ -15,6 +15,11 @@ import {
|
|
|
15
15
|
import { selectEffectiveExchangeRates } from "../../../utils/exchange-rate-map";
|
|
16
16
|
import { EmptyState } from "../../../components";
|
|
17
17
|
import { ResolvedStockChart } from "../../../components/chart/stock-chart";
|
|
18
|
+
import { PriceReturnStrip } from "../../../components/price-performance";
|
|
19
|
+
import {
|
|
20
|
+
appendQuoteToPriceReturnHistory,
|
|
21
|
+
buildPriceReturnFields,
|
|
22
|
+
} from "../../../market-data/performance";
|
|
18
23
|
import type { TickerFinancials } from "../../../types/financials";
|
|
19
24
|
import type { TickerRecord } from "../../../types/ticker";
|
|
20
25
|
import {
|
|
@@ -96,6 +101,19 @@ export function OverviewTab({
|
|
|
96
101
|
baseCurrency,
|
|
97
102
|
toBase,
|
|
98
103
|
});
|
|
104
|
+
const performanceFields = buildPriceReturnFields(
|
|
105
|
+
appendQuoteToPriceReturnHistory(financials?.priceHistory ?? [], quote),
|
|
106
|
+
).map((field) => {
|
|
107
|
+
if (field.value != null) return field;
|
|
108
|
+
if (field.id === "1Y" && fundamentals?.return1Y != null) {
|
|
109
|
+
return { ...field, value: fundamentals.return1Y };
|
|
110
|
+
}
|
|
111
|
+
if (field.id === "3Y" && fundamentals?.return3Y != null) {
|
|
112
|
+
return { ...field, value: fundamentals.return3Y };
|
|
113
|
+
}
|
|
114
|
+
return field;
|
|
115
|
+
});
|
|
116
|
+
const hasPerformance = performanceFields.some((field) => field.value != null);
|
|
99
117
|
const positionRows = buildPositionRows({
|
|
100
118
|
ticker,
|
|
101
119
|
quote,
|
|
@@ -210,6 +228,13 @@ export function OverviewTab({
|
|
|
210
228
|
/>
|
|
211
229
|
)}
|
|
212
230
|
|
|
231
|
+
{hasPerformance && (
|
|
232
|
+
<Box flexDirection="column">
|
|
233
|
+
<SectionHeader title="Price Return" />
|
|
234
|
+
<PriceReturnStrip fields={performanceFields} width={contentWidth} />
|
|
235
|
+
</Box>
|
|
236
|
+
)}
|
|
237
|
+
|
|
213
238
|
{stats.length > 0 && (
|
|
214
239
|
<Box flexDirection="column">
|
|
215
240
|
<SectionHeader title="Fundamentals" />
|
|
@@ -23,8 +23,13 @@ import {
|
|
|
23
23
|
type WindowResizeEvent,
|
|
24
24
|
} from "./window-events";
|
|
25
25
|
import type { DesktopStateBroadcaster, DesktopStateRpc } from "./state-broadcaster";
|
|
26
|
+
import { applyWindowsCustomChrome } from "./windows-custom-chrome";
|
|
26
27
|
import { applyWindowsWindowIcon } from "./windows-icons";
|
|
27
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
desktopTitleBarStyle,
|
|
30
|
+
desktopWindowRenderer,
|
|
31
|
+
desktopWindowStyleMask,
|
|
32
|
+
} from "./window-style";
|
|
28
33
|
|
|
29
34
|
const INITIAL_DOCK_SUPPRESSION_MS = 800;
|
|
30
35
|
const WINDOW_CONTROL_DOCK_SUPPRESSION_MS = 5_000;
|
|
@@ -188,13 +193,15 @@ export class DesktopDetachedWindowManager<Rpc extends DesktopStateRpc> {
|
|
|
188
193
|
title,
|
|
189
194
|
frame: initialFrame,
|
|
190
195
|
url: "views://mainview/index.html",
|
|
191
|
-
renderer:
|
|
196
|
+
renderer: desktopWindowRenderer(),
|
|
192
197
|
rpc: rpc as never,
|
|
198
|
+
styleMask: desktopWindowStyleMask(),
|
|
193
199
|
titleBarStyle: desktopTitleBarStyle(),
|
|
194
200
|
navigationRules: JSON.stringify(["views://*"]),
|
|
195
201
|
sandbox: false,
|
|
196
202
|
});
|
|
197
203
|
applyWindowsWindowIcon(title);
|
|
204
|
+
applyWindowsCustomChrome(title);
|
|
198
205
|
updateWindowFrameCache(window, initialFrame, DETACHED_WINDOW_MIN_SIZE);
|
|
199
206
|
this.windows.set(instanceId, window);
|
|
200
207
|
this.suppressDockUntil.set(instanceId, Date.now() + INITIAL_DOCK_SUPPRESSION_MS);
|
|
@@ -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
|
};
|
|
@@ -22,7 +22,16 @@ const maximizedWindowRestoreFrames = new WeakMap<ControllableDesktopWindow, Desk
|
|
|
22
22
|
function normalizeFrame(frame: Partial<DesktopWindowFrame> | null | undefined): DesktopWindowFrame | null {
|
|
23
23
|
if (!frame) return null;
|
|
24
24
|
const { x, y, width, height } = frame;
|
|
25
|
-
if (
|
|
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
|
+
) {
|
|
26
35
|
return null;
|
|
27
36
|
}
|
|
28
37
|
return { x, y, width, height };
|
|
@@ -56,7 +65,7 @@ export function applyDesktopWindowControl(
|
|
|
56
65
|
if (maximizedWindowRestoreFrames.has(window)) {
|
|
57
66
|
maximizedWindowRestoreFrames.delete(window);
|
|
58
67
|
window.unmaximize?.();
|
|
59
|
-
restoreWindowFrame(window, restoreFrame);
|
|
68
|
+
restoreWindowFrame(window, restoreFrame ?? null);
|
|
60
69
|
return;
|
|
61
70
|
}
|
|
62
71
|
maximizedWindowRestoreFrames.set(window, readWindowFrame(window));
|
|
@@ -1,5 +1,31 @@
|
|
|
1
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
|
+
};
|
|
2
10
|
|
|
3
11
|
export function desktopTitleBarStyle(): DesktopTitleBarStyle {
|
|
4
|
-
|
|
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
|
+
};
|
|
5
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
|
+
}
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { existsSync } from "fs";
|
|
2
2
|
import { resolve } from "path";
|
|
3
|
-
import {
|
|
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";
|
|
4
11
|
|
|
5
12
|
const WM_SETICON = 0x0080;
|
|
6
13
|
const ICON_SMALL = 0;
|
|
@@ -10,70 +17,10 @@ const LR_LOADFROMFILE = 0x0010;
|
|
|
10
17
|
const LR_DEFAULTSIZE = 0x0040;
|
|
11
18
|
const GCLP_HICON = -14;
|
|
12
19
|
const GCLP_HICONSM = -34;
|
|
13
|
-
|
|
14
|
-
const RETRY_DELAY_MS = 100;
|
|
15
|
-
|
|
16
|
-
type Win32 = ReturnType<typeof loadWin32>;
|
|
17
|
-
|
|
18
|
-
let cachedWin32: Win32 | null | undefined;
|
|
20
|
+
type Win32 = NonNullable<ReturnType<typeof win32OrNull>>;
|
|
19
21
|
let smallIconHandle: Pointer | null = null;
|
|
20
22
|
let bigIconHandle: Pointer | null = null;
|
|
21
23
|
|
|
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
24
|
function loadIconHandle(win32: Win32, iconPath: string, size: number): Pointer | null {
|
|
78
25
|
const iconPathBuffer = wideString(iconPath);
|
|
79
26
|
const iconHandle = win32.symbols.LoadImageW(
|
|
@@ -117,7 +64,7 @@ export function applyWindowsWindowIcon(title: string, attempt = 1): void {
|
|
|
117
64
|
const windowHandle = findCurrentProcessWindow(win32, title);
|
|
118
65
|
if (windowHandle && setWindowIcon(win32, windowHandle)) return;
|
|
119
66
|
|
|
120
|
-
if (attempt <
|
|
121
|
-
setTimeout(() => applyWindowsWindowIcon(title, attempt + 1),
|
|
67
|
+
if (attempt < WINDOWS_HANDLE_MAX_ATTEMPTS) {
|
|
68
|
+
setTimeout(() => applyWindowsWindowIcon(title, attempt + 1), WINDOWS_HANDLE_RETRY_DELAY_MS);
|
|
122
69
|
}
|
|
123
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
|
+
}
|
|
@@ -47,8 +47,13 @@ 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";
|
|
50
51
|
import { applyWindowsWindowIcon } from "./desktop/windows-icons";
|
|
51
|
-
import {
|
|
52
|
+
import {
|
|
53
|
+
desktopTitleBarStyle,
|
|
54
|
+
desktopWindowRenderer,
|
|
55
|
+
desktopWindowStyleMask,
|
|
56
|
+
} from "./desktop/window-style";
|
|
52
57
|
import { applyDesktopWindowControl, type DesktopWindowControlAction } from "./desktop/window-controls";
|
|
53
58
|
|
|
54
59
|
type DesktopRpc = ReturnType<typeof BrowserView.defineRPC<ElectrobunDesktopRpcSchema>>;
|
|
@@ -458,13 +463,15 @@ mainWindow = new BrowserWindow({
|
|
|
458
463
|
title: "Gloomberb",
|
|
459
464
|
frame: initialMainWindowFrame,
|
|
460
465
|
url: "views://mainview/index.html",
|
|
461
|
-
renderer:
|
|
466
|
+
renderer: desktopWindowRenderer(),
|
|
462
467
|
rpc: mainRpc,
|
|
468
|
+
styleMask: desktopWindowStyleMask(),
|
|
463
469
|
titleBarStyle: desktopTitleBarStyle(),
|
|
464
470
|
navigationRules: JSON.stringify(["views://*"]),
|
|
465
471
|
sandbox: false,
|
|
466
472
|
});
|
|
467
473
|
applyWindowsWindowIcon("Gloomberb");
|
|
474
|
+
applyWindowsCustomChrome("Gloomberb");
|
|
468
475
|
updateWindowFrameCache(mainWindow, initialMainWindowFrame, MAIN_WINDOW_MIN_SIZE);
|
|
469
476
|
detachedWindowManager.focusWindowForRpcKey(MAIN_WINDOW_RPC_KEY);
|
|
470
477
|
(mainWindow as any).on?.("move", (event: WindowMoveEvent) => {
|
|
@@ -14,6 +14,7 @@ export interface ElectrobunBackendInit {
|
|
|
14
14
|
desktopThemePreview: DesktopThemePreviewState;
|
|
15
15
|
pluginState: Record<string, Record<string, unknown>>;
|
|
16
16
|
capabilityManifests: CapabilityManifest[];
|
|
17
|
+
desktopPlatform: string;
|
|
17
18
|
windowKind: "main" | "detached";
|
|
18
19
|
paneId?: string;
|
|
19
20
|
}
|
|
@@ -19,7 +19,7 @@ import { DesktopFatalScreen, ElectrobunErrorBoundary } from "./fatal-screen";
|
|
|
19
19
|
import { WebInputHostProvider } from "./input-host";
|
|
20
20
|
import { webNativeRenderer } from "./native-renderer";
|
|
21
21
|
import { WebToastHostProvider } from "./toast-host";
|
|
22
|
-
import {
|
|
22
|
+
import { createWebUiHost, webRendererHost } from "./ui-host";
|
|
23
23
|
import { createApplicationMenuBridge } from "./application-menu-bridge";
|
|
24
24
|
import { createDesktopWindowBridge } from "./desktop/window/bridge";
|
|
25
25
|
import { prepareDetachedSnapshot } from "./desktop/window/snapshot";
|
|
@@ -88,6 +88,7 @@ async function boot() {
|
|
|
88
88
|
const config = desktopSnapshot?.config ?? init.config;
|
|
89
89
|
const desktopWindowBridge = createDesktopWindowBridge(init.windowKind, init.paneId);
|
|
90
90
|
const desktopApplicationMenuBridge = createApplicationMenuBridge();
|
|
91
|
+
const webUiHost = createWebUiHost(init.desktopPlatform);
|
|
91
92
|
measurePerfAsync("startup.electrobun.root-render", async () => {
|
|
92
93
|
root.render(
|
|
93
94
|
<ElectrobunErrorBoundary>
|