@firstpick/pi-package-remote-webui 0.1.0 → 0.1.1

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
@@ -4,7 +4,7 @@ Mobile connection helper for [Pi coding agent](https://www.npmjs.com/package/@ea
4
4
 
5
5
  This package adds a `/remote` slash command that reuses the existing `@firstpick/pi-package-webui` server/UI, opens it to a trusted local network, and shows a QR code in Pi so a phone can connect quickly.
6
6
 
7
- > **Security:** Pi Web UI can control the Web UI/Pi session. Remote PIN authentication is off by default; enable it in Web UI **Controls → Network → Remote PIN auth** if you want a 4-digit PIN for non-local clients. Use `/remote` only on trusted local networks and close LAN access when done.
7
+ > **Security:** Pi Web UI can control the Web UI/Pi session. `/remote` asks whether to activate Remote PIN authentication before showing the QR code. Use `/remote` only on trusted local networks and close LAN access when done.
8
8
 
9
9
  ## Install
10
10
 
@@ -23,9 +23,10 @@ Restart Pi after installation so the `/remote` command is loaded. The QR rendere
23
23
  Default behavior:
24
24
 
25
25
  1. Reuse a running Pi Web UI on `127.0.0.1:31415`, or start one for the current working directory.
26
- 2. Open the Web UI listener to the local network through the existing Web UI `/api/network/open` endpoint.
27
- 3. Show a terminal QR code, the LAN URL, and the current Remote PIN auth state.
28
- 4. Scan the QR code from your phone and use the normal Pi Web UI. If Remote PIN auth is enabled, enter the displayed 4-digit PIN on the phone.
26
+ 2. Ask whether to activate Remote PIN auth when it is currently off.
27
+ 3. Open the Web UI listener to the local network through the existing Web UI `/api/network/open` endpoint.
28
+ 4. Show a terminal QR code, the LAN URL, and the current Remote PIN auth state.
29
+ 5. Scan the QR code from your phone and use the normal Pi Web UI. If Remote PIN auth is enabled and the local server reports the PIN, the QR code opens an auth link that signs in automatically; the displayed PIN remains a manual fallback.
29
30
 
30
31
  ## Commands
31
32
 
@@ -41,24 +42,25 @@ Default behavior:
41
42
 
42
43
  | Command | Behavior |
43
44
  |---|---|
44
- | `/remote` | Start/reuse Web UI, confirm, open LAN access, and show QR plus Remote PIN auth state. |
45
+ | `/remote` | Start/reuse Web UI, confirm LAN access, ask whether to activate Remote PIN auth, open LAN access, and show QR plus auth state. |
45
46
  | `/remote status` | Show Web UI online/network state, LAN URLs, and auth state. |
46
47
  | `/remote refresh` | Re-read current LAN URL/auth state and redraw the QR widget. |
47
48
  | `/remote close` | Close Web UI LAN exposure and clear the QR widget. |
48
49
  | `/remote --port 31500` | Use another Web UI port. |
49
50
  | `/remote --name mobile` | Name the initial Web UI tab when this package starts the server. |
50
- | `/remote --yes` | Skip the LAN exposure confirmation. |
51
+ | `/remote --yes` | Skip prompts and activate Remote PIN auth automatically before opening LAN access. |
51
52
 
52
53
  ## Remote PIN auth
53
54
 
54
- `/remote` does not enable Remote PIN auth by itself. Auth is intentionally controlled by the Web UI server:
55
+ `/remote` checks the Web UI server's auth state before opening LAN access:
55
56
 
56
- - In the local Web UI, open **Controls Network Remote PIN auth**.
57
+ - If Remote PIN auth is off, `/remote` asks whether to activate it.
58
+ - `/remote --yes` treats the auth prompt as accepted and activates it automatically.
57
59
  - Enabling it generates a random 4-digit PIN.
58
- - Non-local browser clients must enter that PIN before reaching Web UI.
60
+ - Non-local browser clients must authenticate before reaching Web UI.
59
61
  - Localhost clients can always use the UI and toggle the setting.
60
62
 
61
- The `/remote` QR widget shows `Remote PIN auth: off` or `Remote PIN auth: on · PIN 1234` when the Web UI server reports it. You can also start Web UI with auth already enabled by using `pi-webui --remote-auth` or `/webui-start --remote-auth` from `@firstpick/pi-package-webui`.
63
+ The `/remote` QR widget shows `Remote PIN auth: off` or `Remote PIN auth: on · PIN 1234` when the Web UI server reports it. When a PIN is available, the QR code targets `/remote-auth#pin=1234` so the phone can authenticate automatically without typing the PIN; the fragment is scrubbed by the auth page before it posts to the server. You can also start Web UI with auth already enabled by using `pi-webui --remote-auth` or `/webui-start --remote-auth` from `@firstpick/pi-package-webui`.
62
64
 
63
65
  ## Caveat
64
66
 
package/index.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  generateQrLines,
15
15
  openRemoteWebui,
16
16
  parseRemoteArgs,
17
+ remoteAuthQrUrl,
17
18
  requiresOpenConfirmation,
18
19
  usage,
19
20
  } from "./lib/remote-core.mjs";
@@ -25,13 +26,21 @@ const LOCAL_HOST = "127.0.0.1";
25
26
 
26
27
  const OPEN_WARNING = [
27
28
  "Pi Web UI can control Pi/WebUI and run allowed tools from connected browsers.",
28
- "Remote PIN auth is off by default; enable it in Web UI Controls if you want a 4-digit PIN for non-local clients.",
29
+ "You will be asked whether to activate Remote PIN auth before the QR code is shown.",
29
30
  "",
30
31
  "Only open this on a trusted local network.",
31
32
  "",
32
33
  "Open to local network?",
33
34
  ].join("\n");
34
35
 
36
+ const AUTH_WARNING = [
37
+ "Remote PIN auth is currently off.",
38
+ "",
39
+ "Activate it now? /remote will embed the generated PIN in the QR code so your phone can sign in without typing it.",
40
+ "",
41
+ "Choose No only on a trusted LAN where anyone with the URL may control Pi/WebUI.",
42
+ ].join("\n");
43
+
35
44
  type WebuiChild = ChildProcessByStdio<null, Readable, Readable>;
36
45
 
37
46
  type RemoteOptions = {
@@ -41,6 +50,11 @@ type RemoteOptions = {
41
50
  yes: boolean;
42
51
  };
43
52
 
53
+ type RemoteNetworkStatus = {
54
+ auth?: { enabled?: boolean; pin?: string };
55
+ [key: string]: unknown;
56
+ };
57
+
44
58
  function resolveExistingPath(candidate: string | undefined): string | undefined {
45
59
  if (!candidate) return undefined;
46
60
  return existsSync(candidate) ? candidate : undefined;
@@ -174,8 +188,9 @@ function setFullRemoteWidget(ctx: ExtensionCommandContext, lines: string[]): voi
174
188
  }
175
189
 
176
190
  async function renderRemoteWidget(ctx: ExtensionCommandContext, result: { url: string; network?: unknown; started?: boolean }): Promise<void> {
177
- const qrLines = await generateQrLines(result.url);
178
- const lines = buildRemoteWidgetLines({ url: result.url, qrLines, network: result.network, started: result.started });
191
+ const qrUrl = remoteAuthQrUrl(result.url, result.network || {});
192
+ const qrLines = await generateQrLines(qrUrl);
193
+ const lines = buildRemoteWidgetLines({ url: result.url, qrUrl, qrLines, network: result.network, started: result.started });
179
194
  setFullRemoteWidget(ctx, lines);
180
195
  setRemoteStatus(ctx, `remote ${result.url}`);
181
196
  }
@@ -186,6 +201,32 @@ async function confirmRemoteOpen(options: RemoteOptions, ctx: ExtensionCommandCo
186
201
  return await ctx.ui.confirm("Open Pi Web UI to LAN?", OPEN_WARNING);
187
202
  }
188
203
 
204
+ function remoteAuthEnabled(network: unknown): boolean {
205
+ return (network as RemoteNetworkStatus | undefined)?.auth?.enabled === true;
206
+ }
207
+
208
+ function networkFromAuthUpdate(previous: unknown, data: unknown): unknown {
209
+ const update = data as { network?: unknown; auth?: unknown } | undefined;
210
+ if (update?.network) return update.network;
211
+ if (update?.auth && previous && typeof previous === "object") return { ...(previous as RemoteNetworkStatus), auth: update.auth };
212
+ return previous;
213
+ }
214
+
215
+ async function maybeActivateRemoteAuth(options: RemoteOptions, ctx: ExtensionCommandContext, controller: RemoteWebuiController, network: unknown): Promise<unknown> {
216
+ if (remoteAuthEnabled(network)) return network;
217
+
218
+ let activate = options.yes === true;
219
+ if (!activate) {
220
+ if (!ctx.hasUI) return network;
221
+ activate = await ctx.ui.confirm("Activate Remote PIN auth?", AUTH_WARNING);
222
+ }
223
+ if (!activate) return network;
224
+
225
+ setRemoteStatus(ctx, "enabling remote PIN auth…");
226
+ const data = await controller.setRemoteAuth(options.port, true);
227
+ return networkFromAuthUpdate(network, data);
228
+ }
229
+
189
230
  async function handleStatus(options: RemoteOptions, ctx: ExtensionCommandContext, controller: RemoteWebuiController): Promise<void> {
190
231
  setRemoteStatus(ctx, "checking remote webui…");
191
232
  try {
@@ -243,6 +284,7 @@ async function handleOpen(options: RemoteOptions, ctx: ExtensionCommandContext,
243
284
  const result = await openRemoteWebui(options, {
244
285
  controller,
245
286
  startWebui: async (startOptions: RemoteOptions) => spawnWebui(startOptions, ctx),
287
+ prepareNetwork: async (network: unknown) => maybeActivateRemoteAuth(options, ctx, controller, network),
246
288
  });
247
289
  await renderRemoteWidget(ctx, result);
248
290
  ctx.ui.notify(`Pi Remote WebUI ready:\n${result.url}\n\nScan the QR code above from your phone.`, "info");
@@ -201,6 +201,16 @@ export class RemoteWebuiController {
201
201
  throw new Error(result.body?.error || "Failed to close Pi Web UI network access");
202
202
  }
203
203
 
204
+ async setRemoteAuth(port, enabled, timeoutMs = 1_500) {
205
+ const result = await fetchJsonWithTimeout(endpointUrl(port, "/api/remote-auth/settings"), {
206
+ method: "POST",
207
+ headers: { "content-type": "application/json" },
208
+ body: JSON.stringify({ enabled: enabled === true }),
209
+ }, timeoutMs, this.fetchImpl);
210
+ if (result.ok && result.body?.ok === true) return result.body.data;
211
+ throw new Error(result.body?.error || "Failed to update Pi Web UI Remote PIN auth");
212
+ }
213
+
204
214
  async waitForNetworkOpen(port, { timeoutMs = DEFAULT_NETWORK_TIMEOUT_MS, pollMs = DEFAULT_POLL_MS } = {}) {
205
215
  const deadline = Date.now() + timeoutMs;
206
216
  let last;
@@ -235,7 +245,31 @@ export function selectLanUrl(network) {
235
245
  return urls.find((url) => typeof url === "string" && /^https?:\/\//i.test(url)) || undefined;
236
246
  }
237
247
 
238
- export async function openRemoteWebui(options, { controller, startWebui }) {
248
+ function safeQrReturnPath(value = "/") {
249
+ const text = String(value || "/").trim();
250
+ if (!text.startsWith("/") || text.startsWith("//")) return "/";
251
+ return text;
252
+ }
253
+
254
+ export function remoteAuthQrUrl(url, network = {}) {
255
+ if (typeof url !== "string" || !url) return url;
256
+ const pin = String(network?.auth?.pin || "").trim();
257
+ if (!network?.auth?.enabled || !/^\d{4}$/.test(pin)) return url;
258
+
259
+ try {
260
+ const parsed = new URL(url);
261
+ const returnPath = safeQrReturnPath(`${parsed.pathname || "/"}${parsed.search || ""}`);
262
+ parsed.pathname = "/remote-auth";
263
+ parsed.search = "";
264
+ parsed.searchParams.set("return", returnPath);
265
+ parsed.hash = new URLSearchParams({ pin }).toString();
266
+ return parsed.toString();
267
+ } catch {
268
+ return url;
269
+ }
270
+ }
271
+
272
+ export async function openRemoteWebui(options, { controller, startWebui, prepareNetwork } = {}) {
239
273
  if (!controller) throw new Error("RemoteWebuiController is required");
240
274
  let health = await controller.probeHealth(options.port);
241
275
  let started = false;
@@ -254,6 +288,10 @@ export async function openRemoteWebui(options, { controller, startWebui }) {
254
288
  network = health.data?.network;
255
289
  }
256
290
 
291
+ if (typeof prepareNetwork === "function") {
292
+ network = (await prepareNetwork(network, { health, started })) || network;
293
+ }
294
+
257
295
  if (!network?.open || network?.closing) {
258
296
  await controller.openNetwork(options.port);
259
297
  network = await controller.waitForNetworkOpen(options.port);
@@ -299,25 +337,32 @@ export function formatStatus(status) {
299
337
  `Bind: ${network.host || "unknown"}:${network.port || "?"}`,
300
338
  ];
301
339
  if (networkUrls.length) lines.push(`LAN URLs: ${networkUrls.join(", ")}`);
340
+ const auth = network.auth || {};
341
+ lines.push(auth.enabled ? `Remote PIN auth: on${auth.pin ? ` · PIN ${auth.pin}` : ""}` : "Remote PIN auth: off");
302
342
  if (status.health?.data?.webuiVersion) lines.push(`Version: ${status.health.data.webuiVersion}`);
303
343
  if (status.error) lines.push(`Warning: ${status.error}`);
304
344
  return lines.join("\n");
305
345
  }
306
346
 
307
- export function buildRemoteWidgetLines({ url, qrLines = [], network = {}, started = false } = {}) {
347
+ export function buildRemoteWidgetLines({ url, qrUrl, qrLines = [], network = {}, started = false } = {}) {
308
348
  const auth = network?.auth || {};
349
+ const displayUrl = url || selectLanUrl(network) || network.localUrl || "(no URL)";
350
+ const qrTarget = qrUrl || remoteAuthQrUrl(displayUrl, network);
351
+ const hasAutoAuthQr = auth.enabled && !!auth.pin && qrTarget !== displayUrl;
309
352
  const authLine = auth.enabled ? `Remote PIN auth: on${auth.pin ? ` · PIN ${auth.pin}` : ""}` : "Remote PIN auth: off";
310
- const warningLine = auth.enabled
311
- ? "Trusted LAN only. Anyone with this URL and PIN can control Pi/WebUI."
312
- : "Trusted LAN only. Remote PIN auth is off; anyone with this URL can control Pi/WebUI.";
353
+ const warningLine = hasAutoAuthQr
354
+ ? "Trusted LAN only. The QR signs in with the embedded PIN; keep it private."
355
+ : auth.enabled
356
+ ? "Trusted LAN only. Anyone with this URL and PIN can control Pi/WebUI."
357
+ : "Trusted LAN only. Remote PIN auth is off; anyone with this URL can control Pi/WebUI.";
313
358
  const lines = [
314
359
  "Pi Remote WebUI",
315
360
  "",
316
- "Scan with your phone:",
361
+ hasAutoAuthQr ? "Scan with your phone (auto-auth QR):" : "Scan with your phone:",
317
362
  "",
318
363
  ...qrLines,
319
364
  "",
320
- url || selectLanUrl(network) || network.localUrl || "(no URL)",
365
+ displayUrl,
321
366
  authLine,
322
367
  "",
323
368
  warningLine,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-package-remote-webui",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Pi /remote command that opens the existing Pi Web UI to a trusted LAN and shows a QR code for mobile.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/Firstp1ck/npm-packages/tree/main/pi-package-remote-webui#readme",
@@ -1,11 +1,13 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
+ import { readFile } from "node:fs/promises";
3
4
  import {
4
5
  DEFAULT_PORT,
5
6
  buildRemoteWidgetLines,
6
7
  formatStatus,
7
8
  generateQrLines,
8
9
  parseRemoteArgs,
10
+ remoteAuthQrUrl,
9
11
  requiresOpenConfirmation,
10
12
  selectLanUrl,
11
13
  tokenizeArgs,
@@ -60,19 +62,33 @@ test("selectLanUrl chooses the first HTTP LAN URL", () => {
60
62
  );
61
63
  });
62
64
 
63
- test("formatStatus renders offline and online states", () => {
64
- assert.match(formatStatus({ online: false, url: "http://127.0.0.1:31415/", health: { error: "offline" } }), /Online:\s+no/);
65
- assert.match(
66
- formatStatus({
67
- online: true,
68
- url: "http://192.168.1.20:31415/",
69
- health: { data: { webuiVersion: "0.3.8" } },
70
- network: { open: true, host: "0.0.0.0", port: 31415, networkUrls: ["http://192.168.1.20:31415/"] },
71
- }),
72
- /open to LAN/,
65
+ test("remoteAuthQrUrl embeds an available remote PIN in a URL fragment auth link", () => {
66
+ assert.equal(
67
+ remoteAuthQrUrl("http://192.168.1.20:31415/", { auth: { enabled: true, pin: "1234" } }),
68
+ "http://192.168.1.20:31415/remote-auth?return=%2F#pin=1234",
69
+ );
70
+ assert.equal(
71
+ remoteAuthQrUrl("http://192.168.1.20:31415/tree?tab=abc", { auth: { enabled: true, pin: "1234" } }),
72
+ "http://192.168.1.20:31415/remote-auth?return=%2Ftree%3Ftab%3Dabc#pin=1234",
73
+ );
74
+ assert.equal(
75
+ remoteAuthQrUrl("http://192.168.1.20:31415/", { auth: { enabled: true } }),
76
+ "http://192.168.1.20:31415/",
73
77
  );
74
78
  });
75
79
 
80
+ test("formatStatus renders offline, online, and auth states", () => {
81
+ assert.match(formatStatus({ online: false, url: "http://127.0.0.1:31415/", health: { error: "offline" } }), /Online:\s+no/);
82
+ const onlineStatus = formatStatus({
83
+ online: true,
84
+ url: "http://192.168.1.20:31415/",
85
+ health: { data: { webuiVersion: "0.3.8" } },
86
+ network: { open: true, host: "0.0.0.0", port: 31415, networkUrls: ["http://192.168.1.20:31415/"], auth: { enabled: true, pin: "1234" } },
87
+ });
88
+ assert.match(onlineStatus, /open to LAN/);
89
+ assert.match(onlineStatus, /Remote PIN auth: on · PIN 1234/);
90
+ });
91
+
76
92
  test("buildRemoteWidgetLines includes QR, URL, auth state, and close instruction", () => {
77
93
  const lines = buildRemoteWidgetLines({
78
94
  url: "http://192.168.1.20:31415/",
@@ -83,6 +99,7 @@ test("buildRemoteWidgetLines includes QR, URL, auth state, and close instruction
83
99
  assert(lines.includes("QR-A"));
84
100
  assert(lines.includes("http://192.168.1.20:31415/"));
85
101
  assert(lines.some((line) => line.includes("PIN 1234")));
102
+ assert(lines.some((line) => line.includes("QR signs in")));
86
103
  assert(lines.some((line) => line.includes("/remote close")));
87
104
  assert(lines.some((line) => line.includes("Started a Pi Web UI server")));
88
105
  });
@@ -110,3 +127,10 @@ test("generateQrLines reports QR failures without duplicating the URL", async ()
110
127
  });
111
128
  assert.deepEqual(lines, ["[QR generation failed: boom]"]);
112
129
  });
130
+
131
+ test("extension asks whether to activate Remote PIN auth while opening /remote", async () => {
132
+ const source = await readFile(new URL("../index.ts", import.meta.url), "utf8");
133
+ assert.match(source, /ctx\.ui\.confirm\("Activate Remote PIN auth\?", AUTH_WARNING\)/);
134
+ assert.match(source, /controller\.setRemoteAuth\(options\.port, true\)/);
135
+ assert.match(source, /prepareNetwork: async \(network: unknown\) => maybeActivateRemoteAuth/);
136
+ });
@@ -75,6 +75,77 @@ test("openRemoteWebui starts when offline, opens network, and returns a LAN URL"
75
75
  });
76
76
  });
77
77
 
78
+ test("openRemoteWebui can prepare network settings before opening LAN access", async () => {
79
+ const calls = [];
80
+ let networkOpen = false;
81
+ let authEnabled = false;
82
+
83
+ await withMockWebui((req, res) => {
84
+ calls.push(`${req.method} ${req.url}`);
85
+ if (req.url === "/api/health" && req.method === "GET") return sendJson(res, 200, { ok: true, webuiVersion: "0.3.8" });
86
+ if (req.url === "/api/network" && req.method === "GET") {
87
+ return sendJson(res, 200, {
88
+ ok: true,
89
+ data: {
90
+ open: networkOpen,
91
+ opening: false,
92
+ closing: false,
93
+ host: networkOpen ? "0.0.0.0" : "127.0.0.1",
94
+ port: 0,
95
+ localUrl: "http://127.0.0.1/",
96
+ networkUrls: networkOpen ? ["http://10.0.0.8:31415/"] : [],
97
+ auth: authEnabled ? { enabled: true, pin: "1234" } : { enabled: false },
98
+ },
99
+ });
100
+ }
101
+ if (req.url === "/api/remote-auth/settings" && req.method === "POST") {
102
+ authEnabled = true;
103
+ return sendJson(res, 200, {
104
+ ok: true,
105
+ data: {
106
+ network: {
107
+ open: networkOpen,
108
+ opening: false,
109
+ closing: false,
110
+ host: networkOpen ? "0.0.0.0" : "127.0.0.1",
111
+ port: 0,
112
+ localUrl: "http://127.0.0.1/",
113
+ networkUrls: networkOpen ? ["http://10.0.0.8:31415/"] : [],
114
+ auth: { enabled: true, pin: "1234" },
115
+ },
116
+ },
117
+ });
118
+ }
119
+ if (req.url === "/api/network/open" && req.method === "POST") {
120
+ networkOpen = true;
121
+ return sendJson(res, 202, { ok: true, data: { opening: true } });
122
+ }
123
+ sendJson(res, 404, { ok: false });
124
+ }, async (port) => {
125
+ const controller = new RemoteWebuiController({ sleepImpl: () => Promise.resolve() });
126
+ const result = await openRemoteWebui({ port }, {
127
+ controller,
128
+ startWebui: async () => {},
129
+ prepareNetwork: async (network) => {
130
+ calls.push(`prepare auth=${network.auth.enabled}`);
131
+ const data = await controller.setRemoteAuth(port, true);
132
+ return data.network;
133
+ },
134
+ });
135
+
136
+ assert.equal(result.url, "http://10.0.0.8:31415/");
137
+ assert.equal(result.network.auth.pin, "1234");
138
+ assert.deepEqual(calls, [
139
+ "GET /api/health",
140
+ "GET /api/network",
141
+ "prepare auth=false",
142
+ "POST /api/remote-auth/settings",
143
+ "POST /api/network/open",
144
+ "GET /api/network",
145
+ ]);
146
+ });
147
+ });
148
+
78
149
  test("openRemoteWebui reuses an already open WebUI", async () => {
79
150
  let startCalled = false;
80
151