Merge main into #2018

This commit is contained in:
bflatastic committed 2026-11-07 08:09:33 +07:00
commit eb88dff481
45 files changed
+997 -290

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
name: House rules
on:
push:
branches: [main]
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
pull_request:
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: house-rules-${{ github.ref }}
cancel-in-progress: true
jobs:
house-rules:
uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
with:
rust: true
+61 -9
View File
@@ -1,3 +1,56 @@
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
## House rules
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
10. **Keep secrets and personal details out** of the repository, logs and requests.
11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
<!-- craft-house-rules:end -->
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
## Rust Craft repositories
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix.
2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it.
3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository.
4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build.
5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md).
6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`.
## Licence allowlist
Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules.
- `ISC`
- `MIT`
- `BSD-2-Clause`
- `BSD-3-Clause`
- `Apache-2.0`
- `OFL-1.1`
- `Ubuntu-font-1.0`
- `Bitstream-Vera`
- `CC0-1.0`
- `CC-BY-4.0`
- `CC-BY-SA-4.0`
- `LicenseRef-Public-Domain`
`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so.
<!-- craft-house-rules:end -->
# FigCraft — instructions for agents
FigCraft is a clean-room, open-source, pure-Rust (egui) reimplementation of the Figma design workflow:
@@ -12,11 +65,10 @@ Repos don't share code.
1. Read [`ROADMAP.md`](ROADMAP.md) (stage, headline numbers, estimates) and
[`docs/gaps.md`](docs/gaps.md) (the ranked work list). Unless the user gives you a task, pick
work from the top of `docs/gaps.md`.
2. Read `plan/STATUS.md` and `plan/execution-plan.md` (local, gitignored) if present; behaviour
reference notes are in `plan/figma/*.md`.
3. Orient → plan → implement + test → verify (screenshot) → record (ROADMAP, devlog) → commit.
2. Orient → plan → implement + test → verify (screenshot) → record (ROADMAP, devlog) → commit.
`plan/` is gitignored (local only).
`plan/` is an optional gitignored notebook. Behaviour reference notes may be in `plan/figma/*.md`.
Do not ask for it, and do not stop when it is absent.
## Non-negotiables
@@ -59,6 +111,9 @@ Repos don't share code.
- No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` in shipped code
(workspace clippy lints deny them; tests may use them). Return `Result`/`Option`; use `.get()` for
data-derived indexes; slice strings only at char boundaries; check floats are finite; cap sizes.
- New workspace crates include `[lints] workspace = true` in `Cargo.toml`. Production crate roots
declare the crate-level lint attributes before modules or other crate items; crate-level `//!`
documentation may precede them.
- `figcraft_engine::guard` turns a panicking command into `EngineError::Internal` and the document
is rolled back; the desktop app also guards each UI frame and writes recovery copies. These are
safety nets, not substitutes for `Result`.
@@ -78,13 +133,11 @@ Repos don't share code.
L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
`app.set_props(ids, props)`. Colours come from `theme::Tokens`.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Never break wasm** (`cargo xtask wasm`).
### Quality gates (before every commit)
`cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm). One task per commit with a
milestone id in the message (`M2.1: auto layout wrap`). Commit after every landed arc of work; push
to `main`.
milestone id in the message (`M2.1: auto layout wrap`). Commit after every landed arc of work.
## Running and looking at the app
- `cargo run -p figcraft -- --control <port> [file.figcraft|file.svg|image]`. Pick a free port
@@ -98,7 +151,6 @@ to `main`.
incumbent side by side where the owner allows (`plan/figma/`).
- Headless: `figcraft-cli` (`info`, `export`, `run`, `commands`, `mcp`). MCP: `figcraft-cli mcp`
(see `docs/mcp.md`).
- Shell gotcha on the owner's machine: `mv`/`cp` may be aliased interactive — use `/bin/mv -f`.
- Parallel agents: separate `CARGO_TARGET_DIR` per agent, edit only the crates/files you own,
write manifests atomically, delete your target dir when done (disk is limited).
+1 -133
View File
@@ -1,133 +1 @@
# FigCraft — instructions for agents
FigCraft is a clean-room, open-source, pure-Rust (egui) reimplementation of the Figma design workflow:
interface design, auto layout, components and variants, styles and variables, prototyping and
inspection. It runs natively on macOS, Windows, Linux and FreeBSD, and in the browser via WASM.
Siblings: `../vectorcraft` (Illustrator-class), `../photocraft` (Photoshop-class), `../designcraft`
(InDesign-class) and the other Crafting Apps; same conventions.
Repos don't share code.
## Start every session here
1. Read [`ROADMAP.md`](ROADMAP.md) (stage, headline numbers, estimates) and
[`docs/gaps.md`](docs/gaps.md) (the ranked work list). Unless the user gives you a task, pick
work from the top of `docs/gaps.md`.
2. Read `plan/STATUS.md` and `plan/execution-plan.md` (local, gitignored) if present; behaviour
reference notes are in `plan/figma/*.md`.
3. Orient → plan → implement + test → verify (screenshot) → record (ROADMAP, devlog) → commit.
`plan/` is gitignored (local only).
## Non-negotiables
### Clean-room
- Behaviour comes from public documentation and black-box observation of the running incumbent app
with synthetic documents only. Never read, disassemble or copy anything inside the incumbent's
application bundle, web bundle or network traffic (names and public listings only).
- Never copy GPL/AGPL code.
- Reference screenshots of the incumbent stay local under the gitignored `plan/figma/screenshots/`
and are never committed, published or used as assets.
### Assets: no third-party product iconography or images — ever (absolute rule from the project owner)
- **Never add, copy, trace, redraw-from, embed or ship any icon, image, illustration, cursor, logo,
template, UI kit, preset library, font file or screenshot from Figma, Adobe, Avid, Autodesk or any
other product, or from any source whose licence doesn't allow it.** This includes "lookalike"
redraws of their icons. Breaking this rule is the worst thing you can do in this repo.
- Every image, icon, font or other asset must be one of:
- original work created for FigCraft by a contributor (licensed MIT OR Apache-2.0);
- OSI open source;
- public domain / CC0;
- Creative Commons with redistribution allowed.
- **Every asset file must have a row in [`ATTRIBUTION.md`](ATTRIBUTION.md)** (path, author, source,
licence, notes). `cargo xtask assets` (part of `cargo xtask ci`) fails on any unlisted file under
`assets/`, `docs/brand/` or `docs/images/`.
- FigCraft's UI icons are drawn in code (`crates/ui-egui/src/icons.rs`) from simple primitives and
are original. Keep it that way: new icons are new code, designed from scratch.
- The app icon is the owner's chameleon drawing, vectorised (`assets/app-icon/README.md`); `cargo xtask icon`
renders the PNGs and `.ico` from `assets/app-icon/figcraft.svg`.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this
repo.** The app uses it only through the optional `CRAFT_FONTS_DIR` build input
(`.cargo/config.toml` points it at `../craft-fonts` for local builds; `crates/text/build.rs`
embeds it). Everything must build, test and run without it (system fonts are scanned at runtime).
- Demo and test content is synthetic or public domain; never personal photos or data.
- Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The
ArtCraft logos in `docs/brand/` are trademarks, not open source, under
`docs/brand/LICENSE-brand.txt`: never modify them or use them outside FigCraft.
- If the provenance of an asset is unclear, don't add it.
### Never crash
- No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` in shipped code
(workspace clippy lints deny them; tests may use them). Return `Result`/`Option`; use `.get()` for
data-derived indexes; slice strings only at char boundaries; check floats are finite; cap sizes.
- New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them.
- `figcraft_engine::guard` turns a panicking command into `EngineError::Internal` and the document
is rolled back; the desktop app also guards each UI frame and writes recovery copies. These are
safety nets, not substitutes for `Result`.
- `unsafe_code = "forbid"` workspace-wide.
- Every crash fix comes with a regression test. The engine has a junk-parameter sweep over every
command (`crates/engine/src/tests.rs`): extend fuzz/sweep tests when you add parsers or commands.
### Architecture
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
(`spec!(id, label, params doc, undoable, run)`) + tests. The menus, shortcuts, Actions palette,
CLI, control channel and MCP server all dispatch the same command ids. Command ids follow the
incumbent's menu catalog in `crates/engine/src/catalog.rs` (the parity metric: `cargo xtask parity`).
- **Programmatic calls never open dialogs.** Commands only *request* UI (`Session::request_ui`); the
control channel drops dialog requests.
- **Layering** is enforced by `cargo xtask layers`: geom/color (L0) → doc (L1) → layout/text/pathops/plugin/collab
(L2) → render/format (L3) → svg/pdf (L4) → engine (L5) → ui-egui/mcp (L6) → apps. Nothing below
L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
`app.set_props(ids, props)`. Colours come from `theme::Tokens`.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
### Quality gates (before every commit)
`cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm). One task per commit with a
milestone id in the message (`M2.1: auto layout wrap`). Commit after every landed arc of work; push
to `main`.
## Running and looking at the app
- `cargo run -p figcraft -- --control <port> [file.figcraft|file.svg|image]`. Pick a free port
(`python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1])'`);
sibling apps use the control channel too.
- Drive it with JSON lines, e.g.
`{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}}`
then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods are documented
in `crates/ui-egui/src/control.rs` and `docs/control-protocol.md`.
- **For UI work, look at the result** (take a screenshot and read the PNG). Compare with the
incumbent side by side where the owner allows (`plan/figma/`).
- Headless: `figcraft-cli` (`info`, `export`, `run`, `commands`, `mcp`). MCP: `figcraft-cli mcp`
(see `docs/mcp.md`).
- Shell gotcha on the owner's machine: `mv`/`cp` may be aliased interactive — use `/bin/mv -f`.
- Parallel agents: separate `CARGO_TARGET_DIR` per agent, edit only the crates/files you own,
write manifests atomically, delete your target dir when done (disk is limited).
## Roadmap
Progress docs: [`ROADMAP.md`](ROADMAP.md) (one-page
summary and stage), [`docs/target-app-parity.md`](docs/target-app-parity.md) (the authoritative
assessment), [`docs/gaps.md`](docs/gaps.md) (ranked work list), [`docs/roadmap.md`](docs/roadmap.md)
(milestones), [`docs/architecture.md`](docs/architecture.md), the per-area `docs/*-parity.md` files
and the generated [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`).
When a task lands, update the affected rows and the status line and revision history in the same
commit. Grade by behaviour against the incumbent, not by whether a menu item exists; err low.
## Contributor credits (About window)
- About ▸ Contributors are compiled into the binary from `contributors/contributors.json`
(commit stats; never hand-edit) and `contributors/people.toml` (names people chose for
themselves).
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
whether they want to be credited by more than their username: a real name, a display name, and/or
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
(copy the template at the top of the file)
and include it in their PR, committed as them. If no, change nothing: they are credited as
`@username` anyway.
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
GitHub profiles). Never hand-edit `contributors.json`.
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
@AGENTS.md
+2 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic, so a String variable's Amharic value draws in the panels instead of boxes (#2014); glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving maLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic, so a String variable's Amharic value draws in the panels instead of boxes (#2014); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` conLine truncated
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -359,6 +359,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-11-06 | patch | M8: glass takes one backdrop sample instead of its 18-sample dispersion window where the colours split under 1/256 px (the flat end of the curve, where the move is short), within one level of the window over any backdrop: a large pane renders about 2–5% faster, with the same pixels in the renders compared. Tests: `glass::tests::a_split_too_narrow_to_show_takes_one_sample`. Numbers unchanged |
| 2026-11-02 | patch | M8, #1885: a glass whose shape is too big to keep whole at the zoom (past `MAX_SHAPE_PX` with its core's margin) bends along its edge normals instead of working out a core, so its fields never cover more pixels than a kept shape's, and the core's feature transform holds 16 bytes a pixel. The edge normals are box-blurred twice again and the bend directions once, as documented. Test: `glass::tests::a_shape_too_big_to_keep_bends_along_its_normals`. Numbers unchanged |
| 2026-11-02 | minor | M3, #1885: glass bends the backdrop towards its core, the shape shrunk by `depth` and the bevel, as the reference tool's: round a sharp corner the bend turns smoothly instead of meeting along the diagonal, and a part thinner than twice that (a capsule, a letter's stroke) bends across itself towards its own middle (pixels about as deep as any within 2 px). Rectangles, frames, components, instances and sections whose corners aren't smoothed work their glass out in closed form, with nothing to rasterise, transform or keep: a zoom frame draws in 40 ms instead of 54 and a resize frame in 46 instead of 67 (`glass_render_timings`, release, 2×). Other shapes keep their fields, with the bend directions for the last two depths asked for beside them, so dragging the Depth slider works out only those again. Tests: `glass::tests::a_sharp_corner_bends_round_towards_the_core`, `glass::tests::a_thin_part_bends_across_itself`, `glass::tests::neighbouring_strokes_bend_towards_their_own_middles`, `glass::tests::a_rounded_rectangle_in_closed_form_matches_its_distance_transform`, `glass_cut_tests::what_the_shape_depends_on_works_it_out_again_and_nothing_else_does`. Numbers unchanged |
| 2026-11-02 | minor | M3, #1885: glass's light is a line on its 1-unit bevel added to the backdrop, as the reference tool's: fully where the edge faces the light, none below 0.35 of facing it and 0.6 as much on the opposite side, with a faint gradient across the curve, 5% darker facing the light and lighter opposite (it was a glow mixed towards white, as deep as `splay` made it). `splay` turns the bend from along the edge's normal towards the way away from the layer's centre, as the reference tool's does, and no longer widens the light: files with a splay above 0 look different. The direction to the centre is worked out in the shape's own frame, so cut renders match. Tests: `glass::tests::the_light_lies_on_the_bevel_and_the_gradient_across_the_curve`, `backdrop_tests::splay_turns_the_bend_towards_the_centre`, `glass_cut_tests::the_light_is_a_line_on_the_bevel`. Numbers unchanged |
@@ -77,6 +77,7 @@ fn root(name: &str) -> PathBuf {
r
}
/// What the scripted link says about the session it is in.
fn info() -> SessionInfo {
SessionInfo {
role: Role::Guest,
@@ -90,6 +91,7 @@ fn info() -> SessionInfo {
address: SERVER.into(),
network: true,
access: Access::Edit,
view: None,
}
}
+4
View File
@@ -104,3 +104,7 @@ real_name = "Oleksii Sukholita"
[people.helenkwok]
real_name = "Helen Kwok"
[people.denisg0mes]
real_name = "Denis Gomes"
display_name = "Denis Gomes"
+126 -7
View File
@@ -1,9 +1,9 @@
//! Join codes over the network: a file shared on the local network joined by its code (two
//! engine sessions over loopback), what the browser's driver does with an online code, a server
//! Join codes over the network: a file shared on the local network joined by its code, and by
//! its view-only code to look without editing (engine sessions over loopback), what the browser's driver does with an online code, a server
//! older than join codes, and the links that name a code. (A `figcraft-server` handing codes out
//! and resolving them is tested in its own crate, with the same link.)
use std::net::{Ipv4Addr, TcpListener};
use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener};
use std::sync::Arc;
use std::time::{Duration, Instant};
@@ -18,8 +18,9 @@ use crate::driver::{Driver, Socket};
use crate::guest::GuestConfig;
use crate::inbox::Inbox;
use crate::invite::{Endpoint, Invite, Lookup};
use crate::link::reachable;
use crate::loopback_tests::{fast_limits, quick};
use crate::{NetLink, manage};
use crate::{NetLink, lan_ip, manage};
fn session(name: &str) -> Session {
let mut s = Session::new();
@@ -53,6 +54,7 @@ fn layers(s: &Session) -> usize {
s.doc().map_or(0, |d| d.node_count())
}
/// A local network share's code joins its session from another app, and edits sync both ways.
#[test]
fn a_local_share_is_joined_by_its_code_and_edits_sync() {
let (mut host, mut guest) = (session("Hana"), session("Ada"));
@@ -65,7 +67,13 @@ fn a_local_share_is_joined_by_its_code_and_edits_sync() {
assert_eq!(local.ip, Ipv4Addr::LOCALHOST);
assert_eq!(shared["address"], format!("127.0.0.1:{}", local.port));
assert_eq!(shared["link"], local.link(), "the code and the invite link name the same session");
assert!(shared["share"]["viewCode"].is_null(), "everyone edits on the local network");
// A view-only code too, with its own secret, and no comment code.
let view = shared["share"]["viewCode"].as_str().unwrap();
let Code::Local(viewing) = code::parse(view).unwrap() else { panic!("not a local code") };
assert_eq!((viewing.ip, viewing.port), (local.ip, local.port), "the same session");
assert_ne!(viewing.secret, local.secret, "a secret of its own");
assert_eq!(shared["share"]["viewLink"], viewing.link());
assert!(shared["share"]["commentCode"].is_null());
// Typed the way people type: lower case, spaces for the dashes.
let typed = shown.to_lowercase().replace('-', " ");
assert_eq!(guest.execute("collab.join", &json!({"code": typed})).unwrap()["status"], "connecting");
@@ -93,6 +101,8 @@ fn a_local_share_is_joined_by_its_code_and_edits_sync() {
assert!(label.contains("Couldn't join") || label.contains("The host refused"), "{label}");
}
/// A well-formed code with the wrong secret is refused by the host, a mistyped one never connects,
/// and there is no comment code on the local network.
#[test]
fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() {
let (mut host, mut guest) = (session("Hana"), session("Ada"));
@@ -112,11 +122,120 @@ fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() {
*last = if *last == 'A' { 'B' } else { 'A' };
let e = guest.execute("collab.join", &json!({"code": typo.iter().collect::<String>()})).unwrap_err().to_string();
assert!(e.contains("typing mistake"), "{e}");
// A view-only code doesn't exist here, and says where it does.
let e = host.execute("collab.share", &json!({"access": "view"})).unwrap_err().to_string();
// A comment code doesn't exist here, and says where it does.
let e = host.execute("collab.share", &json!({"access": "comment"})).unwrap_err().to_string();
assert!(e.contains("share it online"), "{e}");
}
/// A local network share started for looking only shows its view code: whoever joins with it sees
/// every change and can't make one, the edit code (picked later) still edits, and stopping ends
/// both.
#[test]
fn a_local_view_code_lets_people_look_and_not_edit() {
let (mut host, mut viewer, mut editor) = (session("Hana"), session("Ada"), session("Bo"));
let shared = host.execute("collab.share", &json!({"via": "local", "bind": "local", "access": "view"})).unwrap();
assert_eq!(shared["share"]["access"], "view");
let (shown, edit) = (shared["code"].as_str().unwrap().to_string(), shared["share"]["editCode"].as_str().unwrap().to_string());
assert_eq!(shared["share"]["viewCode"], shown, "the code shown is the view code");
assert_ne!(shown, edit);
viewer.execute("collab.join", &json!({"code": shown})).unwrap();
pump(&mut [&mut host, &mut viewer], "the viewer's join", |s| live(s[1]) && s[0].live().peers.len() == 1);
let summary = viewer.live_summary();
assert_eq!((&summary["access"], &summary["readOnly"]), (&json!("view"), &json!(true)));
// The host's edit reaches the viewer; the viewer's is refused before it leaves.
let before = layers(&host);
host.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
pump(&mut [&mut host, &mut viewer], "the host's edit", |s| layers(s[1]) == before + 1);
let e = viewer.execute("shape.ellipse", &json!({"x": 80, "y": 10, "width": 40, "height": 30})).unwrap_err().to_string();
assert!(e.contains("View only"), "{e}");
// The edit code, picked now, still edits, and the viewer sees it.
assert_eq!(host.execute("collab.share", &json!({"access": "edit"})).unwrap()["code"], edit);
editor.execute("collab.join", &json!({"code": edit})).unwrap();
pump(&mut [&mut host, &mut viewer, &mut editor], "the editor's join", |s| live(s[2]) && s[0].live().peers.len() == 2);
assert_eq!(editor.live_summary()["access"], "edit");
editor.execute("shape.ellipse", &json!({"x": 80, "y": 10, "width": 40, "height": 30})).unwrap();
pump(&mut [&mut host, &mut viewer, &mut editor], "the editor's edit", |s| layers(s[0]) == before + 2 && layers(s[1]) == before + 2);
assert_eq!(viewer.live_summary()["readOnly"], true, "picking the edit code changes nothing for who joined to look");
assert_eq!(host.execute("collab.stop", &json!({})).unwrap()["ended"], true);
pump(&mut [&mut host, &mut viewer, &mut editor], "the end", |s| !s[1].live().status.active() && !s[2].live().status.active());
}
/// A local network share's `link` and `url` are the ones that go with the code shown: started for
/// looking only, whoever joins through either can't edit, and the session's line in `sessions`
/// says the same.
#[test]
fn a_local_view_share_s_link_and_url_only_let_people_look() {
let mut host = session("Hana");
let shared = host.execute("collab.share", &json!({"via": "local", "bind": "local", "access": "view"})).unwrap();
for key in ["link", "url"] {
let way = shared[key].as_str().unwrap().to_string();
let mut viewer = session("Ada");
viewer.execute("collab.join", &json!({"link": way})).unwrap();
pump(&mut [&mut host, &mut viewer], "the join", |s| live(s[1]) && s[0].live().peers.len() == 1);
let summary = viewer.live_summary();
assert_eq!((&summary["access"], &summary["readOnly"]), (&json!("view"), &json!(true)), "joined with {key} {way}");
let before = layers(&host);
let e = viewer.execute("shape.ellipse", &json!({"x": 80, "y": 10, "width": 40, "height": 30})).unwrap_err().to_string();
assert!(e.contains("View only"), "{key}: {e}");
host.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
pump(&mut [&mut host, &mut viewer], "the host's edit", |s| layers(s[1]) == before + 1);
assert_eq!(layers(&host), before + 1, "nothing of the viewer's reached the host ({key})");
viewer.execute("collab.leave", &json!({})).unwrap();
pump(&mut [&mut host, &mut viewer], "the leave", |s| s[0].live().peers.is_empty());
}
// The link, the URL and the code name the same way in, wherever the session is listed.
let Code::Local(local) = code::parse(shared["code"].as_str().unwrap()).unwrap() else { panic!("not a local code") };
assert_eq!((&shared["link"], &shared["share"]["viewLink"]), (&json!(local.link()), &json!(local.link())));
let url = shared["url"].as_str().unwrap();
assert!(url.starts_with("ws://") && url.ends_with(&format!("&token={}", local.token())), "{url}");
assert_eq!(shared["sessions"][0]["link"], shared["link"]);
// Picking the edit code makes them the edit ones too.
let edit = host.execute("collab.share", &json!({"access": "edit"})).unwrap();
assert_eq!((&edit["link"], &edit["sessions"][0]["link"]), (&edit["share"]["editLink"], &edit["share"]["editLink"]));
let edit_token = edit["link"].as_str().unwrap().rsplit("token=").next().unwrap();
assert!(edit["url"].as_str().unwrap().ends_with(&format!("&token={edit_token}")), "{}", edit["url"]);
assert_ne!((&edit["link"], &edit["url"]), (&shared["link"], &shared["url"]));
assert_eq!(host.execute("collab.stop", &json!({})).unwrap()["ended"], true);
}
/// A share listening on every interface (`bind` 0.0.0.0) names where others reach it, never
/// 0.0.0.0: this machine's local network address (loopback without one) in its codes, links, URL
/// and address, and its view code and edit link both join it.
#[test]
fn a_share_on_every_interface_names_an_address_others_reach() {
let (mut host, mut viewer, mut editor) = (session("Hana"), session("Ada"), session("Bo"));
let shared = host.execute("collab.share", &json!({"via": "local", "bind": "0.0.0.0", "access": "view"})).unwrap();
let Code::Local(viewing) = code::parse(shared["code"].as_str().unwrap()).unwrap() else { panic!("not a local code") };
let at = lan_ip().unwrap_or(Ipv4Addr::LOCALHOST.into());
assert_eq!(IpAddr::from(viewing.ip), at);
let address = format!("{at}:{}", viewing.port);
assert_eq!(shared["address"], address);
let Code::Local(editing) = code::parse(shared["share"]["editCode"].as_str().unwrap()).unwrap() else { panic!("not a local code") };
assert_eq!((editing.ip, editing.port), (viewing.ip, viewing.port));
for way in [&shared["link"], &shared["url"], &shared["share"]["viewLink"], &shared["share"]["editLink"], &shared["sessions"][0]["link"]] {
let way = way.as_str().unwrap_or_default();
assert!(way.contains(&address) && !way.contains("0.0.0.0"), "{way}");
}
viewer.execute("collab.join", &json!({"code": shared["code"]})).unwrap();
editor.execute("collab.join", &json!({"link": shared["share"]["editLink"]})).unwrap();
pump(&mut [&mut host, &mut viewer, &mut editor], "the joins", |s| live(s[1]) && live(s[2]) && s[0].live().peers.len() == 2);
assert_eq!((&viewer.live_summary()["access"], &editor.live_summary()["access"]), (&json!("view"), &json!("edit")));
assert_eq!(host.execute("collab.stop", &json!({})).unwrap()["ended"], true);
}
/// A listener on one address is reached at it; one on every interface at an address of its own
/// family, on its port.
#[test]
fn a_listener_on_every_interface_is_reached_at_an_address_of_its_family() {
let one: SocketAddr = "192.168.1.20:47900".parse().unwrap();
assert_eq!(reachable(one), one);
for any in ["0.0.0.0:47900", "[::]:47901"] {
let any: SocketAddr = any.parse().unwrap();
let at = reachable(any);
assert!(!at.ip().is_unspecified() && at.is_ipv4() == any.is_ipv4() && at.port() == any.port(), "{any}: {at}");
}
}
/// An invite naming only `server` (what a join code is asked at).
fn code_at(server: &Endpoint) -> Invite {
Invite { server: server.clone(), file: String::new(), token: String::new() }
+1 -1
View File
@@ -391,7 +391,7 @@ impl<S: Socket> Driver<S> {
match after(outcome, &self.cfg, self.was_live, &mut self.attempt, &self.inbox) {
Next::Stop(e) => {
if let Some(e) = e {
self.inbox.push(e);
self.inbox.push(*e);
}
self.done = true;
}
+3
View File
@@ -419,6 +419,8 @@ fn pump(d: &mut Driver<Loopback>) {
}
}
/// The browser's driver joins a real host over loopback, edits with it, sends presence and hears
/// the session end.
#[test]
fn joins_a_real_host_and_edits_with_it() {
let host_probe = Inbox::new(Arc::new(|| {}));
@@ -428,6 +430,7 @@ fn joins_a_real_host_and_edits_with_it() {
any_port_if_taken: false,
file: FILE.into(),
token: TOKEN.into(),
view_token: None,
doc: shared(),
name: "Hana".into(),
limits: fast_limits(),
+2 -2
View File
@@ -67,7 +67,7 @@ impl Guest {
});
if let Err(o) = asked.and_then(|(file, token)| resolved(&mut cfg, file, token)) {
if let Next::Stop(Some(e)) = after(unresolved(&cfg, o), &cfg, false, &mut 0, &inbox) {
inbox.push(e);
inbox.push(*e);
}
return;
}
@@ -217,7 +217,7 @@ fn supervise(cfg: &GuestConfig, inbox: &Inbox, rx: &Receiver<Cmd>, me: &Sender<C
match after(outcome, cfg, was_live, &mut attempt, inbox) {
Next::Stop(e) => {
if let Some(e) = e {
inbox.push(e);
inbox.push(*e);
}
return;
}
+23 -8
View File
@@ -1,7 +1,8 @@
//! The session host the app embeds: the shared [hub](crate::hub) serving one file, the one the
//! app shares, with the session's single token. The person hosting is in it without a socket:
//! their presence goes in with [`Host::presence`], their edits with [`Host::send`], what others do
//! comes out through their [`Inbox`]. When the host stops, the session ends for everyone.
//! app shares, with the session's edit token and, for a share that has one, its view token. The
//! person hosting is in it without a socket: their presence goes in with [`Host::presence`], their
//! edits with [`Host::send`], what others do comes out through their [`Inbox`]. When the host
//! stops, the session ends for everyone.
use std::io;
use std::net::{SocketAddr, TcpListener};
@@ -26,6 +27,9 @@ pub struct HostConfig {
/// The file's session id and secret (both in the invite).
pub file: String,
pub token: String,
/// A second secret that lets its holders look and not edit (`None`: everyone who gets in
/// edits). It must differ from `token`.
pub view_token: Option<String>,
/// The shared document (the session's authoritative copy starts from it).
pub doc: Document,
/// The host's own name (empty: "Host").
@@ -40,21 +44,28 @@ pub struct Host {
me: Peer,
}
/// The app's one file and its token: everyone with the invite edits.
/// The app's one file and its tokens: whoever has the edit token edits, whoever has the view
/// token only looks (the server refuses their changes).
struct OneFile {
file: String,
token: String,
view: Option<String>,
}
impl Backend for OneFile {
/// Let in a hello for `file` with `token`, with what its token allows.
fn admit(&mut self, file: &str, token: &str) -> Result<Admission, String> {
if file != self.file {
return Err("this invite is for another file or an earlier session".into());
}
if !same_secret(token, &self.token) {
return Err("wrong session token".into());
// Both tokens are compared, whichever matches, so the time taken doesn't tell them apart.
let edit = same_secret(token, &self.token);
let view = self.view.as_deref().is_some_and(|v| same_secret(token, v));
match (edit, view) {
(true, _) => Ok(Admission { access: Access::Edit, link: None }),
(false, true) => Ok(Admission { access: Access::View, link: None }),
(false, false) => Err("wrong session token".into()),
}
Ok(Admission { access: Access::Edit, link: None })
}
fn open(&mut self, _: &str) -> Result<Server, String> {
@@ -69,6 +80,10 @@ impl Backend for OneFile {
impl Host {
/// Listen and start the hub; the host joins its own session as the first person.
pub fn start(cfg: HostConfig, inbox: Inbox) -> io::Result<Host> {
// A view token equal to the edit token would let its holders edit: nobody gets in with it.
if cfg.view_token.as_deref().is_some_and(|v| same_secret(v, &cfg.token)) {
return Err(io::Error::other("the view-only secret is the same as the edit secret"));
}
let listener = match TcpListener::bind(cfg.addr) {
Ok(l) => l,
Err(e) if e.kind() == io::ErrorKind::AddrInUse && cfg.any_port_if_taken => TcpListener::bind(SocketAddr::new(cfg.addr.ip(), 0))?,
@@ -99,7 +114,7 @@ impl Host {
}
}
let me = server.peers().find(|p| p.client_id == me).cloned().ok_or_else(|| io::Error::other("the host didn't join its own session"))?;
let backend = OneFile { file: cfg.file.clone(), token: cfg.token };
let backend = OneFile { file: cfg.file.clone(), token: cfg.token, view: cfg.view_token };
let local = Local { file: cfg.file, server, id: me.client_id, inbox };
let hosting = Hosting::start(listener, backend, cfg.limits, Some(local))?;
Ok(Host { hosting, me })
+6 -4
View File
@@ -137,8 +137,9 @@ pub(crate) fn closed(code: u16, reason: &str) -> Outcome {
/// What a guest does after a connection ended.
pub(crate) enum Next {
/// Done: tell the UI this (nothing when you left).
Stop(Option<Event>),
/// Done: tell the UI this (nothing when you left). Boxed: an event can be large (a welcome),
/// and this one is only ever a status or the end of the session.
Stop(Option<Box<Event>>),
/// Connect again after this pause (the UI has been told: `Reconnecting`).
Reconnect(Duration),
}
@@ -147,12 +148,12 @@ pub(crate) enum Next {
/// can't reach the host; a live connection that dropped tries again with growing pauses until the
/// attempts run out.
pub(crate) fn after(outcome: Outcome, cfg: &GuestConfig, was_live: bool, attempt: &mut u32, inbox: &Inbox) -> Next {
let failed = |reason: String| Next::Stop(Some(Event::Status(Status::Failed { reason })));
let failed = |reason: String| Next::Stop(Some(Box::new(Event::Status(Status::Failed { reason }))));
match outcome {
Outcome::Left => Next::Stop(None),
Outcome::Refused(why) => failed(format!("The host refused: {why}")),
Outcome::Failed(why) => failed(why),
Outcome::Ended(reason) => Next::Stop(Some(Event::Ended { reason })),
Outcome::Ended(reason) => Next::Stop(Some(Box::new(Event::Ended { reason }))),
Outcome::Retry(why) if !was_live => failed(format!("Couldn't join the live session: {why}")),
Outcome::Retry(why) => {
*attempt += 1;
@@ -228,6 +229,7 @@ pub(crate) fn take(mut m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mu
address: inv.server.display(),
network: !inv.loopback(),
access,
view: None,
};
seen.peers = peers.clone();
seen.info = Some(info.clone());
+62 -20
View File
@@ -1,6 +1,6 @@
//! [`NetLink`]: the engine's [`LiveLink`] over the network.
use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
@@ -9,7 +9,7 @@ use figcraft_collab::code;
use figcraft_collab::message::{Access, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, PluginReply, PluginReq};
use figcraft_engine::live::{
Bind, Event, HostInfo, HostOptions, LiveLink, NO_SESSION, NetReply, NetRequest, PageInfo, Presence, PublishOptions, Published, Resume, Role,
SessionId, SessionInfo, Shared,
SessionId, SessionInfo, Shared, WayIn,
};
use crate::guest::{Guest, GuestConfig, Publish, upload_file};
@@ -97,24 +97,62 @@ impl NetLink {
}
}
/// A new secret for a hosted session: one a join code can carry (`short`: 44 bits, see
/// `figcraft_collab::code`), else a full one.
fn session_secret(short: bool) -> Result<String, String> {
match short {
true => Ok(code::Local::new(Ipv4Addr::UNSPECIFIED, 0, token::random()?).token()),
false => token::secret(),
}
}
/// A view-only secret for a session whose edit secret is `edit`: of the same kind, never the same.
fn view_secret(short: bool, edit: &str) -> Result<String, String> {
for _ in 0..3 {
let view = session_secret(short)?;
if view != edit {
return Ok(view);
}
}
Err("couldn't make a view-only secret".into())
}
/// This machine's address on the local network: the interface the default route leaves by (no
/// packet is sent).
pub fn lan_ip() -> Option<IpAddr> {
let sock = UdpSocket::bind(SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))).ok()?;
// Documentation and private ranges: only the route matters.
for probe in ["192.0.2.1:9", "10.255.255.255:9"] {
if sock.connect(probe).is_ok()
&& let Ok(a) = sock.local_addr()
&& !a.ip().is_loopback()
&& !a.ip().is_unspecified()
{
return Some(a.ip());
}
}
None
route_ip(Ipv4Addr::UNSPECIFIED.into(), &["192.0.2.1:9", "10.255.255.255:9"])
}
/// The address of `any`'s family that the route to the first of `probes` that has one leaves by
/// (no packet is sent): never a loopback, unspecified or IPv6 link-local one, which others can't
/// reach this machine at.
fn route_ip(any: IpAddr, probes: &[&str]) -> Option<IpAddr> {
let sock = UdpSocket::bind(SocketAddr::new(any, 0)).ok()?;
probes.iter().find_map(|probe| {
sock.connect(probe).ok()?;
let ip = sock.local_addr().ok()?.ip();
let link_local = matches!(ip, IpAddr::V6(v6) if v6.is_unicast_link_local());
(!ip.is_loopback() && !ip.is_unspecified() && !link_local).then_some(ip)
})
}
/// Where others reach a session listening on `addr`: `addr` itself, or for a listener on every
/// interface (`0.0.0.0`, `::`) this machine's local network address of the same family (its
/// loopback without one), on the same port. Links name it; the listener keeps every interface.
pub(crate) fn reachable(addr: SocketAddr) -> SocketAddr {
let ip = match addr.ip() {
ip if !ip.is_unspecified() => ip,
IpAddr::V4(_) => lan_ip().unwrap_or(Ipv4Addr::LOCALHOST.into()),
// Documentation and unique local ranges, as for IPv4.
IpAddr::V6(_) => route_ip(Ipv6Addr::UNSPECIFIED.into(), &["[2001:db8::1]:9", "[fd00::1]:9"]).unwrap_or(Ipv6Addr::LOCALHOST.into()),
};
SocketAddr::new(ip, addr.port())
}
impl LiveLink for NetLink {
/// Host the open file on `o.bind`: a join code's short secret when `o.short`, and a second,
/// view-only secret with its invite link when `o.view`.
fn host(&mut self, session: SessionId, o: HostOptions) -> Result<SessionInfo, String> {
let ip = match o.bind {
Bind::Local => IpAddr::V4(Ipv4Addr::LOCALHOST),
@@ -123,11 +161,10 @@ impl LiveLink for NetLink {
};
// A session shared with a join code: its secret is short enough to be typed with the
// address (see `figcraft_collab::code`); the code and the invite link name the same session.
let (file, token) = if o.short {
(code::LOCAL_FILE.to_string(), code::Local::new(Ipv4Addr::UNSPECIFIED, 0, token::random()?).token())
} else {
(token::file_id()?, token::secret()?)
};
let file = if o.short { code::LOCAL_FILE.to_string() } else { token::file_id()? };
let token = session_secret(o.short)?;
// A way in for looking only: a second secret of the same kind, never the edit one.
let view_token = o.view.then(|| view_secret(o.short, &token)).transpose()?;
let inbox = self.sessions.inbox();
let doc = (*o.doc).clone();
let pages = doc.pages.iter().map(|p| PageInfo { id: p.0, name: doc.node(*p).map(|n| n.name.clone()).unwrap_or_default() }).collect();
@@ -137,13 +174,17 @@ impl LiveLink for NetLink {
any_port_if_taken: o.port.is_none(),
file: file.clone(),
token: token.clone(),
view_token: view_token.clone(),
doc,
name: o.name,
limits: self.limits,
core: figcraft_collab::Limits::default(),
};
let host = Host::start(cfg, inbox.clone()).map_err(|e| format!("can't listen on {ip}: {e}"))?;
let invite = Invite::new(host.addr().to_string(), file, token);
// Listening on every interface (`0.0.0.0`, `::`), the links name an address others reach.
let at = reachable(host.addr()).to_string();
let view = view_token.map(|v| Invite::new(at.clone(), file.clone(), v)).map(|v| WayIn { link: v.link(), url: v.url() });
let invite = Invite::new(at.clone(), file, token);
let info = SessionInfo {
role: Role::Host,
me: host.me().client_id,
@@ -153,9 +194,10 @@ impl LiveLink for NetLink {
pages,
link: invite.link(),
url: invite.url(),
address: host.addr().to_string(),
address: at,
network: !ip.is_loopback(),
access: Access::Edit,
view,
};
self.started(session, Active::Host(host), inbox);
Ok(info)
+57 -1
View File
@@ -7,7 +7,7 @@ use std::net::{SocketAddr, TcpStream};
use std::sync::Arc;
use std::time::{Duration, Instant};
use figcraft_collab::message::{self, CatchUp, ClientMsg, Hello, Presence, ServerMsg, Signal};
use figcraft_collab::message::{self, Access, CatchUp, ClientMsg, Hello, Presence, ServerMsg, Signal};
use figcraft_collab::{Client, Limits};
use figcraft_doc::{Document, NodeId};
use figcraft_engine::live::{Event, PEOPLE, Status, TOO_LARGE_TO_JOIN};
@@ -78,6 +78,7 @@ pub(crate) fn fast_limits() -> NetLimits {
NetLimits { hello_timeout: Duration::from_secs(5), ..NetLimits::default() }
}
/// A host on a free loopback port with these limits, a probe of what it is told, and its invite.
fn start_with(limits: NetLimits, core: Limits) -> (Host, Probe, Invite) {
let probe = Probe::new();
let mut doc = Document::new("Shared file");
@@ -87,6 +88,7 @@ fn start_with(limits: NetLimits, core: Limits) -> (Host, Probe, Invite) {
any_port_if_taken: false,
file: FILE.into(),
token: TOKEN.into(),
view_token: None,
doc,
name: "Hana".into(),
limits,
@@ -214,6 +216,7 @@ fn guests_join_and_see_each_other() {
assert_eq!(host.connections(), 0);
}
/// A guest with the wrong token or for another file is refused, and the secret never shows.
#[test]
fn a_wrong_token_or_file_is_refused() {
let (mut host, mut hp, invite) = start();
@@ -236,6 +239,57 @@ fn a_wrong_token_or_file_is_refused() {
host.stop();
}
/// Join `addr` with `token` over a bare socket and make an edit: what the welcome allowed, and
/// whether the host took the edit (`Changes`) or rejected it (`Reject`).
fn join_and_edit(addr: SocketAddr, token: &str) -> (Access, ServerMsg) {
let mut raw = Raw::hello(addr, token);
let mut welcome = std::iter::from_fn(|| raw.next()).find(|m| matches!(m, ServerMsg::Welcome { .. })).expect("a welcome");
message::unpack(&mut welcome, 64 << 20).unwrap();
let ServerMsg::Welcome { access, .. } = welcome else { panic!("not a welcome: {welcome:?}") };
let mut client = Client::join(welcome).unwrap();
let mut doc = client.document().clone();
doc.add_page("Mine");
assert!(raw.send(&client.edit(&doc).unwrap().unwrap()));
let answer = std::iter::from_fn(|| raw.next()).find(|m| matches!(m, ServerMsg::Changes(_) | ServerMsg::Reject { .. })).expect("an answer");
(access, answer)
}
/// A session hosted with a view token as well (a local network share's view-only code): whoever
/// joins with it is welcomed as a viewer and their edits are rejected, the edit token still edits,
/// any other token is still refused, and a view token equal to the edit token isn't hosted.
#[test]
fn a_view_token_lets_people_look_and_not_edit() {
const VIEW: &str = "ViewOnlyViewOnlyView00";
let probe = Probe::new();
let cfg = |view: &str| {
let mut doc = Document::new("Shared file");
doc.repair();
HostConfig {
addr: SocketAddr::from(([127, 0, 0, 1], 0)),
any_port_if_taken: false,
file: FILE.into(),
token: TOKEN.into(),
view_token: Some(view.into()),
doc,
name: "Hana".into(),
limits: fast_limits(),
core: Limits::default(),
}
};
let same = Host::start(cfg(TOKEN), probe.inbox.clone()).err().expect("a view token equal to the edit token is refused");
assert!(same.to_string().contains("view-only"), "{same}");
let mut host = Host::start(cfg(VIEW), probe.inbox.clone()).unwrap();
let (access, answer) = join_and_edit(host.addr(), VIEW);
assert_eq!(access, Access::View);
assert!(matches!(&answer, ServerMsg::Reject { reason, .. } if reason.contains("view")), "{answer:?}");
let (access, answer) = join_and_edit(host.addr(), TOKEN);
assert_eq!(access, Access::Edit);
assert!(matches!(answer, ServerMsg::Changes(_)), "{answer:?}");
let refusal = Raw::hello(host.addr(), "WrongWrongWrongWrong00").refusal();
assert!(refusal.as_deref().is_some_and(|r| r.contains("token")), "{refusal:?}");
host.stop();
}
#[test]
fn unreachable_hosts_fail_at_once() {
// A port nothing listens on.
@@ -677,6 +731,7 @@ fn the_host_removes_someone_and_they_cant_come_back() {
assert_eq!(host.connections(), 0);
}
/// A welcome larger than the guest takes says the file is too large to join.
#[test]
fn a_welcome_over_the_size_limit_says_the_file_is_too_large() {
let mut doc = Document::new("B".repeat(1 << 20));
@@ -687,6 +742,7 @@ fn a_welcome_over_the_size_limit_says_the_file_is_too_large() {
any_port_if_taken: false,
file: FILE.into(),
token: TOKEN.into(),
view_token: None,
doc,
name: "Hana".into(),
limits: fast_limits(),
+3 -1
View File
@@ -663,7 +663,9 @@ pub struct Glass {
/// Blur of the backdrop (a blur radius, document units, 0–[`Self::MAX_FROST`]).
pub frost: f64,
/// Which way the curved edge bends the backdrop: towards the shape's core (0; along the edge's
/// normal for a shape too big to work one out) to away from the layer's centre (1).
/// normal for a shape too big to work one out) to away from the centre of the layer's bounds
/// (1; on a ring or an L, whose centre lies outside the shape, an inner edge bends towards the
/// hole, as the reference tool's does).
pub splay: f64,
}
+9
View File
@@ -1352,6 +1352,15 @@ pub fn library_of(doc: &Document, id: NodeId) -> Option<&str> {
doc.libraries.import_of(root).map(|r| r.library.as_str())
}
pub fn library_source(doc: &Document, id: NodeId) -> Option<(&str, NodeId)> {
if !doc.is_remote(id) {
return None;
}
let record = doc.libraries.import_of(asset_root(doc, id)?)?;
let key = record.ids.iter().find(|(_, local)| **local == id).map_or(record.key, |(key, _)| *key);
Some((record.library.as_str(), key))
}
/// The display name of library `id` in `doc` (its link's name, else the id).
pub fn library_name<'a>(doc: &'a Document, id: &'a str) -> &'a str {
doc.libraries.link(id).map_or(id, |l| l.name.as_str())
+69 -2
View File
@@ -189,8 +189,6 @@ fn assert_remote_read_only(s: &mut Session, b: NodeId, remote_button: NodeId) {
let style = s.doc().unwrap().styles[0].id.clone();
assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err());
assert!(s.execute("style.delete", &json!({"style": style})).is_err());
let e = s.execute("component.goToMain", &json!({"id": b})).unwrap_err();
assert!(e.to_string().contains("library"), "{e}");
assert!(s.execute("select.set", &json!({"ids": [remote_button]})).is_err(), "library copies can't be selected");
assert!(s.doc().unwrap().pages.contains(&s.page().unwrap()));
assert!(s.execute("component.pushChanges", &json!({"id": b})).is_err());
@@ -399,6 +397,75 @@ fn add_place_and_update_keep_overrides() {
let _ = std::fs::remove_dir_all(&d);
}
fn go_to_main(s: &mut Session, id: NodeId) -> Value {
s.active = 0;
ex(s, "component.goToMain", json!({"id": id}))
}
#[test]
fn going_to_a_library_main_opens_the_library_file() {
let d = dir("go-to-main");
let lib_path = path_str(&d.join("kit.figcraft"));
let mut lib = Session::new();
let k = kit(&mut lib);
let mut chips = Vec::new();
for (i, color) in ["#ff0000", "#00ff00"].iter().enumerate() {
let f = idv(&ex(&mut lib, "frame.create", json!({"x": 400.0 + i as f64 * 100.0, "y": 0, "width": 60, "height": 20, "name": "Chip"})));
ex(&mut lib, "node.set", json!({"id": f, "props": {"fill": color}}));
chips.push(idv(&ex(&mut lib, "component.create", json!({"ids": [f]}))));
}
ex(&mut lib, "component.combineVariants", json!({"ids": chips}));
let kit_page = lib.doc().unwrap().page_of(k.card).unwrap();
ex(&mut lib, "page.add", json!({"name": "Notes"}));
let lid = publish_to(&mut lib, &lib_path);
let mut s = Session::new();
ex(&mut s, "library.add", json!({"path": lib_path}));
let card = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Card", "x": 0, "y": 0})));
let nested_button = node(&s, card).children[0];
let chip = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Chip", "x": 0, "y": 200})));
ex(&mut s, "instance.setProperties", json!({"ids": [chip], "properties": {"Property 1": "Variant2"}}));
s.ds_mut().unwrap().view.vw = 900.0;
s.ds_mut().unwrap().view.vh = 500.0;
let using = s.doc().unwrap().clone();
let r = go_to_main(&mut s, card);
assert_eq!((id_of(&r["id"]), r["index"].as_u64()), (Some(k.card), Some(1)), "{r}");
let ds = s.ds().unwrap();
assert_eq!(ds.path.as_deref(), Some(lib_path.as_str()));
assert_eq!((ds.selection.clone(), ds.page), (vec![k.card], kit_page));
assert_eq!((ds.view.vw, ds.view.vh), (900.0, 500.0), "the canvas it opens on is the one shown, so the fit stands");
let shown = ds.doc.world_bounds(k.card).unwrap().center();
assert_eq!((ds.view.cx, ds.view.cy), (shown.x, shown.y));
for (layer, main) in [(nested_button, k.button), (chip, chips[1])] {
let r = go_to_main(&mut s, layer);
assert_eq!((id_of(&r["id"]), r["index"].as_u64()), (Some(main), Some(1)), "{r}");
assert_eq!(s.ds().unwrap().selection, vec![main]);
}
assert_eq!(s.docs.len(), 2, "the open library file comes to the front");
assert_eq!(s.docs[0].doc.as_ref(), &using, "the file going there is unchanged");
s.active = 1;
ex(&mut s, "edit.delete", json!({"ids": [k.card]}));
let r = go_to_main(&mut s, card);
assert!(r["id"].is_null() && r["index"] == 1, "{r}");
assert!(s.message.take().is_some_and(|m| m.contains("isn't in this file any more")));
ex(&mut s, "file.close", json!({"index": 1, "force": true}));
std::fs::rename(&lib_path, d.join("moved.figcraft")).unwrap();
s.active = 0;
let e = s.execute("component.goToMain", &json!({"id": card})).unwrap_err();
assert!(e.to_string().contains("isn't at"), "{e}");
assert_eq!(s.docs.len(), 1);
let NodeKind::Instance { component: copy, .. } = node(&s, card).kind else { panic!("not an instance") };
s.doc_mut().unwrap().libraries_mut().components.retain(|r| r.root != copy);
let e = s.execute("component.goToMain", &json!({"id": card})).unwrap_err();
assert!(e.to_string().contains("no link to its library"), "an imported copy without its record: {e}");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn library_styles_apply_and_import_with_pages() {
let d = dir("styles");
+72 -21
View File
@@ -186,28 +186,42 @@ pub fn specs() -> Vec<CommandSpec> {
true,
reset_override
),
spec!("component.goToMain", "Go to main component", "{id?}", false, |s, p| {
let cmd = "component.goToMain";
let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?;
let doc = s.doc()?;
let inst = owning_instance(doc, i).ok_or_else(|| bad(cmd, "not an instance"))?;
let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else {
return Err(bad(cmd, "not an instance"));
};
if !doc.contains(component) {
return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into()));
spec!(
"component.goToMain",
"Go to main component",
"{id?} selects the instance's main component; a library component's main is in its library file, which opens in a tab (or comes to the front) with the main selected. Returns {id} (the main in the file now shown), and `index` (the tab) when it went to a library file",
false,
|s, p| {
let cmd = "component.goToMain";
let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?;
let doc = s.doc()?;
let inst = owning_instance(doc, i).ok_or_else(|| bad(cmd, "not an instance"))?;
let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else {
return Err(bad(cmd, "not an instance"));
};
if !doc.contains(component) {
return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into()));
}
if let Some((library, key)) = crate::library::library_source(doc, component) {
let name = crate::library::library_name(doc, library).to_string();
let library = library.to_string();
return go_to_library_main(s, &library, &name, key);
}
if doc.is_remote(component) {
return Err(EngineError::Disabled(
cmd.into(),
"the main component is a library copy with no link to its library (it came with an imported file): enable that library to reach it"
.into(),
));
}
super::edit::select_and_page(s, vec![component])?;
let ds = s.ds_mut()?;
if let Some(b) = ds.doc.world_bounds(component) {
ds.view.fit(b, 2.0);
}
Ok(json!({"id": component}))
}
if let Some(lib) = crate::library::library_of(doc, component) {
let name = crate::library::library_name(doc, lib);
return Err(EngineError::Disabled(cmd.into(), format!("the main component is in the library \"{name}\": open that file to edit it")));
}
super::edit::select_and_page(s, vec![component])?;
let ds = s.ds_mut()?;
if let Some(b) = ds.doc.world_bounds(component) {
ds.view.fit(b, 2.0);
}
Ok(json!({"id": component}))
}),
),
spec!("component.pushChanges", "Push changes to main component", "{id?}", true, push_changes),
spec!(
"component.restoreMain",
@@ -283,6 +297,43 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn go_to_library_main(s: &mut Session, library: &str, name: &str, key: NodeId) -> Result<Value> {
let cmd = "component.goToMain";
let refuse = |why: String| EngineError::Disabled(cmd.into(), why);
let path = s
.doc()?
.libraries
.link(library)
.map(|l| l.path.clone())
.ok_or_else(|| refuse(format!("the library \"{name}\" was removed from this file")))?;
if crate::library_server::is_server_link(&path) {
return Err(refuse(format!("the main component is in the team library \"{name}\": open its file from the server to edit it")));
}
if !craft_docpath::is_local(&path) {
return Err(refuse(format!("{path}: a library is opened only from this computer")));
}
if !crate::file_access::exists(&path) {
return Err(refuse(format!("the library file of \"{name}\" isn't at {path} any more")));
}
let viewport = s.ds().map(|d| (d.view.vw, d.view.vh))?;
let opened = super::file::open(s, &json!({"path": path}))?;
let ds = s.ds_mut()?;
(ds.view.vw, ds.view.vh) = viewport;
let doc = &ds.doc;
let same_library = doc.published.as_ref().is_some_and(|p| p.manifest.id == library);
let main = doc.node(key).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }));
if !same_library || !main || doc.is_remote(key) {
s.message = Some(format!("{name}: the main component isn't in this file any more"));
return Ok(json!({"id": null, "index": opened["index"]}));
}
super::edit::select_and_page(s, vec![key])?;
let ds = s.ds_mut()?;
if let Some(b) = ds.doc.world_bounds(key) {
ds.view.fit(b, 2.0);
}
Ok(json!({"id": key, "index": opened["index"]}))
}
/// Place an instance of `component` centred on page point (`x`, `y`), in the frame or auto layout
/// slot under it (`instance.place`).
pub(crate) fn place(s: &mut Session, p: &Value) -> Result<Value> {
+2 -1
View File
@@ -37,7 +37,8 @@ pub(crate) use load::{load_fig, read_capped, recovered, recovered_from, variable
pub use open::MAX_RECENT;
#[cfg(test)]
use open::append_pages;
use open::{import, open, open_recent, revert};
pub(crate) use open::open;
use open::{import, open_recent, revert};
pub(crate) use save::save_ends_preview;
pub use save::{FileSnapshot, file_bytes, thumbnail};
use save::{color_profile, mark_saved, save, save_as};
+1 -1
View File
@@ -54,7 +54,7 @@ pub(super) fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"files": files}))
}
pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
let variables = variables_param("file.open", p)?;
if string(p, "path").is_none()
&& let Some(d) = string(p, "data")
+2 -1
View File
@@ -81,11 +81,12 @@ fn only_a_comment_link_leaves_the_comment_tool() {
assert!(!tool_left(crate::live::PUBLISHING, ToolId::Comment));
}
/// A local network share has edit and view-only codes, not a comment code, and says where one is.
#[test]
fn a_local_share_has_no_comment_code() {
let (mut s, _) = session();
let e = s.execute("collab.share", &json!({"via": "local", "access": "comment"})).unwrap_err().to_string();
assert!(e.contains("can edit it"), "{e}");
assert!(e.contains("share it online for a comment code"), "{e}");
let e = s.execute("collab.share", &json!({"access": "owner"})).unwrap_err().to_string();
assert!(e.contains("\"comment\""), "{e}");
}
+42 -10
View File
@@ -13,7 +13,7 @@ use serde_json::{Value, json};
use crate::live::{
Access, Bind, DEFAULT_SERVER, Event, HostOptions, LiveLink, Presence, PublishOptions, Published, Role, SessionInfo, Shared, SharedLink, Status,
join_target,
WayIn, join_target,
};
use crate::{EngineError, Session};
@@ -36,13 +36,23 @@ struct Link(Arc<Mutex<Script>>);
/// Where the scripted host listens, and its session's secret.
const HOST: Local = Local { ip: Ipv4Addr::new(192, 168, 1, 20), port: 47_900, secret: 0x123_4567_89AB };
/// The same session's view-only secret.
const VIEW: Local = Local { secret: 0xBA9_8765_4321, ..HOST };
/// The WebSocket URL the scripted link gives with an invite `link` (the same secret).
fn mock_url(link: &str) -> String {
link.replace("figcraft://join?host=", "ws://")
}
impl LiveLink for Link {
/// Host as the network link does: a code's short secret when shared with a code, and the
/// view-only link when asked for one.
fn host(&mut self, session: u64, opts: HostOptions) -> Result<SessionInfo, String> {
let mut s = self.0.lock().unwrap();
s.session = session;
// As the network link does: a short secret when the session is shared with a code.
let link = if opts.short { HOST.link() } else { "figcraft://join?host=192.168.1.20:47900&file=f&token=AbCdEfGhIjKlMnOpQrStUv".to_string() };
let view = opts.view.then(|| WayIn { link: VIEW.link(), url: mock_url(&VIEW.link()) });
s.hosted.push(opts);
Ok(SessionInfo {
role: Role::Host,
@@ -51,11 +61,12 @@ impl LiveLink for Link {
color: "#0D99FF".into(),
file_name: "Shared".into(),
pages: vec![],
url: link.replace("figcraft://join?host=", "ws://"),
url: mock_url(&link),
link,
address: "192.168.1.20:47900".into(),
network: true,
access: Access::Edit,
view,
})
}
fn join(&mut self, session: u64, link: &str, _: &str) -> Result<(), String> {
@@ -146,6 +157,7 @@ fn on_the_server(s: &mut Session, script: &Arc<Mutex<Script>>, codes: Option<(&s
address: "wss://share.example:443/".into(),
network: true,
access: Access::Edit,
view: None,
};
let events = vec![Event::Published(published), Event::Shared(Ok(links(0, codes))), Event::Welcome { info, peers: vec![] }];
script.lock().unwrap().events.extend(events);
@@ -352,6 +364,8 @@ fn online_sharing_is_off_without_a_server() {
assert!(matches!(bare.execute("collab.join", &json!({"code": HOST.code()})), Err(EngineError::Disabled(..))));
}
/// A local network share shows the code that carries the host's address, has a view-only code
/// for the same session, switches which shows without hosting again, and listens where told.
#[test]
fn sharing_on_the_local_network_shows_a_code_that_carries_the_address() {
let (mut s, script) = session();
@@ -360,30 +374,48 @@ fn sharing_on_the_local_network_shows_a_code_that_carries_the_address() {
let shown = r["code"].as_str().unwrap();
assert_eq!(code::parse(shown), Ok(code::Code::Local(HOST)), "{shown}");
assert_eq!(shown, code::grouped(&HOST.code()));
assert_eq!((&r["link"], &r["share"]["viewCode"]), (&json!(HOST.link()), &Value::Null));
assert_eq!(r["link"], json!(HOST.link()));
// A view-only code too, for the same session with its own secret; no comment code.
let view = code::grouped(&VIEW.code());
assert_eq!((&r["share"]["viewCode"], &r["share"]["viewLink"]), (&json!(view), &json!(VIEW.link())));
assert_eq!((&r["share"]["editLink"], &r["share"]["commentCode"]), (&json!(HOST.link()), &Value::Null));
{
let sc = script.lock().unwrap();
let [h] = sc.hosted.as_slice() else { panic!("hosted {}", sc.hosted.len()) };
assert!(h.short && h.bind == Bind::Lan && h.port.is_none(), "the local network unless told otherwise");
assert!(h.short && h.view && h.bind == Bind::Lan && h.port.is_none(), "the local network unless told otherwise");
}
// Everyone edits there.
assert!(err(&mut s, "collab.share", json!({"access": "view"})).contains("share it online"));
// Which code shows: the view code, then the edit code again (nothing is hosted twice).
// The link goes with the code: a view-only code's link doesn't edit.
let r = ex(&mut s, "collab.share", json!({"access": "view"}));
assert_eq!((&r["code"], &r["share"]["access"]), (&json!(view), &json!("view")));
assert_eq!((&r["link"], &r["url"], &r["sessions"][0]["link"]), (&json!(VIEW.link()), &json!(mock_url(&VIEW.link())), &json!(VIEW.link())));
let r = ex(&mut s, "collab.share", json!({"access": "edit"}));
assert_eq!((&r["code"], &r["link"], &r["url"]), (&json!(code::grouped(&HOST.code())), &json!(HOST.link()), &json!(mock_url(&HOST.link()))));
assert!(err(&mut s, "collab.share", json!({"access": "comment"})).contains("share it online"));
assert_eq!(script.lock().unwrap().hosted.len(), 1);
assert_eq!(ex(&mut s, "collab.stop", json!({}))["ended"], true);
assert!(script.lock().unwrap().asked.is_empty(), "no server to tell");
assert!(err(&mut s, "collab.share", json!({"via": "local", "access": "view"})).contains("share it online"));
// Shared for looking only from the start: the edit code isn't the one shown.
let r = ex(&mut s, "collab.share", json!({"via": "local", "access": "view"}));
assert_eq!((&r["code"], &r["share"]["access"]), (&json!(view), &json!("view")));
assert_eq!((&r["link"], &r["url"]), (&json!(VIEW.link()), &json!(mock_url(&VIEW.link()))), "the edit link and URL aren't handed out");
assert_eq!(r["sessions"][0]["link"], json!(VIEW.link()));
ex(&mut s, "collab.stop", json!({}));
assert!(err(&mut s, "collab.share", json!({"via": "local", "access": "comment"})).contains("share it online"));
// Where to listen is the advanced part.
ex(&mut s, "collab.share", json!({"via": "local", "bind": "local", "port": 5000}));
let sc = script.lock().unwrap();
assert!(sc.hosted[1].bind == Bind::Local && sc.hosted[1].port == Some(5000));
assert!(sc.hosted[2].bind == Bind::Local && sc.hosted[2].port == Some(5000));
drop(sc);
ex(&mut s, "collab.stop", json!({}));
for bad in [json!({"via": "post"}), json!({"via": "local", "port": 70000}), json!({"via": "local", "bind": "everywhere"})] {
assert!(matches!(s.execute("collab.share", &bad), Err(EngineError::BadParams { .. })), "{bad}");
}
// A plain `collab.host` session has an invite link, not a code.
// A plain `collab.host` session has an invite link, not a code, and everyone in it edits.
let r = ex(&mut s, "collab.host", json!({}));
assert_eq!((&r["code"], &r["share"]), (&Value::Null, &Value::Null));
assert!(!script.lock().unwrap().hosted[2].short);
let h = &script.lock().unwrap().hosted[3];
assert!(!h.short && !h.view);
}
#[test]
+2
View File
@@ -90,6 +90,7 @@ impl LiveLink for Mock {
}
}
/// A session you are in as `role`, as client `me`, everyone in it editing.
pub(crate) fn info(role: Role, me: u32) -> SessionInfo {
SessionInfo {
role,
@@ -103,6 +104,7 @@ pub(crate) fn info(role: Role, me: u32) -> SessionInfo {
address: "127.0.0.1:47900".into(),
network: false,
access: crate::live::Access::Edit,
view: None,
}
}
+2
View File
@@ -136,6 +136,7 @@ fn edit_link(server: &str, file: &str) -> String {
format!("figcraft://join?server={server}&file={file}&token={EDIT_TOKEN}")
}
/// A session you are in as `role` under `name`, invited with `link`.
fn info(role: Role, name: &str, link: &str) -> SessionInfo {
SessionInfo {
role,
@@ -149,6 +150,7 @@ fn info(role: Role, name: &str, link: &str) -> SessionInfo {
address: SERVER.into(),
network: true,
access: Access::Edit,
view: None,
}
}
+13 -1
View File
@@ -72,7 +72,7 @@ pub(crate) use join::start_join;
pub use join::{can_remove_people, specs};
pub use types::{
Bind, DEFAULT_SERVER, Event, HostOptions, LiveLink, MAX_NAME, MAX_PEERS, MAX_SELECTION, NO_HOSTING, NO_SESSION, NetReply, NetRequest, ONLINE_OFF,
PEOPLE, PageInfo, PublishOptions, Published, Role, SessionId, SessionInfo, Share, Shared, SharedLink, Status, TOO_LARGE_TO_JOIN, Via,
PEOPLE, PageInfo, PublishOptions, Published, Role, SessionId, SessionInfo, Share, Shared, SharedLink, Status, TOO_LARGE_TO_JOIN, Via, WayIn,
join_target, parse_color,
};
@@ -147,6 +147,18 @@ impl Live {
self.info.as_ref().map(|i| i.role)
}
/// The session's invite link and WebSocket URL as `collab.peers` gives them. A file shared
/// on the local network gives the ones that go with the code shown: the session's own link
/// and URL are the edit way in, and a view-only share must not hand them out. Otherwise the
/// session's own (online, the owner's: `share` has the links for others).
pub fn invite(&self) -> (Option<&str>, Option<&str>) {
let info = self.info.as_ref();
match &self.share {
Some(sh) if sh.via == Via::Local => info.and_then(|i| i.way_in(sh.access)).unzip(),
_ => (info.map(|i| i.link.as_str()), info.map(|i| i.url.as_str())),
}
}
/// Stop following. False when you weren't following anyone.
fn unfollow(&mut self) -> bool {
self.followed = None;
+28 -15
View File
@@ -36,14 +36,16 @@ pub(super) fn unavailable(cmd: &str) -> EngineError {
)
}
/// `collab.host`: host the open file (on this machine unless `p` says where) with an invite link.
pub(super) fn host(s: &mut Session, p: &Value) -> Result<Value> {
start_host(s, "collab.host", p, Bind::Local, false)?;
start_host(s, "collab.host", p, Bind::Local, false, false)?;
Ok(s.live_summary())
}
/// Start hosting the open file for command `cmd` (on `bind` unless `p` says where; `short`: with
/// a secret a join code can carry): the new session.
fn start_host(s: &mut Session, cmd: &str, p: &Value, bind: Bind, short: bool) -> Result<SessionId> {
/// a secret a join code can carry; `view`: with a second secret for looking only): the new
/// session.
fn start_host(s: &mut Session, cmd: &str, p: &Value, bind: Bind, short: bool, view: bool) -> Result<SessionId> {
let port = match get(p, "port") {
None => None,
Some(v) => Some(v.as_u64().and_then(|n| u16::try_from(n).ok()).ok_or_else(|| bad(cmd, "`port` must be a whole number from 0 to 65535"))?),
@@ -55,7 +57,7 @@ fn start_host(s: &mut Session, cmd: &str, p: &Value, bind: Bind, short: bool) ->
Some(other) => other.parse::<IpAddr>().map(Bind::Addr).map_err(|_| bad(cmd, "`bind` must be \"local\", \"lan\" or an IP address"))?,
};
let (doc, uid) = s.ds().map(|ds| (ds.doc.clone(), ds.uid))?;
let opts = HostOptions { port, bind, name: display_name(&s.prefs), doc, short };
let opts = HostOptions { port, bind, name: display_name(&s.prefs), doc, short, view };
let (id, info) = s.live.start(cmd, |l, id| l.host(id, opts).map_err(|e| EngineError::Other(format!("couldn't start the live session: {e}"))))?;
s.live.open(Live { id, status: Status::Live, info: Some(info), doc_uid: Some(uid), tab: Some(uid), ..Live::default() });
Ok(id)
@@ -82,8 +84,9 @@ fn start_publish(s: &mut Session, cmd: &str, server: &str, p: &Value, share: boo
Ok(id)
}
/// Why a local network share has no view-only or comment code.
const LOCAL_EDITS: &str = "everyone who joins a file shared on the local network can edit it: share it online for a view-only or comment code";
/// Why a local network share has no comment code.
const LOCAL_COMMENTS: &str =
"a file shared on the local network has an edit code and a view-only code, not a comment code: share it online for a comment code";
/// Why a share has no comment code.
const NO_COMMENT_LINK: &str =
@@ -94,6 +97,8 @@ fn access_param(cmd: &str, p: &Value) -> Result<Option<Access>> {
string(p, "access").map(|a| Access::parse(&a).ok_or_else(|| bad(cmd, "`access` must be \"edit\", \"comment\" or \"view\""))).transpose()
}
/// `collab.share`: share the open file with join codes, online or on the local network, or (shared
/// already) switch which code shows.
pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "collab.share";
let via = match string(p, "via").as_deref().map(str::trim) {
@@ -112,8 +117,8 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
"this file is shared the other way already: stop sharing it first (collab.stop), or leave its session (collab.leave)",
));
}
if access.is_some_and(|a| a != Access::Edit) && shared == Via::Local {
return Err(bad(CMD, LOCAL_EDITS));
if access == Some(Access::Comment) && shared == Via::Local {
return Err(bad(CMD, LOCAL_COMMENTS));
}
if let Some(req) = share_request(s, Vec::new(), true).filter(|_| s.live().share.as_ref().is_some_and(|sh| sh.links.is_none())) {
let links = share_answer(s, req)?.ok_or_else(|| EngineError::Other("the server made no links".into()))?;
@@ -130,8 +135,8 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
let access = access.unwrap_or_default();
let mut server = String::new();
let id = match via {
Via::Local if access != Access::Edit => return Err(bad(CMD, LOCAL_EDITS)),
Via::Local => start_host(s, CMD, p, Bind::Lan, true)?,
Via::Local if access == Access::Comment => return Err(bad(CMD, LOCAL_COMMENTS)),
Via::Local => start_host(s, CMD, p, Bind::Lan, true, true)?,
Via::Online => {
server = string(p, "server").unwrap_or_else(|| s.prefs.publish_server.clone()).trim().to_string();
if server.is_empty() {
@@ -141,17 +146,25 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
}
};
if let Some(live) = s.live.sessions.get_mut(&id) {
// On the local network the session's own link is what others join with, and the code says
// what the link says: where the session is and its secret. (Online they arrive with the file.)
let links = live.info.as_ref().filter(|_| via == Via::Local).map(|i| {
let code = code::Local::from_link(&i.link).map(|l| l.code());
Shared { edit: SharedLink { id: String::new(), link: i.link.clone(), code }, ..Shared::default() }
// On the local network the session's own links are what others join with (its view link
// only lets them look), and each code says what its link says: where the session is and
// that link's secret. (Online they arrive with the file.)
let links = live.info.as_ref().filter(|_| via == Via::Local).map(|i| Shared {
edit: local_link(&i.link),
view: i.view.as_ref().map(|v| local_link(&v.link)),
comment: None,
});
live.share = Some(Share { via, server, access, links });
}
Ok(s.live_summary())
}
/// A way into a session this app hosts: its invite link and the join code that stands for it
/// (none when the link can't be put in one: a host without an IPv4 address).
fn local_link(link: &str) -> SharedLink {
SharedLink { id: String::new(), link: link.to_string(), code: code::Local::from_link(link).map(|l| l.code()) }
}
/// What others join the open file's server file with changed: in its session and its record.
pub(super) fn shared_now(s: &mut Session, links: Option<Shared>) {
if let Some((server, file)) = s.live().server_file() {
+3 -2
View File
@@ -162,6 +162,7 @@ fn spotlight(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"spotlighting": s.live().spotlighting}))
}
/// The live session commands (`collab.*`).
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
@@ -174,7 +175,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"collab.share",
"Share with a join code",
"{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"comment\" (online only: view and comment, not edit) | \"view\" (online only: which code `code` is), server? (online: another server's address, with ?key=… when it needs its key), key? (online: the server's key, when the address has none), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` an invite link. Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` is {via, access, shared, editCode, viewCode, commentCode, editLink, viewLink, commentLink}: links made for others (commentCode and commentLink null from a server older than comment links) (the file's own links in `published` stay yours); `code` stays null when the server is older than join codes (give `share.editLink` / `share.viewLink` instead). Called again while shared: only switches which code `code` is (`access`). Called after `collab.stop` on a file that is still on the server: new links and codes for that same file (nothing is published twice). `collab.stop` stops sharing",
"{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"comment\" (online only: view and comment, not edit) | \"view\" (look only: their edits are refused): which code `code` is, server? (online: another server's address, with ?key=… when it needs its key), key? (online: the server's key, when the address has none), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` the invite link to give with it and `url` its WebSocket URL (local: the ones that go with `code`, so a view-only code's link and URL let people look only; online: your own link to the file, which edits: `share` has the link that goes with each code). Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols), and `share` is {via, access, shared, editCode, viewCode, editLink, viewLink}: the edit and view-only ways in, each with its own secret (commentCode and commentLink null). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` is {via, access, shared, editCode, viewCode, commentCode, editLink, viewLink, commentLink}: links made for others (commentCode and commentLink null from a server older than comment links) (the file's own links in `published` stay yours); `code` stays null when the server is older than join codes (give `share.editLink` / `share.viewLink` instead). Called again while shared: only switches which code `code` is (`access`). Called after `collab.stop` on a file that is still on the server: new links and codes for that same file (nothing is published twice). `collab.stop` stops sharing",
false,
share
),
@@ -230,7 +231,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"collab.peers",
"People in this file",
"{} → the open file's session (each open file has its own or none): {available, canHost (false in the browser: it can only join), session (its id; null: this file isn't in a session), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, access: edit|view|comment (view: a server's view link, edits are refused; comment: its comment link, edits other than to comment threads are refused), readOnly (the open file can't be edited now: view only, comment only, or being published), published (a file you published: {server, file, name, edit, view}), code (the join code to give others, when the file was shared with `collab.share`; null otherwise or while it is on its way), share ({via: online|local, access, shared (false: a file of yours on a server that nobody else gets into now), editCode, viewCode, commentCode, editLink, viewLink, commentLink (what others join with; null when not shared)} or null), follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}], sessions: [{session, document (index of its open document; null for a join on its way), status, reason, role, fileName, link, peers (how many others)}] (every session of the app)}",
"{} → the open file's session (each open file has its own or none): {available, canHost (false in the browser: it can only join), session (its id; null: this file isn't in a session), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, access: edit|view|comment (view: a view link or code, a server's or a local network share's, edits are refused; comment: a server's comment link, edits other than to comment threads are refused), readOnly (the open file can't be edited now: view only, comment only, or being published), published (a file you published: {server, file, name, edit, view}), code (the join code to give others, when the file was shared with `collab.share`; null otherwise or while it is on its way), share ({via: online|local, access, shared (false: a file of yours on a server that nobody else gets into now), editCode, viewCode, commentCode, editLink, viewLink, commentLink (what others join with; null when not shared)} or null), follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}], sessions: [{session, document (index of its open document; null for a join on its way), status, reason, role, fileName, link, peers (how many others)}] (every session of the app)}",
false,
|s, _| Ok(s.live_summary())
),
+4 -3
View File
@@ -283,6 +283,7 @@ impl Session {
})
.collect();
let mine = synced.and_then(|d| d.text_edit.as_ref()).map(|t| editing(&editing_of(t)));
let (link, url) = l.invite();
let sessions: Vec<Value> = self
.live
.iter()
@@ -295,7 +296,7 @@ impl Session {
"reason": reason(o),
"role": o.role(),
"fileName": info.map(|i| i.file_name.as_str()),
"link": info.map(|i| i.link.as_str()),
"link": o.invite().0,
"peers": o.peers.len(),
})
})
@@ -313,8 +314,8 @@ impl Session {
"color": info.map(|i| i.color.as_str()),
"fileName": info.map(|i| i.file_name.as_str()),
"pages": info.map(|i| &i.pages),
"link": info.map(|i| i.link.as_str()),
"url": info.map(|i| i.url.as_str()),
"link": link,
"url": url,
"address": info.map(|i| i.address.as_str()),
"network": info.map(|i| i.network),
"access": l.client.as_ref().map(Client::access).or(info.map(|i| i.access)),
+29 -2
View File
@@ -119,9 +119,33 @@ pub struct SessionInfo {
pub address: String,
/// Reachable from other machines (the host listens on a network address, not loopback).
pub network: bool,
/// What you may do: edit, only view (a server's view link), or view and comment (its comment
/// link).
/// What you may do: edit, only view (a view link or code), or view and comment (a server's
/// comment link).
pub access: Access,
/// A session this app hosts with a way in for looking only ([`HostOptions::view`]): its
/// invite link and WebSocket URL, which carry the view secret (`None`: everyone who joins
/// edits, or you joined).
pub view: Option<WayIn>,
}
impl SessionInfo {
/// The invite link and WebSocket URL that let people into a session this app hosts with
/// `access` (its own link and URL edit). `None`: no way in gives that.
pub fn way_in(&self, access: Access) -> Option<(&str, &str)> {
match access {
Access::Edit => Some((&self.link, &self.url)),
Access::View => self.view.as_ref().map(|v| (v.link.as_str(), v.url.as_str())),
Access::Comment => None,
}
}
}
/// A way into a session this app hosts: an invite link and the WebSocket URL it connects to,
/// which carry the same secret.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct WayIn {
pub link: String,
pub url: String,
}
/// A file you published to a server: where, and its first share links (they carry their tokens:
@@ -390,6 +414,9 @@ pub struct HostOptions {
pub doc: Arc<Document>,
/// Shared with a join code (`collab.share`): the session's secret is short enough to type.
pub short: bool,
/// Also make a second secret whose holders only look: they see every change, and the session
/// refuses theirs ([`SessionInfo::view`]).
pub view: bool,
}
/// What [`LiveLink::publish`] needs to put the open file on a server.
+66 -15
View File
@@ -3,13 +3,15 @@
//! Within `depth` of the outline the surface curves like a thick lens: the backdrop there is bent
//! towards the inside, most a [`BEVEL`] in from the edge (`refraction` × `depth` far), falling off
//! as the 3.5th power of the way out of the curve and gone `depth` further in, with a fifth as
//! much on the bevel itself ([`bend_at`]). `splay` turns which way it bends, from along the edge's
//! normal (0) to away from the layer's centre (1). Each colour channel bends by a different amount
//! (`dispersion`, a rainbow fringe: red furthest, blue least, each smeared along the bend). The
//! bevel catches the light as a thin line added to the backdrop ([`rim_light_at`]): fully where the
//! edge faces `light_angle`, none below 0.35 of facing it, 0.6 as much on the opposite side; and a
//! faint gradient runs across the curve, 5% darker where it faces the light and lighter opposite.
//! `frost` blurs the backdrop first. Distances to the outline come from an exact Euclidean
//! much on the bevel itself ([`bend_at`]). `splay` turns which way it bends, from towards the
//! shape's core (0; along the edge's normal for a shape too big to keep, see [`MAX_SHAPE_PX`]) to
//! away from the centre of the layer's bounds (1), as the reference tool's: on a ring or an L,
//! whose centre lies outside the shape, an inner edge bends towards the hole. Each colour channel
//! bends by a different amount (`dispersion`, a rainbow fringe: red furthest, blue least, each
//! smeared along the bend). The bevel catches the light as a thin line added to the backdrop
//! ([`rim_light_at`]): fully where the edge faces `light_angle`, none below 0.35 of facing it, 0.6
//! as much on the opposite side; and a faint gradient runs across the curve, 5% darker where it
//! faces the light and lighter opposite. `frost` blurs the backdrop first. Distances to the outline come from an exact Euclidean
//! distance transform of the shape's coverage.
//!
//! The shape's coverage, distances and normals depend only on the shape and the zoom. Once a
@@ -379,15 +381,27 @@ pub(crate) fn bend_at(x: f32, depth: f32, bevel: f32, refraction: f32) -> f32 {
core + (rim - core) * u
}
/// Below this split (pixels: the move's length × `k`) the dispersion window takes one sample: its
/// steps lie within 1/256 px of the moved point, so one sample is within one level (of 255) of the
/// window even across the hardest edge, from black to white or opaque to clear in a pixel.
const ONE_SAMPLE_SPLIT_PX: f32 = 1.0 / 256.0;
/// The backdrop seen at pixel coordinates (`x`, `y`) moved by (`dx`, `dy`), its channels split by
/// `k` (dispersion, 0–1): red moves up to `k` further, blue up to `k` less, each smeared along the
/// move by a triangular window of [`DISPERSION_STEPS`]. One sample when `k` is too small to show.
/// move by a triangular window of [`DISPERSION_STEPS`]. One sample when the split is too small to
/// show: `k` under 0.001, or the channels under [`ONE_SAMPLE_SPLIT_PX`] apart (where the curve
/// flattens out and the move is short).
fn dispersed(frosted: &Pixmap, x: f32, y: f32, dx: f32, dy: f32, k: f32) -> [f32; 4] {
let at = |s: f32| sample(frosted, x + dx * s, y + dy * s);
if k < 0.001 {
let s = at(1.0);
if k < 0.001 || (dx * dx + dy * dy).sqrt() * k < ONE_SAMPLE_SPLIT_PX {
let s = sample(frosted, x + dx, y + dy);
return [s[0].min(s[3]), s[1].min(s[3]), s[2].min(s[3]), s[3]];
}
dispersion_window(frosted, x, y, dx, dy, k)
}
/// [`dispersed`]'s triangular window, every step sampled.
fn dispersion_window(frosted: &Pixmap, x: f32, y: f32, dx: f32, dy: f32, k: f32) -> [f32; 4] {
let at = |s: f32| sample(frosted, x + dx * s, y + dy * s);
let last = (DISPERSION_STEPS - 1) as f32;
let (mut acc, mut sum) = ([0.0f32; 4], 0.0f32);
for i in 1..DISPERSION_STEPS - 1 {
@@ -575,8 +589,11 @@ struct ShapeKey {
r: usize,
}
/// The cached fields of a shape whose window holds at most this many pixels; bigger shapes (a
/// pane far larger than the view) are worked out for the part a render shows each time.
/// The cached fields of a shape whose window holds at most this many pixels: 4M on the desktop, 1M
/// on the web. Bigger shapes (a pane far larger than the view) are worked out for the part a render
/// shows each time, with no core: they bend along their edge normals. So a large glass text or
/// vector can bend differently once zoomed into, in a 2× export than on the canvas, and on the web
/// than on the desktop. A rounded rectangle works out its core in closed form at any size.
#[cfg(not(target_arch = "wasm32"))]
const MAX_SHAPE_PX: u64 = 4 << 20;
#[cfg(target_arch = "wasm32")]
@@ -919,8 +936,8 @@ pub fn glass(g: &Glass, frame: EffectFrame<'_>, rect: Option<RoundedRect>) -> Op
fx::blur(&mut frosted, g.frost / 2.0 * scale);
let ang = g.light_angle.to_radians();
let light = (ang.sin() as f32, -ang.cos() as f32);
// The layer's centre in its own frame (see `own_frame`), which `splay` bends away from: the
// direction to it is the same wherever a render puts or cuts the layer.
// The centre of the layer's bounds in its own frame (see `own_frame`), which `splay` bends away
// from: the direction to it is the same wherever a render puts or cuts the layer.
let local_centre = shape.0.bounding_box().center();
let ((sx, sy), centre) = match own_frame(xf) {
Some(own) => (own.shift, own.xf * local_centre),
@@ -1048,6 +1065,40 @@ mod tests {
assert_eq!(spread[2], 0.0);
}
/// Where the channels split under [`ONE_SAMPLE_SPLIT_PX`], one sample stands in for the window
/// within one level, even over the hardest backdrops: 1 px columns of black and white, and of
/// white and nothing.
#[test]
fn a_split_too_narrow_to_show_takes_one_sample() {
for clear in [false, true] {
let mut p = Pixmap::new(64, 4).unwrap();
for (i, px) in p.data_mut().chunks_exact_mut(4).enumerate() {
let on = i % 2 == 1;
let v = if on { 255 } else { 0 };
px.copy_from_slice(&[v, v, v, if clear { v } else { 255 }]);
}
let mut worst = 0.0f32;
for step in 0..400 {
let x = 20.0 + step as f32 / 100.0;
// Along the columns' rows and across them, and out past the backdrop's last row.
for (dx, dy) in [(2.0f32, 0.0f32), (-2.0, 0.0), (1.2, 1.6), (0.0, 2.0), (40.0, 0.0)] {
let k = ONE_SAMPLE_SPLIT_PX * 0.999 / (dx * dx + dy * dy).sqrt();
let one = dispersed(&p, x, 2.0, dx, dy, k);
assert_eq!(one, dispersed(&p, x, 2.0, dx, dy, 0.0), "one sample at {x}");
let window = dispersion_window(&p, x, 2.0, dx, dy, k);
worst = one.iter().zip(window).fold(worst, |m, (a, b)| m.max((a - b).abs()));
}
}
assert!(worst <= 1.0, "clear {clear}: {worst}");
}
// A wider split still takes the window.
let mut p = Pixmap::new(64, 4).unwrap();
p.data_mut().chunks_exact_mut(4).step_by(2).for_each(|px| px.copy_from_slice(&[255; 4]));
let k = ONE_SAMPLE_SPLIT_PX * 2.0 / 2.0;
assert_eq!(dispersed(&p, 20.3, 2.0, 2.0, 0.0, k), dispersion_window(&p, 20.3, 2.0, 2.0, 0.0, k));
assert_ne!(dispersed(&p, 20.3, 2.0, 2.0, 0.0, k), dispersed(&p, 20.3, 2.0, 2.0, 0.0, 0.0));
}
/// A `w` × `h` coverage with the rectangles `rects` (x, y, width, height) filled.
fn coverage(w: u32, h: u32, rects: &[(f32, f32, f32, f32)]) -> Pixmap {
let mut p = Pixmap::new(w, h).unwrap();
+1
View File
@@ -127,6 +127,7 @@ name = "English"
"Another server, or one that needs its key. The usual server is set in Preferences." = "Another server, or one that needs its key. The usual server is set in Preferences."
"Any status" = "Any status"
"Anyone on your network with this code can edit the file, while this app stays open." = "Anyone on your network with this code can edit the file, while this app stays open."
"Anyone on your network with this code can look at the file, not change it, while this app stays open." = "Anyone on your network with this code can look at the file, not change it, while this app stays open."
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them."
"Anyone with this code can edit the file." = "Anyone with this code can edit the file."
"Anyone with this code can look at the file, not change it." = "Anyone with this code can look at the file, not change it."
+25 -9
View File
@@ -195,21 +195,37 @@ fn pointer_event(app: &FigApp, kind: PointerKind, pos: Pos2, m: &egui::Modifiers
/// labels, handles, comments, versions/branch/dev/live UI and the cursor.
pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
let rect = ui.max_rect();
app.canvas.rect = rect;
let old_rect = std::mem::replace(&mut app.canvas.rect, rect);
let ctx = ui.ctx().clone();
let ppp = ctx.pixels_per_point();
let resp = ui.interact(rect, ui.scope_id().with("canvas"), Sense::click_and_drag());
let key_box = app.ui.key_box;
crate::a11y::canvas(&mut app.ui.nav, &resp, || key_box.map_or_else(|| describe(&app.session), describe_box));
if let Ok(ds) = app.session.ds_mut()
&& ((ds.view.vw - f64::from(rect.width())).abs() > 0.5 || (ds.view.vh - f64::from(rect.height())).abs() > 0.5)
{
// Keep the centre; the first sizing after opening re-fits.
if let Ok(ds) = app.session.ds_mut() {
let resized = (ds.view.vw - f64::from(rect.width())).abs() > 0.5 || (ds.view.vh - f64::from(rect.height())).abs() > 0.5;
// The first sizing after opening re-fits.
let first = ds.view.vw == 1200.0 && ds.view.vh == 800.0;
ds.view.vw = f64::from(rect.width());
ds.view.vh = f64::from(rect.height());
if first {
ds.zoom_to_fit();
// While a button is held that went down on the canvas (a click that selects shows the
// right panel, minimized, and a click that deselects hides it; the left panel and rail
// can appear too) the page point under a still pointer stays where it is: the gesture's
// own pointer events would otherwise map to another page point and the move handling
// would take that for a drag. `to_page` is `origin + (p - rect.min) / z` with
// `origin = c - vw / 2 / z`, so the centre moves by half the size change plus the
// rect's own move, over the zoom.
let held = resp.is_pointer_button_down_on() && !first && old_rect.is_finite();
let z = ds.view.zoom.max(1e-9);
let (dw, dh) = if resized { (f64::from(rect.width()) - ds.view.vw, f64::from(rect.height()) - ds.view.vh) } else { (0.0, 0.0) };
if resized {
// Otherwise keep the centre.
ds.view.vw = f64::from(rect.width());
ds.view.vh = f64::from(rect.height());
if first {
ds.zoom_to_fit();
}
}
if held {
ds.view.cx += (dw / 2.0 + f64::from(rect.min.x - old_rect.min.x)) / z;
ds.view.cy += (dh / 2.0 + f64::from(rect.min.y - old_rect.min.y)) / z;
}
}
let own_cursor = input(app, ui, &resp);
+21 -1
View File
@@ -108,6 +108,9 @@ pub struct LiveUi {
focus_join: bool,
/// Share on the local network (else online, through the server in Preferences).
pub local: bool,
/// What the code a local network share starts with lets people do: edit, or only look (the
/// edit code then doesn't show until it is picked).
pub local_access: Access,
/// The Share popover's Advanced part shows; what it sets for a local network share: only
/// this computer can join, and the port (empty: the usual one).
pub advanced: bool,
@@ -852,6 +855,7 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
}
if local {
widgets::label(ui, tr("People on your network join with a code. Nothing goes through a server."), t.text_secondary);
local_access_choice(app, ui);
} else {
online_note(app, ui, t, &server);
}
@@ -861,10 +865,24 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
join_and_advanced(app, ui, t, |app, ui, t| share_advanced(app, ui, t, local, &server));
}
/// What the code of a local network share lets people do, picked before it starts: edit the file
/// or only look at it.
fn local_access_choice(app: &mut FigApp, ui: &mut Ui) {
let levels: Vec<(Access, &str)> = LEVELS.into_iter().filter(|(a, _)| *a != Access::Comment).collect();
let labels: Vec<&str> = levels.iter().map(|(_, l)| tr(l)).collect();
let at = levels.iter().position(|(a, _)| *a == app.ui.live.local_access).unwrap_or_default();
if let Some((a, _)) = widgets::segmented_text(ui, &labels, at).and_then(|i| levels.get(i)) {
app.ui.live.local_access = *a;
}
}
/// Start sharing the open file on the local network, or online on `server` (the one in
/// Preferences).
fn start_sharing(app: &mut FigApp, local: bool, server: &str) {
let mut params = json!({"via": if local { "local" } else { "online" }});
if local {
params["access"] = json!(app.ui.live.local_access.id());
}
if local && app.ui.live.this_computer {
params["bind"] = json!("local");
}
@@ -1068,6 +1086,7 @@ fn share_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens, share: &Share) {
(true, Access::View) => tr("Anyone with this code can look at the file, not change it."),
(true, Access::Comment) => tr("Anyone with this code can look at the file and comment on it, not change it."),
(true, Access::Edit) => tr("Anyone with this code can edit the file."),
(false, Access::View) => tr("Anyone on your network with this code can look at the file, not change it, while this app stays open."),
(false, _) => tr("Anyone on your network with this code can edit the file, while this app stays open."),
};
widgets::label(ui, who, t.text_secondary);
@@ -1081,7 +1100,8 @@ fn share_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens, share: &Share) {
}
/// What the code shown lets people do, to pick from the levels the share has links for: a server
/// has links (and codes) for looking only and for commenting; a session this app hosts doesn't.
/// has links (and codes) for looking only and for commenting; a session this app hosts on the
/// local network has one for looking only.
fn access_choice(app: &mut FigApp, ui: &mut Ui, share: &Share) {
let levels: Vec<(Access, &str)> = LEVELS.into_iter().filter(|(a, _)| share.links.as_ref().is_some_and(|l| l.of(*a).is_some())).collect();
if levels.len() < 2 {
+2
View File
@@ -17,6 +17,7 @@ type Queue = Arc<Mutex<Vec<(u64, LiveEvent)>>>;
struct Hosting(Queue);
impl LiveLink for Hosting {
/// Host at once, with an invite link and nobody else in the session.
fn host(&mut self, session: u64, _: HostOptions) -> Result<SessionInfo, String> {
let address = format!("127.0.0.1:{}", 47_899 + session);
Ok(SessionInfo {
@@ -31,6 +32,7 @@ impl LiveLink for Hosting {
address,
network: false,
access: Access::Edit,
view: None,
})
}
fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> {
+1
View File
@@ -44,6 +44,7 @@ pub(crate) fn joined(h: &mut Harness, access: Access) {
address: "127.0.0.1:47900".into(),
network: false,
access,
view: None,
};
h.send(vec![LiveEvent::Welcome { info, peers: vec![] }, LiveEvent::Sync(w)]);
h.frames(2);
+51 -8
View File
@@ -1,7 +1,7 @@
//! Sharing with a join code in the window: the Share popover not sharing (online or the local
//! network, one Start button, the rest under Advanced), sharing online (the code large, copying,
//! what it lets people do, who is here, stop: the file stays on the server, not shared, and can
//! be shared again or removed), sharing on the local network, a server without
//! be shared again or removed), sharing on the local network (to edit, or to look only), a server without
//! codes, joining with the one join field (the popover, the home screen, the Join dialog), its
//! errors, the server preference, and an idle popover asking for no frames.
@@ -13,7 +13,7 @@ use figcraft_engine::figcraft_collab::code::{self, Local};
use figcraft_engine::figcraft_collab::message::ClientMsg;
use figcraft_engine::live::{
Access, DEFAULT_SERVER, Event as LiveEvent, HostOptions, LiveLink, NetReply, NetRequest, Peer, Presence, PublishOptions, Published, Role,
SessionInfo, Shared as SharedLinks, SharedLink, Status,
SessionInfo, Shared as SharedLinks, SharedLink, Status, WayIn,
};
use serde_json::json;
@@ -53,9 +53,12 @@ struct Link(Shared);
/// Where the scripted host listens, and its session's secret.
const HOST: Local = Local { ip: std::net::Ipv4Addr::new(192, 168, 1, 20), port: 47_900, secret: 0x123_4567_89AB };
/// The same session's view-only secret.
const VIEW: Local = Local { secret: 0xBA9_8765_4321, ..HOST };
/// A link without a code in it (an address a code can't carry).
const PLAIN: &str = "figcraft://join?host=[fd00::1]:47900&file=f&token=AbCdEfGhIjKlMnOpQrStUv";
/// What the scripted link says about a session you are in as `role`, invited with `link`.
fn info(role: Role, link: &str) -> SessionInfo {
SessionInfo {
role,
@@ -69,16 +72,20 @@ fn info(role: Role, link: &str) -> SessionInfo {
address: "192.168.1.20:47900".into(),
network: true,
access: Access::Edit,
view: None,
}
}
impl LiveLink for Link {
/// Host as the network link does: port 6 stands for an address a code can't carry, and a
/// share for looking only gets a view link too.
fn host(&mut self, session: u64, opts: HostOptions) -> Result<SessionInfo, String> {
let mut s = self.0.lock().unwrap();
s.session = session;
let link = if opts.port == Some(6) { PLAIN.to_string() } else { HOST.link() };
let (link, view) = if opts.port == Some(6) { (PLAIN.to_string(), PLAIN.replace("AbCd", "View")) } else { (HOST.link(), VIEW.link()) };
let view = opts.view.then(|| WayIn { url: view.replace("figcraft://join?host=", "ws://"), link: view });
s.hosted.push(opts);
Ok(info(Role::Host, &link))
Ok(SessionInfo { view, ..info(Role::Host, &link) })
}
fn join(&mut self, session: u64, link: &str, _: &str) -> Result<(), String> {
let mut s = self.0.lock().unwrap();
@@ -208,6 +215,8 @@ fn document_arrives(h: &mut Harness, script: &Shared) {
h.frames(3);
}
/// Not sharing: one way to start (online or the local network, which also offers Can edit / Can
/// view) and one to join, with addresses, keys and ports under Advanced.
#[test]
fn not_sharing_the_popover_offers_one_way_to_start_and_one_to_join() {
let (mut h, script) = harness();
@@ -223,9 +232,11 @@ fn not_sharing_the_popover_offers_one_way_to_start_and_one_to_join() {
}
let shapes = click(&mut h, &shapes, "Advanced");
assert!(has_text(&shapes, "Publish to another server…") && !has_text(&shapes, "Port"));
// The local network: what it means, and its own advanced part.
assert!(!has_text(&shapes, "Can view"), "online, what the code lets people do is picked once shared");
// The local network: what it means, what the code lets people do, and its own advanced part.
let shapes = click(&mut h, &shapes, "Local network");
assert!(has_text(&shapes, "People on your network join with a code. Nothing goes through a server."));
assert!(has_text(&shapes, "Can edit") && has_text(&shapes, "Can view") && !has_text(&shapes, "Can comment"));
assert!(has_text(&shapes, "This computer only") && has_text(&shapes, "Port") && !has_text(&shapes, "Publish to another server…"));
assert!(!has_text(&shapes, "About this server"));
assert_eq!(crate::live::inspect(&h.app)["local"], true);
@@ -377,6 +388,8 @@ fn a_file_that_is_not_shared_can_be_removed_from_the_server_after_asking() {
assert!(has_text(&shapes, "Share this file") && has_text(&shapes, "Removed from the server"));
}
/// Sharing on the local network shows the longer code, copies it and its link, and Can view
/// shows the view-only code; Advanced picks this computer only and a port.
#[test]
fn sharing_on_the_local_network_shows_its_longer_code() {
let (mut h, script) = harness();
@@ -388,15 +401,24 @@ fn sharing_on_the_local_network_shows_its_longer_code() {
assert!(has_text(&shapes, t), "sharing shows {t:?}");
}
assert!(has_text(&shapes, "Anyone on your network with this code can edit the file, while this app stays open."));
assert!(!has_text(&shapes, "Can view"), "everyone edits on the local network");
assert!(has_text(&shapes, "Can view") && !has_text(&shapes, "Can comment"), "a view-only code too, no comment code");
{
let s = script.lock().unwrap();
assert!(s.hosted[0].short && s.hosted[0].bind == figcraft_engine::live::Bind::Lan && s.hosted[0].port.is_none());
assert!(s.hosted[0].short && s.hosted[0].view && s.hosted[0].bind == figcraft_engine::live::Bind::Lan && s.hosted[0].port.is_none());
assert!(s.published.is_empty(), "nothing goes to a server");
}
let shapes = click(&mut h, &shapes, "Copy code");
let shapes = click(&mut h, &shapes, "Copy link");
assert_eq!(script.lock().unwrap().copied, [shown, HOST.link()]);
assert_eq!(script.lock().unwrap().copied, [shown.clone(), HOST.link()]);
// Can view shows the view-only code and link, and says what they let people do.
let shapes = click(&mut h, &shapes, "Can view");
let view = code::grouped(&VIEW.code());
assert!(has_text(&shapes, &view) && !has_text(&shapes, &shown));
assert!(has_text(&shapes, "Anyone on your network with this code can look at the file, not change it, while this app stays open."));
let shapes = click(&mut h, &shapes, "Copy code");
let shapes = click(&mut h, &shapes, "Copy link");
assert_eq!(script.lock().unwrap().copied[2..], [view, VIEW.link()]);
assert_eq!(script.lock().unwrap().hosted.len(), 1, "the same session");
// Advanced: this computer only, on a port of your choosing.
let shapes = click(&mut h, &shapes, "Stop sharing");
let shapes = click(&mut h, &shapes, "Advanced");
@@ -407,6 +429,27 @@ fn sharing_on_the_local_network_shows_its_longer_code() {
assert!(s.hosted[1].bind == figcraft_engine::live::Bind::Local && s.hosted[1].port == Some(6));
}
/// Can view picked before a local network share starts: the view-only code is the one shown, so
/// the edit code isn't on the screen until Can edit is picked.
#[test]
fn a_local_share_can_start_for_looking_only() {
let (mut h, script) = harness();
let shapes = open(&mut h);
let shapes = click(&mut h, &shapes, "Local network");
let shapes = click(&mut h, &shapes, "Can view");
assert_eq!(h.app.ui.live.local_access, Access::View);
assert_eq!(h.app.session.live().status, Status::Off, "picking starts nothing");
let shapes = click(&mut h, &shapes, "Start sharing");
let (view, edit) = (code::grouped(&VIEW.code()), code::grouped(&HOST.code()));
assert!(has_text(&shapes, &view) && !has_text(&shapes, &edit));
assert!(has_text(&shapes, "Anyone on your network with this code can look at the file, not change it, while this app stays open."));
assert_eq!(crate::live::inspect(&h.app)["share"]["access"], "view");
assert!(script.lock().unwrap().hosted[0].view);
// Can edit shows the edit code.
let shapes = click(&mut h, &shapes, "Can edit");
assert!(has_text(&shapes, &edit) && has_text(&shapes, "Anyone on your network with this code can edit the file, while this app stays open."));
}
#[test]
fn without_a_code_the_link_is_shared_instead() {
// A server from before join codes.
+1
View File
@@ -131,6 +131,7 @@ impl Harness {
address: "127.0.0.1:47900".into(),
network: false,
access: figcraft_engine::live::Access::Edit,
view: None,
};
self.send(vec![LiveEvent::Welcome { info, peers: vec![Peer { client_id: 1, name: "Hana".into(), color: "#9747FF".into() }] }]);
}
+8 -5
View File
@@ -129,9 +129,6 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec<u8>, f64, f64)> {
Some(readable) => readable,
None => with_pixel_size(encoded_clipboard_image(app)?)?,
};
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PASTED_IMAGE_PIXELS {
return None;
}
let density = figcraft_format::pixel_density(&png);
let (w, h) = figcraft_format::placed_size(w, h, density, figcraft_engine::cmd::create::PLACED_IMAGE_MAX_SIDE);
Some((png, w, h))
@@ -140,8 +137,14 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec<u8>, f64, f64)> {
const MAX_PASTED_IMAGE_PIXELS: u64 = 100_000_000;
fn with_pixel_size(png: Vec<u8>) -> Option<(Vec<u8>, (u32, u32))> {
let size = image::ImageReader::new(std::io::Cursor::new(&png)).with_guessed_format().ok()?.into_dimensions().ok()?;
Some((png, size))
use image::ImageDecoder as _;
let decoder = image::ImageReader::new(std::io::Cursor::new(&png)).with_guessed_format().ok()?.into_decoder().ok()?;
let (w, h) = decoder.dimensions();
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PASTED_IMAGE_PIXELS {
return None;
}
image::DynamicImage::from_decoder(decoder).ok()?;
Some((png, (w, h)))
}
fn encoded_clipboard_image(app: &mut FigApp) -> Option<Vec<u8>> {
+2
View File
@@ -48,6 +48,7 @@ type Shared = Arc<Mutex<Script>>;
struct Link(Shared);
/// A session you are in as `role` under `name`.
fn info(role: Role, name: &str) -> SessionInfo {
SessionInfo {
role,
@@ -61,6 +62,7 @@ fn info(role: Role, name: &str) -> SessionInfo {
address: "192.168.1.20:47900".into(),
network: true,
access: Access::Edit,
view: None,
}
}
+127
View File
@@ -346,3 +346,130 @@ fn a_narrow_window_shows_one_side_panel_at_a_time() {
let wide = canvas_width(&mut app, vec2(1440.0, 900.0));
assert!(wide > 600.0 && wide < 1100.0, "a wide window shows both panels beside the canvas ({wide})");
}
/// Five frames side by side at 46% zoom with the interface minimized and nothing selected.
fn minimized_row() -> (Ui, Vec<figcraft_doc::NodeId>) {
let mut s = Session::new();
let mut ids = Vec::new();
for k in 0..5 {
let r = s.execute("frame.create", &json!({"x": k * 470, "y": 0, "width": 400, "height": 600})).unwrap();
ids.push(figcraft_engine::params::id_of(&r["id"]).unwrap());
}
s.execute("select.set", &json!({"ids": []})).unwrap();
let mut u = Ui::new(s);
u.app.run("view.minimize", json!({"on": true}));
u.app.run("view.set", json!({"zoom": 0.46, "cx": 1500, "cy": 300}));
for _ in 0..4 {
u.frame(vec![]);
}
(u, ids)
}
fn frame_x(u: &Ui, id: figcraft_doc::NodeId) -> f64 {
u.app.session.doc().unwrap().world_bounds(id).unwrap().x0
}
/// A click on a frame while minimized selects it and shows the right panel (the canvas gets
/// narrower): the frame stays where it was, and no step is added to the history.
#[test]
fn a_click_that_shows_the_right_panel_does_not_move_the_frame() {
let (mut u, ids) = minimized_row();
assert!(u.app.ui.right_panel_rect.is_none(), "nothing selected: no right panel");
let wide = u.app.canvas.rect.width();
let steps = u.app.session.ds().unwrap().history.undo.len();
let p = u.app.to_screen(1880.0 + 200.0, 300.0);
u.frame(vec![Event::PointerMoved(p)]);
u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }]);
u.frame(vec![]);
u.frame(vec![]);
u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }]);
for _ in 0..4 {
u.frame(vec![]);
}
assert_eq!(u.app.session.selection(), vec![ids[4]], "the click selects the frame");
assert!(u.app.ui.right_panel_rect.is_some(), "the right panel shows");
assert!(u.app.canvas.rect.width() < wide - 100.0, "the canvas is narrower");
assert_eq!(frame_x(&u, ids[4]), 1880.0, "a click did not move the frame");
assert_eq!(u.app.session.ds().unwrap().history.undo.len(), steps, "no history step");
}
/// A real drag still moves the frame, by the pointer's screen movement over the zoom, in one step.
#[test]
fn a_drag_after_the_right_panel_shows_moves_the_frame_by_the_pointer() {
let (mut u, ids) = minimized_row();
let steps = u.app.session.ds().unwrap().history.undo.len();
let p = u.app.to_screen(1880.0 + 200.0, 300.0);
u.frame(vec![Event::PointerMoved(p)]);
u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }]);
u.frame(vec![]);
u.frame(vec![Event::PointerMoved(p + vec2(50.0, 0.0))]);
u.frame(vec![Event::PointerMoved(p + vec2(100.0, 0.0))]);
u.frame(vec![Event::PointerButton {
pos: p + vec2(100.0, 0.0),
button: egui::PointerButton::Primary,
pressed: false,
modifiers: Default::default(),
}]);
for _ in 0..3 {
u.frame(vec![]);
}
assert!(u.app.ui.right_panel_rect.is_some(), "the right panel shows");
let moved = frame_x(&u, ids[4]) - 1880.0;
assert!((moved - 100.0 / 0.46).abs() < 6.0, "moved by 100 points over the zoom, got {moved}");
assert_eq!(u.app.session.ds().unwrap().history.undo.len(), steps + 1, "one step");
}
/// Clicking empty canvas deselects and hides the right panel again; the marquee that starts there
/// has no offset: nothing moves and nothing is selected.
#[test]
fn a_click_that_hides_the_right_panel_does_not_move_the_frame() {
let (mut u, ids) = minimized_row();
u.app.run("select.set", json!({"ids": [ids[4]]}));
for _ in 0..3 {
u.frame(vec![]);
}
assert!(u.app.ui.right_panel_rect.is_some());
let p = u.app.to_screen(1880.0 + 200.0, 1000.0);
let steps = u.app.session.ds().unwrap().history.undo.len();
u.frame(vec![Event::PointerMoved(p)]);
u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }]);
u.frame(vec![]);
u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }]);
for _ in 0..3 {
u.frame(vec![]);
}
assert!(u.app.ui.right_panel_rect.is_none(), "deselected: no right panel");
assert_eq!(frame_x(&u, ids[4]), 1880.0);
assert_eq!(u.app.session.ds().unwrap().history.undo.len(), steps);
}
/// The left sidebar and rail appearing while a press is held (Minimize toggled off mid-press)
/// moves the canvas's left edge: a still pointer keeps its page point, so the release moves nothing.
#[test]
fn a_held_press_keeps_its_page_point_when_the_canvas_moves() {
let (mut u, ids) = minimized_row();
u.app.run("select.set", json!({"ids": [ids[3]]}));
for _ in 0..3 {
u.frame(vec![]);
}
let steps = u.app.session.ds().unwrap().history.undo.len();
let left = u.app.canvas.rect.min.x;
let p = u.app.to_screen(1880.0 + 200.0, 300.0);
u.frame(vec![Event::PointerMoved(p)]);
u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }]);
u.frame(vec![]);
let before = u.app.to_page(p);
u.app.run("view.minimize", json!({"on": false}));
u.frame(vec![]);
u.frame(vec![]);
assert!(u.app.canvas.rect.min.x > left + 100.0, "the left sidebar and rail moved the canvas's edge");
let after = u.app.to_page(p);
assert!((after.x - before.x).abs() < 0.5 && (after.y - before.y).abs() < 0.5, "the pointer's page point moved: {before:?} to {after:?}");
u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }]);
for _ in 0..3 {
u.frame(vec![]);
}
assert_eq!(frame_x(&u, ids[4]), 1880.0, "the frame did not move");
assert_eq!(frame_x(&u, ids[3]), 1410.0);
assert_eq!(u.app.session.ds().unwrap().history.undo.len(), steps, "no history step");
}
+15
View File
@@ -549,3 +549,18 @@ fn a_clipboard_png_past_the_byte_limit_falls_back_to_the_decoded_image() {
let pasted = doc.nodes().find(|n| n.name == "Pasted image").expect("pasted from the decoded image");
assert_eq!((pasted.width, pasted.height), (2.0, 2.0));
}
#[test]
fn a_clipboard_png_with_a_cut_off_body_falls_back_to_the_decoded_image() {
let mut w = Win::new();
let mut png = std::io::Cursor::new(Vec::new());
image::RgbaImage::new(4, 4).write_to(&mut png, image::ImageFormat::Png).unwrap();
let png = png.into_inner();
let cut_off = png[..png.len() - 20].to_vec();
assert_eq!(image::ImageReader::new(std::io::Cursor::new(&cut_off)).with_guessed_format().unwrap().into_dimensions().unwrap(), (4, 4));
*w.clip.borrow_mut() = Clip { png: Some(cut_off), image: Some((2, 2, vec![255; 16])), ..Clip::default() };
w.cmd_v();
let doc = w.app.session.doc().unwrap();
let pasted = doc.nodes().find(|n| n.name == "Pasted image").expect("pasted from the decoded image");
assert_eq!((pasted.width, pasted.height), (2.0, 2.0));
}
+10 -2
View File
@@ -597,8 +597,16 @@ resuming with its offline edits, and leaving the session going back to snapshot
`local`), so a local share is protected by 44 bits, not 128: a code is guessed over the network
at the rate the host accepts connections, which makes 44 bits years of trying, but treat it as
a secret for the local network, not for the internet. The invite link names the same session.
Whoever sees the code learns the host's address. Everyone who joins edits; a host without an
IPv4 address gets no code and shares its link.
Whoever sees the code learns the host's address. The session has two secrets, so two codes:
the **edit code** and a **view-only code** (the same address and port, a 44-bit secret of its
own). The host welcomes whoever joins with the view secret as a viewer (`Access::View`): they
see every change and everyone's presence, the app makes the file read-only for them, and the
session refuses any batch they send anyway, as a server does for its view links. The Share
popover picks which code shows (Can edit / Can view, also before sharing starts, so the edit
code need never be on screen; `collab.share {access}`, whose `link` and `url` are the ones that
go with the code shown, so a view-only share never hands out the edit secret); picking one later changes
nothing for who already joined. There is no comment code on the local network. A host without an IPv4
address gets no codes and shares its links.
- **In the browser** a code joins like a link (the page asks the server over its own WebSocket,
then opens the session's); it shares online through the server in Preferences as the desktop
app does, and not on the local network (a page can't listen).
+3 -2
View File
@@ -1,6 +1,6 @@
# Collaboration parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); `figcraft-server` checks the server key by comparing SHA-256 digests, not the raw keys (#1130); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; `comment.list {mentions}` lists only the threads that @mention a name (#1415); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); an AI agent attached over MCP shows where it works (#1416); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (a file shared on the local network can be shared to look at only: a view-only code next to the edit code, picked in the Share popover before or while sharing)
Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version
history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model,
@@ -15,7 +15,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/<key>` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). The batches kept for apps that reconnect are bounded in bytes by default (64 MiB; `max_log_mb` on `figcraft-server`), and an app further behind catches up with a snapshot (#1069). Placing a layer among its siblings is a binary search, so large pastes and reorders in one batch no longer take quadratic time (#1065). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 |
| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors (one name for live sessions and comments: Preferences ▸ Your name, also in the Share popover and the Join dialog, kept in the browser too; a rename shows at once in the sessions you're in, #1380), selection outlines, avatars (an AI agent attached over MCP also shows its avatar and an outline around the layers it last edited or selected while it works, in or out of a session, #1416), follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 |
| Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 |
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit, comment and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser), and shares online and publishes to a server as the desktop app does, though not on the local network (#917; the owner token of a file it puts on an open server lasts as long as the page)); `figcraft-server` share links per file with edit, comment or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; a commenter also adds, replies to, reacts to and resolves comment threads, with a "Can comment" badge and the comment tool, #1525; the server refuses their other edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer; a server only a team reaches can also list the files shared from it now for everyone (`team_files`): Home ▸ Online shows that team list for the server in Preferences, a row joins its file, and its links (`figcraft://join?server=…&file=…&team=1`, or the browser build's) carry no secret and stop working with the share. No accounts, teams or projects | 8–17 |
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit, comment and view links with short codes; local network: the app hosts and the code carries its address, with an edit code and a view-only code, picked before or while sharing, whose holders see every change and can't make one; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser), and shares online and publishes to a server as the desktop app does, though not on the local network (#917; the owner token of a file it puts on an open server lasts as long as the page)); `figcraft-server` share links per file with edit, comment or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; a commenter also adds, replies to, reacts to and resolves comment threads, with a "Can comment" badge and the comment tool, #1525; the server refuses their other edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer; a server only a team reaches can also list the files shared from it now for everyone (`team_files`): Home ▸ Online shows that team list for the server in Preferences, a row joins its file, and its links (`figcraft://join?server=…&file=…&team=1`, or the browser build's) carry no secret and stop working with the share. No accounts, teams or projects | 8–17 |
| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences (an AI agent attached over MCP comments as its own name, marked Agent); a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. `comment.list` (CLI, MCP) gives each message its `mentions` (`{name, known, you}`, by the highlighting rule). In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) |
| Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — |
| Branching and merging | yes (paid plans) | **done, also in live sessions**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived; entries for its creation and updates from main); fills, strokes, effects, layout grids, export settings, guides, interactions, annotations and Dev Mode resources merge item by item. In a live session a branch stays with its author (its ids in their own client space, far above the main file's) and merging it is one edit everyone gets; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link refuses merging. Left: others in the session can't see or open your branches (the server would have to keep them); text runs conflict as a whole; a branch made offline that adds layers merges only outside a session | 1–2 |
@@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
| Date | Change | Summary |
|---|---|---|
| 2026-11-06 | minor | M9: a file shared on the local network can be shared read-only. The session the app hosts has a second secret, so a view-only code next to the edit code (same address and port, a 44-bit secret of its own); whoever joins with it is welcomed as a viewer, sees every change and presence, gets the "View only" badge and only the moving tools, and the session refuses their edits. The Share popover offers Can edit / Can view under Local network before sharing starts (the edit code then never shows) and while sharing; `collab.share {via: "local", access: "view"}` does the same (its `link` and `url`, like its `code`, are the view-only way in), and `share` in `collab.peers` lists both codes and links. No comment code on the local network. Numbers unchanged. |
| 2026-10-31 | patch | M9, #1665: the document in a live session's welcome goes compressed (deflate) to clients whose hello announces `packed`, about seven times smaller on the wire (a synthetic file of 4,040 layers: 397 KB to 53 KB); older clients keep getting it uncompressed, and the client reads a packed document only up to the size an uncompressed welcome could have had. |
| 2026-10-30 | minor | M9, part of #742: a team list on `figcraft-server`. With `team_files = "edit"` (or `comment`, `view`) everyone who reaches the server sees the files shared from it now (a link of theirs has a join code: Share ▸ Start sharing) and joins them by a team list link, `figcraft://join?server=…&file=…&team=1`, which carries no secret: the server hands out the file's team link while it stays shared (`ResolveTeam`, answered like a join code), and Stop sharing revokes it with the others, disconnecting whoever joined with it. Turning the list off or changing its access refuses the old team links at once. `collab.files {refresh}` asks the server in Preferences for its list (`Admin::Team`; not the public server FigCraft ships with), and Home ▸ Online shows it below your own files: a row joins its file, its menu copies the link for the app or, with the server's `web_url`, for the browser build (`<web_url>#join=…`). A browser build served beside its server names it with `<meta name="figcraft-server">`, the page's default for sharing, codes and the team list. No accounts: the list is for a server only your team reaches. Tests: `team_tests` (the server and apps over loopback), `code_tests` (links and the browser's driver), `online_tests` (the Online section), `query_tests` (the page's server). Percentages and hours unchanged |
| 2026-10-30 | minor | M9, #1130: `figcraft-server` compares the SHA-256 digests of the presented and the kept server key instead of the raw strings, as `docs/server.md` says; the key file, `figcraft-server key` and rotation are unchanged. Numbers unchanged. |
+3 -1
View File
@@ -1,6 +1,6 @@
# Components, variants, styles and variables parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Go to main component on an instance of a library component opens the library file in a tab and selects the main there; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and
variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300
@@ -50,6 +50,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|---|---|---|
| Publish a file's components, sets, styles and variables (per-asset versions) | done | as local library files |
| Enable a library in a file; read-only linked copies | done | |
| Go to main component from an instance of a library component | done | opens the library file in a tab, or brings it to the front, on the page holding the main, selected and zoomed to; a variant goes to that variant. Team libraries on a server are refused with a note; no Return to instance yet |
| Library updates available; overrides kept | done | a library file saved again within two seconds of being read, at the same length, is read again (its time stamp can't prove it unchanged; #652) |
| Team libraries: publish to a `figcraft-server` under the file, enable by a share link (a view link reads it), background update checks | done | [server.md](server.md#team-libraries); no team-wide library list or accounts ([gaps](gaps.md) #10) |
| Library analytics | partial | instances per component and the files using them, and the instances each file detached (per component and per file), as apps report them; insertions and detachments week by week for a year (Show usage: the past 30 days, 90 days or year), for components; the layers and files using each style and variable, without insertions, detachments or modes; no chart |
@@ -78,6 +79,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Date | Change | Summary |
|---|---|---|
| 2026-11-06 | minor | M3: Go to main component (context menu, Design panel, Ctrl+Alt+Cmd+K) on an instance of a library component opens the library file, as the help center describes for mains kept in another file ("Edit main components"); it used to refuse. The file opens in a tab, or an open tab comes to the front, on the main's page with the main selected and zoomed to: a nested instance goes to its own main, a variant to that variant. A main deleted from the library file since it was published leaves the file open with a note; a library file that moved, a team library on a server and a path off this computer are refused with a message. Test: `library::tests::going_to_a_library_main_opens_the_library_file`. Numbers unchanged |
| 2026-11-02 | minor | M3, #1885: glass effects take variables, as the reference tool's do: its light angle, intensity, refraction, depth, dispersion, frost and splay bind to number variables on a layer (`variable.bind`, `effects/<i>/<lightAngle\|lightIntensity\|refraction\|depth\|dispersion\|radius\|splay>`, `radius` being the frost) and in an effect style (`style.bind`), and the Design panel's glass fields take a variable like the shadow and blur fields. A variable reads as the field shows it: intensity, refraction, dispersion and splay in percent. Tests: `binding_lists_tests::glass_fields_take_number_variables`, `effects_tests::glass_fields_take_bound_numbers_as_the_panel_shows_them`. Grade unchanged. |
| 2026-11-01 | patch | M3: Delete on a layer inside an instance hides it (an override) instead of being refused ("You can't delete a layer or object from an instance. If you try, Figma will only toggle the layer's visibility instead of removing it", Guide to auto layout); `edit.delete` reports those ids under `hidden`, the structure and the main component stay as they were, one undo step; deleting the instance itself still removes it. Found by a documentation audit of the auto layout page. Test: `components_behaviour_tests::deleting_a_layer_inside_an_instance_hides_it_instead` (the refusal row for it is gone from `what_instances_cannot_override_is_refused`). Numbers unchanged |
| 2026-10-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. |
+2 -1
View File
@@ -1,6 +1,6 @@
# FigCraft parity with Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (glass bends towards its core, rounded rectangles in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps, measured against the reference tool; bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; sLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (glass's docs say which centre splay bends away from and where a shape gets no core; glass bends towards its core, rounded rectangles in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps, measured against the reference tool; bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke Line truncated
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
@@ -650,6 +650,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
| Date | Change | Summary |
|---|---|---|
| 2026-11-06 | patch | M3: glass's docs say which centre `splay` bends away from: the centre of the layer's bounds, as the reference tool's, checked side by side on a ring and an L at Splay 0% and 100% (an inner edge bends towards the hole in both). And where a glass shape gets no core: past 4M px on the desktop and 1M on the web (`MAX_SHAPE_PX`) it bends along its edge normals, so a large glass text or vector can bend differently once zoomed into, in a 2× export, or on the web. Docs only. Grade unchanged. |
| 2026-11-02 | minor | M3, #1885: glass bends towards its core, the shape shrunk by `depth` and the bevel, as the reference tool's: round a sharp corner the bend turns smoothly, and a thin part (a capsule, a letter's stroke) bends across itself, checked side by side on a capsule 44 units tall. Rounded rectangles work it out in closed form. A shape too big to keep whole at the zoom bends along its edge normals instead, so no render works out a core over more pixels than a kept shape holds. Tests: `glass::tests::a_sharp_corner_bends_round_towards_the_core`, `glass::tests::a_thin_part_bends_across_itself`, `glass::tests::a_shape_too_big_to_keep_bends_along_its_normals`, `glass_cut_tests::a_cut_through_the_pane_matches_the_whole_render`. Grade unchanged. |
| 2026-11-02 | minor | M3, #1885: glass's light is a line on its 1-unit bevel added to the backdrop, as the reference tool's: fully where the edge faces the light, none below 0.35 of facing it, 0.6 as much on the opposite side, with a faint gradient across the curve. `splay` turns the bend from along the edge's normal towards the way away from the layer's centre and no longer widens the light, as in the reference tool. Checked side by side on synthetic files: a splay of 1 bends the backdrop towards the centre in both. Tests: `glass::tests::the_light_lies_on_the_bevel_and_the_gradient_across_the_curve`, `backdrop_tests::splay_turns_the_bend_towards_the_centre`, `glass_cut_tests::the_light_is_a_line_on_the_bevel`. Grade unchanged. |
| 2026-11-02 | minor | M3, #1885: glass bends the backdrop with the reference tool's thick-lens profile (most a 1-unit bevel in from the edge, falling off as the 3.5th power of the way out of the curve to nothing `depth` further in) and splits its colours over 8 steps, red and blue 0.16 of the bend apart at 100% dispersion. Measured against the reference tool on synthetic files at 2×: the black band a pane pulls to its edge ends within 3 px of the reference tool's, and red and blue part 2.27 px at 50% dispersion and 4.66 px at 100% (2.38 and 4.69 there). Tests: `glass::tests::the_bend_follows_the_thick_lens_profile`, `glass::tests::dispersion_splits_the_channels_along_the_bend`. Grade unchanged. |