- Use
freenet localfor routine River UI development and manual testing. Local mode exercises the app without spinning up multiple network peers. - Use
freenet networkonly when validating peer-to-peer sync or multi-node behaviour. Document the scenario when you switch modes.
River is a decentralized group chat application built on Freenet and consists of:
- Rust + Dioxus web UI compiled to WebAssembly
- Smart contracts (room and web container) deployed on Freenet
- Delegates that execute contract logic and perform background tasks
- Shared
common/crate with data types and crypto helpers used across UI and contracts
cargo make dev-example # UI with example data, no Freenet connection
cargo make dev # Standard development server
cargo make build # Full release build
cargo make build-ui # UI artifacts only
cargo make build-ui-example-no-sync# UI build with example data and no synccargo make test # Full workspace tests
cargo make test-room-contract
cargo make test-web-container
cargo make test-common
cargo make test-chat-delegate
cargo make test-web-container-integrationFor rapid UI iteration without publishing to Freenet:
# From the ui/ directory
cd ui
# Local only (127.0.0.1)
dx serve --port 8082 --features example-data,no-sync
# Accessible from other machines (0.0.0.0)
dx serve --port 8082 --addr 0.0.0.0 --features example-data,no-syncFeatures:
example-data- Populates UI with sample rooms, members, messages, and reactionsno-sync- Disables Freenet sync (no WebSocket connection required)
Tips:
- dx serve auto-rebuilds on file changes, but sometimes needs manual restart
- Check
/tmp/dx-serve-new.logfor build errors if UI doesn't update - Use
--addr 0.0.0.0when testing from remote machines (e.g., technic → nova) - Example data includes reactions on messages for testing the emoji picker UI
Always run Playwright tests before publishing to Freenet. Republishing takes minutes, so catch layout issues locally first.
# One-time setup: install browsers
cargo make test-ui-playwright-setup
# Build UI with example data (no Freenet connection needed)
cargo make build-ui-example-no-sync
# Serve built files (do NOT use dx serve — it auto-rebuilds and can serve stale content)
cd target/dx/river-ui/release/web/public && python3 -m http.server 8082 &
# Run all tests across Chromium, Firefox, WebKit, mobile Chrome, mobile Safari
cd ui/tests && npx playwright test
# Run specific browser or test
npx playwright test --project=chromium
npx playwright test --project=webkit --grep "iframe"
npx playwright test --project=mobile-safari --grep "Mobile"Test coverage:
- Desktop 1280px: 3-column layout, no overflow
- Tablet 768px: narrower sidebars via CSS clamp
- Breakpoint 767px: mobile mode (single panel)
- Mobile 480px: view switching (hamburger, members, back buttons)
- Mobile 320px: small screen readability
- Desktop recovery after mobile resize
- Sandboxed iframe embedding (matching Freenet gateway)
Important Tailwind v4 note: The @source "../src/**/*.rs" directive in ui/assets/tailwind.css is REQUIRED. Without it, Tailwind v4 won't scan Rust files for class names, and responsive utilities like md:flex won't be generated.
Two test directories exist:
ui/tests/— Layout/visual tests againstdx buildwith example data (runs in CI)e2e-test/— Integration tests against a real Freenet node (manual)
The Playwright MCP plugin is enabled in .claude/settings.local.json. Use it
to interactively test the UI against a running local node — no manual browser
needed.
Testing against example data (no Freenet node required):
# Build and serve with example data
cargo make build-ui-example-no-sync
cd target/dx/river-ui/release/web/public && python3 -m http.server 8082 &Then use Playwright MCP tools:
browser_navigate→http://127.0.0.1:8082/browser_snapshot→ inspect DOM state, verify layoutbrowser_click/browser_fill_form→ interact with UI elementsbrowser_console_messages→ check for WASM panics or JS errors
Testing against a local Freenet node (full integration):
# Publish to local node first
./scripts/local-republish.sh
# Script outputs the URL, e.g.:
# http://127.0.0.1:7510/v1/contract/web/{CONTRACT_ID}/Then use Playwright MCP tools to navigate to the published URL. This tests the full stack: WASM ↔ WebSocket ↔ Freenet node ↔ contract/delegate.
Common verification tasks with Playwright MCP:
- After UI changes: Navigate, take snapshot, verify layout renders correctly
- After message send fixes: Fill message input, click send, verify message appears
- After crash fixes: Navigate, send message, check
browser_console_messagesfor panics - Mobile simulation: Use
browser_resizeto test responsive breakpoints (767px, 480px, 320px) - Debug overlay: Navigate to
?debug=1URL, verify overlay appears and logs render
When to use Playwright MCP vs Playwright test suite:
- MCP (interactive): Exploratory testing, debugging specific issues, verifying a fix before publishing
- Test suite (
npx playwright test): Regression testing across all browsers/viewports before publishing
cargo make clippy
cargo fmtQuick publish (when cargo make publish-river works):
cargo make publish-river # Publish release build to FreenetManual publish (when automated publish fails):
The web container contract requires signed metadata with a version number higher than the current published version. When cargo make publish-river fails with version or network errors, use this workflow:
-
Check current version (by attempting to publish or checking error messages)
-
Build and sign with correct version:
# Build the UI cargo make compress-webapp # Sign with version higher than current (check error message for current version) target/native/x86_64-unknown-linux-gnu/release/web-container-tool sign \ --input target/webapp/webapp.tar.xz \ --output target/webapp/webapp.metadata \ --parameters target/webapp/webapp.parameters \ --version <CURRENT_VERSION + 1>
-
Publish to local node:
fdev -p 7509 publish \ --code published-contract/web_container_contract.wasm \ --parameters published-contract/webapp.parameters \ contract \ --webapp-archive target/webapp/webapp.tar.xz \ --webapp-metadata target/webapp/webapp.metadata
Important notes:
- The parameters file (
published-contract/webapp.parameters) determines the contract ID - always use the committed one to getraAqMhMG7KUpXBU2SxgCQ3Vh4PYjttxdSWd9ftV7RLv - The metadata contains the signature and version - regenerate it with each publish
- Version numbers must be strictly increasing - check error messages for current version
- The signing key is in
~/.config/river/web-container-keys.toml
Verify deployment:
curl -s http://127.0.0.1:7509/v1/contract/web/raAqMhMG7KUpXBU2SxgCQ3Vh4PYjttxdSWd9ftV7RLv/ | grep -o 'Built: [^<]*' | head -1Contract ID: raAqMhMG7KUpXBU2SxgCQ3Vh4PYjttxdSWd9ftV7RLv
common/: shared state types (RoomState,Member,Message,Invitation) and cryptography helpers.contracts/room-contract: manages room membership, permissions, and message history.contracts/web-container-contract: serves the compiled UI as a Freenet contract asset.delegates/chat-delegate: handles chat-specific workflows and background tasks.ui/: Dioxus UI, includingexample-dataandno-syncmodes for offline testing.
- Messages, metadata, and member nicknames are encrypted with AES-256-GCM.
- Room secrets distributed with ECIES (X25519 + AES-256-GCM).
- Secret rotation happens manually (UI button), automatically on user ban, and weekly via scheduled checks.
- Key files:
common/src/room_state/privacy.rs,secret.rs,configuration.rsui/src/util/ecies.rs,ui/src/room_data.rscommon/tests/private_room_test.rs
When delegate or contract WASM changes (due to code changes in delegates/, contracts/, or common/),
the delegate/contract key changes. Without migration, existing users lose room data.
All legacy delegate entries are defined in legacy_delegates.toml at the repo root.
This file is the only place migration entries are managed. The UI's build.rs generates
Rust code from it at compile time. CI reads it directly for validation.
When you change code that affects delegate or contract WASM:
# 1. Add old delegate hash to migration registry
cargo make add-migration
# 2. Build new WASMs and copy to all committed locations
cargo make sync-wasm
# 3. Run migration tests
cargo test -p river-core --test migration_test
# 4. Verify UI compiles with new generated code
cargo check -p river-ui --target wasm32-unknown-unknown --features no-sync
# 5. Commit everything
git add legacy_delegates.toml ui/public/contracts/ cli/contracts/
git commit -m "fix: update WASMs with delegate migration entry"cargo make check-migration— local check: builds delegate WASM and verifies migration entry exists if hash changedcargo test -p river-core --test migration_test— validates TOML entries: correct hex, 32-byte keys, delegate_key = BLAKE3(code_hash)- CI
check-delegate-migrationworkflow — builds base and PR WASMs, verifies old hash is inlegacy_delegates.toml - CI
check-cli-wasmworkflow — verifiesui/public/contracts/andcli/contracts/WASMs are in sync
| File | Purpose |
|---|---|
legacy_delegates.toml |
Single source of truth for migration entries |
ui/build.rs |
Generates Rust const array from TOML at compile time |
ui/src/components/app/chat_delegate.rs |
Uses generated LEGACY_DELEGATES for runtime migration |
scripts/check-migration.sh |
Local + CI migration validation |
scripts/add-migration.sh |
Computes keys and appends entry to TOML |
scripts/sync-wasm.sh |
Builds all WASMs and copies to committed locations |
common/tests/migration_test.rs |
Validates TOML entries are well-formed |
- Delegate key formula:
BLAKE3(BLAKE3(wasm) || params)— both steps use BLAKE3 - DelegateKey equality checks BOTH
keyANDcode_hashfields - WASM on disk is versioned:
store_delegate()wraps raw WASM withto_bytes_versioned(). The code_hash in.regfiles is authoritative. - WASM committed in 3 places:
ui/public/contracts/,cli/contracts/, andtarget/(build output). Usecargo make sync-wasmto keep them in sync.
- Run
cd common && cargo test private_roomwhen modifying encryption or secret distribution. - Use
cargo make testbefore every PR to ensure all components still build and pass tests.
Every piece of data in contract state must be cryptographically authorized. Never accept unauthorized data into state, even as a "temporary" or "lenient" measure.
- Messages must have a valid signature from a verified member at the time they are added
- Members must have a valid invitation chain back to the room owner
- Bans must be authorized by the room owner
- Verification must happen at insertion time — never defer verification to "when the data arrives later"
If a delta would introduce data that cannot be verified (e.g., a message whose author is not in the members list), the fix must ensure the authorization data is included in the delta (e.g., include the member entry alongside the message), NOT relax verification to accept unauthorized data.
Relaxing verification creates security holes that are exploitable by malicious peers. A contract that accepts unverified messages is vulnerable to spam, impersonation, and state pollution.
A key benefit of fully-authorized state: it enables permissionless contract migration. When contract WASM changes, anyone can migrate state from the old contract to the new one because the state is self-validating (see Contract Upgrade below).
When the room contract WASM changes, the contract key changes (key = BLAKE3(WASM_hash || params)).
Both the UI and riverctl detect this automatically via regenerate_contract_key().
Because all state is cryptographically self-authorized, contract migration is permissionless:
- ANY node (not just the room owner) can GET state from the old contract key and PUT it to the new contract key. The new contract validates all signatures and accepts it.
- The room owner does NOT need to be online or take any special action.
- The
OptionalUpgradeV1pointer on the old contract is a courtesy for clients still running old versions — it tells them where the new contract lives. But updated clients already know the new key because they have the new WASM bundled.
Upgrade flow for an updated client:
- On load,
regenerate_contract_key()detects old_key != new_key - Client subscribes to the new contract key
- Client GETs state from the old key and PUTs/merges it to the new key
- If room owner: also sends an
OptionalUpgradeV1pointer on the old key for stragglers
This only works if:
- The state format is backwards-compatible (see below)
- All state data is cryptographically authorized (see above)
ChatRoomStateV1 and all sub-types must remain backwards-compatible:
- New fields must use
#[serde(default)] - Never remove or rename existing fields
- Never change serialization format of existing fields
- If a breaking change is truly needed, create a V2 type with explicit migration (separate project)
This ensures any client can re-PUT old state bytes and the new WASM's validate_state() accepts it,
which is critical for the permissionless contract migration system described above.
The UI runs as single-threaded WASM. Firefox mobile runs Dioxus signal subscriber
notifications synchronously during Drop, causing RefCell already borrowed panics.
These rules prevent re-entrant borrow crashes.
// WRONG — panics if signal is being written
let rooms = ROOMS.read();
// RIGHT — returns Err instead of panicking
let Ok(rooms) = ROOMS.try_read() else { return; };IMPORTANT: In Dioxus 0.7.x, try_read() does NOT register signal subscriptions
when it returns Err. The subscription is registered only on the success path
(after the borrow succeeds). This means a use_memo that hits try_read() -> Err
will NOT be notified of future signal changes — it permanently stops re-evaluating.
To mitigate: ensure signal mutations happen in clean execution contexts (via
crate::util::defer()) so try_read() never encounters a concurrent borrow.
Also, memos that read multiple signals (e.g., CURRENT_ROOM.read() + ROOMS.try_read())
get a backup subscription from the non-try signal.
Use safe_spawn_local() (in util.rs) which defers via setTimeout(0):
// WRONG — re-entrant Task::run() panic on Firefox at singlethread.rs:132
wasm_bindgen_futures::spawn_local(async { ... });
// RIGHT
crate::util::safe_spawn_local(async { ... });Signal mutations (ROOMS.with_mut(), ROOMS.write(), CURRENT_ROOM.write(), etc.)
must always be wrapped in crate::util::defer() when called from spawn_local tasks
or synchronous event handlers (onclick, etc.). This is required for TWO reasons:
-
RefCell re-entrancy: Signal write Drop handlers fire subscriber notifications synchronously. Those notifications poll memos that call
try_read()on the same signal — panics if the write guard's RefCell borrow is still held.setTimeout(0)breaks the call stack so no borrows are active. -
Missing Dioxus scope:
wasm_bindgen_futures::spawn_localtasks run without a Dioxus scope on thescope_stack. Signal subscriber notifications callcurrent_scope_id()which panics on an empty scope_stack (runtime.rs:223). Ourdefer()usesruntime.in_scope(ScopeId::ROOT, f)to push both the runtime and a root scope before executing the closure.
IMPORTANT: defer() depends on capture_runtime() being called at app startup
(in App() component). Without it, deferred closures have no runtime to push and
GlobalSignal access panics with "Must be called from inside a Dioxus runtime."
// WRONG — panics at runtime.rs:223 (empty scope_stack) and/or
// runtime.rs:280 (RefCell already borrowed)
spawn_local(async {
ROOMS.with_mut(|rooms| { /* mutate */ });
});
// ALSO WRONG — onclick handlers trigger the same RefCell panic
onclick: move |_| {
ROOMS.write().map.remove(&key);
};
// RIGHT — defer mutation to clean execution context with runtime+scope
spawn_local(async {
// ... async work (signing, etc.) ...
crate::util::defer(move || {
ROOMS.with_mut(|rooms| { /* mutate */ });
crate::components::app::mark_needs_sync(key);
});
});
// RIGHT — onclick with defer
onclick: move |_| {
crate::util::defer(move || {
ROOMS.write().map.remove(&key);
});
};Ordering caveat: defer() schedules via setTimeout(0), so the closure runs
asynchronously. Code after defer() executes BEFORE the deferred closure. If you
need data from a signal mutation for subsequent code, extract it before deferring:
// WRONG — signing_keys will be empty because ROOMS merge hasn't happened yet
crate::util::defer(move || { ROOMS.with_mut(|r| r.merge(loaded_rooms)); });
let signing_keys = ROOMS.with(|r| /* read signing keys */); // reads pre-merge state!
// RIGHT — extract data before moving into defer
let signing_keys = loaded_rooms.iter().map(|r| r.signing_key()).collect();
crate::util::defer(move || { ROOMS.with_mut(|r| r.merge(loaded_rooms)); });See defer() in util.rs, capture_runtime() in util.rs, mark_needs_sync() in app.rs.
Always use crate::util::defer() instead of manual web_sys::window().set_timeout_with_callback().
Our defer() pushes the Dioxus runtime and root scope via runtime.in_scope(ScopeId::ROOT, f).
Raw setTimeout runs without any Dioxus context, so GlobalSignal access panics.
Signal clears that the effect subscribes to must be synchronous. Deferring causes an infinite loop (set remains non-empty → effect re-runs → defers clear → effect re-runs...).
- Follow Conventional Commit style for PR titles (e.g.,
fix(ui): correct room timestamp format). - Include a brief description of test coverage in the PR body.
- When touching contracts, note any required redeploy steps.