getobsrv 0.11.0 → 0.12.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/README.md +34 -0
- package/out/main/cli.js +1 -1
- package/out/main/index.js +741 -201
- package/out/main/{targetSource-nbfc-Fe6.js → targetSource-DlsHc-Ri.js} +43 -1
- package/out/mcp/server.js +21 -1
- package/out/preload/app.js +19 -16
- package/out/renderer/assets/{index-B61fbgqt.css → index-DvwDBeho.css} +107 -4
- package/out/renderer/assets/{index-BlbBg6lm.js → index-gYDbIgqJ.js} +380 -118
- package/out/renderer/index.html +2 -2
- package/out/shared/control.js +13 -1
- package/out/shared/ipcPayloads.js +29 -1
- package/out/shared/presets.js +6 -1
- package/out/shared/tabList.js +46 -0
- package/package.json +1 -1
- package/skills/obsrv-screens/SKILL.md +7 -0
package/out/main/index.js
CHANGED
|
@@ -4,87 +4,16 @@ const node_fs = require("node:fs");
|
|
|
4
4
|
const promises = require("node:fs/promises");
|
|
5
5
|
const node_path = require("node:path");
|
|
6
6
|
const node_crypto = require("node:crypto");
|
|
7
|
-
const targetSource = require("./targetSource-
|
|
7
|
+
const targetSource = require("./targetSource-DlsHc-Ri.js");
|
|
8
8
|
const node_http = require("node:http");
|
|
9
9
|
const node_url = require("node:url");
|
|
10
|
-
const IPC = {
|
|
11
|
-
navigate: "obsrv:navigate",
|
|
12
|
-
reload: "obsrv:reload",
|
|
13
|
-
back: "obsrv:back",
|
|
14
|
-
forward: "obsrv:forward",
|
|
15
|
-
setViewport: "obsrv:set-viewport",
|
|
16
|
-
setNativeBounds: "obsrv:set-native-bounds",
|
|
17
|
-
setNativeVisible: "obsrv:set-native-visible",
|
|
18
|
-
setMode: "obsrv:set-mode",
|
|
19
|
-
sendInput: "obsrv:send-input",
|
|
20
|
-
getHostInfo: "obsrv:get-host-info",
|
|
21
|
-
getSettings: "obsrv:get-settings",
|
|
22
|
-
setSettings: "obsrv:set-settings",
|
|
23
|
-
frame: "obsrv:frame",
|
|
24
|
-
frameSubscribe: "obsrv:frame-subscribe",
|
|
25
|
-
urlChanged: "obsrv:url-changed",
|
|
26
|
-
loadError: "obsrv:load-error",
|
|
27
|
-
hostChanged: "obsrv:host-changed",
|
|
28
|
-
targetLoading: "obsrv:target-loading",
|
|
29
|
-
targetNavigating: "obsrv:target-navigating",
|
|
30
|
-
nativeFocused: "obsrv:native-focused",
|
|
31
|
-
syncScroll: "obsrv:sync-scroll",
|
|
32
|
-
applyScroll: "obsrv:apply-scroll",
|
|
33
|
-
scrollResult: "obsrv:scroll-result",
|
|
34
|
-
openImage: "obsrv:open-image",
|
|
35
|
-
focusUrl: "obsrv:focus-url",
|
|
36
|
-
openImagePath: "obsrv:open-image-path",
|
|
37
|
-
readImageFile: "obsrv:read-image-file",
|
|
38
|
-
uiState: "obsrv:ui-state",
|
|
39
|
-
agentApply: "obsrv:agent-apply",
|
|
40
|
-
agentActivity: "obsrv:agent-activity",
|
|
41
|
-
getUpdate: "obsrv:get-update",
|
|
42
|
-
checkUpdate: "obsrv:check-update",
|
|
43
|
-
openRelease: "obsrv:open-release",
|
|
44
|
-
updateStatus: "obsrv:update-status",
|
|
45
|
-
getHistory: "obsrv:get-history",
|
|
46
|
-
clearHistory: "obsrv:clear-history",
|
|
47
|
-
historyChanged: "obsrv:history-changed"
|
|
48
|
-
};
|
|
49
|
-
function attachFrameBus(target, win) {
|
|
50
|
-
let ready = false;
|
|
51
|
-
let enabled = true;
|
|
52
|
-
const gone = () => win.isDestroyed() || win.webContents.isDestroyed();
|
|
53
|
-
const onFrame = (msg) => {
|
|
54
|
-
if (!ready || !enabled || gone()) return;
|
|
55
|
-
win.webContents.send(IPC.frame, msg);
|
|
56
|
-
};
|
|
57
|
-
const onSubscribe = (e) => {
|
|
58
|
-
if (gone() || e.sender !== win.webContents) return;
|
|
59
|
-
ready = true;
|
|
60
|
-
if (enabled) target.invalidate();
|
|
61
|
-
};
|
|
62
|
-
const onRendererGone = (details) => {
|
|
63
|
-
if (details.isMainFrame && !details.isSameDocument) ready = false;
|
|
64
|
-
};
|
|
65
|
-
target.on("frame", onFrame);
|
|
66
|
-
electron.ipcMain.on(IPC.frameSubscribe, onSubscribe);
|
|
67
|
-
win.webContents.on("did-start-navigation", onRendererGone);
|
|
68
|
-
return {
|
|
69
|
-
detach() {
|
|
70
|
-
ready = false;
|
|
71
|
-
target.off("frame", onFrame);
|
|
72
|
-
electron.ipcMain.removeListener(IPC.frameSubscribe, onSubscribe);
|
|
73
|
-
if (!gone()) win.webContents.off("did-start-navigation", onRendererGone);
|
|
74
|
-
},
|
|
75
|
-
setEnabled(next) {
|
|
76
|
-
enabled = next;
|
|
77
|
-
if (enabled && ready) target.invalidate();
|
|
78
|
-
}
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
10
|
const MAX_SCROLL_SELECTOR = 512;
|
|
82
11
|
const MAX_RECT = 16384;
|
|
83
12
|
const MAX_SCROLL_WARNINGS = 4;
|
|
84
13
|
const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
|
|
85
|
-
const isRecord$
|
|
14
|
+
const isRecord$3 = (v) => typeof v === "object" && v !== null;
|
|
86
15
|
function parseRect(raw) {
|
|
87
|
-
if (!isRecord$
|
|
16
|
+
if (!isRecord$3(raw)) return null;
|
|
88
17
|
const { x, y, width, height } = raw;
|
|
89
18
|
if (!isFiniteNumber(x) || !isFiniteNumber(y) || !isFiniteNumber(width) || !isFiniteNumber(height)) return null;
|
|
90
19
|
const r = { x: Math.round(x), y: Math.round(y), width: Math.round(width), height: Math.round(height) };
|
|
@@ -106,7 +35,7 @@ function parseModifiers(raw) {
|
|
|
106
35
|
return raw.filter((m) => typeof m === "string" && MODIFIERS.has(m));
|
|
107
36
|
}
|
|
108
37
|
function parseInputEvent(raw) {
|
|
109
|
-
if (!isRecord$
|
|
38
|
+
if (!isRecord$3(raw)) return null;
|
|
110
39
|
const modifiers = parseModifiers(raw.modifiers);
|
|
111
40
|
switch (raw.type) {
|
|
112
41
|
case "mouseDown":
|
|
@@ -139,7 +68,7 @@ function parseDeviceScaleFactor(raw) {
|
|
|
139
68
|
return raw;
|
|
140
69
|
}
|
|
141
70
|
function parseSettings(raw) {
|
|
142
|
-
if (!isRecord$
|
|
71
|
+
if (!isRecord$3(raw)) return null;
|
|
143
72
|
const { hostDiagonalInches, hostNits } = raw;
|
|
144
73
|
if (!isFiniteNumber(hostDiagonalInches) || hostDiagonalInches <= 0) return null;
|
|
145
74
|
if (!isFiniteNumber(hostNits) || hostNits <= 0) return null;
|
|
@@ -153,15 +82,24 @@ function parseSettings(raw) {
|
|
|
153
82
|
if (typeof recordHistory !== "boolean") return null;
|
|
154
83
|
const split = raw.split ?? targetSource.DEFAULT_SETTINGS.split;
|
|
155
84
|
if (!isFiniteNumber(split) || split < targetSource.SPLIT_MIN || split > targetSource.SPLIT_MAX) return null;
|
|
156
|
-
|
|
85
|
+
const maxTabs = raw.maxTabs ?? targetSource.DEFAULT_SETTINGS.maxTabs;
|
|
86
|
+
if (!isFiniteNumber(maxTabs) || !Number.isInteger(maxTabs) || maxTabs < targetSource.MAX_TABS_MIN || maxTabs > targetSource.MAX_TABS_MAX) return null;
|
|
87
|
+
return { hostDiagonalInches, hostNits, agentControl, updateCheck, lastUpdateCheck, recordHistory, split, maxTabs };
|
|
157
88
|
}
|
|
158
89
|
function parseMode(raw) {
|
|
159
90
|
return raw === "url" || raw === "image" ? raw : null;
|
|
160
91
|
}
|
|
161
92
|
const MAX_UI_ID = 64;
|
|
93
|
+
const MAX_TAB_ID = 64;
|
|
94
|
+
function parseTabId(raw) {
|
|
95
|
+
if (typeof raw !== "string" || raw.length === 0 || raw.length > MAX_TAB_ID) return null;
|
|
96
|
+
return raw;
|
|
97
|
+
}
|
|
162
98
|
function parseUiState(raw) {
|
|
163
|
-
if (!isRecord$
|
|
99
|
+
if (!isRecord$3(raw)) return null;
|
|
164
100
|
const { presetId, profileId, viewMode, mode } = raw;
|
|
101
|
+
const tabId = parseTabId(raw.tabId);
|
|
102
|
+
if (tabId === null) return null;
|
|
165
103
|
if (typeof presetId !== "string" || presetId.length === 0 || presetId.length > MAX_UI_ID) return null;
|
|
166
104
|
if (typeof profileId !== "string" || profileId.length === 0 || profileId.length > MAX_UI_ID) return null;
|
|
167
105
|
if (viewMode !== "1:1" && viewMode !== "fit") return null;
|
|
@@ -169,6 +107,7 @@ function parseUiState(raw) {
|
|
|
169
107
|
const panes = raw.panes ?? "both";
|
|
170
108
|
if (panes !== "both" && panes !== "target") return null;
|
|
171
109
|
return {
|
|
110
|
+
tabId,
|
|
172
111
|
presetId,
|
|
173
112
|
profileId,
|
|
174
113
|
viewMode,
|
|
@@ -179,7 +118,7 @@ function parseUiState(raw) {
|
|
|
179
118
|
};
|
|
180
119
|
}
|
|
181
120
|
function parseScrollPos(raw) {
|
|
182
|
-
if (!isRecord$
|
|
121
|
+
if (!isRecord$3(raw)) return null;
|
|
183
122
|
const { x, y } = raw;
|
|
184
123
|
if (!isFiniteNumber(x) || !isFiniteNumber(y) || x < 0 || y < 0) return null;
|
|
185
124
|
return { x, y };
|
|
@@ -196,7 +135,7 @@ function parseScrollRequest(raw) {
|
|
|
196
135
|
return { ...pos, selector: trimmed };
|
|
197
136
|
}
|
|
198
137
|
function parseScrollReport(raw) {
|
|
199
|
-
if (!isRecord$
|
|
138
|
+
if (!isRecord$3(raw)) return null;
|
|
200
139
|
const { id, x, y, scroller } = raw;
|
|
201
140
|
if (!isFiniteNumber(id)) return null;
|
|
202
141
|
if (!isFiniteNumber(x) || !isFiniteNumber(y)) return null;
|
|
@@ -229,7 +168,7 @@ const CONTROL_COMMANDS = [
|
|
|
229
168
|
function isControlCommand(v) {
|
|
230
169
|
return typeof v === "string" && CONTROL_COMMANDS.includes(v);
|
|
231
170
|
}
|
|
232
|
-
const isRecord$
|
|
171
|
+
const isRecord$2 = (v) => typeof v === "object" && v !== null;
|
|
233
172
|
function tokenEqual(expected, provided) {
|
|
234
173
|
if (typeof provided !== "string") return false;
|
|
235
174
|
const a = node_crypto.createHash("sha256").update(expected).digest();
|
|
@@ -265,7 +204,7 @@ function pixelExactApplyError(v) {
|
|
|
265
204
|
}
|
|
266
205
|
function parseClick(raw, viewport) {
|
|
267
206
|
const shape = "click payload must be { x, y, button? } with finite CSS-pixel coordinates";
|
|
268
|
-
if (!isRecord$
|
|
207
|
+
if (!isRecord$2(raw)) return shape;
|
|
269
208
|
const { x, y } = raw;
|
|
270
209
|
if (typeof x !== "number" || !Number.isFinite(x) || typeof y !== "number" || !Number.isFinite(y)) return shape;
|
|
271
210
|
if (x < 0 || y < 0 || x >= viewport.width || y >= viewport.height) {
|
|
@@ -294,7 +233,7 @@ function parseHighlight(raw) {
|
|
|
294
233
|
}
|
|
295
234
|
const HISTORY_MAX = 500;
|
|
296
235
|
const MAX_URL_LENGTH = 2048;
|
|
297
|
-
const isRecord = (v) => typeof v === "object" && v !== null;
|
|
236
|
+
const isRecord$1 = (v) => typeof v === "object" && v !== null;
|
|
298
237
|
const isCount = (v) => typeof v === "number" && Number.isInteger(v) && v >= 1;
|
|
299
238
|
const isStamp$1 = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
|
|
300
239
|
function isStorableUrl(v) {
|
|
@@ -327,7 +266,7 @@ function loadHistory(file) {
|
|
|
327
266
|
const seen = /* @__PURE__ */ new Set();
|
|
328
267
|
const out = [];
|
|
329
268
|
for (const item of raw) {
|
|
330
|
-
if (!isRecord(item)) continue;
|
|
269
|
+
if (!isRecord$1(item)) continue;
|
|
331
270
|
if (!isStorableUrl(item.url) || seen.has(item.url)) continue;
|
|
332
271
|
seen.add(item.url);
|
|
333
272
|
out.push({
|
|
@@ -347,16 +286,63 @@ function saveHistory(file, entries) {
|
|
|
347
286
|
if (!Array.isArray(entries)) throw new RangeError("history must be an array");
|
|
348
287
|
if (entries.length > HISTORY_MAX) throw new RangeError(`history must hold at most ${HISTORY_MAX} entries`);
|
|
349
288
|
for (const e of entries) {
|
|
350
|
-
if (!isRecord(e) || !isStorableUrl(e.url)) throw new RangeError("history entries need a loadable url");
|
|
289
|
+
if (!isRecord$1(e) || !isStorableUrl(e.url)) throw new RangeError("history entries need a loadable url");
|
|
351
290
|
if (!isCount(e.visits)) throw new RangeError("visits must be an integer >= 1");
|
|
352
291
|
if (!isStamp$1(e.lastVisit)) throw new RangeError("lastVisit must be a finite epoch ms >= 0");
|
|
353
292
|
}
|
|
354
293
|
node_fs.mkdirSync(node_path.dirname(file), { recursive: true });
|
|
355
294
|
node_fs.writeFileSync(file, JSON.stringify(entries, null, 2));
|
|
356
295
|
}
|
|
296
|
+
const IPC = {
|
|
297
|
+
navigate: "obsrv:navigate",
|
|
298
|
+
reload: "obsrv:reload",
|
|
299
|
+
back: "obsrv:back",
|
|
300
|
+
forward: "obsrv:forward",
|
|
301
|
+
setViewport: "obsrv:set-viewport",
|
|
302
|
+
setNativeBounds: "obsrv:set-native-bounds",
|
|
303
|
+
setNativeVisible: "obsrv:set-native-visible",
|
|
304
|
+
setMode: "obsrv:set-mode",
|
|
305
|
+
sendInput: "obsrv:send-input",
|
|
306
|
+
getHostInfo: "obsrv:get-host-info",
|
|
307
|
+
getSettings: "obsrv:get-settings",
|
|
308
|
+
setSettings: "obsrv:set-settings",
|
|
309
|
+
frame: "obsrv:frame",
|
|
310
|
+
frameSubscribe: "obsrv:frame-subscribe",
|
|
311
|
+
urlChanged: "obsrv:url-changed",
|
|
312
|
+
titleChanged: "obsrv:title-changed",
|
|
313
|
+
loadError: "obsrv:load-error",
|
|
314
|
+
hostChanged: "obsrv:host-changed",
|
|
315
|
+
targetLoading: "obsrv:target-loading",
|
|
316
|
+
targetNavigating: "obsrv:target-navigating",
|
|
317
|
+
nativeFocused: "obsrv:native-focused",
|
|
318
|
+
syncScroll: "obsrv:sync-scroll",
|
|
319
|
+
applyScroll: "obsrv:apply-scroll",
|
|
320
|
+
scrollResult: "obsrv:scroll-result",
|
|
321
|
+
openImage: "obsrv:open-image",
|
|
322
|
+
focusUrl: "obsrv:focus-url",
|
|
323
|
+
openImagePath: "obsrv:open-image-path",
|
|
324
|
+
readImageFile: "obsrv:read-image-file",
|
|
325
|
+
uiState: "obsrv:ui-state",
|
|
326
|
+
agentApply: "obsrv:agent-apply",
|
|
327
|
+
agentActivity: "obsrv:agent-activity",
|
|
328
|
+
getUpdate: "obsrv:get-update",
|
|
329
|
+
checkUpdate: "obsrv:check-update",
|
|
330
|
+
openRelease: "obsrv:open-release",
|
|
331
|
+
updateStatus: "obsrv:update-status",
|
|
332
|
+
getHistory: "obsrv:get-history",
|
|
333
|
+
clearHistory: "obsrv:clear-history",
|
|
334
|
+
historyChanged: "obsrv:history-changed",
|
|
335
|
+
getTabs: "obsrv:get-tabs",
|
|
336
|
+
addTab: "obsrv:add-tab",
|
|
337
|
+
closeTab: "obsrv:close-tab",
|
|
338
|
+
activateTab: "obsrv:activate-tab",
|
|
339
|
+
tabsChanged: "obsrv:tabs-changed"
|
|
340
|
+
};
|
|
357
341
|
const isPositive = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
|
|
358
342
|
const isStamp = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
|
|
359
343
|
const isRatio = (v) => typeof v === "number" && Number.isFinite(v) && v >= targetSource.SPLIT_MIN && v <= targetSource.SPLIT_MAX;
|
|
344
|
+
const isTabCap = (v) => typeof v === "number" && Number.isInteger(v) && v >= targetSource.MAX_TABS_MIN && v <= targetSource.MAX_TABS_MAX;
|
|
345
|
+
const readTabCap = (v) => typeof v === "number" && Number.isInteger(v) ? Math.min(targetSource.MAX_TABS_MAX, Math.max(targetSource.MAX_TABS_MIN, v)) : targetSource.DEFAULT_SETTINGS.maxTabs;
|
|
360
346
|
function loadSettings(file) {
|
|
361
347
|
try {
|
|
362
348
|
const raw = JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
@@ -378,7 +364,12 @@ function loadSettings(file) {
|
|
|
378
364
|
// was hand-edited to 0.97 means "I want the target tiny", not "refuse
|
|
379
365
|
// to start". Falling back to an even split loses one preference; the
|
|
380
366
|
// panes stay usable, which is the point of the band.
|
|
381
|
-
split: isRatio(raw.split) ? raw.split : targetSource.DEFAULT_SETTINGS.split
|
|
367
|
+
split: isRatio(raw.split) ? raw.split : targetSource.DEFAULT_SETTINGS.split,
|
|
368
|
+
// Lenient like `split`, but clamped rather than dropped — see
|
|
369
|
+
// `readTabCap` for why this field diverges. Only the on-disk load is
|
|
370
|
+
// lenient at all: it is the one path that has to cope with whatever a
|
|
371
|
+
// human typed.
|
|
372
|
+
maxTabs: readTabCap(raw.maxTabs)
|
|
382
373
|
};
|
|
383
374
|
} catch {
|
|
384
375
|
return { ...targetSource.DEFAULT_SETTINGS };
|
|
@@ -391,6 +382,40 @@ function saveSettings(file, s) {
|
|
|
391
382
|
if (!isStamp(s.lastUpdateCheck)) throw new RangeError("lastUpdateCheck must be a finite epoch ms >= 0");
|
|
392
383
|
if (typeof s.recordHistory !== "boolean") throw new RangeError("recordHistory must be a boolean");
|
|
393
384
|
if (!isRatio(s.split)) throw new RangeError(`split must be a finite ratio in ${targetSource.SPLIT_MIN}..${targetSource.SPLIT_MAX}`);
|
|
385
|
+
if (!isTabCap(s.maxTabs)) throw new RangeError(`maxTabs must be a whole count in ${targetSource.MAX_TABS_MIN}..${targetSource.MAX_TABS_MAX}`);
|
|
386
|
+
node_fs.mkdirSync(node_path.dirname(file), { recursive: true });
|
|
387
|
+
node_fs.writeFileSync(file, JSON.stringify(s, null, 2));
|
|
388
|
+
}
|
|
389
|
+
const DEFAULT_PRESET = "1080p-24";
|
|
390
|
+
const DEFAULT_PROFILE = targetSource.PANEL_PROFILES[0].id;
|
|
391
|
+
const isRecord = (v) => typeof v === "object" && v !== null;
|
|
392
|
+
const EMPTY = () => ({ tabs: [], activeIndex: 0 });
|
|
393
|
+
function loadTabs(file, max = Infinity) {
|
|
394
|
+
try {
|
|
395
|
+
const raw = JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
396
|
+
if (!isRecord(raw) || !Array.isArray(raw.tabs)) return EMPTY();
|
|
397
|
+
const tabs = [];
|
|
398
|
+
for (const entry of raw.tabs) {
|
|
399
|
+
if (!isRecord(entry) || typeof entry.url !== "string") continue;
|
|
400
|
+
tabs.push({
|
|
401
|
+
url: entry.url,
|
|
402
|
+
presetId: targetSource.SCREEN_PRESETS.some((p) => p.id === entry.presetId) ? entry.presetId : DEFAULT_PRESET,
|
|
403
|
+
profileId: targetSource.PANEL_PROFILES.some((p) => p.id === entry.profileId) ? entry.profileId : DEFAULT_PROFILE
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
const kept = tabs.slice(0, Math.max(0, max));
|
|
407
|
+
const idx = raw.activeIndex;
|
|
408
|
+
const activeIndex = typeof idx === "number" && Number.isInteger(idx) && idx >= 0 && idx < kept.length ? idx : 0;
|
|
409
|
+
return { tabs: kept, activeIndex };
|
|
410
|
+
} catch {
|
|
411
|
+
return EMPTY();
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
function saveTabs(file, s) {
|
|
415
|
+
if (!Array.isArray(s.tabs)) throw new TypeError("tabs must be an array");
|
|
416
|
+
for (const t of s.tabs) {
|
|
417
|
+
if (!isRecord(t) || typeof t.url !== "string") throw new TypeError("each tab needs a url string");
|
|
418
|
+
}
|
|
394
419
|
node_fs.mkdirSync(node_path.dirname(file), { recursive: true });
|
|
395
420
|
node_fs.writeFileSync(file, JSON.stringify(s, null, 2));
|
|
396
421
|
}
|
|
@@ -699,7 +724,7 @@ async function checkForUpdate(current, now) {
|
|
|
699
724
|
request.end();
|
|
700
725
|
});
|
|
701
726
|
}
|
|
702
|
-
const TOOLBAR_H =
|
|
727
|
+
const TOOLBAR_H = 114;
|
|
703
728
|
const MAX_IMAGE_FILE_BYTES = 64 * 1024 * 1024;
|
|
704
729
|
const SCROLL_REPLY_TIMEOUT_MS = 1e3;
|
|
705
730
|
function hostInfo(win) {
|
|
@@ -715,76 +740,91 @@ function hostInfo(win) {
|
|
|
715
740
|
}
|
|
716
741
|
}
|
|
717
742
|
function registerIpc(ctx) {
|
|
718
|
-
const { win,
|
|
743
|
+
const { win, bus, tabs } = ctx;
|
|
744
|
+
const tab = () => tabs.active();
|
|
719
745
|
const settingsFile = node_path.join(electron.app.getPath("userData"), "settings.json");
|
|
720
746
|
let settings = loadSettings(settingsFile);
|
|
747
|
+
tabs.maxTabs = settings.maxTabs;
|
|
721
748
|
const fromRenderer = (e) => e.sender === win.webContents;
|
|
722
749
|
const assertRenderer = (e) => {
|
|
723
750
|
if (!fromRenderer(e)) throw new Error("ipc: unexpected sender");
|
|
724
751
|
};
|
|
725
|
-
const
|
|
752
|
+
const undo = [];
|
|
753
|
+
const on = (channel, listener) => {
|
|
754
|
+
electron.ipcMain.on(channel, listener);
|
|
755
|
+
undo.push(() => electron.ipcMain.off(channel, listener));
|
|
756
|
+
};
|
|
757
|
+
const handle = (channel, listener) => {
|
|
758
|
+
electron.ipcMain.handle(channel, listener);
|
|
759
|
+
undo.push(() => electron.ipcMain.removeHandler(channel));
|
|
760
|
+
};
|
|
761
|
+
const navigateBoth = async (url, session) => {
|
|
762
|
+
const s = session ?? tab();
|
|
763
|
+
await s.ready;
|
|
726
764
|
let wanted = url;
|
|
727
765
|
try {
|
|
728
766
|
wanted = targetSource.normalizeUrl(url);
|
|
729
|
-
sync.expect(wanted);
|
|
767
|
+
s.sync.expect(wanted);
|
|
730
768
|
} catch {
|
|
731
769
|
}
|
|
732
|
-
const [applied] = await Promise.all([native.load(wanted), target.load(wanted)]);
|
|
770
|
+
const [applied] = await Promise.all([s.native.load(wanted), s.target.load(wanted)]);
|
|
733
771
|
return applied;
|
|
734
772
|
};
|
|
735
|
-
|
|
773
|
+
handle(IPC.navigate, (e, url) => {
|
|
736
774
|
assertRenderer(e);
|
|
737
775
|
return navigateBoth(url);
|
|
738
776
|
});
|
|
739
777
|
const reloadBoth = () => {
|
|
740
|
-
native.reload();
|
|
741
|
-
target.reload();
|
|
778
|
+
tab().native.reload();
|
|
779
|
+
tab().target.reload();
|
|
742
780
|
};
|
|
743
|
-
const goBack = () => native.back();
|
|
744
|
-
const goForward = () => native.forward();
|
|
745
|
-
|
|
781
|
+
const goBack = () => tab().native.back();
|
|
782
|
+
const goForward = () => tab().native.forward();
|
|
783
|
+
on(IPC.reload, (e) => {
|
|
746
784
|
if (!fromRenderer(e)) return;
|
|
747
785
|
reloadBoth();
|
|
748
786
|
});
|
|
749
|
-
|
|
787
|
+
on(IPC.back, (e) => {
|
|
750
788
|
if (!fromRenderer(e)) return;
|
|
751
789
|
goBack();
|
|
752
790
|
});
|
|
753
|
-
|
|
791
|
+
on(IPC.forward, (e) => {
|
|
754
792
|
if (!fromRenderer(e)) return;
|
|
755
793
|
goForward();
|
|
756
794
|
});
|
|
757
|
-
|
|
795
|
+
handle(IPC.setViewport, (e, width, height, rawDsf) => {
|
|
758
796
|
assertRenderer(e);
|
|
759
|
-
viewportArrived = true;
|
|
797
|
+
tab().viewportArrived = true;
|
|
760
798
|
const dsf = parseDeviceScaleFactor(rawDsf);
|
|
761
799
|
if (dsf === null) throw new Error("invalid deviceScaleFactor");
|
|
762
|
-
const v = target.setViewport(width, height, dsf);
|
|
800
|
+
const v = tab().target.setViewport(width, height, dsf);
|
|
763
801
|
return { width: v.width, height: v.height };
|
|
764
802
|
});
|
|
765
|
-
|
|
803
|
+
on(IPC.sendInput, (e, raw) => {
|
|
766
804
|
if (!fromRenderer(e)) return;
|
|
767
805
|
const ev = parseInputEvent(raw);
|
|
768
806
|
if (!ev) return;
|
|
769
807
|
try {
|
|
770
|
-
target.sendInput(ev);
|
|
808
|
+
tab().target.sendInput(ev);
|
|
771
809
|
} catch {
|
|
772
810
|
}
|
|
773
811
|
});
|
|
774
|
-
let modeIsLive = true;
|
|
775
812
|
let panesShowNative = true;
|
|
813
|
+
tabs.nativeVisible = (s) => s.modeIsLive && panesShowNative;
|
|
814
|
+
tabs.busEnabled = (s) => s.modeIsLive;
|
|
776
815
|
const applyNativeVisibility = () => {
|
|
777
|
-
|
|
816
|
+
const s = tab();
|
|
817
|
+
s.native.setVisible(tabs.nativeVisible(s));
|
|
778
818
|
};
|
|
779
|
-
|
|
819
|
+
on(IPC.setMode, (e, raw) => {
|
|
780
820
|
if (!fromRenderer(e)) return;
|
|
781
821
|
const mode = parseMode(raw);
|
|
782
822
|
if (!mode) return;
|
|
783
|
-
modeIsLive = mode === "url";
|
|
823
|
+
tab().modeIsLive = mode === "url";
|
|
784
824
|
applyNativeVisibility();
|
|
785
|
-
bus.setEnabled(modeIsLive);
|
|
825
|
+
bus.setEnabled(tab().modeIsLive);
|
|
786
826
|
});
|
|
787
|
-
|
|
827
|
+
on(IPC.setNativeVisible, (e, raw) => {
|
|
788
828
|
if (!fromRenderer(e)) return;
|
|
789
829
|
if (typeof raw !== "boolean") return;
|
|
790
830
|
panesShowNative = raw;
|
|
@@ -794,7 +834,7 @@ function registerIpc(ctx) {
|
|
|
794
834
|
const fallbackLayout = () => {
|
|
795
835
|
if (rendererDrivesLayout) return;
|
|
796
836
|
const [w = 0, h = 0] = win.getContentSize();
|
|
797
|
-
|
|
837
|
+
tabs.setSlotRect({
|
|
798
838
|
x: 0,
|
|
799
839
|
y: TOOLBAR_H,
|
|
800
840
|
width: Math.floor(w / 2),
|
|
@@ -803,17 +843,17 @@ function registerIpc(ctx) {
|
|
|
803
843
|
};
|
|
804
844
|
fallbackLayout();
|
|
805
845
|
win.on("resize", fallbackLayout);
|
|
806
|
-
|
|
846
|
+
on(IPC.setNativeBounds, (e, raw) => {
|
|
807
847
|
if (!fromRenderer(e)) return;
|
|
808
848
|
const rect = parseRect(raw);
|
|
809
849
|
if (!rect) return;
|
|
810
|
-
|
|
850
|
+
tabs.setSlotRect(rect);
|
|
811
851
|
rendererDrivesLayout = true;
|
|
812
852
|
});
|
|
813
853
|
let scrollSeq = 0;
|
|
814
854
|
const scrollWaiters = /* @__PURE__ */ new Map();
|
|
815
|
-
|
|
816
|
-
if (
|
|
855
|
+
on(IPC.scrollResult, (e, raw) => {
|
|
856
|
+
if (!tabs.byWebContents(e.sender)) return;
|
|
817
857
|
const report = parseScrollReport(raw);
|
|
818
858
|
if (!report) return;
|
|
819
859
|
const waiter = scrollWaiters.get(report.id);
|
|
@@ -822,11 +862,12 @@ function registerIpc(ctx) {
|
|
|
822
862
|
waiter(report);
|
|
823
863
|
});
|
|
824
864
|
const scrollBoth = async (req) => {
|
|
825
|
-
|
|
865
|
+
const s = tab();
|
|
866
|
+
await awaitViewportStable(s);
|
|
826
867
|
const base = { x: req.x, y: req.y };
|
|
827
868
|
if (req.selector !== void 0) base.selector = req.selector;
|
|
828
|
-
if (!native.webContents.isDestroyed()) native.webContents.send(IPC.applyScroll, base);
|
|
829
|
-
const wc = target.webContents;
|
|
869
|
+
if (!s.native.webContents.isDestroyed()) s.native.webContents.send(IPC.applyScroll, base);
|
|
870
|
+
const wc = s.target.webContents;
|
|
830
871
|
if (wc.isDestroyed()) return null;
|
|
831
872
|
const id = ++scrollSeq;
|
|
832
873
|
const answered = new Promise((resolve) => {
|
|
@@ -842,7 +883,7 @@ function registerIpc(ctx) {
|
|
|
842
883
|
wc.send(IPC.applyScroll, { ...base, id });
|
|
843
884
|
return answered;
|
|
844
885
|
};
|
|
845
|
-
|
|
886
|
+
handle(IPC.getHostInfo, (e) => {
|
|
846
887
|
assertRenderer(e);
|
|
847
888
|
return hostInfo(win);
|
|
848
889
|
});
|
|
@@ -860,76 +901,107 @@ function registerIpc(ctx) {
|
|
|
860
901
|
electron.screen.on("display-metrics-changed", pushHostIfChanged);
|
|
861
902
|
electron.screen.on("display-added", pushHostIfChanged);
|
|
862
903
|
electron.screen.on("display-removed", pushHostIfChanged);
|
|
863
|
-
|
|
904
|
+
handle(IPC.getSettings, (e) => {
|
|
864
905
|
assertRenderer(e);
|
|
865
906
|
return settings;
|
|
866
907
|
});
|
|
867
|
-
|
|
908
|
+
handle(IPC.setSettings, (e, raw) => {
|
|
868
909
|
assertRenderer(e);
|
|
869
910
|
const s = parseSettings(raw);
|
|
870
911
|
if (!s) throw new Error("invalid settings");
|
|
871
912
|
saveSettings(settingsFile, s);
|
|
872
913
|
const wasEnabled = settings.agentControl;
|
|
873
914
|
settings = s;
|
|
915
|
+
tabs.maxTabs = s.maxTabs;
|
|
874
916
|
if (s.agentControl !== wasEnabled) applyAgentControl(s.agentControl);
|
|
875
917
|
});
|
|
876
|
-
const uiState = {
|
|
918
|
+
const uiState = {
|
|
919
|
+
get presetId() {
|
|
920
|
+
return tab().presetId;
|
|
921
|
+
},
|
|
922
|
+
set presetId(v) {
|
|
923
|
+
tab().presetId = v;
|
|
924
|
+
},
|
|
925
|
+
get profileId() {
|
|
926
|
+
return tab().profileId;
|
|
927
|
+
},
|
|
928
|
+
set profileId(v) {
|
|
929
|
+
tab().profileId = v;
|
|
930
|
+
},
|
|
931
|
+
get viewMode() {
|
|
932
|
+
return tab().viewMode;
|
|
933
|
+
},
|
|
934
|
+
set viewMode(v) {
|
|
935
|
+
tab().viewMode = v;
|
|
936
|
+
},
|
|
937
|
+
get mode() {
|
|
938
|
+
return tab().reportedMode;
|
|
939
|
+
},
|
|
940
|
+
set mode(v) {
|
|
941
|
+
tab().reportedMode = v;
|
|
942
|
+
},
|
|
943
|
+
panes: "both"
|
|
944
|
+
};
|
|
877
945
|
let targetBounds = null;
|
|
878
946
|
let canvasBounds = null;
|
|
879
947
|
const MAX_PENDING_APPLIES = 32;
|
|
880
948
|
let rendererReported = false;
|
|
881
949
|
let warnedPendingOverflow = false;
|
|
882
950
|
const pendingApplies = [];
|
|
883
|
-
|
|
951
|
+
on(IPC.uiState, (e, raw) => {
|
|
884
952
|
if (!fromRenderer(e)) return;
|
|
885
953
|
const s = parseUiState(raw);
|
|
886
954
|
if (!s) return;
|
|
887
|
-
const { targetBounds: bounds, canvasBounds: canvas, ...state } = s;
|
|
888
|
-
Object.assign(uiState, state);
|
|
889
|
-
targetBounds = bounds ?? null;
|
|
890
|
-
canvasBounds = canvas ?? null;
|
|
891
955
|
if (!rendererReported) {
|
|
892
956
|
rendererReported = true;
|
|
893
957
|
for (const patch of pendingApplies.splice(0)) {
|
|
894
958
|
if (!win.isDestroyed()) win.webContents.send(IPC.agentApply, patch);
|
|
895
959
|
}
|
|
896
960
|
}
|
|
961
|
+
if (s.tabId !== tabs.activeId) return;
|
|
962
|
+
const { tabId: _tabId, targetBounds: bounds, canvasBounds: canvas, ...state } = s;
|
|
963
|
+
Object.assign(uiState, state);
|
|
964
|
+
targetBounds = bounds ?? null;
|
|
965
|
+
canvasBounds = canvas ?? null;
|
|
966
|
+
persistTabs();
|
|
897
967
|
});
|
|
898
|
-
let viewportPending = false;
|
|
899
|
-
let viewportArrived = false;
|
|
900
968
|
const VIEWPORT_ARRIVAL_MS = 600;
|
|
901
|
-
const awaitViewportStable = async () => {
|
|
902
|
-
if (!viewportPending) return;
|
|
903
|
-
viewportPending = false;
|
|
969
|
+
const awaitViewportStable = async (s = tab()) => {
|
|
970
|
+
if (!s.viewportPending) return;
|
|
971
|
+
s.viewportPending = false;
|
|
904
972
|
const arrivalDeadline = Date.now() + VIEWPORT_ARRIVAL_MS;
|
|
905
|
-
while (!viewportArrived && Date.now() < arrivalDeadline) {
|
|
973
|
+
while (!s.viewportArrived && Date.now() < arrivalDeadline) {
|
|
906
974
|
await new Promise((r) => setTimeout(r, SETTLE_POLL_MS));
|
|
907
975
|
}
|
|
908
|
-
await settleTarget();
|
|
976
|
+
await settleTarget(s.target);
|
|
909
977
|
};
|
|
910
978
|
const SETTLE_POLL_MS = 80;
|
|
911
979
|
const SETTLE_STABLE_READS = 2;
|
|
912
980
|
const SETTLE_BUDGET_MS = 4e3;
|
|
913
981
|
const SETTLE_DRAW_MS = 120;
|
|
914
|
-
const nextFrame = (budgetMs) => new Promise((resolve) => {
|
|
982
|
+
const nextFrame = (t, budgetMs) => new Promise((resolve) => {
|
|
983
|
+
if (!t.painting) {
|
|
984
|
+
resolve(false);
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
915
987
|
const timer = setTimeout(() => {
|
|
916
|
-
|
|
988
|
+
t.off("frame", onFrame);
|
|
917
989
|
resolve(false);
|
|
918
990
|
}, budgetMs);
|
|
919
991
|
const onFrame = () => {
|
|
920
992
|
clearTimeout(timer);
|
|
921
|
-
|
|
993
|
+
t.off("frame", onFrame);
|
|
922
994
|
resolve(true);
|
|
923
995
|
};
|
|
924
|
-
|
|
925
|
-
|
|
996
|
+
t.on("frame", onFrame);
|
|
997
|
+
t.invalidate();
|
|
926
998
|
});
|
|
927
|
-
const settleTarget = async () => {
|
|
999
|
+
const settleTarget = async (t = tab().target) => {
|
|
928
1000
|
const deadline = Date.now() + SETTLE_BUDGET_MS;
|
|
929
1001
|
let last = "";
|
|
930
1002
|
let stable = 0;
|
|
931
1003
|
while (Date.now() < deadline) {
|
|
932
|
-
const v =
|
|
1004
|
+
const v = t.getViewport();
|
|
933
1005
|
const key = `${v.width}x${v.height}`;
|
|
934
1006
|
stable = key === last ? stable + 1 : 0;
|
|
935
1007
|
last = key;
|
|
@@ -937,7 +1009,7 @@ function registerIpc(ctx) {
|
|
|
937
1009
|
await new Promise((r) => setTimeout(r, SETTLE_POLL_MS));
|
|
938
1010
|
}
|
|
939
1011
|
if (stable < SETTLE_STABLE_READS) return false;
|
|
940
|
-
const painted = await nextFrame(Math.max(0, deadline - Date.now()));
|
|
1012
|
+
const painted = await nextFrame(t, Math.max(0, deadline - Date.now()));
|
|
941
1013
|
await new Promise((r) => setTimeout(r, SETTLE_DRAW_MS));
|
|
942
1014
|
return painted;
|
|
943
1015
|
};
|
|
@@ -971,15 +1043,15 @@ function registerIpc(ctx) {
|
|
|
971
1043
|
if (!win.isDestroyed()) win.webContents.send(IPC.updateStatus, state);
|
|
972
1044
|
return state;
|
|
973
1045
|
};
|
|
974
|
-
|
|
1046
|
+
handle(IPC.getUpdate, (e) => {
|
|
975
1047
|
assertRenderer(e);
|
|
976
1048
|
return update;
|
|
977
1049
|
});
|
|
978
|
-
|
|
1050
|
+
handle(IPC.checkUpdate, (e) => {
|
|
979
1051
|
assertRenderer(e);
|
|
980
1052
|
return runUpdateCheck();
|
|
981
1053
|
});
|
|
982
|
-
|
|
1054
|
+
handle(IPC.openRelease, async (e) => {
|
|
983
1055
|
assertRenderer(e);
|
|
984
1056
|
if (releaseUrl === "") return false;
|
|
985
1057
|
await electron.shell.openExternal(releaseUrl);
|
|
@@ -1000,17 +1072,96 @@ function registerIpc(ctx) {
|
|
|
1000
1072
|
history = next;
|
|
1001
1073
|
publishHistory();
|
|
1002
1074
|
};
|
|
1003
|
-
|
|
1075
|
+
tabs.onNativeNavigate = (url) => {
|
|
1004
1076
|
if (!settings.recordHistory) return;
|
|
1005
1077
|
const next = recordVisit(history, url, Date.now());
|
|
1006
1078
|
if (next === history) return;
|
|
1007
1079
|
commitHistory(next);
|
|
1080
|
+
};
|
|
1081
|
+
const publishTabs = () => {
|
|
1082
|
+
if (win.isDestroyed() || win.webContents.isDestroyed()) return;
|
|
1083
|
+
win.webContents.send(IPC.tabsChanged, tabs.snapshot());
|
|
1084
|
+
};
|
|
1085
|
+
tabs.onTabsChanged = () => {
|
|
1086
|
+
publishTabs();
|
|
1087
|
+
persistTabs();
|
|
1088
|
+
};
|
|
1089
|
+
handle(IPC.getTabs, (e) => {
|
|
1090
|
+
assertRenderer(e);
|
|
1091
|
+
return tabs.snapshot();
|
|
1092
|
+
});
|
|
1093
|
+
handle(IPC.addTab, (e) => {
|
|
1094
|
+
assertRenderer(e);
|
|
1095
|
+
const session = tabs.add();
|
|
1096
|
+
if (!session) return null;
|
|
1097
|
+
tabs.activate(session.id);
|
|
1098
|
+
return session.id;
|
|
1099
|
+
});
|
|
1100
|
+
on(IPC.closeTab, (e, raw) => {
|
|
1101
|
+
if (!fromRenderer(e)) return;
|
|
1102
|
+
const id = parseTabId(raw);
|
|
1103
|
+
if (id === null) return;
|
|
1104
|
+
tabs.close(id);
|
|
1105
|
+
});
|
|
1106
|
+
on(IPC.activateTab, (e, raw) => {
|
|
1107
|
+
if (!fromRenderer(e)) return;
|
|
1108
|
+
const id = parseTabId(raw);
|
|
1109
|
+
if (id === null) return;
|
|
1110
|
+
tabs.activate(id);
|
|
1008
1111
|
});
|
|
1009
|
-
electron.
|
|
1112
|
+
const tabsFile = node_path.join(electron.app.getPath("userData"), "tabs.json");
|
|
1113
|
+
let lastWritten = "";
|
|
1114
|
+
let persistReady = false;
|
|
1115
|
+
const persistTabs = () => {
|
|
1116
|
+
if (!persistReady) return;
|
|
1117
|
+
const stored = {
|
|
1118
|
+
tabs: tabs.tabs.map((t) => ({ url: t.url, presetId: t.presetId, profileId: t.profileId })),
|
|
1119
|
+
activeIndex: tabs.activeIndex
|
|
1120
|
+
};
|
|
1121
|
+
const json = JSON.stringify(stored);
|
|
1122
|
+
if (json === lastWritten) return;
|
|
1123
|
+
lastWritten = json;
|
|
1124
|
+
try {
|
|
1125
|
+
saveTabs(tabsFile, stored);
|
|
1126
|
+
} catch (e) {
|
|
1127
|
+
console.warn("obsrv: could not save tabs", e);
|
|
1128
|
+
}
|
|
1129
|
+
};
|
|
1130
|
+
tabs.onTabUrlChanged = persistTabs;
|
|
1131
|
+
const restoreTabs = async () => {
|
|
1132
|
+
const stored = loadTabs(tabsFile, settings.maxTabs);
|
|
1133
|
+
if (stored.tabs.length === 0) return;
|
|
1134
|
+
const sessions = [];
|
|
1135
|
+
for (const [i, entry] of stored.tabs.entries()) {
|
|
1136
|
+
const s = i === 0 ? tabs.tabs[0] : tabs.add();
|
|
1137
|
+
if (!s) break;
|
|
1138
|
+
s.presetId = entry.presetId;
|
|
1139
|
+
s.profileId = entry.profileId;
|
|
1140
|
+
sessions.push(s);
|
|
1141
|
+
}
|
|
1142
|
+
tabs.activate(sessions[Math.min(stored.activeIndex, sessions.length - 1)].id);
|
|
1143
|
+
await Promise.all(
|
|
1144
|
+
sessions.map(async (s, i) => {
|
|
1145
|
+
const wanted = stored.tabs[i].url;
|
|
1146
|
+
try {
|
|
1147
|
+
await navigateBoth(wanted, s);
|
|
1148
|
+
} catch (e) {
|
|
1149
|
+
console.warn("obsrv: could not restore a tab", e);
|
|
1150
|
+
}
|
|
1151
|
+
if (s.url === "" || s.url === "about:blank") s.url = wanted;
|
|
1152
|
+
})
|
|
1153
|
+
);
|
|
1154
|
+
};
|
|
1155
|
+
void restoreTabs().finally(() => {
|
|
1156
|
+
persistReady = true;
|
|
1157
|
+
persistTabs();
|
|
1158
|
+
publishTabs();
|
|
1159
|
+
});
|
|
1160
|
+
handle(IPC.getHistory, (e) => {
|
|
1010
1161
|
assertRenderer(e);
|
|
1011
1162
|
return history;
|
|
1012
1163
|
});
|
|
1013
|
-
|
|
1164
|
+
handle(IPC.clearHistory, (e) => {
|
|
1014
1165
|
assertRenderer(e);
|
|
1015
1166
|
commitHistory([]);
|
|
1016
1167
|
});
|
|
@@ -1022,17 +1173,17 @@ function registerIpc(ctx) {
|
|
|
1022
1173
|
status: () => {
|
|
1023
1174
|
let url = "";
|
|
1024
1175
|
try {
|
|
1025
|
-
url = target.webContents.getURL();
|
|
1176
|
+
url = tab().target.webContents.getURL();
|
|
1026
1177
|
} catch {
|
|
1027
1178
|
}
|
|
1028
|
-
return { version: appVersion, url, ...uiState };
|
|
1179
|
+
return { version: appVersion, url, tabId: tabs.activeId, tabIndex: tabs.activeIndex, ...uiState };
|
|
1029
1180
|
},
|
|
1030
1181
|
navigate: navigateBoth,
|
|
1031
1182
|
apply: (patch) => {
|
|
1032
1183
|
if (win.isDestroyed()) return;
|
|
1033
1184
|
if (patch.presetId !== void 0) {
|
|
1034
|
-
viewportPending = true;
|
|
1035
|
-
viewportArrived = false;
|
|
1185
|
+
tab().viewportPending = true;
|
|
1186
|
+
tab().viewportArrived = false;
|
|
1036
1187
|
}
|
|
1037
1188
|
if (!rendererReported) {
|
|
1038
1189
|
if (pendingApplies.length >= MAX_PENDING_APPLIES) {
|
|
@@ -1072,7 +1223,7 @@ function registerIpc(ctx) {
|
|
|
1072
1223
|
warnings
|
|
1073
1224
|
};
|
|
1074
1225
|
},
|
|
1075
|
-
viewport: () => target.getViewport(),
|
|
1226
|
+
viewport: () => tab().target.getViewport(),
|
|
1076
1227
|
// An agent scroll drives both panes over the same `applyScroll` channel
|
|
1077
1228
|
// the pane-sync mirror uses — each pane's sync preload applies it and
|
|
1078
1229
|
// suppresses its own echo, so the two arrive together with no loop.
|
|
@@ -1084,8 +1235,8 @@ function registerIpc(ctx) {
|
|
|
1084
1235
|
const up = parseInputEvent({ type: "mouseUp", x: c.x, y: c.y, button: c.button, clickCount: 1 });
|
|
1085
1236
|
if (!down || !up) return;
|
|
1086
1237
|
try {
|
|
1087
|
-
target.sendInput(down);
|
|
1088
|
-
target.sendInput(up);
|
|
1238
|
+
tab().target.sendInput(down);
|
|
1239
|
+
tab().target.sendInput(up);
|
|
1089
1240
|
} catch {
|
|
1090
1241
|
}
|
|
1091
1242
|
},
|
|
@@ -1113,7 +1264,7 @@ function registerIpc(ctx) {
|
|
|
1113
1264
|
if (process.env.OBSRV_AGENT_CONTROL === "1") settings = { ...settings, agentControl: true };
|
|
1114
1265
|
if (settings.agentControl) applyAgentControl(true);
|
|
1115
1266
|
electron.app.on("will-quit", () => control.stop());
|
|
1116
|
-
|
|
1267
|
+
handle(IPC.readImageFile, async (e, raw) => {
|
|
1117
1268
|
assertRenderer(e);
|
|
1118
1269
|
if (typeof raw !== "string" || !targetSource.IMAGE_EXTENSIONS.test(raw)) throw new Error("Unsupported file type");
|
|
1119
1270
|
const { size } = await promises.stat(raw);
|
|
@@ -1122,23 +1273,59 @@ function registerIpc(ctx) {
|
|
|
1122
1273
|
}
|
|
1123
1274
|
return promises.readFile(raw);
|
|
1124
1275
|
});
|
|
1276
|
+
return () => {
|
|
1277
|
+
for (const off of undo.splice(0)) off();
|
|
1278
|
+
electron.screen.off("display-metrics-changed", pushHostIfChanged);
|
|
1279
|
+
electron.screen.off("display-added", pushHostIfChanged);
|
|
1280
|
+
electron.screen.off("display-removed", pushHostIfChanged);
|
|
1281
|
+
control.stop();
|
|
1282
|
+
};
|
|
1125
1283
|
}
|
|
1126
|
-
function installMenu({ win,
|
|
1284
|
+
function installMenu({ win, tabs }) {
|
|
1127
1285
|
const send = (channel) => {
|
|
1128
1286
|
if (!win.isDestroyed()) win.webContents.send(channel);
|
|
1129
1287
|
};
|
|
1288
|
+
const openTab = () => {
|
|
1289
|
+
const session = tabs.add();
|
|
1290
|
+
if (session) tabs.activate(session.id);
|
|
1291
|
+
};
|
|
1292
|
+
const closeTab2 = () => tabs.close(tabs.activeId);
|
|
1293
|
+
const selectTab = (index) => {
|
|
1294
|
+
const list = tabs.tabs;
|
|
1295
|
+
const wanted = index < 0 ? list[list.length - 1] : list[index];
|
|
1296
|
+
if (wanted) tabs.activate(wanted.id);
|
|
1297
|
+
};
|
|
1298
|
+
const selectItems = [
|
|
1299
|
+
...[1, 2, 3, 4, 5, 6, 7, 8].map((n) => ({
|
|
1300
|
+
id: `select-tab-${n}`,
|
|
1301
|
+
label: `Select Tab ${n}`,
|
|
1302
|
+
accelerator: `CmdOrCtrl+${n}`,
|
|
1303
|
+
click: () => selectTab(n - 1)
|
|
1304
|
+
})),
|
|
1305
|
+
{
|
|
1306
|
+
id: "select-tab-last",
|
|
1307
|
+
label: "Select Last Tab",
|
|
1308
|
+
accelerator: "CmdOrCtrl+9",
|
|
1309
|
+
click: () => selectTab(-1)
|
|
1310
|
+
}
|
|
1311
|
+
];
|
|
1130
1312
|
const template = [
|
|
1131
1313
|
{ role: "appMenu" },
|
|
1132
1314
|
{
|
|
1133
1315
|
label: "File",
|
|
1134
1316
|
submenu: [
|
|
1317
|
+
{ id: "new-tab", label: "New Tab", accelerator: "CmdOrCtrl+T", click: openTab },
|
|
1318
|
+
{ id: "close-tab", label: "Close Tab", accelerator: "CmdOrCtrl+W", click: closeTab2 },
|
|
1319
|
+
{ type: "separator" },
|
|
1135
1320
|
{
|
|
1136
1321
|
label: "Open Image…",
|
|
1137
1322
|
accelerator: "CmdOrCtrl+O",
|
|
1138
1323
|
click: () => send(IPC.openImage)
|
|
1139
1324
|
},
|
|
1140
1325
|
{ type: "separator" },
|
|
1141
|
-
|
|
1326
|
+
// Displaced to Shift+Cmd+W, as in every browser, because Cmd+W now
|
|
1327
|
+
// belongs to the tab. The role keeps the platform behaviour.
|
|
1328
|
+
{ role: "close", accelerator: "Shift+CmdOrCtrl+W" }
|
|
1142
1329
|
]
|
|
1143
1330
|
},
|
|
1144
1331
|
{ role: "editMenu" },
|
|
@@ -1149,6 +1336,7 @@ function installMenu({ win, native, target }) {
|
|
|
1149
1336
|
label: "Reload",
|
|
1150
1337
|
accelerator: "CmdOrCtrl+R",
|
|
1151
1338
|
click: () => {
|
|
1339
|
+
const { native, target } = tabs.active();
|
|
1152
1340
|
native.reload();
|
|
1153
1341
|
target.reload();
|
|
1154
1342
|
}
|
|
@@ -1170,10 +1358,71 @@ function installMenu({ win, native, target }) {
|
|
|
1170
1358
|
{ role: "togglefullscreen" }
|
|
1171
1359
|
]
|
|
1172
1360
|
},
|
|
1361
|
+
// Chrome's Tab menu, with Chrome's items: the numbers are discoverable
|
|
1362
|
+
// here rather than being folklore, and the labels say which is which.
|
|
1363
|
+
{ label: "Tab", submenu: selectItems },
|
|
1173
1364
|
{ role: "windowMenu" }
|
|
1174
1365
|
];
|
|
1175
1366
|
electron.Menu.setApplicationMenu(electron.Menu.buildFromTemplate(template));
|
|
1176
1367
|
}
|
|
1368
|
+
function closeTab(tabs, closeId, activeId) {
|
|
1369
|
+
const index = tabs.findIndex((t) => t.id === closeId);
|
|
1370
|
+
if (index === -1) return { tabs, activeId };
|
|
1371
|
+
const next = tabs.filter((t) => t.id !== closeId);
|
|
1372
|
+
if (next.length === 0) return { tabs: next, activeId: null };
|
|
1373
|
+
if (closeId !== activeId) return { tabs: next, activeId };
|
|
1374
|
+
const neighbour = next[Math.min(index, next.length - 1)];
|
|
1375
|
+
return { tabs: next, activeId: neighbour.id };
|
|
1376
|
+
}
|
|
1377
|
+
function canAddTab(count, max) {
|
|
1378
|
+
return count < max;
|
|
1379
|
+
}
|
|
1380
|
+
function attachFrameBus(target, win) {
|
|
1381
|
+
let ready = false;
|
|
1382
|
+
let enabled = true;
|
|
1383
|
+
let source = target;
|
|
1384
|
+
const gone = () => win.isDestroyed() || win.webContents.isDestroyed();
|
|
1385
|
+
const onFrame = (msg) => {
|
|
1386
|
+
if (!ready || !enabled || gone()) return;
|
|
1387
|
+
win.webContents.send(IPC.frame, msg);
|
|
1388
|
+
};
|
|
1389
|
+
const onSubscribe = (e) => {
|
|
1390
|
+
if (gone() || e.sender !== win.webContents) return;
|
|
1391
|
+
ready = true;
|
|
1392
|
+
if (enabled) source.invalidate();
|
|
1393
|
+
};
|
|
1394
|
+
const onRendererGone = (details) => {
|
|
1395
|
+
if (details.isMainFrame && !details.isSameDocument) ready = false;
|
|
1396
|
+
};
|
|
1397
|
+
const bind = (next) => {
|
|
1398
|
+
source.off("frame", onFrame);
|
|
1399
|
+
source = next;
|
|
1400
|
+
source.on("frame", onFrame);
|
|
1401
|
+
};
|
|
1402
|
+
source.on("frame", onFrame);
|
|
1403
|
+
electron.ipcMain.on(IPC.frameSubscribe, onSubscribe);
|
|
1404
|
+
win.webContents.on("did-start-navigation", onRendererGone);
|
|
1405
|
+
return {
|
|
1406
|
+
detach() {
|
|
1407
|
+
ready = false;
|
|
1408
|
+
source.off("frame", onFrame);
|
|
1409
|
+
electron.ipcMain.removeListener(IPC.frameSubscribe, onSubscribe);
|
|
1410
|
+
if (!gone()) win.webContents.off("did-start-navigation", onRendererGone);
|
|
1411
|
+
},
|
|
1412
|
+
setEnabled(next) {
|
|
1413
|
+
enabled = next;
|
|
1414
|
+
if (enabled && ready) source.invalidate();
|
|
1415
|
+
},
|
|
1416
|
+
// Deliberately not short-circuited when `next` is already the source:
|
|
1417
|
+
// unbind-then-rebind is idempotent, and the identity guard would hide the
|
|
1418
|
+
// one failure this has to rule out — a `bind` that subscribes without
|
|
1419
|
+
// unsubscribing, which stacks listeners and delivers every paint twice.
|
|
1420
|
+
setSource(next) {
|
|
1421
|
+
bind(next);
|
|
1422
|
+
if (ready && enabled) next.invalidate();
|
|
1423
|
+
}
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1177
1426
|
const ERR_ABORTED = -3;
|
|
1178
1427
|
class NativePane {
|
|
1179
1428
|
constructor(win, events) {
|
|
@@ -1291,21 +1540,27 @@ function attachSyncBus(native, target, onUrlChanged) {
|
|
|
1291
1540
|
native.webContents.on("did-navigate", onNativeNav);
|
|
1292
1541
|
native.webContents.on("did-navigate-in-page", onNativeNavInPage);
|
|
1293
1542
|
target.on("url-changed", onTargetNav);
|
|
1294
|
-
const onScroll = (e, raw) => {
|
|
1295
|
-
const other = e.sender === native.webContents ? target.webContents : e.sender === target.webContents ? native.webContents : null;
|
|
1296
|
-
if (!other || other.isDestroyed()) return;
|
|
1297
|
-
const pos = parseScrollPos(raw);
|
|
1298
|
-
if (!pos) return;
|
|
1299
|
-
other.send(IPC.applyScroll, pos);
|
|
1300
|
-
};
|
|
1301
|
-
electron.ipcMain.on(IPC.syncScroll, onScroll);
|
|
1302
1543
|
return {
|
|
1303
1544
|
expect(url) {
|
|
1304
1545
|
pending.native = url;
|
|
1305
1546
|
pending.target = url;
|
|
1306
1547
|
},
|
|
1548
|
+
// This channel is driven by the sync preload inside the two *page*
|
|
1549
|
+
// webContents, never by the app's own renderer, so the sender check here
|
|
1550
|
+
// is the inverse of `registerIpc`'s: accept exactly the two panes and
|
|
1551
|
+
// route to the other one. The manager has already resolved the sender to
|
|
1552
|
+
// this session, but the check stays — it is what picks the *other* pane,
|
|
1553
|
+
// and a sender belonging to neither is still dropped rather than guessed.
|
|
1554
|
+
// The payload comes from a preload running alongside a third-party page,
|
|
1555
|
+
// so it is parsed like any renderer message.
|
|
1556
|
+
onScroll(e, raw) {
|
|
1557
|
+
const other = e.sender === native.webContents ? target.webContents : e.sender === target.webContents ? native.webContents : null;
|
|
1558
|
+
if (!other || other.isDestroyed()) return;
|
|
1559
|
+
const pos = parseScrollPos(raw);
|
|
1560
|
+
if (!pos) return;
|
|
1561
|
+
other.send(IPC.applyScroll, pos);
|
|
1562
|
+
},
|
|
1307
1563
|
detach() {
|
|
1308
|
-
electron.ipcMain.off(IPC.syncScroll, onScroll);
|
|
1309
1564
|
target.off("url-changed", onTargetNav);
|
|
1310
1565
|
if (!native.webContents.isDestroyed()) {
|
|
1311
1566
|
native.webContents.off("did-navigate", onNativeNav);
|
|
@@ -1314,8 +1569,321 @@ function attachSyncBus(native, target, onUrlChanged) {
|
|
|
1314
1569
|
}
|
|
1315
1570
|
};
|
|
1316
1571
|
}
|
|
1572
|
+
let nextId = 1;
|
|
1573
|
+
class TabSession {
|
|
1574
|
+
id;
|
|
1575
|
+
native;
|
|
1576
|
+
target;
|
|
1577
|
+
sync;
|
|
1578
|
+
// `modeIsLive` and `reportedMode` describe the same idea from opposite
|
|
1579
|
+
// directions and must not be collapsed into one field. `modeIsLive` is
|
|
1580
|
+
// main's own authority over an OS-level view: `IPC.setMode` is the command
|
|
1581
|
+
// that writes it and `applyNativeVisibility` is the only thing that reads
|
|
1582
|
+
// it. `reportedMode` is a mirror of what the renderer says it is showing,
|
|
1583
|
+
// written by the renderer's `IPC.uiState` report so the control server's
|
|
1584
|
+
// `status` can answer without a round trip.
|
|
1585
|
+
//
|
|
1586
|
+
// Letting the report drive visibility would let an observation write to a
|
|
1587
|
+
// control, and it reorders badly: a `uiState` arriving ahead of its matching
|
|
1588
|
+
// `setMode` would make the next `setNativeVisible` apply the new mode early.
|
|
1589
|
+
// Tab activation will start moving these messages around, so keep them apart.
|
|
1590
|
+
/** False in image mode: the left pane is drawn in the renderer instead. */
|
|
1591
|
+
modeIsLive = true;
|
|
1592
|
+
/** The renderer's last reported mode, for `status`. Never drives the view. */
|
|
1593
|
+
reportedMode = "url";
|
|
1594
|
+
/**
|
|
1595
|
+
* What the strip shows for this tab. Both are main's, because main is what
|
|
1596
|
+
* every tab's panes report to: a background tab has no other way to keep its
|
|
1597
|
+
* own strip entry current, and the renderer holds one URL bar for the tab in
|
|
1598
|
+
* front. `title` is Chromium's page title, empty until the page has one —
|
|
1599
|
+
* `tabTitle` falls back to the host, then the URL.
|
|
1600
|
+
*/
|
|
1601
|
+
url = "";
|
|
1602
|
+
title = "";
|
|
1603
|
+
/** A preset change is in flight; a capture or scroll must wait for it. */
|
|
1604
|
+
viewportPending = false;
|
|
1605
|
+
viewportArrived = false;
|
|
1606
|
+
presetId = "1080p-24";
|
|
1607
|
+
profileId = "reference";
|
|
1608
|
+
viewMode = "fit";
|
|
1609
|
+
/**
|
|
1610
|
+
* Resolves once both panes have settled on their initial `about:blank`.
|
|
1611
|
+
*
|
|
1612
|
+
* `TargetSource` owns its first navigation, so the pair does not exist in a
|
|
1613
|
+
* usable state the instant the constructor returns: a `navigate` issued
|
|
1614
|
+
* before that internal commit lands is undone by it, because the commit
|
|
1615
|
+
* clears the expectation the navigate had just set and `SyncBus` rightly
|
|
1616
|
+
* mirrors a stray `about:blank` into the other pane. With one session that
|
|
1617
|
+
* only ever happened at boot, which is why `boot()` announced the pair by
|
|
1618
|
+
* hand; with tabs it happens every time one is opened and driven, so the
|
|
1619
|
+
* announcement and the settle belong to the session itself.
|
|
1620
|
+
*/
|
|
1621
|
+
ready;
|
|
1622
|
+
constructor(win, onUrlChanged) {
|
|
1623
|
+
this.id = `tab-${nextId++}`;
|
|
1624
|
+
this.native = new NativePane(win, {
|
|
1625
|
+
onLoadError: (err) => {
|
|
1626
|
+
if (!win.isDestroyed()) win.webContents.send(IPC.loadError, { tabId: this.id, error: err });
|
|
1627
|
+
},
|
|
1628
|
+
// The renderer reads the file back over `readImageFile`; no bytes here.
|
|
1629
|
+
onImageDrop: (path) => {
|
|
1630
|
+
if (!win.isDestroyed()) win.webContents.send(IPC.openImagePath, path);
|
|
1631
|
+
}
|
|
1632
|
+
});
|
|
1633
|
+
this.target = new targetSource.TargetSource();
|
|
1634
|
+
this.sync = attachSyncBus(this.native, this.target, onUrlChanged);
|
|
1635
|
+
this.sync.expect("about:blank");
|
|
1636
|
+
this.ready = Promise.all([this.native.load("about:blank"), this.target.ready]).then(() => void 0);
|
|
1637
|
+
}
|
|
1638
|
+
/**
|
|
1639
|
+
* Background tabs stay loaded but stop rasterising. Offscreen rendering runs
|
|
1640
|
+
* at a fixed frame rate with `backgroundThrottling: false`, so without this
|
|
1641
|
+
* every hidden tab would paint a full viewport forever for nobody. The page
|
|
1642
|
+
* keeps its DOM, timers, network and scroll — only pixel production stops.
|
|
1643
|
+
*
|
|
1644
|
+
* Only the target is suspended. The native pane is an OS-level view that the
|
|
1645
|
+
* window has already hidden, and it is Chromium's own compositor deciding
|
|
1646
|
+
* whether an occluded view costs anything — main has no `stopPainting` for
|
|
1647
|
+
* it and does not need one.
|
|
1648
|
+
*
|
|
1649
|
+
* The wish is kept by `TargetSource`, not here: a dsf change recreates the
|
|
1650
|
+
* offscreen window, and a flag held at this level would go stale the moment
|
|
1651
|
+
* a backgrounded tab changed preset.
|
|
1652
|
+
*/
|
|
1653
|
+
setPainting(painting) {
|
|
1654
|
+
this.target.setPainting(painting);
|
|
1655
|
+
}
|
|
1656
|
+
/** Whether this session is producing pixels. */
|
|
1657
|
+
get painting() {
|
|
1658
|
+
return this.target.painting;
|
|
1659
|
+
}
|
|
1660
|
+
destroy() {
|
|
1661
|
+
this.sync.detach();
|
|
1662
|
+
this.target.destroy();
|
|
1663
|
+
this.native.view.webContents.close();
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
class TabManager {
|
|
1667
|
+
constructor(win) {
|
|
1668
|
+
this.win = win;
|
|
1669
|
+
const first = this.create();
|
|
1670
|
+
this.list.push(first);
|
|
1671
|
+
this.id = first.id;
|
|
1672
|
+
this.bus = attachFrameBus(first.target, win);
|
|
1673
|
+
electron.ipcMain.on(IPC.syncScroll, this.route);
|
|
1674
|
+
}
|
|
1675
|
+
list = [];
|
|
1676
|
+
id = "";
|
|
1677
|
+
/**
|
|
1678
|
+
* Where the native pane goes, in window coordinates. It is window-global —
|
|
1679
|
+
* one slot on screen — and applied to *every* session's view, so activation
|
|
1680
|
+
* needs no fresh measurement: the incoming view is already positioned.
|
|
1681
|
+
*/
|
|
1682
|
+
slot = null;
|
|
1683
|
+
/** The one bus. Re-pointed by `activate`, never duplicated. */
|
|
1684
|
+
bus;
|
|
1685
|
+
/** The cap from Settings. `registerIpc` owns the settings file and writes it. */
|
|
1686
|
+
maxTabs = targetSource.DEFAULT_SETTINGS.maxTabs;
|
|
1687
|
+
/**
|
|
1688
|
+
* Whether the active session's native view belongs on screen. Two window-
|
|
1689
|
+
* level inputs decide it (image mode and the Both/Target toggle) and
|
|
1690
|
+
* `registerIpc` derives them in one place, so activation asks rather than
|
|
1691
|
+
* re-deriving and drifting.
|
|
1692
|
+
*/
|
|
1693
|
+
nativeVisible = () => true;
|
|
1694
|
+
/**
|
|
1695
|
+
* Whether the bus should deliver the active session's frames. Image mode is
|
|
1696
|
+
* per tab, so this cannot be left to the `setMode` handler alone: that
|
|
1697
|
+
* handler only fires when the *mode* changes, and a tab switch changes which
|
|
1698
|
+
* mode is in force without any mode changing. Leaving it out strands the bus
|
|
1699
|
+
* in the outgoing tab's setting — switch off an image-mode tab and the
|
|
1700
|
+
* canvas never receives another frame.
|
|
1701
|
+
*/
|
|
1702
|
+
busEnabled = () => true;
|
|
1703
|
+
/** A committed main-frame navigation in any tab's native pane, for history. */
|
|
1704
|
+
onNativeNavigate = () => {
|
|
1705
|
+
};
|
|
1706
|
+
/**
|
|
1707
|
+
* The strip changed shape: a tab opened, closed, or came to the front.
|
|
1708
|
+
* `registerIpc` publishes the snapshot; per-tab url and title travel on their
|
|
1709
|
+
* own channels, so this fires only for list identity.
|
|
1710
|
+
*/
|
|
1711
|
+
onTabsChanged = () => {
|
|
1712
|
+
};
|
|
1713
|
+
/**
|
|
1714
|
+
* A tab's URL changed. Separate from `onTabsChanged` because the strip
|
|
1715
|
+
* already learns this on its own channel: the list did not change shape, and
|
|
1716
|
+
* what is on disk did. Persistence is the only caller.
|
|
1717
|
+
*/
|
|
1718
|
+
onTabUrlChanged = () => {
|
|
1719
|
+
};
|
|
1720
|
+
route = (e, raw) => {
|
|
1721
|
+
const session = this.byWebContents(e.sender);
|
|
1722
|
+
if (!session) return;
|
|
1723
|
+
session.sync.onScroll(e, raw);
|
|
1724
|
+
};
|
|
1725
|
+
get tabs() {
|
|
1726
|
+
return this.list;
|
|
1727
|
+
}
|
|
1728
|
+
get activeId() {
|
|
1729
|
+
return this.id;
|
|
1730
|
+
}
|
|
1731
|
+
/**
|
|
1732
|
+
* The active tab's position in the strip, 0-based. `findIndex` cannot miss —
|
|
1733
|
+
* the manager always holds its active session — but a -1 escaping into
|
|
1734
|
+
* `tabs.json` or an agent's `status` would be a lie, so it clamps.
|
|
1735
|
+
*/
|
|
1736
|
+
get activeIndex() {
|
|
1737
|
+
return Math.max(0, this.list.findIndex((t) => t.id === this.id));
|
|
1738
|
+
}
|
|
1739
|
+
active() {
|
|
1740
|
+
return this.list.find((t) => t.id === this.id) ?? this.list[0];
|
|
1741
|
+
}
|
|
1742
|
+
byWebContents(wc) {
|
|
1743
|
+
return this.list.find((t) => t.native.webContents === wc || t.target.webContents === wc);
|
|
1744
|
+
}
|
|
1745
|
+
/** Null at the cap. The new tab is a background tab until `activate`. */
|
|
1746
|
+
add() {
|
|
1747
|
+
if (!canAddTab(this.list.length, this.maxTabs)) return null;
|
|
1748
|
+
const session = this.create();
|
|
1749
|
+
this.list.push(session);
|
|
1750
|
+
session.native.setVisible(false);
|
|
1751
|
+
session.setPainting(false);
|
|
1752
|
+
if (this.slot) session.native.setBounds(this.slot);
|
|
1753
|
+
this.onTabsChanged();
|
|
1754
|
+
return session;
|
|
1755
|
+
}
|
|
1756
|
+
/**
|
|
1757
|
+
* Brings a tab to the front. The ordering here is load-bearing:
|
|
1758
|
+
*
|
|
1759
|
+
* * painting resumes on the incoming session *before* `setSource`, because
|
|
1760
|
+
* `setSource`'s invalidate lands on a stopped webContents otherwise and
|
|
1761
|
+
* the canvas keeps showing the outgoing tab's pixels;
|
|
1762
|
+
* * the bus is re-enabled *after* `setSource`, never before: enabling it
|
|
1763
|
+
* while the outgoing target is still bound invalidates that one, and the
|
|
1764
|
+
* frame it produces paints the tab being left over the tab being
|
|
1765
|
+
* entered;
|
|
1766
|
+
* * the slot rect is applied before the view is shown, so it never appears
|
|
1767
|
+
* at a stale position first;
|
|
1768
|
+
* * the outgoing session is suspended last, so nothing is dark in between.
|
|
1769
|
+
*/
|
|
1770
|
+
activate(id) {
|
|
1771
|
+
const next = this.list.find((t) => t.id === id);
|
|
1772
|
+
if (!next) return;
|
|
1773
|
+
const prev = this.list.find((t) => t.id === this.id);
|
|
1774
|
+
this.id = id;
|
|
1775
|
+
if (prev && prev !== next) prev.native.setVisible(false);
|
|
1776
|
+
next.setPainting(true);
|
|
1777
|
+
this.bus.setSource(next.target);
|
|
1778
|
+
this.bus.setEnabled(this.busEnabled(next));
|
|
1779
|
+
if (this.slot) next.native.setBounds(this.slot);
|
|
1780
|
+
next.native.setVisible(this.nativeVisible(next));
|
|
1781
|
+
if (prev && prev !== next) prev.setPainting(false);
|
|
1782
|
+
this.onTabsChanged();
|
|
1783
|
+
}
|
|
1784
|
+
close(id) {
|
|
1785
|
+
const going = this.list.find((t) => t.id === id);
|
|
1786
|
+
if (!going) return;
|
|
1787
|
+
going.native.setVisible(false);
|
|
1788
|
+
const result = closeTab([...this.list], id, this.id);
|
|
1789
|
+
this.list.length = 0;
|
|
1790
|
+
this.list.push(...result.tabs);
|
|
1791
|
+
if (result.activeId === null) {
|
|
1792
|
+
const fresh = this.create();
|
|
1793
|
+
this.list.push(fresh);
|
|
1794
|
+
this.id = fresh.id;
|
|
1795
|
+
this.bus.setSource(fresh.target);
|
|
1796
|
+
this.bus.setEnabled(this.busEnabled(fresh));
|
|
1797
|
+
if (this.slot) fresh.native.setBounds(this.slot);
|
|
1798
|
+
fresh.native.setVisible(this.nativeVisible(fresh));
|
|
1799
|
+
} else if (result.activeId !== this.id) {
|
|
1800
|
+
this.activate(result.activeId);
|
|
1801
|
+
}
|
|
1802
|
+
going.destroy();
|
|
1803
|
+
this.onTabsChanged();
|
|
1804
|
+
}
|
|
1805
|
+
/** One slot on screen; every view is moved to it, only the active one shows. */
|
|
1806
|
+
setSlotRect(rect) {
|
|
1807
|
+
this.slot = rect;
|
|
1808
|
+
for (const t of this.list) t.native.setBounds(rect);
|
|
1809
|
+
}
|
|
1810
|
+
getSlotRect() {
|
|
1811
|
+
return this.slot;
|
|
1812
|
+
}
|
|
1813
|
+
destroy() {
|
|
1814
|
+
electron.ipcMain.off(IPC.syncScroll, this.route);
|
|
1815
|
+
this.bus.detach();
|
|
1816
|
+
for (const t of this.list) t.destroy();
|
|
1817
|
+
this.list.length = 0;
|
|
1818
|
+
}
|
|
1819
|
+
/** What the strip renders: list identity plus each tab's own state. */
|
|
1820
|
+
snapshot() {
|
|
1821
|
+
return {
|
|
1822
|
+
tabs: this.list.map((t) => ({
|
|
1823
|
+
id: t.id,
|
|
1824
|
+
url: t.url,
|
|
1825
|
+
title: t.title,
|
|
1826
|
+
presetId: t.presetId,
|
|
1827
|
+
profileId: t.profileId
|
|
1828
|
+
})),
|
|
1829
|
+
activeId: this.id
|
|
1830
|
+
};
|
|
1831
|
+
}
|
|
1832
|
+
/**
|
|
1833
|
+
* Builds a session and wires its pane events to the window.
|
|
1834
|
+
*
|
|
1835
|
+
* **Every forward names its tab.** They used to be gated on the session being
|
|
1836
|
+
* the one in front, because the renderer held one URL bar, one load-error
|
|
1837
|
+
* badge and one spinner, and an unnamed report from a background tab would
|
|
1838
|
+
* rewrite the front tab's address. With a strip that shows every tab, the
|
|
1839
|
+
* gate is the defect instead: a background tab could never update its own
|
|
1840
|
+
* entry, so the strip would show whatever it said when it was last in front.
|
|
1841
|
+
* The id restores the isolation the gate was standing in for — the renderer
|
|
1842
|
+
* writes the tab that is named, not the tab that is showing.
|
|
1843
|
+
*/
|
|
1844
|
+
create() {
|
|
1845
|
+
const s = new TabSession(this.win, (url) => {
|
|
1846
|
+
s.url = url;
|
|
1847
|
+
s.title = "";
|
|
1848
|
+
this.toRenderer(IPC.urlChanged, { tabId: s.id, url });
|
|
1849
|
+
this.toRenderer(IPC.titleChanged, { tabId: s.id, title: "" });
|
|
1850
|
+
this.onTabUrlChanged(s);
|
|
1851
|
+
});
|
|
1852
|
+
s.native.webContents.on("page-title-updated", (_e, title) => {
|
|
1853
|
+
s.title = title;
|
|
1854
|
+
this.toRenderer(IPC.titleChanged, { tabId: s.id, title });
|
|
1855
|
+
});
|
|
1856
|
+
s.native.webContents.on("focus", () => {
|
|
1857
|
+
this.toRenderer(IPC.nativeFocused, { tabId: s.id });
|
|
1858
|
+
});
|
|
1859
|
+
s.native.webContents.on("did-navigate", (_e, url) => this.onNativeNavigate(url, s));
|
|
1860
|
+
s.target.on("load-error", (err) => this.toRenderer(IPC.loadError, { tabId: s.id, error: err }));
|
|
1861
|
+
s.target.on("loading", (loading) => this.toRenderer(IPC.targetLoading, { tabId: s.id, loading }));
|
|
1862
|
+
s.target.on("navigating", () => this.toRenderer(IPC.targetNavigating, { tabId: s.id }));
|
|
1863
|
+
return s;
|
|
1864
|
+
}
|
|
1865
|
+
toRenderer(channel, payload) {
|
|
1866
|
+
if (this.win.isDestroyed() || this.win.webContents.isDestroyed()) return;
|
|
1867
|
+
this.win.webContents.send(channel, payload);
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1317
1870
|
function exposeForTests(ctx) {
|
|
1318
|
-
if (process.env.OBSRV_TEST
|
|
1871
|
+
if (process.env.OBSRV_TEST !== "1") return;
|
|
1872
|
+
globalThis.__obsrv = {
|
|
1873
|
+
...ctx,
|
|
1874
|
+
get session() {
|
|
1875
|
+
return ctx.tabs.active();
|
|
1876
|
+
},
|
|
1877
|
+
get native() {
|
|
1878
|
+
return ctx.tabs.active().native;
|
|
1879
|
+
},
|
|
1880
|
+
get target() {
|
|
1881
|
+
return ctx.tabs.active().target;
|
|
1882
|
+
},
|
|
1883
|
+
get sync() {
|
|
1884
|
+
return ctx.tabs.active().sync;
|
|
1885
|
+
}
|
|
1886
|
+
};
|
|
1319
1887
|
}
|
|
1320
1888
|
function createMainWindow() {
|
|
1321
1889
|
const win = new electron.BrowserWindow({
|
|
@@ -1340,42 +1908,14 @@ function createMainWindow() {
|
|
|
1340
1908
|
}
|
|
1341
1909
|
function boot() {
|
|
1342
1910
|
const win = createMainWindow();
|
|
1343
|
-
const
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
},
|
|
1347
|
-
// The renderer reads the file back over `readImageFile`; no bytes here.
|
|
1348
|
-
onImageDrop: (path) => {
|
|
1349
|
-
if (!win.isDestroyed()) win.webContents.send(IPC.openImagePath, path);
|
|
1350
|
-
}
|
|
1351
|
-
});
|
|
1352
|
-
native.webContents.on("focus", () => {
|
|
1353
|
-
if (!win.isDestroyed()) win.webContents.send(IPC.nativeFocused);
|
|
1354
|
-
});
|
|
1355
|
-
const target = new targetSource.TargetSource();
|
|
1356
|
-
target.on("load-error", (err) => {
|
|
1357
|
-
if (!win.isDestroyed()) win.webContents.send(IPC.loadError, err);
|
|
1358
|
-
});
|
|
1359
|
-
target.on("loading", (loading) => {
|
|
1360
|
-
if (!win.isDestroyed()) win.webContents.send(IPC.targetLoading, loading);
|
|
1361
|
-
});
|
|
1362
|
-
target.on("navigating", () => {
|
|
1363
|
-
if (!win.isDestroyed()) win.webContents.send(IPC.targetNavigating);
|
|
1364
|
-
});
|
|
1365
|
-
const bus = attachFrameBus(target, win);
|
|
1366
|
-
const sync = attachSyncBus(native, target, (url) => {
|
|
1367
|
-
if (!win.isDestroyed()) win.webContents.send(IPC.urlChanged, url);
|
|
1368
|
-
});
|
|
1369
|
-
const ctx = { win, native, target, bus, sync, toolbarH: TOOLBAR_H };
|
|
1370
|
-
registerIpc(ctx);
|
|
1911
|
+
const tabs = new TabManager(win);
|
|
1912
|
+
const ctx = { win, tabs, bus: tabs.bus, toolbarH: TOOLBAR_H };
|
|
1913
|
+
const stopIpc = registerIpc(ctx);
|
|
1371
1914
|
installMenu(ctx);
|
|
1372
1915
|
win.on("close", () => {
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
target.destroy();
|
|
1916
|
+
stopIpc();
|
|
1917
|
+
tabs.destroy();
|
|
1376
1918
|
});
|
|
1377
|
-
sync.expect("about:blank");
|
|
1378
|
-
void native.load("about:blank");
|
|
1379
1919
|
exposeForTests(ctx);
|
|
1380
1920
|
}
|
|
1381
1921
|
void electron.app.whenReady().then(boot);
|