Skip to content

Serve multiple WebUIs from HTTP/UIDir at /ui/<name>/ - #1885

Merged
MitchBradley merged 17 commits into
mainfrom
HttpIndexFile
Oct 12, 2026
Merged

MitchBradley merged 17 commits into
mainfrom
HttpIndexFile

Conversation

@MitchBradley

@MitchBradley MitchBradley commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Builds on #1916 (HashFS locking, WebDAV rehashing and other general fixes, split out of this PR). Merge that first; the HashFS, WebDAV, MIME and logging changes listed here belong to it.

Summary

Several WebUIs can be present at once, each in its own subdirectory of a new $HTTP/UIDir, and each browser picks one by URL. A build too large for LocalFS can live on SD.

  • $HTTP/UIDir (default /sd/ui): each subdirectory holds one WebUI. Names are limited to [A-Za-z0-9._-]; empty, . and .. path components are rejected, so requests cannot escape the directory.
  • /ui/ lists the WebUIs that have an index.html[.gz], marking the default.
  • /ui/localfs/ is the WebUI in the LocalFS root, the one / has always served. It's listed, can be chosen as HTTP/DefaultUI, and stays reachable when HTTP/DefaultUI redirects / elsewhere. A /ui-aware WebUI served from there keeps its state files in the LocalFS root, where they always were. The name is reserved: a subdirectory of HTTP/UIDir called localfs is hidden.
  • /ui/<name>/ serves that WebUI's index; /ui/<name> redirects there, keeping the query string, so relative URLs resolve beneath it. Other files under it are served inline.
  • /ui files are served one at a time. SD is mounted with room for only two open files, and a job or a PUT may hold one, while a browser loading a multi-file WebUI asks for about six at once. While one file is being sent, later requests are paused and resumed in order when its connection closes. myStreamFile() takes an optional callback, run on disconnect, for this.
  • $HTTP/DefaultUI: / redirects (302) to /ui/<name>/ if that index exists; otherwise / behaves as before (LocalFS index.html, then the built-in page). ?forcefallback=yes still skips it.
  • PUT /ui/<name>/<path> writes a file in that WebUI's directory, so a WebUI can keep its state files (e.g. preferences.json) apart from other WebUIs. It creates the WebUI's directory and any subdirectories in <path> as needed (only within HTTP/UIDir), so a new WebUI can be installed with a single PUT /ui/<name>/index.html.gz. It writes a per-request temporary file (<path>.part<n>) and renames it into place only after checking that everything arrived, so a failed or overlapping upload never replaces the file with a partial one. It checks free space, removes the temporary file if the client disconnects, clears stale temporary files of the same target, rehashes, and is refused with 503 if motion starts (HTTP/BlockDuringMotion). A non-chunked form-encoded body (application/x-www-form-urlencoded) is refused with 415 rather than misread as empty. A chunked one reaches the body handler and is written as-is. A PUT with no body creates an empty file. PUT /ui/<name>/ (no file name) is refused with 405, and so are methods other than GET/HEAD on /ui/ and on /ui/<name> without the slash.

This replaces the earlier $HTTP/IndexFile setting in this PR.

Per-WebUI sessions (opt-in)

All tabs and windows of a browser profile share the sessionId cookie, so loading one WebUI closed the WebSocket channels of any other. Now:

  • Loading /ui/<name>/ also sets a uiSession cookie with Path=/ui/<name>/, and reloading it closes only that WebUI's previous channels, not a root WebUI's in another tab.
  • A WebSocket at /ui/<name>/ and commands at /ui/<name>/command[_silent] carry that cookie, and it takes precedence over sessionId.
  • A WebUI that uses relative URLs for its WebSocket and commands therefore gets channels isolated from other WebUIs. One that uses absolute URLs is unchanged.
  • sessionId now has an explicit Path=/, so a cookie set by a /ui page is not narrowed to that directory. Cookies are matched by exact name.

Hashing

  • Each WebUI's index.html[.gz] is hashed at startup, like top-level LocalFS files, so a reload can be answered with 304 during motion. Other files under HTTP/UIDir are hashed on first request outside motion and cached, up to 64 in all, so a multi-file WebUI can also be revalidated during motion without the cache growing without bound.
  • HashFS keys these by full path so they cannot collide with the bare-filename keys of LocalFS files. SD paths are compared in lower case, since FAT ignores case: a PUT through /ui/abc/ must not leave /ui/ABC/ answering 304 for the old file. Changing HTTP/UIDir forgets the old directory's hashes; it does not hash immediately, since that could happen during motion.
  • The cache is locked; a hash computed across an invalidation is not stored, and an on-demand hash never overwrites a newer one from a file change.
  • Deleting or renaming a directory drops hashes beneath it; WebUI's deletedir now does so. WebDAV PUT/MOVE/DELETE keep hashes current (also fixing a pre-existing gap for LocalFS).
  • HashFS does no hashing unless the HTTP server is running; $File/ShowHash computes on demand in that case.

WebUI Uploader

The built-in page (?forcefallback=yes, or / with no WebUI installed) is reworked to manage WebUIs and the SD card:

  • A file panel for LocalFS, SD, or the WebUIs in HTTP/UIDir. The WebUIs view only appears if the firmware reports an HTTP/UIDir it can use.
  • Buttons to open each WebUI, including /ui/localfs/.
  • Uploads are now one file at a time; the previous multi-file selection is gone.

Also

  • "fopen failed" is now log_debug instead of log_info: probing for files that may not exist is routine.
  • MIME types for .svg, .json, .mjs, .wasm, .woff[2], .webmanifest, .webp and .map, which multi-file WebUIs use.
  • Directory listing and the WebUI index walk use the error_code forms, so a card pulled mid-listing can't throw out of an async_tcp callback.

Known limitations

  • Cached hashes for SD files go stale if the card is edited elsewhere (e.g. in a PC) or swapped while FluidNC runs; reboot after doing that.
  • On FAT, which won't rename onto an existing file, the target is removed before the temporary file is renamed into place, so a power loss at that instant leaves only <name>.part<n> (which the next PUT of that file clears).
  • The reload-blocked page's Pause/Stop buttons go to /, i.e. the default WebUI.
  • When motion starts mid-upload, discarding the partial file closes and deletes it on SD then. That's a single small operation.
  • The 64-entry cap on cached /ui asset hashes never evicts, so asset names left behind by an old build can fill it. After that, new assets get no 304 during motion.
  • A long download to a slow client delays other /ui files until it finishes, because they're served one at a time.
  • With the default /sd/ui, startup hashing attempts an SD mount, which is a brief, quiet delay if SD is configured but no card is present.

Documentation

Wiki: bdring/fluidnc-wiki-content#8 (merge together with this PR, for v4.1.2).

Testing

  • pio run -e wifi builds; wifi_s3 builds with the directory-creation change.
  • Reviewed by an agent (three passes). Findings were addressed, apart from those under Known limitations.
  • The one-at-a-time serving, /ui/localfs/, 405s, lower-case SD keys and the Uploader view change build (pio run -e wifi) but have not been run yet.
  • On the wasm build (with wasm demo: run WebUIs against FluidNC's own web server #1913 and Make FluidPath::canonPath() idempotent #1914), headless HTTP tests, including: form-encoded PUT refused with the file untouched, replace with no temporary files left, stale temporary files cleared, chunked PUT, the 64-file cache cap and asset 304s, MIME types, and a root WebUI's WebSocket surviving a /ui reload. Also: PUT installs a new WebUI with no prior directory (even with HTTP/UIDir absent), creates nested subdirectories, works for an empty-body PUT, and still refuses ..; /ui/ lists the WebUIs, /ui/<name>/ serves each, and HTTP/DefaultUI redirects /.
  • Tested in a wasm demo build, including WebUI2 with /ui/ support.

🤖 Generated with Claude Code

MitchBradley and others added 2 commits September 30, 2026 08:50
A WebUI build too large for LocalFS can now live elsewhere, e.g. on SD.
When $HTTP/IndexFile is set, "/" serves that file (or its .gz form)
first, falling back to LocalFS index.html.  Everything else, including
WebUI's preferences and macro files, stays in LocalFS as before.

The index file is hashed in advance, like top-level LocalFS files, so
ETag/304 works for it, including during motion.  HashFS keys it by full
path so it cannot collide with the bare-filename keys of LocalFS files.
Deleting or renaming a directory drops the hashes beneath it, and
WebDAV writes, moves and deletes now keep the hashes current.

The hashes are only used by the HTTP server, so HashFS now does no
hashing unless the server is running; $File/ShowHash computes on demand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Probing for a file that may not exist - index.html before index.html.gz,
HTTP/IndexFile before LocalFS index.html - is routine, and callers report
failures that matter with their own errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings September 30, 2026 19:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Shared-state races and motion-time SD mounting remain unresolved, and this version has not been hardware-tested.

Review effort: Balanced
Findings: 2 High severity · 3 Medium severity

Open (5)
What changed in this PR

This PR lets FluidNC serve the WebUI index from a configured filesystem path while keeping other WebUI files on LocalFS.

Changes:

  • Add $HTTP/IndexFile with LocalFS and built-in fallback behavior.
  • Extend HashFS and WebDAV hash updates to support the selected index and filesystem changes.
  • Lower routine file-open failure logging to debug level.
File Description
FluidNC/​src/​WebUI/​WebUIServer.cpp Selects and serves the configured index.
FluidNC/​src/​WebUI/​WebDAV.cpp Updates hashes after WebDAV file operations.
FluidNC/​src/​HashFS.h Declares index-path and hash-cache controls.
FluidNC/​src/​HashFS.cpp Caches index hashes and manages hash entries.
FluidNC/​src/​FileStream.cpp Reduces logging for failed file probes.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread FluidNC/src/HashFS.cpp Outdated
Comment thread FluidNC/src/WebUI/WebUIServer.cpp Outdated
Comment thread FluidNC/src/HashFS.cpp Outdated
Comment thread FluidNC/src/HashFS.cpp Outdated
Comment thread FluidNC/src/WebUI/WebUIServer.cpp Outdated
…tion

- Guard the hash map and the index path with a mutex.  They are used by
  the web server task and by file commands on the protocol task.  The
  lock is never held across file I/O.  The map is now private; listing
  takes a snapshot via HashFS::hashes().
- When HTTP/IndexFile changes, drop the old file's full-path hashes so
  they are neither listed nor reused.  A top-level LocalFS entry stays.
- During motion, myStreamFile() decides 304/fallback/blocked from the
  canonical name before constructing a FluidPath, which would mount an
  SD card on the async_tcp task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Cross-task hash publication can leave stale ETags, and the SD-backed motion-time path warrants final hardware review.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
Resolved since last review (5)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Out-of-order hash publication stores stale ETags

FluidNC/​src/​HashFS.cpp:181

Overlapping writes to the same path can publish hashes out of order. WebDAV now rehashes a PUT on the web task, while file commands can write and rehash that path on the protocol task. If the first hash finishes before the second write but pauses before this assignment, the second rehash can store the new digest first and this assignment then replaces it with the old one. A request for the changed file can receive a stale ETag or an incorrect 304. Order hash publication per path with write/rehash generations (or serialize writes and rehashes for each path); locking only the map assignment does not preserve file-update order.

Comment thread FluidNC/src/HashFS.cpp Outdated
Hashes are computed with the lock released, so an entry could be
invalidated - a delete, an IndexFile change, enable(false) - and then
restored by a hash that finished afterwards.  Every invalidation now
bumps a generation counter, and a hash is stored only if the generation
is unchanged since the file was read.  rehash_file() re-reads on a
mismatch, a few times at most, and otherwise leaves no hash, which is
safe: no ETag rather than a stale one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MitchBradley added a commit that referenced this pull request Oct 9, 2026
"From LocalFS (index.html / index.html.gz)" loads the WebUI the way
FluidNC's own web server finds it on a controller (WebUIServer.cpp:
handle_root -> myStreamFile("index.html")): LocalFS index.html, else
index.html.gz. Whatever WebUI build is uploaded there persists across
visits like a controller's own, and any build can be tried without a
GitHub release. If neither file exists, the status line says so.

The $HTTP/IndexFile setting (PR #1885), which the firmware tries first,
is not supported yet: it is defined in WebUI/WebUIServer.cpp, which the
WASM build leaves out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MitchBradley added a commit that referenced this pull request Oct 9, 2026
"From LocalFS (index.html / index.html.gz)" loads the WebUI the way
FluidNC's own web server finds it on a controller (WebUIServer.cpp:
handle_root -> myStreamFile("index.html")): LocalFS index.html, else
index.html.gz. Whatever WebUI build is uploaded there persists across
visits like a controller's own, and any build can be tried without a
GitHub release. If neither file exists, the status line says so.

The $HTTP/IndexFile setting (PR #1885), which the firmware tries first,
is not supported yet: it is defined in WebUI/WebUIServer.cpp, which the
WASM build leaves out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MitchBradley and others added 2 commits October 9, 2026 14:27
Several WebUIs can now be present at once, each in a subdirectory of
$HTTP/UIDir (default /sd/ui), and each browser picks one by URL:

- /ui/ lists the WebUIs that have an index.html[.gz].
- /ui/<name>/ serves that WebUI's index.html; /ui/<name> redirects there
  so relative URLs resolve beneath it.  Other files under /ui/<name>/
  are served inline, and PUT writes them (via a .part file, renamed on
  completion; refused during motion when HTTP/BlockDuringMotion is on).
- $HTTP/DefaultUI names a WebUI that / redirects to, if its index exists.
  Otherwise / behaves as before.  ?forcefallback=yes still skips it.

Sessions: loading /ui/<name>/ sets a uiSession cookie with
Path=/ui/<name>/, and a WebSocket at /ui/<name>/ and commands at
/ui/<name>/command[_silent] use it, so a WebUI that uses relative URLs
has channels separate from other WebUIs in the same browser.  A WebUI
using absolute URLs is unchanged, sharing the sessionId cookie, which
now carries an explicit Path=/ so a /ui page does not narrow it.

Each WebUI's index.html[.gz] is hashed at startup, so a reload can get a
304 during motion; other files under HTTP/UIDir are hashed on their first
request.  WebUI names are limited to [A-Za-z0-9._-], and empty, "." and
".." path components are rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MitchBradley MitchBradley changed the title Add $HTTP/IndexFile: serve WebUI's index.html from any filesystem Serve multiple WebUIs from HTTP/UIDir at /ui/<name>/ Oct 10, 2026
MitchBradley added a commit that referenced this pull request Oct 10, 2026
- vnet-host.js: the cookie jar now follows RFC 6265 path rules instead of
  keeping one cookie per name.  A cookie belongs to its Path (or the
  directory of the request that set it), is sent only on requests at or
  under that path, most specific first, and Max-Age=0 or a past Expires
  removes it.  FluidNC's sessionId is set for "/", and the HTTP/UIDir
  WebUIs of #1885 set a uiSession cookie for "/ui/<name>/" that must reach
  only that WebUI; sending one UI's uiSession everywhere defeated that
  isolation.
- BrowserAsyncTCP: keep the client and local addresses in network byte
  order, as lwIP does and IPAddress(uint32_t) expects; the log showed
  requests "from 1.0.0.127".
- vnet_unit_test.mjs: cookie-path tests (they fail on the previous jar).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PUT /ui/<name>/<path> wrote <path>.part directly, so it failed with 500
unless HTTP/UIDir/<name>/ (and any subdirectory in <path>) already
existed; installing a new WebUI over HTTP took a WebDAV MKCOL first.
Create the parent directories, both for a PUT with a body and for an
empty one.  uiPutPath() has already confined the path to HTTP/UIDir, so
only directories there can be created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MitchBradley added a commit that referenced this pull request Oct 10, 2026
canonPath() turns a path into a filesystem path for a volume.  Given a path
it had already resolved, it should return it unchanged.  On ESP32 it does,
because the volume prefixes ("/littlefs", "/sd") are absolute and its
volume-name check maps them to themselves.  On native ports the prefixes
are relative ("native_localfs", "native_sd"), so a resolved path was
treated as relative to the default volume and got a second prefix:
"native_localfs/native_sd/...".

Any code that builds a FluidPath from a string that came from a FluidPath
hit this.  #1911 and #1912 fixed such call sites in the web server and
WebDAV one by one; the HTTP/UIDir WebUIs in #1885 do it in six more
places.  Return a path that already starts with a volume prefix unchanged,
so the whole class goes away.  ESP32 behavior is unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
MitchBradley and others added 4 commits October 9, 2026 20:51
PUT /ui/<name>/<path>:
- A body the server diverted into form parsing (urlencoded or key=value
  text/plain), or a failed allocation of the PUT state, was taken as "no
  body" and replaced the file with an empty one, reporting success.  Only
  a PUT whose Content-Length is 0 now creates an empty file; otherwise the
  PUT fails with 415 or 500.
- The final flush happens on close, where an error goes unreported, so a
  truncated temporary file could replace the target.  Check that its size
  matches the bytes written (and Content-Length) before renaming.
- Each PUT writes its own temporary file (<path>.part<n>), so overlapping
  PUTs to one target no longer interleave.  Rename first, and remove the
  target only when the rename fails because it exists (FAT); once the
  target is gone, keep the temporary file rather than delete the only copy.
- Motion is checked on every chunk, not just the first.
- PUT to /ui/<name>/ (no file name) is refused instead of emptying the
  index, and /ui/<name>/command[_silent] are not file paths.

Also:
- Loading /ui/<name>/ closes only that WebUI's uiSession channels; it
  falls back to the browser-wide sessionId only when there is no
  uiSession, so it no longer disconnects a root WebUI in another tab.
- HashFS caches only each WebUI's index.html[.gz] under HTTP/UIDir, so a
  WebUI's other files no longer grow the cache without bound.
- rehash_file() invalidates hashes already in flight, so a hash of a
  file's old contents can't be stored after the new one.
- The /ui/ listing and the WebUI index walk use the error_code forms of
  directory iteration, so a card pulled mid-listing can't throw out of an
  async_tcp callback.
- MIME types for .svg, .json, .mjs, .wasm, .woff[2], .webmanifest, .webp
  and .map, which multi-file WebUIs under /ui use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Cache a WebUI's other files again, not just its index, up to 64 of them
  in all.  Without them, a multi-file WebUI reloaded during motion got
  304 for its index but 503 for every asset, and outside motion each
  asset was read twice per request (once to hash it).  The cap keeps many
  or uniquely named files from growing the cache without bound.
- Replace the global invalidation added to rehash_file() with a per-file
  rule: an on-demand hash() never overwrites an existing entry, since one
  that appeared meanwhile came from rehash_file(), whose caller has just
  changed the file.  The global bump made unrelated concurrent rehashes
  invalidate each other.
- A chunked PUT (Transfer-Encoding, now kept by the header filter) counts
  as having a body even without a Content-Length, so it can't take the
  empty-file path.
- Remove a target's stale "<name>.part<n>" files, left by a reboot, power
  loss or failed rename, when a new PUT of that target starts; files of
  PUTs still in progress are kept.
- Correct the comment on which session a /ui page load closes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The /files and /upload createdir action used create_directory, so it
failed when the parent did not exist.  With HTTP/UIDir defaulting to
/sd/ui, a card without a ui directory could not get a WebUI added from
the built-in page.  It now creates any missing parents, as a PUT under
/ui/<name>/ already does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The page FluidNC serves when no WebUI is installed (and at
/?forcefallback=yes) is reworked as the "WebUI Uploader":

- "Go to WebUI:" buttons for the default WebUI (/) and each WebUI in
  HTTP/UIDir, or a note that none are present.
- "Manage Files on:" LocalFS, SD or WebUIs.  The WebUIs view browses
  HTTP/UIDir, wherever it is, and links files at /ui/<name>/...; SD
  files link at /sd/....  A missing directory lists as empty.
- One Upload button, which uploads the chosen file at once, on the
  same row as Refresh.
- Help links to the wiki's WebUI Uploader page; the Firmware and
  Interface links are gone.  The /ui/ list page's link to the page is
  renamed to match.

The gzipped page shrinks from 6828 to 6341 bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MitchBradley and others added 3 commits October 11, 2026 19:01
The /ui code was ~500 self-contained lines in WebUIServer.cpp.  It now
lives in WebUI/UiDir.cpp behind a small interface - UiDir::init() for
the settings, addHandlers(), announce() for HashFS, and
redirectToDefault() for / - so WebUIServer.cpp keeps only those hooks
and the session-cookie changes, which belong to the server.

UiDir is a friend of WebUI_Server for myStreamFile(), send404Page(),
handleReloadBlocked() and the command handlers; getCookie() and
http_block_during_motion are now declared in WebUIServer.h.  No
behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A store bumps the generation too, so an earlier hash of the same file
can't overwrite a newer one; erasing a path that can't be cached (not
top-level LocalFS, not under HTTP/UIDir, nothing beneath it) doesn't, so
unrelated SD activity doesn't abort hashes in progress; and hashFile()
keeps read()'s result signed, so a read error can't overrun the buffer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SD is mounted with room for only two open files, and a job or a PUT may
hold one, while a browser loading a multi-file WebUI asks for ~6 at
once: the third fopen() failed and that asset got a 404.  /ui GETs are
now served one at a time.  While one is being sent, later requests are
paused (AsyncWebServerRequest::pause()) and resumed in order when its
connection closes.  myStreamFile() takes an optional onDone callback,
run on disconnect, to release the turn.

With HTTP/DefaultUI set, / redirected away and the WebUI in the LocalFS
root could not be reached.  It is now also /ui/localfs/ - the same
mapping as /ui/<name>/, onto the LocalFS root - so it can be listed,
chosen as HTTP/DefaultUI, and keep a /ui-aware WebUI's state where it
always was.  The Uploader's button for it now goes there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…der view

- PUT/POST/DELETE to /ui/, and to /ui/<name> without the slash, now get
  405 instead of the list page or a redirect.
- The /ui/<name> -> /ui/<name>/ redirect keeps the query string.
- FAT ignores case, so HashFS compares SD paths in lower case: a PUT
  through /ui/abc/ no longer leaves /ui/ABC/ answering 304 for the old
  file.
- The Uploader's "WebUIs" view stays hidden unless the firmware reports
  an $HTTP/UIDir it can use; choosing it otherwise fetched HTML and threw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MitchBradley and others added 2 commits October 11, 2026 19:49
embedded/build.py wrote the array as const char, so regenerating the
page with it turned main's const uint8_t into char.  The gzip data has
bytes above 0x7F, which clang rejects as narrowing - the wasm build
failed.  build.py now writes uint8_t too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
build.bat wrote a stale path (../FluidNC/nofile.h) with the old
const char array type; build.py is the generator for NoFile.h.
header.txt and footer.txt were used only by build.bat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MitchBradley
MitchBradley merged commit 3e1fad4 into main Oct 12, 2026
6 checks passed
MitchBradley added a commit that referenced this pull request Oct 12, 2026
- vnet-host.js: the cookie jar now follows RFC 6265 path rules instead of
  keeping one cookie per name.  A cookie belongs to its Path (or the
  directory of the request that set it), is sent only on requests at or
  under that path, most specific first, and Max-Age=0 or a past Expires
  removes it.  FluidNC's sessionId is set for "/", and the HTTP/UIDir
  WebUIs of #1885 set a uiSession cookie for "/ui/<name>/" that must reach
  only that WebUI; sending one UI's uiSession everywhere defeated that
  isolation.
- BrowserAsyncTCP: keep the client and local addresses in network byte
  order, as lwIP does and IPAddress(uint32_t) expects; the log showed
  requests "from 1.0.0.127".
- vnet_unit_test.mjs: cookie-path tests (they fail on the previous jar).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MitchBradley
MitchBradley deleted the HttpIndexFile branch October 12, 2026 06:05
MitchBradley added a commit that referenced this pull request Oct 12, 2026
* wasm: run FluidNC's own web server inside the wasm module

The wasm build now includes the real WebUI/ web server -- HTTP routes,
WSChannel and WebDAV on unmodified ESPAsyncWebServer -- so a browser can
use it exactly as it uses a controller.  A web page cannot listen on a
socket, so the sockets come from JS instead:

- BrowserAsyncTCP/: the AsyncTCP API that ESPAsyncWebServer builds on,
  over numbered virtual connections that JS opens, feeds and closes with
  fluidnc_vconn_open/send/close; output returns through
  self.fluidncOnVconnData/Close.  All callbacks run on one async_tcp
  thread, as on lwIP's task on ESP32.
- VirtualNet.cpp: the network module -- always up, at 192.168.0.1 (the
  access-point address) and <$Hostname>.local -- providing ESP800/ESP111
  and the $I and ESP420 network lines.
- env:wasm uses Arduino-Emulator, like the posix envs.  Library builds
  are switched to emcc too (previously only project sources were), FS is
  exported to JS, and wasm/IPAddress.h defers to Arduino-Emulator's.
- ESPAsyncWebServer's chunked in-flight pacing is off for this env: with
  instant acks it stalls responses whose source is briefly empty, which
  truncated /command replies such as [ESP400] (upstream fix proposed in
  ESP32Async/ESPAsyncWebServer#488).
- The postMessage WebUI bridge this replaces is removed: ShimChannel.cpp,
  wasm_fs_bridge.cpp and FwInfo.cpp.
- vconn_smoke_test.mjs: a headless Node test of HTTP, upload and
  WebSocket over virtual connections.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* wasm demo: a virtual network from the page to FluidNC's web server

A page can't listen on a socket, so these carry a WebUI's traffic to the
web server inside the wasm module over its virtual connections.  A WebUI
loaded from FluidNC then talks ordinary HTTP and WebSocket, unmodified.

- vnet-sw.js: a Service Worker (scope device/) that relays every
  same-origin request from the device frame -- /, /command, /files,
  /upload, /sd/... -- to the demo page and streams the response back.  It
  decodes gzip (browsers don't decode a Response a worker builds), adds the
  COEP header the isolated page requires of a nested document, injects the
  WebSocket stand-in into HTML, and keeps redirects inside its scope.
- vnet-ws-shim.js: replaces window.WebSocket in the device frame, since
  Service Workers can't see WebSockets.  It treats the device's names and
  the page's own host (on any port) as the device, keeps same-device links
  and window.open inside the frame, and reports in-page URL changes.
- vnet-host.js: on the demo page, the HTTP/1.1 and WebSocket client over
  fluidnc_vconn_*, with a cookie jar (browsers drop Set-Cookie from
  worker-built responses), and helpers for device names and URLs.  Its
  createClient() core has no DOM dependencies.
- vnet_client_test.mjs: drives that client against the wasm server in
  Node -- gzip, cookies, chunked replies, uploads, the WebUI install
  sequence, and a WebSocket session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* wasm demo: run WebUIs the way a controller does

The demo now behaves like a board on the network.  A WebUI runs only by
browsing to FluidNC, which serves it -- or its built-in file manager --
from LocalFS; the demo needs no WebUI-specific code.

- An address bar preset to fluidnc.local, with Back/Forward/Reload:
  paths work as on a board (/?forcefallback=yes, /files?path=/,
  /command?plain=...), only <$Hostname>.local and the device's IP
  resolve, and other names, https: and other ports fail as a browser
  would report it.  ?browse=<address> opens the demo at an address, and a
  navigation that escapes the device frame is sent back into it.
- Install WebUI: fetches a project's latest GitHub release (through
  webui-proxy, which now returns the release's index.html.gz unchanged and
  which serve.py now also implements for local use) or a local build, and
  uploads it to LocalFS as index.html.gz through FluidNC's /files route,
  removing any old index.html/index.html.gz.  As after an upload to a
  board, the user browses to FluidNC to run it.
- The Upload/Download panel goes through FluidNC too: /files and /upload
  for uploads, their JSON for listings, WebDAV /flash and /sd for
  downloads.
- Files and NVS settings persist across reloads in IndexedDB (one saving
  tab, coordinated with Web Locks), seeded from seed-localfs/ and
  seed-sd/, with a button to reset to the built-in copies.

This supersedes #1900, whose persistence and seeding are included here;
its WebUI bridge features are replaced by the above.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* wasm demo: address review findings

- vnet-sw.js: never route a frame's requests to another tab's FluidNC.
  The frame-to-page mapping is lost when the browser stops the idle
  worker; it used to fall back to any demo page.  It now recovers by
  asking the frame for its page's token (vnet-ws-shim.js answers) and the
  pages which one owns it, and without an answer guesses only when a
  single demo page is open -- otherwise the request fails with 503.
- vnet-host.js: a Content-Length or chunked body cut short by the server
  closing is now an error, not a short success; only close-delimited
  bodies end at close.  A WebSocket closed while connect() is still
  waiting for the server no longer opens an upgrade afterwards.
- index.html: the WebUI installer checks each delete and fails if an old
  index.html survives, since FluidNC would keep serving it.
- IPAddress.h: comment named the old env:wasm_webui.
- vnet_unit_test.mjs: tests for the above against a fake module and fake
  browser clients (they fail on the previous code); the integration test
  also checks the delete replies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* wasm demo: path-scoped cookie jar; network-order loopback address

- vnet-host.js: the cookie jar now follows RFC 6265 path rules instead of
  keeping one cookie per name.  A cookie belongs to its Path (or the
  directory of the request that set it), is sent only on requests at or
  under that path, most specific first, and Max-Age=0 or a past Expires
  removes it.  FluidNC's sessionId is set for "/", and the HTTP/UIDir
  WebUIs of #1885 set a uiSession cookie for "/ui/<name>/" that must reach
  only that WebUI; sending one UI's uiSession everywhere defeated that
  isolation.
- BrowserAsyncTCP: keep the client and local addresses in network byte
  order, as lwIP does and IPAddress(uint32_t) expects; the log showed
  requests "from 1.0.0.127".
- vnet_unit_test.mjs: cookie-path tests (they fail on the previous jar).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* env:wasm: link the no-op posix mDNS

#1906 made WebUIServer and TelnetServer register mDNS services
unconditionally and added a no-op posix/Mdns.cpp so native builds still
link.  env:wasm takes only selected posix/ files, so it needs this one
too, or WebUIServer's Mdns::add/remove are undefined at link time.  (On a
base before #1906 the file doesn't exist yet and the filter line matches
nothing.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* wasm demo: give the device frame the device's own URLs; log traffic

The Service Worker served the device frame under device/, and the prefix
showed: a WebUI at /ui/webui2/ saw /device/ui/webui2/ as its own path.
WebUI 2's HTTP/UIDir support derives its base from location.pathname, so
it fell back to root-UI behavior and saved its preferences to LocalFS.
Navigations to a bare "/" also escaped the scope to the demo page.

The worker now covers the whole site and routes each request by who made
it: frame navigations and requests from nested windows go to FluidNC with
exactly their paths; the demo page, its workers and top-level tabs use the
network.  The device frame therefore sees the same URLs as on a
controller -- /, /ui/<name>/, /#/dashboard and the reload helper pages all
behave as they would on a board.  The frame guard and Location rewriting
are gone, the old device/-scoped registration is removed, and the demo
must be served from the root of its origin.

The virtual network's traffic is now logged to the DevTools console: a
line per HTTP exchange (method, path, status, bytes each way, time; the
demo page's own requests marked "(demo)") and per WebSocket open/close
with message and byte counts.  FluidNCVnet.logFrames = true adds each
WebSocket message; FluidNCVnet.log = false silences it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Demo: style the controls and frame borders

The demo's buttons, drop-downs and address bar get a larger
sans-serif font, padding and rounded corners: blue action buttons,
grey navigation buttons, and a red outline on Reset, whose
explanation moves into its tooltip.  The terminal and WebUI frames get
a heavier border.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* wasm demo: install a WebUI to /ui/<name>/ as well as LocalFS

"Install WebUI to" chooses LocalFS index.html.gz, as before, or
/ui/<name> (HTTP/UIDir), with a name defaulting to the project's (figui,
webui2, webui3) or the local file's.  The latter installs through
FluidNC's HTTP interface as a browser on the LAN would: a WebDAV DELETE
of any old index.html, which would be served in preference, then
PUT /ui/<name>/index.html.gz, which creates the directory.  The name
localfs is FluidNC's for the LocalFS root, so it installs there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
MitchBradley added a commit that referenced this pull request Oct 12, 2026
* HashFS: lock the cache, skip it when HTTP is off; keep it current from WebDAV

HashFS's map was read and written from both the web server task and the
protocol task (file commands) with no lock.  It is now guarded by a
mutex, held only around map access, never across file I/O.  Since a hash
is computed unlocked, every invalidation bumps a generation count, and a
hash is stored only if no invalidation happened meanwhile (retrying a
few times, else leaving no hash rather than a stale one).

- The hashes are only used by the HTTP server, so nothing is hashed
  unless it is running; $File/ShowHash computes on demand in that case.
  showLocalFSHashes lists a locked snapshot.
- Deleting or rehashing a file outside the top level of LocalFS no
  longer drops the hash of a top-level file with the same name.
- WebDAV PUT, MOVE and DELETE now keep the hashes current; WebDAV writes
  to LocalFS never rehashed before.
- myStreamFile() looks up the cached hash during motion by canonical
  name, so it never mounts an SD card then.
- createdir creates missing parent directories.
- More MIME types (svg, json, mjs, wasm, woff/woff2, webmanifest, webp,
  map).
- "fopen failed" is logged at debug level: probing for files that may
  not exist is routine, and callers report real failures themselves.

Split out of #1885, which builds on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* HashFS: a store invalidates too; only LocalFS paths invalidate; signed read length

- A successful store now bumps the generation, so a hash of the same
  file that started earlier (e.g. while it was still being written) can
  no longer overwrite it with one of the old content.
- Erasing a path that is never cached (SD, LocalFS subdirectories) no
  longer bumps the generation, so SD activity does not abort LocalFS
  hashes in progress.
- hashFile() kept FileStream::read()'s int result in a size_t, so a
  read error (-1) became SIZE_MAX and overran the buffer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* copyFile: keep read()'s result signed and report a read error

copyFile() kept FileStream::read()'s int result in a size_t, so a read
error (-1) became SIZE_MAX and write() overran the 512-byte buffer.  Now
the loop stops and the copy fails with FsFailedRead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants