feat(ui): replace subcommands with interactive menu
/remote-control now opens a select menu with Turn on/off, Configure URL, and Status instead of relying on subcommands. Adds ability to stop the server. Shows current URL in the Configure URL menu item and in the input dialog title.
This commit is contained in:
parent
ee3341d20c
commit
7080cdc34f
22
README.md
22
README.md
|
|
@ -6,22 +6,12 @@
|
||||||
pi install https://github.com/goofansu/pi-remote-control
|
pi install https://github.com/goofansu/pi-remote-control
|
||||||
```
|
```
|
||||||
|
|
||||||
## Setup
|
## Usage
|
||||||
|
|
||||||
### 1. Configure your public URL
|
Run `/remote-control` to open the menu:
|
||||||
|
|
||||||
Tell the extension the base URL your proxy/tunnel exposes:
|
- **Turn on / Turn off** — start or stop the server
|
||||||
|
- **Configure URL** — set the public base URL your proxy/tunnel exposes (saved to `~/.pi/agent/remote-control.json`)
|
||||||
|
- **Status** — show the QR code and connection URL (only when server is running)
|
||||||
|
|
||||||
```
|
On first use, you'll be prompted to configure the URL before the server starts.
|
||||||
/remote-control config
|
|
||||||
```
|
|
||||||
|
|
||||||
Enter something like `http://pi.myhost` or `https://pi.example.com`. This is saved to `~/.pi/agent/remote-control.json`.
|
|
||||||
|
|
||||||
### 2. Start the server
|
|
||||||
|
|
||||||
```
|
|
||||||
/remote-control
|
|
||||||
```
|
|
||||||
|
|
||||||
This starts the server on a random localhost port, generates an auth token, and displays a QR code + URL. Open the URL on any device.
|
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,10 @@ async function configureRemoteControlUI(ctx: ExtensionContext): Promise<void> {
|
||||||
if (!ctx.hasUI) return;
|
if (!ctx.hasUI) return;
|
||||||
|
|
||||||
const current = (await readRemoteControlConfig()).publicBaseUrl ?? "";
|
const current = (await readRemoteControlConfig()).publicBaseUrl ?? "";
|
||||||
const raw = await ctx.ui.input("Remote-control public base URL", current || "e.g. http://pi.sgponte");
|
const title = current
|
||||||
|
? `Public base URL (current: ${current})`
|
||||||
|
: "Public base URL";
|
||||||
|
const raw = await ctx.ui.input(title, "e.g. http://pi.myhost");
|
||||||
if (raw === undefined) return;
|
if (raw === undefined) return;
|
||||||
|
|
||||||
let value: string;
|
let value: string;
|
||||||
|
|
@ -1215,62 +1218,93 @@ export default function remoteControl(pi: ExtensionAPI) {
|
||||||
|
|
||||||
// ── /remote-control command ───────────────────────────────────────────────
|
// ── /remote-control command ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function showConnectionInfo(ctx: ExtensionContext): Promise<void> {
|
||||||
|
if (!server) return;
|
||||||
|
|
||||||
|
const config = await readRemoteControlConfig();
|
||||||
|
const publicBaseUrl = config.publicBaseUrl?.trim();
|
||||||
|
if (!publicBaseUrl) return;
|
||||||
|
|
||||||
|
const url = buildRemoteControlUrl(publicBaseUrl, server.port, server.token);
|
||||||
|
|
||||||
|
// Generate QR code
|
||||||
|
let qrLines: string[] = [];
|
||||||
|
try {
|
||||||
|
const qr = execFileSync("qrencode", ["-t", "UTF8", "-m", "1", url], {
|
||||||
|
encoding: "utf8",
|
||||||
|
}).trimEnd();
|
||||||
|
qrLines = qr.split("\n");
|
||||||
|
} catch {
|
||||||
|
// qrencode not available
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show in editor area — press any key to dismiss
|
||||||
|
await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
|
||||||
|
const container = new Container();
|
||||||
|
container.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
|
||||||
|
container.addChild(new Text(
|
||||||
|
theme.fg("accent", theme.bold(" Remote-control")) + theme.fg("dim", " (Esc/q/Enter to close)"),
|
||||||
|
1, 0,
|
||||||
|
));
|
||||||
|
container.addChild(new Text("\n" + qrLines.map((l) => ` ${l}`).join("\n") + "\n", 1, 0));
|
||||||
|
container.addChild(new Text(theme.fg("accent", url), 1, 0));
|
||||||
|
container.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
render: (w) => container.render(w),
|
||||||
|
invalidate: () => container.invalidate(),
|
||||||
|
handleInput: (data) => {
|
||||||
|
if (matchesKey(data, Key.escape) || data.toLowerCase() === "q" || matchesKey(data, Key.enter)) done();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
pi.registerCommand("remote-control", {
|
pi.registerCommand("remote-control", {
|
||||||
description: "Start localhost-only remote control server for use behind a port-forwarding proxy",
|
description: "Remote control — start/stop server, configure, show connection info",
|
||||||
handler: async (args, ctx) => {
|
handler: async (args, ctx) => {
|
||||||
if (!ctx.hasUI) return;
|
if (!ctx.hasUI) return;
|
||||||
const subcommand = args.trim().toLowerCase();
|
|
||||||
if (subcommand === "config") {
|
|
||||||
await configureRemoteControlUI(ctx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const isRunning = !!server;
|
||||||
const config = await readRemoteControlConfig();
|
const config = await readRemoteControlConfig();
|
||||||
const publicBaseUrl = config.publicBaseUrl?.trim();
|
const currentUrl = config.publicBaseUrl?.trim();
|
||||||
if (!publicBaseUrl) {
|
|
||||||
ctx.ui.notify("Set the public URL first with /remote-control config", "warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start server on first invocation
|
const configLabel = currentUrl ? `Configure URL (${currentUrl})` : "Configure URL (not set)";
|
||||||
if (!server) {
|
const menuItems = [
|
||||||
|
isRunning ? "Turn off" : "Turn on",
|
||||||
|
configLabel,
|
||||||
|
...(isRunning ? ["Status"] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const choice = await ctx.ui.select("Remote control", menuItems);
|
||||||
|
if (choice === undefined) return;
|
||||||
|
|
||||||
|
if (choice === "Turn on") {
|
||||||
|
const publicBaseUrl = currentUrl;
|
||||||
|
if (!publicBaseUrl) {
|
||||||
|
ctx.ui.notify("Set the public URL first — opening config…", "warning");
|
||||||
|
await configureRemoteControlUI(ctx);
|
||||||
|
// Re-check after config
|
||||||
|
const updated = await readRemoteControlConfig();
|
||||||
|
if (!updated.publicBaseUrl?.trim()) return;
|
||||||
|
}
|
||||||
server = await startServer(pi, ctx);
|
server = await startServer(pi, ctx);
|
||||||
server.onClientChange(() => updateStatus(ctx));
|
server.onClientChange(() => updateStatus(ctx));
|
||||||
updateStatus(ctx);
|
updateStatus(ctx);
|
||||||
|
ctx.ui.notify("Remote-control server started", "info");
|
||||||
|
await showConnectionInfo(ctx);
|
||||||
|
} else if (choice === "Turn off") {
|
||||||
|
if (server) {
|
||||||
|
await server.stop();
|
||||||
|
server = undefined;
|
||||||
|
ctx.ui.setStatus("remote-control", undefined);
|
||||||
|
ctx.ui.notify("Remote-control server stopped", "info");
|
||||||
|
}
|
||||||
|
} else if (choice === configLabel) {
|
||||||
|
await configureRemoteControlUI(ctx);
|
||||||
|
} else if (choice === "Status") {
|
||||||
|
await showConnectionInfo(ctx);
|
||||||
}
|
}
|
||||||
const url = buildRemoteControlUrl(publicBaseUrl, server.port, server.token);
|
|
||||||
|
|
||||||
// Generate QR code
|
|
||||||
let qrLines: string[] = [];
|
|
||||||
try {
|
|
||||||
const qr = execFileSync("qrencode", ["-t", "UTF8", "-m", "1", url], {
|
|
||||||
encoding: "utf8",
|
|
||||||
}).trimEnd();
|
|
||||||
qrLines = qr.split("\n");
|
|
||||||
} catch {
|
|
||||||
// qrencode not available
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show in editor area — press any key to dismiss
|
|
||||||
await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
|
|
||||||
const container = new Container();
|
|
||||||
container.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
|
|
||||||
container.addChild(new Text(
|
|
||||||
theme.fg("accent", theme.bold(" Remote-control")) + theme.fg("dim", " (Esc/q/Enter to close)"),
|
|
||||||
1, 0,
|
|
||||||
));
|
|
||||||
container.addChild(new Text("\n" + qrLines.map((l) => ` ${l}`).join("\n") + "\n", 1, 0));
|
|
||||||
container.addChild(new Text(theme.fg("accent", url), 1, 0));
|
|
||||||
container.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
|
|
||||||
|
|
||||||
return {
|
|
||||||
render: (w) => container.render(w),
|
|
||||||
invalidate: () => container.invalidate(),
|
|
||||||
handleInput: (data) => {
|
|
||||||
if (matchesKey(data, Key.escape) || data.toLowerCase() === "q" || matchesKey(data, Key.enter)) done();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue