diff --git a/fedac/native/pieces/prompt.mjs b/fedac/native/pieces/prompt.mjs index 2257b3bdc1..53e3d80f6a 100644 --- a/fedac/native/pieces/prompt.mjs +++ b/fedac/native/pieces/prompt.mjs @@ -197,6 +197,27 @@ function boot({ system }) { const raw = system?.readFile?.(CREDS_PATH); if (raw) savedCreds = JSON.parse(raw); } catch (_) {} + + // Outside-in remote prompt: ac-native stages text + commandId here when a + // viewer publishes cmd:"prompt" through /machines. Run it through the same + // execute() that local typing uses, then ship whatever message landed back. + const remote = system?.consumePromptCmd?.(); + if (remote && remote.text) { + const before = message; + message = ""; + let ok = 1; + try { + execute(remote.text, system); + } catch (err) { + ok = 0; + message = "error: " + (err?.message || String(err)); + } + const reply = message || "(no output)"; + try { system?.machinesResponse?.(remote.id, reply, ok); } catch (_) {} + // Restore prior message so the local screen doesn't get hijacked when + // execute() didn't produce visible feedback. + if (!message) message = before; + } } function act({ event: e, system, sound }) { diff --git a/fedac/native/src/ac-native.c b/fedac/native/src/ac-native.c index 592513d20f..d80a29af17 100644 --- a/fedac/native/src/ac-native.c +++ b/fedac/native/src/ac-native.c @@ -456,7 +456,7 @@ static void try_mount_log(void) { // Generated on first boot, read back on subsequent boots. // Accessible from js-bindings.c via extern. char g_machine_id[64] = {0}; -static ACMachines g_machines = {0}; +ACMachines g_machines = {0}; static void init_machine_id(void) { FILE *f = fopen("/mnt/.machine-id", "r"); @@ -4571,6 +4571,19 @@ int main(int argc, char *argv[]) { strncpy(rt->jump_target, g_machines.cmd_target, sizeof(rt->jump_target) - 1); rt->jump_requested = 1; ac_log("[machines] jump → %s\n", g_machines.cmd_target); + } else if (strcmp(g_machines.cmd_type, "prompt") == 0 && g_machines.cmd_target[0]) { + // Stage text + id for prompt.mjs to consume on boot, + // then route the runtime through the prompt piece. + strncpy(rt->pending_prompt_text, g_machines.cmd_target, + sizeof(rt->pending_prompt_text) - 1); + rt->pending_prompt_text[sizeof(rt->pending_prompt_text) - 1] = 0; + strncpy(rt->pending_prompt_id, g_machines.cmd_id, + sizeof(rt->pending_prompt_id) - 1); + rt->pending_prompt_id[sizeof(rt->pending_prompt_id) - 1] = 0; + rt->pending_prompt_cmd = 1; + strncpy(rt->jump_target, "prompt", sizeof(rt->jump_target) - 1); + rt->jump_requested = 1; + ac_log("[machines] prompt → %.80s\n", g_machines.cmd_target); } else if (strcmp(g_machines.cmd_type, "update") == 0) { // Jump to notepat which handles OTA updates strncpy(rt->jump_target, "notepat", sizeof(rt->jump_target) - 1); diff --git a/fedac/native/src/js-bindings.c b/fedac/native/src/js-bindings.c index ec4489e7f9..9e6254290e 100644 --- a/fedac/native/src/js-bindings.c +++ b/fedac/native/src/js-bindings.c @@ -20,6 +20,7 @@ #include #include "qrcodegen.h" #include +#include "machines.h" #ifdef HAVE_RAYLIB #include "raylib-soft.h" #endif @@ -2631,6 +2632,41 @@ static JSValue js_paste(JSContext *ctx, JSValueConst this_val, int argc, JSValue return JS_UNDEFINED; } +// system.consumePromptCmd() — pop a pending outside-in prompt command staged +// by ac-native.c (machines daemon → cmd:"prompt"). Returns {id, text} once +// per arrival and clears the slot, or null if nothing's queued. +static JSValue js_consume_prompt_cmd(JSContext *ctx, JSValueConst this_val, + int argc, JSValueConst *argv) { + (void)this_val; (void)argc; (void)argv; + if (!current_rt || !current_rt->pending_prompt_cmd) return JS_NULL; + JSValue obj = JS_NewObject(ctx); + JS_SetPropertyStr(ctx, obj, "id", JS_NewString(ctx, current_rt->pending_prompt_id)); + JS_SetPropertyStr(ctx, obj, "text", JS_NewString(ctx, current_rt->pending_prompt_text)); + current_rt->pending_prompt_cmd = 0; + current_rt->pending_prompt_text[0] = 0; + current_rt->pending_prompt_id[0] = 0; + return obj; +} + +// system.machinesResponse(id, output, ok?) — ship a command-response back +// through the existing /machines WebSocket session so the viewer's UI can +// render what the device produced for an outside-in prompt. +static JSValue js_machines_response(JSContext *ctx, JSValueConst this_val, + int argc, JSValueConst *argv) { + (void)this_val; + if (argc < 2) return JS_FALSE; + const char *id = JS_ToCString(ctx, argv[0]); + const char *output = JS_ToCString(ctx, argv[1]); + int ok = 1; + if (argc >= 3) ok = JS_ToBool(ctx, argv[2]); + if (id && output) { + machines_send_response(&g_machines, id, "prompt", output, ok); + } + if (id) JS_FreeCString(ctx, id); + if (output) JS_FreeCString(ctx, output); + return JS_TRUE; +} + // system.raylibTest(painting, frame?) — fill a painting buffer with a // raylib-rendered test pattern (CPU-side software path). Returns true on // success, false when raylib isn't compiled in or the painting is invalid. @@ -7105,6 +7141,12 @@ static JSValue build_system_obj(JSContext *ctx) { JS_SetPropertyStr(ctx, sys, "raylibTest", JS_NewCFunction(ctx, js_raylib_test, "raylibTest", 2)); + // Outside-in remote prompt round-trip (cmd:"prompt" via /machines). + JS_SetPropertyStr(ctx, sys, "consumePromptCmd", + JS_NewCFunction(ctx, js_consume_prompt_cmd, "consumePromptCmd", 0)); + JS_SetPropertyStr(ctx, sys, "machinesResponse", + JS_NewCFunction(ctx, js_machines_response, "machinesResponse", 3)); + // Printer detection and raw printing JS_SetPropertyStr(ctx, sys, "listPrinters", JS_NewCFunction(ctx, js_list_printers, "listPrinters", 0)); diff --git a/fedac/native/src/js-bindings.h b/fedac/native/src/js-bindings.h index c5d2810a8a..62368e9b4a 100644 --- a/fedac/native/src/js-bindings.h +++ b/fedac/native/src/js-bindings.h @@ -94,6 +94,14 @@ typedef struct { char jump_params[8][64]; // colon-separated params (e.g. "chat:clock" → ["clock"]) int jump_param_count; + // Outside-in remote prompt (cmd:"prompt" via /machines). + // ac-native.c stages text + commandId here when a remote prompt arrives; + // prompt.mjs's boot consumes it via system.consumePromptCmd() and replies + // through system.machinesResponse(id, output, ok). + volatile int pending_prompt_cmd; + char pending_prompt_text[2048]; + char pending_prompt_id[32]; + // PTY terminal emulator ACPty pty; int pty_active; // 1 = PTY session is running diff --git a/fedac/native/src/machines.c b/fedac/native/src/machines.c index 00a867d69e..215f505abb 100644 --- a/fedac/native/src/machines.c +++ b/fedac/native/src/machines.c @@ -76,6 +76,38 @@ static int json_get_str(const char *json, const char *key, char *out, int out_sz return len; } +// Escape-aware JSON string extraction: respects \", \\, \n, \r, \t so values +// like `(format t "hi")` survive transit. Stops at the first un-escaped quote. +static int json_get_str_unesc(const char *json, const char *key, char *out, int out_sz) { + char needle[128]; + snprintf(needle, sizeof(needle), "\"%s\":\"", key); + const char *p = strstr(json, needle); + if (!p) return -1; + p += strlen(needle); + int j = 0; + while (*p && j < out_sz - 1) { + if (*p == '"') break; + if (*p == '\\' && p[1]) { + char c = p[1]; + if (c == '"') out[j++] = '"'; + else if (c == '\\') out[j++] = '\\'; + else if (c == 'n') out[j++] = '\n'; + else if (c == 'r') out[j++] = '\r'; + else if (c == 't') out[j++] = '\t'; + else if (c == '/') out[j++] = '/'; + else { // unknown escape — keep verbatim + out[j++] = '\\'; + if (j < out_sz - 1) out[j++] = c; + } + p += 2; + } else { + out[j++] = *p++; + } + } + out[j] = 0; + return j; +} + // Escape a string for JSON embedding (backslash, quotes, control chars). // Returns number of bytes written (excluding null terminator). static int json_escape(const char *in, int in_len, char *out, int out_sz) { @@ -340,6 +372,21 @@ static void process_messages(ACMachines *m) { ac_log("[machines] rebooting by remote command\n"); sync(); reboot(0x01234567); // LINUX_REBOOT_CMD_RESTART + } else if (strcmp(cmd, "prompt") == 0) { + // Free-text remote prompt: feed `text` straight into prompt.mjs's + // execute() on the device. Use the escape-aware extractor so + // strings/parens/newlines in the payload survive. + char text[2048] = ""; + json_get_str_unesc(raw, "text", text, sizeof(text)); + if (text[0] && !m->cmd_pending) { + strncpy(m->cmd_type, "prompt", sizeof(m->cmd_type) - 1); + m->cmd_type[sizeof(m->cmd_type) - 1] = 0; + strncpy(m->cmd_target, text, sizeof(m->cmd_target) - 1); + m->cmd_target[sizeof(m->cmd_target) - 1] = 0; + strncpy(m->cmd_id, cmd_id, sizeof(m->cmd_id) - 1); + m->cmd_id[sizeof(m->cmd_id) - 1] = 0; + m->cmd_pending = 1; + } } else if (strcmp(cmd, "jump") == 0 || strcmp(cmd, "update") == 0 || strcmp(cmd, "request-logs") == 0) { @@ -424,6 +471,22 @@ void machines_tick(ACMachines *m, ACWifi *wifi, int frame, int fps, } } +void machines_send_response(ACMachines *m, const char *cmd_id, + const char *cmd, const char *output, int ok) { + if (!m || !cmd_id || !cmd) return; + char escaped[8192]; + int olen = output ? (int)strlen(output) : 0; + json_escape(output ? output : "", olen, escaped, sizeof(escaped)); + char msg[12288]; + snprintf(msg, sizeof(msg), + "{\"type\":\"command-response\"," + "\"commandId\":\"%s\"," + "\"command\":\"%s\"," + "\"data\":{\"ok\":%s,\"output\":\"%s\"}}", + cmd_id, cmd, ok ? "true" : "false", escaped); + sq_push(m, msg); +} + void machines_flush_logs(ACMachines *m) { if (!m->ws || !m->ws->connected) return; diff --git a/fedac/native/src/machines.h b/fedac/native/src/machines.h index 1cb3f9b20c..0d72d9f468 100644 --- a/fedac/native/src/machines.h +++ b/fedac/native/src/machines.h @@ -28,11 +28,15 @@ typedef struct { // Pending command from server → forwarded to JS runtime volatile int cmd_pending; - char cmd_type[32]; // "jump", "reboot", "update", "request-logs" - char cmd_target[128]; // e.g. piece name for "jump" + char cmd_type[32]; // "jump" | "prompt" | "reboot" | "update" | "request-logs" + char cmd_target[2048]; // piece name for "jump"; free-text for "prompt" char cmd_id[32]; // commandId for ack } ACMachines; +// Singleton instance defined in ac-native.c. Exposed so js-bindings.c can +// queue command-response messages out through the same WebSocket session. +extern ACMachines g_machines; + // Call once at startup (after init_machine_id, before main loop) void machines_init(ACMachines *m); @@ -43,5 +47,11 @@ void machines_tick(ACMachines *m, ACWifi *wifi, int frame, int fps, // Flush final logs before shutdown (blocking drain of send queue) void machines_flush_logs(ACMachines *m); +// Send a command-response back to the viewer for an outside-in prompt. +// `output` is the device's reply text (may contain newlines/quotes). +// `ok` is 1 for success, 0 for failure. +void machines_send_response(ACMachines *m, const char *cmd_id, + const char *cmd, const char *output, int ok); + // Call at shutdown void machines_destroy(ACMachines *m); diff --git a/session-server/session.mjs b/session-server/session.mjs index cfab6f2c2a..212adb0300 100644 --- a/session-server/session.mjs +++ b/session-server/session.mjs @@ -1822,6 +1822,9 @@ wss.on("connection", async (ws, req) => { command: msg.cmd, commandId, target: msg.args?.target || msg.args?.piece || undefined, + // Free-text payload for cmd:"prompt" — runs through the + // device's prompt.mjs execute() exactly as if typed locally. + text: typeof msg.args?.text === "string" ? msg.args.text : undefined, })); log(`Command '${msg.cmd}' → ${msg.machineId} (${commandId})`); } diff --git a/system/public/aesthetic.computer/disks/machines.mjs b/system/public/aesthetic.computer/disks/machines.mjs index bbdaa1ceee..1e14ff44d0 100644 --- a/system/public/aesthetic.computer/disks/machines.mjs +++ b/system/public/aesthetic.computer/disks/machines.mjs @@ -213,6 +213,22 @@ function handleWsMessage(msg) { logs.unshift(...fetched); if (logs.length > 500) logs.length = 500; } + } else if (msg.command === "prompt" && msg.data) { + if (selectedMachine?.machineId === msg.machineId) { + const ok = msg.data.ok !== false; + const out = typeof msg.data.output === "string" + ? msg.data.output + : JSON.stringify(msg.data); + out.split("\n").filter(Boolean).forEach((line) => { + logs.unshift({ + level: ok ? "reply" : "error", + message: (ok ? "← " : "✗ ") + line, + type: "prompt", + when: new Date().toISOString(), + }); + }); + if (logs.length > 500) logs.length = 500; + } } break; } @@ -453,16 +469,16 @@ function paintDetail({ ink, line, screen, help }) { const btnH = 12; if (jumpInputActive) { - ink(80).write("Piece:", { x: M, y: btnY }); - ink(255).write(jumpText + "_", { x: M + 42, y: btnY }); + ink(80).write("Prompt:", { x: M, y: btnY }); + ink(255).write(jumpText + "_", { x: M + 50, y: btnY }); y += btnH + 4; } else { - // Jump button - ink(100, 200, 255).write("[Jump]", { x: M, y: btnY }); + // Prompt button — free-text execute through the device's prompt.mjs + ink(100, 200, 255).write("[Prompt]", { x: M, y: btnY }); // Update button - ink(100, 220, 100).write("[Update]", { x: M + 44, y: btnY }); + ink(100, 220, 100).write("[Update]", { x: M + 56, y: btnY }); // Reboot button - ink(220, 100, 100).write("[Reboot]", { x: M + 100, y: btnY }); + ink(220, 100, 100).write("[Reboot]", { x: M + 112, y: btnY }); y += btnH + 4; } @@ -525,7 +541,11 @@ function paintDetail({ ink, line, screen, help }) { ? [220, 80, 80] : entry.level === "warn" ? [220, 180, 60] - : [70, 70, 90]; + : entry.level === "cmd" + ? [120, 200, 255] + : entry.level === "reply" + ? [140, 220, 140] + : [70, 70, 90]; ink(...levelColor).write(time, { x: M, y: logY }); ink(160).write( @@ -597,10 +617,19 @@ function act({ event: e, needsPaint, jump, pen }) { return; } - // Jump text input + // Prompt text input — sends free text through the device's prompt.mjs if (jumpInputActive) { if (e.is("keyboard:down:enter") && jumpText.length > 0) { - sendCommand(selectedMachine.machineId, "jump", { piece: jumpText }); + sendCommand(selectedMachine.machineId, "prompt", { text: jumpText }); + // Mirror the submission as a log entry so the viewer sees what was typed + // before the device's reply lands a tick later. + logs.unshift({ + level: "cmd", + message: "❯ " + jumpText, + type: "prompt", + when: new Date().toISOString(), + }); + if (logs.length > 500) logs.length = 500; jumpInputActive = false; jumpText = ""; needsPaint(); @@ -667,23 +696,23 @@ function act({ event: e, needsPaint, jump, pen }) { // Command buttons (at row ~after info) // Approximate: commands area starts around y=120-160 depending on hw info - // We use the text positions: [Jump] starts at M, [Update] at M+44, [Reboot] at M+100 + // Text positions: [Prompt] starts at M, [Update] at M+56, [Reboot] at M+112. const cmdY = findCommandY(); if (py >= cmdY && py < cmdY + 14) { - if (px >= M && px < M + 40) { - // Jump + if (px >= M && px < M + 52) { + // Prompt — open free-text input jumpInputActive = true; jumpText = ""; needsPaint(); return; } - if (px >= M + 44 && px < M + 96) { + if (px >= M + 56 && px < M + 108) { // Update sendCommand(selectedMachine.machineId, "update"); needsPaint(); return; } - if (px >= M + 100 && px < M + 152) { + if (px >= M + 112 && px < M + 164) { // Reboot confirmReboot = selectedMachine.machineId; needsPaint();