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/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-CbJwfugi.js");
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$1 = (v) => typeof v === "object" && v !== null;
14
+ const isRecord$3 = (v) => typeof v === "object" && v !== null;
83
15
  function parseRect(raw) {
84
- if (!isRecord$1(raw)) return null;
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$1(raw)) return null;
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$1(raw)) return null;
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
- return { hostDiagonalInches, hostNits, agentControl, updateCheck, lastUpdateCheck };
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$1(raw)) return null;
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$1(raw)) return null;
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$1(raw)) return null;
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 = 82;
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, native, target, bus, sync } = ctx;
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 navigateBoth = async (url) => {
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
- electron.ipcMain.handle(IPC.navigate, (e, url) => {
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
- electron.ipcMain.on(IPC.reload, (e) => {
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
- electron.ipcMain.on(IPC.back, (e) => {
787
+ on(IPC.back, (e) => {
669
788
  if (!fromRenderer(e)) return;
670
789
  goBack();
671
790
  });
672
- electron.ipcMain.on(IPC.forward, (e) => {
791
+ on(IPC.forward, (e) => {
673
792
  if (!fromRenderer(e)) return;
674
793
  goForward();
675
794
  });
676
- electron.ipcMain.handle(IPC.setViewport, (e, width, height, rawDsf) => {
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
- electron.ipcMain.on(IPC.sendInput, (e, raw) => {
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
- native.setVisible(modeIsLive && panesShowNative);
816
+ const s = tab();
817
+ s.native.setVisible(tabs.nativeVisible(s));
697
818
  };
698
- electron.ipcMain.on(IPC.setMode, (e, raw) => {
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
- electron.ipcMain.on(IPC.setNativeVisible, (e, raw) => {
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
- native.setBounds({
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
- electron.ipcMain.on(IPC.setNativeBounds, (e, raw) => {
846
+ on(IPC.setNativeBounds, (e, raw) => {
726
847
  if (!fromRenderer(e)) return;
727
848
  const rect = parseRect(raw);
728
849
  if (!rect) return;
729
- native.setBounds(rect);
850
+ tabs.setSlotRect(rect);
730
851
  rendererDrivesLayout = true;
731
852
  });
732
853
  let scrollSeq = 0;
733
854
  const scrollWaiters = /* @__PURE__ */ new Map();
734
- electron.ipcMain.on(IPC.scrollResult, (e, raw) => {
735
- if (e.sender !== target.webContents && e.sender !== native.webContents) return;
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
- await awaitViewportStable();
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
- electron.ipcMain.handle(IPC.getHostInfo, (e) => {
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
- electron.ipcMain.handle(IPC.getSettings, (e) => {
904
+ handle(IPC.getSettings, (e) => {
783
905
  assertRenderer(e);
784
906
  return settings;
785
907
  });
786
- electron.ipcMain.handle(IPC.setSettings, (e, raw) => {
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 = { presetId: "1080p-24", profileId: "reference", viewMode: "fit", panes: "both", mode: "url" };
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
- electron.ipcMain.on(IPC.uiState, (e, raw) => {
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
- target.off("frame", onFrame);
988
+ t.off("frame", onFrame);
836
989
  resolve(false);
837
990
  }, budgetMs);
838
991
  const onFrame = () => {
839
992
  clearTimeout(timer);
840
- target.off("frame", onFrame);
993
+ t.off("frame", onFrame);
841
994
  resolve(true);
842
995
  };
843
- target.on("frame", onFrame);
844
- target.invalidate();
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 = target.getViewport();
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
- electron.ipcMain.handle(IPC.getUpdate, (e) => {
1046
+ handle(IPC.getUpdate, (e) => {
894
1047
  assertRenderer(e);
895
1048
  return update;
896
1049
  });
897
- electron.ipcMain.handle(IPC.checkUpdate, (e) => {
1050
+ handle(IPC.checkUpdate, (e) => {
898
1051
  assertRenderer(e);
899
1052
  return runUpdateCheck();
900
1053
  });
901
- electron.ipcMain.handle(IPC.openRelease, async (e) => {
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
- electron.ipcMain.handle(IPC.readImageFile, async (e, raw) => {
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, native, target }) {
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
- { role: "close" }
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 === "1") globalThis.__obsrv = ctx;
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 native = new NativePane(win, {
1234
- onLoadError: (err) => {
1235
- if (!win.isDestroyed()) win.webContents.send(IPC.loadError, err);
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
- sync.detach();
1264
- bus.detach();
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);