feat: bindAddress from config + PWA support

- config: read from ~/.pi/remote-control/config.json (zerray-compatible path)
- config: add bindAddress + advertisedBaseUrl fields
- server: listen on host/port from bindAddress (default: 127.0.0.1:random)
- server: /manifest.json + /icon.svg routes (no auth, PWA)
- server: manifest-src 'self' in CSP
- html: apple-mobile-web-app meta tags + manifest/touch-icon links
- index: advertisedBaseUrl as fallback for publicBaseUrl
This commit is contained in:
jay 2026-05-14 18:51:54 +02:00
parent 48810a5456
commit 1b610013c3
4 changed files with 54 additions and 9 deletions

View File

@ -10,10 +10,22 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
const REMOTE_CONTROL_CONFIG_FILE = "remote-control.json"; const REMOTE_CONTROL_CONFIG_FILE = "config.json";
export interface RemoteControlConfig { export interface RemoteControlConfig {
publicBaseUrl?: string; publicBaseUrl?: string;
advertisedBaseUrl?: string;
bindAddress?: string;
}
/** Parse host and port from a "host:port" bindAddress string. */
export function parseBindAddress(bindAddress: string): { host: string; port: number } {
const idx = bindAddress.lastIndexOf(":");
if (idx === -1) return { host: bindAddress, port: 0 };
return {
host: bindAddress.slice(0, idx) || "127.0.0.1",
port: Number.parseInt(bindAddress.slice(idx + 1), 10) || 0,
};
} }
function getAgentDir(): string { function getAgentDir(): string {
@ -40,7 +52,7 @@ function getAgentDir(): string {
} }
function getRemoteControlConfigPath(): string { function getRemoteControlConfigPath(): string {
return path.join(getAgentDir(), REMOTE_CONTROL_CONFIG_FILE); return path.join(os.homedir(), ".pi", "remote-control", REMOTE_CONTROL_CONFIG_FILE);
} }
export async function readRemoteControlConfig(): Promise<RemoteControlConfig> { export async function readRemoteControlConfig(): Promise<RemoteControlConfig> {
@ -82,7 +94,8 @@ export function buildRemoteControlUrl(
token: string, token: string,
): string { ): string {
const parsed = new URL(normalizePublicBaseUrl(publicBaseUrl)); const parsed = new URL(normalizePublicBaseUrl(publicBaseUrl));
if (parsed.protocol === "http:") { // Only override port when using http: and no fixed port was configured
if (parsed.protocol === "http:" && !parsed.port) {
parsed.port = String(port); parsed.port = String(port);
} }
parsed.searchParams.set("token", token); parsed.searchParams.set("token", token);

View File

@ -12,6 +12,11 @@ export function buildHTML(nonce: string): string {
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, interactive-widget=resizes-content"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, interactive-widget=resizes-content">
<title>π - remote-control</title> <title>π - remote-control</title>
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Pi">
<link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" href="/icon.svg">
<style nonce="${nonce}"> <style nonce="${nonce}">
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

View File

@ -72,7 +72,7 @@ export default function remoteControl(pi: ExtensionAPI) {
if (pi.getFlag("remote-control") !== true) return; if (pi.getFlag("remote-control") !== true) return;
const config = await readRemoteControlConfig(); const config = await readRemoteControlConfig();
const publicBaseUrl = config.publicBaseUrl?.trim(); const publicBaseUrl = (config.publicBaseUrl ?? config.advertisedBaseUrl)?.trim();
if (!publicBaseUrl) { if (!publicBaseUrl) {
if (ctx.hasUI) { if (ctx.hasUI) {
ctx.ui.notify( ctx.ui.notify(
@ -178,7 +178,7 @@ export default function remoteControl(pi: ExtensionAPI) {
if (!server) return; if (!server) return;
const config = await readRemoteControlConfig(); const config = await readRemoteControlConfig();
const publicBaseUrl = config.publicBaseUrl?.trim(); const publicBaseUrl = (config.publicBaseUrl ?? config.advertisedBaseUrl)?.trim();
if (!publicBaseUrl) return; if (!publicBaseUrl) return;
const url = buildRemoteControlUrl(publicBaseUrl, server.port, server.token); const url = buildRemoteControlUrl(publicBaseUrl, server.port, server.token);
@ -235,7 +235,7 @@ export default function remoteControl(pi: ExtensionAPI) {
const isRunning = !!server; const isRunning = !!server;
const config = await readRemoteControlConfig(); const config = await readRemoteControlConfig();
const currentUrl = config.publicBaseUrl?.trim(); const currentUrl = (config.publicBaseUrl ?? config.advertisedBaseUrl)?.trim();
const configLabel = currentUrl const configLabel = currentUrl
? `Configure URL (${currentUrl})` ? `Configure URL (${currentUrl})`

View File

@ -21,6 +21,7 @@ import {
SESSION_COOKIE, SESSION_COOKIE,
validateToken, validateToken,
} from "./auth.js"; } from "./auth.js";
import { parseBindAddress, readRemoteControlConfig } from "./config.js";
import { buildHTML } from "./html.js"; import { buildHTML } from "./html.js";
import { buildSyncMessage } from "./messages.js"; import { buildSyncMessage } from "./messages.js";
@ -63,10 +64,15 @@ export interface RemoteServer {
token: string; token: string;
} }
export function startServer( export async function startServer(
pi: ExtensionAPI, pi: ExtensionAPI,
ctx: ExtensionContext, ctx: ExtensionContext,
): Promise<RemoteServer> { ): Promise<RemoteServer> {
const config = await readRemoteControlConfig();
const bindAddr = config.bindAddress ?? "";
const { host: bindHost, port: bindPort } = bindAddr
? parseBindAddress(bindAddr)
: { host: "127.0.0.1", port: 0 };
const clientChangeListeners: Array<() => void> = []; const clientChangeListeners: Array<() => void> = [];
const clients = new Set<WsClient>(); const clients = new Set<WsClient>();
const token = generateToken(); const token = generateToken();
@ -117,6 +123,27 @@ export function startServer(
const url = new URL(req.url ?? "/", "http://localhost"); const url = new URL(req.url ?? "/", "http://localhost");
const pathname = url.pathname; const pathname = url.pathname;
if (pathname === "/manifest.json") {
res.writeHead(200, { "Content-Type": "application/manifest+json; charset=utf-8" });
res.end(JSON.stringify({
name: "Pi Remote",
short_name: "Pi",
description: "Remote control for Pi sessions",
start_url: "/",
display: "standalone",
background_color: "#0d1117",
theme_color: "#0d1117",
icons: [{ src: "/icon.svg", sizes: "any", type: "image/svg+xml" }],
}));
return;
}
if (pathname === "/icon.svg") {
res.writeHead(200, { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=86400" });
res.end(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180"><rect width="180" height="180" rx="40" fill="#0d1117"/><text x="90" y="133" font-family="-apple-system,Helvetica,Arial,sans-serif" font-size="110" text-anchor="middle" fill="#3fb950">π</text></svg>`);
return;
}
if (pathname === "/" || pathname === "/index.html") { if (pathname === "/" || pathname === "/index.html") {
// Check authentication // Check authentication
const cookies = parseCookies(req.headers.cookie); const cookies = parseCookies(req.headers.cookie);
@ -155,7 +182,7 @@ export function startServer(
"X-Frame-Options": "DENY", "X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff", "X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer", "Referrer-Policy": "no-referrer",
"Content-Security-Policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'`, "Content-Security-Policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; manifest-src 'self'; base-uri 'none'`,
}); });
res.end(buildHTML(nonce)); res.end(buildHTML(nonce));
} else { } else {
@ -258,7 +285,7 @@ export function startServer(
}); });
return new Promise((resolve) => { return new Promise((resolve) => {
httpServer.listen(0, "127.0.0.1", () => { httpServer.listen(bindPort, bindHost, () => {
resolve({ resolve({
broadcast, broadcast,
sync, sync,