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 +3 -1
- package/client/picker.js +50 -6
- package/extensions/pi-picker.ts +90 -12
- package/package.json +1 -1
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-
|
|
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
|
|
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
|
-
|
|
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;
|
package/extensions/pi-picker.ts
CHANGED
|
@@ -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:
|
|
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
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
|
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 && !
|
|
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
|
-
|
|
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 (!
|
|
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
|
+
"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",
|