getobsrv 0.11.0 → 0.13.0

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