getobsrv 0.10.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 +842 -192
- package/out/main/{targetSource-CbJwfugi.js → targetSource-DlsHc-Ri.js} +50 -1
- package/out/mcp/server.js +21 -1
- package/out/preload/app.js +25 -16
- package/out/renderer/assets/{index-CZUlXhEb.css → index-DvwDBeho.css} +284 -14
- package/out/renderer/assets/{index-CQVE5hF3.js → index-gYDbIgqJ.js} +655 -157
- package/out/renderer/index.html +2 -2
- package/out/shared/control.js +13 -1
- package/out/shared/history.js +90 -0
- package/out/shared/ipcPayloads.js +41 -2
- package/out/shared/presets.js +12 -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,84 +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
|
-
};
|
|
46
|
-
function attachFrameBus(target, win) {
|
|
47
|
-
let ready = false;
|
|
48
|
-
let enabled = true;
|
|
49
|
-
const gone = () => win.isDestroyed() || win.webContents.isDestroyed();
|
|
50
|
-
const onFrame = (msg) => {
|
|
51
|
-
if (!ready || !enabled || gone()) return;
|
|
52
|
-
win.webContents.send(IPC.frame, msg);
|
|
53
|
-
};
|
|
54
|
-
const onSubscribe = (e) => {
|
|
55
|
-
if (gone() || e.sender !== win.webContents) return;
|
|
56
|
-
ready = true;
|
|
57
|
-
if (enabled) target.invalidate();
|
|
58
|
-
};
|
|
59
|
-
const onRendererGone = (details) => {
|
|
60
|
-
if (details.isMainFrame && !details.isSameDocument) ready = false;
|
|
61
|
-
};
|
|
62
|
-
target.on("frame", onFrame);
|
|
63
|
-
electron.ipcMain.on(IPC.frameSubscribe, onSubscribe);
|
|
64
|
-
win.webContents.on("did-start-navigation", onRendererGone);
|
|
65
|
-
return {
|
|
66
|
-
detach() {
|
|
67
|
-
ready = false;
|
|
68
|
-
target.off("frame", onFrame);
|
|
69
|
-
electron.ipcMain.removeListener(IPC.frameSubscribe, onSubscribe);
|
|
70
|
-
if (!gone()) win.webContents.off("did-start-navigation", onRendererGone);
|
|
71
|
-
},
|
|
72
|
-
setEnabled(next) {
|
|
73
|
-
enabled = next;
|
|
74
|
-
if (enabled && ready) target.invalidate();
|
|
75
|
-
}
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
10
|
const MAX_SCROLL_SELECTOR = 512;
|
|
79
11
|
const MAX_RECT = 16384;
|
|
80
12
|
const MAX_SCROLL_WARNINGS = 4;
|
|
81
13
|
const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
|
|
82
|
-
const isRecord$
|
|
14
|
+
const isRecord$3 = (v) => typeof v === "object" && v !== null;
|
|
83
15
|
function parseRect(raw) {
|
|
84
|
-
if (!isRecord$
|
|
16
|
+
if (!isRecord$3(raw)) return null;
|
|
85
17
|
const { x, y, width, height } = raw;
|
|
86
18
|
if (!isFiniteNumber(x) || !isFiniteNumber(y) || !isFiniteNumber(width) || !isFiniteNumber(height)) return null;
|
|
87
19
|
const r = { x: Math.round(x), y: Math.round(y), width: Math.round(width), height: Math.round(height) };
|
|
@@ -103,7 +35,7 @@ function parseModifiers(raw) {
|
|
|
103
35
|
return raw.filter((m) => typeof m === "string" && MODIFIERS.has(m));
|
|
104
36
|
}
|
|
105
37
|
function parseInputEvent(raw) {
|
|
106
|
-
if (!isRecord$
|
|
38
|
+
if (!isRecord$3(raw)) return null;
|
|
107
39
|
const modifiers = parseModifiers(raw.modifiers);
|
|
108
40
|
switch (raw.type) {
|
|
109
41
|
case "mouseDown":
|
|
@@ -136,7 +68,7 @@ function parseDeviceScaleFactor(raw) {
|
|
|
136
68
|
return raw;
|
|
137
69
|
}
|
|
138
70
|
function parseSettings(raw) {
|
|
139
|
-
if (!isRecord$
|
|
71
|
+
if (!isRecord$3(raw)) return null;
|
|
140
72
|
const { hostDiagonalInches, hostNits } = raw;
|
|
141
73
|
if (!isFiniteNumber(hostDiagonalInches) || hostDiagonalInches <= 0) return null;
|
|
142
74
|
if (!isFiniteNumber(hostNits) || hostNits <= 0) return null;
|
|
@@ -146,15 +78,28 @@ function parseSettings(raw) {
|
|
|
146
78
|
if (typeof updateCheck !== "boolean") return null;
|
|
147
79
|
const lastUpdateCheck = raw.lastUpdateCheck ?? 0;
|
|
148
80
|
if (!isFiniteNumber(lastUpdateCheck) || lastUpdateCheck < 0) return null;
|
|
149
|
-
|
|
81
|
+
const recordHistory = raw.recordHistory ?? true;
|
|
82
|
+
if (typeof recordHistory !== "boolean") return null;
|
|
83
|
+
const split = raw.split ?? targetSource.DEFAULT_SETTINGS.split;
|
|
84
|
+
if (!isFiniteNumber(split) || split < targetSource.SPLIT_MIN || split > targetSource.SPLIT_MAX) return null;
|
|
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 };
|
|
150
88
|
}
|
|
151
89
|
function parseMode(raw) {
|
|
152
90
|
return raw === "url" || raw === "image" ? raw : null;
|
|
153
91
|
}
|
|
154
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
|
+
}
|
|
155
98
|
function parseUiState(raw) {
|
|
156
|
-
if (!isRecord$
|
|
99
|
+
if (!isRecord$3(raw)) return null;
|
|
157
100
|
const { presetId, profileId, viewMode, mode } = raw;
|
|
101
|
+
const tabId = parseTabId(raw.tabId);
|
|
102
|
+
if (tabId === null) return null;
|
|
158
103
|
if (typeof presetId !== "string" || presetId.length === 0 || presetId.length > MAX_UI_ID) return null;
|
|
159
104
|
if (typeof profileId !== "string" || profileId.length === 0 || profileId.length > MAX_UI_ID) return null;
|
|
160
105
|
if (viewMode !== "1:1" && viewMode !== "fit") return null;
|
|
@@ -162,6 +107,7 @@ function parseUiState(raw) {
|
|
|
162
107
|
const panes = raw.panes ?? "both";
|
|
163
108
|
if (panes !== "both" && panes !== "target") return null;
|
|
164
109
|
return {
|
|
110
|
+
tabId,
|
|
165
111
|
presetId,
|
|
166
112
|
profileId,
|
|
167
113
|
viewMode,
|
|
@@ -172,7 +118,7 @@ function parseUiState(raw) {
|
|
|
172
118
|
};
|
|
173
119
|
}
|
|
174
120
|
function parseScrollPos(raw) {
|
|
175
|
-
if (!isRecord$
|
|
121
|
+
if (!isRecord$3(raw)) return null;
|
|
176
122
|
const { x, y } = raw;
|
|
177
123
|
if (!isFiniteNumber(x) || !isFiniteNumber(y) || x < 0 || y < 0) return null;
|
|
178
124
|
return { x, y };
|
|
@@ -189,7 +135,7 @@ function parseScrollRequest(raw) {
|
|
|
189
135
|
return { ...pos, selector: trimmed };
|
|
190
136
|
}
|
|
191
137
|
function parseScrollReport(raw) {
|
|
192
|
-
if (!isRecord$
|
|
138
|
+
if (!isRecord$3(raw)) return null;
|
|
193
139
|
const { id, x, y, scroller } = raw;
|
|
194
140
|
if (!isFiniteNumber(id)) return null;
|
|
195
141
|
if (!isFiniteNumber(x) || !isFiniteNumber(y)) return null;
|
|
@@ -222,7 +168,7 @@ const CONTROL_COMMANDS = [
|
|
|
222
168
|
function isControlCommand(v) {
|
|
223
169
|
return typeof v === "string" && CONTROL_COMMANDS.includes(v);
|
|
224
170
|
}
|
|
225
|
-
const isRecord = (v) => typeof v === "object" && v !== null;
|
|
171
|
+
const isRecord$2 = (v) => typeof v === "object" && v !== null;
|
|
226
172
|
function tokenEqual(expected, provided) {
|
|
227
173
|
if (typeof provided !== "string") return false;
|
|
228
174
|
const a = node_crypto.createHash("sha256").update(expected).digest();
|
|
@@ -258,7 +204,7 @@ function pixelExactApplyError(v) {
|
|
|
258
204
|
}
|
|
259
205
|
function parseClick(raw, viewport) {
|
|
260
206
|
const shape = "click payload must be { x, y, button? } with finite CSS-pixel coordinates";
|
|
261
|
-
if (!isRecord(raw)) return shape;
|
|
207
|
+
if (!isRecord$2(raw)) return shape;
|
|
262
208
|
const { x, y } = raw;
|
|
263
209
|
if (typeof x !== "number" || !Number.isFinite(x) || typeof y !== "number" || !Number.isFinite(y)) return shape;
|
|
264
210
|
if (x < 0 || y < 0 || x >= viewport.width || y >= viewport.height) {
|
|
@@ -285,8 +231,118 @@ function parseHighlight(raw) {
|
|
|
285
231
|
durationMs: Math.min(Math.max(Math.round(d), HIGHLIGHT_DURATION_MIN_MS), HIGHLIGHT_DURATION_MAX_MS)
|
|
286
232
|
};
|
|
287
233
|
}
|
|
234
|
+
const HISTORY_MAX = 500;
|
|
235
|
+
const MAX_URL_LENGTH = 2048;
|
|
236
|
+
const isRecord$1 = (v) => typeof v === "object" && v !== null;
|
|
237
|
+
const isCount = (v) => typeof v === "number" && Number.isInteger(v) && v >= 1;
|
|
238
|
+
const isStamp$1 = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
|
|
239
|
+
function isStorableUrl(v) {
|
|
240
|
+
if (typeof v !== "string" || v === "" || v.length > MAX_URL_LENGTH) return false;
|
|
241
|
+
try {
|
|
242
|
+
return targetSource.ALLOWED_URL_SCHEMES.includes(new URL(v).protocol);
|
|
243
|
+
} catch {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function byRank(a, b) {
|
|
248
|
+
if (a.lastVisit !== b.lastVisit) return b.lastVisit - a.lastVisit;
|
|
249
|
+
if (a.visits !== b.visits) return b.visits - a.visits;
|
|
250
|
+
return a.url < b.url ? -1 : a.url > b.url ? 1 : 0;
|
|
251
|
+
}
|
|
252
|
+
function recordVisit(entries, url, now) {
|
|
253
|
+
if (!isStorableUrl(url) || !isStamp$1(now)) return entries;
|
|
254
|
+
const previous = entries.find((e) => e.url === url);
|
|
255
|
+
const next = {
|
|
256
|
+
url,
|
|
257
|
+
visits: previous ? previous.visits + 1 : 1,
|
|
258
|
+
lastVisit: now
|
|
259
|
+
};
|
|
260
|
+
return [next, ...entries.filter((e) => e.url !== url)].sort(byRank).slice(0, HISTORY_MAX);
|
|
261
|
+
}
|
|
262
|
+
function loadHistory(file) {
|
|
263
|
+
try {
|
|
264
|
+
const raw = JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
265
|
+
if (!Array.isArray(raw)) return [];
|
|
266
|
+
const seen = /* @__PURE__ */ new Set();
|
|
267
|
+
const out = [];
|
|
268
|
+
for (const item of raw) {
|
|
269
|
+
if (!isRecord$1(item)) continue;
|
|
270
|
+
if (!isStorableUrl(item.url) || seen.has(item.url)) continue;
|
|
271
|
+
seen.add(item.url);
|
|
272
|
+
out.push({
|
|
273
|
+
url: item.url,
|
|
274
|
+
// These two do fall back: a row whose counter got mangled is still a
|
|
275
|
+
// page that was visited, and losing its rank beats losing the row.
|
|
276
|
+
visits: isCount(item.visits) ? item.visits : 1,
|
|
277
|
+
lastVisit: isStamp$1(item.lastVisit) ? item.lastVisit : 0
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return out.sort(byRank).slice(0, HISTORY_MAX);
|
|
281
|
+
} catch {
|
|
282
|
+
return [];
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function saveHistory(file, entries) {
|
|
286
|
+
if (!Array.isArray(entries)) throw new RangeError("history must be an array");
|
|
287
|
+
if (entries.length > HISTORY_MAX) throw new RangeError(`history must hold at most ${HISTORY_MAX} entries`);
|
|
288
|
+
for (const e of entries) {
|
|
289
|
+
if (!isRecord$1(e) || !isStorableUrl(e.url)) throw new RangeError("history entries need a loadable url");
|
|
290
|
+
if (!isCount(e.visits)) throw new RangeError("visits must be an integer >= 1");
|
|
291
|
+
if (!isStamp$1(e.lastVisit)) throw new RangeError("lastVisit must be a finite epoch ms >= 0");
|
|
292
|
+
}
|
|
293
|
+
node_fs.mkdirSync(node_path.dirname(file), { recursive: true });
|
|
294
|
+
node_fs.writeFileSync(file, JSON.stringify(entries, null, 2));
|
|
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
|
+
};
|
|
288
341
|
const isPositive = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
|
|
289
342
|
const isStamp = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
|
|
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;
|
|
290
346
|
function loadSettings(file) {
|
|
291
347
|
try {
|
|
292
348
|
const raw = JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
@@ -299,7 +355,21 @@ function loadSettings(file) {
|
|
|
299
355
|
// The opposite default: only a literal false turns the update check off,
|
|
300
356
|
// so a file from before this feature keeps it on.
|
|
301
357
|
updateCheck: raw.updateCheck !== false,
|
|
302
|
-
lastUpdateCheck: isStamp(raw.lastUpdateCheck) ? raw.lastUpdateCheck : 0
|
|
358
|
+
lastUpdateCheck: isStamp(raw.lastUpdateCheck) ? raw.lastUpdateCheck : 0,
|
|
359
|
+
// Same default as `updateCheck`, for the same reason: a file written
|
|
360
|
+
// before this feature existed has no key, and history is on by default.
|
|
361
|
+
recordHistory: raw.recordHistory !== false,
|
|
362
|
+
// A ratio outside 0.1–0.9 is treated as absent, not rejected: unlike a
|
|
363
|
+
// nits or diagonal figure it is a layout preference, and a file that
|
|
364
|
+
// was hand-edited to 0.97 means "I want the target tiny", not "refuse
|
|
365
|
+
// to start". Falling back to an even split loses one preference; the
|
|
366
|
+
// panes stay usable, which is the point of the band.
|
|
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)
|
|
303
373
|
};
|
|
304
374
|
} catch {
|
|
305
375
|
return { ...targetSource.DEFAULT_SETTINGS };
|
|
@@ -310,6 +380,42 @@ function saveSettings(file, s) {
|
|
|
310
380
|
if (typeof s.agentControl !== "boolean") throw new RangeError("agentControl must be a boolean");
|
|
311
381
|
if (typeof s.updateCheck !== "boolean") throw new RangeError("updateCheck must be a boolean");
|
|
312
382
|
if (!isStamp(s.lastUpdateCheck)) throw new RangeError("lastUpdateCheck must be a finite epoch ms >= 0");
|
|
383
|
+
if (typeof s.recordHistory !== "boolean") throw new RangeError("recordHistory must be a boolean");
|
|
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
|
+
}
|
|
313
419
|
node_fs.mkdirSync(node_path.dirname(file), { recursive: true });
|
|
314
420
|
node_fs.writeFileSync(file, JSON.stringify(s, null, 2));
|
|
315
421
|
}
|
|
@@ -618,7 +724,7 @@ async function checkForUpdate(current, now) {
|
|
|
618
724
|
request.end();
|
|
619
725
|
});
|
|
620
726
|
}
|
|
621
|
-
const TOOLBAR_H =
|
|
727
|
+
const TOOLBAR_H = 114;
|
|
622
728
|
const MAX_IMAGE_FILE_BYTES = 64 * 1024 * 1024;
|
|
623
729
|
const SCROLL_REPLY_TIMEOUT_MS = 1e3;
|
|
624
730
|
function hostInfo(win) {
|
|
@@ -634,76 +740,91 @@ function hostInfo(win) {
|
|
|
634
740
|
}
|
|
635
741
|
}
|
|
636
742
|
function registerIpc(ctx) {
|
|
637
|
-
const { win,
|
|
743
|
+
const { win, bus, tabs } = ctx;
|
|
744
|
+
const tab = () => tabs.active();
|
|
638
745
|
const settingsFile = node_path.join(electron.app.getPath("userData"), "settings.json");
|
|
639
746
|
let settings = loadSettings(settingsFile);
|
|
747
|
+
tabs.maxTabs = settings.maxTabs;
|
|
640
748
|
const fromRenderer = (e) => e.sender === win.webContents;
|
|
641
749
|
const assertRenderer = (e) => {
|
|
642
750
|
if (!fromRenderer(e)) throw new Error("ipc: unexpected sender");
|
|
643
751
|
};
|
|
644
|
-
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;
|
|
645
764
|
let wanted = url;
|
|
646
765
|
try {
|
|
647
766
|
wanted = targetSource.normalizeUrl(url);
|
|
648
|
-
sync.expect(wanted);
|
|
767
|
+
s.sync.expect(wanted);
|
|
649
768
|
} catch {
|
|
650
769
|
}
|
|
651
|
-
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)]);
|
|
652
771
|
return applied;
|
|
653
772
|
};
|
|
654
|
-
|
|
773
|
+
handle(IPC.navigate, (e, url) => {
|
|
655
774
|
assertRenderer(e);
|
|
656
775
|
return navigateBoth(url);
|
|
657
776
|
});
|
|
658
777
|
const reloadBoth = () => {
|
|
659
|
-
native.reload();
|
|
660
|
-
target.reload();
|
|
778
|
+
tab().native.reload();
|
|
779
|
+
tab().target.reload();
|
|
661
780
|
};
|
|
662
|
-
const goBack = () => native.back();
|
|
663
|
-
const goForward = () => native.forward();
|
|
664
|
-
|
|
781
|
+
const goBack = () => tab().native.back();
|
|
782
|
+
const goForward = () => tab().native.forward();
|
|
783
|
+
on(IPC.reload, (e) => {
|
|
665
784
|
if (!fromRenderer(e)) return;
|
|
666
785
|
reloadBoth();
|
|
667
786
|
});
|
|
668
|
-
|
|
787
|
+
on(IPC.back, (e) => {
|
|
669
788
|
if (!fromRenderer(e)) return;
|
|
670
789
|
goBack();
|
|
671
790
|
});
|
|
672
|
-
|
|
791
|
+
on(IPC.forward, (e) => {
|
|
673
792
|
if (!fromRenderer(e)) return;
|
|
674
793
|
goForward();
|
|
675
794
|
});
|
|
676
|
-
|
|
795
|
+
handle(IPC.setViewport, (e, width, height, rawDsf) => {
|
|
677
796
|
assertRenderer(e);
|
|
678
|
-
viewportArrived = true;
|
|
797
|
+
tab().viewportArrived = true;
|
|
679
798
|
const dsf = parseDeviceScaleFactor(rawDsf);
|
|
680
799
|
if (dsf === null) throw new Error("invalid deviceScaleFactor");
|
|
681
|
-
const v = target.setViewport(width, height, dsf);
|
|
800
|
+
const v = tab().target.setViewport(width, height, dsf);
|
|
682
801
|
return { width: v.width, height: v.height };
|
|
683
802
|
});
|
|
684
|
-
|
|
803
|
+
on(IPC.sendInput, (e, raw) => {
|
|
685
804
|
if (!fromRenderer(e)) return;
|
|
686
805
|
const ev = parseInputEvent(raw);
|
|
687
806
|
if (!ev) return;
|
|
688
807
|
try {
|
|
689
|
-
target.sendInput(ev);
|
|
808
|
+
tab().target.sendInput(ev);
|
|
690
809
|
} catch {
|
|
691
810
|
}
|
|
692
811
|
});
|
|
693
|
-
let modeIsLive = true;
|
|
694
812
|
let panesShowNative = true;
|
|
813
|
+
tabs.nativeVisible = (s) => s.modeIsLive && panesShowNative;
|
|
814
|
+
tabs.busEnabled = (s) => s.modeIsLive;
|
|
695
815
|
const applyNativeVisibility = () => {
|
|
696
|
-
|
|
816
|
+
const s = tab();
|
|
817
|
+
s.native.setVisible(tabs.nativeVisible(s));
|
|
697
818
|
};
|
|
698
|
-
|
|
819
|
+
on(IPC.setMode, (e, raw) => {
|
|
699
820
|
if (!fromRenderer(e)) return;
|
|
700
821
|
const mode = parseMode(raw);
|
|
701
822
|
if (!mode) return;
|
|
702
|
-
modeIsLive = mode === "url";
|
|
823
|
+
tab().modeIsLive = mode === "url";
|
|
703
824
|
applyNativeVisibility();
|
|
704
|
-
bus.setEnabled(modeIsLive);
|
|
825
|
+
bus.setEnabled(tab().modeIsLive);
|
|
705
826
|
});
|
|
706
|
-
|
|
827
|
+
on(IPC.setNativeVisible, (e, raw) => {
|
|
707
828
|
if (!fromRenderer(e)) return;
|
|
708
829
|
if (typeof raw !== "boolean") return;
|
|
709
830
|
panesShowNative = raw;
|
|
@@ -713,7 +834,7 @@ function registerIpc(ctx) {
|
|
|
713
834
|
const fallbackLayout = () => {
|
|
714
835
|
if (rendererDrivesLayout) return;
|
|
715
836
|
const [w = 0, h = 0] = win.getContentSize();
|
|
716
|
-
|
|
837
|
+
tabs.setSlotRect({
|
|
717
838
|
x: 0,
|
|
718
839
|
y: TOOLBAR_H,
|
|
719
840
|
width: Math.floor(w / 2),
|
|
@@ -722,17 +843,17 @@ function registerIpc(ctx) {
|
|
|
722
843
|
};
|
|
723
844
|
fallbackLayout();
|
|
724
845
|
win.on("resize", fallbackLayout);
|
|
725
|
-
|
|
846
|
+
on(IPC.setNativeBounds, (e, raw) => {
|
|
726
847
|
if (!fromRenderer(e)) return;
|
|
727
848
|
const rect = parseRect(raw);
|
|
728
849
|
if (!rect) return;
|
|
729
|
-
|
|
850
|
+
tabs.setSlotRect(rect);
|
|
730
851
|
rendererDrivesLayout = true;
|
|
731
852
|
});
|
|
732
853
|
let scrollSeq = 0;
|
|
733
854
|
const scrollWaiters = /* @__PURE__ */ new Map();
|
|
734
|
-
|
|
735
|
-
if (
|
|
855
|
+
on(IPC.scrollResult, (e, raw) => {
|
|
856
|
+
if (!tabs.byWebContents(e.sender)) return;
|
|
736
857
|
const report = parseScrollReport(raw);
|
|
737
858
|
if (!report) return;
|
|
738
859
|
const waiter = scrollWaiters.get(report.id);
|
|
@@ -741,11 +862,12 @@ function registerIpc(ctx) {
|
|
|
741
862
|
waiter(report);
|
|
742
863
|
});
|
|
743
864
|
const scrollBoth = async (req) => {
|
|
744
|
-
|
|
865
|
+
const s = tab();
|
|
866
|
+
await awaitViewportStable(s);
|
|
745
867
|
const base = { x: req.x, y: req.y };
|
|
746
868
|
if (req.selector !== void 0) base.selector = req.selector;
|
|
747
|
-
if (!native.webContents.isDestroyed()) native.webContents.send(IPC.applyScroll, base);
|
|
748
|
-
const wc = target.webContents;
|
|
869
|
+
if (!s.native.webContents.isDestroyed()) s.native.webContents.send(IPC.applyScroll, base);
|
|
870
|
+
const wc = s.target.webContents;
|
|
749
871
|
if (wc.isDestroyed()) return null;
|
|
750
872
|
const id = ++scrollSeq;
|
|
751
873
|
const answered = new Promise((resolve) => {
|
|
@@ -761,7 +883,7 @@ function registerIpc(ctx) {
|
|
|
761
883
|
wc.send(IPC.applyScroll, { ...base, id });
|
|
762
884
|
return answered;
|
|
763
885
|
};
|
|
764
|
-
|
|
886
|
+
handle(IPC.getHostInfo, (e) => {
|
|
765
887
|
assertRenderer(e);
|
|
766
888
|
return hostInfo(win);
|
|
767
889
|
});
|
|
@@ -779,76 +901,107 @@ function registerIpc(ctx) {
|
|
|
779
901
|
electron.screen.on("display-metrics-changed", pushHostIfChanged);
|
|
780
902
|
electron.screen.on("display-added", pushHostIfChanged);
|
|
781
903
|
electron.screen.on("display-removed", pushHostIfChanged);
|
|
782
|
-
|
|
904
|
+
handle(IPC.getSettings, (e) => {
|
|
783
905
|
assertRenderer(e);
|
|
784
906
|
return settings;
|
|
785
907
|
});
|
|
786
|
-
|
|
908
|
+
handle(IPC.setSettings, (e, raw) => {
|
|
787
909
|
assertRenderer(e);
|
|
788
910
|
const s = parseSettings(raw);
|
|
789
911
|
if (!s) throw new Error("invalid settings");
|
|
790
912
|
saveSettings(settingsFile, s);
|
|
791
913
|
const wasEnabled = settings.agentControl;
|
|
792
914
|
settings = s;
|
|
915
|
+
tabs.maxTabs = s.maxTabs;
|
|
793
916
|
if (s.agentControl !== wasEnabled) applyAgentControl(s.agentControl);
|
|
794
917
|
});
|
|
795
|
-
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
|
+
};
|
|
796
945
|
let targetBounds = null;
|
|
797
946
|
let canvasBounds = null;
|
|
798
947
|
const MAX_PENDING_APPLIES = 32;
|
|
799
948
|
let rendererReported = false;
|
|
800
949
|
let warnedPendingOverflow = false;
|
|
801
950
|
const pendingApplies = [];
|
|
802
|
-
|
|
951
|
+
on(IPC.uiState, (e, raw) => {
|
|
803
952
|
if (!fromRenderer(e)) return;
|
|
804
953
|
const s = parseUiState(raw);
|
|
805
954
|
if (!s) return;
|
|
806
|
-
const { targetBounds: bounds, canvasBounds: canvas, ...state } = s;
|
|
807
|
-
Object.assign(uiState, state);
|
|
808
|
-
targetBounds = bounds ?? null;
|
|
809
|
-
canvasBounds = canvas ?? null;
|
|
810
955
|
if (!rendererReported) {
|
|
811
956
|
rendererReported = true;
|
|
812
957
|
for (const patch of pendingApplies.splice(0)) {
|
|
813
958
|
if (!win.isDestroyed()) win.webContents.send(IPC.agentApply, patch);
|
|
814
959
|
}
|
|
815
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();
|
|
816
967
|
});
|
|
817
|
-
let viewportPending = false;
|
|
818
|
-
let viewportArrived = false;
|
|
819
968
|
const VIEWPORT_ARRIVAL_MS = 600;
|
|
820
|
-
const awaitViewportStable = async () => {
|
|
821
|
-
if (!viewportPending) return;
|
|
822
|
-
viewportPending = false;
|
|
969
|
+
const awaitViewportStable = async (s = tab()) => {
|
|
970
|
+
if (!s.viewportPending) return;
|
|
971
|
+
s.viewportPending = false;
|
|
823
972
|
const arrivalDeadline = Date.now() + VIEWPORT_ARRIVAL_MS;
|
|
824
|
-
while (!viewportArrived && Date.now() < arrivalDeadline) {
|
|
973
|
+
while (!s.viewportArrived && Date.now() < arrivalDeadline) {
|
|
825
974
|
await new Promise((r) => setTimeout(r, SETTLE_POLL_MS));
|
|
826
975
|
}
|
|
827
|
-
await settleTarget();
|
|
976
|
+
await settleTarget(s.target);
|
|
828
977
|
};
|
|
829
978
|
const SETTLE_POLL_MS = 80;
|
|
830
979
|
const SETTLE_STABLE_READS = 2;
|
|
831
980
|
const SETTLE_BUDGET_MS = 4e3;
|
|
832
981
|
const SETTLE_DRAW_MS = 120;
|
|
833
|
-
const nextFrame = (budgetMs) => new Promise((resolve) => {
|
|
982
|
+
const nextFrame = (t, budgetMs) => new Promise((resolve) => {
|
|
983
|
+
if (!t.painting) {
|
|
984
|
+
resolve(false);
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
834
987
|
const timer = setTimeout(() => {
|
|
835
|
-
|
|
988
|
+
t.off("frame", onFrame);
|
|
836
989
|
resolve(false);
|
|
837
990
|
}, budgetMs);
|
|
838
991
|
const onFrame = () => {
|
|
839
992
|
clearTimeout(timer);
|
|
840
|
-
|
|
993
|
+
t.off("frame", onFrame);
|
|
841
994
|
resolve(true);
|
|
842
995
|
};
|
|
843
|
-
|
|
844
|
-
|
|
996
|
+
t.on("frame", onFrame);
|
|
997
|
+
t.invalidate();
|
|
845
998
|
});
|
|
846
|
-
const settleTarget = async () => {
|
|
999
|
+
const settleTarget = async (t = tab().target) => {
|
|
847
1000
|
const deadline = Date.now() + SETTLE_BUDGET_MS;
|
|
848
1001
|
let last = "";
|
|
849
1002
|
let stable = 0;
|
|
850
1003
|
while (Date.now() < deadline) {
|
|
851
|
-
const v =
|
|
1004
|
+
const v = t.getViewport();
|
|
852
1005
|
const key = `${v.width}x${v.height}`;
|
|
853
1006
|
stable = key === last ? stable + 1 : 0;
|
|
854
1007
|
last = key;
|
|
@@ -856,7 +1009,7 @@ function registerIpc(ctx) {
|
|
|
856
1009
|
await new Promise((r) => setTimeout(r, SETTLE_POLL_MS));
|
|
857
1010
|
}
|
|
858
1011
|
if (stable < SETTLE_STABLE_READS) return false;
|
|
859
|
-
const painted = await nextFrame(Math.max(0, deadline - Date.now()));
|
|
1012
|
+
const painted = await nextFrame(t, Math.max(0, deadline - Date.now()));
|
|
860
1013
|
await new Promise((r) => setTimeout(r, SETTLE_DRAW_MS));
|
|
861
1014
|
return painted;
|
|
862
1015
|
};
|
|
@@ -890,20 +1043,128 @@ function registerIpc(ctx) {
|
|
|
890
1043
|
if (!win.isDestroyed()) win.webContents.send(IPC.updateStatus, state);
|
|
891
1044
|
return state;
|
|
892
1045
|
};
|
|
893
|
-
|
|
1046
|
+
handle(IPC.getUpdate, (e) => {
|
|
894
1047
|
assertRenderer(e);
|
|
895
1048
|
return update;
|
|
896
1049
|
});
|
|
897
|
-
|
|
1050
|
+
handle(IPC.checkUpdate, (e) => {
|
|
898
1051
|
assertRenderer(e);
|
|
899
1052
|
return runUpdateCheck();
|
|
900
1053
|
});
|
|
901
|
-
|
|
1054
|
+
handle(IPC.openRelease, async (e) => {
|
|
902
1055
|
assertRenderer(e);
|
|
903
1056
|
if (releaseUrl === "") return false;
|
|
904
1057
|
await electron.shell.openExternal(releaseUrl);
|
|
905
1058
|
return true;
|
|
906
1059
|
});
|
|
1060
|
+
const historyFile = node_path.join(electron.app.getPath("userData"), "history.json");
|
|
1061
|
+
let history = loadHistory(historyFile);
|
|
1062
|
+
const publishHistory = () => {
|
|
1063
|
+
if (!win.isDestroyed()) win.webContents.send(IPC.historyChanged, history);
|
|
1064
|
+
};
|
|
1065
|
+
const commitHistory = (next) => {
|
|
1066
|
+
try {
|
|
1067
|
+
saveHistory(historyFile, next);
|
|
1068
|
+
} catch (e) {
|
|
1069
|
+
console.warn("obsrv: could not write history.json", e);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
history = next;
|
|
1073
|
+
publishHistory();
|
|
1074
|
+
};
|
|
1075
|
+
tabs.onNativeNavigate = (url) => {
|
|
1076
|
+
if (!settings.recordHistory) return;
|
|
1077
|
+
const next = recordVisit(history, url, Date.now());
|
|
1078
|
+
if (next === history) return;
|
|
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);
|
|
1111
|
+
});
|
|
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) => {
|
|
1161
|
+
assertRenderer(e);
|
|
1162
|
+
return history;
|
|
1163
|
+
});
|
|
1164
|
+
handle(IPC.clearHistory, (e) => {
|
|
1165
|
+
assertRenderer(e);
|
|
1166
|
+
commitHistory([]);
|
|
1167
|
+
});
|
|
907
1168
|
const bootCheckAllowed = process.env.OBSRV_TEST !== "1" || process.env.OBSRV_RELEASES_API !== void 0;
|
|
908
1169
|
if (bootCheckAllowed && settings.updateCheck && isCheckDue(settings.lastUpdateCheck, Date.now())) {
|
|
909
1170
|
void runUpdateCheck();
|
|
@@ -912,17 +1173,17 @@ function registerIpc(ctx) {
|
|
|
912
1173
|
status: () => {
|
|
913
1174
|
let url = "";
|
|
914
1175
|
try {
|
|
915
|
-
url = target.webContents.getURL();
|
|
1176
|
+
url = tab().target.webContents.getURL();
|
|
916
1177
|
} catch {
|
|
917
1178
|
}
|
|
918
|
-
return { version: appVersion, url, ...uiState };
|
|
1179
|
+
return { version: appVersion, url, tabId: tabs.activeId, tabIndex: tabs.activeIndex, ...uiState };
|
|
919
1180
|
},
|
|
920
1181
|
navigate: navigateBoth,
|
|
921
1182
|
apply: (patch) => {
|
|
922
1183
|
if (win.isDestroyed()) return;
|
|
923
1184
|
if (patch.presetId !== void 0) {
|
|
924
|
-
viewportPending = true;
|
|
925
|
-
viewportArrived = false;
|
|
1185
|
+
tab().viewportPending = true;
|
|
1186
|
+
tab().viewportArrived = false;
|
|
926
1187
|
}
|
|
927
1188
|
if (!rendererReported) {
|
|
928
1189
|
if (pendingApplies.length >= MAX_PENDING_APPLIES) {
|
|
@@ -962,7 +1223,7 @@ function registerIpc(ctx) {
|
|
|
962
1223
|
warnings
|
|
963
1224
|
};
|
|
964
1225
|
},
|
|
965
|
-
viewport: () => target.getViewport(),
|
|
1226
|
+
viewport: () => tab().target.getViewport(),
|
|
966
1227
|
// An agent scroll drives both panes over the same `applyScroll` channel
|
|
967
1228
|
// the pane-sync mirror uses — each pane's sync preload applies it and
|
|
968
1229
|
// suppresses its own echo, so the two arrive together with no loop.
|
|
@@ -974,8 +1235,8 @@ function registerIpc(ctx) {
|
|
|
974
1235
|
const up = parseInputEvent({ type: "mouseUp", x: c.x, y: c.y, button: c.button, clickCount: 1 });
|
|
975
1236
|
if (!down || !up) return;
|
|
976
1237
|
try {
|
|
977
|
-
target.sendInput(down);
|
|
978
|
-
target.sendInput(up);
|
|
1238
|
+
tab().target.sendInput(down);
|
|
1239
|
+
tab().target.sendInput(up);
|
|
979
1240
|
} catch {
|
|
980
1241
|
}
|
|
981
1242
|
},
|
|
@@ -1003,7 +1264,7 @@ function registerIpc(ctx) {
|
|
|
1003
1264
|
if (process.env.OBSRV_AGENT_CONTROL === "1") settings = { ...settings, agentControl: true };
|
|
1004
1265
|
if (settings.agentControl) applyAgentControl(true);
|
|
1005
1266
|
electron.app.on("will-quit", () => control.stop());
|
|
1006
|
-
|
|
1267
|
+
handle(IPC.readImageFile, async (e, raw) => {
|
|
1007
1268
|
assertRenderer(e);
|
|
1008
1269
|
if (typeof raw !== "string" || !targetSource.IMAGE_EXTENSIONS.test(raw)) throw new Error("Unsupported file type");
|
|
1009
1270
|
const { size } = await promises.stat(raw);
|
|
@@ -1012,23 +1273,59 @@ function registerIpc(ctx) {
|
|
|
1012
1273
|
}
|
|
1013
1274
|
return promises.readFile(raw);
|
|
1014
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
|
+
};
|
|
1015
1283
|
}
|
|
1016
|
-
function installMenu({ win,
|
|
1284
|
+
function installMenu({ win, tabs }) {
|
|
1017
1285
|
const send = (channel) => {
|
|
1018
1286
|
if (!win.isDestroyed()) win.webContents.send(channel);
|
|
1019
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
|
+
];
|
|
1020
1312
|
const template = [
|
|
1021
1313
|
{ role: "appMenu" },
|
|
1022
1314
|
{
|
|
1023
1315
|
label: "File",
|
|
1024
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" },
|
|
1025
1320
|
{
|
|
1026
1321
|
label: "Open Image…",
|
|
1027
1322
|
accelerator: "CmdOrCtrl+O",
|
|
1028
1323
|
click: () => send(IPC.openImage)
|
|
1029
1324
|
},
|
|
1030
1325
|
{ type: "separator" },
|
|
1031
|
-
|
|
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" }
|
|
1032
1329
|
]
|
|
1033
1330
|
},
|
|
1034
1331
|
{ role: "editMenu" },
|
|
@@ -1039,6 +1336,7 @@ function installMenu({ win, native, target }) {
|
|
|
1039
1336
|
label: "Reload",
|
|
1040
1337
|
accelerator: "CmdOrCtrl+R",
|
|
1041
1338
|
click: () => {
|
|
1339
|
+
const { native, target } = tabs.active();
|
|
1042
1340
|
native.reload();
|
|
1043
1341
|
target.reload();
|
|
1044
1342
|
}
|
|
@@ -1060,10 +1358,71 @@ function installMenu({ win, native, target }) {
|
|
|
1060
1358
|
{ role: "togglefullscreen" }
|
|
1061
1359
|
]
|
|
1062
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 },
|
|
1063
1364
|
{ role: "windowMenu" }
|
|
1064
1365
|
];
|
|
1065
1366
|
electron.Menu.setApplicationMenu(electron.Menu.buildFromTemplate(template));
|
|
1066
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
|
+
}
|
|
1067
1426
|
const ERR_ABORTED = -3;
|
|
1068
1427
|
class NativePane {
|
|
1069
1428
|
constructor(win, events) {
|
|
@@ -1181,21 +1540,27 @@ function attachSyncBus(native, target, onUrlChanged) {
|
|
|
1181
1540
|
native.webContents.on("did-navigate", onNativeNav);
|
|
1182
1541
|
native.webContents.on("did-navigate-in-page", onNativeNavInPage);
|
|
1183
1542
|
target.on("url-changed", onTargetNav);
|
|
1184
|
-
const onScroll = (e, raw) => {
|
|
1185
|
-
const other = e.sender === native.webContents ? target.webContents : e.sender === target.webContents ? native.webContents : null;
|
|
1186
|
-
if (!other || other.isDestroyed()) return;
|
|
1187
|
-
const pos = parseScrollPos(raw);
|
|
1188
|
-
if (!pos) return;
|
|
1189
|
-
other.send(IPC.applyScroll, pos);
|
|
1190
|
-
};
|
|
1191
|
-
electron.ipcMain.on(IPC.syncScroll, onScroll);
|
|
1192
1543
|
return {
|
|
1193
1544
|
expect(url) {
|
|
1194
1545
|
pending.native = url;
|
|
1195
1546
|
pending.target = url;
|
|
1196
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
|
+
},
|
|
1197
1563
|
detach() {
|
|
1198
|
-
electron.ipcMain.off(IPC.syncScroll, onScroll);
|
|
1199
1564
|
target.off("url-changed", onTargetNav);
|
|
1200
1565
|
if (!native.webContents.isDestroyed()) {
|
|
1201
1566
|
native.webContents.off("did-navigate", onNativeNav);
|
|
@@ -1204,8 +1569,321 @@ function attachSyncBus(native, target, onUrlChanged) {
|
|
|
1204
1569
|
}
|
|
1205
1570
|
};
|
|
1206
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
|
+
}
|
|
1207
1870
|
function exposeForTests(ctx) {
|
|
1208
|
-
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
|
+
};
|
|
1209
1887
|
}
|
|
1210
1888
|
function createMainWindow() {
|
|
1211
1889
|
const win = new electron.BrowserWindow({
|
|
@@ -1230,42 +1908,14 @@ function createMainWindow() {
|
|
|
1230
1908
|
}
|
|
1231
1909
|
function boot() {
|
|
1232
1910
|
const win = createMainWindow();
|
|
1233
|
-
const
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
},
|
|
1237
|
-
// The renderer reads the file back over `readImageFile`; no bytes here.
|
|
1238
|
-
onImageDrop: (path) => {
|
|
1239
|
-
if (!win.isDestroyed()) win.webContents.send(IPC.openImagePath, path);
|
|
1240
|
-
}
|
|
1241
|
-
});
|
|
1242
|
-
native.webContents.on("focus", () => {
|
|
1243
|
-
if (!win.isDestroyed()) win.webContents.send(IPC.nativeFocused);
|
|
1244
|
-
});
|
|
1245
|
-
const target = new targetSource.TargetSource();
|
|
1246
|
-
target.on("load-error", (err) => {
|
|
1247
|
-
if (!win.isDestroyed()) win.webContents.send(IPC.loadError, err);
|
|
1248
|
-
});
|
|
1249
|
-
target.on("loading", (loading) => {
|
|
1250
|
-
if (!win.isDestroyed()) win.webContents.send(IPC.targetLoading, loading);
|
|
1251
|
-
});
|
|
1252
|
-
target.on("navigating", () => {
|
|
1253
|
-
if (!win.isDestroyed()) win.webContents.send(IPC.targetNavigating);
|
|
1254
|
-
});
|
|
1255
|
-
const bus = attachFrameBus(target, win);
|
|
1256
|
-
const sync = attachSyncBus(native, target, (url) => {
|
|
1257
|
-
if (!win.isDestroyed()) win.webContents.send(IPC.urlChanged, url);
|
|
1258
|
-
});
|
|
1259
|
-
const ctx = { win, native, target, bus, sync, toolbarH: TOOLBAR_H };
|
|
1260
|
-
registerIpc(ctx);
|
|
1911
|
+
const tabs = new TabManager(win);
|
|
1912
|
+
const ctx = { win, tabs, bus: tabs.bus, toolbarH: TOOLBAR_H };
|
|
1913
|
+
const stopIpc = registerIpc(ctx);
|
|
1261
1914
|
installMenu(ctx);
|
|
1262
1915
|
win.on("close", () => {
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
target.destroy();
|
|
1916
|
+
stopIpc();
|
|
1917
|
+
tabs.destroy();
|
|
1266
1918
|
});
|
|
1267
|
-
sync.expect("about:blank");
|
|
1268
|
-
void native.load("about:blank");
|
|
1269
1919
|
exposeForTests(ctx);
|
|
1270
1920
|
}
|
|
1271
1921
|
void electron.app.whenReady().then(boot);
|