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