getobsrv 0.10.0 → 0.11.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/cli.js CHANGED
@@ -3,7 +3,7 @@ const electron = require("electron");
3
3
  const node_fs = require("node:fs");
4
4
  const node_os = require("node:os");
5
5
  const node_path = require("node:path");
6
- const targetSource = require("./targetSource-CbJwfugi.js");
6
+ const targetSource = require("./targetSource-nbfc-Fe6.js");
7
7
  function boxDownsample(src, factor) {
8
8
  if (!Number.isInteger(factor) || factor < 1) throw new RangeError("factor must be an integer >= 1");
9
9
  const width = Math.floor(src.width / factor);
package/out/main/index.js CHANGED
@@ -4,7 +4,7 @@ const node_fs = require("node:fs");
4
4
  const promises = require("node:fs/promises");
5
5
  const node_path = require("node:path");
6
6
  const node_crypto = require("node:crypto");
7
- const targetSource = require("./targetSource-CbJwfugi.js");
7
+ const targetSource = require("./targetSource-nbfc-Fe6.js");
8
8
  const node_http = require("node:http");
9
9
  const node_url = require("node:url");
10
10
  const IPC = {
@@ -41,7 +41,10 @@ const IPC = {
41
41
  getUpdate: "obsrv:get-update",
42
42
  checkUpdate: "obsrv:check-update",
43
43
  openRelease: "obsrv:open-release",
44
- updateStatus: "obsrv:update-status"
44
+ updateStatus: "obsrv:update-status",
45
+ getHistory: "obsrv:get-history",
46
+ clearHistory: "obsrv:clear-history",
47
+ historyChanged: "obsrv:history-changed"
45
48
  };
46
49
  function attachFrameBus(target, win) {
47
50
  let ready = false;
@@ -79,9 +82,9 @@ const MAX_SCROLL_SELECTOR = 512;
79
82
  const MAX_RECT = 16384;
80
83
  const MAX_SCROLL_WARNINGS = 4;
81
84
  const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
82
- const isRecord$1 = (v) => typeof v === "object" && v !== null;
85
+ const isRecord$2 = (v) => typeof v === "object" && v !== null;
83
86
  function parseRect(raw) {
84
- if (!isRecord$1(raw)) return null;
87
+ if (!isRecord$2(raw)) return null;
85
88
  const { x, y, width, height } = raw;
86
89
  if (!isFiniteNumber(x) || !isFiniteNumber(y) || !isFiniteNumber(width) || !isFiniteNumber(height)) return null;
87
90
  const r = { x: Math.round(x), y: Math.round(y), width: Math.round(width), height: Math.round(height) };
@@ -103,7 +106,7 @@ function parseModifiers(raw) {
103
106
  return raw.filter((m) => typeof m === "string" && MODIFIERS.has(m));
104
107
  }
105
108
  function parseInputEvent(raw) {
106
- if (!isRecord$1(raw)) return null;
109
+ if (!isRecord$2(raw)) return null;
107
110
  const modifiers = parseModifiers(raw.modifiers);
108
111
  switch (raw.type) {
109
112
  case "mouseDown":
@@ -136,7 +139,7 @@ function parseDeviceScaleFactor(raw) {
136
139
  return raw;
137
140
  }
138
141
  function parseSettings(raw) {
139
- if (!isRecord$1(raw)) return null;
142
+ if (!isRecord$2(raw)) return null;
140
143
  const { hostDiagonalInches, hostNits } = raw;
141
144
  if (!isFiniteNumber(hostDiagonalInches) || hostDiagonalInches <= 0) return null;
142
145
  if (!isFiniteNumber(hostNits) || hostNits <= 0) return null;
@@ -146,14 +149,18 @@ function parseSettings(raw) {
146
149
  if (typeof updateCheck !== "boolean") return null;
147
150
  const lastUpdateCheck = raw.lastUpdateCheck ?? 0;
148
151
  if (!isFiniteNumber(lastUpdateCheck) || lastUpdateCheck < 0) return null;
149
- return { hostDiagonalInches, hostNits, agentControl, updateCheck, lastUpdateCheck };
152
+ const recordHistory = raw.recordHistory ?? true;
153
+ if (typeof recordHistory !== "boolean") return null;
154
+ const split = raw.split ?? targetSource.DEFAULT_SETTINGS.split;
155
+ if (!isFiniteNumber(split) || split < targetSource.SPLIT_MIN || split > targetSource.SPLIT_MAX) return null;
156
+ return { hostDiagonalInches, hostNits, agentControl, updateCheck, lastUpdateCheck, recordHistory, split };
150
157
  }
151
158
  function parseMode(raw) {
152
159
  return raw === "url" || raw === "image" ? raw : null;
153
160
  }
154
161
  const MAX_UI_ID = 64;
155
162
  function parseUiState(raw) {
156
- if (!isRecord$1(raw)) return null;
163
+ if (!isRecord$2(raw)) return null;
157
164
  const { presetId, profileId, viewMode, mode } = raw;
158
165
  if (typeof presetId !== "string" || presetId.length === 0 || presetId.length > MAX_UI_ID) return null;
159
166
  if (typeof profileId !== "string" || profileId.length === 0 || profileId.length > MAX_UI_ID) return null;
@@ -172,7 +179,7 @@ function parseUiState(raw) {
172
179
  };
173
180
  }
174
181
  function parseScrollPos(raw) {
175
- if (!isRecord$1(raw)) return null;
182
+ if (!isRecord$2(raw)) return null;
176
183
  const { x, y } = raw;
177
184
  if (!isFiniteNumber(x) || !isFiniteNumber(y) || x < 0 || y < 0) return null;
178
185
  return { x, y };
@@ -189,7 +196,7 @@ function parseScrollRequest(raw) {
189
196
  return { ...pos, selector: trimmed };
190
197
  }
191
198
  function parseScrollReport(raw) {
192
- if (!isRecord$1(raw)) return null;
199
+ if (!isRecord$2(raw)) return null;
193
200
  const { id, x, y, scroller } = raw;
194
201
  if (!isFiniteNumber(id)) return null;
195
202
  if (!isFiniteNumber(x) || !isFiniteNumber(y)) return null;
@@ -222,7 +229,7 @@ const CONTROL_COMMANDS = [
222
229
  function isControlCommand(v) {
223
230
  return typeof v === "string" && CONTROL_COMMANDS.includes(v);
224
231
  }
225
- const isRecord = (v) => typeof v === "object" && v !== null;
232
+ const isRecord$1 = (v) => typeof v === "object" && v !== null;
226
233
  function tokenEqual(expected, provided) {
227
234
  if (typeof provided !== "string") return false;
228
235
  const a = node_crypto.createHash("sha256").update(expected).digest();
@@ -258,7 +265,7 @@ function pixelExactApplyError(v) {
258
265
  }
259
266
  function parseClick(raw, viewport) {
260
267
  const shape = "click payload must be { x, y, button? } with finite CSS-pixel coordinates";
261
- if (!isRecord(raw)) return shape;
268
+ if (!isRecord$1(raw)) return shape;
262
269
  const { x, y } = raw;
263
270
  if (typeof x !== "number" || !Number.isFinite(x) || typeof y !== "number" || !Number.isFinite(y)) return shape;
264
271
  if (x < 0 || y < 0 || x >= viewport.width || y >= viewport.height) {
@@ -285,8 +292,71 @@ function parseHighlight(raw) {
285
292
  durationMs: Math.min(Math.max(Math.round(d), HIGHLIGHT_DURATION_MIN_MS), HIGHLIGHT_DURATION_MAX_MS)
286
293
  };
287
294
  }
295
+ const HISTORY_MAX = 500;
296
+ const MAX_URL_LENGTH = 2048;
297
+ const isRecord = (v) => typeof v === "object" && v !== null;
298
+ const isCount = (v) => typeof v === "number" && Number.isInteger(v) && v >= 1;
299
+ const isStamp$1 = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
300
+ function isStorableUrl(v) {
301
+ if (typeof v !== "string" || v === "" || v.length > MAX_URL_LENGTH) return false;
302
+ try {
303
+ return targetSource.ALLOWED_URL_SCHEMES.includes(new URL(v).protocol);
304
+ } catch {
305
+ return false;
306
+ }
307
+ }
308
+ function byRank(a, b) {
309
+ if (a.lastVisit !== b.lastVisit) return b.lastVisit - a.lastVisit;
310
+ if (a.visits !== b.visits) return b.visits - a.visits;
311
+ return a.url < b.url ? -1 : a.url > b.url ? 1 : 0;
312
+ }
313
+ function recordVisit(entries, url, now) {
314
+ if (!isStorableUrl(url) || !isStamp$1(now)) return entries;
315
+ const previous = entries.find((e) => e.url === url);
316
+ const next = {
317
+ url,
318
+ visits: previous ? previous.visits + 1 : 1,
319
+ lastVisit: now
320
+ };
321
+ return [next, ...entries.filter((e) => e.url !== url)].sort(byRank).slice(0, HISTORY_MAX);
322
+ }
323
+ function loadHistory(file) {
324
+ try {
325
+ const raw = JSON.parse(node_fs.readFileSync(file, "utf8"));
326
+ if (!Array.isArray(raw)) return [];
327
+ const seen = /* @__PURE__ */ new Set();
328
+ const out = [];
329
+ for (const item of raw) {
330
+ if (!isRecord(item)) continue;
331
+ if (!isStorableUrl(item.url) || seen.has(item.url)) continue;
332
+ seen.add(item.url);
333
+ out.push({
334
+ url: item.url,
335
+ // These two do fall back: a row whose counter got mangled is still a
336
+ // page that was visited, and losing its rank beats losing the row.
337
+ visits: isCount(item.visits) ? item.visits : 1,
338
+ lastVisit: isStamp$1(item.lastVisit) ? item.lastVisit : 0
339
+ });
340
+ }
341
+ return out.sort(byRank).slice(0, HISTORY_MAX);
342
+ } catch {
343
+ return [];
344
+ }
345
+ }
346
+ function saveHistory(file, entries) {
347
+ if (!Array.isArray(entries)) throw new RangeError("history must be an array");
348
+ if (entries.length > HISTORY_MAX) throw new RangeError(`history must hold at most ${HISTORY_MAX} entries`);
349
+ for (const e of entries) {
350
+ if (!isRecord(e) || !isStorableUrl(e.url)) throw new RangeError("history entries need a loadable url");
351
+ if (!isCount(e.visits)) throw new RangeError("visits must be an integer >= 1");
352
+ if (!isStamp$1(e.lastVisit)) throw new RangeError("lastVisit must be a finite epoch ms >= 0");
353
+ }
354
+ node_fs.mkdirSync(node_path.dirname(file), { recursive: true });
355
+ node_fs.writeFileSync(file, JSON.stringify(entries, null, 2));
356
+ }
288
357
  const isPositive = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
289
358
  const isStamp = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
359
+ const isRatio = (v) => typeof v === "number" && Number.isFinite(v) && v >= targetSource.SPLIT_MIN && v <= targetSource.SPLIT_MAX;
290
360
  function loadSettings(file) {
291
361
  try {
292
362
  const raw = JSON.parse(node_fs.readFileSync(file, "utf8"));
@@ -299,7 +369,16 @@ function loadSettings(file) {
299
369
  // The opposite default: only a literal false turns the update check off,
300
370
  // so a file from before this feature keeps it on.
301
371
  updateCheck: raw.updateCheck !== false,
302
- lastUpdateCheck: isStamp(raw.lastUpdateCheck) ? raw.lastUpdateCheck : 0
372
+ lastUpdateCheck: isStamp(raw.lastUpdateCheck) ? raw.lastUpdateCheck : 0,
373
+ // Same default as `updateCheck`, for the same reason: a file written
374
+ // before this feature existed has no key, and history is on by default.
375
+ recordHistory: raw.recordHistory !== false,
376
+ // A ratio outside 0.1–0.9 is treated as absent, not rejected: unlike a
377
+ // nits or diagonal figure it is a layout preference, and a file that
378
+ // was hand-edited to 0.97 means "I want the target tiny", not "refuse
379
+ // to start". Falling back to an even split loses one preference; the
380
+ // panes stay usable, which is the point of the band.
381
+ split: isRatio(raw.split) ? raw.split : targetSource.DEFAULT_SETTINGS.split
303
382
  };
304
383
  } catch {
305
384
  return { ...targetSource.DEFAULT_SETTINGS };
@@ -310,6 +389,8 @@ function saveSettings(file, s) {
310
389
  if (typeof s.agentControl !== "boolean") throw new RangeError("agentControl must be a boolean");
311
390
  if (typeof s.updateCheck !== "boolean") throw new RangeError("updateCheck must be a boolean");
312
391
  if (!isStamp(s.lastUpdateCheck)) throw new RangeError("lastUpdateCheck must be a finite epoch ms >= 0");
392
+ if (typeof s.recordHistory !== "boolean") throw new RangeError("recordHistory must be a boolean");
393
+ if (!isRatio(s.split)) throw new RangeError(`split must be a finite ratio in ${targetSource.SPLIT_MIN}..${targetSource.SPLIT_MAX}`);
313
394
  node_fs.mkdirSync(node_path.dirname(file), { recursive: true });
314
395
  node_fs.writeFileSync(file, JSON.stringify(s, null, 2));
315
396
  }
@@ -904,6 +985,35 @@ function registerIpc(ctx) {
904
985
  await electron.shell.openExternal(releaseUrl);
905
986
  return true;
906
987
  });
988
+ const historyFile = node_path.join(electron.app.getPath("userData"), "history.json");
989
+ let history = loadHistory(historyFile);
990
+ const publishHistory = () => {
991
+ if (!win.isDestroyed()) win.webContents.send(IPC.historyChanged, history);
992
+ };
993
+ const commitHistory = (next) => {
994
+ try {
995
+ saveHistory(historyFile, next);
996
+ } catch (e) {
997
+ console.warn("obsrv: could not write history.json", e);
998
+ return;
999
+ }
1000
+ history = next;
1001
+ publishHistory();
1002
+ };
1003
+ native.webContents.on("did-navigate", (_e, url) => {
1004
+ if (!settings.recordHistory) return;
1005
+ const next = recordVisit(history, url, Date.now());
1006
+ if (next === history) return;
1007
+ commitHistory(next);
1008
+ });
1009
+ electron.ipcMain.handle(IPC.getHistory, (e) => {
1010
+ assertRenderer(e);
1011
+ return history;
1012
+ });
1013
+ electron.ipcMain.handle(IPC.clearHistory, (e) => {
1014
+ assertRenderer(e);
1015
+ commitHistory([]);
1016
+ });
907
1017
  const bootCheckAllowed = process.env.OBSRV_TEST !== "1" || process.env.OBSRV_RELEASES_API !== void 0;
908
1018
  if (bootCheckAllowed && settings.updateCheck && isCheckDue(settings.lastUpdateCheck, Date.now())) {
909
1019
  void runUpdateCheck();
@@ -3,12 +3,16 @@ const electron = require("electron");
3
3
  const node_events = require("node:events");
4
4
  const node_path = require("node:path");
5
5
  const MAX_VIEWPORT = 4096;
6
+ const SPLIT_MIN = 0.1;
7
+ const SPLIT_MAX = 0.9;
6
8
  const DEFAULT_SETTINGS = {
7
9
  hostDiagonalInches: 27,
8
10
  hostNits: 500,
9
11
  agentControl: false,
10
12
  updateCheck: true,
11
- lastUpdateCheck: 0
13
+ lastUpdateCheck: 0,
14
+ recordHistory: true,
15
+ split: 0.5
12
16
  };
13
17
  const SCREEN_PRESETS = [
14
18
  // Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
@@ -365,10 +369,13 @@ class TargetSource extends node_events.EventEmitter {
365
369
  if (!this.win.isDestroyed()) this.win.destroy();
366
370
  }
367
371
  }
372
+ exports.ALLOWED_URL_SCHEMES = ALLOWED_URL_SCHEMES;
368
373
  exports.DEFAULT_SETTINGS = DEFAULT_SETTINGS;
369
374
  exports.IMAGE_EXTENSIONS = IMAGE_EXTENSIONS;
370
375
  exports.PANEL_PROFILES = PANEL_PROFILES;
371
376
  exports.SCREEN_PRESETS = SCREEN_PRESETS;
377
+ exports.SPLIT_MAX = SPLIT_MAX;
378
+ exports.SPLIT_MIN = SPLIT_MIN;
372
379
  exports.TargetSource = TargetSource;
373
380
  exports.classifyFileNavigation = classifyFileNavigation;
374
381
  exports.findPreset = findPreset;
@@ -31,7 +31,10 @@ const IPC = {
31
31
  getUpdate: "obsrv:get-update",
32
32
  checkUpdate: "obsrv:check-update",
33
33
  openRelease: "obsrv:open-release",
34
- updateStatus: "obsrv:update-status"
34
+ updateStatus: "obsrv:update-status",
35
+ getHistory: "obsrv:get-history",
36
+ clearHistory: "obsrv:clear-history",
37
+ historyChanged: "obsrv:history-changed"
35
38
  };
36
39
  function subscribe(channel, cb) {
37
40
  const listener = (_e, v) => cb(v);
@@ -112,6 +115,9 @@ const api = {
112
115
  getUpdate: () => electron.ipcRenderer.invoke(IPC.getUpdate),
113
116
  checkUpdate: () => electron.ipcRenderer.invoke(IPC.checkUpdate),
114
117
  openRelease: () => electron.ipcRenderer.invoke(IPC.openRelease),
115
- onUpdateStatus: (cb) => subscribe(IPC.updateStatus, cb)
118
+ onUpdateStatus: (cb) => subscribe(IPC.updateStatus, cb),
119
+ getHistory: () => electron.ipcRenderer.invoke(IPC.getHistory),
120
+ clearHistory: () => electron.ipcRenderer.invoke(IPC.clearHistory),
121
+ onHistoryChanged: (cb) => subscribe(IPC.historyChanged, cb)
116
122
  };
117
123
  electron.contextBridge.exposeInMainWorld("obsrv", api);
@@ -129,7 +129,43 @@
129
129
  :root[data-surround='grey50'] { --surround: #808080; }
130
130
 
131
131
  html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color: var(--text-0); }
132
- .app { height: 100%; display: flex; flex-direction: column; }
132
+ /* `--split` (the native pane's share), `--pane-min` and `--seam` live here
133
+ rather than on `.panes` because the chrome needs them too — see
134
+ `--native-right` below. `App` writes the first two inline; the fallbacks
135
+ keep the layout sane if either is ever missing. */
136
+ .app {
137
+ height: 100%;
138
+ display: flex;
139
+ flex-direction: column;
140
+ --split: 0.5;
141
+ --pane-min: 240px;
142
+ --seam: 1px;
143
+ --drawer-w: 0px;
144
+ }
145
+
146
+ /* The drawer's whole box. Declared here so the panes row's width can be
147
+ derived from the window's without measuring either; `.drawer` sizes itself
148
+ from the same value, so the two cannot drift. */
149
+ .app:has(.drawer) { --drawer-w: 309px; }
150
+
151
+ /* Where the native pane's right edge falls, in window coordinates: the very
152
+ clamp `.panes > .pane:first-child` is laid out with, applied to the width
153
+ the row gets (the window, less whatever a drawer takes). A style rule, not
154
+ a measurement — the split is already a custom property, so nothing here
155
+ observes the layout it is describing.
156
+ Solo target has no native view, so the clamp collapses to zero. */
157
+ .app[data-panes='both'] {
158
+ --native-right: round(
159
+ down,
160
+ clamp(
161
+ var(--pane-min),
162
+ calc((100vw - var(--drawer-w) - var(--seam)) * var(--split)),
163
+ calc(100vw - var(--drawer-w) - var(--seam) - var(--pane-min))
164
+ ),
165
+ 1px
166
+ );
167
+ }
168
+ .app[data-panes='target'] { --native-right: 0px; }
133
169
  .muted { color: var(--text-1); }
134
170
  .warn { color: var(--warn); }
135
171
  .chrome .warn { white-space: nowrap; }
@@ -148,6 +184,10 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
148
184
  border-bottom: 1px solid var(--line);
149
185
  }
150
186
  .chrome-browse { height: 44px; background: var(--chrome-1); }
187
+ /* The containing block the URL-history list falls back to. Its padding box
188
+ starts at the window's left edge (the row's only border is at the bottom),
189
+ which is the frame `--native-right` is expressed in. */
190
+ .chrome-browse { position: relative; }
151
191
  .chrome-screen { height: 38px; background: var(--chrome-0); }
152
192
  .chrome-spacer { flex: 1 1 auto; }
153
193
 
@@ -168,7 +208,12 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
168
208
  cursor: pointer;
169
209
  }
170
210
 
171
- .url-form { flex: 1 1 auto; min-width: 160px; }
211
+ /* Static in Both, so the history list below resolves against `.chrome-browse`
212
+ and can be placed in window coordinates. In solo target there is no native
213
+ view to dodge and the list belongs under the field, so the form becomes the
214
+ containing block instead. */
215
+ .url-form { position: static; flex: 1 1 auto; min-width: 160px; }
216
+ .app[data-panes='target'] .url-form { position: relative; }
172
217
  .url-form input {
173
218
  width: 100%;
174
219
  height: 30px;
@@ -182,6 +227,61 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
182
227
  }
183
228
  .url-form input[readonly] { color: var(--text-1); }
184
229
 
230
+ /* Visited-URL type-ahead. Its left edge is clamped to the native pane's right
231
+ edge because that pane is an OS-level WebContentsView painting over
232
+ anything the renderer draws beneath it: a list anchored to the field, which
233
+ starts near x=120, would be entirely invisible. The cost is cosmetic — with
234
+ a wide native pane the list sits offset from the field it belongs to — and
235
+ it is the same constraint that pushed the overflow menu rightward.
236
+ `right` bounds it against the window so a very wide split cannot push it
237
+ off the edge; `max-width` keeps it from spanning a whole empty pane. */
238
+ .url-history {
239
+ position: absolute;
240
+ top: calc(100% + 4px);
241
+ left: var(--native-right, 0px);
242
+ right: 10px;
243
+ max-width: 560px;
244
+ z-index: 10;
245
+ margin: 0;
246
+ padding: 4px 0;
247
+ list-style: none;
248
+ font-size: 11.5px;
249
+ background: var(--chrome-2);
250
+ border: 1px solid var(--line);
251
+ border-radius: 6px;
252
+ }
253
+ /* Solo target: the list is the field's own dropdown, so it tracks its width. */
254
+ .app[data-panes='target'] .url-history { left: 0; right: 0; max-width: none; }
255
+ .url-history-row {
256
+ display: flex;
257
+ align-items: baseline;
258
+ gap: 10px;
259
+ height: 26px;
260
+ padding: 0 11px;
261
+ cursor: pointer;
262
+ }
263
+ /* The chrome's one selected idiom, shared with `.menu-row[aria-pressed]`. */
264
+ .url-history-row.active { background: var(--chrome-3); box-shadow: inset 2px 0 0 var(--text-0); }
265
+ /* Truncated at the *start*, not the end. What distinguishes one of these
266
+ addresses from another is the tail — the port, the path, the filename —
267
+ and right-truncating a list of localhost URLs or file paths yields six
268
+ identical rows, which is what the first screenshot of this list showed.
269
+ `direction: rtl` puts the ellipsis on the leading edge; the `bdi` around
270
+ the URL keeps the characters themselves in left-to-right order. */
271
+ .url-history-url {
272
+ flex: 1 1 auto;
273
+ min-width: 0;
274
+ overflow: hidden;
275
+ text-overflow: ellipsis;
276
+ white-space: nowrap;
277
+ direction: rtl;
278
+ /* The box is right-to-left only to move the ellipsis; a URL short enough to
279
+ fit must still start at the left edge like every other row. */
280
+ text-align: left;
281
+ font-family: var(--mono);
282
+ }
283
+ .url-history-age { flex: 0 0 auto; color: var(--text-1); font-size: 10px; }
284
+
185
285
  .status-cluster { flex: 0 0 auto; display: flex; align-items: center; gap: 6px; font-size: 10px; }
186
286
  .badge-error {
187
287
  color: var(--error);
@@ -335,13 +435,71 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
335
435
 
336
436
  .panes { flex: 1 1 auto; display: flex; min-height: 0; }
337
437
  .pane {
338
- flex: 1 1 50%;
438
+ flex: 1 1 0;
339
439
  min-width: 0;
340
440
  display: flex;
341
441
  flex-direction: column;
342
442
  background: var(--surround);
343
443
  }
344
- .target-pane { border-left: 1px solid var(--line); }
444
+ /* The native (or image) pane takes its share of everything the seam leaves;
445
+ the target pane grows into the rest, so the 1px divider comes out of the
446
+ row rather than out of one pane's half and skewing the ratio.
447
+
448
+ The `clamp()` is the *stored* ratio's floor, and the reason it lives in CSS
449
+ rather than in the drag: a ratio saved on a wide monitor and restored on a
450
+ narrow window is applied as close as 240px a side allows, without being
451
+ written back — grow the window and the preference returns on its own, with
452
+ no resize plumbing to keep in step. The drag enforces the same floor in JS,
453
+ where it also decides what to persist. */
454
+ .app[data-panes='both'] .panes > .pane:first-child {
455
+ flex-grow: 0;
456
+ flex-shrink: 0;
457
+ /* Rounded to whole pixels, and this is load-bearing. `TargetCanvas`
458
+ measures its pane with `clientWidth`, which *rounds*: a 1129.5px pane
459
+ reads as 1130, fit sizes the canvas to 1130, and the render then
460
+ overflows its own pane by half a pixel — which quietly turns off the
461
+ `safe center` centring and leaves the pane scrollable. Whole-pixel panes
462
+ keep the measurement honest. (The old `flex: 1 1 50%` hit the same thing
463
+ on an odd-width window; taking the seam out of the row would have made
464
+ it every window.) */
465
+ flex-basis: round(
466
+ down,
467
+ clamp(
468
+ var(--pane-min),
469
+ calc((100% - var(--seam)) * var(--split)),
470
+ calc(100% - var(--seam) - var(--pane-min))
471
+ ),
472
+ 1px
473
+ );
474
+ }
475
+
476
+ /* The seam. A flex item exactly one pixel wide — the hairline the target pane
477
+ used to draw as its own `border-left` — with the grab area straddling it as
478
+ an overflowing pseudo-element, so the handle is 6px to the pointer and 1px
479
+ to the eye. The style spec forbids decorating anything that touches a pane,
480
+ and a resize handle drawn as furniture would be decoration. */
481
+ .pane-divider {
482
+ flex: 0 0 var(--seam);
483
+ /* Positioned *and* raised: `.target-wrap` next door is positioned too and
484
+ comes later in the DOM, so without a `z-index` the target pane's canvas
485
+ paints over the half of the grab area that overhangs it and swallows the
486
+ press. */
487
+ position: relative;
488
+ z-index: 1;
489
+ background: var(--line);
490
+ cursor: col-resize;
491
+ /* Or the drag scrolls the pane instead of moving the seam. */
492
+ touch-action: none;
493
+ }
494
+ .pane-divider::before {
495
+ content: '';
496
+ position: absolute;
497
+ inset: 0 -2.5px;
498
+ }
499
+ .pane-divider:hover, .pane-divider:focus-visible { background: var(--text-1); }
500
+ /* The hairline is the affordance; a ring around a 1px element would be a box
501
+ floating on the seam. Same reasoning as `.target-canvas:focus`. */
502
+ .pane-divider:focus-visible { outline: none; }
345
503
  /* Square corners, no shadow, no blur: a rounded or blurred pane edge would
346
504
  destroy the very corner pixels the user opened this app to inspect. */
347
505
  .pane-body { flex: 1 1 auto; min-height: 0; overflow: auto; }
@@ -353,7 +511,12 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
353
511
  on the very pixels under inspection. Keyboard focus in the target pane is
354
512
  shown on the pane's own hairlines instead, which touch no page pixel. */
355
513
  .target-canvas:focus { outline: none; }
356
- .target-pane:has(.target-canvas:focus-visible) { border-left-color: var(--text-1); }
514
+ /* The seam moved out of `.target-pane`'s border and into its own element, so
515
+ the focus signal reaches it from the row both panes share — `:has` on the
516
+ ancestor, since the divider is the canvas's *earlier* sibling's sibling and
517
+ no combinator walks backwards. The footer rule is unchanged, and carries
518
+ the whole signal on its own in solo target, where there is no seam. */
519
+ .panes:has(.target-canvas:focus-visible) .pane-divider { background: var(--text-1); }
357
520
  .target-pane:has(.target-canvas:focus-visible) .pane-footer { border-top-color: var(--text-1); }
358
521
 
359
522
  /* Signature: each pane always states what it is showing, so a screenshot of
@@ -406,9 +569,6 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
406
569
  .surround-control button[aria-pressed='true'] { box-shadow: inset 0 0 0 2px var(--text-0); }
407
570
  .surround-swatch { display: block; width: 12px; height: 12px; margin: 0 auto; }
408
571
 
409
- /* Nothing sits to the target pane's left in solo view, so the divider goes. */
410
- .panes[data-panes='target'] .target-pane { border-left: 0; }
411
-
412
572
  /* Numbers never jitter as a slider moves. */
413
573
  .num { font-family: var(--mono); font-variant-numeric: tabular-nums; }
414
574
 
@@ -431,7 +591,11 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
431
591
  .body { flex: 1 1 auto; display: flex; min-height: 0; }
432
592
  .panes { min-width: 0; }
433
593
  .drawer {
434
- flex: 0 0 280px;
594
+ /* Border-box against `--drawer-w`: 309 - 28 padding - 1 border = the 280px
595
+ of content this has always had, now stated once for both this and the
596
+ URL-history clamp. */
597
+ box-sizing: border-box;
598
+ flex: 0 0 var(--drawer-w);
435
599
  overflow: auto;
436
600
  padding: 12px 14px;
437
601
  background: var(--chrome-1);
@@ -664,7 +828,8 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
664
828
  cursor: pointer;
665
829
  font: inherit;
666
830
  }
667
- .check-now {
831
+ .check-now,
832
+ .clear-history {
668
833
  width: 100%;
669
834
  height: 24px;
670
835
  margin-top: 8px;
@@ -674,3 +839,5 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
674
839
  border-radius: 4px;
675
840
  cursor: pointer;
676
841
  }
842
+ /* Nothing to erase: the button says so rather than pretending to act. */
843
+ .clear-history:disabled { color: var(--text-1); cursor: default; }
@@ -12688,12 +12688,16 @@ const createImpl = (createState) => {
12688
12688
  };
12689
12689
  const create = ((createState) => createImpl);
12690
12690
  const MAX_VIEWPORT = 4096;
12691
+ const SPLIT_MIN = 0.1;
12692
+ const SPLIT_MAX = 0.9;
12691
12693
  const DEFAULT_SETTINGS = {
12692
12694
  hostDiagonalInches: 27,
12693
12695
  hostNits: 500,
12694
12696
  agentControl: false,
12695
12697
  updateCheck: true,
12696
- lastUpdateCheck: 0
12698
+ lastUpdateCheck: 0,
12699
+ recordHistory: true,
12700
+ split: 0.5
12697
12701
  };
12698
12702
  const SCREEN_PRESETS = [
12699
12703
  // Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
@@ -12780,6 +12784,7 @@ const useStore = create()((set) => ({
12780
12784
  error: null,
12781
12785
  toast: null,
12782
12786
  update: null,
12787
+ history: [],
12783
12788
  image: null,
12784
12789
  surround: "graphite",
12785
12790
  // Fit, not 1:1: fit never enlarges past 1:1, so a render that already fits
@@ -12812,6 +12817,7 @@ const useStore = create()((set) => ({
12812
12817
  // duplicate must not replace the object and re-render everything twice.
12813
12818
  setError: (error) => set((s) => sameError(s.error, error) ? {} : { error }),
12814
12819
  setUpdate: (update) => set({ update }),
12820
+ setHistory: (history) => set({ history }),
12815
12821
  setToast: (toast) => set({ toast }),
12816
12822
  setImage: (image) => set({ image }),
12817
12823
  setSurround: (surround) => set({ surround }),
@@ -13115,6 +13121,98 @@ function NativeSlot() {
13115
13121
  /* @__PURE__ */ jsxRuntimeExports.jsx(NativeFooter, { width: size.width, height: size.height })
13116
13122
  ] });
13117
13123
  }
13124
+ const MIN_PANE_PX = 240;
13125
+ const KEY_STEP = 0.02;
13126
+ const KEY_STEP_COARSE = 0.1;
13127
+ function PaneDivider() {
13128
+ const split = useStore((s) => s.settings.split);
13129
+ const ref = reactExports.useRef(null);
13130
+ const drag = reactExports.useRef(null);
13131
+ const geometry = () => {
13132
+ const el = ref.current;
13133
+ const row = el?.parentElement;
13134
+ if (!el || !row) return { left: 0, usable: 1, min: 0.5, max: 0.5 };
13135
+ const r = row.getBoundingClientRect();
13136
+ const usable2 = Math.max(1, r.width - el.getBoundingClientRect().width);
13137
+ const floor = MIN_PANE_PX / usable2;
13138
+ return {
13139
+ left: r.left,
13140
+ usable: usable2,
13141
+ min: Math.min(Math.max(SPLIT_MIN, floor), 0.5),
13142
+ max: Math.max(Math.min(SPLIT_MAX, 1 - floor), 0.5)
13143
+ };
13144
+ };
13145
+ const clamp = (ratio) => {
13146
+ const g = geometry();
13147
+ return Math.min(Math.max(ratio, g.min), g.max);
13148
+ };
13149
+ const preview = (ratio) => {
13150
+ const s = useStore.getState();
13151
+ const next = clamp(ratio);
13152
+ if (next === s.settings.split) return;
13153
+ s.setSettings({ ...s.settings, split: next });
13154
+ };
13155
+ const persist = (before) => {
13156
+ const now = useStore.getState().settings;
13157
+ if (now.split === before.split) return;
13158
+ window.obsrv.setSettings(now).catch(() => useStore.getState().setSettings(before));
13159
+ };
13160
+ const commit = (ratio) => {
13161
+ const before = useStore.getState().settings;
13162
+ preview(ratio);
13163
+ persist(before);
13164
+ };
13165
+ const onPointerDown = (e) => {
13166
+ if (e.button !== 0) return;
13167
+ e.preventDefault();
13168
+ const el = e.currentTarget;
13169
+ el.setPointerCapture(e.pointerId);
13170
+ drag.current = {
13171
+ before: useStore.getState().settings,
13172
+ grab: e.clientX - el.getBoundingClientRect().left
13173
+ };
13174
+ };
13175
+ const onPointerMove = (e) => {
13176
+ const d = drag.current;
13177
+ if (!d) return;
13178
+ const g = geometry();
13179
+ preview((e.clientX - d.grab - g.left) / g.usable);
13180
+ };
13181
+ const onPointerUp = (e) => {
13182
+ const d = drag.current;
13183
+ if (!d) return;
13184
+ drag.current = null;
13185
+ e.currentTarget.releasePointerCapture(e.pointerId);
13186
+ persist(d.before);
13187
+ };
13188
+ const onKeyDown = (e) => {
13189
+ const step = e.shiftKey ? KEY_STEP_COARSE : KEY_STEP;
13190
+ const delta = e.key === "ArrowLeft" ? -step : e.key === "ArrowRight" ? step : 0;
13191
+ if (!delta) return;
13192
+ e.preventDefault();
13193
+ commit(useStore.getState().settings.split + delta);
13194
+ };
13195
+ return /* @__PURE__ */ jsxRuntimeExports.jsx(
13196
+ "div",
13197
+ {
13198
+ ref,
13199
+ className: "pane-divider",
13200
+ role: "separator",
13201
+ "aria-orientation": "vertical",
13202
+ "aria-label": "Pane split",
13203
+ tabIndex: 0,
13204
+ "aria-valuenow": Math.round(split * 100),
13205
+ "aria-valuemin": Math.round(SPLIT_MIN * 100),
13206
+ "aria-valuemax": Math.round(SPLIT_MAX * 100),
13207
+ onPointerDown,
13208
+ onPointerMove,
13209
+ onPointerUp,
13210
+ onPointerCancel: onPointerUp,
13211
+ onDoubleClick: () => commit(0.5),
13212
+ onKeyDown
13213
+ }
13214
+ );
13215
+ }
13118
13216
  const CONTRAST_MAX = 3e3;
13119
13217
  const CUSTOM_PROFILE_ID = "custom";
13120
13218
  function profileToControls(p, hostNits) {
@@ -13308,6 +13406,7 @@ function SettingsPanel() {
13308
13406
  const host = useStore(useShallow((s) => s.host));
13309
13407
  const settings = useStore(useShallow((s) => s.settings));
13310
13408
  const update = useStore((s) => s.update);
13409
+ const history = useStore((s) => s.history);
13311
13410
  const custom = useStore(useShallow((s) => s.custom));
13312
13411
  const viewport = useStore(useShallow(selectViewport));
13313
13412
  const scale = useStore(selectScale);
@@ -13464,7 +13563,34 @@ function SettingsPanel() {
13464
13563
  /* @__PURE__ */ jsxRuntimeExports.jsx("span", { children: "Check for updates automatically" })
13465
13564
  ] }),
13466
13565
  /* @__PURE__ */ jsxRuntimeExports.jsx("button", { type: "button", className: "check-now", onClick: () => void window.obsrv.checkUpdate(), children: "Check now" }),
13467
- /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "muted", children: "One unauthenticated request to GitHub, at most once a day. No identifiers are sent." })
13566
+ /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "muted", children: "One unauthenticated request to GitHub, at most once a day. No identifiers are sent." }),
13567
+ /* @__PURE__ */ jsxRuntimeExports.jsx("h2", { children: "History" }),
13568
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("label", { className: "control inline record-history-toggle", children: [
13569
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
13570
+ "input",
13571
+ {
13572
+ type: "checkbox",
13573
+ checked: settings.recordHistory,
13574
+ onChange: (e) => commit({ ...settings, recordHistory: e.target.checked })
13575
+ }
13576
+ ),
13577
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { children: "Remember visited addresses" })
13578
+ ] }),
13579
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
13580
+ "button",
13581
+ {
13582
+ type: "button",
13583
+ className: "clear-history",
13584
+ disabled: history.length === 0,
13585
+ onClick: () => void window.obsrv.clearHistory(),
13586
+ children: "Clear history"
13587
+ }
13588
+ ),
13589
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("p", { className: "readout", children: [
13590
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "history-count num", children: history.length }),
13591
+ history.length === 1 ? " address remembered" : " addresses remembered"
13592
+ ] }),
13593
+ /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "muted", children: "Typed into the URL bar as suggestions and nowhere else. Turning this off stops recording and keeps what is stored; Clear erases it." })
13468
13594
  ] });
13469
13595
  }
13470
13596
  const VERT_SRC = `#version 300 es
@@ -14279,6 +14405,16 @@ function Toast() {
14279
14405
  if (!toast) return null;
14280
14406
  return /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "toast", role: "status", children: toast });
14281
14407
  }
14408
+ const HISTORY_SUGGESTIONS = 6;
14409
+ function byRank(a, b) {
14410
+ if (a.lastVisit !== b.lastVisit) return b.lastVisit - a.lastVisit;
14411
+ if (a.visits !== b.visits) return b.visits - a.visits;
14412
+ return a.url < b.url ? -1 : a.url > b.url ? 1 : 0;
14413
+ }
14414
+ function matchHistory(entries, query, limit = HISTORY_SUGGESTIONS) {
14415
+ const needle = query.trim().toLowerCase();
14416
+ return entries.filter((e) => e.url.toLowerCase().includes(needle)).sort(byRank).slice(0, Math.max(0, limit));
14417
+ }
14282
14418
  /**
14283
14419
  * @license lucide-react v1.34.0 - ISC
14284
14420
  *
@@ -14631,8 +14767,18 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
14631
14767
  const setPanes = useStore((s) => s.setPanes);
14632
14768
  const agentControl = useStore((s) => s.settings.agentControl);
14633
14769
  const setSettings = useStore((s) => s.setSettings);
14770
+ const history = useStore((s) => s.history);
14634
14771
  const inputRef = reactExports.useRef(null);
14635
14772
  const [draft, setDraft] = reactExports.useState(barText);
14773
+ const [open, setOpen] = reactExports.useState(false);
14774
+ const [highlight, setHighlight] = reactExports.useState(-1);
14775
+ const matches = reactExports.useMemo(() => open ? matchHistory(history, draft) : [], [open, history, draft]);
14776
+ const listOpen = open && matches.length > 0;
14777
+ const closeList = () => {
14778
+ setOpen(false);
14779
+ setHighlight(-1);
14780
+ };
14781
+ const picked = highlight >= 0 && highlight < matches.length ? matches[highlight] : null;
14636
14782
  const [agentActive, setAgentActive] = reactExports.useState(false);
14637
14783
  reactExports.useEffect(() => {
14638
14784
  let timer;
@@ -14665,14 +14811,50 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
14665
14811
  });
14666
14812
  }, []);
14667
14813
  const readOnly = mode === "image";
14668
- const submit = async (e) => {
14669
- e.preventDefault();
14670
- if (readOnly || draft.trim() === "") return;
14814
+ reactExports.useEffect(() => {
14815
+ if (readOnly) closeList();
14816
+ }, [readOnly]);
14817
+ const go = async (url) => {
14818
+ closeList();
14671
14819
  setError(null);
14672
- const applied = await window.obsrv.navigate(draft);
14820
+ const applied = await window.obsrv.navigate(url);
14673
14821
  setUrl(applied);
14674
14822
  setDraft(applied);
14675
14823
  };
14824
+ const submit = (e) => {
14825
+ e.preventDefault();
14826
+ if (readOnly || draft.trim() === "") return;
14827
+ void go(draft);
14828
+ };
14829
+ const onKeyDown = (e) => {
14830
+ if (readOnly) return;
14831
+ if (e.key === "ArrowDown") {
14832
+ e.preventDefault();
14833
+ if (!open) {
14834
+ setOpen(true);
14835
+ setHighlight(0);
14836
+ return;
14837
+ }
14838
+ setHighlight((h) => h + 1 >= matches.length ? -1 : h + 1);
14839
+ return;
14840
+ }
14841
+ if (e.key === "ArrowUp" && listOpen) {
14842
+ e.preventDefault();
14843
+ setHighlight((h) => h <= -1 ? matches.length - 1 : h - 1);
14844
+ return;
14845
+ }
14846
+ if (e.key === "Enter" && picked) {
14847
+ e.preventDefault();
14848
+ void go(picked.url);
14849
+ return;
14850
+ }
14851
+ if (e.key === "Escape") {
14852
+ const dismissed = listOpen;
14853
+ closeList();
14854
+ if (dismissed) return;
14855
+ setDraft(barText);
14856
+ }
14857
+ };
14676
14858
  const presetLabel = SCREEN_PRESETS.find((p) => p.id === presetId)?.label ?? "Custom";
14677
14859
  const profileLabel = PANEL_PROFILES.find((p) => p.id === profileId)?.label ?? profileId;
14678
14860
  return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "chrome", children: [
@@ -14710,20 +14892,49 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
14710
14892
  children: /* @__PURE__ */ jsxRuntimeExports.jsx(Icon, { name: "reload" })
14711
14893
  }
14712
14894
  ),
14713
- /* @__PURE__ */ jsxRuntimeExports.jsx("form", { className: "url-form", onSubmit: submit, children: /* @__PURE__ */ jsxRuntimeExports.jsx(
14714
- "input",
14715
- {
14716
- ref: inputRef,
14717
- value: draft,
14718
- readOnly,
14719
- spellCheck: false,
14720
- placeholder: "Enter a URL, or drop a PNG",
14721
- onChange: (e) => setDraft(e.target.value),
14722
- onKeyDown: (e) => {
14723
- if (e.key === "Escape") setDraft(barText);
14895
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("form", { className: "url-form", onSubmit: submit, children: [
14896
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
14897
+ "input",
14898
+ {
14899
+ ref: inputRef,
14900
+ value: draft,
14901
+ readOnly,
14902
+ spellCheck: false,
14903
+ placeholder: "Enter a URL, or drop a PNG",
14904
+ role: "combobox",
14905
+ "aria-expanded": listOpen,
14906
+ "aria-controls": "url-history",
14907
+ "aria-autocomplete": "list",
14908
+ "aria-activedescendant": picked ? `url-history-${highlight}` : void 0,
14909
+ onChange: (e) => {
14910
+ setDraft(e.target.value);
14911
+ setOpen(true);
14912
+ setHighlight(-1);
14913
+ },
14914
+ onKeyDown,
14915
+ onBlur: closeList
14724
14916
  }
14725
- }
14726
- ) }),
14917
+ ),
14918
+ listOpen && /* @__PURE__ */ jsxRuntimeExports.jsx("ul", { className: "url-history", id: "url-history", role: "listbox", "aria-label": "Visited addresses", children: matches.map((m, i) => /* @__PURE__ */ jsxRuntimeExports.jsxs(
14919
+ "li",
14920
+ {
14921
+ id: `url-history-${i}`,
14922
+ className: `url-history-row${i === highlight ? " active" : ""}`,
14923
+ role: "option",
14924
+ "aria-selected": i === highlight,
14925
+ onMouseDown: (e) => {
14926
+ e.preventDefault();
14927
+ void go(m.url);
14928
+ },
14929
+ onMouseEnter: () => setHighlight(i),
14930
+ children: [
14931
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "url-history-url", children: /* @__PURE__ */ jsxRuntimeExports.jsx("bdi", { children: m.url }) }),
14932
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "url-history-age", children: formatAge(m.lastVisit, Date.now()) })
14933
+ ]
14934
+ },
14935
+ m.url
14936
+ )) })
14937
+ ] }),
14727
14938
  /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "status-cluster", children: [
14728
14939
  mode === "image" && /* @__PURE__ */ jsxRuntimeExports.jsx(
14729
14940
  "button",
@@ -14890,6 +15101,7 @@ function App() {
14890
15101
  const setError = useStore((s) => s.setError);
14891
15102
  const setTargetLoading = useStore((s) => s.setTargetLoading);
14892
15103
  const setUpdate = useStore((s) => s.setUpdate);
15104
+ const setHistory = useStore((s) => s.setHistory);
14893
15105
  const setImageMeta = useStore((s) => s.setImage);
14894
15106
  const setMode = useStore((s) => s.setMode);
14895
15107
  const setToast = useStore((s) => s.setToast);
@@ -14901,27 +15113,34 @@ function App() {
14901
15113
  const profileId = useStore((s) => s.profileId);
14902
15114
  const viewMode = useStore((s) => s.viewMode);
14903
15115
  const panes = useStore((s) => s.panes);
15116
+ const split = useStore((s) => s.settings.split);
14904
15117
  reactExports.useEffect(() => {
14905
15118
  window.obsrv.getHostInfo().then(setHost, (e) => console.warn("obsrv: getHostInfo failed", e));
14906
15119
  window.obsrv.getSettings().then(setSettings, (e) => console.warn("obsrv: getSettings failed", e));
14907
15120
  window.obsrv.getUpdate().then(setUpdate, (e) => console.warn("obsrv: getUpdate failed", e));
15121
+ window.obsrv.getHistory().then(setHistory, (e) => console.warn("obsrv: getHistory failed", e));
14908
15122
  const offs = [
14909
15123
  window.obsrv.onHostChanged(setHost),
14910
15124
  // A committed navigation — back, forward, reload, a link — supersedes
14911
- // the last load failure. A *failed* load commits Chromium's error page
14912
- // first and reports its error after, so the badge still lands last.
15125
+ // the last load failure, and only a committed one: Chromium commits its
15126
+ // error page without emitting `did-navigate`, so a failed load reports
15127
+ // no URL at all (measured on Electron 43; a bad host, a refused
15128
+ // connection, a missing file and a Back onto an error page all fire
15129
+ // `did-fail-load` alone). The badge therefore lands last by having
15130
+ // nothing race it.
14913
15131
  window.obsrv.onUrlChanged((url) => {
14914
15132
  setError(null);
14915
15133
  setUrl(url);
14916
15134
  }),
14917
15135
  window.obsrv.onLoadError(setError),
14918
15136
  window.obsrv.onTargetLoading(setTargetLoading),
14919
- window.obsrv.onUpdateStatus(setUpdate)
15137
+ window.obsrv.onUpdateStatus(setUpdate),
15138
+ window.obsrv.onHistoryChanged(setHistory)
14920
15139
  ];
14921
15140
  return () => {
14922
15141
  for (const off of offs) off();
14923
15142
  };
14924
- }, [setHost, setSettings, setUrl, setError, setTargetLoading, setUpdate]);
15143
+ }, [setHost, setSettings, setUrl, setError, setTargetLoading, setUpdate, setHistory]);
14925
15144
  reactExports.useEffect(() => {
14926
15145
  void window.obsrv.setViewport(viewport.width, viewport.height, deviceScaleFactor);
14927
15146
  }, [viewport.width, viewport.height, deviceScaleFactor]);
@@ -15031,29 +15250,46 @@ function App() {
15031
15250
  };
15032
15251
  }, [mode, image]);
15033
15252
  if (fatal) return /* @__PURE__ */ jsxRuntimeExports.jsx(Fatal, { message: fatal });
15034
- return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "app", children: [
15035
- /* @__PURE__ */ jsxRuntimeExports.jsx(Toolbar, { drawer, onTogglePanel: toggle("panel"), onToggleSettings: toggle("settings") }),
15036
- /* @__PURE__ */ jsxRuntimeExports.jsx(DropZone, { onImage }),
15037
- /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "body", children: [
15038
- /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "panes", "data-panes": panes, children: [
15039
- panes === "both" && (mode === "image" && image ? /* @__PURE__ */ jsxRuntimeExports.jsx(
15040
- ImagePane,
15041
- {
15042
- src: image.objectUrl,
15043
- width: image.natural.width,
15044
- height: image.natural.height
15045
- }
15046
- ) : /* @__PURE__ */ jsxRuntimeExports.jsx(NativeSlot, {})),
15047
- /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "pane target-pane", ref: targetPaneRef, children: [
15048
- /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "pane-body", children: /* @__PURE__ */ jsxRuntimeExports.jsx(TargetCanvas, { onFatal: setFatal, imageFrame }) }),
15049
- /* @__PURE__ */ jsxRuntimeExports.jsx(TargetFooter, {})
15050
- ] })
15051
- ] }),
15052
- drawer === "panel" && /* @__PURE__ */ jsxRuntimeExports.jsx("aside", { className: "drawer", children: /* @__PURE__ */ jsxRuntimeExports.jsx(PanelControls, {}) }),
15053
- drawer === "settings" && /* @__PURE__ */ jsxRuntimeExports.jsx("aside", { className: "drawer", children: /* @__PURE__ */ jsxRuntimeExports.jsx(SettingsPanel, {}) })
15054
- ] }),
15055
- /* @__PURE__ */ jsxRuntimeExports.jsx(Toast, {})
15056
- ] });
15253
+ return (
15254
+ // The split and the panes mode are published here rather than on `.panes`
15255
+ // because the chrome needs them too: the URL bar's history dropdown is
15256
+ // clamped to the native pane's right edge, and a custom property set on
15257
+ // `.panes` reaches nothing above it. Everything below still inherits them.
15258
+ /* @__PURE__ */ jsxRuntimeExports.jsxs(
15259
+ "div",
15260
+ {
15261
+ className: "app",
15262
+ "data-panes": panes,
15263
+ style: { "--split": split, "--pane-min": `${MIN_PANE_PX}px` },
15264
+ children: [
15265
+ /* @__PURE__ */ jsxRuntimeExports.jsx(Toolbar, { drawer, onTogglePanel: toggle("panel"), onToggleSettings: toggle("settings") }),
15266
+ /* @__PURE__ */ jsxRuntimeExports.jsx(DropZone, { onImage }),
15267
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "body", children: [
15268
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "panes", children: [
15269
+ panes === "both" && /* @__PURE__ */ jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, { children: [
15270
+ mode === "image" && image ? /* @__PURE__ */ jsxRuntimeExports.jsx(
15271
+ ImagePane,
15272
+ {
15273
+ src: image.objectUrl,
15274
+ width: image.natural.width,
15275
+ height: image.natural.height
15276
+ }
15277
+ ) : /* @__PURE__ */ jsxRuntimeExports.jsx(NativeSlot, {}),
15278
+ /* @__PURE__ */ jsxRuntimeExports.jsx(PaneDivider, {})
15279
+ ] }),
15280
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "pane target-pane", ref: targetPaneRef, children: [
15281
+ /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "pane-body", children: /* @__PURE__ */ jsxRuntimeExports.jsx(TargetCanvas, { onFatal: setFatal, imageFrame }) }),
15282
+ /* @__PURE__ */ jsxRuntimeExports.jsx(TargetFooter, {})
15283
+ ] })
15284
+ ] }),
15285
+ drawer === "panel" && /* @__PURE__ */ jsxRuntimeExports.jsx("aside", { className: "drawer", children: /* @__PURE__ */ jsxRuntimeExports.jsx(PanelControls, {}) }),
15286
+ drawer === "settings" && /* @__PURE__ */ jsxRuntimeExports.jsx("aside", { className: "drawer", children: /* @__PURE__ */ jsxRuntimeExports.jsx(SettingsPanel, {}) })
15287
+ ] }),
15288
+ /* @__PURE__ */ jsxRuntimeExports.jsx(Toast, {})
15289
+ ]
15290
+ }
15291
+ )
15292
+ );
15057
15293
  }
15058
15294
  function BrowserNotice() {
15059
15295
  return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "browser-notice", role: "note", children: [
@@ -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-BlbBg6lm.js"></script>
8
+ <link rel="stylesheet" crossorigin href="./assets/index-B61fbgqt.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
@@ -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
+ }
@@ -10,6 +10,7 @@ exports.parseUiState = parseUiState;
10
10
  exports.parseScrollPos = parseScrollPos;
11
11
  exports.parseScrollRequest = parseScrollRequest;
12
12
  exports.parseScrollReport = parseScrollReport;
13
+ const presets_1 = require("./presets");
13
14
  const types_1 = require("./types");
14
15
  /**
15
16
  * Parsers for everything the renderer sends main over IPC. Each returns a
@@ -99,7 +100,7 @@ function parseDeviceScaleFactor(raw) {
99
100
  return raw;
100
101
  }
101
102
  /**
102
- * Copies exactly the five known keys; the numbers must be finite and
103
+ * Copies exactly the known keys; the numbers must be finite and
103
104
  * positive. A missing `agentControl` means false and a missing `updateCheck`
104
105
  * means true (the pre-feature wire shapes); any non-boolean value is refused,
105
106
  * never coerced.
@@ -121,7 +122,17 @@ function parseSettings(raw) {
121
122
  const lastUpdateCheck = raw.lastUpdateCheck ?? 0;
122
123
  if (!isFiniteNumber(lastUpdateCheck) || lastUpdateCheck < 0)
123
124
  return null;
124
- return { hostDiagonalInches, hostNits, agentControl, updateCheck, lastUpdateCheck };
125
+ const recordHistory = raw.recordHistory ?? true;
126
+ if (typeof recordHistory !== 'boolean')
127
+ return null;
128
+ // A missing `split` is the pre-feature wire shape and means the default.
129
+ // Out of band it is refused rather than clamped: `loadSettings` forgives a
130
+ // hand-edited file because it must, but the renderer clamps before it
131
+ // sends, so a bad ratio arriving here is a bug worth surfacing.
132
+ const split = raw.split ?? presets_1.DEFAULT_SETTINGS.split;
133
+ if (!isFiniteNumber(split) || split < presets_1.SPLIT_MIN || split > presets_1.SPLIT_MAX)
134
+ return null;
135
+ return { hostDiagonalInches, hostNits, agentControl, updateCheck, lastUpdateCheck, recordHistory, split };
125
136
  }
126
137
  function parseMode(raw) {
127
138
  return raw === 'url' || raw === 'image' ? raw : null;
@@ -1,15 +1,21 @@
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.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;
7
11
  exports.DEFAULT_SETTINGS = {
8
12
  hostDiagonalInches: 27,
9
13
  hostNits: 500,
10
14
  agentControl: false,
11
15
  updateCheck: true,
12
16
  lastUpdateCheck: 0,
17
+ recordHistory: true,
18
+ split: 0.5,
13
19
  };
14
20
  exports.SCREEN_PRESETS = [
15
21
  // Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getobsrv",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "See your site the way 1x screens see it",
5
5
  "main": "./out/main/index.js",
6
6
  "bin": {