bunite-core 0.12.1 → 0.16.0
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 +4 -4
- package/src/host/core/App.ts +19 -2
- package/src/host/core/BrowserView.ts +515 -38
- package/src/host/core/SurfaceBrowserIPC.ts +53 -3
- package/src/host/core/SurfaceManager.ts +603 -30
- package/src/host/core/SurfaceRegistry.ts +9 -1
- package/src/host/core/inputDispatch.ts +147 -0
- package/src/host/events/webviewEvents.ts +25 -1
- package/src/host/log.ts +6 -1
- package/src/host/native.ts +263 -1
- package/src/host/preloadBundle.ts +7 -2
- package/src/native/linux/bunite_linux_ffi.cpp +427 -6
- package/src/native/linux/bunite_linux_internal.h +18 -0
- package/src/native/linux/bunite_linux_runtime.cpp +6 -1
- package/src/native/linux/bunite_linux_utils.cpp +2 -2
- package/src/native/linux/bunite_linux_view.cpp +296 -5
- package/src/native/mac/bunite_mac_ffi.mm +630 -8
- package/src/native/mac/bunite_mac_internal.h +19 -0
- package/src/native/mac/bunite_mac_utils.mm +2 -2
- package/src/native/mac/bunite_mac_view.mm +371 -9
- package/src/native/shared/ffi_exports.h +200 -2
- package/src/native/win/native_host_cef.cpp +186 -11
- package/src/native/win/native_host_ffi.cpp +1194 -1
- package/src/native/win/native_host_internal.h +35 -0
- package/src/native/win/native_host_utils.cpp +2 -1
- package/src/native/win/process_helper_win.cpp +54 -27
- package/src/native/win-webview2/bunite_webview2_ffi.cpp +1023 -12
- package/src/native/win-webview2/webview2_internal.h +25 -0
- package/src/native/win-webview2/webview2_runtime.cpp +403 -34
- package/src/native/win-webview2/webview2_utils.cpp +30 -12
- package/src/preload/runtime.built.js +1 -1
- package/src/preload/runtime.ts +97 -0
- package/src/rpc/framework.ts +340 -8
- package/src/rpc/index.ts +32 -0
- package/src/webview/native.ts +253 -51
- package/src/webview/polyfill.ts +283 -22
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { BrowserView } from "./BrowserView";
|
|
2
2
|
import {
|
|
3
3
|
trackSurface, untrackSurface, getOwnedSurface,
|
|
4
|
-
getHostSurfaceIds, getSurfaceRecord,
|
|
4
|
+
getHostSurfaceIds, getSurfaceRecord, onSurfaceDispose,
|
|
5
5
|
MAX_SURFACES_PER_HOST
|
|
6
6
|
} from "./SurfaceRegistry";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
SurfaceCap, type ImplOf, IpcError,
|
|
9
|
+
type SurfaceEvent, type SurfaceEventBase, type DialogEvent, type ConsoleEntry,
|
|
10
|
+
type NavigationState, type DownloadEvent, type WaitForDownloadResult,
|
|
11
|
+
} from "../../rpc/index";
|
|
8
12
|
import { Stream } from "../../rpc/stream";
|
|
13
|
+
import { log } from "../log";
|
|
9
14
|
|
|
10
15
|
function applyHostOffset(hostView: BrowserView, x: number, y: number) {
|
|
11
16
|
return { x: x + hostView.frame.x, y: y + hostView.frame.y };
|
|
@@ -18,22 +23,296 @@ export function onSurfaceInit(cb: SurfaceInitCallback) {
|
|
|
18
23
|
initCallbacks.push(cb);
|
|
19
24
|
}
|
|
20
25
|
|
|
21
|
-
type
|
|
22
|
-
const
|
|
26
|
+
type SurfaceEventEmit = (event: { surfaceId: number; event: SurfaceEvent }) => void;
|
|
27
|
+
const surfaceEventSubs = new Map<number, Set<SurfaceEventEmit>>();
|
|
23
28
|
|
|
24
|
-
export function
|
|
25
|
-
|
|
29
|
+
export function emitSurfaceEvent(
|
|
30
|
+
hostViewId: number,
|
|
31
|
+
surfaceId: number,
|
|
32
|
+
event: SurfaceEventBase,
|
|
33
|
+
) {
|
|
34
|
+
// Drop late events after dispose so dead surfaceIds don't resurrect state.
|
|
35
|
+
if (!getSurfaceRecord(surfaceId)) return;
|
|
36
|
+
// Mutate before the subscriber guard so `getNavigationState` stays correct.
|
|
37
|
+
const state = getOrCreateState(surfaceId);
|
|
38
|
+
if (event.type === "navigate") {
|
|
39
|
+
state.lastLoadEpoch++;
|
|
40
|
+
state.currentUrl = event.url;
|
|
41
|
+
} else if (event.type === "load-start") {
|
|
42
|
+
state.isLoading = true;
|
|
43
|
+
} else if (event.type === "load-finish" || event.type === "load-fail") {
|
|
44
|
+
state.isLoading = false;
|
|
45
|
+
}
|
|
46
|
+
const stamped: SurfaceEvent = { ...event, epoch: state.lastLoadEpoch };
|
|
47
|
+
const subs = surfaceEventSubs.get(hostViewId);
|
|
26
48
|
if (!subs) return;
|
|
27
|
-
for (const emit of subs) emit({ surfaceId,
|
|
49
|
+
for (const emit of subs) emit({ surfaceId, event: stamped });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function seedNavigationState(surfaceId: number, initialUrl: string) {
|
|
53
|
+
const state = getOrCreateState(surfaceId);
|
|
54
|
+
if (state.currentUrl === "") state.currentUrl = initialUrl;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type DialogEmit = (event: { surfaceId: number; event: DialogEvent }) => void;
|
|
58
|
+
const dialogSubs = new Map<number, Set<DialogEmit>>();
|
|
59
|
+
|
|
60
|
+
type ConsoleEmit = (event: { surfaceId: number; entry: ConsoleEntry }) => void;
|
|
61
|
+
const consoleSubs = new Map<number, Set<ConsoleEmit>>();
|
|
62
|
+
|
|
63
|
+
type DownloadEmit = (event: { surfaceId: number; event: DownloadEvent }) => void;
|
|
64
|
+
const downloadSubs = new Map<number, Set<DownloadEmit>>();
|
|
65
|
+
|
|
66
|
+
type PendingPopup = {
|
|
67
|
+
newSurfaceId: number;
|
|
68
|
+
openerHostViewId: number;
|
|
69
|
+
openerSurfaceId: number;
|
|
70
|
+
url: string;
|
|
71
|
+
disposition: "tab" | "window" | "popup";
|
|
72
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
73
|
+
armTs: number; // arm emit timestamp for the 60s extend cap
|
|
74
|
+
};
|
|
75
|
+
const pendingPopups = new Map<number, PendingPopup>();
|
|
76
|
+
const POPUP_ADOPT_TIMEOUT_MS = 5000;
|
|
77
|
+
const POPUP_EXTEND_CAP_MS = 60_000;
|
|
78
|
+
// Capped log so callers calling extendPopupTimeout after resolution get a
|
|
79
|
+
// distinct error code instead of bare not_found.
|
|
80
|
+
const popupResolutionLog = new Map<number, "adopted" | "dismissed">();
|
|
81
|
+
const POPUP_RESOLUTION_LOG_MAX = 64;
|
|
82
|
+
function recordResolution(id: number, kind: "adopted" | "dismissed") {
|
|
83
|
+
if (popupResolutionLog.size >= POPUP_RESOLUTION_LOG_MAX) {
|
|
84
|
+
const firstKey = popupResolutionLog.keys().next().value;
|
|
85
|
+
if (firstKey !== undefined) popupResolutionLog.delete(firstKey);
|
|
86
|
+
}
|
|
87
|
+
popupResolutionLog.set(id, kind);
|
|
88
|
+
popupCounters[kind] += 1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Process-lifetime popup lifecycle counters; surfaced via RuntimeCap.popupMetrics.
|
|
92
|
+
const popupCounters = { armed: 0, adopted: 0, dismissed: 0, timeoutFired: 0, extended: 0 };
|
|
93
|
+
export function getPopupMetricsSnapshot() { return { ...popupCounters }; }
|
|
94
|
+
|
|
95
|
+
/** Called when a backend mints a popup view. Stashes the pending adoption +
|
|
96
|
+
* arms a timer that auto-dismisses if the host doesn't respond. */
|
|
97
|
+
export function emitPopupRequested(
|
|
98
|
+
hostViewId: number,
|
|
99
|
+
openerSurfaceId: number,
|
|
100
|
+
args: { newSurfaceId: number; url: string; disposition: "tab" | "window" | "popup" },
|
|
101
|
+
) {
|
|
102
|
+
const armTs = Date.now();
|
|
103
|
+
popupCounters.armed += 1;
|
|
104
|
+
const entry: PendingPopup = {
|
|
105
|
+
newSurfaceId: args.newSurfaceId,
|
|
106
|
+
openerHostViewId: hostViewId,
|
|
107
|
+
openerSurfaceId,
|
|
108
|
+
url: args.url,
|
|
109
|
+
disposition: args.disposition,
|
|
110
|
+
timer: null,
|
|
111
|
+
armTs,
|
|
112
|
+
};
|
|
113
|
+
entry.timer = setTimeout(() => {
|
|
114
|
+
if (!pendingPopups.delete(args.newSurfaceId)) return;
|
|
115
|
+
popupCounters.timeoutFired += 1;
|
|
116
|
+
recordResolution(args.newSurfaceId, "dismissed");
|
|
117
|
+
BrowserView.dismissPopupById(args.newSurfaceId);
|
|
118
|
+
}, POPUP_ADOPT_TIMEOUT_MS);
|
|
119
|
+
pendingPopups.set(args.newSurfaceId, entry);
|
|
120
|
+
emitSurfaceEvent(hostViewId, openerSurfaceId, {
|
|
121
|
+
type: "popup",
|
|
122
|
+
url: args.url,
|
|
123
|
+
disposition: args.disposition,
|
|
124
|
+
openerSurfaceId,
|
|
125
|
+
newSurfaceId: args.newSurfaceId,
|
|
126
|
+
});
|
|
28
127
|
}
|
|
128
|
+
type DownloadWaiter = {
|
|
129
|
+
/** Resolved on the next `completed` event (or `failed`/`blocked`). */
|
|
130
|
+
resolve: (r: WaitForDownloadResult) => void;
|
|
131
|
+
/** Captured at registration; only events with `id` started after this are eligible. */
|
|
132
|
+
pendingId: string | null;
|
|
133
|
+
};
|
|
134
|
+
const downloadWaiters = new Map<number, DownloadWaiter[]>();
|
|
135
|
+
const downloadStartedMeta = new Map<number, Map<string, { url: string; suggestedFilename: string; mimeType?: string; sizeBytes?: number }>>();
|
|
136
|
+
// Recent started events that no waiter has claimed yet — lets a `waitForDownload`
|
|
137
|
+
// registered *after* the started event still bind. Per-surface, trimmed to 30s.
|
|
138
|
+
const recentUnownedStarts = new Map<number, { id: string; ts: number }[]>();
|
|
29
139
|
|
|
30
|
-
|
|
31
|
-
|
|
140
|
+
export function emitDownload(hostViewId: number, surfaceId: number, event: DownloadEvent) {
|
|
141
|
+
if (event.kind === "started") {
|
|
142
|
+
let bySurface = downloadStartedMeta.get(surfaceId);
|
|
143
|
+
if (!bySurface) { bySurface = new Map(); downloadStartedMeta.set(surfaceId, bySurface); }
|
|
144
|
+
bySurface.set(event.id, { url: event.url, suggestedFilename: event.suggestedFilename, mimeType: event.mimeType, sizeBytes: event.sizeBytes });
|
|
145
|
+
// Track unowned started events so a waitForDownload registering AFTER the
|
|
146
|
+
// started event can still bind. Trimmed to recent 30s on each insert.
|
|
147
|
+
let recents = recentUnownedStarts.get(surfaceId);
|
|
148
|
+
if (!recents) { recents = []; recentUnownedStarts.set(surfaceId, recents); }
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
recents.push({ id: event.id, ts: now });
|
|
151
|
+
while (recents.length && now - recents[0].ts > 30_000) recents.shift();
|
|
152
|
+
const queue = downloadWaiters.get(surfaceId);
|
|
153
|
+
if (queue) for (const w of queue) if (w.pendingId === null) {
|
|
154
|
+
w.pendingId = event.id;
|
|
155
|
+
// Take the unowned entry out — it's now bound.
|
|
156
|
+
const idx = recents.findIndex((r) => r.id === event.id);
|
|
157
|
+
if (idx >= 0) recents.splice(idx, 1);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const subs = downloadSubs.get(hostViewId);
|
|
162
|
+
if (subs) for (const emit of subs) emit({ surfaceId, event });
|
|
163
|
+
if (event.kind === "completed" || event.kind === "failed" || event.kind === "blocked") {
|
|
164
|
+
const queue = downloadWaiters.get(surfaceId);
|
|
165
|
+
if (queue) {
|
|
166
|
+
// First match by id (started → terminal pair). Else for blocked-without-started
|
|
167
|
+
// (policy=block emits only blocked), bind to the first waiting waiter.
|
|
168
|
+
let idx = queue.findIndex((w) => w.pendingId === event.id);
|
|
169
|
+
if (idx < 0 && event.kind === "blocked") idx = queue.findIndex((w) => w.pendingId === null);
|
|
170
|
+
if (idx >= 0) {
|
|
171
|
+
const [waiter] = queue.splice(idx, 1);
|
|
172
|
+
if (event.kind === "completed") {
|
|
173
|
+
const meta = downloadStartedMeta.get(surfaceId)?.get(event.id);
|
|
174
|
+
waiter.resolve({
|
|
175
|
+
ok: true, id: event.id, localPath: event.localPath,
|
|
176
|
+
url: meta?.url ?? "", suggestedFilename: meta?.suggestedFilename ?? "",
|
|
177
|
+
mimeType: meta?.mimeType, sizeBytes: meta?.sizeBytes,
|
|
178
|
+
});
|
|
179
|
+
} else if (event.kind === "failed") {
|
|
180
|
+
waiter.resolve({ ok: false, code: "failed", message: event.reason });
|
|
181
|
+
} else {
|
|
182
|
+
waiter.resolve({ ok: false, code: "blocked", message: event.reason });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
downloadStartedMeta.get(surfaceId)?.delete(event.id);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const CONSOLE_BUFFER_LIMIT = 200;
|
|
191
|
+
const DEFAULT_DIALOG_TIMEOUT_MS = 5000;
|
|
192
|
+
|
|
193
|
+
type PendingDialog = {
|
|
194
|
+
requestId: number;
|
|
195
|
+
originalKind: "alert" | "confirm" | "prompt" | "beforeunload";
|
|
196
|
+
message: string;
|
|
197
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
type SurfaceState = {
|
|
201
|
+
consoleBuffer: ConsoleEntry[];
|
|
202
|
+
dialogTimeoutMs: number | null; // null = no auto-dismiss
|
|
203
|
+
pendingDialogs: Map<number, PendingDialog>;
|
|
204
|
+
lastLoadEpoch: number;
|
|
205
|
+
isLoading: boolean;
|
|
206
|
+
currentUrl: string;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const surfaceState = new Map<number, SurfaceState>();
|
|
210
|
+
|
|
211
|
+
function getOrCreateState(surfaceId: number): SurfaceState {
|
|
212
|
+
let s = surfaceState.get(surfaceId);
|
|
213
|
+
if (!s) {
|
|
214
|
+
s = {
|
|
215
|
+
consoleBuffer: [],
|
|
216
|
+
dialogTimeoutMs: DEFAULT_DIALOG_TIMEOUT_MS,
|
|
217
|
+
pendingDialogs: new Map(),
|
|
218
|
+
lastLoadEpoch: 0,
|
|
219
|
+
isLoading: false,
|
|
220
|
+
currentUrl: "",
|
|
221
|
+
};
|
|
222
|
+
surfaceState.set(surfaceId, s);
|
|
223
|
+
}
|
|
224
|
+
return s;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function disposeSurfaceState(surfaceId: number) {
|
|
228
|
+
const s = surfaceState.get(surfaceId);
|
|
229
|
+
if (s) {
|
|
230
|
+
for (const p of s.pendingDialogs.values()) if (p.timer) clearTimeout(p.timer);
|
|
231
|
+
surfaceState.delete(surfaceId);
|
|
232
|
+
}
|
|
233
|
+
const waiters = downloadWaiters.get(surfaceId);
|
|
234
|
+
if (waiters) {
|
|
235
|
+
for (const w of waiters) w.resolve({ ok: false, code: "not_supported", message: "surface destroyed" });
|
|
236
|
+
downloadWaiters.delete(surfaceId);
|
|
237
|
+
}
|
|
238
|
+
downloadStartedMeta.delete(surfaceId);
|
|
239
|
+
recentUnownedStarts.delete(surfaceId);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Wire dispose to any untrack path (remove + removeSurfacesForHostView).
|
|
243
|
+
onSurfaceDispose(disposeSurfaceState);
|
|
244
|
+
|
|
245
|
+
export function clearConsoleBuffer(surfaceId: number) {
|
|
246
|
+
const s = surfaceState.get(surfaceId);
|
|
247
|
+
if (s) s.consoleBuffer.length = 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function emitDialog(hostViewId: number, surfaceId: number, event: DialogEvent) {
|
|
251
|
+
const subs = dialogSubs.get(hostViewId);
|
|
252
|
+
log.debug("dialog/emit hostViewId=" + hostViewId + " surfaceId=" + surfaceId +
|
|
253
|
+
" kind=" + (event as { kind?: string }).kind + " subscribers=" + (subs?.size ?? 0));
|
|
254
|
+
if (!subs) return;
|
|
255
|
+
for (const emit of subs) emit({ surfaceId, event });
|
|
256
|
+
}
|
|
32
257
|
|
|
33
|
-
export function
|
|
34
|
-
const
|
|
258
|
+
export function emitConsole(hostViewId: number, surfaceId: number, entry: ConsoleEntry) {
|
|
259
|
+
const state = getOrCreateState(surfaceId);
|
|
260
|
+
state.consoleBuffer.push(entry);
|
|
261
|
+
if (state.consoleBuffer.length > CONSOLE_BUFFER_LIMIT) {
|
|
262
|
+
state.consoleBuffer.splice(0, state.consoleBuffer.length - CONSOLE_BUFFER_LIMIT);
|
|
263
|
+
}
|
|
264
|
+
const subs = consoleSubs.get(hostViewId);
|
|
35
265
|
if (!subs) return;
|
|
36
|
-
for (const emit of subs) emit({ surfaceId,
|
|
266
|
+
for (const emit of subs) emit({ surfaceId, entry });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Called by SurfaceBrowserIPC on native `dialog` event. Stashes pending entry
|
|
270
|
+
* + arms the auto-dismiss timer, then broadcasts to subscribers. */
|
|
271
|
+
export function registerDialogRequest(
|
|
272
|
+
hostViewId: number,
|
|
273
|
+
surfaceId: number,
|
|
274
|
+
request: { requestId: number; kind: "alert" | "confirm" | "prompt" | "beforeunload"; message: string; defaultPrompt?: string }
|
|
275
|
+
) {
|
|
276
|
+
log.debug("dialog/register hostViewId=" + hostViewId + " surfaceId=" + surfaceId +
|
|
277
|
+
" kind=" + request.kind + " rid=" + request.requestId);
|
|
278
|
+
const state = getOrCreateState(surfaceId);
|
|
279
|
+
const view = getSurfaceRecord(surfaceId)?.view;
|
|
280
|
+
|
|
281
|
+
const entry: PendingDialog = {
|
|
282
|
+
requestId: request.requestId,
|
|
283
|
+
originalKind: request.kind,
|
|
284
|
+
message: request.message,
|
|
285
|
+
timer: null,
|
|
286
|
+
};
|
|
287
|
+
if (state.dialogTimeoutMs !== null && view) {
|
|
288
|
+
entry.timer = setTimeout(() => {
|
|
289
|
+
if (!state.pendingDialogs.delete(request.requestId)) return;
|
|
290
|
+
view.respondToDialog(request.requestId, false);
|
|
291
|
+
emitDialog(hostViewId, surfaceId, {
|
|
292
|
+
kind: "auto-dismissed",
|
|
293
|
+
originalKind: entry.originalKind,
|
|
294
|
+
message: entry.message,
|
|
295
|
+
});
|
|
296
|
+
}, state.dialogTimeoutMs);
|
|
297
|
+
}
|
|
298
|
+
state.pendingDialogs.set(request.requestId, entry);
|
|
299
|
+
|
|
300
|
+
emitDialog(hostViewId, surfaceId, {
|
|
301
|
+
kind: request.kind,
|
|
302
|
+
requestId: request.requestId,
|
|
303
|
+
message: request.message,
|
|
304
|
+
defaultPrompt: request.defaultPrompt,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function consumePendingDialog(surfaceId: number, requestId: number): boolean {
|
|
309
|
+
const state = surfaceState.get(surfaceId);
|
|
310
|
+
if (!state) return false;
|
|
311
|
+
const entry = state.pendingDialogs.get(requestId);
|
|
312
|
+
if (!entry) return false;
|
|
313
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
314
|
+
state.pendingDialogs.delete(requestId);
|
|
315
|
+
return true;
|
|
37
316
|
}
|
|
38
317
|
|
|
39
318
|
export function createSurfaceCapImpl(hostViewId: number): ImplOf<typeof SurfaceCap> {
|
|
@@ -62,6 +341,7 @@ export function createSurfaceCapImpl(hostViewId: number): ImplOf<typeof SurfaceC
|
|
|
62
341
|
autoResize: false,
|
|
63
342
|
});
|
|
64
343
|
trackSurface(view.id, { view, hostViewId, hidden });
|
|
344
|
+
seedNavigationState(view.id, src);
|
|
65
345
|
try {
|
|
66
346
|
await view.whenReady();
|
|
67
347
|
} catch {
|
|
@@ -87,6 +367,7 @@ export function createSurfaceCapImpl(hostViewId: number): ImplOf<typeof SurfaceC
|
|
|
87
367
|
const record = ownedSurface(surfaceId);
|
|
88
368
|
if (!record) return;
|
|
89
369
|
untrackSurface(surfaceId);
|
|
370
|
+
disposeSurfaceState(surfaceId);
|
|
90
371
|
record.view.remove();
|
|
91
372
|
},
|
|
92
373
|
|
|
@@ -145,51 +426,343 @@ export function createSurfaceCapImpl(hostViewId: number): ImplOf<typeof SurfaceC
|
|
|
145
426
|
record?.view.reload();
|
|
146
427
|
},
|
|
147
428
|
|
|
148
|
-
evaluate: async ({ surfaceId, script }) => {
|
|
429
|
+
evaluate: async ({ surfaceId, script, frameId }) => {
|
|
149
430
|
const record = ownedSurface(surfaceId);
|
|
150
431
|
if (!record) return { ok: false, code: "not_supported", message: "surface not found" };
|
|
151
|
-
return record.view.evaluate(script);
|
|
432
|
+
return record.view.evaluate(script, frameId);
|
|
433
|
+
},
|
|
434
|
+
|
|
435
|
+
click: ({ surfaceId, ...args }) => {
|
|
436
|
+
const record = ownedSurface(surfaceId);
|
|
437
|
+
if (!record) return;
|
|
438
|
+
record.view.click(args);
|
|
439
|
+
},
|
|
440
|
+
|
|
441
|
+
type: ({ surfaceId, text }) => {
|
|
442
|
+
const record = ownedSurface(surfaceId);
|
|
443
|
+
if (!record) return;
|
|
444
|
+
record.view.type(text);
|
|
445
|
+
},
|
|
446
|
+
|
|
447
|
+
press: ({ surfaceId, key, modifiers, action }) => {
|
|
448
|
+
const record = ownedSurface(surfaceId);
|
|
449
|
+
if (!record) return;
|
|
450
|
+
record.view.press(key, modifiers, action);
|
|
451
|
+
},
|
|
452
|
+
|
|
453
|
+
scroll: ({ surfaceId, ...args }) => {
|
|
454
|
+
const record = ownedSurface(surfaceId);
|
|
455
|
+
if (!record) return;
|
|
456
|
+
record.view.scroll(args);
|
|
457
|
+
},
|
|
458
|
+
|
|
459
|
+
mouse: ({ surfaceId, ...args }) => {
|
|
460
|
+
const record = ownedSurface(surfaceId);
|
|
461
|
+
if (!record) return;
|
|
462
|
+
record.view.mouse(args);
|
|
463
|
+
},
|
|
464
|
+
|
|
465
|
+
screenshot: async ({ surfaceId, format = "png", quality = 90 }) => {
|
|
466
|
+
const record = ownedSurface(surfaceId);
|
|
467
|
+
if (!record) return { ok: false as const, code: "not_supported" as const, message: "surface not found" };
|
|
468
|
+
return record.view.screenshot(format, quality);
|
|
469
|
+
},
|
|
470
|
+
|
|
471
|
+
waitForSelector: async ({ surfaceId, selector, timeoutMs = 5000, frameId }) => {
|
|
472
|
+
const record = ownedSurface(surfaceId);
|
|
473
|
+
if (!record) return { ok: false as const, code: "runtime_error" as const, message: "surface not found" };
|
|
474
|
+
const deadline = Date.now() + timeoutMs;
|
|
475
|
+
const expr = `!!document.querySelector(${JSON.stringify(selector)})`;
|
|
476
|
+
while (Date.now() < deadline) {
|
|
477
|
+
const res = await record.view.evaluate(expr, frameId);
|
|
478
|
+
if (res.ok && res.value === true) return { ok: true as const };
|
|
479
|
+
if (!res.ok && res.code !== "timeout") {
|
|
480
|
+
const code = res.code === "cross_origin" ? "cross_origin" as const : "runtime_error" as const;
|
|
481
|
+
return { ok: false as const, code, message: res.message };
|
|
482
|
+
}
|
|
483
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
484
|
+
}
|
|
485
|
+
return { ok: false as const, code: "timeout" as const, message: `selector ${JSON.stringify(selector)} not found within ${timeoutMs}ms` };
|
|
486
|
+
},
|
|
487
|
+
|
|
488
|
+
waitForFunction: async ({ surfaceId, expression, timeoutMs = 5000, pollIntervalMs = 50, frameId }) => {
|
|
489
|
+
const record = ownedSurface(surfaceId);
|
|
490
|
+
if (!record) return { ok: false as const, code: "runtime_error" as const, message: "surface not found" };
|
|
491
|
+
const deadline = Date.now() + timeoutMs;
|
|
492
|
+
while (Date.now() < deadline) {
|
|
493
|
+
const res = await record.view.evaluate(expression, frameId);
|
|
494
|
+
if (res.ok && res.value) return { ok: true as const };
|
|
495
|
+
if (!res.ok && res.code !== "timeout") {
|
|
496
|
+
const code = res.code === "cross_origin" ? "cross_origin" as const : "runtime_error" as const;
|
|
497
|
+
return { ok: false as const, code, message: res.message };
|
|
498
|
+
}
|
|
499
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
500
|
+
}
|
|
501
|
+
return { ok: false as const, code: "timeout" as const, message: `function did not satisfy within ${timeoutMs}ms` };
|
|
502
|
+
},
|
|
503
|
+
|
|
504
|
+
respondToDialog: ({ surfaceId, requestId, accept, text }) => {
|
|
505
|
+
const record = ownedSurface(surfaceId);
|
|
506
|
+
if (!record) return;
|
|
507
|
+
if (!consumePendingDialog(surfaceId, requestId)) return; // stale or already auto-dismissed
|
|
508
|
+
record.view.respondToDialog(requestId, accept, text);
|
|
509
|
+
},
|
|
510
|
+
|
|
511
|
+
setDialogTimeout: ({ surfaceId, ms }) => {
|
|
512
|
+
const record = ownedSurface(surfaceId);
|
|
513
|
+
if (!record) return;
|
|
514
|
+
const state = getOrCreateState(surfaceId);
|
|
515
|
+
state.dialogTimeoutMs = ms;
|
|
516
|
+
},
|
|
517
|
+
|
|
518
|
+
getConsoleBuffer: ({ surfaceId, clear }) => {
|
|
519
|
+
const record = ownedSurface(surfaceId);
|
|
520
|
+
if (!record) return [];
|
|
521
|
+
const state = getOrCreateState(surfaceId);
|
|
522
|
+
const snapshot = state.consoleBuffer.slice();
|
|
523
|
+
if (clear) state.consoleBuffer.length = 0;
|
|
524
|
+
return snapshot;
|
|
152
525
|
},
|
|
153
526
|
|
|
154
527
|
capabilities: ({ surfaceId }) => {
|
|
155
528
|
const record = ownedSurface(surfaceId);
|
|
156
529
|
if (!record) {
|
|
157
530
|
return {
|
|
158
|
-
evaluate: false, crossOriginEval: false,
|
|
531
|
+
evaluate: false, crossOriginEval: false, surfaceEvents: false,
|
|
159
532
|
nativeInputTrusted: false, click: false, type: false, press: false,
|
|
160
|
-
scroll: false,
|
|
533
|
+
scroll: false, mouse: false, dialogs: false, console: false,
|
|
534
|
+
screenshot: false, accessibilitySnapshot: false, getBoundingRect: false,
|
|
535
|
+
frames: false, downloads: false, popups: false, resolveAndClick: false,
|
|
161
536
|
};
|
|
162
537
|
}
|
|
163
538
|
return record.view.capabilities();
|
|
164
539
|
},
|
|
165
540
|
|
|
166
|
-
|
|
167
|
-
let subs =
|
|
541
|
+
surfaceEvents: ({ surfaceId: filterId }) => Stream.from<SurfaceEvent>((emit, signal) => {
|
|
542
|
+
let subs = surfaceEventSubs.get(hostViewId);
|
|
168
543
|
if (!subs) {
|
|
169
544
|
subs = new Set();
|
|
170
|
-
|
|
545
|
+
surfaceEventSubs.set(hostViewId, subs);
|
|
546
|
+
}
|
|
547
|
+
const wrapped: SurfaceEventEmit = ({ surfaceId, event }) => {
|
|
548
|
+
if (surfaceId === filterId) emit(event);
|
|
549
|
+
};
|
|
550
|
+
subs.add(wrapped);
|
|
551
|
+
signal.addEventListener("abort", () => {
|
|
552
|
+
const set = surfaceEventSubs.get(hostViewId);
|
|
553
|
+
if (!set) return;
|
|
554
|
+
set.delete(wrapped);
|
|
555
|
+
if (set.size === 0) surfaceEventSubs.delete(hostViewId);
|
|
556
|
+
});
|
|
557
|
+
}),
|
|
558
|
+
|
|
559
|
+
dialogs: ({ surfaceId: filterId }) => Stream.from<DialogEvent>((emit, signal) => {
|
|
560
|
+
let subs = dialogSubs.get(hostViewId);
|
|
561
|
+
if (!subs) {
|
|
562
|
+
subs = new Set();
|
|
563
|
+
dialogSubs.set(hostViewId, subs);
|
|
564
|
+
}
|
|
565
|
+
const wrapped: DialogEmit = ({ surfaceId, event }) => {
|
|
566
|
+
if (surfaceId === filterId) emit(event);
|
|
567
|
+
};
|
|
568
|
+
subs.add(wrapped);
|
|
569
|
+
log.debug("dialog/subscribe hostViewId=" + hostViewId + " filterId=" + filterId + " total=" + subs.size);
|
|
570
|
+
signal.addEventListener("abort", () => {
|
|
571
|
+
const set = dialogSubs.get(hostViewId);
|
|
572
|
+
if (!set) return;
|
|
573
|
+
set.delete(wrapped);
|
|
574
|
+
log.debug("dialog/unsubscribe hostViewId=" + hostViewId + " filterId=" + filterId + " remaining=" + set.size);
|
|
575
|
+
if (set.size === 0) dialogSubs.delete(hostViewId);
|
|
576
|
+
});
|
|
577
|
+
}),
|
|
578
|
+
|
|
579
|
+
getNavigationState: ({ surfaceId }): NavigationState => {
|
|
580
|
+
const record = ownedSurface(surfaceId);
|
|
581
|
+
if (!record) return { lastLoadEpoch: 0, isLoading: false, currentUrl: "" };
|
|
582
|
+
const state = getOrCreateState(surfaceId);
|
|
583
|
+
return {
|
|
584
|
+
lastLoadEpoch: state.lastLoadEpoch,
|
|
585
|
+
isLoading: state.isLoading,
|
|
586
|
+
currentUrl: state.currentUrl,
|
|
587
|
+
};
|
|
588
|
+
},
|
|
589
|
+
|
|
590
|
+
accessibilitySnapshot: async ({ surfaceId, interestingOnly = true }) => {
|
|
591
|
+
const record = ownedSurface(surfaceId);
|
|
592
|
+
if (!record) return { ok: false as const, code: "runtime_error" as const, message: "surface not found" };
|
|
593
|
+
return record.view.accessibilitySnapshot(interestingOnly);
|
|
594
|
+
},
|
|
595
|
+
|
|
596
|
+
getBoundingRect: async ({ surfaceId, selector, frameId }) => {
|
|
597
|
+
const record = ownedSurface(surfaceId);
|
|
598
|
+
if (!record) return { ok: false as const, code: "runtime_error" as const, message: "surface not found" };
|
|
599
|
+
const expr = `(function(){var el=document.querySelector(${JSON.stringify(selector)});if(!el)return null;var r=el.getBoundingClientRect();return {x:r.x,y:r.y,width:r.width,height:r.height,visible:r.width>0&&r.height>0&&r.bottom>0&&r.right>0&&r.top<innerHeight&&r.left<innerWidth};})()`;
|
|
600
|
+
const res = await record.view.evaluate(expr, frameId);
|
|
601
|
+
if (!res.ok) {
|
|
602
|
+
const code = res.code === "cross_origin" ? "cross_origin" as const
|
|
603
|
+
: res.code === "not_supported" ? "not_supported" as const
|
|
604
|
+
: "runtime_error" as const;
|
|
605
|
+
return { ok: false as const, code, message: res.message };
|
|
606
|
+
}
|
|
607
|
+
const v = res.value as null | { x: number; y: number; width: number; height: number; visible: boolean };
|
|
608
|
+
if (!v) return { ok: false as const, code: "not_found" as const, message: `selector ${JSON.stringify(selector)} not found` };
|
|
609
|
+
return { ok: true as const, rect: { x: v.x, y: v.y, width: v.width, height: v.height }, visible: v.visible };
|
|
610
|
+
},
|
|
611
|
+
|
|
612
|
+
listFrames: async ({ surfaceId }) => {
|
|
613
|
+
const record = ownedSurface(surfaceId);
|
|
614
|
+
if (!record) return { ok: false as const, code: "runtime_error" as const, message: "surface not found" };
|
|
615
|
+
return record.view.listFrames();
|
|
616
|
+
},
|
|
617
|
+
|
|
618
|
+
resolveAndClick: async (args) => {
|
|
619
|
+
const record = ownedSurface(args.surfaceId);
|
|
620
|
+
if (!record) return { ok: false as const, code: "runtime_error" as const, message: "surface not found" };
|
|
621
|
+
if (!record.view.capabilities().resolveAndClick) {
|
|
622
|
+
return { ok: false as const, code: "not_supported" as const, message: "resolveAndClick not supported on this backend" };
|
|
171
623
|
}
|
|
172
|
-
|
|
624
|
+
return record.view.resolveAndClick(args);
|
|
625
|
+
},
|
|
626
|
+
|
|
627
|
+
downloadEvents: ({ surfaceId: filterId }) => Stream.from<DownloadEvent>((emit, signal) => {
|
|
628
|
+
let subs = downloadSubs.get(hostViewId);
|
|
629
|
+
if (!subs) { subs = new Set(); downloadSubs.set(hostViewId, subs); }
|
|
630
|
+
const wrapped: DownloadEmit = ({ surfaceId, event }) => {
|
|
631
|
+
if (surfaceId === filterId) emit(event);
|
|
632
|
+
};
|
|
633
|
+
subs.add(wrapped);
|
|
173
634
|
signal.addEventListener("abort", () => {
|
|
174
|
-
const set =
|
|
635
|
+
const set = downloadSubs.get(hostViewId);
|
|
175
636
|
if (!set) return;
|
|
176
|
-
set.delete(
|
|
177
|
-
if (set.size === 0)
|
|
637
|
+
set.delete(wrapped);
|
|
638
|
+
if (set.size === 0) downloadSubs.delete(hostViewId);
|
|
178
639
|
});
|
|
179
640
|
}),
|
|
180
641
|
|
|
181
|
-
|
|
182
|
-
|
|
642
|
+
waitForDownload: async ({ surfaceId, timeoutMs = 30000 }) => {
|
|
643
|
+
const record = ownedSurface(surfaceId);
|
|
644
|
+
if (!record) return { ok: false as const, code: "not_supported" as const, message: "surface not found" };
|
|
645
|
+
if (!record.view.capabilities().downloads) {
|
|
646
|
+
return { ok: false as const, code: "not_supported" as const, message: "downloads not supported on this backend" };
|
|
647
|
+
}
|
|
648
|
+
return new Promise<WaitForDownloadResult>((resolve) => {
|
|
649
|
+
// If a `started` already arrived without a waiter, consume it.
|
|
650
|
+
const recents = recentUnownedStarts.get(surfaceId);
|
|
651
|
+
const claimed = recents?.shift();
|
|
652
|
+
const waiter: DownloadWaiter = {
|
|
653
|
+
resolve: (r) => { clearTimeout(timer); resolve(r); },
|
|
654
|
+
pendingId: claimed?.id ?? null,
|
|
655
|
+
};
|
|
656
|
+
let queue = downloadWaiters.get(surfaceId);
|
|
657
|
+
if (!queue) { queue = []; downloadWaiters.set(surfaceId, queue); }
|
|
658
|
+
queue.push(waiter);
|
|
659
|
+
const timer = setTimeout(() => {
|
|
660
|
+
const q = downloadWaiters.get(surfaceId);
|
|
661
|
+
if (!q) return;
|
|
662
|
+
const idx = q.indexOf(waiter);
|
|
663
|
+
if (idx >= 0) q.splice(idx, 1);
|
|
664
|
+
resolve({ ok: false, code: "timeout", message: `no download started within ${timeoutMs}ms` });
|
|
665
|
+
}, timeoutMs);
|
|
666
|
+
});
|
|
667
|
+
},
|
|
668
|
+
|
|
669
|
+
setDownloadPolicy: ({ surfaceId, policy, downloadDir }) => {
|
|
670
|
+
const record = ownedSurface(surfaceId);
|
|
671
|
+
if (!record) return;
|
|
672
|
+
if (!record.view.capabilities().downloads) return; // mac/linux silent no-op signal — caller should gate on cap.
|
|
673
|
+
record.view.setDownloadPolicy(policy, downloadDir);
|
|
674
|
+
},
|
|
675
|
+
|
|
676
|
+
acceptPopup: async ({ newSurfaceId, hostViewId: targetHostId, bounds }) => {
|
|
677
|
+
const pending = pendingPopups.get(newSurfaceId);
|
|
678
|
+
if (!pending) return { ok: false as const, code: "not_found" as const, message: "popup not pending" };
|
|
679
|
+
// Only the opener's host page can adopt the popup. The target host
|
|
680
|
+
// (where the new pane lands) is a separate decision.
|
|
681
|
+
if (pending.openerHostViewId !== hostViewId) {
|
|
682
|
+
return { ok: false as const, code: "not_found" as const, message: "popup not owned by this host" };
|
|
683
|
+
}
|
|
684
|
+
const targetHost = BrowserView.getById(targetHostId);
|
|
685
|
+
if (!targetHost || !targetHost.windowId) {
|
|
686
|
+
// Don't consume pending state on validation failure — host can retry
|
|
687
|
+
// with a different target until the auto-dismiss timer fires.
|
|
688
|
+
return { ok: false as const, code: "host_view_invalid" as const, message: "host view not found" };
|
|
689
|
+
}
|
|
690
|
+
const existing = getHostSurfaceIds(targetHostId);
|
|
691
|
+
if (existing && existing.size >= MAX_SURFACES_PER_HOST) {
|
|
692
|
+
return { ok: false as const, code: "host_view_invalid" as const, message: `host surface limit reached (${MAX_SURFACES_PER_HOST})` };
|
|
693
|
+
}
|
|
694
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
695
|
+
pendingPopups.delete(newSurfaceId);
|
|
696
|
+
recordResolution(newSurfaceId, "adopted");
|
|
697
|
+
const offset = applyHostOffset(targetHost, bounds.x, bounds.y);
|
|
698
|
+
const view = BrowserView.adopt({
|
|
699
|
+
nativeViewId: newSurfaceId,
|
|
700
|
+
hostWindowId: targetHost.windowId,
|
|
701
|
+
bounds: { x: offset.x, y: offset.y, width: bounds.width, height: bounds.height },
|
|
702
|
+
appresRoot: targetHost.appresRoot,
|
|
703
|
+
});
|
|
704
|
+
trackSurface(view.id, { view, hostViewId: targetHostId, hidden: false });
|
|
705
|
+
seedNavigationState(view.id, pending.url);
|
|
706
|
+
for (const cb of initCallbacks) cb(view.id, targetHostId, view);
|
|
707
|
+
return { ok: true as const };
|
|
708
|
+
},
|
|
709
|
+
|
|
710
|
+
dismissPopup: ({ newSurfaceId }) => {
|
|
711
|
+
const pending = pendingPopups.get(newSurfaceId);
|
|
712
|
+
if (!pending) return;
|
|
713
|
+
if (pending.openerHostViewId !== hostViewId) return; // not this host's popup
|
|
714
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
715
|
+
pendingPopups.delete(newSurfaceId);
|
|
716
|
+
recordResolution(newSurfaceId, "dismissed");
|
|
717
|
+
BrowserView.dismissPopupById(newSurfaceId);
|
|
718
|
+
},
|
|
719
|
+
|
|
720
|
+
extendPopupTimeout: ({ newSurfaceId, gracePeriodMs }) => {
|
|
721
|
+
if (!Number.isFinite(gracePeriodMs) || gracePeriodMs <= 0) {
|
|
722
|
+
return { ok: false as const, code: "not_found" as const, message: "gracePeriodMs must be a positive finite number" };
|
|
723
|
+
}
|
|
724
|
+
const pending = pendingPopups.get(newSurfaceId);
|
|
725
|
+
if (!pending) {
|
|
726
|
+
const prior = popupResolutionLog.get(newSurfaceId);
|
|
727
|
+
if (prior === "adopted") return { ok: false as const, code: "already_adopted" as const, message: "popup adopted" };
|
|
728
|
+
if (prior === "dismissed") return { ok: false as const, code: "already_dismissed" as const, message: "popup dismissed" };
|
|
729
|
+
return { ok: false as const, code: "not_found" as const, message: "popup not pending" };
|
|
730
|
+
}
|
|
731
|
+
if (pending.openerHostViewId !== hostViewId) {
|
|
732
|
+
return { ok: false as const, code: "not_found" as const, message: "popup not owned by this host" };
|
|
733
|
+
}
|
|
734
|
+
const now = Date.now();
|
|
735
|
+
const requested = now + gracePeriodMs;
|
|
736
|
+
const cap = pending.armTs + POPUP_EXTEND_CAP_MS;
|
|
737
|
+
if (requested > cap) {
|
|
738
|
+
return { ok: false as const, code: "cap_exceeded" as const, message: `extend exceeds ${POPUP_EXTEND_CAP_MS}ms cap since arm` };
|
|
739
|
+
}
|
|
740
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
741
|
+
pending.timer = setTimeout(() => {
|
|
742
|
+
if (!pendingPopups.delete(newSurfaceId)) return;
|
|
743
|
+
popupCounters.timeoutFired += 1;
|
|
744
|
+
recordResolution(newSurfaceId, "dismissed");
|
|
745
|
+
BrowserView.dismissPopupById(newSurfaceId);
|
|
746
|
+
}, gracePeriodMs);
|
|
747
|
+
popupCounters.extended += 1;
|
|
748
|
+
return { ok: true as const, deadlineMs: requested };
|
|
749
|
+
},
|
|
750
|
+
|
|
751
|
+
consoleEvents: ({ surfaceId: filterId }) => Stream.from<ConsoleEntry>((emit, signal) => {
|
|
752
|
+
let subs = consoleSubs.get(hostViewId);
|
|
183
753
|
if (!subs) {
|
|
184
754
|
subs = new Set();
|
|
185
|
-
|
|
755
|
+
consoleSubs.set(hostViewId, subs);
|
|
186
756
|
}
|
|
187
|
-
|
|
757
|
+
const wrapped: ConsoleEmit = ({ surfaceId, entry }) => {
|
|
758
|
+
if (surfaceId === filterId) emit(entry);
|
|
759
|
+
};
|
|
760
|
+
subs.add(wrapped);
|
|
188
761
|
signal.addEventListener("abort", () => {
|
|
189
|
-
const set =
|
|
762
|
+
const set = consoleSubs.get(hostViewId);
|
|
190
763
|
if (!set) return;
|
|
191
|
-
set.delete(
|
|
192
|
-
if (set.size === 0)
|
|
764
|
+
set.delete(wrapped);
|
|
765
|
+
if (set.size === 0) consoleSubs.delete(hostViewId);
|
|
193
766
|
});
|
|
194
767
|
}),
|
|
195
768
|
};
|