A file put on a server without the server's key stays its owner's after its
tab closes or the app quits. The app records it with the preferences (server,
id, name, owner token, the owner's links, what others join with; at most 200;
validated when read back) and never shows the token: not in command results,
ui.inspect, logs or debug output.
- Home > Online lists your online files: open one as its owner (a new tab, or
its tab comes to the front), start and stop sharing, copy the code or link,
rename on the server, remove from the server (confirmed), forget here. One
request per server when the section opens; offline and gone states.
- collab.files, collab.openFile, collab.forgetFile; collab.manageFile takes
its server and token from the record and gains share / unshare.
- Server: Admin owned (a compatible addition within protocol version 2) says
what became of the files a list of owner tokens owns. Older servers leave
the list as recorded.
- Tabs mark files in a live session (shared, or open only to you); toasts
about a file that isn't in front name it; after a crash a recorded file
reopens from its server as its owner.
- Draft catalogs (de, fr, ja, ko, pt-BR, zh-Hans) translate the 71 strings
and 5 command labels they lacked and the new ones; es and es-419 follow.
- Docs: collab-protocol, server, mcp, control-protocol, parity pages, gaps,
ROADMAP.
Online, the file is its owner's: others get links and codes of their own, and
Stop sharing (collab.stop) revokes those on the server. Whoever joined with
them is disconnected at once and told sharing was stopped (the session's end,
with their copy); the owner stays in the file, which stays on the server, and
Start sharing gives the same file new links and codes. Removing the file is a
separate, confirmed step under Advanced.
The join field is the shared text input: as tall as the Join button beside it.
The Share popover is one flow: Online (the file is published to the server in
Preferences and the server names its links with short codes) or Local network
(the app hosts; the code carries its address and the session's secret), one
Start sharing button, the code large with Copy code and Copy link, who is here,
can edit / can view, and Stop sharing. Join with a code is one field in the
popover, on the home screen and in the Join dialog, for a code of either kind
or an invite link.
Protocol (within version 2, new variants last): Resolve / Resolved and Admin
code. The server keeps a stretched hash and the wrapped token, never the code,
and limits wrong codes per address (with a lockout) and for everyone. Older
servers leave the links in place of a code; older apps join by link.
collab.share, collab.join {code}, code and share in collab.peers; the server
preference defaults to the built-in server and can be reset or turned off.
Live sessions are per document instead of one for the whole app.
- **Engine** (`crates/engine/src/live.rs`): `Session.live` is now `Lives`: the app-wide parts (the
installed link, the invite-link registration) plus one `Live` per session. One place resolves
"the session of this document": `Session::live()` (the active document's; status off when it
has none), `live_mut()`, `live_of(uid)`. Sync, undo, read-only checks, follow, cursor chat,
spotlight and presence all go through the session of the document they are about, so edits and
presence never cross. Sessions behind the active tab keep syncing; your presence there is empty.
- **Link** (`LiveLink`): every call takes the `SessionId` the engine picked and `poll` returns
`(SessionId, Event)`, so one link carries several sessions. The desktop link and the browser
link keep them in a shared table with one mailbox per session
(`crates/collab-net/src/sessions.rs`); each hosted document listens on its own port.
- **Commands**: `collab.*` act on the active document's session; no parameters changed.
`collab.peers` keeps its shape and gains `session` (its id, null when the document isn't in
one) and `sessions` (every session: `{session, document, status, reason, role, fileName, link,
peers}`). `collab.join` returns the new session and opens its file in a tab of its own without
touching other sessions; until then (or if it fails) it shows on the tab it was started from.
Starting a session for a document replaces only that document's own session. Closing a document
ends its session only.
- **UI** (`crates/ui-egui/src/live.rs`): the Share popover, avatars, cursors, chat, spotlight
banner, follow frame and read-only reason read the session of the tab in front, per window. What
belongs to one session in the window (cursors shown, your chat box, a report being written) is
dropped when another tab comes to the front. `ui.inspect`'s `live` gains `session` and
`sessions`. No new strings or command ids.
- **Docs**: `docs/collab-protocol.md`, `docs/mcp.md`, `docs/control-protocol.md`,
`docs/collaboration-parity.md`, `docs/gaps.md` (gap 2), `ROADMAP.md`.
Third review:
- a broadcast counts once per recipient block, welcomes don't count
against block caps, and presence, cursor chat and pings have their own
per-address budget, so an office behind one address works together
- when everyone's reading budget runs out, the biggest readers are cut
off until it fits (their bytes freed at once), no cliff
- block counters are forgotten once only the table holds them
- install.sh: root's downloads, clone, swap file and built binary in a
root-only directory; the build user builds its own copy; the binary is
copied out with the build user's rights
- recommended open-mode settings in install.sh's config and the docs
Second security review:
- a broadcast counts once against the queued-bytes budget; each block of
addresses gets at most an eighth; when the budget runs out the biggest
consumer is shed, not the receiver
- forced compactions at most once a minute per file
- plugin downloads check the per-address share before the global budget
and before reading
- install.sh never replaces a Caddyfile with other sites (one import line,
once; install-test.sh in packaging lint); the built binary runs only as
the server's account
- bytes being read count against per-block and global budgets, the
biggest readers dropped first
- block multipliers x2 (/56) and x4 (/48), connection caps at most a
sixteenth/eighth of all; global publish and download budgets eight times
a /48's share
- problems capped at 200; writes refused below max(1 GiB, two files) free;
the private-server file cap documented
Fixes from the security review of the public server:
- team libraries only in key-published files on open servers; unpacking
bounded by the file cap (zip bombs refused)
- byte-bounded send queues per connection and in all, a bound on bytes
waiting for the hub, per-address and global download budgets
- batches checked against the file, per-address and total caps as files
grow (refused with a Reject the app rolls back); the core's recent-batch
memory bounded in bytes; nothing confirmed when a log can't be written
- usage reports kept in memory; per-kind message caps checked while read
- layered IPv6 limits (/64, /56, /48), a global publish rate, connections
reserved for sessions, short idle timeout for one-shot requests
- revoked links and removed files cut library connections
- data directory 0700, server key 0600 from creation
- install.sh: SSH ports from sshd, ufw left off when other services
listen, unprivileged builds of the latest tag, settings checked before
use, own Caddy snippet; systemd memory and network caps; backups check
free space and say they hold the key
- linger reads raw bytes after an oversized frame (tungstenite asserted)
Config-gated (open_publish): anyone publishes without the server key and
gets an owner token (128-bit, shown once, stored hashed) that manages only
that file. Per-address limits (X-Forwarded-For only from trusted_proxy,
bounded tables), global caps, idle expiry, reports, persisted blocks,
files list --sort, check, GET /healthz on the same listener, startup
warnings. The app asks the server first, publishes without a key when it
is open, shows the owner token once, the host/terms line, the expiry date
and a report button; collab.serverInfo, collab.manageFile, collab.report
and pref.publishServer for MCP/CLI. Protocol additions stay within
version 2, gated like signals.
- **Installed plugins survive a crash.** Importing, installing, updating or removing a plugin, and a
plugin changing its settings, now sets `Runtime::unsaved`; at the end of each frame the app hands
the preferences to a new `Services::save_prefs`, and the desktop app writes `state.json` at once
(temporary file + rename, the same atomic write the quit path uses, now shared). Nothing waits for
quit any more.
- **The UI never waits on the network.** New `Session::start_background(id, params)`: the command
runs as usual until it makes a server request; the request goes out on the link's own thread
(`LiveLink::background`, implemented by the desktop `NetLink` with one short-lived thread per
request) and the command stops; when the answer arrives (`Event::Net`, taken by `live_poll`, which
the network's waker already triggers) the command runs again from the start on the file it
started on, answered from the answers so far, until it needs no new request. All server requests
go through one funnel (`Session::net`), so `plugin.directory.*`, `library.publishToServer`,
`library.add` (links), `library.usage` and library fetches work this way without being split.
A re-run of `library.publishToServer` caches the library it actually sent (a fresh copy differs
in its time and, on a first publish, its id). One job per command; `cancel_background` drops a
late answer.
- **UI:** `FigApp::run_background(ctx, id, params, done)` with a boxed completion. Manage plugins >
Browse shows "Fetching from the server..." with Cancel instead of Browse; the install confirmation
shows "Installing..." with Cancel and stays open until the download is in (an error leaves it open
to retry); row buttons wait during an install; closing the window cancels. Assets > Libraries:
Publish to server shows "Publishing..." + Cancel, Show usage and pasting a team library link show
"Fetching from the server..." + Cancel. No polling repaints: the window draws again when the
answer's waker fires; pending state is a scan of a short Vec, no per-frame allocation.
- **Programmatic callers are unchanged:** MCP, CLI, control channel and scripts still run the same
commands synchronously (and the browser link, which has no background requests, too).
- New string "Installing..." (en/es); docs: plugins.md (immediate saves, Browse/Install never wait),
server.md (team library requests never wait).
A self-hosted plugin directory, with no third-party service: `figcraft-server` keeps plugin bundles,
the app browses, installs and updates from it, and `figcraft-cli` publishes to it.
- **Bundles** (`figcraft_plugin::bundle`): a plugin's manifest, module and up to 32 plain-named
assets in one uncompressed, length-prefixed binary form (nothing can expand beyond the bytes
sent). Its SHA-256 names it. `Bundle::decode` never allocates beyond its input and refuses
trailing bytes; `Bundle::check` parses the manifest (refusing a plugin API this build doesn't
run), requires a version of 1-4 numbers, compiles the module without running it (only the
sandbox's imports allowed) and checks asset names (no paths, no dot files, no Windows device
names, unique ignoring case). Manifests gain an optional `author`.
- **Protocol**: a new connection kind next to managing and team-library connections:
`OpenPlugins {key}` then `Plugins {list | fetch | publish | unpublish}`. Browsing needs no
credential; publishing and unpublishing need the server key (a wrong key is refused). Older
servers refuse the connection and the app says the server has no plugin directory. The protocol
version is unchanged; old clients are unaffected.
- **Server** (`apps/figcraft-server/src/directory.rs`): `<data>/plugins/index.json` plus
`<data>/plugins/<sha256>.bin`, so no publisher-chosen name reaches the file system. Every publish
is checked in full and must be a newer version; caps `max_plugins` (versions, 0 turns it off),
`max_plugin_mb` (<= 32), `max_plugins_mb`. Fetches re-hash the file before sending (a damaged
bundle is never sent) and share a download budget (~32 MiB/s) so a flood is refused, not queued.
Damaged index entries are skipped and reported, never deleted; an unreadable index lists nothing
and refuses writes until the operator fixes it. Operator moderation:
`figcraft-server plugins [list]` and `plugins remove ID [--version V]` (offline or through the
running server). Logs name ids and versions, never keys.
- **App**: Plugins > Manage plugins gets Installed / Browse tabs. Browse lists a server's plugins
(version, author as typed, size, access) with a search; nothing is fetched until Browse is
pressed. Install / Update open a confirmation listing the permissions asked for, the source and
the SHA-256; only its button downloads. The download must match the listed SHA-256 (and the one
shown), then is checked like an imported plugin before anything is written. Installed plugins
live per user under `<settings>/FigCraft/Plugins/<hash of id>/` and run in the same sandbox;
Remove deletes the folder.
- **Commands**: `plugin.directory.list {server?, query?}`, `plugin.directory.install {id, version?,
confirm, server?, sha256?}` (refused without `confirm: true`; never opens a dialog),
`plugin.directory.publish {path | plugin, server}`, `plugin.directory.unpublish {id, version?,
server}`. `ui.set {pluginsTab, pluginConfirm}` on the control channel.
- **CLI**: `figcraft-cli plugin publish FOLDER --server ws://host:port/` with the key from
`FIGCRAFT_SERVER_KEY` (or `?key=`), never printed.
- Docs: plugins.md (directories, publishing, commands, manifest), server.md (operating the
directory: access, checks, caps, moderation, backups, security), collab-protocol.md, mcp.md,
cli.md, control-protocol.md, target-app-parity area 18 and Ecosystem, ROADMAP, roadmap.md M11,
gaps.md gap 6. Plugins & widgets 55%/32% -> 60%/35%, 10-30 h -> 8-26 h.
The desktop app joins wss:// sessions (rustls with webpki roots, certificates always checked, clear errors; FIGCRAFT_CA_FILE for private authorities), with one TLS session shared by the reader and writer threads. figcraft-server stops gracefully on SIGTERM/SIGINT/SIGHUP/Ctrl+C, ships in Linux x86_64/aarch64 release archives, and docs/server.md gains a Deploy on a Linux server guide with a hardened systemd unit, Caddyfile, nginx config and Dockerfile in packaging/server.
A file's library can be published to figcraft-server and enabled in other files by a share link (view access is enough); placing, styles, variables and library updates with overrides kept work as for local libraries, with a background check while the Assets panel is open. library.swap re-points instances, styles and variables to another library by name (one undo step, unmatched items listed). The server keeps per-file usage reports (library.usage).
figcraft-server hosts many files over the same hub the app uses for sessions, persists each as a checksummed snapshot plus an append-only log (synced before replies, compacted, damaged files skipped and reported) so restarts lose nothing confirmed, and gives per-file share links with edit or view access (hashed 128-bit tokens, revocable). The app can publish a file to a server and join server-hosted files; view-only tabs refuse edits. Protocol version 2.