getobsrv 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:" />
6
6
  <title>Obsrv</title>
7
- <script type="module" crossorigin src="./assets/index-CQVE5hF3.js"></script>
8
- <link rel="stylesheet" crossorigin href="./assets/index-CZUlXhEb.css">
7
+ <script type="module" crossorigin src="./assets/index-gYDbIgqJ.js"></script>
8
+ <link rel="stylesheet" crossorigin href="./assets/index-DvwDBeho.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
@@ -233,5 +233,17 @@ function parseControlStatus(raw) {
233
233
  const panes = raw.panes ?? 'both';
234
234
  if (panes !== 'both' && panes !== 'target')
235
235
  return null;
236
- return { version, url, presetId, profileId, viewMode, panes, mode };
236
+ // The same skew, one release later. An app that predates tabs has exactly
237
+ // one session — it is the tab at index 0, and it has no id to name — so the
238
+ // defaults describe that app truthfully rather than papering over it. An
239
+ // agent polling one sees an unchanging `tabId`, which is correct: there is
240
+ // no other tab for the user to switch to. Returning null instead would take
241
+ // out drive and live snap wholesale against every app older than tabs.
242
+ const tabId = raw.tabId ?? '';
243
+ if (typeof tabId !== 'string')
244
+ return null;
245
+ const tabIndex = raw.tabIndex ?? 0;
246
+ if (typeof tabIndex !== 'number' || !Number.isInteger(tabIndex) || tabIndex < 0)
247
+ return null;
248
+ return { version, url, presetId, profileId, viewMode, panes, mode, tabId, tabIndex };
237
249
  }
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isStamp = exports.isCount = exports.isRecord = exports.MAX_URL_LENGTH = exports.HISTORY_SUGGESTIONS = exports.HISTORY_MAX = void 0;
4
+ exports.isStorableUrl = isStorableUrl;
5
+ exports.byRank = byRank;
6
+ exports.recordVisit = recordVisit;
7
+ exports.matchHistory = matchHistory;
8
+ const url_1 = require("./url");
9
+ /**
10
+ * The file is read and written whole, so it needs a bound. 500 is far beyond
11
+ * what this app's usage produces — a handful of dev and staging addresses
12
+ * returned to over and over — and small enough to parse instantly.
13
+ */
14
+ exports.HISTORY_MAX = 500;
15
+ /** Rows the URL bar offers. Past six, a longer list is a worse tool than a better query. */
16
+ exports.HISTORY_SUGGESTIONS = 6;
17
+ /**
18
+ * The other half of the bound: 500 entries of unbounded length is not a
19
+ * bounded file. Far longer than any address this tool is pointed at, and
20
+ * short enough that the whole file stays trivial to parse.
21
+ */
22
+ exports.MAX_URL_LENGTH = 2048;
23
+ const isRecord = (v) => typeof v === 'object' && v !== null;
24
+ exports.isRecord = isRecord;
25
+ const isCount = (v) => typeof v === 'number' && Number.isInteger(v) && v >= 1;
26
+ exports.isCount = isCount;
27
+ const isStamp = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0;
28
+ exports.isStamp = isStamp;
29
+ /**
30
+ * Is this an address worth remembering? Only the schemes the app can load
31
+ * back (`shared/url.ts`), so `about:blank` — which the native pane commits on
32
+ * every launch — never becomes a suggestion, and a scheme the URL bar would
33
+ * refuse can never be offered by it.
34
+ */
35
+ function isStorableUrl(v) {
36
+ if (typeof v !== 'string' || v === '' || v.length > exports.MAX_URL_LENGTH)
37
+ return false;
38
+ try {
39
+ return url_1.ALLOWED_URL_SCHEMES.includes(new URL(v).protocol);
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ }
45
+ /**
46
+ * Most recent first, visit count breaking ties — so the address used
47
+ * constantly stays at the top without a stale favourite outranking what was
48
+ * on screen ten minutes ago. URL breaks the remaining tie only to keep the
49
+ * order stable across reloads of the same file.
50
+ */
51
+ function byRank(a, b) {
52
+ if (a.lastVisit !== b.lastVisit)
53
+ return b.lastVisit - a.lastVisit;
54
+ if (a.visits !== b.visits)
55
+ return b.visits - a.visits;
56
+ return a.url < b.url ? -1 : a.url > b.url ? 1 : 0;
57
+ }
58
+ /**
59
+ * The list after a committed navigation to `url`. Returns `entries`
60
+ * *unchanged* — the same reference — when the URL is not one we store, which
61
+ * is what lets the caller write the file only when something actually
62
+ * changed.
63
+ *
64
+ * Eviction is by rank, so the least recently visited entry is the one that
65
+ * goes; a busy day cannot push out the address visited an hour ago in favour
66
+ * of one visited last year.
67
+ */
68
+ function recordVisit(entries, url, now) {
69
+ if (!isStorableUrl(url) || !(0, exports.isStamp)(now))
70
+ return entries;
71
+ const previous = entries.find(e => e.url === url);
72
+ const next = {
73
+ url,
74
+ visits: previous ? previous.visits + 1 : 1,
75
+ lastVisit: now,
76
+ };
77
+ return [next, ...entries.filter(e => e.url !== url)].sort(byRank).slice(0, exports.HISTORY_MAX);
78
+ }
79
+ /**
80
+ * The rows to offer for what has been typed: case-insensitive substring
81
+ * against the whole URL, in rank order. An empty query matches everything,
82
+ * so pressing Down in an empty field offers the most recent addresses.
83
+ */
84
+ function matchHistory(entries, query, limit = exports.HISTORY_SUGGESTIONS) {
85
+ const needle = query.trim().toLowerCase();
86
+ return entries
87
+ .filter(e => e.url.toLowerCase().includes(needle))
88
+ .sort(byRank)
89
+ .slice(0, Math.max(0, limit));
90
+ }
@@ -6,10 +6,12 @@ exports.parseInputEvent = parseInputEvent;
6
6
  exports.parseDeviceScaleFactor = parseDeviceScaleFactor;
7
7
  exports.parseSettings = parseSettings;
8
8
  exports.parseMode = parseMode;
9
+ exports.parseTabId = parseTabId;
9
10
  exports.parseUiState = parseUiState;
10
11
  exports.parseScrollPos = parseScrollPos;
11
12
  exports.parseScrollRequest = parseScrollRequest;
12
13
  exports.parseScrollReport = parseScrollReport;
14
+ const presets_1 = require("./presets");
13
15
  const types_1 = require("./types");
14
16
  /**
15
17
  * Parsers for everything the renderer sends main over IPC. Each returns a
@@ -99,7 +101,7 @@ function parseDeviceScaleFactor(raw) {
99
101
  return raw;
100
102
  }
101
103
  /**
102
- * Copies exactly the five known keys; the numbers must be finite and
104
+ * Copies exactly the known keys; the numbers must be finite and
103
105
  * positive. A missing `agentControl` means false and a missing `updateCheck`
104
106
  * means true (the pre-feature wire shapes); any non-boolean value is refused,
105
107
  * never coerced.
@@ -121,13 +123,43 @@ function parseSettings(raw) {
121
123
  const lastUpdateCheck = raw.lastUpdateCheck ?? 0;
122
124
  if (!isFiniteNumber(lastUpdateCheck) || lastUpdateCheck < 0)
123
125
  return null;
124
- return { hostDiagonalInches, hostNits, agentControl, updateCheck, lastUpdateCheck };
126
+ const recordHistory = raw.recordHistory ?? true;
127
+ if (typeof recordHistory !== 'boolean')
128
+ return null;
129
+ // A missing `split` is the pre-feature wire shape and means the default.
130
+ // Out of band it is refused rather than clamped: `loadSettings` forgives a
131
+ // hand-edited file because it must, but the renderer clamps before it
132
+ // sends, so a bad ratio arriving here is a bug worth surfacing.
133
+ const split = raw.split ?? presets_1.DEFAULT_SETTINGS.split;
134
+ if (!isFiniteNumber(split) || split < presets_1.SPLIT_MIN || split > presets_1.SPLIT_MAX)
135
+ return null;
136
+ // Same shape for the tab cap: absent is the pre-tabs wire shape and means
137
+ // the default, and an out-of-band or fractional count is refused rather
138
+ // than clamped — the Settings input is a bounded integer field, so one
139
+ // arriving here is a bug rather than a hand-edited file.
140
+ const maxTabs = raw.maxTabs ?? presets_1.DEFAULT_SETTINGS.maxTabs;
141
+ if (!isFiniteNumber(maxTabs) || !Number.isInteger(maxTabs) || maxTabs < presets_1.MAX_TABS_MIN || maxTabs > presets_1.MAX_TABS_MAX)
142
+ return null;
143
+ return { hostDiagonalInches, hostNits, agentControl, updateCheck, lastUpdateCheck, recordHistory, split, maxTabs };
125
144
  }
126
145
  function parseMode(raw) {
127
146
  return raw === 'url' || raw === 'image' ? raw : null;
128
147
  }
129
148
  /** Longest preset/profile id the UI-state mirror will store. */
130
149
  const MAX_UI_ID = 64;
150
+ /**
151
+ * Longest tab id main will act on. Ids are minted main-side (`tab-N`), so a
152
+ * renderer message naming one is only ever echoing what it was told; the bound
153
+ * is there because the renderer is still the one sending it. An id no session
154
+ * carries resolves to nothing in the manager and the command is dropped, so
155
+ * shape is all this has to check.
156
+ */
157
+ const MAX_TAB_ID = 64;
158
+ function parseTabId(raw) {
159
+ if (typeof raw !== 'string' || raw.length === 0 || raw.length > MAX_TAB_ID)
160
+ return null;
161
+ return raw;
162
+ }
131
163
  /**
132
164
  * The renderer's UI-state report (`IPC.uiState`), mirrored main-side so the
133
165
  * agent-control server can answer `status` without a renderer round-trip.
@@ -145,6 +177,12 @@ function parseUiState(raw) {
145
177
  if (!isRecord(raw))
146
178
  return null;
147
179
  const { presetId, profileId, viewMode, mode } = raw;
180
+ // Required, unlike `panes` below: there is no sane default for "which tab
181
+ // this describes", and guessing would reintroduce exactly the misattribution
182
+ // the field exists to stop.
183
+ const tabId = parseTabId(raw.tabId);
184
+ if (tabId === null)
185
+ return null;
148
186
  if (typeof presetId !== 'string' || presetId.length === 0 || presetId.length > MAX_UI_ID)
149
187
  return null;
150
188
  if (typeof profileId !== 'string' || profileId.length === 0 || profileId.length > MAX_UI_ID)
@@ -160,6 +198,7 @@ function parseUiState(raw) {
160
198
  if (panes !== 'both' && panes !== 'target')
161
199
  return null;
162
200
  return {
201
+ tabId,
163
202
  presetId,
164
203
  profileId,
165
204
  viewMode,
@@ -1,15 +1,26 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.PANEL_PROFILES = exports.SCREEN_PRESETS = exports.DEFAULT_SETTINGS = exports.MAX_VIEWPORT = void 0;
3
+ exports.PANEL_PROFILES = exports.SCREEN_PRESETS = exports.DEFAULT_SETTINGS = exports.MAX_TABS_MAX = exports.MAX_TABS_MIN = exports.SPLIT_MAX = exports.SPLIT_MIN = exports.MAX_VIEWPORT = void 0;
4
4
  exports.findPreset = findPreset;
5
5
  exports.findProfile = findProfile;
6
6
  exports.MAX_VIEWPORT = 4096;
7
+ /** The loose sanity band for `Settings.split`; the clamp that matters is the
8
+ * drag's 240px-a-side floor, which only the renderer knows the pixels for. */
9
+ exports.SPLIT_MIN = 0.1;
10
+ exports.SPLIT_MAX = 0.9;
11
+ /** The band `Settings.maxTabs` is held to. Below two there is nothing to tab
12
+ * between; above thirty-two the process count is a problem on any machine. */
13
+ exports.MAX_TABS_MIN = 2;
14
+ exports.MAX_TABS_MAX = 32;
7
15
  exports.DEFAULT_SETTINGS = {
8
16
  hostDiagonalInches: 27,
9
17
  hostNits: 500,
10
18
  agentControl: false,
11
19
  updateCheck: true,
12
20
  lastUpdateCheck: 0,
21
+ recordHistory: true,
22
+ split: 0.5,
23
+ maxTabs: 12,
13
24
  };
14
25
  exports.SCREEN_PRESETS = [
15
26
  // Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.closeTab = closeTab;
4
+ exports.canAddTab = canAddTab;
5
+ exports.tabTitle = tabTitle;
6
+ /**
7
+ * Removes a tab and says which one should take focus. Closing the active tab
8
+ * moves right, because the tab that took its screen position is the one the
9
+ * eye is already on; at the end of the strip there is nothing to the right, so
10
+ * it moves left.
11
+ */
12
+ function closeTab(tabs, closeId, activeId) {
13
+ const index = tabs.findIndex(t => t.id === closeId);
14
+ if (index === -1)
15
+ return { tabs, activeId };
16
+ const next = tabs.filter(t => t.id !== closeId);
17
+ if (next.length === 0)
18
+ return { tabs: next, activeId: null };
19
+ if (closeId !== activeId)
20
+ return { tabs: next, activeId };
21
+ const neighbour = next[Math.min(index, next.length - 1)];
22
+ return { tabs: next, activeId: neighbour.id };
23
+ }
24
+ function canAddTab(count, max) {
25
+ return count < max;
26
+ }
27
+ /**
28
+ * What the strip shows. The port is kept deliberately: two local dev servers
29
+ * differ only by it, and a host-only label would render them identically.
30
+ */
31
+ function tabTitle(url, pageTitle) {
32
+ if (pageTitle.trim() !== '')
33
+ return pageTitle;
34
+ // `about:blank` is where every session starts and what an unused tab still
35
+ // holds, so it is the empty tab spelled in Chromium's words rather than an
36
+ // address anyone typed. It has no host, and falling through to the raw
37
+ // string labels a fresh tab `about:blank` — which the screenshot caught.
38
+ if (url.trim() === '' || url.trim() === 'about:blank')
39
+ return 'New tab';
40
+ try {
41
+ return new URL(url).host || url;
42
+ }
43
+ catch {
44
+ return url;
45
+ }
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getobsrv",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "See your site the way 1x screens see it",
5
5
  "main": "./out/main/index.js",
6
6
  "bin": {
@@ -61,6 +61,13 @@ when the PNG is taken. Reaching for `obsrv_snap` after a scroll works only
61
61
  when the app is already on that exact URL (it answers `navigated: false`);
62
62
  snapping a different URL is a fresh load and lands back at the top.
63
63
 
64
+ The app can hold several sessions open as tabs, and both tools act on the one
65
+ in **front** — resolved per command, so the user can move it under you. Each
66
+ result names the tab (`tabId`, `tabIndex`); if you drive over several calls and
67
+ the state has to hold, check that `tabId` did not change rather than assuming
68
+ it. You cannot name another tab, and you cannot open, close or switch tabs —
69
+ ask the user. An empty `tabId` is an app older than tabs, which has only one.
70
+
64
71
  ## The loop that catches real regressions
65
72
 
66
73
  1. Snap the dev URL across `--matrix laptop-768,android-65,1080p-24`, plus a