gloomberb 0.9.5 → 0.9.7
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 +49 -8
- package/package.json +2 -2
- package/src/app/global-shortcuts.test.tsx +20 -1
- package/src/app/global-shortcuts.ts +2 -0
- package/src/app/pane-runtime/index.tsx +11 -4
- package/src/app/pane-runtime/pane-settings-runtime.tsx +1 -1
- package/src/app.tsx +41 -17
- package/src/cli/commands/remote.ts +206 -0
- package/src/cli/index.ts +2 -0
- package/src/components/chart/core/keyboard.ts +10 -1
- package/src/components/chart/static/bar-chart-surface.test.tsx +4 -0
- package/src/components/chart/static/chart/surface.test.tsx +59 -4
- package/src/components/chart/static/chart/surface.tsx +4 -0
- package/src/components/chart/stock-chart/index.test.ts +7 -0
- package/src/components/command-bar/list/view.tsx +47 -0
- package/src/components/command-bar/multi-select-picker.tsx +7 -0
- package/src/components/command-bar/routing/navigation-state.ts +10 -1
- package/src/components/command-bar/surface/index.test.tsx +14 -0
- package/src/components/command-bar/theme-picker.tsx +91 -79
- package/src/components/command-bar/workflow/field-row.tsx +30 -5
- package/src/components/index.ts +7 -0
- package/src/components/layout/pane/footer/index.tsx +23 -0
- package/src/components/layout/shell/index.test.tsx +2 -0
- package/src/components/layout/shell/index.tsx +8 -3
- package/src/components/layout/shell/pane/actions.ts +21 -7
- package/src/components/metric-treemap/index.tsx +317 -0
- package/src/components/metric-treemap/layout.test.ts +77 -0
- package/src/components/metric-treemap/layout.ts +476 -0
- package/src/components/toggle-list.tsx +10 -0
- package/src/components/ui/button.tsx +12 -0
- package/src/components/ui/data-table/index.tsx +51 -0
- package/src/components/ui/fields.tsx +18 -0
- package/src/components/ui/list-view.tsx +83 -3
- package/src/components/ui/message-composer.tsx +22 -0
- package/src/components/ui/tabs.tsx +45 -0
- package/src/core/state/app/layout-reducer.ts +7 -1
- package/src/core/state/app/layout.ts +6 -0
- package/src/core/state/app/state.ts +23 -1
- package/src/core/state/app/types.ts +4 -2
- package/src/pane-settings.test.ts +1 -0
- package/src/plugins/builtin/chat/channels.ts +5 -18
- package/src/plugins/builtin/chat/chat.test.tsx +1 -7
- package/src/plugins/builtin/chat/status-widget.tsx +1 -7
- package/src/plugins/builtin/cloud/plugin.tsx +4 -13
- package/src/plugins/builtin/holders/treemap.tsx +28 -297
- package/src/plugins/builtin/holders/types.ts +0 -34
- package/src/plugins/builtin/market-heatmap/data.test.ts +121 -0
- package/src/plugins/builtin/market-heatmap/data.ts +296 -0
- package/src/plugins/builtin/market-heatmap/index.tsx +320 -0
- package/src/plugins/builtin/plugin-groups.ts +2 -0
- package/src/plugins/builtin/portfolio-list/grid.tsx +197 -0
- package/src/plugins/builtin/portfolio-list/index.test.ts +52 -1
- package/src/plugins/builtin/portfolio-list/index.tsx +1 -0
- package/src/plugins/builtin/portfolio-list/pane/data.ts +15 -1
- package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +149 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +67 -26
- package/src/plugins/builtin/portfolio-list/pane/streaming.ts +54 -4
- package/src/plugins/builtin/portfolio-list/settings.ts +36 -1
- package/src/plugins/ibkr/gateway/service/index.test.ts +22 -0
- package/src/plugins/ibkr/gateway/service/index.ts +6 -9
- package/src/plugins/ibkr/gateway/service/native-loader.ts +12 -0
- package/src/remote/app-host.tsx +77 -0
- package/src/remote/client.ts +34 -0
- package/src/remote/command-bar.ts +98 -0
- package/src/remote/controller-utils.ts +111 -0
- package/src/remote/controller.test.ts +365 -0
- package/src/remote/controller.ts +434 -0
- package/src/remote/endpoint.ts +41 -0
- package/src/remote/json-patch.test.ts +31 -0
- package/src/remote/json-patch.ts +89 -0
- package/src/remote/layout-helpers.ts +62 -0
- package/src/remote/resources.ts +186 -0
- package/src/remote/revision.ts +18 -0
- package/src/remote/schema.ts +143 -0
- package/src/remote/semantic-helpers.ts +161 -0
- package/src/remote/semantic-tree.tsx +112 -0
- package/src/remote/server.test.ts +50 -0
- package/src/remote/server.ts +121 -0
- package/src/remote/types.ts +119 -0
- package/src/renderers/electrobun/bun/index.ts +55 -1
- package/src/renderers/electrobun/shared/protocol.ts +11 -1
- package/src/renderers/electrobun/view/backend-rpc.ts +28 -1
- package/src/renderers/electrobun/view/build-assets.ts +1 -0
- package/src/renderers/electrobun/view/host/box.tsx +5 -0
- package/src/renderers/electrobun/view/host/mouse.test.ts +37 -0
- package/src/renderers/electrobun/view/host/mouse.ts +2 -0
- package/src/renderers/electrobun/view/main.tsx +5 -1
- package/src/renderers/electrobun/view/native-stubs/ibkr-native-loader.ts +3 -0
- package/src/renderers/opentui/chart-surface.test.tsx +101 -0
- package/src/renderers/opentui/chart-surface.tsx +15 -20
- package/src/renderers/opentui/start.tsx +32 -0
- package/src/sources/yahoo-finance/http.ts +31 -4
- package/src/state/app/context/index.test.tsx +47 -0
- package/src/state/app/context/index.tsx +48 -18
- package/src/types/config.ts +1 -0
- package/src/ui/host.tsx +1 -0
- package/src/ui/index.tsx +183 -7
- package/src/version.ts +1 -1
- package/src/plugins/builtin/holders/treemap-layout.ts +0 -231
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "http";
|
|
3
|
+
import type { RemoteAppKind, RemoteControlRequest, RemoteControlResponse, RemoteEndpoint } from "./types";
|
|
4
|
+
import { REMOTE_CONTROL_PROTOCOL_VERSION } from "./types";
|
|
5
|
+
import { removeRemoteEndpointFiles, writeRemoteEndpointFiles } from "./endpoint";
|
|
6
|
+
|
|
7
|
+
export interface RemoteControlServerOptions {
|
|
8
|
+
dataDir: string;
|
|
9
|
+
appKind: RemoteAppKind;
|
|
10
|
+
handle: (request: RemoteControlRequest) => Promise<RemoteControlResponse>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface RemoteControlServer {
|
|
14
|
+
endpoint: RemoteEndpoint;
|
|
15
|
+
close(): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
export async function startRemoteControlServer(options: RemoteControlServerOptions): Promise<RemoteControlServer> {
|
|
21
|
+
const token = randomUUID();
|
|
22
|
+
const httpServer = createServer((request, response) => {
|
|
23
|
+
void handleHttpRequest(options.handle, token, request, response);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
await new Promise<void>((resolve, reject) => {
|
|
27
|
+
httpServer.once("error", reject);
|
|
28
|
+
httpServer.listen(0, "127.0.0.1", () => {
|
|
29
|
+
httpServer.off("error", reject);
|
|
30
|
+
resolve();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const address = httpServer.address();
|
|
35
|
+
if (!address || typeof address === "string") {
|
|
36
|
+
httpServer.close();
|
|
37
|
+
throw new Error("Remote control server did not bind to a TCP port.");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const endpoint: RemoteEndpoint = {
|
|
41
|
+
protocolVersion: REMOTE_CONTROL_PROTOCOL_VERSION,
|
|
42
|
+
appKind: options.appKind,
|
|
43
|
+
pid: process.pid,
|
|
44
|
+
port: address.port,
|
|
45
|
+
token,
|
|
46
|
+
startedAt: new Date().toISOString(),
|
|
47
|
+
};
|
|
48
|
+
await writeRemoteEndpointFiles(options.dataDir, endpoint);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
endpoint,
|
|
52
|
+
close: async () => {
|
|
53
|
+
await new Promise<void>((resolve, reject) => {
|
|
54
|
+
httpServer.close((error) => (error ? reject(error) : resolve()));
|
|
55
|
+
}).catch((error) => {
|
|
56
|
+
if ((error as NodeJS.ErrnoException).code !== "ERR_SERVER_NOT_RUNNING") throw error;
|
|
57
|
+
});
|
|
58
|
+
await removeRemoteEndpointFiles(options.dataDir, endpoint);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function handleHttpRequest(
|
|
64
|
+
handle: RemoteControlServerOptions["handle"],
|
|
65
|
+
token: string,
|
|
66
|
+
request: IncomingMessage,
|
|
67
|
+
response: ServerResponse,
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
try {
|
|
70
|
+
if (request.method === "GET" && request.url === "/health") {
|
|
71
|
+
sendJson(response, 200, { ok: true });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (request.method !== "POST" || request.url !== "/rpc") {
|
|
75
|
+
sendJson(response, 404, { ok: false, error: { code: "not_found", message: "Unknown remote control endpoint." } });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (request.headers.authorization !== `Bearer ${token}`) {
|
|
79
|
+
sendJson(response, 401, { ok: false, error: { code: "unauthorized", message: "Invalid remote control token." } });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const body = await readBody(request);
|
|
84
|
+
const remoteRequest = JSON.parse(body) as RemoteControlRequest;
|
|
85
|
+
sendJson(response, 200, await handle(remoteRequest));
|
|
86
|
+
} catch (error) {
|
|
87
|
+
sendJson(response, 500, {
|
|
88
|
+
ok: false,
|
|
89
|
+
error: {
|
|
90
|
+
code: "remote_transport_error",
|
|
91
|
+
message: error instanceof Error ? error.message : String(error),
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function readBody(request: IncomingMessage): Promise<string> {
|
|
98
|
+
let total = 0;
|
|
99
|
+
const chunks: Uint8Array[] = [];
|
|
100
|
+
for await (const chunk of request) {
|
|
101
|
+
const bytes = chunk instanceof Uint8Array ? new Uint8Array(chunk) : new TextEncoder().encode(String(chunk));
|
|
102
|
+
total += bytes.byteLength;
|
|
103
|
+
if (total > MAX_BODY_BYTES) throw new Error("Remote control request is too large.");
|
|
104
|
+
chunks.push(bytes);
|
|
105
|
+
}
|
|
106
|
+
const body = new Uint8Array(total);
|
|
107
|
+
let offset = 0;
|
|
108
|
+
for (const chunk of chunks) {
|
|
109
|
+
body.set(chunk, offset);
|
|
110
|
+
offset += chunk.byteLength;
|
|
111
|
+
}
|
|
112
|
+
return new TextDecoder().decode(body);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function sendJson(response: ServerResponse, status: number, body: unknown): void {
|
|
116
|
+
response.writeHead(status, {
|
|
117
|
+
"content-type": "application/json; charset=utf-8",
|
|
118
|
+
"cache-control": "no-store",
|
|
119
|
+
});
|
|
120
|
+
response.end(JSON.stringify(body));
|
|
121
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
export const REMOTE_CONTROL_PROTOCOL_VERSION = 1;
|
|
2
|
+
|
|
3
|
+
export type RemoteAppKind = "tui" | "desktop";
|
|
4
|
+
|
|
5
|
+
export interface RemoteEndpoint {
|
|
6
|
+
protocolVersion: number;
|
|
7
|
+
appKind: RemoteAppKind;
|
|
8
|
+
pid: number;
|
|
9
|
+
port: number;
|
|
10
|
+
token: string;
|
|
11
|
+
startedAt: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RemoteJsonPatchOperation {
|
|
15
|
+
op: "add" | "replace" | "remove";
|
|
16
|
+
path: string;
|
|
17
|
+
value?: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type RemoteStateInclude =
|
|
21
|
+
| "app"
|
|
22
|
+
| "layout"
|
|
23
|
+
| "panes"
|
|
24
|
+
| "commandBar"
|
|
25
|
+
| "commandBar.results"
|
|
26
|
+
| "ui"
|
|
27
|
+
| "schema"
|
|
28
|
+
| "help"
|
|
29
|
+
| "all";
|
|
30
|
+
|
|
31
|
+
export type RemoteControlRequest =
|
|
32
|
+
| { type: "help"; topic?: string }
|
|
33
|
+
| { type: "schema" }
|
|
34
|
+
| { type: "get"; resource: string; include?: RemoteStateInclude[] }
|
|
35
|
+
| { type: "call"; operation: string; input?: unknown; dryRun?: boolean; include?: RemoteStateInclude[] }
|
|
36
|
+
| {
|
|
37
|
+
type: "patch";
|
|
38
|
+
resource: string;
|
|
39
|
+
patch: RemoteJsonPatchOperation[];
|
|
40
|
+
expectRev?: string;
|
|
41
|
+
dryRun?: boolean;
|
|
42
|
+
include?: RemoteStateInclude[];
|
|
43
|
+
}
|
|
44
|
+
| {
|
|
45
|
+
type: "batch";
|
|
46
|
+
requests: RemoteControlRequest[];
|
|
47
|
+
dryRun?: boolean;
|
|
48
|
+
haltOnError?: boolean;
|
|
49
|
+
settle?: "none" | "afterEach" | "afterBatch";
|
|
50
|
+
include?: RemoteStateInclude[];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export interface RemoteIncludedState {
|
|
54
|
+
rev: string;
|
|
55
|
+
included: RemoteStateInclude[];
|
|
56
|
+
app?: unknown;
|
|
57
|
+
layout?: unknown;
|
|
58
|
+
panes?: unknown;
|
|
59
|
+
commandBar?: unknown;
|
|
60
|
+
ui?: RemoteUiNodeSnapshot[];
|
|
61
|
+
schema?: RemoteControlSchema;
|
|
62
|
+
help?: unknown;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface RemoteControlSuccess<T = unknown> {
|
|
66
|
+
ok: true;
|
|
67
|
+
data: T;
|
|
68
|
+
rev?: string;
|
|
69
|
+
state?: RemoteIncludedState;
|
|
70
|
+
warnings?: string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface RemoteControlFailure {
|
|
74
|
+
ok: false;
|
|
75
|
+
error: {
|
|
76
|
+
code: string;
|
|
77
|
+
message: string;
|
|
78
|
+
details?: unknown;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type RemoteControlResponse<T = unknown> =
|
|
83
|
+
| RemoteControlSuccess<T>
|
|
84
|
+
| RemoteControlFailure;
|
|
85
|
+
|
|
86
|
+
export interface RemoteResourceSchema {
|
|
87
|
+
uri: string;
|
|
88
|
+
description: string;
|
|
89
|
+
patchable?: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface RemoteOperationSchema {
|
|
93
|
+
id: string;
|
|
94
|
+
description: string;
|
|
95
|
+
inputShape?: string;
|
|
96
|
+
outputShape?: string;
|
|
97
|
+
returns?: string;
|
|
98
|
+
recommendedNextReads?: string[];
|
|
99
|
+
examples?: unknown[];
|
|
100
|
+
sideEffectLevel: "none" | "local-write" | "network-write" | "external-side-effect" | "external-trade";
|
|
101
|
+
requiresConfirmation?: boolean;
|
|
102
|
+
dryRun?: boolean;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface RemoteControlSchema {
|
|
106
|
+
protocolVersion: number;
|
|
107
|
+
resources: RemoteResourceSchema[];
|
|
108
|
+
operations: RemoteOperationSchema[];
|
|
109
|
+
help: unknown;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface RemoteUiNodeSnapshot {
|
|
113
|
+
id: string;
|
|
114
|
+
role: string;
|
|
115
|
+
label?: string;
|
|
116
|
+
disabled?: boolean;
|
|
117
|
+
actions: string[];
|
|
118
|
+
metadata?: Record<string, unknown>;
|
|
119
|
+
}
|
|
@@ -55,6 +55,8 @@ import {
|
|
|
55
55
|
desktopWindowStyleMask,
|
|
56
56
|
} from "./desktop/window-style";
|
|
57
57
|
import { applyDesktopWindowControl, type DesktopWindowControlAction } from "./desktop/window-controls";
|
|
58
|
+
import { startRemoteControlServer, type RemoteControlServer } from "../../../remote/server";
|
|
59
|
+
import type { RemoteControlRequest, RemoteControlResponse } from "../../../remote/types";
|
|
58
60
|
|
|
59
61
|
type DesktopRpc = ReturnType<typeof BrowserView.defineRPC<ElectrobunDesktopRpcSchema>>;
|
|
60
62
|
|
|
@@ -70,6 +72,7 @@ let services: AppServices | null = null;
|
|
|
70
72
|
let mainWindow: BrowserWindow | null = null;
|
|
71
73
|
let desktopWorkspace: DesktopWorkspace | null = null;
|
|
72
74
|
let desktopRestartInProgress = false;
|
|
75
|
+
let desktopRemoteControlServer: RemoteControlServer | null = null;
|
|
73
76
|
|
|
74
77
|
const windowRpcRegistry = createDesktopRpcRegistry<DesktopRpc>();
|
|
75
78
|
const contextMenuRequestRpcs = new Map<string, DesktopRpc>();
|
|
@@ -100,6 +103,10 @@ function requireDesktopWorkspace(): DesktopWorkspace {
|
|
|
100
103
|
return desktopWorkspace;
|
|
101
104
|
}
|
|
102
105
|
|
|
106
|
+
function remoteFailure(code: string, message: string): RemoteControlResponse {
|
|
107
|
+
return { ok: false, error: { code, message } };
|
|
108
|
+
}
|
|
109
|
+
|
|
103
110
|
const {
|
|
104
111
|
forEachReadyWindowRpc,
|
|
105
112
|
getRpcWindowKey,
|
|
@@ -130,6 +137,46 @@ const detachedWindowManager = new DesktopDetachedWindowManager<DesktopRpc>({
|
|
|
130
137
|
stateBroadcaster: desktopStateBroadcaster,
|
|
131
138
|
});
|
|
132
139
|
|
|
140
|
+
async function forwardRemoteControlRequest(request: RemoteControlRequest): Promise<RemoteControlResponse> {
|
|
141
|
+
const rpc = getWindowRpc(MAIN_WINDOW_RPC_KEY);
|
|
142
|
+
if (!rpc || !isWindowRpcReady(MAIN_WINDOW_RPC_KEY)) {
|
|
143
|
+
return remoteFailure("remote_unavailable", "The main desktop window is not ready for remote control requests.");
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const response = await rpc.request["remote.request"]({
|
|
147
|
+
request: encodeRpcValue(request) as RemoteControlRequest,
|
|
148
|
+
});
|
|
149
|
+
return decodeRpcValue<RemoteControlResponse>(response);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
return remoteFailure(
|
|
152
|
+
"remote_forward_error",
|
|
153
|
+
error instanceof Error ? error.message : String(error),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function ensureDesktopRemoteControlServer(): Promise<void> {
|
|
159
|
+
if (desktopRemoteControlServer) return;
|
|
160
|
+
const config = requireConfig();
|
|
161
|
+
desktopRemoteControlServer = await startRemoteControlServer({
|
|
162
|
+
dataDir: config.dataDir,
|
|
163
|
+
appKind: "desktop",
|
|
164
|
+
handle: forwardRemoteControlRequest,
|
|
165
|
+
});
|
|
166
|
+
console.error("[remote] desktop control endpoint started", {
|
|
167
|
+
port: desktopRemoteControlServer.endpoint.port,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function stopDesktopRemoteControlServer(): void {
|
|
172
|
+
const server = desktopRemoteControlServer;
|
|
173
|
+
desktopRemoteControlServer = null;
|
|
174
|
+
if (!server) return;
|
|
175
|
+
void server.close().catch((error) => {
|
|
176
|
+
console.error("[remote] desktop control endpoint cleanup failed", summarizeError(error));
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
133
180
|
function openMainWindowDevTools(): void {
|
|
134
181
|
mainWindow?.webview.openDevTools();
|
|
135
182
|
}
|
|
@@ -228,6 +275,7 @@ function disposeWindowScopedResources(windowKey: string): void {
|
|
|
228
275
|
}
|
|
229
276
|
|
|
230
277
|
function teardownServices(): void {
|
|
278
|
+
stopDesktopRemoteControlServer();
|
|
231
279
|
capabilityBridge.disposeAll();
|
|
232
280
|
services?.destroy();
|
|
233
281
|
services = null;
|
|
@@ -306,7 +354,7 @@ async function initialize(
|
|
|
306
354
|
rpc: DesktopRpc,
|
|
307
355
|
payload: Record<string, unknown>,
|
|
308
356
|
) {
|
|
309
|
-
|
|
357
|
+
const init = await initializeDesktopBackend({
|
|
310
358
|
getCurrentConfig: () => currentConfig,
|
|
311
359
|
getCurrentServices: () => services,
|
|
312
360
|
getDesktopSnapshot,
|
|
@@ -328,6 +376,12 @@ async function initialize(
|
|
|
328
376
|
},
|
|
329
377
|
syncConfigAccessors,
|
|
330
378
|
});
|
|
379
|
+
if (init.windowKind === "main") {
|
|
380
|
+
void ensureDesktopRemoteControlServer().catch((error) => {
|
|
381
|
+
console.error("[remote] desktop control endpoint failed", summarizeError(error));
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
return init;
|
|
331
385
|
}
|
|
332
386
|
|
|
333
387
|
async function handleBackendRequest(
|
|
@@ -4,6 +4,7 @@ import type { DesktopApplicationMenuCommand } from "../../../types/desktop-menu"
|
|
|
4
4
|
import type { AppConfig } from "../../../types/config";
|
|
5
5
|
import type { UpdateProgress } from "../../../updater";
|
|
6
6
|
import type { CapabilityManifest } from "../../../capabilities";
|
|
7
|
+
import type { RemoteControlRequest, RemoteControlResponse } from "../../../remote/types";
|
|
7
8
|
|
|
8
9
|
export const ELECTROBUN_CONTEXT_MENU_ACTION = "gloom.context-menu.select";
|
|
9
10
|
|
|
@@ -59,6 +60,10 @@ export interface DesktopRestartMessage {
|
|
|
59
60
|
source?: string;
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
export interface RemoteControlRequestMessage {
|
|
64
|
+
request: RemoteControlRequest;
|
|
65
|
+
}
|
|
66
|
+
|
|
62
67
|
export interface ElectrobunDesktopRpcSchema {
|
|
63
68
|
bun: {
|
|
64
69
|
requests: {
|
|
@@ -72,7 +77,12 @@ export interface ElectrobunDesktopRpcSchema {
|
|
|
72
77
|
};
|
|
73
78
|
};
|
|
74
79
|
webview: {
|
|
75
|
-
requests: {
|
|
80
|
+
requests: {
|
|
81
|
+
"remote.request": {
|
|
82
|
+
params: RemoteControlRequestMessage;
|
|
83
|
+
response: RemoteControlResponse;
|
|
84
|
+
};
|
|
85
|
+
};
|
|
76
86
|
messages: {
|
|
77
87
|
"context-menu.select": ContextMenuSelectMessage;
|
|
78
88
|
"application-menu.select": ApplicationMenuSelectMessage;
|
|
@@ -9,10 +9,12 @@ import {
|
|
|
9
9
|
type DesktopStateMessage,
|
|
10
10
|
type DesktopThemePreviewMessage,
|
|
11
11
|
type ElectrobunBackendInit,
|
|
12
|
+
type RemoteControlRequestMessage,
|
|
12
13
|
type ElectrobunDesktopRpcSchema,
|
|
13
14
|
type UpdateProgressMessage,
|
|
14
15
|
} from "../shared/protocol";
|
|
15
16
|
import { decodeRpcValue, encodeRpcValue } from "./rpc-codec";
|
|
17
|
+
import type { RemoteControlRequest, RemoteControlResponse } from "../../../remote/types";
|
|
16
18
|
|
|
17
19
|
type ContextMenuSelectListener = (message: ContextMenuSelectMessage) => void;
|
|
18
20
|
type ApplicationMenuSelectListener = (message: ApplicationMenuSelectMessage) => void;
|
|
@@ -21,8 +23,10 @@ type DesktopDockPreviewListener = (message: DesktopDockPreviewMessage) => void;
|
|
|
21
23
|
type DesktopThemePreviewListener = (message: DesktopThemePreviewMessage) => void;
|
|
22
24
|
type UpdateProgressListener = (message: UpdateProgressMessage) => void;
|
|
23
25
|
type CapabilityEventListener = (message: CapabilityEventMessage) => void;
|
|
26
|
+
type RemoteControlRequestHandler = (request: RemoteControlRequest) => Promise<RemoteControlResponse>;
|
|
24
27
|
|
|
25
28
|
let initSnapshot: ElectrobunBackendInit | null = null;
|
|
29
|
+
let remoteControlRequestHandler: RemoteControlRequestHandler | null = null;
|
|
26
30
|
const contextMenuSelectListeners = new Map<string, Set<ContextMenuSelectListener>>();
|
|
27
31
|
const applicationMenuSelectListeners = new Set<ApplicationMenuSelectListener>();
|
|
28
32
|
const desktopStateListeners = new Set<DesktopStateListener>();
|
|
@@ -63,7 +67,21 @@ function subscribe<T>(
|
|
|
63
67
|
const rpc = Electroview.defineRPC<ElectrobunDesktopRpcSchema>({
|
|
64
68
|
maxRequestTime: 120_000,
|
|
65
69
|
handlers: {
|
|
66
|
-
requests: {
|
|
70
|
+
requests: {
|
|
71
|
+
"remote.request": async (message: RemoteControlRequestMessage) => {
|
|
72
|
+
if (!remoteControlRequestHandler) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
error: {
|
|
76
|
+
code: "remote_unavailable",
|
|
77
|
+
message: "The desktop app has not installed a remote control handler yet.",
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const decoded = decodeRpcValue<RemoteControlRequest>(message.request);
|
|
82
|
+
return encodeRpcValue(await remoteControlRequestHandler(decoded)) as RemoteControlResponse;
|
|
83
|
+
},
|
|
84
|
+
},
|
|
67
85
|
messages: {
|
|
68
86
|
"context-menu.select": (message) => {
|
|
69
87
|
dispatch(contextMenuSelectListeners, message.requestId, message);
|
|
@@ -140,6 +158,15 @@ export function getElectrobunBackendInitSnapshot(): ElectrobunBackendInit | null
|
|
|
140
158
|
return initSnapshot;
|
|
141
159
|
}
|
|
142
160
|
|
|
161
|
+
export function setElectrobunRemoteRequestHandler(handler: RemoteControlRequestHandler | null): () => void {
|
|
162
|
+
remoteControlRequestHandler = handler;
|
|
163
|
+
return () => {
|
|
164
|
+
if (remoteControlRequestHandler === handler) {
|
|
165
|
+
remoteControlRequestHandler = null;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
143
170
|
export function onCapabilityEvent(
|
|
144
171
|
subscriptionId: string,
|
|
145
172
|
listener: (message: CapabilityEventMessage) => void,
|
|
@@ -23,6 +23,7 @@ const COMMON_ALIAS_RULES: AliasRule[] = [
|
|
|
23
23
|
["./kitty/support", "components/chart/native/renderer-selection.ts", "native-stubs/chart/kitty-support.ts"],
|
|
24
24
|
["native/surface/manager", "native-stubs/chart/surface-manager.ts"],
|
|
25
25
|
["native/surface/sync", "native-stubs/chart/surface-sync.ts"],
|
|
26
|
+
["./native-loader", "plugins/ibkr/gateway/service/index.ts", "native-stubs/ibkr-native-loader.ts"],
|
|
26
27
|
];
|
|
27
28
|
|
|
28
29
|
export function electrobunViewPath(...parts: string[]): string {
|
|
@@ -135,6 +135,10 @@ export const WebBox = forwardRef<HTMLDivElement, Record<string, unknown> & { chi
|
|
|
135
135
|
callMouseHandler(propsRef.current.onMouseScroll, event, "scroll");
|
|
136
136
|
};
|
|
137
137
|
|
|
138
|
+
const handleMouseOver = (event: MouseEvent) => {
|
|
139
|
+
callMouseHandler(propsRef.current.onMouseOver, event, "over");
|
|
140
|
+
};
|
|
141
|
+
|
|
138
142
|
const handleMouseOut = (event: MouseEvent) => {
|
|
139
143
|
pendingMoveRef.current = null;
|
|
140
144
|
callMouseHandler(propsRef.current.onMouseOut, event, "out");
|
|
@@ -147,6 +151,7 @@ export const WebBox = forwardRef<HTMLDivElement, Record<string, unknown> & { chi
|
|
|
147
151
|
data-gloom-interactive={hasDirectMouseHandler(props) ? "true" : undefined}
|
|
148
152
|
ref={elementRef}
|
|
149
153
|
onMouseDown={handleMouseDown}
|
|
154
|
+
onMouseOver={handleMouseOver}
|
|
150
155
|
onMouseMove={(event) => scheduleFrameMouseHandler(event, "move")}
|
|
151
156
|
onMouseUp={(event) => callMouseHandler(propsRef.current.onMouseUp, event, "up")}
|
|
152
157
|
onMouseOut={handleMouseOut}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { hasDirectMouseHandler, mouseHandlers } from "./mouse";
|
|
3
|
+
|
|
4
|
+
function fakeMouseEvent() {
|
|
5
|
+
return {
|
|
6
|
+
clientX: 16,
|
|
7
|
+
clientY: 36,
|
|
8
|
+
button: 0,
|
|
9
|
+
detail: 0,
|
|
10
|
+
timeStamp: 1,
|
|
11
|
+
shiftKey: false,
|
|
12
|
+
altKey: false,
|
|
13
|
+
ctrlKey: false,
|
|
14
|
+
metaKey: false,
|
|
15
|
+
preventDefault() {},
|
|
16
|
+
stopPropagation() {},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("desktop web mouse host", () => {
|
|
21
|
+
test("forwards mouse over handlers", () => {
|
|
22
|
+
let cellX = -1;
|
|
23
|
+
let cellY = -1;
|
|
24
|
+
const handlers = mouseHandlers({
|
|
25
|
+
onMouseOver: (event: { x: number; y: number }) => {
|
|
26
|
+
cellX = event.x;
|
|
27
|
+
cellY = event.y;
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
handlers.onMouseOver?.(fakeMouseEvent() as never);
|
|
32
|
+
|
|
33
|
+
expect(cellX).toBe(2);
|
|
34
|
+
expect(cellY).toBe(2);
|
|
35
|
+
expect(hasDirectMouseHandler({ onMouseOver: () => {} })).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -73,6 +73,7 @@ export function mouseHandlers(props: Record<string, unknown>) {
|
|
|
73
73
|
);
|
|
74
74
|
return {
|
|
75
75
|
onMouseDown: wrap(props.onMouseDown),
|
|
76
|
+
onMouseOver: wrap(props.onMouseOver),
|
|
76
77
|
onMouseMove: wrap(props.onMouseMove),
|
|
77
78
|
onMouseUp: wrap(props.onMouseUp),
|
|
78
79
|
onMouseOut: wrap(props.onMouseOut),
|
|
@@ -91,6 +92,7 @@ export function callCellMouseHandler(handler: unknown, event: CellMouseEvent): v
|
|
|
91
92
|
|
|
92
93
|
export function hasDirectMouseHandler(props: Record<string, unknown>): boolean {
|
|
93
94
|
return typeof props.onMouseDown === "function"
|
|
95
|
+
|| typeof props.onMouseOver === "function"
|
|
94
96
|
|| typeof props.onMouseUp === "function"
|
|
95
97
|
|| typeof props.onMouseMove === "function"
|
|
96
98
|
|| typeof props.onMouseOut === "function"
|
|
@@ -5,7 +5,7 @@ import { App } from "../../../app";
|
|
|
5
5
|
import { UiHostProvider } from "../../../ui/host";
|
|
6
6
|
import { debugLog } from "../../../utils/debug-log";
|
|
7
7
|
import { measurePerfAsync } from "../../../utils/perf-marks";
|
|
8
|
-
import { backendRequest, initElectrobunBackend } from "./backend-rpc";
|
|
8
|
+
import { backendRequest, initElectrobunBackend, setElectrobunRemoteRequestHandler } from "./backend-rpc";
|
|
9
9
|
import { installElectrobunAiHost } from "./ai-host";
|
|
10
10
|
import { installElectrobunBrokerRemoteClient } from "./broker-remote-client";
|
|
11
11
|
import { installElectrobunConfigStoreHost } from "./config-host";
|
|
@@ -89,6 +89,9 @@ async function boot() {
|
|
|
89
89
|
const desktopWindowBridge = createDesktopWindowBridge(init.windowKind, init.paneId);
|
|
90
90
|
const desktopApplicationMenuBridge = createApplicationMenuBridge();
|
|
91
91
|
const webUiHost = createWebUiHost(init.desktopPlatform);
|
|
92
|
+
const remoteControlAdapter = init.windowKind === "main"
|
|
93
|
+
? { registerHandler: setElectrobunRemoteRequestHandler }
|
|
94
|
+
: undefined;
|
|
92
95
|
measurePerfAsync("startup.electrobun.root-render", async () => {
|
|
93
96
|
root.render(
|
|
94
97
|
<ElectrobunErrorBoundary>
|
|
@@ -102,6 +105,7 @@ async function boot() {
|
|
|
102
105
|
desktopApplicationMenuBridge={desktopApplicationMenuBridge}
|
|
103
106
|
desktopSnapshot={desktopSnapshot}
|
|
104
107
|
desktopThemePreview={init.desktopThemePreview}
|
|
108
|
+
remoteControlAdapter={remoteControlAdapter}
|
|
105
109
|
/>
|
|
106
110
|
</WebDialogHostProvider>
|
|
107
111
|
</WebToastHostProvider>
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createTestRenderer } from "@opentui/core/testing";
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { ChartSurface, Text } from "../../ui";
|
|
5
|
+
import { getNativeSurfaceManager } from "../../components/chart/native/surface/manager";
|
|
6
|
+
import type { ChartRendererPreference } from "../../components/chart/core/types";
|
|
7
|
+
import { AppProvider } from "../../state/app/context";
|
|
8
|
+
import { createDefaultConfig } from "../../types/config";
|
|
9
|
+
import { createOpenTuiTestRoot } from "./test-utils";
|
|
10
|
+
|
|
11
|
+
let testSetup: Awaited<ReturnType<typeof createTestRenderer>> | undefined;
|
|
12
|
+
let root: ReturnType<typeof createOpenTuiTestRoot> | undefined;
|
|
13
|
+
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
|
14
|
+
|
|
15
|
+
const bitmap = {
|
|
16
|
+
width: 4,
|
|
17
|
+
height: 4,
|
|
18
|
+
pixels: new Uint8Array(4 * 4 * 4).fill(255),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function setNativeRendererReady(): void {
|
|
22
|
+
(testSetup!.renderer as { _capabilities: unknown })._capabilities = { kitty_graphics: true };
|
|
23
|
+
(testSetup!.renderer as { _resolution: unknown })._resolution = { width: 800, height: 400 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function surfaceCount(): number {
|
|
27
|
+
const manager = getNativeSurfaceManager(testSetup!.renderer as never) as unknown as {
|
|
28
|
+
surfaces: Map<string, unknown>;
|
|
29
|
+
};
|
|
30
|
+
return manager.surfaces.size;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function flushFrames(): Promise<void> {
|
|
34
|
+
await act(async () => {
|
|
35
|
+
await testSetup!.renderOnce();
|
|
36
|
+
await testSetup!.renderOnce();
|
|
37
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
38
|
+
await testSetup!.renderOnce();
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function Harness({ preference }: { preference: ChartRendererPreference }) {
|
|
43
|
+
const config = createDefaultConfig(`/tmp/gloomberb-chart-surface-${preference}`);
|
|
44
|
+
config.chartPreferences.renderer = preference;
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<AppProvider config={config}>
|
|
48
|
+
<ChartSurface width={20} height={4} flexDirection="column" bitmaps={[bitmap]}>
|
|
49
|
+
<Text>fallback chart</Text>
|
|
50
|
+
</ChartSurface>
|
|
51
|
+
</AppProvider>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
if (root) {
|
|
57
|
+
act(() => {
|
|
58
|
+
root!.unmount();
|
|
59
|
+
});
|
|
60
|
+
root = undefined;
|
|
61
|
+
}
|
|
62
|
+
if (testSetup) {
|
|
63
|
+
testSetup.renderer.destroy();
|
|
64
|
+
testSetup = undefined;
|
|
65
|
+
}
|
|
66
|
+
actEnvironment.IS_REACT_ACT_ENVIRONMENT = false;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("OpenTuiChartSurface", () => {
|
|
70
|
+
test("does not register kitty surfaces when the chart renderer is forced to braille", async () => {
|
|
71
|
+
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
|
72
|
+
testSetup = await createTestRenderer({ width: 40, height: 12 });
|
|
73
|
+
setNativeRendererReady();
|
|
74
|
+
root = createOpenTuiTestRoot(testSetup.renderer);
|
|
75
|
+
|
|
76
|
+
act(() => {
|
|
77
|
+
root!.render(<Harness preference="braille" />);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await flushFrames();
|
|
81
|
+
|
|
82
|
+
expect(testSetup.captureCharFrame()).toContain("fallback chart");
|
|
83
|
+
expect(surfaceCount()).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("registers kitty surfaces when the chart renderer is forced to kitty and native graphics are ready", async () => {
|
|
87
|
+
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
|
88
|
+
testSetup = await createTestRenderer({ width: 40, height: 12 });
|
|
89
|
+
setNativeRendererReady();
|
|
90
|
+
root = createOpenTuiTestRoot(testSetup.renderer);
|
|
91
|
+
|
|
92
|
+
act(() => {
|
|
93
|
+
root!.render(<Harness preference="kitty" />);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await flushFrames();
|
|
97
|
+
|
|
98
|
+
expect(testSetup.captureCharFrame()).not.toContain("fallback chart");
|
|
99
|
+
expect(surfaceCount()).toBe(1);
|
|
100
|
+
});
|
|
101
|
+
});
|