Parcourir la source

Merge branch 'v2.026' into claude/jolly-goodall-lpCDo

Alan Yeung il y a 2 semaines
Parent
commit
f0b1bff062

+ 245 - 26
src/mod/agi/README.md

@@ -756,13 +756,257 @@ ffmpeg.convertWithProgress("user:/in.mp4", "user:/out.gif", "tmp:/conv_progress.
 
 ## websocket API
 
+The websocket library upgrades the current HTTP connection to a WebSocket session.
+It is only available in script paths reached via a live HTTP request context
+(standard `InterfaceHandler` or token-handler routes — not `execd` children).
+
 Load:
 
 ```javascript
 requirelib("websocket");
 ```
 
-This library is only available in request handlers with active HTTP request/response context.
+> **Note on `delay()` after upgrade** — `websocket.upgrade()` replaces the global
+> `delay()` with a message-pumping version. While the script sleeps inside `delay()`,
+> any queued inbound frames are dispatched to `websocket.onMessage` (if set).
+> `delay()` is therefore the natural yield point in event-driven loops.
+> When `onMessage` is `null` the buffer is left untouched so that
+> `available()` and `read()` can still see the frames.
+
+---
+
+### `websocket.upgrade(timeoutSec)` → `bool`
+
+Upgrades the HTTP connection to WebSocket and starts the background frame reader.
+The connection is closed automatically after `timeoutSec` seconds of idle time
+(default `300`). Also installs the message-pumping `delay()` override.
+
+Returns `false` if the upgrade fails.
+
+```javascript
+requirelib("websocket");
+if (!websocket.upgrade(120)) exit();
+```
+
+---
+
+### `websocket.send(text)` → `bool`
+
+Sends a UTF-8 text frame to the client. Returns `false` if the connection is closed.
+
+```javascript
+websocket.send("Hello from server");
+```
+
+---
+
+### `websocket.read(timeoutMs?)` → `string | null | false`
+
+Reads the next inbound message from the internal buffer.
+
+| Return value | Meaning |
+|---|---|
+| `string` | Message text |
+| `null` | `timeoutMs` elapsed with no message; connection still open |
+| `false` | Connection is closed |
+
+`timeoutMs = 0` or omitted blocks indefinitely until a message arrives or the
+connection closes.
+
+```javascript
+// Block until a message arrives or connection closes
+var msg = websocket.read();
+
+// Wait at most 5 s; returns null on timeout
+var msg = websocket.read(5000);
+
+if (msg === false) { /* connection closed */ }
+if (msg === null)  { /* timed out, still open */ }
+```
+
+---
+
+### `websocket.available()` → `number`
+
+Returns the number of messages currently queued in the inbound buffer.
+Non-blocking — safe to call on every iteration of a tight loop.
+
+```javascript
+if (websocket.available() > 0) {
+    var msg = websocket.read();
+}
+```
+
+---
+
+### `websocket.isClosed()` → `bool`
+
+Returns `true` when the WebSocket connection is no longer active.
+
+```javascript
+while (!websocket.isClosed()) {
+    websocket.send("tick");
+    delay(1000);
+}
+```
+
+---
+
+### `websocket.onMessage`
+
+Assign a `function(msg)` callback to receive messages asynchronously.
+The handler fires inside `delay()` on the script's own goroutine — Otto-safe, no
+concurrent JS execution.
+
+**Message object properties:**
+
+| Property | Type | Description |
+|---|---|---|
+| `msg.data` | `string` | Text payload |
+| `msg.timestamp` | `number` | Arrival time (Unix milliseconds) |
+| `msg.type` | `number` | Frame type: `1` = text, `2` = binary |
+
+```javascript
+websocket.onMessage = function(msg) {
+    console.log("Received at " + msg.timestamp + " ms: " + msg.data);
+};
+```
+
+Set back to `null` to stop receiving callbacks and leave messages in the buffer:
+
+```javascript
+websocket.onMessage = null;
+```
+
+---
+
+### `websocket.close()`
+
+Sends a normal-closure frame and closes the connection.
+
+```javascript
+websocket.close();
+```
+
+---
+
+### Pattern 1 — blocking read with optional timeout
+
+Simplest pattern. `read(timeoutMs)` returns `null` on timeout so the loop can
+send a keep-alive or do other work without blocking forever.
+
+```javascript
+requirelib("websocket");
+if (!websocket.upgrade(120)) exit();
+
+websocket.send("Connected. Commands: echo <text> | stop");
+
+while (true) {
+    var msg = websocket.read(30000); // wait up to 30 s
+
+    if (msg === false) break;        // remote side closed
+    if (msg === null)  {             // 30-second idle timeout
+        websocket.send("Still here.");
+        continue;
+    }
+
+    msg = msg.trim();
+    if (msg === "stop") {
+        websocket.send("Bye!");
+        break;
+    } else if (msg.indexOf("echo ") === 0) {
+        websocket.send(msg.slice(5));
+    } else if (msg !== "") {
+        websocket.send("Unknown command: '" + msg + "'");
+    }
+}
+
+websocket.close();
+```
+
+---
+
+### Pattern 2 — `available()` polling (Arduino-style)
+
+Use when you want to drain all queued frames in one shot each iteration, or when
+the main loop body does other work regardless of incoming messages.
+
+`onMessage` must be `null` (the default) so that `delay()` does **not** consume
+frames behind your back.
+
+```javascript
+requirelib("websocket");
+if (!websocket.upgrade(120)) exit();
+
+websocket.send("available() polling mode.");
+
+while (true) {
+    if (websocket.isClosed()) break;
+
+    var n = websocket.available();
+    if (n > 0) {
+        // Drain all waiting frames without blocking
+        for (var i = 0; i < n; i++) {
+            var msg = websocket.read(); // data already queued, returns immediately
+            if (msg === false) break;
+            msg = msg.trim();
+            if (msg === "stop") {
+                websocket.send("Bye!");
+                websocket.close();
+                break;
+            }
+            websocket.send("Echo: " + msg);
+        }
+    } else {
+        delay(500); // sleep; buffer is untouched because onMessage is null
+    }
+}
+```
+
+---
+
+### Pattern 3 — `onMessage` callback with `delay()` pump
+
+Event-driven style. The callback fires inside `delay()` on the script goroutine.
+Use a shared variable to hand data from the callback to the main loop.
+
+```javascript
+requirelib("websocket");
+if (!websocket.upgrade(120)) exit();
+
+websocket.send("onMessage mode. Commands: echo <text> | stop");
+
+var lastMessage = "";
+
+websocket.onMessage = function(msg) {
+    // Runs on the script goroutine during delay() — safe to update shared state
+    lastMessage = msg.data;
+};
+
+while (true) {
+    if (lastMessage !== "") {
+        var msg = lastMessage.trim();
+        lastMessage = "";
+
+        if (msg === "stop") {
+            websocket.send("Bye!");
+            break;
+        } else if (msg.indexOf("echo ") === 0) {
+            websocket.send(msg.slice(5));
+        } else if (msg !== "") {
+            websocket.send("Unknown command: '" + msg + "'");
+        }
+    }
+
+    if (websocket.isClosed()) break;
+
+    // delay() pumps the inbound channel and fires onMessage for each queued frame
+    delay(100);
+}
+
+websocket.onMessage = null;
+websocket.close();
+```
 
 ## Scheduler Library (`scheduler`)
 
@@ -846,31 +1090,6 @@ The scheduler calls `cron.agi` with the permissions of the user who approved it,
 
 ## Examples
 
-### Complete File Upload Handler
-### `websocket.upgrade(timeoutSec)`
-Upgrades current HTTP request to WebSocket. Default timeout is 300 seconds.
-
-```javascript
-if (!websocket.upgrade(300)) exit();
-```
-
-### `websocket.send(text)`
-```javascript
-websocket.send("hello client");
-```
-
-### `websocket.read()`
-Returns incoming message string, or `false` when closed.
-
-```javascript
-var msg = websocket.read();
-```
-
-### `websocket.close()`
-```javascript
-websocket.close();
-```
-
 ### Background Scheduler (webapp backend)
 
 A typical webapp has three files that work together to set up a background task.

+ 2 - 1
src/web/Movie/backend/common.js

@@ -14,7 +14,8 @@ var TRANSCODE_API  = "../media/transcode";            // ?file
 var AGI_INTERFACE = "../system/ajgi/interface?script=";
 
 // ── Script paths (used when calling ao_module_agirun from the frontend) ──────
-var SCRIPT_GET_LIBRARY   = BACKEND_PATH + "getLibrary.js";
+var SCRIPT_GET_LIBRARY       = BACKEND_PATH + "getLibrary.js";
+var SCRIPT_GET_LIBRARY_CACHE = BACKEND_PATH + "getLibraryCache.js";
 var SCRIPT_GET_EPISODES  = BACKEND_PATH + "getEpisodes.js";
 var SCRIPT_GET_THUMBNAIL = BACKEND_PATH + "getThumbnail.js";
 var SCRIPT_LIST_FOLDER   = BACKEND_PATH + "listFolder.js";

+ 13 - 0
src/web/Movie/backend/getLibrary.js

@@ -343,6 +343,19 @@ function main() {
         for (var j = 0; j < found.length; j++) { allAlbums.push(found[j]); }
     }
 
+    // Persist to server-side cache so any device (or next session) can load instantly.
+    // This runs before sendJSONResp so the file is guaranteed written even if the
+    // client closes the tab before the response arrives.
+    try {
+        if (!filelib.fileExists("user:/Document/"))           { filelib.mkdir("user:/Document/"); }
+        if (!filelib.fileExists("user:/Document/Appdata/"))   { filelib.mkdir("user:/Document/Appdata/"); }
+        if (!filelib.fileExists("user:/Document/Appdata/Movie/")) { filelib.mkdir("user:/Document/Appdata/Movie/"); }
+        filelib.writeFile(
+            "user:/Document/Appdata/Movie/library_cache.json",
+            JSON.stringify({ ts: new Date().getTime(), data: allAlbums })
+        );
+    } catch (e) {}  // never let a cache-write failure break the response
+
     sendJSONResp(JSON.stringify(allAlbums));
 }
 

+ 31 - 0
src/web/Movie/backend/getLibraryCache.js

@@ -0,0 +1,31 @@
+/*
+    Movie App - Library Cache Reader
+    Returns the previously-saved library scan result from server storage.
+    Does NO file-system scanning — responds in milliseconds.
+
+    Written by getLibrary.js after every full scan, so it is always as fresh
+    as the last completed scan (even if the browser tab was closed during it).
+
+    Returns JSON: { ts: <unix-ms>, data: [...albums] }
+    or            { error: "no_cache" }
+*/
+
+includes("common.js");
+requirelib("filelib");
+
+var CACHE_FILE = "user:/Document/Appdata/Movie/library_cache.json";
+
+function main() {
+    if (!filelib.fileExists(CACHE_FILE)) {
+        sendJSONResp(JSON.stringify({ error: "no_cache" }));
+        return;
+    }
+    var content = filelib.readFile(CACHE_FILE);
+    if (!content || content === false || content.length < 10) {
+        sendJSONResp(JSON.stringify({ error: "no_cache" }));
+        return;
+    }
+    sendJSONResp(content);  // { ts, data }  — already valid JSON
+}
+
+main();

+ 72 - 66
src/web/Movie/index.html

@@ -1170,10 +1170,10 @@ html, body {
 // ─── All configurable paths come from backend/common.js ──────────────────────
 // (SCRIPT_GET_LIBRARY, SCRIPT_GET_EPISODES, SCRIPT_GET_THUMBNAIL, MEDIA_API)
 
-// ─── Library cache (localStorage) ────────────────────────────────────────────
-var LIBRARY_CACHE_KEY     = 'movie_library_cache';
-var LIBRARY_CACHE_VERSION = 1;
-var libraryNeedsRedraw    = false; // set when bg-refresh finishes outside the library view
+// ─── Library cache ────────────────────────────────────────────────────────────
+// Cache is stored server-side (user:/Document/Appdata/Movie/library_cache.json)
+// so it is shared across devices for the same user account.
+var libraryNeedsRedraw = false; // set when bg-refresh finishes outside the library view
 
 // ─── App state ────────────────────────────────────────────────────────────────
 var library        = [];   // full album array from server
@@ -1638,29 +1638,8 @@ $(document).ready(function () {
     });
 });
 
-// ─── Library cache helpers ─────────────────────────────────────────────────────
-function getCachedLibrary() {
-    try {
-        var raw = localStorage.getItem(LIBRARY_CACHE_KEY);
-        if (!raw) { return null; }
-        var obj = JSON.parse(raw);
-        if (!obj || obj.version !== LIBRARY_CACHE_VERSION || !Array.isArray(obj.data)) { return null; }
-        return obj;  // { version, ts, data }
-    } catch (e) { return null; }
-}
-
-function updateLibraryCache(data) {
-    try {
-        localStorage.setItem(LIBRARY_CACHE_KEY, JSON.stringify({
-            version: LIBRARY_CACHE_VERSION,
-            ts:      Date.now(),
-            data:    data
-        }));
-    } catch (e) {} // storage full or unavailable — silently ignore
-}
-
-// spinning=true  →  show spinner, hide Refresh button
-// spinning=false →  hide spinner, show Refresh button
+// ─── Library status bar helpers ────────────────────────────────────────────────
+// spinning=true  shows the spinner and hides the Refresh button, and vice-versa.
 function setLibraryStatus(text, spinning) {
     $('#library-status-text').text(text);
     $('#library-spinner').toggle(spinning);
@@ -1675,67 +1654,95 @@ function timeAgo(ts) {
     return Math.floor(d / 86400) + 'd ago';
 }
 
-// Re-render the library grid, preserving an active search filter if present
+// Re-render the library grid, preserving an active search filter if present.
 function renderCurrentLibrary() {
     var q = $('#search-input').val().trim().toLowerCase();
     renderLibrary(q ? library.filter(function (a) { return a.name.toLowerCase().indexOf(q) > -1; }) : library);
 }
 
-// ─── Load library (cache-first) ────────────────────────────────────────────────
+// Run a background full scan. getLibrary.js writes the cache file server-side
+// before it sends its response, so the cache is updated even if the tab closes
+// partway through — the browser just won't receive the response in that case.
+function backgroundScanLibrary() {
+    ao_module_agirun(SCRIPT_GET_LIBRARY, {}, function (data) {
+        if (!data || data.error) {
+            setLibraryStatus('Refresh failed · showing cached data', false);
+            return;
+        }
+        library = data;
+        if ($('#view-library').hasClass('active')) {
+            renderCurrentLibrary();
+            libraryNeedsRedraw = false;
+        } else {
+            libraryNeedsRedraw = true;
+        }
+        var n = library.length;
+        setLibraryStatus(n + ' item' + (n !== 1 ? 's' : ''), false);
+    }, function () {
+        setLibraryStatus('Refresh failed · showing cached data', false);
+    });
+}
+
+// ─── Load library (server-side cache-first) ────────────────────────────────────
+// 1. Ask getLibraryCache.js for the last saved scan result (milliseconds, no scan).
+// 2a. Cache hit  → render immediately, kick off backgroundScanLibrary().
+// 2b. Cache miss → show loading overlay, run full scan, render when done.
 function loadLibrary() {
-    var cached = getCachedLibrary();
+    ao_module_agirun(SCRIPT_GET_LIBRARY_CACHE, {}, function (cached) {
 
-    if (cached) {
-        // ── Have cache: show it immediately, skip the loading overlay ──────────
-        $('#loading-overlay').hide();
-        library = cached.data;
-        renderLibrary(library);
-        setLibraryStatus('Cached · ' + timeAgo(cached.ts) + ' · refreshing…', true);
+        if (cached && !cached.error && Array.isArray(cached.data)) {
+            // Fast path: paint the UI from cache right away
+            $('#loading-overlay').hide();
+            library = cached.data;
+            renderLibrary(library);
+            setLibraryStatus('Cached · ' + timeAgo(cached.ts) + ' · refreshing…', true);
+            backgroundScanLibrary();
 
-        // Background fetch — silently update when done
-        ao_module_agirun(SCRIPT_GET_LIBRARY, {}, function (data) {
-            if (!data || data.error) {
-                setLibraryStatus('⚠ Refresh failed · showing cached data', false);
-                return;
-            }
-            updateLibraryCache(data);
-            library = data;
-            if ($('#view-library').hasClass('active')) {
-                renderCurrentLibrary();    // user is watching — update smoothly
-                libraryNeedsRedraw = false;
-            } else {
-                libraryNeedsRedraw = true; // user navigated away — re-render on return
-            }
-            var n = library.length;
-            setLibraryStatus('✓ ' + n + ' item' + (n !== 1 ? 's' : ''), false);
-        }, function () {
-            setLibraryStatus('⚠ Refresh failed · showing cached data', false);
-        });
+        } else {
+            // Cold start: no cache yet — full scan with loading overlay
+            setLibraryStatus('Loading…', true);
+            ao_module_agirun(SCRIPT_GET_LIBRARY, {}, function (data) {
+                $('#loading-overlay').fadeOut(300);
+                if (!data || data.error) {
+                    showToast('Failed to load library');
+                    setLibraryStatus('Failed to load library', false);
+                    return;
+                }
+                library = data;
+                renderLibrary(library);
+                var n = library.length;
+                setLibraryStatus(n + ' item' + (n !== 1 ? 's' : ''), false);
+            }, function () {
+                $('#loading-overlay').fadeOut(300);
+                showToast('Error loading library');
+                setLibraryStatus('Error loading library', false);
+            });
+        }
 
-    } else {
-        // ── No cache: full loading screen, wait for first fetch ───────────────
+    }, function () {
+        // getLibraryCache.js itself failed — treat as cold start
         setLibraryStatus('Loading…', true);
         ao_module_agirun(SCRIPT_GET_LIBRARY, {}, function (data) {
             $('#loading-overlay').fadeOut(300);
             if (!data || data.error) {
                 showToast('Failed to load library');
-                setLibraryStatus('Failed to load library', false);
+                setLibraryStatus('Failed to load library', false);
                 return;
             }
-            updateLibraryCache(data);
             library = data;
             renderLibrary(library);
             var n = library.length;
-            setLibraryStatus('✓ ' + n + ' item' + (n !== 1 ? 's' : ''), false);
+            setLibraryStatus(n + ' item' + (n !== 1 ? 's' : ''), false);
         }, function () {
             $('#loading-overlay').fadeOut(300);
             showToast('Error loading library');
-            setLibraryStatus('Error loading library', false);
+            setLibraryStatus('Error loading library', false);
         });
-    }
+    });
 }
 
 // ─── Manual refresh (Refresh button) ──────────────────────────────────────────
+// Triggers a full scan; getLibrary.js saves the cache file automatically.
 function refreshLibrary() {
     $('#library-refresh-btn').prop('disabled', true);
     setLibraryStatus('Refreshing…', true);
@@ -1743,19 +1750,18 @@ function refreshLibrary() {
         $('#library-refresh-btn').prop('disabled', false);
         if (!data || data.error) {
             showToast('Failed to refresh library');
-            setLibraryStatus('Refresh failed', false);
+            setLibraryStatus('Refresh failed', false);
             return;
         }
-        updateLibraryCache(data);
         library = data;
         renderCurrentLibrary();
         libraryNeedsRedraw = false;
         var n = library.length;
-        setLibraryStatus('✓ ' + n + ' item' + (n !== 1 ? 's' : '') + ' · just refreshed', false);
+        setLibraryStatus(n + ' item' + (n !== 1 ? 's' : '') + ' · just refreshed', false);
     }, function () {
         $('#library-refresh-btn').prop('disabled', false);
         showToast('Error refreshing library');
-        setLibraryStatus('Refresh failed', false);
+        setLibraryStatus('Refresh failed', false);
     });
 }
 

+ 127 - 31
src/web/UnitTest/special/websocket.js

@@ -1,59 +1,155 @@
 /*
     WebSocket Test Script
 
-    Supports an interactive command loop:
-      echo <text>  — sends <text> back to the client
-      stop         — closes the connection gracefully
+    Demonstrates three read patterns:
 
-    Author: tobychui
-*/
+    MODE 1 — blocking read with timeout  (default)
+      Send "mode2" or "mode3" to switch to another demo.
+      echo <text>  → echoes text back
+      stop         → closes the connection
 
-function setup() {
-    if (!requirelib("websocket")) {
-        console.log("WebSocket library load failed");
-        return false;
-    }
+    MODE 2 — available() polling (Arduino-style)
+      Uses websocket.available() + websocket.read(0) in a tight loop.
 
-    // Upgrade to WebSocket; 120-second idle timeout
-    if (!websocket.upgrade(120)) {
-        console.log("WebSocket upgrade failed");
-        return false;
-    }
+    MODE 3 — onMessage callback
+      Assigns websocket.onMessage; the handler fires inside delay().
+      echo <text>  → echoes text back
+      stop         → closes the connection
 
+    Author: tobychui
+*/
+
+if (!requirelib("websocket")) {
+    console.log("WebSocket library load failed");
+} else if (!websocket.upgrade(120)) {
+    // upgrade() also overrides delay() with a message-pumping version
+    console.log("WebSocket upgrade failed");
+} else {
     console.log("WebSocket opened");
-    return true;
+    runMode1();
+    websocket.close();
+    console.log("WebSocket closed");
 }
 
-function commandLoop() {
-    websocket.send("Connected. Commands: echo <text> | stop");
+// ── MODE 1: blocking read with optional millisecond timeout ──────────────────
+function runMode1() {
+    websocket.send("[Mode 1] Blocking read with timeout. Commands: echo <text> | stop | mode2 | mode3");
 
     while (true) {
-        var msg = websocket.read();
+        // Block up to 30 s waiting for a message; returns null on timeout, false if closed
+        var msg = websocket.read(30000);
 
-        // null means the connection was closed or timed out
-        if (msg == null) {
-            console.log("WebSocket read returned null — closing");
+        if (msg === false) {
+            // Connection was closed remotely
             break;
         }
+        if (msg === null) {
+            // 30-second idle timeout — send a ping to keep things alive
+            websocket.send("[Mode 1] Still here. 30 s idle timeout reached.");
+            continue;
+        }
 
         msg = msg.trim();
 
         if (msg === "stop") {
             websocket.send("Bye!");
-            break;
+            return;
+        } else if (msg === "mode2") {
+            websocket.send("Switching to Mode 2 (available polling)...");
+            runMode2();
+            return;
+        } else if (msg === "mode3") {
+            websocket.send("Switching to Mode 3 (onMessage callback)...");
+            runMode3();
+            return;
         } else if (msg.indexOf("echo ") === 0) {
             websocket.send(msg.slice(5));
-        } else if (msg === "") {
-            // ignore empty messages
+        } else if (msg !== "") {
+            websocket.send("[Mode 1] Unknown command: '" + msg + "'");
+        }
+    }
+}
+
+// ── MODE 2: available() + non-blocking read ───────────────────────────────────
+function runMode2() {
+    websocket.send("[Mode 2] available() polling. Commands: echo <text> | stop | mode1");
+
+    while (true) {
+        if (websocket.isClosed()) {
+            break;
+        }
+
+        var pending = websocket.available();
+        if (pending > 0) {
+            // Read all queued messages without blocking
+            for (var i = 0; i < pending; i++) {
+                var msg = websocket.read(0); // 0 = block; channel already has data
+                if (msg === false) { return; }
+                if (msg === null)  { continue; }
+
+                msg = msg.trim();
+                if (msg === "stop") {
+                    websocket.send("Bye!");
+                    return;
+                } else if (msg === "mode1") {
+                    websocket.send("Switching to Mode 1...");
+                    runMode1();
+                    return;
+                } else if (msg.indexOf("echo ") === 0) {
+                    websocket.send(msg.slice(5));
+                } else if (msg !== "") {
+                    websocket.send("[Mode 2] Unknown command: '" + msg + "'");
+                }
+            }
         } else {
-            websocket.send("Unknown command: '" + msg + "'");
+            // Nothing waiting — report queue depth and sleep briefly
+            websocket.send("[Mode 2] Queue empty (available=" + pending + "). Sleeping 500 ms...");
+            delay(500); // delay() is now message-pumping, but onMessage is null here
         }
     }
 }
 
-if (setup()) {
-    commandLoop();
-    websocket.close();
-} else {
-    console.log("WebSocket setup failed");
+// ── MODE 3: onMessage callback ────────────────────────────────────────────────
+function runMode3() {
+    websocket.send("[Mode 3] onMessage callback. Commands: echo <text> | stop | mode1");
+
+    var lastMessage = "";
+
+    // Assign the async handler — fires inside delay() on the script's goroutine
+    websocket.onMessage = function(msg) {
+        // msg = { data: string, timestamp: number, type: number }
+        lastMessage = msg.data;
+        console.log("onMessage fired at " + msg.timestamp + " ms: " + msg.data);
+    };
+
+    while (true) {
+        if (lastMessage !== "") {
+            var msg = lastMessage.trim();
+            lastMessage = "";
+
+            if (msg === "stop") {
+                websocket.send("Bye!");
+                websocket.onMessage = null;
+                return;
+            } else if (msg === "mode1") {
+                websocket.send("Switching to Mode 1...");
+                websocket.onMessage = null;
+                runMode1();
+                return;
+            } else if (msg.indexOf("echo ") === 0) {
+                websocket.send(msg.slice(5));
+            } else if (msg !== "") {
+                websocket.send("[Mode 3] Unknown command: '" + msg + "'");
+            }
+        }
+
+        if (websocket.isClosed()) {
+            break;
+        }
+
+        // delay() pumps the inbound channel and fires onMessage for each frame
+        delay(100);
+    }
+
+    websocket.onMessage = null;
 }