diff --git a/frontend/src/components/raspberry-pi/VirtualFileSystem.tsx b/frontend/src/components/raspberry-pi/VirtualFileSystem.tsx index 3c221fe3..3fea0b29 100644 --- a/frontend/src/components/raspberry-pi/VirtualFileSystem.tsx +++ b/frontend/src/components/raspberry-pi/VirtualFileSystem.tsx @@ -10,6 +10,7 @@ import { useVfsStore } from '../../store/useVfsStore'; import type { VfsNode } from '../../store/useVfsStore'; import { getBoardBridge, useSimulatorStore } from '../../store/useSimulatorStore'; import { showConfirmDialog } from '../../store/useMessageDialogStore'; +import { uploadFilesToPi } from '../../utils/piUpload'; /** Resolve true once the board's guest Linux has booted to a shell * (board.piBooted), or false after timeoutMs. Polls the store. */ @@ -381,35 +382,7 @@ export const VirtualFileSystem: React.FC = ({ boardId, o return; } - // Flow-controlled sends: wait for the shell prompt to return after each - // command instead of guessing with fixed delays (long lines used to drop - // on the unflow-controlled console). Ensure a clean prompt + rw rootfs. - await bridge.sendAndWaitForPrompt('\n', 4000); - await bridge.sendAndWaitForPrompt('mount -o remount,rw / 2>/dev/null; true\n', 6000); - - for (const { path, content } of files) { - // Create parent dir (before the heredoc, so the path exists). - const dir = path.substring(0, path.lastIndexOf('/')); - if (dir) await bridge.sendAndWaitForPrompt(`mkdir -p ${dir}\n`, 6000); - - // Write the file via a heredoc with a unique delimiter. Open it, stream - // the body in small chunks so the console FIFO doesn't overflow on large - // files, then close it and wait for the prompt. - const delim = `VELXIO_${Math.random().toString(36).slice(2, 10).toUpperCase()}`; - const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - bridge.sendSerialText(`cat > ${path} << '${delim}'\n`); - const body = `${normalized}\n`; - for (let i = 0; i < body.length; i += 256) { - bridge.sendSerialText(body.slice(i, i + 256)); - await new Promise((r) => setTimeout(r, 25)); - } - await bridge.sendAndWaitForPrompt(`${delim}\n`, 8000); - - // Make scripts executable (after the file exists). - if (path.endsWith('.py') || path.endsWith('.sh')) { - await bridge.sendAndWaitForPrompt(`chmod +x ${path}\n`, 5000); - } - } + await uploadFilesToPi(bridge, files); setUploadStatus('done'); setTimeout(() => setUploadStatus('idle'), 2500); diff --git a/frontend/src/components/simulator/SerialMonitor.tsx b/frontend/src/components/simulator/SerialMonitor.tsx index 9911e0c5..b7cc1268 100644 --- a/frontend/src/components/simulator/SerialMonitor.tsx +++ b/frontend/src/components/simulator/SerialMonitor.tsx @@ -266,7 +266,11 @@ export const SerialMonitor: React.FC = () => { // parses + answers them — this only cleans the dumb mirror.) const text = activeBoard.serialOutput .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '') - .replace(/\x1b[=>]/g, ''); + .replace(/\x1b[=>]/g, '') + // Line-editing bytes the guest shell echoes (DEL on + // backspace, BEL, other C0 controls) render as tofu boxes + // in a
; strip everything except \t \n \r.
+                .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
               // ESP32 (QEMU slirp) hands out 192.168.4.x; the Pico W virtual
               // net hands out 10.13.37.x. Both reach their emulated server
               // through the same /api/gateway proxy, so linkify either subnet.
diff --git a/frontend/src/lib/proBoardRegistry.ts b/frontend/src/lib/proBoardRegistry.ts
index 8d986c9a..0886efd9 100644
--- a/frontend/src/lib/proBoardRegistry.ts
+++ b/frontend/src/lib/proBoardRegistry.ts
@@ -63,6 +63,14 @@ export interface ProBoardDef {
    *  prompt (piFamily boards only). Lets a board de-brand the generic
    *  image, e.g. set its own hostname/PS1 and clear the stock motd. */
   guestSetup?: string;
+  /** Home directory of the guest user for the VFS panel and uploads
+   *  (piFamily boards only; default '/home/pi'). Boards whose guest logs
+   *  in as root pass '/root'; these also drop the hello.sh sample. */
+  guestHome?: string;
+  /** Shell command run automatically after boot: the VFS is uploaded and
+   *  this line executed, so a single click on Run boots, uploads and
+   *  starts the user's script (piFamily boards only). */
+  autoRun?: string;
   /** Canvas renderer. Receives the placed board's props; return a React node.
    *  When omitted, the canvas renders ``. */
   render?: (props: { id: string; x: number; y: number; running: boolean }) => React.ReactNode;
diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts
index 390f37a1..5786b456 100644
--- a/frontend/src/store/useSimulatorStore.ts
+++ b/frontend/src/store/useSimulatorStore.ts
@@ -1249,18 +1249,35 @@ export const useSimulatorStore = create((set, get) => {
         // it runs before piBooted flips so the VFS upload (gated on piBooted)
         // cannot interleave with it.
         bridge.onBooted = () => {
-          const setup = getProBoard(boardKind)?.guestSetup;
+          const proDef = getProBoard(boardKind);
+          const setup = proDef?.guestSetup;
           const flip = () =>
             set((s) => ({
               boards: s.boards.map((b) => (b.id === id ? { ...b, piBooted: true } : b)),
             }));
-          if (setup) {
-            void bridge
-              .sendAndWaitForPrompt(setup.endsWith('\n') ? setup : setup + '\n')
-              .then(flip);
-          } else {
+          // Overlay boards may declare autoRun: after boot (+setup) the VFS
+          // is uploaded and the command executed, so one click on Run boots,
+          // uploads and starts the user's script — same UX as every other
+          // board's compile-and-run.
+          const autoRun = async (): Promise => {
+            const cmd = proDef?.autoRun;
+            if (!cmd) return;
+            try {
+              const files = useVfsStore.getState().serializeForUpload(id);
+              const { uploadFilesToPi } = await import('../utils/piUpload');
+              await uploadFilesToPi(bridge, files);
+              bridge.sendSerialText(cmd.endsWith('\n') ? cmd : cmd + '\n');
+            } catch (e) {
+              console.warn(`[${boardKind}] autoRun failed:`, e);
+            }
+          };
+          void (async () => {
+            if (setup) {
+              await bridge.sendAndWaitForPrompt(setup.endsWith('\n') ? setup : setup + '\n');
+            }
             flip();
-          }
+            await autoRun();
+          })();
         };
         bridge.onDisconnected = () => {
           set((s) => {
@@ -1388,9 +1405,14 @@ export const useSimulatorStore = create((set, get) => {
       if (get().activeBoardId === id) {
         useEditorStore.getState().setActiveGroup(`group-${id}`);
       }
-      // Init VFS for Raspberry Pi 3 boards
+      // Init VFS for QEMU-Linux boards. Overlay boards may declare their
+      // guest home (e.g. '/root' when the guest logs in as root); those
+      // also drop the historic hello.sh sample.
       if (isPiBoardKind(boardKind)) {
-        useVfsStore.getState().initBoardVfs(id);
+        const home = getProBoard(boardKind)?.guestHome;
+        useVfsStore
+          .getState()
+          .initBoardVfs(id, home ? { home, withShellSample: false } : undefined);
       }
       // ── Interconnect: register the board and rebuild routes ──────────
       icBindBoard(id, boardKind);
diff --git a/frontend/src/store/useVfsStore.ts b/frontend/src/store/useVfsStore.ts
index e669b1c6..fd5a95bd 100644
--- a/frontend/src/store/useVfsStore.ts
+++ b/frontend/src/store/useVfsStore.ts
@@ -36,38 +36,54 @@ const DEFAULT_SH_CONTENT = `#!/bin/bash
 echo "Hello from Pi!"
 `;
 
-function makeDefaultTree(): { tree: VfsTree; rootId: string } {
-  const rootId = nanoid(8);
-  const homeId = nanoid(8);
-  const piId = nanoid(8);
-  const scriptId = nanoid(8);
-  const shellId = nanoid(8);
+export interface VfsInitOptions {
+  /** Home directory path for the default tree (default '/home/pi'). Overlay
+   *  boards whose guest logs in as root pass '/root'. */
+  home?: string;
+  /** Include the hello.sh shell sample (default true — historic Pi VFS). */
+  withShellSample?: boolean;
+}
 
+function makeDefaultTree(opts?: VfsInitOptions): { tree: VfsTree; rootId: string } {
+  const home = (opts?.home ?? '/home/pi').replace(/^\/+|\/+$/g, '');
+  const withShell = opts?.withShellSample ?? true;
+  const segments = home.split('/').filter(Boolean);
+
+  const rootId = nanoid(8);
   const tree: VfsTree = {
-    [rootId]: { id: rootId, name: '/', type: 'directory', children: [homeId], parentId: null },
-    [homeId]: { id: homeId, name: 'home', type: 'directory', children: [piId], parentId: rootId },
-    [piId]: {
-      id: piId,
-      name: 'pi',
-      type: 'directory',
-      children: [scriptId, shellId],
-      parentId: homeId,
-    },
-    [scriptId]: {
-      id: scriptId,
-      name: 'script.py',
-      type: 'file',
-      content: DEFAULT_PY_CONTENT,
-      parentId: piId,
-    },
-    [shellId]: {
+    [rootId]: { id: rootId, name: '/', type: 'directory', children: [], parentId: null },
+  };
+
+  // Build the home directory chain (e.g. home/pi, or just root).
+  let parentId = rootId;
+  for (const name of segments) {
+    const dirId = nanoid(8);
+    tree[dirId] = { id: dirId, name, type: 'directory', children: [], parentId };
+    tree[parentId].children!.push(dirId);
+    parentId = dirId;
+  }
+
+  const scriptId = nanoid(8);
+  tree[scriptId] = {
+    id: scriptId,
+    name: 'script.py',
+    type: 'file',
+    content: DEFAULT_PY_CONTENT,
+    parentId,
+  };
+  tree[parentId].children!.push(scriptId);
+
+  if (withShell) {
+    const shellId = nanoid(8);
+    tree[shellId] = {
       id: shellId,
       name: 'hello.sh',
       type: 'file',
       content: DEFAULT_SH_CONTENT,
-      parentId: piId,
-    },
-  };
+      parentId,
+    };
+    tree[parentId].children!.push(shellId);
+  }
 
   return { tree, rootId };
 }
@@ -78,7 +94,7 @@ interface VfsState {
   // Per-board: boardId → selected nodeId (for editor focus)
   selectedNodeId: Record;
 
-  initBoardVfs: (boardId: string) => void;
+  initBoardVfs: (boardId: string, opts?: VfsInitOptions) => void;
   getTree: (boardId: string) => VfsTree;
   getRootId: (boardId: string) => string | null;
   getNode: (boardId: string, nodeId: string) => VfsNode | null;
@@ -131,9 +147,9 @@ export const useVfsStore = create((set, get) => ({
   boards: {},
   selectedNodeId: {},
 
-  initBoardVfs: (boardId) => {
+  initBoardVfs: (boardId, opts) => {
     if (get().boards[boardId]) return; // already initialized
-    const { tree, rootId } = makeDefaultTree();
+    const { tree, rootId } = makeDefaultTree(opts);
     set((s) => ({
       boards: { ...s.boards, [boardId]: { tree, rootId } },
       selectedNodeId: { ...s.selectedNodeId, [boardId]: null },
diff --git a/frontend/src/utils/piUpload.ts b/frontend/src/utils/piUpload.ts
new file mode 100644
index 00000000..831e23f5
--- /dev/null
+++ b/frontend/src/utils/piUpload.ts
@@ -0,0 +1,44 @@
+/**
+ * piUpload — flow-controlled file upload into a running QEMU-Linux guest
+ * over the serial console (heredocs, prompt-gated). Extracted from the
+ * VirtualFileSystem panel so the auto-run path (ProBoardDef.autoRun) can
+ * reuse the exact same sequence.
+ */
+import type { RaspberryPi3Bridge } from '../simulation/RaspberryPi3Bridge';
+
+export async function uploadFilesToPi(
+  bridge: RaspberryPi3Bridge,
+  files: Array<{ path: string; content: string }>,
+): Promise {
+  if (files.length === 0) return;
+
+  // Flow-controlled sends: wait for the shell prompt to return after each
+  // command instead of guessing with fixed delays (long lines used to drop
+  // on the unflow-controlled console). Ensure a clean prompt + rw rootfs.
+  await bridge.sendAndWaitForPrompt('\n', 4000);
+  await bridge.sendAndWaitForPrompt('mount -o remount,rw / 2>/dev/null; true\n', 6000);
+
+  for (const { path, content } of files) {
+    // Create parent dir (before the heredoc, so the path exists).
+    const dir = path.substring(0, path.lastIndexOf('/'));
+    if (dir) await bridge.sendAndWaitForPrompt(`mkdir -p ${dir}\n`, 6000);
+
+    // Write the file via a heredoc with a unique delimiter. Open it, stream
+    // the body in small chunks so the console FIFO doesn't overflow on large
+    // files, then close it and wait for the prompt.
+    const delim = `VELXIO_${Math.random().toString(36).slice(2, 10).toUpperCase()}`;
+    const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
+    bridge.sendSerialText(`cat > ${path} << '${delim}'\n`);
+    const body = `${normalized}\n`;
+    for (let i = 0; i < body.length; i += 256) {
+      bridge.sendSerialText(body.slice(i, i + 256));
+      await new Promise((r) => setTimeout(r, 25));
+    }
+    await bridge.sendAndWaitForPrompt(`${delim}\n`, 8000);
+
+    // Make scripts executable (after the file exists).
+    if (path.endsWith('.py') || path.endsWith('.sh')) {
+      await bridge.sendAndWaitForPrompt(`chmod +x ${path}\n`, 5000);
+    }
+  }
+}