pi-ele-picker 0.3.1 → 0.4.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/README.md CHANGED
@@ -94,6 +94,8 @@ The launcher starts in the **bottom-right** corner and stays out of the way:
94
94
  - **Draggable** — grab it and drop it anywhere; the position is remembered per browser (`localStorage`).
95
95
  - **Fades when idle** (opacity `0.5`) and returns to full opacity on hover.
96
96
  - **Shows the live pi session status** as a coloured dot **when enabled** — green while pi is running, amber while follow-ups are queued, grey while awaiting input, red when the bridge is gone (see [Session status](#session-status)).
97
+ - **Hovering the launcher reopens the panel** for the element you captured last — no need to pick it again, and the captured element is only replaced when you commit a new pick. Closing the panel with **✕** keeps it available this way.
98
+ - Clicking the launcher starts a **new pick**: the panel steps out of the way, and pressing **Esc** keeps the previously captured element intact.
97
99
  - The panel always opens **next to the launcher**, flipping sides and clamping to stay on screen.
98
100
  - **The panel is draggable too** — grab its title bar (or any empty spot) to move it; the offset from the launcher is remembered per browser (`localStorage`), and the panel still follows the launcher while clamping itself to stay on screen.
99
101
 
@@ -131,7 +133,7 @@ The same payload is available from the bridge directly:
131
133
  "provider": "anthropic", "pid": 12345, "cwd": "…", "updatedAt": 1700000000000 }
132
134
  ```
133
135
 
134
- `/status` needs no token, but non-loopback browser origins get `403`, so an arbitrary website cannot probe session metadata.
136
+ `/status` needs no token, but non-local browser origins get `403`, so an arbitrary website cannot probe session metadata. "Local" covers `localhost` / `*.localhost`, `127.0.0.0/8`, `0.0.0.0`, `[::1]`, and private or link-local IPv4 literals (`10.x.x.x`, `172.16-31.x.x`, `192.168.x.x`, `169.254.x.x`) — so a dev server you open over your machine's LAN IPv4 address (Vite binds `0.0.0.0` by default) works, while an arbitrary site does not.
135
137
 
136
138
  ## Notes
137
139
 
package/client/picker.js CHANGED
@@ -119,7 +119,7 @@
119
119
  triggerDot.className = 'dot st-off';
120
120
  trigger.appendChild(triggerDot);
121
121
  trigger.appendChild(document.createTextNode('Pi Picker'));
122
- trigger.title = '拖拽可移动 · 面板标题栏也可拖拽 · 点击开始选择元素';
122
+ trigger.title = '拖拽可移动 · 面板标题栏也可拖拽 · 悬浮可再次打开上次元素 · 点击开始选择元素';
123
123
  root.appendChild(trigger);
124
124
 
125
125
  var panel = document.createElement('div');
@@ -149,6 +149,8 @@
149
149
  var fileEl = panel.querySelector('.file');
150
150
 
151
151
  var picking = false;
152
+ // The captured element the panel acts on. Stays put across close/reopen so
153
+ // hovering the launcher can bring the panel back without overwriting it.
152
154
  var target = null;
153
155
  var shots = [];
154
156
  // Set when a pick commits on pointerdown; swallows the follow-up click so the
@@ -281,6 +283,7 @@
281
283
 
282
284
  trigger.addEventListener('pointerdown', function (e) {
283
285
  if (e.pointerType === 'mouse' && e.button !== 0) return;
286
+ clearHoverOpen();
284
287
  var r = trigger.getBoundingClientRect();
285
288
  drag = { id: e.pointerId, sx: e.clientX, sy: e.clientY, ox: r.left, oy: r.top, moved: false };
286
289
  try {
@@ -419,7 +422,8 @@
419
422
  function onMove(e) {
420
423
  var el = elFromEvent(e);
421
424
  if (!el) return;
422
- target = el;
425
+ // Highlight only: `target` is replaced solely by a committed pick, so Esc
426
+ // or pausing on another element never clobbers the panel's element.
423
427
  showHighlight(el);
424
428
  }
425
429
 
@@ -464,6 +468,12 @@
464
468
  hint.style.display = on ? 'block' : 'none';
465
469
  trigger.style.display = on ? 'none' : 'flex';
466
470
  if (on) {
471
+ // Picking means "choose another element": hide the panel so it never
472
+ // covers the page. It reopens when the new pick commits, and the previous
473
+ // element only gets replaced at that point.
474
+ clearHoverOpen();
475
+ panel.classList.remove('open');
476
+ hideHighlight();
467
477
  window.addEventListener('mousemove', onMove, true);
468
478
  window.addEventListener('pointerdown', onPointerDown, true);
469
479
  window.addEventListener('keydown', onKey, true);
@@ -475,12 +485,19 @@
475
485
  }
476
486
  }
477
487
 
488
+ /** Re-show the panel for the element already captured — no re-pick, no overwrite. */
489
+ function openPanel() {
490
+ if (!target) return false;
491
+ panel.classList.add('open');
492
+ showHighlight(target);
493
+ positionPanel();
494
+ return true;
495
+ }
496
+
478
497
  function select(el) {
479
498
  target = el;
480
- showHighlight(el);
481
499
  metaEl.innerHTML = '<code>' + escapeHtml(cssPath(el)) + '</code>' + vueLine(el);
482
- panel.classList.add('open');
483
- positionPanel();
500
+ openPanel();
484
501
  taEl.focus();
485
502
  }
486
503
 
@@ -722,6 +739,33 @@
722
739
  window.addEventListener('pointerdown', function () { swallowClick = false; }, true);
723
740
  window.addEventListener('click', onClick, true);
724
741
 
742
+ /* --- hover reopens the last captured element --- */
743
+ var HOVER_OPEN_DELAY = 200;
744
+ var hoverOpenTimer = null;
745
+
746
+ function clearHoverOpen() {
747
+ if (hoverOpenTimer) {
748
+ clearTimeout(hoverOpenTimer);
749
+ hoverOpenTimer = null;
750
+ }
751
+ }
752
+
753
+ trigger.addEventListener('pointerenter', function (e) {
754
+ // Mouse only: touch/pen enter is part of a tap, which must keep its click.
755
+ if (e.pointerType !== 'mouse') return;
756
+ if (picking || drag) return;
757
+ clearHoverOpen();
758
+ // A short dwell keeps a mouse merely passing over the launcher from popping
759
+ // the panel open, while still feeling immediate when you aim at it.
760
+ hoverOpenTimer = setTimeout(function () {
761
+ hoverOpenTimer = null;
762
+ if (picking || drag) return;
763
+ openPanel();
764
+ }, HOVER_OPEN_DELAY);
765
+ });
766
+
767
+ trigger.addEventListener('pointerleave', clearHoverOpen);
768
+
725
769
  trigger.addEventListener('click', function () {
726
770
  if (suppressClick) {
727
771
  suppressClick = false;
@@ -802,7 +846,7 @@
802
846
  if (info && info.sessionId) detail.push('会话 ' + String(info.sessionId).slice(0, 8));
803
847
  var line = STATUS_LABEL[st] + (detail.length ? ' · ' + detail.join(' · ') : '');
804
848
  sessionText.textContent = line;
805
- var title = line + ' · 拖拽可移动 · 面板标题栏也可拖拽 · 点击开始选择元素';
849
+ var title = line + ' · 拖拽可移动 · 面板标题栏也可拖拽 · 悬浮可再次打开上次元素 · 点击开始选择元素';
806
850
  if (title !== statusTitle) {
807
851
  trigger.title = title;
808
852
  statusTitle = title;
@@ -12,7 +12,10 @@
12
12
  * Wire protocol (POST /pick):
13
13
  * headers: content-type: application/json
14
14
  * x-pi-picker-token: <token from the registry file>
15
- * origin: http://localhost:<port> | http://127.0.0.1:<port>
15
+ * origin: a local origin — http(s)://localhost:<port>,
16
+ * http(s)://127.0.0.1:<port>, or any loopback /
17
+ * private IPv4 literal (192.168.x.x, 10.x.x.x,
18
+ * 172.16-31.x.x, 169.254.x.x, 0.0.0.0)
16
19
  * body: { text?, images?: [{name, dataUrl}], element?: {...}, url?, title?, submit? }
17
20
  * reply: { ok: true, mode, inserted } | { ok: true, mode, submitted, queued? }
18
21
  * | { ok: false, error }
@@ -22,9 +25,9 @@
22
25
  * mode, sessionId, model, provider, pid, cwd, updatedAt }
23
26
  *
24
27
  * `/status` is polled by the browser picker to render a live session badge (the
25
- * launcher dot + the panel status line). It has no token: any loopback-origin
26
- * page may read it, but non-loopback browser origins are rejected so an arbitrary
27
- * website cannot probe session metadata.
28
+ * launcher dot + the panel status line). It has no token: any local-origin page
29
+ * may read it, but non-local browser origins are rejected so an arbitrary website
30
+ * cannot probe session metadata.
28
31
  *
29
32
  * Two delivery modes:
30
33
  * - default (submit falsy): pre-fill the current pi input editor for review.
@@ -50,12 +53,79 @@ function log(msg: string): void {
50
53
  }
51
54
  }
52
55
 
53
- function isLoopbackOrigin(origin: unknown): boolean {
54
- return typeof origin === 'string' && /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin);
56
+ /** Loopback / private / link-local / "this host" IPv4 literals. */
57
+ function isLocalIpv4(ip: string): boolean {
58
+ const parts = ip.split('.');
59
+ if (parts.length !== 4) return false;
60
+ const octets: number[] = [];
61
+ for (const p of parts) {
62
+ if (!/^\d{1,3}$/.test(p)) return false;
63
+ const n = Number(p);
64
+ if (n > 255) return false;
65
+ octets.push(n);
66
+ }
67
+ const [a, b] = octets;
68
+ if (a === 127 || a === 0) return true; // 127.0.0.0/8 loopback · 0.0.0.0 "this host"
69
+ if (a === 10) return true; // 10.0.0.0/8
70
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
71
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16
72
+ if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local
73
+ return false;
74
+ }
75
+
76
+ /**
77
+ * `::ffff:7f00:1` (the hex form WHATWG normalises IPv4-mapped IPv6 to) and the
78
+ * literal `::ffff:127.0.0.1` both become `127.0.0.1`; anything else is null.
79
+ */
80
+ function v4MappedToIpv4(host: string): string | null {
81
+ const m = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(host);
82
+ if (!m) return null;
83
+ const hi = parseInt(m[1], 16);
84
+ const lo = parseInt(m[2], 16);
85
+ return [hi >> 8, hi & 0xff, lo >> 8, lo & 0xff].join('.');
86
+ }
87
+
88
+ /**
89
+ * Is this hostname the developer's own machine?
90
+ *
91
+ * The picker is injected by the developer's Vite dev server, which Vite binds to
92
+ * `0.0.0.0` by default — so the page is routinely opened as
93
+ * `http://0.0.0.0:5173`, `http://192.168.1.5:5173`, or `http://[::1]:5173`
94
+ * rather than `http://localhost:5173`. All of those are still local, so they are
95
+ * accepted alongside `localhost` / `*.localhost` / `127.0.0.1`.
96
+ *
97
+ * Only literal addresses are accepted: a *name* (`dev.internal`, `evil.com`)
98
+ * never passes, which keeps the DNS-rebinding defence intact — that attack needs
99
+ * a name in the Host header pointed at 127.0.0.1.
100
+ */
101
+ function isLocalHostname(hostname: string): boolean {
102
+ const host = hostname.replace(/^\[|\]$/g, '').toLowerCase();
103
+ if (host === 'localhost' || host.endsWith('.localhost')) return true;
104
+ if (host === '::1') return true;
105
+ if (host.startsWith('::ffff:')) {
106
+ return isLocalIpv4(v4MappedToIpv4(host) ?? host.slice('::ffff:'.length));
107
+ }
108
+ return isLocalIpv4(host);
55
109
  }
56
110
 
57
- function isLoopbackHost(host: unknown): boolean {
58
- return typeof host === 'string' && /^(localhost|127\.0\.0\.1)(:\d+)?$/.test(host);
111
+ /** Validate a browser `Origin` header (`scheme://host[:port]`). */
112
+ function isLocalOrigin(origin: unknown): boolean {
113
+ if (typeof origin !== 'string' || !origin) return false;
114
+ let url: URL;
115
+ try {
116
+ url = new URL(origin);
117
+ } catch {
118
+ return false; // `null` (file://, sandboxed iframes) and junk land here
119
+ }
120
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
121
+ return isLocalHostname(url.hostname);
122
+ }
123
+
124
+ /** Validate a raw `Host` header (`host[:port]` or `[ipv6][:port]`). */
125
+ function isLocalHost(host: unknown): boolean {
126
+ if (typeof host !== 'string' || !host) return false;
127
+ const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(host);
128
+ return isLocalHostname(bracketed ? bracketed[1] : host.replace(/:\d+$/, ''));
59
129
  }
60
130
 
61
131
  function isPidAlive(pid: number): boolean {
@@ -297,6 +367,13 @@ export default function (pi: ExtensionAPI): void {
297
367
  res.setHeader('access-control-allow-headers', 'content-type,x-pi-picker-token');
298
368
  res.setHeader('access-control-allow-methods', 'GET,POST,OPTIONS');
299
369
  if (req.method === 'OPTIONS') {
370
+ // Chrome's Private Network Access: a page served from a LAN address is a
371
+ // "private" context while the bridge on 127.0.0.1 is "local", so the
372
+ // browser preflights with this header before any fetch. Grant the
373
+ // permission here — the real origin gate still runs on POST /pick below.
374
+ if (req.headers['access-control-request-private-network'] === 'true') {
375
+ res.setHeader('access-control-allow-private-network', 'true');
376
+ }
300
377
  res.writeHead(204);
301
378
  res.end();
302
379
  return;
@@ -311,9 +388,9 @@ export default function (pi: ExtensionAPI): void {
311
388
  }
312
389
 
313
390
  if (req.method === 'GET' && url === '/status') {
314
- // Read-only and tokenless, so gate it on a loopback browser origin: reject a
391
+ // Read-only and tokenless, so gate it on a local browser origin: reject a
315
392
  // foreign page while still allowing no-Origin callers (curl, local scripts).
316
- if (origin && !isLoopbackOrigin(origin)) {
393
+ if (origin && !isLocalOrigin(origin)) {
317
394
  res.writeHead(403, { 'content-type': 'application/json' });
318
395
  res.end(JSON.stringify({ ok: false, error: 'forbidden origin' }));
319
396
  return;
@@ -330,12 +407,13 @@ export default function (pi: ExtensionAPI): void {
330
407
  }
331
408
 
332
409
  // SECURITY: block cross-site / DNS-rebinding requests from arbitrary pages.
333
- if (!isLoopbackOrigin(origin)) {
410
+ // Local dev servers that are opened via a LAN IPv4 address still count as local.
411
+ if (!isLocalOrigin(origin)) {
334
412
  res.writeHead(403, { 'content-type': 'application/json' });
335
413
  res.end(JSON.stringify({ ok: false, error: 'forbidden origin' }));
336
414
  return;
337
415
  }
338
- if (!isLoopbackHost(req.headers.host)) {
416
+ if (!isLocalHost(req.headers.host)) {
339
417
  res.writeHead(403, { 'content-type': 'application/json' });
340
418
  res.end(JSON.stringify({ ok: false, error: 'forbidden host' }));
341
419
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ele-picker",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Pick an element in your running page, describe the change, and send it to a pi session. Ships a Vite dev plugin (browser picker + discovery endpoint) and the matching pi bridge extension.",
5
5
  "type": "module",
6
6
  "license": "MIT",