getobsrv 0.11.0 → 0.12.0

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