bunite-core 0.14.0 → 0.17.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 +6 -3
- package/src/host/core/BrowserView.ts +345 -24
- package/src/host/core/BrowserWindow.ts +52 -6
- package/src/host/core/SurfaceBrowserIPC.ts +10 -1
- package/src/host/core/SurfaceManager.ts +357 -16
- package/src/host/core/windowCap.ts +69 -0
- package/src/host/events/webviewEvents.ts +18 -1
- package/src/host/log.ts +6 -1
- package/src/host/native.ts +145 -1
- package/src/host/preloadBundle.ts +7 -2
- package/src/native/linux/bunite_linux_ffi.cpp +225 -1
- package/src/native/linux/bunite_linux_internal.h +12 -0
- package/src/native/linux/bunite_linux_runtime.cpp +6 -1
- package/src/native/linux/bunite_linux_view.cpp +211 -5
- package/src/native/mac/bunite_mac_ffi.mm +293 -4
- package/src/native/mac/bunite_mac_internal.h +13 -0
- package/src/native/mac/bunite_mac_view.mm +227 -7
- package/src/native/shared/ffi_exports.h +97 -30
- package/src/native/win/native_host_cef.cpp +107 -13
- package/src/native/win/native_host_ffi.cpp +831 -2
- package/src/native/win/native_host_internal.h +22 -0
- package/src/native/win/native_host_runtime.cpp +34 -0
- package/src/native/win-webview2/bunite_webview2_ffi.cpp +827 -5
- package/src/native/win-webview2/webview2_internal.h +19 -0
- package/src/native/win-webview2/webview2_runtime.cpp +383 -31
- package/src/preload/runtime.built.js +1 -1
- package/src/preload/runtime.ts +39 -0
- package/src/rpc/framework.ts +194 -12
- package/src/rpc/index.ts +12 -0
- package/src/rpc/peer.ts +1 -1
- package/src/webview/native.ts +142 -32
- package/src/webview/polyfill.ts +91 -14
|
@@ -6,9 +6,11 @@ import {
|
|
|
6
6
|
} from "./SurfaceRegistry";
|
|
7
7
|
import {
|
|
8
8
|
SurfaceCap, type ImplOf, IpcError,
|
|
9
|
-
type SurfaceEvent, type DialogEvent, type ConsoleEntry,
|
|
9
|
+
type SurfaceEvent, type SurfaceEventBase, type DialogEvent, type ConsoleEntry,
|
|
10
|
+
type NavigationState, type DownloadEvent, type WaitForDownloadResult,
|
|
10
11
|
} from "../../rpc/index";
|
|
11
12
|
import { Stream } from "../../rpc/stream";
|
|
13
|
+
import { log } from "../log";
|
|
12
14
|
|
|
13
15
|
function applyHostOffset(hostView: BrowserView, x: number, y: number) {
|
|
14
16
|
return { x: x + hostView.frame.x, y: y + hostView.frame.y };
|
|
@@ -24,10 +26,32 @@ export function onSurfaceInit(cb: SurfaceInitCallback) {
|
|
|
24
26
|
type SurfaceEventEmit = (event: { surfaceId: number; event: SurfaceEvent }) => void;
|
|
25
27
|
const surfaceEventSubs = new Map<number, Set<SurfaceEventEmit>>();
|
|
26
28
|
|
|
27
|
-
export function emitSurfaceEvent(
|
|
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 };
|
|
28
47
|
const subs = surfaceEventSubs.get(hostViewId);
|
|
29
48
|
if (!subs) return;
|
|
30
|
-
for (const emit of subs) emit({ surfaceId, event });
|
|
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;
|
|
31
55
|
}
|
|
32
56
|
|
|
33
57
|
type DialogEmit = (event: { surfaceId: number; event: DialogEvent }) => void;
|
|
@@ -36,6 +60,133 @@ const dialogSubs = new Map<number, Set<DialogEmit>>();
|
|
|
36
60
|
type ConsoleEmit = (event: { surfaceId: number; entry: ConsoleEntry }) => void;
|
|
37
61
|
const consoleSubs = new Map<number, Set<ConsoleEmit>>();
|
|
38
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
|
+
});
|
|
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 }[]>();
|
|
139
|
+
|
|
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
|
+
|
|
39
190
|
const CONSOLE_BUFFER_LIMIT = 200;
|
|
40
191
|
const DEFAULT_DIALOG_TIMEOUT_MS = 5000;
|
|
41
192
|
|
|
@@ -50,6 +201,9 @@ type SurfaceState = {
|
|
|
50
201
|
consoleBuffer: ConsoleEntry[];
|
|
51
202
|
dialogTimeoutMs: number | null; // null = no auto-dismiss
|
|
52
203
|
pendingDialogs: Map<number, PendingDialog>;
|
|
204
|
+
lastLoadEpoch: number;
|
|
205
|
+
isLoading: boolean;
|
|
206
|
+
currentUrl: string;
|
|
53
207
|
};
|
|
54
208
|
|
|
55
209
|
const surfaceState = new Map<number, SurfaceState>();
|
|
@@ -61,6 +215,9 @@ function getOrCreateState(surfaceId: number): SurfaceState {
|
|
|
61
215
|
consoleBuffer: [],
|
|
62
216
|
dialogTimeoutMs: DEFAULT_DIALOG_TIMEOUT_MS,
|
|
63
217
|
pendingDialogs: new Map(),
|
|
218
|
+
lastLoadEpoch: 0,
|
|
219
|
+
isLoading: false,
|
|
220
|
+
currentUrl: "",
|
|
64
221
|
};
|
|
65
222
|
surfaceState.set(surfaceId, s);
|
|
66
223
|
}
|
|
@@ -69,11 +226,17 @@ function getOrCreateState(surfaceId: number): SurfaceState {
|
|
|
69
226
|
|
|
70
227
|
export function disposeSurfaceState(surfaceId: number) {
|
|
71
228
|
const s = surfaceState.get(surfaceId);
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
|
|
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);
|
|
75
237
|
}
|
|
76
|
-
|
|
238
|
+
downloadStartedMeta.delete(surfaceId);
|
|
239
|
+
recentUnownedStarts.delete(surfaceId);
|
|
77
240
|
}
|
|
78
241
|
|
|
79
242
|
// Wire dispose to any untrack path (remove + removeSurfacesForHostView).
|
|
@@ -86,6 +249,8 @@ export function clearConsoleBuffer(surfaceId: number) {
|
|
|
86
249
|
|
|
87
250
|
export function emitDialog(hostViewId: number, surfaceId: number, event: DialogEvent) {
|
|
88
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));
|
|
89
254
|
if (!subs) return;
|
|
90
255
|
for (const emit of subs) emit({ surfaceId, event });
|
|
91
256
|
}
|
|
@@ -108,6 +273,8 @@ export function registerDialogRequest(
|
|
|
108
273
|
surfaceId: number,
|
|
109
274
|
request: { requestId: number; kind: "alert" | "confirm" | "prompt" | "beforeunload"; message: string; defaultPrompt?: string }
|
|
110
275
|
) {
|
|
276
|
+
log.debug("dialog/register hostViewId=" + hostViewId + " surfaceId=" + surfaceId +
|
|
277
|
+
" kind=" + request.kind + " rid=" + request.requestId);
|
|
111
278
|
const state = getOrCreateState(surfaceId);
|
|
112
279
|
const view = getSurfaceRecord(surfaceId)?.view;
|
|
113
280
|
|
|
@@ -174,6 +341,7 @@ export function createSurfaceCapImpl(hostViewId: number): ImplOf<typeof SurfaceC
|
|
|
174
341
|
autoResize: false,
|
|
175
342
|
});
|
|
176
343
|
trackSurface(view.id, { view, hostViewId, hidden });
|
|
344
|
+
seedNavigationState(view.id, src);
|
|
177
345
|
try {
|
|
178
346
|
await view.whenReady();
|
|
179
347
|
} catch {
|
|
@@ -258,10 +426,10 @@ export function createSurfaceCapImpl(hostViewId: number): ImplOf<typeof SurfaceC
|
|
|
258
426
|
record?.view.reload();
|
|
259
427
|
},
|
|
260
428
|
|
|
261
|
-
evaluate: async ({ surfaceId, script }) => {
|
|
429
|
+
evaluate: async ({ surfaceId, script, frameId }) => {
|
|
262
430
|
const record = ownedSurface(surfaceId);
|
|
263
431
|
if (!record) return { ok: false, code: "not_supported", message: "surface not found" };
|
|
264
|
-
return record.view.evaluate(script);
|
|
432
|
+
return record.view.evaluate(script, frameId);
|
|
265
433
|
},
|
|
266
434
|
|
|
267
435
|
click: ({ surfaceId, ...args }) => {
|
|
@@ -300,17 +468,15 @@ export function createSurfaceCapImpl(hostViewId: number): ImplOf<typeof SurfaceC
|
|
|
300
468
|
return record.view.screenshot(format, quality);
|
|
301
469
|
},
|
|
302
470
|
|
|
303
|
-
waitForSelector: async ({ surfaceId, selector, timeoutMs = 5000 }) => {
|
|
471
|
+
waitForSelector: async ({ surfaceId, selector, timeoutMs = 5000, frameId }) => {
|
|
304
472
|
const record = ownedSurface(surfaceId);
|
|
305
473
|
if (!record) return { ok: false as const, code: "runtime_error" as const, message: "surface not found" };
|
|
306
474
|
const deadline = Date.now() + timeoutMs;
|
|
307
475
|
const expr = `!!document.querySelector(${JSON.stringify(selector)})`;
|
|
308
476
|
while (Date.now() < deadline) {
|
|
309
|
-
const res = await record.view.evaluate(expr);
|
|
477
|
+
const res = await record.view.evaluate(expr, frameId);
|
|
310
478
|
if (res.ok && res.value === true) return { ok: true as const };
|
|
311
479
|
if (!res.ok && res.code !== "timeout") {
|
|
312
|
-
// Propagate cross_origin distinct from runtime_error — consumer
|
|
313
|
-
// may want to retry with a same-origin sub-surface vs. fix the script.
|
|
314
480
|
const code = res.code === "cross_origin" ? "cross_origin" as const : "runtime_error" as const;
|
|
315
481
|
return { ok: false as const, code, message: res.message };
|
|
316
482
|
}
|
|
@@ -319,12 +485,12 @@ export function createSurfaceCapImpl(hostViewId: number): ImplOf<typeof SurfaceC
|
|
|
319
485
|
return { ok: false as const, code: "timeout" as const, message: `selector ${JSON.stringify(selector)} not found within ${timeoutMs}ms` };
|
|
320
486
|
},
|
|
321
487
|
|
|
322
|
-
waitForFunction: async ({ surfaceId, expression, timeoutMs = 5000, pollIntervalMs = 50 }) => {
|
|
488
|
+
waitForFunction: async ({ surfaceId, expression, timeoutMs = 5000, pollIntervalMs = 50, frameId }) => {
|
|
323
489
|
const record = ownedSurface(surfaceId);
|
|
324
490
|
if (!record) return { ok: false as const, code: "runtime_error" as const, message: "surface not found" };
|
|
325
491
|
const deadline = Date.now() + timeoutMs;
|
|
326
492
|
while (Date.now() < deadline) {
|
|
327
|
-
const res = await record.view.evaluate(expression);
|
|
493
|
+
const res = await record.view.evaluate(expression, frameId);
|
|
328
494
|
if (res.ok && res.value) return { ok: true as const };
|
|
329
495
|
if (!res.ok && res.code !== "timeout") {
|
|
330
496
|
const code = res.code === "cross_origin" ? "cross_origin" as const : "runtime_error" as const;
|
|
@@ -365,7 +531,8 @@ export function createSurfaceCapImpl(hostViewId: number): ImplOf<typeof SurfaceC
|
|
|
365
531
|
evaluate: false, crossOriginEval: false, surfaceEvents: false,
|
|
366
532
|
nativeInputTrusted: false, click: false, type: false, press: false,
|
|
367
533
|
scroll: false, mouse: false, dialogs: false, console: false,
|
|
368
|
-
screenshot: false,
|
|
534
|
+
screenshot: false, accessibilitySnapshot: false, getBoundingRect: false,
|
|
535
|
+
frames: false, downloads: false, popups: false, resolveAndClick: false,
|
|
369
536
|
};
|
|
370
537
|
}
|
|
371
538
|
return record.view.capabilities();
|
|
@@ -399,14 +566,188 @@ export function createSurfaceCapImpl(hostViewId: number): ImplOf<typeof SurfaceC
|
|
|
399
566
|
if (surfaceId === filterId) emit(event);
|
|
400
567
|
};
|
|
401
568
|
subs.add(wrapped);
|
|
569
|
+
log.debug("dialog/subscribe hostViewId=" + hostViewId + " filterId=" + filterId + " total=" + subs.size);
|
|
402
570
|
signal.addEventListener("abort", () => {
|
|
403
571
|
const set = dialogSubs.get(hostViewId);
|
|
404
572
|
if (!set) return;
|
|
405
573
|
set.delete(wrapped);
|
|
574
|
+
log.debug("dialog/unsubscribe hostViewId=" + hostViewId + " filterId=" + filterId + " remaining=" + set.size);
|
|
406
575
|
if (set.size === 0) dialogSubs.delete(hostViewId);
|
|
407
576
|
});
|
|
408
577
|
}),
|
|
409
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" };
|
|
623
|
+
}
|
|
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);
|
|
634
|
+
signal.addEventListener("abort", () => {
|
|
635
|
+
const set = downloadSubs.get(hostViewId);
|
|
636
|
+
if (!set) return;
|
|
637
|
+
set.delete(wrapped);
|
|
638
|
+
if (set.size === 0) downloadSubs.delete(hostViewId);
|
|
639
|
+
});
|
|
640
|
+
}),
|
|
641
|
+
|
|
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
|
+
|
|
410
751
|
consoleEvents: ({ surfaceId: filterId }) => Stream.from<ConsoleEntry>((emit, signal) => {
|
|
411
752
|
let subs = consoleSubs.get(hostViewId);
|
|
412
753
|
if (!subs) {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { BrowserWindow } from "./BrowserWindow";
|
|
2
|
+
import {
|
|
3
|
+
BrowserWindowCap, WindowCap, IpcError,
|
|
4
|
+
type ImplOf, type WindowState,
|
|
5
|
+
} from "../../rpc/index";
|
|
6
|
+
import { Stream } from "../../rpc/stream";
|
|
7
|
+
|
|
8
|
+
function browserWindowImpl(win: BrowserWindow): ImplOf<typeof BrowserWindowCap> {
|
|
9
|
+
return {
|
|
10
|
+
focus: () => win.focus(),
|
|
11
|
+
close: () => win.close(),
|
|
12
|
+
setBounds: ({ x, y, w, h }) => win.setFrame(x, y, w, h),
|
|
13
|
+
setTitle: ({ title }) => win.setTitle(title),
|
|
14
|
+
id: () => win.id,
|
|
15
|
+
label: () => win.label,
|
|
16
|
+
minimize: () => win.minimize(),
|
|
17
|
+
unminimize: () => win.unminimize(),
|
|
18
|
+
maximize: () => win.maximize(),
|
|
19
|
+
unmaximize: () => win.unmaximize(),
|
|
20
|
+
toggleMaximize: () => win.toggleMaximize(),
|
|
21
|
+
beginMoveDrag: () => win.beginMoveDrag(),
|
|
22
|
+
getState: () => win.getState(),
|
|
23
|
+
stateWatch: () => Stream.from<WindowState>((emit, signal) => {
|
|
24
|
+
let last = "";
|
|
25
|
+
const push = () => {
|
|
26
|
+
const s = win.getState();
|
|
27
|
+
const key = `${s.maximized}|${s.minimized}|${s.focused}`;
|
|
28
|
+
if (key === last) return;
|
|
29
|
+
last = key;
|
|
30
|
+
emit(s);
|
|
31
|
+
};
|
|
32
|
+
push(); // initial snapshot
|
|
33
|
+
const offs = [
|
|
34
|
+
win.on("focus", push), win.on("blur", push),
|
|
35
|
+
win.on("move", push), win.on("resize", push),
|
|
36
|
+
];
|
|
37
|
+
signal.addEventListener("abort", () => { for (const off of offs) off(); });
|
|
38
|
+
}),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolve(args: { id?: number; label?: string }): BrowserWindow | undefined {
|
|
43
|
+
if (args.id != null) return BrowserWindow.getById(args.id);
|
|
44
|
+
if (args.label) return BrowserWindow.getAll().find((w) => w.label === args.label);
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** WindowCap impl for the renderer of `viewId`. `current()` resolves the owning
|
|
49
|
+
* window host-side from the session's viewId — never from a page-supplied id. */
|
|
50
|
+
export function createWindowCapImpl(viewId: number): ImplOf<typeof WindowCap> {
|
|
51
|
+
return {
|
|
52
|
+
create: ({ url, title, bounds, label }, ctx) => {
|
|
53
|
+
const win = new BrowserWindow({
|
|
54
|
+
url, title, label,
|
|
55
|
+
frame: { x: bounds?.x ?? 80, y: bounds?.y ?? 80, width: bounds?.w ?? 1280, height: bounds?.h ?? 900 },
|
|
56
|
+
});
|
|
57
|
+
return ctx.exportCap(BrowserWindowCap, browserWindowImpl(win));
|
|
58
|
+
},
|
|
59
|
+
list: (_void, ctx) =>
|
|
60
|
+
BrowserWindow.getAll().map((w) => ctx.exportCap(BrowserWindowCap, browserWindowImpl(w))),
|
|
61
|
+
current: (_void, ctx) => {
|
|
62
|
+
const win = BrowserWindow.getByWebviewId(viewId);
|
|
63
|
+
if (!win) throw new IpcError({ code: "not_found", message: "no window for this view" });
|
|
64
|
+
return ctx.exportCap(BrowserWindowCap, browserWindowImpl(win));
|
|
65
|
+
},
|
|
66
|
+
focus: (args) => { resolve(args)?.focus(); },
|
|
67
|
+
close: (args) => { resolve(args)?.close(); },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -15,5 +15,22 @@ export default {
|
|
|
15
15
|
dialog: (data: { requestId: number; kind: "alert" | "confirm" | "prompt" | "beforeunload"; message: string; defaultPrompt?: string }) =>
|
|
16
16
|
new BuniteEvent("dialog", data),
|
|
17
17
|
consoleMessage: (data: { level: "log" | "warn" | "error" | "info" | "debug"; args: string[]; ts: number }) =>
|
|
18
|
-
new BuniteEvent("console-message", data)
|
|
18
|
+
new BuniteEvent("console-message", data),
|
|
19
|
+
downloadEvent: (data: {
|
|
20
|
+
kind: "started" | "progress" | "completed" | "failed" | "blocked";
|
|
21
|
+
id: string;
|
|
22
|
+
url?: string;
|
|
23
|
+
suggestedFilename?: string;
|
|
24
|
+
mimeType?: string;
|
|
25
|
+
sizeBytes?: number;
|
|
26
|
+
receivedBytes?: number;
|
|
27
|
+
totalBytes?: number;
|
|
28
|
+
localPath?: string;
|
|
29
|
+
reason?: string;
|
|
30
|
+
}) => new BuniteEvent("download-event", data),
|
|
31
|
+
popupRequested: (data: {
|
|
32
|
+
newSurfaceId: number;
|
|
33
|
+
url: string;
|
|
34
|
+
disposition: "tab" | "window" | "popup";
|
|
35
|
+
}) => new BuniteEvent("popup-requested", data),
|
|
19
36
|
};
|
package/src/host/log.ts
CHANGED
|
@@ -8,7 +8,12 @@ const levels: Record<LogLevel, number> = {
|
|
|
8
8
|
silent: 4
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
function initialLevel(): LogLevel {
|
|
12
|
+
const v = (typeof process !== "undefined" ? process.env?.BUNITE_LOG_LEVEL : undefined);
|
|
13
|
+
return v === "debug" || v === "info" || v === "warn" || v === "error" || v === "silent" ? v : "warn";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let currentLevel: LogLevel = initialLevel();
|
|
12
17
|
|
|
13
18
|
function shouldLog(level: LogLevel): boolean {
|
|
14
19
|
return levels[level] >= levels[currentLevel];
|