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:
parent
48810a5456
commit
1b610013c3
|
|
@ -10,10 +10,22 @@ import os from "node:os";
|
|||
import path from "node:path";
|
||||
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 {
|
||||
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 {
|
||||
|
|
@ -40,7 +52,7 @@ function getAgentDir(): 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> {
|
||||
|
|
@ -82,7 +94,8 @@ export function buildRemoteControlUrl(
|
|||
token: string,
|
||||
): string {
|
||||
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.searchParams.set("token", token);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ export function buildHTML(nonce: string): string {
|
|||
<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">
|
||||
<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}">
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export default function remoteControl(pi: ExtensionAPI) {
|
|||
if (pi.getFlag("remote-control") !== true) return;
|
||||
|
||||
const config = await readRemoteControlConfig();
|
||||
const publicBaseUrl = config.publicBaseUrl?.trim();
|
||||
const publicBaseUrl = (config.publicBaseUrl ?? config.advertisedBaseUrl)?.trim();
|
||||
if (!publicBaseUrl) {
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.notify(
|
||||
|
|
@ -178,7 +178,7 @@ export default function remoteControl(pi: ExtensionAPI) {
|
|||
if (!server) return;
|
||||
|
||||
const config = await readRemoteControlConfig();
|
||||
const publicBaseUrl = config.publicBaseUrl?.trim();
|
||||
const publicBaseUrl = (config.publicBaseUrl ?? config.advertisedBaseUrl)?.trim();
|
||||
if (!publicBaseUrl) return;
|
||||
|
||||
const url = buildRemoteControlUrl(publicBaseUrl, server.port, server.token);
|
||||
|
|
@ -235,7 +235,7 @@ export default function remoteControl(pi: ExtensionAPI) {
|
|||
|
||||
const isRunning = !!server;
|
||||
const config = await readRemoteControlConfig();
|
||||
const currentUrl = config.publicBaseUrl?.trim();
|
||||
const currentUrl = (config.publicBaseUrl ?? config.advertisedBaseUrl)?.trim();
|
||||
|
||||
const configLabel = currentUrl
|
||||
? `Configure URL (${currentUrl})`
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
SESSION_COOKIE,
|
||||
validateToken,
|
||||
} from "./auth.js";
|
||||
import { parseBindAddress, readRemoteControlConfig } from "./config.js";
|
||||
import { buildHTML } from "./html.js";
|
||||
import { buildSyncMessage } from "./messages.js";
|
||||
|
||||
|
|
@ -63,10 +64,15 @@ export interface RemoteServer {
|
|||
token: string;
|
||||
}
|
||||
|
||||
export function startServer(
|
||||
export async function startServer(
|
||||
pi: ExtensionAPI,
|
||||
ctx: ExtensionContext,
|
||||
): 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 clients = new Set<WsClient>();
|
||||
const token = generateToken();
|
||||
|
|
@ -117,6 +123,27 @@ export function startServer(
|
|||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
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") {
|
||||
// Check authentication
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
|
|
@ -155,7 +182,7 @@ export function startServer(
|
|||
"X-Frame-Options": "DENY",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"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));
|
||||
} else {
|
||||
|
|
@ -258,7 +285,7 @@ export function startServer(
|
|||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
httpServer.listen(0, "127.0.0.1", () => {
|
||||
httpServer.listen(bindPort, bindHost, () => {
|
||||
resolve({
|
||||
broadcast,
|
||||
sync,
|
||||
|
|
|
|||
Loading…
Reference in New Issue