Merge main into #1900

This commit is contained in:
bflatastic
2026-11-07 08:18:31 +07:00
143 changed files with 3889 additions and 563 deletions
+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
+9
View File
@@ -19,7 +19,14 @@ concurrency:
cancel-in-progress: false
jobs:
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
credits:
permissions:
contents: read
pull-requests: read
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
release:
needs: credits
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
@@ -33,6 +40,7 @@ jobs:
# crates/text/build.rs keeps the web allowlist small (Inter and Georgian).
web-craft-fonts: true
version: ${{ inputs.version }}
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -48,3 +56,4 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
+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
Generated
+10
View File
@@ -1157,6 +1157,15 @@ dependencies = [
"zip 9.1.1",
]
[[package]]
name = "craft-web-host"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "craft-window"
version = "0.1.0"
@@ -2121,6 +2130,7 @@ name = "figcraft-web"
version = "0.15.0"
dependencies = [
"base64 0.22.1",
"craft-web-host",
"eframe",
"egui",
"figcraft-collab-net",
+9 -1
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -498,7 +498,8 @@ fn measure_components(table: &mut Table, col: usize, instances: usize) -> CliRes
static RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let run = RUNS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("figcraft-perf-{}-{run}", std::process::id()));
std::fs::create_dir_all(&dir).map_err(failed)?;
// `create_dir`, not `create_dir_all`: a directory or link already at this predictable name is refused.
std::fs::create_dir(&dir).map_err(failed)?;
let path = dir.join("design-system.figcraft").to_string_lossy().into_owned();
let (lib_doc, lib_ds) = design_system_document(&spec).map_err(failed)?;
let mut lib_s = Session::with_document(lib_doc);
@@ -75,3 +75,11 @@ fn codes_are_found_through_the_index_as_before() {
assert_eq!(store.resolve_code(who(105), &codes[2]).unwrap_err(), CODE_GONE);
assert_eq!(store.resolve_code(who(106), &codes[3]).unwrap().0, files[3].0);
}
#[test]
fn a_name_taken_from_the_file_is_cleaned_like_a_given_one() {
let dir = TempDir::new("embedded-name");
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
let (file, ..) = store.publish("", &snapshot("Bad\u{1b}[2Jname\u{202E}")).unwrap();
assert_eq!(file.name, "Bad[2Jname");
}
+2 -1
View File
@@ -95,8 +95,9 @@ impl Store {
return Err(format!("the file has more than {} layers", self.cfg.core.max_nodes));
}
let mut doc = replica.document().clone();
// The name the file keeps, whichever one it comes from, is cleaned.
let name = match clean_name(name) {
n if n.is_empty() => doc.name.clone(),
n if n.is_empty() => clean_name(&doc.name),
n => n,
};
doc.name = name.clone();
+3 -1
View File
@@ -19,6 +19,8 @@ figcraft-format = { workspace = true }
figcraft-ui-egui = { workspace = true }
figcraft-collab-net = { workspace = true }
figcraft-text = { workspace = true }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
base64 = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
@@ -30,4 +32,4 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
js-sys = "0.3"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType", "Storage"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType", "Storage", "Event", "MessageEvent", "UrlSearchParams", "CustomEvent", "CustomEventInit"] }
+143
View File
@@ -0,0 +1,143 @@
//! Host modes: a page that runs FigCraft (as an `<iframe>`, or in its own document) opens
//! documents in it and stores what it saves instead of the browser downloading. The messages
//! and the two transports are `craft-web-host`'s (storytold/craft-libs): the same contract as
//! the other Craft apps' bridges. This module is FigCraft's part of it.
//!
//! - `?host=parent` (an `<iframe>`, such as a Nextcloud app) talks to the same-origin parent over
//! `postMessage`; `data-host="page"` on the canvas (the page's own document) uses DOM
//! `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present.
//! - `figcraft:ready` (to the host) once the app is running and listening; send a file now.
//! - `figcraft:open` (from the host): `{ name, bytes }`. It opens exactly as a file picked with
//! File › Open or dropped on the window would (the app's own open queue, with its "Opening as
//! a new file" card). A payload over the browser's own open limit
//! ([`figcraft_ui_egui::browser_can_read`]'s 512 MB) is refused before its bytes are copied,
//! and anything malformed is ignored. FigCraft reads whole files, so it says `file: false` in
//! `ready` and a host sends `bytes`.
//! - `figcraft:save` (to the host): `{ name, saveAs, bytes }` for every file the app hands its
//! download service; in host mode nothing reaches the browser's downloads. `saveAs` says whether
//! the file becomes the document's file ([`crate::hostmode::save_as`]):
//! - A Save of the file the host opened, under its own name: `false`.
//! - A document file (`.figcraft` or `.json`, the formats `file.save` and `file.saveAs` write)
//! under a name the host didn't open — Save As, or a Save of a document the host never sent
//! (an untitled one): `true`. It becomes the document's file; the next Save sends its name
//! with `false`. Names compare without case.
//! - Everything else is a copy or export that never becomes the document: `false` — rendered
//! images, SVG and PDF, `.fig` copies, and the variables exports (a `.variables.json`
//! sidecar's `.json` extension doesn't make it a document).
//! - `figcraft:dirty` whenever unsaved changes appear or go away, `figcraft:failed` when the app
//! couldn't start.
//!
//! In host mode the app starts with no document (the home screen) and the host sends the file to
//! edit; `?author=` names the user for comments and board notes.
use std::cell::RefCell;
use std::rc::Rc;
use craft_web_host::web::{FileData, Host, Message};
use figcraft_ui_egui::FigApp;
/// The app id in the host messages (`figcraft:ready`).
const APP: &str = "figcraft";
/// The longest user name taken from `?author=`.
const MAX_AUTHOR_CHARS: usize = 200;
/// Files the host sent, on their way to the app's own open queue ([`figcraft_ui_egui::pending_open`]):
/// `(name, bytes)`.
pub type OpenQueue = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
#[derive(Clone)]
pub struct Bridge {
host: Rc<Host>,
/// The file the host opened, and after a `saveAs` hand-over the document's file: the name a
/// Save under (`saveAs: false`) is decided against.
host_file: Rc<RefCell<Option<String>>>,
}
impl Bridge {
/// The bridge for this page, or `None` when FigCraft isn't hosted: no `?host=parent` and no
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
/// with downloads), or a parent on another origin.
pub fn connect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
Some(Self { host: Rc::new(Host::connect(APP, canvas)?), host_file: Rc::default() })
}
/// Hear the files the host sends, and queue each one on `opens` for the app's own open path
/// (the shell drains it into [`figcraft_ui_egui::pending_open`] every frame, so a host file
/// opens like a dropped one).
pub fn listen(&self, opens: OpenQueue, ctx: egui::Context) {
let host_file = self.host_file.clone();
self.host.listen(move |message| {
if let Message::Open { name, data } = message {
deliver_open(&opens, &ctx, &host_file, &name, &data);
}
});
}
/// `?author=`: the host's name for the user signs comments and board notes.
pub fn set_author(app: &mut FigApp, author: &str) {
let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
if !author.is_empty() {
app.session.prefs.user_name = author;
}
}
pub fn post_ready(&self) {
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
}
/// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.
pub fn post_failed(&self, error: &str) {
let _ = self.host.post_failed(error, None);
}
/// The document file the host opened or the last Save As made, if any.
pub fn host_file(&self) -> Option<String> {
self.host_file.borrow().clone()
}
/// Hand a file the app would otherwise download to the host; `false` when nothing was
/// delivered. The `saveAs` rule is this module's documentation's.
pub fn post_save(&self, path: &str, bytes: &[u8]) -> bool {
let name = file_name(path);
let host_file = self.host_file.borrow().clone();
let save_as = crate::hostmode::save_as(&name, host_file.as_deref());
let sent = self.host.post_save(&name, bytes, None, save_as).is_ok();
// After a Save As hand-over the host has the new file: the next Save of it sends its name
// with `saveAs: false`. A failed send changes nothing, so a retry is still a Save As.
if sent && save_as {
*self.host_file.borrow_mut() = Some(name);
}
sent
}
/// Report unsaved changes when they appear or go away, not every frame (the crate does not
/// repeat a state it already reported).
pub fn set_dirty(&self, dirty: bool) {
self.host.post_dirty(dirty);
}
}
/// The file an open message carries, queued for the app's own open path, or nothing when the
/// payload isn't one (a `File` the app doesn't read, or over the open limit).
fn deliver_open(opens: &OpenQueue, ctx: &egui::Context, host_file: &RefCell<Option<String>>, name: &str, data: &FileData) {
let name = crate::hostmode::open_name(name);
let FileData::Bytes(array) = data else {
log::warn!("host file {name} came as a File; FigCraft reads bytes only; not opened");
return;
};
if figcraft_ui_egui::browser_can_read(&name, data.size()).is_err() {
log::warn!("host file {name} is too large to open in the browser; not opened");
return;
}
// The document's file is now the one the host sent: a Save of it under this name is
// `saveAs: false`.
*host_file.borrow_mut() = Some(name.clone());
opens.borrow_mut().push((name, array.to_vec()));
ctx.request_repaint();
}
/// The last component of `path`, or the whole of it when there is none.
fn file_name(path: &str) -> String {
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
}
+92
View File
@@ -0,0 +1,92 @@
//! Pure host-mode rules, shared by the bridge ([`crate::host`]) and testable without a browser
//! (like [`crate::query`]): the name the bridge fills in when an open request has none, and whether
//! a handed-over file becomes the document's file. Which pages host the app is `craft-web-host`'s.
/// The file name used when an open request carries no usable one.
pub const DEFAULT_OPEN_NAME: &str = "Untitled.figcraft";
/// The extensions of files that can be a document's own file: the formats `file.save` and
/// `file.saveAs` write (`file.open` opens both as native documents). A `.fig` file is an export
/// copy instead, and so is everything else the app hands over (rendered images, SVG, PDF,
/// variables).
const DOC_EXTS: &[&str] = &["figcraft", "json"];
/// The suffix `variable.export`'s downloads carry: a `.json` that is a variables sidecar, not a
/// document, so its extension alone can't tell the `saveAs` decision.
const VARIABLES_SIDECAR: &str = ".variables.json";
/// The name `craft-web-host` gives an open request that carries no usable one (it cleans a host's
/// name and falls back to this).
const NAMELESS: &str = "document";
/// A usable file name for an open request: the cleaned name the crate hands over, or
/// [`DEFAULT_OPEN_NAME`] when the host sent none.
pub fn open_name(name: &str) -> String {
if name == NAMELESS { DEFAULT_OPEN_NAME.to_string() } else { name.to_string() }
}
/// Whether the file `name` becomes the document's own file when handed over (`saveAs: true`),
/// given the file the host opened (`None`: the host never sent one). The rule:
///
/// - A document file (`.figcraft` or `.json`, [`DOC_EXTS`]'s formats) under a name the host didn't
/// open — Save As, or a Save of a document the host never sent — is the document's new file:
/// `true`. Names compare without case.
/// - A Save of the host's own file under its name is not: `false`.
/// - Everything else is a copy or export that never becomes the document — rendered images, SVG,
/// PDF, `.fig` copies, variables — and the variables sidecar counts as one whatever its `.json`
/// extension says: `false`.
pub fn save_as(name: &str, host_file: Option<&str>) -> bool {
if name.to_ascii_lowercase().ends_with(VARIABLES_SIDECAR) {
return false;
}
let ext = std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str);
if !ext.is_some_and(|e| DOC_EXTS.iter().any(|x| x.eq_ignore_ascii_case(e))) {
return false;
}
match host_file {
Some(host) => !host.eq_ignore_ascii_case(name),
None => true,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_names_fall_back_to_the_default_when_the_host_sent_none() {
assert_eq!(open_name("Poster.figcraft"), "Poster.figcraft");
assert_eq!(open_name("Poster 2.json"), "Poster 2.json");
assert_eq!(open_name("document"), DEFAULT_OPEN_NAME, "what the crate says for a missing or blank name");
}
#[test]
fn a_save_of_the_host_s_file_is_not_save_as() {
assert!(!save_as("Poster.figcraft", Some("Poster.figcraft")));
assert!(!save_as("poster.FIGCRAFT", Some("Poster.figcraft")), "names compare without case");
assert!(!save_as("design.json", Some("design.json")));
}
#[test]
fn a_new_document_file_is_save_as() {
assert!(save_as("Poster 2.figcraft", Some("Poster.figcraft")));
assert!(save_as("Poster.json", Some("Poster.figcraft")), "the other format a document's file takes");
assert!(save_as("Untitled.figcraft", None), "the host never sent a file");
assert!(save_as("Poster.figcraft", Some("design.json")), "the opened .json saved as .figcraft is a new file");
}
#[test]
fn copies_and_exports_never_become_the_document_s_file() {
for name in ["Poster.png", "Poster@2x.jpg", "Poster.svg", "Poster.pdf", "Poster.fig", "Poster.csv", "Poster.variables.css"] {
assert!(!save_as(name, Some("Poster.figcraft")), "{name} is a copy");
assert!(!save_as(name, None), "{name} is a copy even without a host file");
}
}
#[test]
fn the_variables_sidecar_is_a_copy_whatever_its_extension() {
assert!(!save_as("Poster.variables.json", Some("Poster.figcraft")));
assert!(!save_as("Poster.variables.json", None));
assert!(!save_as("design.variables.json", Some("design.variables.json")));
}
}
+13 -1
View File
@@ -19,9 +19,17 @@
//! server…), but can't host a session. A page served over `https://` reaches only `wss://`
//! servers and the same computer (browsers block `ws://` from secure pages); serve it over
//! `http://` on a trusted network to reach plain `ws://` hosts on other machines.
//! - host mode: `?host=parent` (a same-origin page embedding FigCraft in an `<iframe>`) or
//! `data-host="page"` on the canvas (the page's own document) hands the page the app's files
//! instead of downloading them and reports unsaved changes (`host`);
//! - live sessions: it joins sessions a desktop app hosts (File ▸ Join live session…) but can't
//! host one. A page served over `https://` can only reach sessions on the same computer
//!
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?control` installs
//! `window.figcraft`, the page's control channel (`api.rs`), which is off without it.
//! `window.figcraft`, the page's control channel (`api.rs`), which is off without it; `?host=parent`
//! (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the document to open
//! and stores what FigCraft saves. `&author=` names the user for comments and board notes in either
//! host mode.
//!
//! URL fragment: `#join=<invite link or join code>` joins that live session once the page has
//! loaded, so a bookmark opens a shared file. `join` takes the rest of the fragment: put it last.
@@ -30,6 +38,10 @@
#[cfg(target_arch = "wasm32")]
mod api;
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(any(target_arch = "wasm32", test))]
mod hostmode;
#[cfg(any(target_arch = "wasm32", test))]
mod kept;
#[cfg(target_arch = "wasm32")]
+74 -9
View File
@@ -32,6 +32,15 @@ pub fn start() {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
// Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing FigCraft, opens
// documents in it and stores what it saves (`host`); unframed there is no parent, so that URL
// on its own stays the normal app with downloads. `data-host="page"` on the canvas opts a
// page in instead: it runs FigCraft in its own document and talks over DOM `CustomEvent`s on
// the canvas.
let bridge = crate::host::Bridge::connect(&canvas);
let app_bridge = bridge.clone();
// Files the host sends, on their way into the app's own open queue each frame.
let opens: crate::host::OpenQueue = std::rc::Rc::default();
let mut options = eframe::WebOptions::default();
// `?webgl`: force the WebGL2 backend (troubleshooting, and headless browsers whose WebGPU
// adapter request returns nothing).
@@ -44,12 +53,15 @@ pub fn start() {
.start(
canvas,
options,
Box::new(|cc| {
Box::new(move |cc| {
if let Some(rs) = &cc.wgpu_render_state {
log::info!("figcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
}
let reduce_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
let mut session = Session::new();
// Host mode: the host sends the file to edit, so start with no document (the
// home screen), as the desktop app does without a file; otherwise the session
// starts with one new document.
let mut session = if app_bridge.is_some() { Session::empty() } else { Session::new() };
// The host names its own server: the default for sharing, codes and the team list.
if let Some(server) = page_meta("figcraft-server").as_deref().and_then(crate::query::page_server) {
session.prefs.publish_server = server;
@@ -60,11 +72,18 @@ pub fn start() {
// whatever arrives on the socket wakes the page for a frame.
let ctx = cc.egui_ctx.clone();
session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint()))));
let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone(), host: app_bridge.clone() }));
fig.note_recovered(recovered);
if let Err(e) = crate::pasted::install(cc.egui_ctx.clone()) {
log::error!("couldn't listen for pasted images: {e:?}");
}
// `?author=`: the host's name for the user signs this visit's comments and board
// notes (nothing is stored in the browser, so nothing needs keeping).
if app_bridge.is_some()
&& let Some(author) = query_param("author")
{
crate::host::Bridge::set_author(&mut fig, &author);
}
// `#join=<link>`: join that session, as opening an invite link does on the desktop.
let hash = web_sys::window().and_then(|w| w.location().hash().ok()).unwrap_or_default();
if let Some(link) = crate::query::join_link(&hash) {
@@ -78,10 +97,21 @@ pub fn start() {
log::error!("couldn't install window.figcraft: {e:?}");
}
}
Ok(Box::new(WebShell { fig }))
// The host's files arrive on the app's own open path (a dropped file's).
if let Some(bridge) = &app_bridge {
bridge.listen(opens.clone(), cc.egui_ctx.clone());
}
Ok(Box::new(WebShell { fig, host: app_bridge, opens }))
}),
)
.await;
// Ready only once the runner is up: starting can still fail after the app is made.
if let Some(bridge) = &bridge {
match &result {
Ok(()) => bridge.post_ready(),
Err(e) => bridge.post_failed(&format!("{e:?}")),
}
}
if let Some(el) = document.get_element_by_id(LOADING_ID) {
match result {
Ok(()) => el.remove(),
@@ -141,20 +171,45 @@ fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
fn query_param(key: &str) -> Option<String> {
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
}
struct WebShell {
fig: FigApp,
/// The host bridge, when the page hosts the app (`?host=parent` or `data-host="page"`).
host: Option<crate::host::Bridge>,
/// Files the host sent, waiting to join the app's own open queue.
opens: crate::host::OpenQueue,
}
impl eframe::App for WebShell {
// eframe runs `logic` for visible tabs and, with only `run_logic`, for hidden ones: keep
// autosave/live/control deadlines moving without a paint. `FigApp::ui` does not repeat it.
/// Run the browser shell's eframe logic callback: guard `background_tick` so hidden tabs
/// still advance autosave, live and control deadlines without painting. `FigApp::ui` does
/// Run the browser shell's eframe logic callback: hand the host's files to the app's open
/// queue, guard `background_tick` so hidden tabs still advance autosave, live and control
/// deadlines without painting, and report unsaved changes to the host. `FigApp::ui` does
/// not repeat it.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Every frame, hidden or not, starts here: what the last one left is what a panic keeps.
UNSAVED.with(|u| u.try_borrow_mut().map(|mut u| u.follow(&self.fig.session))).ok();
// Files the host sent join the app's own open queue (a dropped file's): the same
// "Opening as a new file" card, and `file.open` with the contents once read.
for (name, bytes) in std::mem::take(&mut *self.opens.borrow_mut()) {
let read: figcraft_ui_egui::ReadFuture = Box::pin(std::future::ready(Ok(bytes)));
self.fig.ui.pending_opens.push(figcraft_ui_egui::pending_open::PendingOpen {
label: name.clone(),
params: serde_json::json!({ "name": name }),
command: "file.open",
target: None,
read: Some(read),
});
}
figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx));
// The host hears when unsaved changes appear or go away (crate::host).
if let Some(bridge) = &self.host {
bridge.set_dirty(self.fig.session.docs.iter().any(|d| d.is_dirty()));
}
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
@@ -182,12 +237,15 @@ impl eframe::App for WebShell {
/// Browser services. Files are opened and imported through the browser's file picker, which hands
/// over their contents (pickers that need a path return nothing, the trait's defaults); files are
/// saved as downloads. Copies go to the system clipboard through egui's output; pastes arrive as
/// paste events with their text or image (a page can't read the clipboard when it likes).
/// saved as downloads, or handed to the host page in host mode. Copies go to the system clipboard
/// through egui's output; pastes arrive as paste events with their text or image (a page can't
/// read the clipboard when it likes).
struct WebServices {
/// `prefers-reduced-motion: reduce` (it follows the setting while the page is open).
reduce_motion: Option<web_sys::MediaQueryList>,
ctx: egui::Context,
/// The host bridge in host mode: files are handed over instead of downloaded ([`crate::host`]).
host: Option<crate::host::Bridge>,
}
impl Services for WebServices {
@@ -204,11 +262,15 @@ impl Services for WebServices {
fn downloads(&self) -> bool {
true
}
/// A Blob behind a temporary `<a download>` link, clicked and removed.
/// A Blob behind a temporary `<a download>` link, clicked and removed; in host mode the file
/// goes to the host page instead ([`crate::host`]).
fn download(&mut self, name: &str, bytes: &[u8]) -> bool {
if crate::api::capture(name, bytes) {
return true;
}
if let Some(bridge) = &self.host {
return bridge.post_save(name, bytes);
}
match download(name, bytes) {
Ok(()) => true,
Err(e) => {
@@ -217,6 +279,9 @@ impl Services for WebServices {
}
}
}
fn host_file(&self) -> Option<String> {
self.host.as_ref()?.host_file()
}
fn clipboard_set_text(&mut self, s: &str) {
figcraft_ui_egui::egui_copy_text(&self.ctx, s);
}
@@ -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,
}
}
+7
View File
@@ -101,3 +101,10 @@ sync_github_name = true
[people.aleksei-suholita]
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(),
+11
View File
@@ -333,6 +333,17 @@ impl Change {
}
impl Entry {
/// The synced list this entry belongs to (`None` for entries kept in maps).
pub(crate) fn family(&self) -> Option<Family> {
match self {
Entry::Style(_) => Some(Family::Styles),
Entry::Comment(_) => Some(Family::Comments),
Entry::Collection(_) => Some(Family::Collections),
Entry::Variable(_) => Some(Family::Variables),
Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => None,
}
}
/// The client id space the entry's id lies in ([`figcraft_doc::ids`]): ids made during a live
/// session carry their creator's client id, ids from before and ids of other forms are in
/// space 0. `None` for entries keyed by something else (dev snapshots by layer, images by
+38 -7
View File
@@ -95,7 +95,8 @@ pub fn diff(from: &Replica, to: &Document) -> Result<Diff, Error> {
let mut changes = fields;
let new: BTreeSet<NodeId> = added.iter().copied().collect();
// Creations, parents before children.
added.sort_by_cached_key(|id| (depth(to, *id), *id));
let mut memo = BTreeMap::new();
added.sort_by_cached_key(|id| (depth(to, *id, &mut memo), *id));
for id in &added {
if let Some(n) = to.node(*id) {
changes.push(create_change(n, replica.key(*id))?);
@@ -121,18 +122,30 @@ pub fn diff(from: &Replica, to: &Document) -> Result<Diff, Error> {
Ok(Diff { changes, replica })
}
/// Depth of `id` in `doc` (pages are 0).
fn depth(doc: &Document, id: NodeId) -> usize {
let mut d = 0;
/// Depth of `id` in `doc` (pages are 0). `memo` holds the depths found so far, so a deep chain of
/// new layers is walked once rather than once per layer.
fn depth(doc: &Document, id: NodeId, memo: &mut BTreeMap<NodeId, usize>) -> usize {
let mut chain = vec![id];
let mut base = 0;
let mut cur = doc.node(id).and_then(|n| n.parent);
while let Some(p) = cur {
d += 1;
if d > 100_000 {
if let Some(d) = memo.get(&p) {
base = *d + 1;
break;
}
// An acyclic chain is no longer than the document: this only stops a cycle.
if chain.len() > doc.node_count() {
break;
}
chain.push(p);
cur = doc.node(p).and_then(|n| n.parent);
}
d
// `chain` runs from `id` up; the last entry's depth is `base` when it ends at a root or a memo hit.
let top = chain.len();
for (i, c) in chain.iter().enumerate() {
memo.insert(*c, base + (top - 1 - i));
}
memo.get(&id).copied().unwrap_or(0)
}
/// New keys for the children `kids` (in their new order) whose `old` key no longer fits: the
@@ -196,3 +209,21 @@ fn increasing_run(keys: &[Option<&str>]) -> Vec<bool> {
}
kept
}
#[cfg(test)]
mod depth_tests {
use super::*;
use figcraft_doc::NodeKind;
#[test]
fn a_chain_longer_than_a_fixed_bound_is_walked_whole() {
let mut doc = Document::new("File");
let mut parent = doc.first_page().unwrap();
let mut last = parent;
for i in 0..100_050 {
last = doc.create(parent, NodeKind::Frame, &format!("F{i}"), 0.0, 0.0, 1.0, 1.0).unwrap();
parent = last;
}
assert_eq!(depth(&doc, last, &mut BTreeMap::new()), 100_050);
}
}
+11
View File
@@ -310,3 +310,14 @@ fn many_sibling_placements_keep_the_order_of_counting_them() {
assert_eq!(r.document().node(p).unwrap().children, kids[&p], "children of {p}");
}
}
#[test]
fn a_chain_of_new_nested_layers_is_created_parents_first() {
let (a, _, frame, _) = setup();
let mut b = a.document().clone();
let mut parent = frame;
for i in 0..400 {
parent = b.create(parent, NodeKind::Frame, &format!("F{i}"), 0.0, 0.0, 10.0, 10.0).unwrap();
}
assert_eq!(round_trip(&a, &b).iter().filter(|c| matches!(c, Change::Create { .. })).count(), 400);
}
+3 -1
View File
@@ -236,7 +236,9 @@ impl Replica {
self.doc.insert_page(node).map_err(doc_err)?;
}
(false, Some(p)) => {
let at = self.slot(&self.doc.get(*p).map_err(doc_err)?.children, *id, position);
// A new id sits in no sibling list yet: no scan for it, just the binary search.
let siblings = &self.doc.get(*p).map_err(doc_err)?.children;
let at = siblings.partition_point(|c| (self.key(*c), *c) < (position.as_str(), *id));
self.doc.insert(node, *p, Some(at)).map_err(doc_err)?;
}
_ => return Err(Error::Invalid("pages, and only pages, have no parent".into())),
+18 -6
View File
@@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet, VecDeque};
use figcraft_doc::{Document, NodeId};
use serde::{Deserialize, Serialize};
use crate::change::{Change, Entry, Prop, Target};
use crate::change::{Change, Entry, Family, Prop, Target};
use crate::message::{
Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg, Signal, reads_comments, reads_signals,
};
@@ -413,10 +413,14 @@ impl Server {
// client learned what happened to it from the catch-up.
ClientMsg::Changes { batch_id, .. } if self.issued.get(&from).is_some_and(|p| batch_id <= p.last_batch) => Vec::new(),
ClientMsg::Changes { base_seq, batch_id, changes } => {
let mut denied = true;
let result = match self.limited.get(&from) {
Some(Access::View) => Err(Error::Invalid(VIEW_ONLY.into())),
Some(Access::Comment) if !changes.iter().all(Change::is_comment) => Err(Error::Invalid(COMMENT_ONLY.into())),
_ => self.accept(from, base_seq, &changes),
_ => {
denied = false;
self.accept(from, base_seq, &changes)
}
};
if let Some(p) = self.issued.get_mut(&from) {
p.note(batch_id, result.is_err());
@@ -430,7 +434,9 @@ impl Server {
}
Err(e) => {
self.note(Journal::Refused { client: from, batch_id });
let fixes = self.fixes(&changes).unwrap_or_default();
// A refusal for lack of permission costs no repair: the client's confirmed copy
// already is the server's state, so the fixes would only repeat it.
let fixes = if denied { Vec::new() } else { self.fixes(&changes).unwrap_or_default() };
vec![Outgoing::To(from, ServerMsg::Reject { batch_id, reason: reason(&e), fixes })]
}
}
@@ -557,6 +563,7 @@ impl Server {
fn check_entries(&self, from: u32, changes: &[Change], after: &Document) -> Result<Vec<(Entry, bool)>, Error> {
let before = self.state.document();
let mut touched: BTreeSet<Entry> = BTreeSet::new();
let mut listed: BTreeMap<Family, (BTreeSet<Entry>, BTreeSet<Entry>)> = BTreeMap::new();
for c in changes {
match c {
Change::Put { entry, .. } | Change::Remove { entry } if entry.id_space().is_some() => {
@@ -565,8 +572,10 @@ impl Server {
// A whole list replaced (the fallback for lists whose ids aren't unique).
Change::SetDoc { prop, .. } => {
if let Some(Ok(family)) = prop.list() {
touched.extend(docprops::entries(before, family));
touched.extend(docprops::entries(after, family));
let (b, a) = (docprops::entries(before, family), docprops::entries(after, family));
touched.extend(b.iter().chain(&a).cloned());
// Looked up in sets below: a linear search per entry would be quadratic.
listed.entry(family).or_insert_with(|| (b.into_iter().collect(), a.into_iter().collect()));
}
}
_ => {}
@@ -574,7 +583,10 @@ impl Server {
}
let mut out = Vec::new();
for entry in touched {
let (was, now) = (docprops::has(before, &entry), docprops::has(after, &entry));
let (was, now) = match entry.family().and_then(|f| listed.get(&f)) {
Some((b, a)) => (b.contains(&entry), a.contains(&entry)),
None => (docprops::has(before, &entry), docprops::has(after, &entry)),
};
if was == now {
continue;
}
+20
View File
@@ -182,6 +182,26 @@ fn deleted_nodes_can_come_back_but_ids_are_never_handed_out_twice() {
assert_eq!(s.document(), gone.document());
}
#[test]
fn a_viewers_refused_deletion_gets_no_repair() {
let (d, _, frame) = doc();
let mut s = Server::new(d, "tok", Limits::default());
let (id, out) = s.join_as(hello("tok"), crate::message::Access::View).unwrap();
let Some(Outgoing::To(_, welcome)) = out.into_iter().next() else { panic!() };
let mut v = Client::join(welcome).unwrap();
let del = edit(&mut v, |d| d.remove(frame).unwrap());
let (reason, fixes) = rejected(
&mut s,
id,
match del {
ClientMsg::Changes { changes, .. } => changes,
_ => panic!(),
},
);
assert_eq!(reason, crate::VIEW_ONLY);
assert!(fixes.is_empty(), "{fixes:?}");
}
#[test]
fn fixes_roll_a_rejected_client_back_to_the_server_state() {
let (d, _, frame) = doc();
+41
View File
@@ -49,6 +49,47 @@ fn glass_values_are_clamped_and_reach_their_backdrop() {
assert_eq!(hidden.backdrop_radius(), 0.0);
}
#[test]
fn glass_frost_stays_within_the_reference_range() {
// A file from before the 0–100 range draws its frost at 100, and reaches no further.
let g = Glass { frost: 150.0, ..Glass::default() };
assert_eq!(g.sanitized().frost, Glass::MAX_FROST);
let e = Effect { kind: EffectKind::Glass(g), visible: true };
let capped = Effect { kind: EffectKind::Glass(Glass { frost: Glass::MAX_FROST, ..Glass::default() }), visible: true };
assert_eq!(e.backdrop_radius(), capped.backdrop_radius());
}
#[test]
fn glass_fields_take_bound_numbers_as_the_panel_shows_them() {
let mut g = Glass::default();
for (field, x) in [
("lightAngle", 30.0),
("lightIntensity", 25.0),
("refraction", 40.0),
("depth", 12.0),
("dispersion", 10.0),
("radius", 8.0),
("splay", 50.0),
] {
assert!(g.set_bound(field, x), "{field}");
}
assert_eq!((g.light_angle, g.light_intensity, g.refraction, g.depth, g.dispersion, g.frost, g.splay), (30.0, 0.25, 0.4, 12.0, 0.1, 8.0, 0.5));
// Out of range: clamped to the field's range.
for (field, x) in [("lightAngle", 270.0), ("lightIntensity", 250.0), ("depth", 0.0), ("radius", 150.0), ("splay", -5.0)] {
assert!(g.set_bound(field, x), "{field}");
}
assert_eq!((g.light_angle, g.light_intensity, g.depth, g.frost, g.splay), (180.0, 1.0, 1.0, Glass::MAX_FROST, 0.0));
// A field glass doesn't have, or a value that isn't a number, changes nothing.
let before = g.clone();
assert!(!g.set_bound("spread", 3.0));
assert!(!g.set_bound("splay", f64::NAN));
assert!(!g.set_bound("depth", f64::INFINITY));
assert_eq!(g, before);
for field in Glass::BOUND_FIELDS {
assert!(Glass::default().set_bound(field, 1.0), "{field}");
}
}
#[test]
fn effect_colours() {
let mut e = Effect::noise();
+19 -2
View File
@@ -574,6 +574,19 @@ impl Document {
Ok(())
}
/// Remove every child of `id` with its subtree, in one pass over `id`'s child list (removing
/// them one by one would search that list once per child).
pub fn remove_children(&mut self, id: NodeId) -> DocResult<()> {
let kids = std::mem::take(&mut self.get_mut(id)?.children);
for c in kids {
for d in self.subtree(c) {
self.nodes.remove(&d);
self.dirty.touch(d);
}
}
Ok(())
}
/// Whether sections may go in `parent`: a page or another section (never a frame or a group).
pub fn takes_sections(&self, parent: NodeId) -> bool {
self.node(parent).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. }))
@@ -1085,6 +1098,9 @@ impl Document {
// lookups: a wide parent or a long page list doesn't make validation quadratic.
let edges: std::collections::BTreeSet<(NodeId, NodeId)> = self.nodes.iter().flat_map(|(p, n)| n.children.iter().map(|c| (*p, *c))).collect();
let page_ids: std::collections::BTreeSet<NodeId> = self.pages.iter().copied().collect();
if page_ids.len() != self.pages.len() {
note(&mut issues, || "a page is listed twice".to_string());
}
for (id, n) in self.nodes.iter() {
if n.id != *id {
note(&mut issues, || format!("node key {id} has id {}", n.id));
@@ -1178,9 +1194,10 @@ impl Document {
if store != self.libraries.store {
Arc::make_mut(&mut self.libraries).store = store;
}
self.pages.retain(|p| self.nodes.get(p).is_some_and(|n| n.is_page()) && Some(*p) != store);
// A page listed twice would be imported, exported and walked once per listing.
let mut listed = std::collections::BTreeSet::new();
self.pages.retain(|p| self.nodes.get(p).is_some_and(|n| n.is_page()) && Some(*p) != store && listed.insert(*p));
// Unlisted pages join the list; a set keeps many pages from making this quadratic (#1078).
let mut listed: std::collections::BTreeSet<NodeId> = self.pages.iter().copied().collect();
for (k, n) in self.nodes.iter() {
if n.is_page() && Some(*k) != store && listed.insert(*k) {
self.pages.push(*k);
+44 -5
View File
@@ -660,9 +660,12 @@ pub struct Glass {
pub depth: f64,
/// Colour splitting along the curved edge (0–1).
pub dispersion: f64,
/// Blur of the backdrop (a blur radius, document units).
/// Blur of the backdrop (a blur radius, document units, 0–[`Self::MAX_FROST`]).
pub frost: f64,
/// How widely the light spreads over the glass (0–1).
/// 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 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,
}
@@ -673,6 +676,34 @@ impl Default for Glass {
}
impl Glass {
/// The most frost a glass takes, as the reference tool's Frost field (0–100).
pub const MAX_FROST: f64 = 100.0;
/// The fields a variable binds on a glass effect (`effects/<i>/<field>`), see [`Self::set_bound`].
pub const BOUND_FIELDS: [&'static str; 7] = ["lightAngle", "lightIntensity", "refraction", "depth", "dispersion", "radius", "splay"];
/// Sets `field` (one of [`Self::BOUND_FIELDS`]) from a bound number variable, read as the
/// Design panel's field shows it: intensity, refraction, dispersion and splay in percent, the
/// frost as `radius`, each clamped to the field's range. False for a field glass doesn't have
/// or a value that isn't finite (nothing changes).
pub fn set_bound(&mut self, field: &str, x: f64) -> bool {
if !x.is_finite() {
return false;
}
let pct = (x / 100.0).clamp(0.0, 1.0);
match field {
"lightAngle" => self.light_angle = x.clamp(-180.0, 180.0),
"lightIntensity" => self.light_intensity = pct,
"refraction" => self.refraction = pct,
"depth" => self.depth = x.clamp(1.0, 1000.0),
"dispersion" => self.dispersion = pct,
"radius" => self.frost = x.clamp(0.0, Self::MAX_FROST),
"splay" => self.splay = pct,
_ => return false,
}
true
}
/// The values clamped to their ranges (non-finite ones at their defaults).
pub fn sanitized(&self) -> Glass {
let d = Glass::default();
@@ -683,15 +714,23 @@ impl Glass {
refraction: ok(self.refraction, d.refraction, 0.0, 1.0),
depth: ok(self.depth, d.depth, 1.0, 1000.0),
dispersion: ok(self.dispersion, d.dispersion, 0.0, 1.0),
frost: ok(self.frost, d.frost, 0.0, 1000.0),
frost: ok(self.frost, d.frost, 0.0, Self::MAX_FROST),
splay: ok(self.splay, d.splay, 0.0, 1.0),
}
}
/// Furthest the curved edge moves the backdrop (document units).
/// How far `dispersion` splits the colour channels, as a share of the bend: at full dispersion
/// red moves up to this much further and blue as much less. Matched to the reference tool:
/// across a black-to-white edge in the bend, red and blue part about 4.7 px at 100% and 2.4 px
/// at 50% (at 2×, depth 20, refraction 80%).
pub const DISPERSION_SPREAD: f64 = 0.16;
/// Furthest the curved edge moves the backdrop (document units): at most `depth` × `refraction`
/// (all of it a bevel in from the edge), and red's dispersion steps up to
/// [`Self::DISPERSION_SPREAD`] × `dispersion` times that again.
pub fn max_bend(&self) -> f64 {
let g = self.sanitized();
g.refraction * g.depth * (1.0 + g.dispersion * 0.25)
g.refraction * g.depth * (1.0 + Self::DISPERSION_SPREAD * g.dispersion)
}
}
+25 -3
View File
@@ -12,6 +12,8 @@ use crate::{CollectionId, ModeId, VariableValue, Variables};
pub const MAX_EXPR_DEPTH: usize = 32;
/// Longest string an expression builds (characters).
const MAX_STRING: usize = 10_000;
/// The most string bytes one operation's operands may hold together (generous: results cap at `MAX_STRING`).
const MAX_OPERAND_BYTES: usize = 1 << 20;
/// An expression over variables.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -115,7 +117,19 @@ impl Expr {
}
Expr::Op { op, args } => (*op, args),
};
let vals: Vec<VariableValue> = args.iter().map(|a| a.eval_at(vars, modes, depth + 1)).collect::<Option<_>>()?;
// Retained operands are bounded by bytes, so repeated references to one long string can't multiply it.
let mut text_bytes = 0usize;
let mut vals = Vec::with_capacity(args.len());
for a in args {
let v = a.eval_at(vars, modes, depth + 1)?;
if let VariableValue::String(s) = &v {
text_bytes = text_bytes.saturating_add(s.len());
if text_bytes > MAX_OPERAND_BYTES {
return None;
}
}
vals.push(v);
}
apply(op, &vals)
}
@@ -259,10 +273,18 @@ fn apply(op: ExprOp, vals: &[VariableValue]) -> Option<VariableValue> {
match op {
Add if vals.iter().any(|v| matches!(v, VariableValue::String(_))) => {
let mut s = String::new();
let mut chars = 0;
for v in vals {
s.push_str(&as_text(v)?);
// Every operand is checked (a colour makes the whole expression None); only the
// characters it adds are counted, and appending stops at the cap.
let t = as_text(v)?;
if chars < MAX_STRING {
let before = s.len();
s.extend(t.chars().take(MAX_STRING - chars));
chars += s[before..].chars().count();
}
}
Some(VariableValue::String(s.chars().take(MAX_STRING).collect()))
Some(VariableValue::String(s))
}
Add => fold(|a, b| Some(a + b)),
Subtract => fold(|a, b| Some(a - b)),
+16
View File
@@ -124,6 +124,13 @@ fn deep_and_hostile_expressions_are_bounded() {
let joined = Expr::op(ExprOp::Add, vec![s(&long), s(&long)]);
let Some(VariableValue::String(t)) = eval(&joined) else { panic!() };
assert_eq!(t.chars().count(), 10_000, "capped");
// Past the cap a colour operand still fails the expression, and empty operands add nothing.
let color = Expr::Value(VariableValue::Color(Color::BLACK));
let mut ops = vec![s(&"x".repeat(10_000))];
ops.extend((0..50).map(|_| s("")));
assert!(matches!(eval(&Expr::op(ExprOp::Add, ops.clone())), Some(VariableValue::String(t)) if t.chars().count() == 10_000));
ops.push(color);
assert_eq!(eval(&Expr::op(ExprOp::Add, ops)), None, "a colour past the cap is still a type error");
}
fn nav(id: u64) -> Action {
@@ -199,3 +206,12 @@ fn serde_round_trip_and_old_files() {
let old: Action = serde_json::from_str(r#"{"type":"setVariable","variable":"n","value":2}"#).unwrap_or(Action::Back);
assert_eq!(old, Action::SetVariable { variable: "n".into(), value: serde_json::json!(2), expr: None });
}
#[test]
fn operands_holding_too_many_string_bytes_are_refused() {
let long = "x".repeat(300_000);
let few = Expr::op(ExprOp::Add, vec![s(&long), s(&long)]);
assert!(eval(&few).is_some());
let many = Expr::op(ExprOp::Add, (0..8).map(|_| s(&long)).collect());
assert_eq!(eval(&many), None);
}
+28
View File
@@ -348,6 +348,20 @@ fn repair_drops_cycles_and_repeated_children() {
assert_eq!(bad.subtree(p), vec![p, r]);
}
#[test]
fn repair_lists_a_page_once_however_often_the_file_lists_it() {
let mut d = Document::new("x");
let p = page(&d);
let q = d.add_page("Two");
let mut v = serde_json::to_value(&d).unwrap();
v["pages"] = serde_json::json!([p, q, p, q, p]);
let mut bad: Document = serde_json::from_value(v).unwrap();
assert!(bad.validate().iter().any(|i| i.contains("listed twice")), "{:?}", bad.validate());
bad.repair();
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
assert_eq!(bad.pages, vec![p, q]);
}
#[test]
fn validate_caps_the_issue_list() {
let mut d = Document::new("x");
@@ -846,3 +860,17 @@ fn the_colour_walker_visits_calculation_literals() {
let ColorOperand::Literal { literal } = from else { panic!("literal operand") };
assert_eq!(literal.a, 0.5, "the mix's stored literal was converted like every other colour");
}
#[test]
fn remove_children_removes_every_subtree_and_keeps_the_parent() {
let mut doc = Document::new("T");
let page = doc.first_page().unwrap();
let frame = doc.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
let inner = doc.create(frame, NodeKind::Frame, "Inner", 0.0, 0.0, 10.0, 10.0).unwrap();
let leaf = doc.create(inner, NodeKind::Rectangle, "Leaf", 0.0, 0.0, 5.0, 5.0).unwrap();
let other = doc.create(page, NodeKind::Rectangle, "Other", 0.0, 0.0, 5.0, 5.0).unwrap();
doc.remove_children(frame).unwrap();
assert!(doc.contains(frame) && doc.contains(other));
assert!(!doc.contains(inner) && !doc.contains(leaf));
assert!(doc.get(frame).unwrap().children.is_empty());
}
+4
View File
@@ -147,6 +147,10 @@ pub fn inside_instance(doc: &Document, id: NodeId) -> bool {
/// main layer's, for files from before `modes` was an override group. Copies follow their main
/// layer's modes, so a layer on other modes chose them itself.
pub fn mark_layer_modes(doc: &mut Document) {
// Without an instance nothing can be inside one: skip the per-layer ancestry lookups.
if !doc.nodes().any(|n| matches!(n.kind, NodeKind::Instance { .. })) {
return;
}
let chose: Vec<NodeId> = doc
.nodes()
.filter(|n| !n.overrides.contains("modes") && n.source.and_then(|s| doc.node(s)).is_some_and(|m| m.var_modes != n.var_modes))
+18 -1
View File
@@ -277,8 +277,16 @@ pub fn default_selection(doc: &Document, prev: Option<&LibraryManifest>) -> Sele
fn alias_closure(vars: &Variables, seeds: impl IntoIterator<Item = VariableId>) -> BTreeSet<VariableId> {
let mut out = BTreeSet::new();
let mut stack: Vec<VariableId> = seeds.into_iter().collect();
if stack.is_empty() {
return out;
}
// Indexed once (the first of equal ids, as `get_by_id` finds it): a scan per step is quadratic on a long alias chain.
let mut by_id = std::collections::HashMap::new();
for v in &vars.variables {
by_id.entry(v.id.as_str()).or_insert(v);
}
while let Some(cur) = stack.pop() {
let Some(v) = vars.get_by_id(&cur) else { continue };
let Some(v) = by_id.get(cur.as_str()).copied() else { continue };
if out.len() >= MAX_ASSETS || !out.insert(v.id.clone()) {
continue;
}
@@ -1344,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");
+4 -1
View File
@@ -79,10 +79,13 @@ pub fn usage(doc: &Document, library: &str) -> Usage {
let records: std::collections::BTreeMap<NodeId, NodeId> =
doc.libraries.components.iter().filter(|r| r.library == library).map(|r| (r.root, r.key)).collect();
if !records.is_empty() {
// One component's asset root is looked up once, however many instances use it.
let mut roots = std::collections::BTreeMap::<NodeId, Option<NodeId>>::new();
for n in doc.nodes() {
if let NodeKind::Instance { component, .. } = &n.kind
&& store.is_none_or(|st| doc.page_of(n.id) != Some(st))
&& let Some(key) = crate::library::asset_root(doc, *component).and_then(|r| records.get(&r))
&& let Some(key) =
roots.entry(*component).or_insert_with(|| crate::library::asset_root(doc, *component)).and_then(|r| records.get(&r))
{
let c = counts.entry(*key).or_default();
*c = c.saturating_add(1);
+1
View File
@@ -33,6 +33,7 @@ pub fn field_type(kind: &StyleKind, key: &str) -> Option<VariableType> {
(EffectKind::DropShadow(_) | EffectKind::InnerShadow(_), "color") => Some(Color),
(EffectKind::DropShadow(_) | EffectKind::InnerShadow(_), "radius" | "spread" | "offsetX" | "offsetY") => Some(Number),
(EffectKind::LayerBlur(_) | EffectKind::BackgroundBlur(_), "radius" | "startRadius") => Some(Number),
(EffectKind::Glass(_), f) if figcraft_doc::Glass::BOUND_FIELDS.contains(&f) => Some(Number),
_ => None,
},
(StyleKind::Grid { grids }, Some((BoundList::Grids, i, "size" | "count" | "gutter" | "offset"))) => grids.get(i).map(|_| Number),
+8 -2
View File
@@ -156,14 +156,20 @@ impl BatchScope {
lookup(result, path).cloned().map_err(|why| fail(format!("{who}'s result has no `{path}`{why}")))
}
/// Most step names a missing-reference error lists.
const NAME_HINTS: usize = 12;
fn known_names(&self) -> String {
let mut names: Vec<_> = self.names.iter().collect();
names.sort_by_key(|(_, i)| **i);
if names.is_empty() {
return "no step has an `as` name".into();
}
let list: Vec<_> = names.iter().map(|(name, i)| format!("\"{name}\" is step {i}")).collect();
list.join(", ")
// A few of them: the list is built for every missing reference and, with `onError:
// continue`, kept for each, so a long table would cost its length per failed step.
let list: Vec<_> = names.iter().take(Self::NAME_HINTS).map(|(name, i)| format!("\"{name}\" is step {i}")).collect();
let more = names.len().saturating_sub(Self::NAME_HINTS);
if more > 0 { format!("{}, {more} more", list.join(", ")) } else { list.join(", ") }
}
}
+35
View File
@@ -98,6 +98,41 @@ fn effect_moves_and_removals_reject_junk() {
assert_eq!(s.doc().unwrap().node(r).unwrap().effects.len(), 1);
}
fn glass(s: &Session, r: NodeId) -> figcraft_doc::Glass {
match s.doc().unwrap().node(r).unwrap().effects.first().map(|e| &e.kind) {
Some(EffectKind::Glass(g)) => g.clone(),
k => panic!("{k:?}"),
}
}
#[test]
fn glass_fields_take_number_variables() {
let (mut s, r) = setup();
ex(&mut s, "effect.add", json!({"ids": [r], "type": "glass"}));
// Percent fields read the variable as the Design panel shows them; `radius` is the frost.
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "effects/0/splay", "variable": "large"}));
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "effects/0/radius", "variable": "small"}));
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "effects/0/depth", "variable": "large"}));
let g = glass(&s, r);
assert_eq!((g.splay, g.frost, g.depth), (0.3, 2.0, 30.0));
// A new value follows, clamped to the field's range (frost stops at 100).
ex(&mut s, "variable.set", json!({"variable": "small", "value": 150}));
ex(&mut s, "variable.set", json!({"variable": "large", "value": 75}));
let g = glass(&s, r);
assert_eq!((g.splay, g.frost, g.depth), (0.75, figcraft_doc::Glass::MAX_FROST, 75.0));
// A colour variable doesn't fit a glass field.
assert!(s.execute("variable.bind", &json!({"ids": [r], "field": "effects/0/refraction", "variable": "brand"})).is_err());
// An effect style binds them too, and every layer using it follows.
let fx = ex_value(&mut s, "style.create", json!({"type": "effect", "name": "Pane", "from": r}))["id"].as_str().unwrap().to_string();
ex(&mut s, "style.bind", json!({"style": fx, "field": "effects/0/refraction", "variable": "large"}));
assert_eq!(glass(&s, r).refraction, 0.75);
assert!(s.execute("style.bind", &json!({"style": fx, "field": "effects/0/spread", "variable": "large"})).is_err(), "glass has no spread");
}
fn ex_value(s: &mut Session, cmd: &str, p: serde_json::Value) -> serde_json::Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn fill_hex(s: &Session, r: NodeId) -> Vec<String> {
s.doc().unwrap().node(r).unwrap().fills.iter().filter_map(|p| p.solid_color()).map(|c| c.to_hex()).collect()
}
+3 -1
View File
@@ -109,7 +109,9 @@ pub fn eligible(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
out.push(id);
}
}
let ids: Vec<NodeId> = ids.iter().copied().filter(|i| doc.contains(*i)).collect();
// Each id counts once: a repeated group must not be walked once per repeat.
let mut seen = std::collections::BTreeSet::new();
let ids: Vec<NodeId> = ids.iter().copied().filter(|i| doc.contains(*i) && seen.insert(*i)).collect();
let mut out = Vec::new();
for id in roots(doc, &ids) {
walk(doc, id, 0, &mut out);
+14 -12
View File
@@ -218,15 +218,16 @@ impl Session {
// Cmd/Ctrl (and Control on macOS) held while dragging suspends snapping.
let snap_on = self.ds().map(|ds| ds.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl;
let mut guides = Vec::new();
if self.ds().map(|ds| ds.view.snap_pixel).unwrap_or(true) {
// Keep the box's origin on whole pixels (a rotated parent frame's, inside one);
// snapping to layers comes after, so it wins on the axes it snaps.
d = targets.round_move(d);
}
if snap_on {
let (c, g2) = targets.snap_move(d, SNAP_PX / ev.zoom.max(1e-6));
d += c;
guides = g2;
}
if self.ds().map(|ds| ds.view.snap_pixel).unwrap_or(true) {
// Keep the box's origin on whole pixels (a rotated parent frame's, inside one).
d = targets.round_move(d);
}
let ids = ids.clone();
let dup = *duplicate;
let page = self.page()?;
@@ -475,20 +476,21 @@ impl Session {
let targets = if snap { Some(Targets::gather(self.doc()?, page, &ids)) } else { None };
let mut p = ev.p();
let mut guides = Vec::new();
let _ = start;
// In the handle box's own axes (one layer: its local space).
let (frame, w0, h0) = r.frame;
// The pixel grid: an upright box's dragged sides land on whole pixels (the Scale tool
// scales by the factor as dragged). Snapping to layers comes after, so it wins on the axes
// it snaps.
if !scale_tool && self.ds().map(|d| d.view.snap_pixel).unwrap_or(true) && crate::guides::axis_aligned(frame) {
p = Point::new(p.x.round(), p.y.round());
}
if let Some(t) = &targets {
let (dx, dy, g) = t.snap_point(p.x, p.y, SNAP_PX / ev.zoom.max(1e-6));
p.x += dx;
p.y += dy;
guides = g;
}
let _ = start;
// In the handle box's own axes (one layer: its local space).
let (frame, w0, h0) = r.frame;
// The pixel grid: an upright box's dragged sides land on whole pixels (the Scale tool
// scales by the factor as dragged).
if !scale_tool && self.ds().map(|d| d.view.snap_pixel).unwrap_or(true) && crate::guides::axis_aligned(frame) {
p = Point::new(p.x.round(), p.y.round());
}
let q = frame.inverse() * p;
let is_line = r.single && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| matches!(n.kind, NodeKind::Line))).unwrap_or(false);
if scale_tool {
+6 -3
View File
@@ -29,20 +29,23 @@ impl Session {
let tool = self.tool.tool;
let mut p = ev.p();
let mut guides = Vec::new();
let pixel = self.ds().map(|d| d.view.snap_pixel).unwrap_or(true);
// The pixel grid first: snapping to layers comes after, so it wins on the axes it snaps.
if pixel {
p = Point::new(p.x.round(), p.y.round());
}
if self.ds().map(|d| d.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl {
let (dx, dy, g) = targets.snap_point(p.x, p.y, SNAP_PX / ev.zoom.max(1e-6));
p.x += dx;
p.y += dy;
guides = g;
}
let pixel = self.ds().map(|d| d.view.snap_pixel).unwrap_or(true);
let mut created = id;
let page = self.page()?;
self.preview(|doc, sel| {
let (mut a, mut b) = (start, p);
let (mut a, b) = (start, p);
if pixel {
a = Point::new(a.x.round(), a.y.round());
b = Point::new(b.x.round(), b.y.round());
}
let pw = doc.world_transform(parent);
let (kind, base) = match tool {
+2 -1
View File
@@ -465,7 +465,8 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> {
}
EffectKind::Glass(g) => {
g.depth *= k;
g.frost *= k;
// From the frost it draws with (an older file's past 100 draws at 100).
g.frost = (g.sanitized().frost * k).min(figcraft_doc::Glass::MAX_FROST);
}
}
}
+4 -3
View File
@@ -373,10 +373,11 @@ fn listed<'s>(cmd: &str, s: &'s Session, p: &Value, who: &str, people: &[&str])
/// Add the names `text` @mentions to `named` as `{name, known, you}`, each once.
fn push_mentions(named: &mut Vec<Value>, text: &str, people: &[&str], user: &str) {
// `known` and `you` follow from the name, so the name alone tells a repeat.
let mut seen: std::collections::BTreeSet<String> = named.iter().filter_map(|v| v.get("name")?.as_str().map(str::to_string)).collect();
for (name, known) in mentioned(text, people) {
let v = json!({"name": name, "known": known, "you": known && name.eq_ignore_ascii_case(user)});
if !named.contains(&v) {
named.push(v);
if seen.insert(name.to_string()) {
named.push(json!({"name": name, "known": known, "you": known && name.eq_ignore_ascii_case(user)}));
}
}
}
+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")
+3 -1
View File
@@ -139,6 +139,8 @@ pub(crate) fn enum_of<T: serde::de::DeserializeOwned>(v: &Value) -> Option<T> {
let norm: String = {
let mut out = String::new();
let mut up = false;
// Once, not per character: a long value made this quadratic before it was refused.
let no_lowercase = s.chars().all(|c| !c.is_lowercase() || !c.is_alphabetic());
for (i, ch) in s.trim().chars().enumerate() {
if ch == '_' || ch == '-' || ch == ' ' {
up = true;
@@ -149,7 +151,7 @@ pub(crate) fn enum_of<T: serde::de::DeserializeOwned>(v: &Value) -> Option<T> {
} else if up {
out.extend(ch.to_uppercase());
up = false;
} else if s.chars().all(|c| !c.is_lowercase() || !c.is_alphabetic()) {
} else if no_lowercase {
out.extend(ch.to_lowercase());
} else {
out.push(ch);
+5 -2
View File
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"style.bind",
"Bind variable in style",
"{style, field, variable? (id or name; none: unbind)} bind a variable to a field of the style's value: a paint style's `fills/<i>/color` (or `fill`: its first paint) and `fills/<i>/stops/<j>/color`, an effect style's `effects/<i>/<color|radius|spread|offsetX|offsetY|startRadius>`, a grid style's `layoutGrids/<i>/<size|count|gutter|offset>`, a text style's `fontSize|lineHeight|letterSpacing` (numbers, px) and `fontFamily|fontStyle` (strings). Every layer using the style shows the field resolved in its own modes; the style keeps it resolved in the default modes",
"{style, field, variable? (id or name; none: unbind)} bind a variable to a field of the style's value: a paint style's `fills/<i>/color` (or `fill`: its first paint) and `fills/<i>/stops/<j>/color`, an effect style's `effects/<i>/<color|radius|spread|offsetX|offsetY|startRadius>` (glass: `lightAngle|lightIntensity|refraction|depth|dispersion|radius|splay`), a grid style's `layoutGrids/<i>/<size|count|gutter|offset>`, a text style's `fontSize|lineHeight|letterSpacing` (numbers, px) and `fontFamily|fontStyle` (strings). Every layer using the style shows the field resolved in its own modes; the style keeps it resolved in the default modes",
true,
bind_style
),
@@ -196,7 +196,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"variable.bind",
"Bind variable",
"{ids?, field (fill|stroke (first paint)|fills/<i>/color|strokes/<i>/color|fills/<i>/stops/<j>/color (gradient stops)|effects/<i>/<offsetX|offsetY|radius|startRadius|spread|color>|layoutGrids/<i>/<size|count|gutter|offset>|componentProperties/<name> (instances: boolean/text properties; variant properties take string variables naming a value)|width|height|opacity (percent)|cornerRadius|gap|paddingHorizontal|characters|visible|fontSize…; list indices bottom first), variable (null unbinds)}",
"{ids?, field (fill|stroke (first paint)|fills/<i>/color|strokes/<i>/color|fills/<i>/stops/<j>/color (gradient stops)|effects/<i>/<offsetX|offsetY|radius|startRadius|spread|color|lightAngle|lightIntensity|refraction|depth|dispersion|splay> (glass: `radius` is its frost; intensity, refraction, dispersion and splay in percent)|layoutGrids/<i>/<size|count|gutter|offset>|componentProperties/<name> (instances: boolean/text properties; variant properties take string variables naming a value)|width|height|opacity (percent)|cornerRadius|gap|paddingHorizontal|characters|visible|fontSize…; list indices bottom first), variable (null unbinds)}",
true,
bind
),
@@ -1304,6 +1304,9 @@ pub(crate) fn set_effect_field(effects: &mut [Effect], i: usize, prop: &str, val
(EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "color", _, VariableValue::Color(c)) => s.color = *c,
(EffectKind::LayerBlur(b) | EffectKind::BackgroundBlur(b), "radius", Some(x), _) => b.radius = x.max(0.0),
(EffectKind::LayerBlur(b) | EffectKind::BackgroundBlur(b), "startRadius", Some(x), _) => b.start_radius = x.max(0.0),
(EffectKind::Glass(g), field, Some(x), _) => {
g.set_bound(field, x);
}
_ => {}
}
}
+3 -3
View File
@@ -12,7 +12,7 @@ use serde_json::{Value, json};
use super::lists::BoundList;
use super::{CommandSpec, ok};
use crate::params::{boolean, get, num, req_string, string};
use crate::params::{boolean, get, req_index, req_string, string};
use crate::{Result, Session, bad, spec};
pub fn specs() -> Vec<CommandSpec> {
@@ -258,13 +258,13 @@ fn remove_mode(s: &mut Session, p: &Value) -> Result<Value> {
fn move_mode(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.moveMode";
let to = num(p, "to").filter(|x| x.is_finite()).ok_or_else(|| bad(cmd, "`to` must be a number"))?;
let to = req_index(cmd, p, "to")?;
let doc = s.doc_mut()?;
let (ci, mode) = mode_of(doc, cmd, p)?;
let coll = doc.variables.collections.get_mut(ci).ok_or_else(|| bad(cmd, "no such collection"))?;
let from = coll.modes.iter().position(|m| m.id == mode).ok_or_else(|| bad(cmd, "no such mode"))?;
let m = coll.modes.remove(from);
let at = (to.max(0.0) as usize).min(coll.modes.len());
let at = to.min(coll.modes.len());
coll.modes.insert(at, m);
ok()
}
+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}");
}
+11 -4
View File
@@ -189,14 +189,21 @@ fn fill_layers(s: &mut Session, copy: NodeId, (key, value): (&str, &Value), miss
/// Export each copy into `dir` as `<name>.<format>` at `scale`, adding `files` (and `errors`) to
/// `out`.
fn export_copies(s: &mut Session, ids: &[(NodeId, String)], dir: &str, (format, scale): (&str, f64), out: &mut Value) {
let (mut files, mut errors, mut used) = (Vec::new(), Vec::new(), Vec::<String>::new());
let (mut files, mut errors) = (Vec::new(), Vec::new());
let mut used = std::collections::BTreeSet::<String>::new();
// The next number to try per base, so many copies of one name don't retry from 2 each time.
let mut next = std::collections::BTreeMap::<String, u64>::new();
for (copy, name) in ids {
// One file per copy: same or same-looking names get " 2", " 3"… (case-insensitively,
// as macOS and Windows file systems compare names).
let base = file_name(name);
let stem =
(1..).map(|k| if k == 1 { base.clone() } else { format!("{base} {k}") }).find(|n| !used.contains(&n.to_lowercase())).unwrap_or(base);
used.push(stem.to_lowercase());
let from = next.get(&base.to_lowercase()).copied().unwrap_or(1);
let (k, stem) = (from..)
.map(|k| (k, if k == 1 { base.clone() } else { format!("{base} {k}") }))
.find(|(_, n)| !used.contains(&n.to_lowercase()))
.unwrap_or((from, base.clone()));
next.insert(base.to_lowercase(), k + 1);
used.insert(stem.to_lowercase());
let path = std::path::Path::new(dir).join(format!("{stem}.{format}")).to_string_lossy().to_string();
match s.execute("file.export", &json!({"ids": [copy.0], "format": format, "scale": scale, "path": path})) {
Ok(_) => files.push(path),
+1 -1
View File
@@ -34,7 +34,7 @@ pub use css::{CssNames, css_var_name, css_var_of};
use css::{css, tok};
pub(crate) use flutter::{FLUTTER_IMPORT, Place, flutter, flutter_indent};
use html::html;
pub(crate) use html::{Markup, class_name};
pub(crate) use html::{Markup, Names, class_name};
pub(crate) use swift::swift;
pub fn specs() -> Vec<CommandSpec> {
+30 -8
View File
@@ -30,7 +30,7 @@ fn kind_word(n: &Node) -> &str {
/// `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` (per-codepoint transliteration, the slug
/// libraries' way); a name left with nothing (symbols only) takes `kind`, and one starting with a
/// digit keeps the `layer-` prefix.
pub(crate) fn class_name(name: &str, kind: &str, used: &mut Vec<String>) -> String {
pub(crate) fn class_name(name: &str, kind: &str, used: &mut Names) -> String {
let mut base = String::new();
for c in deunicode::deunicode(name).chars().flat_map(char::to_lowercase) {
if c.is_ascii_alphanumeric() {
@@ -57,11 +57,30 @@ pub(crate) fn class_name(name: &str, kind: &str, used: &mut Vec<String>) -> Stri
} else {
base.to_string()
};
let name = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}-{k}") }).find(|n| !used.contains(n)).unwrap_or(base);
used.push(name.clone());
let from = used.next.get(&base).copied().unwrap_or(1);
let (k, name) = (from..)
.map(|k| (k, if k == 1 { base.clone() } else { format!("{base}-{k}") }))
.find(|(_, n)| !used.used.contains(n))
.unwrap_or((from, base.clone()));
used.next.insert(base, k + 1);
used.insert(name.clone());
name
}
/// The names [`class_name`] has given out, and the next suffix to try per base, so many layers
/// sharing one name don't each retry every suffix from 2 (that was cubic in their number).
#[derive(Default)]
pub(crate) struct Names {
used: std::collections::BTreeSet<String>,
next: std::collections::BTreeMap<String, u64>,
}
impl Names {
pub fn insert(&mut self, name: String) {
self.used.insert(name);
}
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('\n', "<br>")
}
@@ -90,7 +109,10 @@ pub(super) fn html(doc: &Document, id: NodeId) -> String {
let ctx = Ctx::new(doc, id);
let mut out = Markup::new(&ctx, CodeLang::Html);
out.node(id, None, 0, "");
format!("{}\n<style>\n{}{}</style>\n", out.markup, out.tokens.root_rule(), out.styles)
// Document text can reach the stylesheet through any property: `<\/` reads back as `</` inside a CSS
// string and can never close the element early.
let css = format!("{}{}", out.tokens.root_rule(), out.styles).replace("</", "<\\/");
format!("{}\n<style>\n{css}</style>\n", out.markup)
}
/// Markup being built for a layer tree: HTML (`class`), React JSX (`className`), Vue
@@ -101,7 +123,7 @@ pub(crate) struct Markup<'c, 'a> {
ctx: &'c Ctx<'a>,
/// Html, React, Vue or Leptos.
lang: CodeLang,
used: Vec<String>,
used: Names,
pub markup: String,
pub styles: String,
pub tokens: Tokens,
@@ -158,7 +180,7 @@ impl<'c, 'a> Markup<'c, 'a> {
Markup {
ctx,
lang,
used: Vec::new(),
used: Names::default(),
markup: String::new(),
styles: String::new(),
tokens: Tokens::default(),
@@ -273,7 +295,7 @@ impl<'c, 'a> Markup<'c, 'a> {
let root = n.id == self.ctx.root;
let class = match self.root_class.take().filter(|_| root) {
Some(c) => {
self.used.push(c.clone());
self.used.insert(c.clone());
c
}
None => {
@@ -329,7 +351,7 @@ impl<'c, 'a> Markup<'c, 'a> {
let open = open("div");
let slot_ident = slot.map(|p| match self.ctx.comp {
Some(c) => crate::component_codegen::slot_name(c, p),
None => class_name(&p.ident, "slot", &mut Vec::new()),
None => class_name(&p.ident, "slot", &mut Names::default()),
});
if let Some(p) = slot.filter(|_| kids.is_empty()) {
let content = if leptos {
+8 -6
View File
@@ -11,7 +11,9 @@ use figcraft_doc::{Document, NodeId, NodeKind, PropDef};
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::codegen::{CodeLang, Ctx, FLUTTER_IMPORT, Markup, Place, arkui, class_name, compose, flutter, flutter_indent, indent, literal, swift};
use crate::codegen::{
CodeLang, Ctx, FLUTTER_IMPORT, Markup, Names, Place, arkui, class_name, compose, flutter, flutter_indent, indent, literal, swift,
};
use crate::components::{prop_default_value, slot_edited, slots_of, variant_options, variant_props, variant_set};
use crate::params::{id_of, string, targets};
use crate::{bad, spec};
@@ -616,14 +618,14 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
/// The `<slot name="…">` of a slot parameter: unique among the component's slot parameters, unlike
/// `class_name` called with a fresh list per parameter (two idents can lowercase alike).
pub(crate) fn slot_name(c: &Component, p: &Param) -> String {
let mut used = Vec::new();
let mut used = Names::default();
for q in c.params.iter().filter(|q| matches!(q.kind, ParamKind::Slot)) {
let name = class_name(&q.ident, "slot", &mut used);
if q.ident == p.ident {
return name;
}
}
class_name(&p.ident, "slot", &mut Vec::new())
class_name(&p.ident, "slot", &mut Names::default())
}
/// The values an instance sets for `c`'s parameters, in order (`None` for other layers): variant
@@ -720,7 +722,7 @@ pub(crate) fn usage_with_styles(doc: &Document, id: NodeId, lang: CodeLang, dept
.filter(|(name, _, _)| is_slot(name))
.map(|(name, expr, _)| {
let slot = c.params.iter().find(|q| q.ident == *name);
let name = slot.map(|p| slot_name(&c, p)).unwrap_or_else(|| class_name(name, "slot", &mut Vec::new()));
let name = slot.map(|p| slot_name(&c, p)).unwrap_or_else(|| class_name(name, "slot", &mut Names::default()));
format!("{pad} <template #{name}>\n{expr}{pad} </template>\n")
})
.collect();
@@ -823,7 +825,7 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
/// `data-*` attribute names for variant parameters, unique among them (in order).
fn data_attrs(variants: &[&Param]) -> Vec<String> {
let mut used = Vec::new();
let mut used = Names::default();
variants.iter().map(|p| format!("data-{}", class_name(&p.prop, "prop", &mut used))).collect()
}
@@ -832,7 +834,7 @@ fn data_attrs(variants: &[&Param]) -> Vec<String> {
fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String) {
let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 2 };
let root_name = c.set.and_then(|s| doc.node(s)).or_else(|| doc.node(c.comp)).map(|n| n.name.clone()).unwrap_or_default();
let root_class = class_name(&root_name, "component", &mut Vec::new());
let root_class = class_name(&root_name, "component", &mut Names::default());
let variants: Vec<&Param> = c.params.iter().filter(|p| matches!(p.kind, ParamKind::Variant(_))).collect();
let attr_names = data_attrs(&variants);
let pass = |root: NodeId| {
+4 -2
View File
@@ -288,8 +288,10 @@ pub fn layer_changes(before: &Document, now: &Document, id: NodeId) -> Vec<PropC
fn order(before: &Document, now: &Document, id: NodeId) -> Option<(usize, usize)> {
let parent = now.node(id)?.parent?;
let (pb, pa) = (before.node(parent)?, now.node(parent)?);
let common_b: Vec<NodeId> = pb.children.iter().rev().copied().filter(|c| pa.children.contains(c)).collect();
let common_a: Vec<NodeId> = pa.children.iter().rev().copied().filter(|c| pb.children.contains(c)).collect();
let (in_b, in_a): (std::collections::BTreeSet<NodeId>, std::collections::BTreeSet<NodeId>) =
(pb.children.iter().copied().collect(), pa.children.iter().copied().collect());
let common_b: Vec<NodeId> = pb.children.iter().rev().copied().filter(|c| in_a.contains(c)).collect();
let common_a: Vec<NodeId> = pa.children.iter().rev().copied().filter(|c| in_b.contains(c)).collect();
let (ib, ia) = (common_b.iter().position(|c| *c == id)?, common_a.iter().position(|c| *c == id)?);
(ib != ia).then_some((ib + 1, ia + 1))
}
+1 -1
View File
@@ -475,7 +475,7 @@ pub fn properties_with(doc: &Document, id: NodeId, fmt: ColorFormat) -> Vec<Sect
pct(g.refraction),
num(g.depth),
pct(g.dispersion),
num(g.frost),
num(g.sanitized().frost),
pct(g.splay)
),
),
+26
View File
@@ -95,6 +95,32 @@ fn scaling_scales_grain_and_glass() {
assert_eq!((g.refraction, nz.density), (d.refraction, 0.5));
}
#[test]
fn scaling_a_frost_from_an_older_file_starts_from_what_it_draws() {
// A frost of 150, saved before the 0–100 range, draws at 100: halved, it draws at 50.
let mut s = Session::new();
let r = rect(&mut s);
s.execute("effect.add", &json!({"ids": [r], "type": "glass"})).unwrap();
s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "glass", "frost": 150}})).unwrap();
s.execute("transform.scale", &json!({"ids": [r], "factor": 0.5})).unwrap();
let EffectKind::Glass(g) = &s.doc().unwrap().node(r).unwrap().effects[0].kind else { panic!() };
assert_eq!(g.frost, 50.0);
}
#[test]
fn inspect_shows_a_frost_from_an_older_file_as_it_draws() {
// A frost of 150, saved before the 0–100 range, draws at 100: Dev Mode says 100 as CSS does.
let mut s = Session::new();
let r = rect(&mut s);
s.execute("effect.add", &json!({"ids": [r], "type": "glass"})).unwrap();
s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "glass", "frost": 150}})).unwrap();
let doc = s.doc().unwrap();
let sections = crate::devinspect::properties(doc, r);
let glass = &sections.iter().find(|s| s.title == "Effects").unwrap().rows[0];
assert!(glass.value.contains(" frost 100 "), "{}", glass.value);
assert!(code_for(doc, r, CodeLang::Css).contains("backdrop-filter: blur(50px);"));
}
#[test]
fn inspect_and_css_show_the_new_effects() {
let mut s = Session::new();
+5 -2
View File
@@ -127,12 +127,15 @@ pub fn topics(v: Option<&Value>) -> Result<Vec<String>, String> {
for t in list {
let Some(s) = t.as_str() else { return Err("`topics` must be an array of strings".into()) };
match s {
// Each once: the list is scanned for every retained event under the log's lock, so
// a long list of repeats would multiply that work.
"comments" | "document" | "all" if out.iter().any(|t| t == s) => {}
"comments" | "document" | "all" => out.push(s.to_string()),
other => return Err(format!("unknown topic `{other}` (comments, document, all)")),
}
}
if out.is_empty() {
out.push("all".into());
if out.is_empty() || out.iter().any(|t| t == "all") {
out = vec!["all".into()];
}
Ok(out)
}
+4
View File
@@ -193,6 +193,10 @@ fn topics_parse_and_reject_unknown_names() {
assert_eq!(events::topics(Some(&json!([]))).unwrap(), vec!["all".to_string()]);
assert_eq!(events::topics(Some(&json!(["comments", "document"]))).unwrap(), vec!["comments".to_string(), "document".to_string()]);
assert!(events::topics(Some(&json!(["nope"]))).is_err());
// Repeats collapse (the list is scanned per event under the log's lock), and `all` is all.
let many: Vec<&str> = std::iter::repeat_n("comments", 10_000).chain(["document"]).collect();
assert_eq!(events::topics(Some(&json!(many))).unwrap(), vec!["comments".to_string(), "document".to_string()]);
assert_eq!(events::topics(Some(&json!(["comments", "all", "document"]))).unwrap(), vec!["all".to_string()]);
assert!(events::topics(Some(&json!("comments"))).is_err());
}
+10
View File
@@ -149,6 +149,16 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option<String> {
if DENIED.iter().any(|p| command.starts_with(p)) && !ALLOWED.contains(&command) {
return Some(format!("`{command}` isn't available to plugins"));
}
// A preview runs its inner command, which the plugin's permissions and the denied list must cover too.
// An inner command that can't be previewed (a nested preview) is refused by the preview itself,
// before it runs: descending into its parameters first would only rescan them.
if command == "text.preview"
&& let Some(inner) = get(params, "command").and_then(Value::as_str)
&& crate::textpreview::PREVIEWABLE.contains(&inner)
&& let Some(why) = refusal(m, inner, get(params, "params").unwrap_or(&Value::Null))
{
return Some(why);
}
// A widget's own data is FigCraft's to write, so no plugin can forge or retarget a widget.
if writes_widget_data(params, 0) {
return Some(format!("plugins can't write `{}…` layer data", crate::widgets::DATA_PREFIX));
+3 -4
View File
@@ -157,8 +157,9 @@ fn state_of(doc: &Document, w: NodeId) -> Map<String, Value> {
/// fields), each once, in layer order.
fn names(doc: &Document, w: NodeId, key: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut seen = std::collections::BTreeSet::new();
for t in doc.subtree(w).into_iter().filter_map(|i| doc.node(i)?.plugin_data.get(key).cloned()) {
if !out.contains(&t) {
if seen.insert(t.clone()) {
out.push(t);
}
}
@@ -243,9 +244,7 @@ fn build(s: &mut Session, plugin: &str, w: NodeId, r: &WidgetRender) -> Result<(
if !is_widget(n) || n.plugin_data.get(PLUGIN).map(String::as_str) != Some(plugin) {
return Err(bad(cmd, "the layer is no longer this plugin's widget"));
}
for c in n.children.clone() {
doc.remove(c)?;
}
doc.remove_children(w)?;
crate::cmd::props::apply(cmd, doc, w, &props(&r.root, true))?;
let n = doc.get_mut(w)?;
n.locked = false;
+27
View File
@@ -73,6 +73,20 @@ fn non_ascii_layer_names_transliterate_into_readable_classes() {
assert!(code.contains("<p class=\"rogeuin\">") && code.contains("<p class=\"rogeuin-2\">"), "{code}");
}
#[test]
fn many_layers_with_one_name_get_numbered_in_linear_time() {
// Each name used to retry every suffix from 2 against a list: cubic in their number.
let mut used = crate::codegen::Names::default();
let start = std::time::Instant::now();
let names: Vec<String> = (0..20_000).map(|_| crate::codegen::class_name("Card / Title", "frame", &mut used)).collect();
assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed());
assert_eq!(names[0], "card-title");
assert_eq!(names[1], "card-title-2");
assert_eq!(names[19_999], "card-title-20000");
let distinct: std::collections::BTreeSet<&String> = names.iter().collect();
assert_eq!(distinct.len(), names.len());
}
#[test]
fn other_scripts_and_symbol_names_transliterate_or_fall_back_to_the_kind() {
let mut s = Session::new();
@@ -163,3 +177,16 @@ fn rem_preserves_text_property_default() {
}
}
}
#[test]
fn the_stylesheet_never_contains_a_closing_style_tag_from_document_text() {
let mut s = Session::new();
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
let t = id(s.execute("text.create", &json!({"text": "x", "x": 0, "y": 0})).unwrap());
s.execute("node.set", &json!({"id": t, "props": {"fontFamily": "a</style><script>alert(1)</script>"}})).unwrap();
s.execute("node.reparent", &json!({"ids": [t], "parent": f})).unwrap();
let code = code_for(s.doc().unwrap(), f, CodeLang::Html);
let (_, css) = code.split_once("<style>").unwrap();
assert_eq!(css.matches("</style>").count(), 1, "{code}");
assert!(!code.contains("<script>"), "{code}");
}
+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,
}
}
@@ -277,6 +277,11 @@ fn renaming_several_layers_by_the_documented_codes() {
assert_eq!(r(None, "$n", Some(1e30), "a"), "1000000000000");
assert_eq!(r(None, "$n", Some(f64::NAN), "a"), "1");
assert_eq!(r(Some("é"), "e", None, "café é"), "cafe e");
// A template is expanded once per match, so its length is capped (1,000 characters).
let long = "$nn".repeat(400);
assert!(crate::rename::BatchRename::new(Some("a"), false, &long, None, None).is_err());
assert!(crate::rename::BatchRename::new(None, false, "x", Some(&long), None).is_err_and(|e| e.contains("pattern")));
assert!(crate::rename::BatchRename::new(Some("a"), false, &"$nn".repeat(300), None, None).is_ok());
}
#[test]
+24
View File
@@ -153,6 +153,30 @@ fn read_only_plugins_and_allowlists_are_enforced() {
assert_eq!(node(&s, a).name, "Rectangle");
}
#[test]
fn a_preview_is_held_to_the_permissions_of_the_command_it_shows() {
let mut s = Session::new();
let a = rect(&mut s, 0.0);
// Only text.preview is allowed: the node.set it would run is not.
install(
&mut s,
manifest("org.test.previewer", "write", &["text.preview"]),
&script(&[("text.preview", json!({"command": "node.set", "params": {"id": a, "props": {"name": "x"}}}))], OnRefusal::Fail, ""),
);
let e = run_err(&mut s, "org.test.previewer");
assert!(e.contains("`node.set` isn't in the commands"), "{e}");
assert_eq!(node(&s, a).name, "Rectangle");
}
#[test]
fn a_nested_preview_is_refused_by_the_preview_itself() {
let mut s = Session::new();
let nested = json!({"command": "text.preview", "params": {"command": "node.set", "params": {"props": {"name": "x"}}}});
install(&mut s, manifest("org.test.nested", "write", &["text.preview"]), &script(&[("text.preview", nested)], OnRefusal::Fail, ""));
let e = run_err(&mut s, "org.test.nested");
assert!(e.contains("can't be previewed"), "{e}");
}
#[test]
fn files_dialogs_history_and_plugins_are_off_limits() {
let mut s = Session::new();
+9
View File
@@ -9,6 +9,9 @@ use figcraft_doc::{Document, NodeId};
/// Longest match pattern accepted (characters).
const MAX_FIND: usize = 1_000;
/// Longest replacement or pattern template accepted (characters): each is expanded again for
/// every match, so its length multiplies the work of a rename.
const MAX_TEMPLATE: usize = 1_000;
/// Longest name a rename produces (characters).
const MAX_NAME: usize = 10_000;
/// Widest zero padding of a number code.
@@ -50,6 +53,12 @@ impl BatchRename {
}
Some(f) => Some(Find::Text(f.to_string())),
};
if replace.chars().count() > MAX_TEMPLATE {
return Err(format!("the replacement is longer than {MAX_TEMPLATE} characters"));
}
if pattern.is_some_and(|p| p.chars().count() > MAX_TEMPLATE) {
return Err(format!("the pattern is longer than {MAX_TEMPLATE} characters"));
}
let start = start.filter(|s| s.is_finite()).map_or(1, |s| s.clamp(0.0, 1e12).round() as u64);
Ok(Self { find, replace: replace.to_string(), pattern: pattern.map(str::to_string), start })
}
+9
View File
@@ -217,6 +217,15 @@ fn remove_mode_and_escape() {
assert!(s.execute("shapeBuilder.mode", &json!({"mode": "sideways"})).is_err());
}
#[test]
fn a_group_named_many_times_is_walked_once() {
let mut s = Session::new();
let (a, b) = squares(&mut s);
let g = id(&ex(&mut s, "object.group", json!({"ids": [a, b]})));
let r = ex(&mut s, "shapeBuilder.regions", json!({"ids": vec![g; 500]}));
assert_eq!(r["sources"], json!([a, b]));
}
#[test]
fn groups_rotated_and_ineligible_layers() {
let mut s = Session::new();
+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.
@@ -625,6 +625,33 @@ fn smart_guides_snap_to_edges_centres_and_equal_spacing() {
assert_eq!(bounds(&s, b).origin(), Point::new(198.0, 2.0));
}
#[test]
fn snapping_to_layers_wins_over_the_pixel_grid() {
// Both on (the defaults): a layer's centre at x = 5.5 is still a target.
let mut s = Session::new();
let a = rect(&mut s, 0.0, 0.0, 11.0, 11.0, None);
let b = rect(&mut s, 200.0, 300.0, 100.0, 100.0, None);
let _ = a;
ex(&mut s, "select.set", json!({"ids": [b]}));
// Moving: B's centre (grabbed) snaps to A's; the free axis stays on whole pixels.
s.pointer(&ev(PointerKind::Down, Point::new(250.0, 350.0), NONE)).unwrap();
s.pointer(&ev(PointerKind::Move, Point::new(6.0, 352.4), NONE)).unwrap();
assert!(s.tool.overlay.guides.iter().any(|g| near(g.x0, 5.5) && near(g.x1, 5.5)), "{:?}", s.tool.overlay.guides);
s.pointer(&ev(PointerKind::Up, Point::new(6.0, 352.4), NONE)).unwrap();
assert_eq!(bounds(&s, b).origin(), Point::new(-44.5, 302.0), "moved onto A's centre");
ex(&mut s, "edit.undo", json!({}));
// Out of reach of any layer, a move lands on whole pixels.
drag(&mut s, Point::new(250.0, 350.0), Point::new(260.3, 360.6), NONE);
assert_eq!(bounds(&s, b).origin(), Point::new(210.0, 311.0));
ex(&mut s, "edit.undo", json!({}));
// Resizing: the left handle snaps to A's centre; the bottom one lands on whole pixels.
drag(&mut s, Point::new(200.0, 350.0), Point::new(6.0, 350.0), NONE);
assert!(near(bounds(&s, b).x0, 5.5), "{:?}", bounds(&s, b));
ex(&mut s, "edit.undo", json!({}));
drag(&mut s, Point::new(250.0, 400.0), Point::new(250.0, 450.6), NONE);
assert_eq!(size(&s, b), (100.0, 151.0));
}
#[test]
fn corner_cursors_stay_diagonal_whatever_the_proportions() {
let mut s = session();
@@ -68,6 +68,20 @@ fn types_and_their_starting_values() {
assert!(s.execute("variable.create", &json!({"collection": "C", "name": "y", "type": "number", "value": "wide"})).is_err());
}
#[test]
fn move_mode_rejects_negative_and_fractional_indexes() {
let mut s = Session::new();
theme(&mut s);
run(&mut s, "variable.addMode", json!({"collection": "Theme", "name": "Dim"}));
let order =
|s: &Session| -> Vec<String> { s.doc().unwrap().variables.collection("Theme").unwrap().modes.iter().map(|m| m.name.clone()).collect() };
let before = order(&s);
for to in [json!(-1), json!(1.9)] {
assert!(s.execute("variable.moveMode", &json!({"collection": "Theme", "mode": "Dim", "to": to})).is_err(), "{to}");
assert_eq!(order(&s), before, "{to}");
}
}
#[test]
fn modes_new_duplicated_reordered_and_the_default() {
// Documented: a new mode starts with the default mode's values and a duplicated one with its
+17 -9
View File
@@ -72,6 +72,9 @@ use kiwi::{Kv, Schema};
/// Largest `canvas.fig` member read, and largest decompressed chunk.
const MAX_CANVAS: u64 = 256 * 1024 * 1024;
const MAX_CHUNK: u64 = 512 * 1024 * 1024;
/// Largest decompressed schema chunk: Figma's schema unpacks to well under 1 MB, so a schema that
/// inflates past this is refused before its definitions are decoded.
const MAX_SCHEMA: u64 = 16 * 1024 * 1024;
/// Largest image member read.
const MAX_IMAGE: u64 = figcraft_doc::ImageData::MAX_BYTES;
/// Total decompressed ZIP image bytes read per import: the native image budget (`IMAGE_BUDGET`) on
@@ -324,7 +327,7 @@ fn decode_canvas(canvas: &[u8]) -> Result<(Schema, Vec<u8>), FormatError> {
let (Some(schema), Some(message)) = (it.next(), it.next()) else {
return Err(corrupt("canvas.fig needs a schema and a message chunk"));
};
let schema = Schema::decode(&inflate(schema)?).map_err(|e| corrupt(e.to_string()))?;
let schema = Schema::decode(&inflate_within(schema, MAX_SCHEMA)?).map_err(|e| corrupt(e.to_string()))?;
Ok((schema, inflate(message)?))
}
@@ -347,18 +350,23 @@ fn chunks(canvas: &[u8]) -> Result<Vec<&[u8]>, FormatError> {
/// Decompress a chunk: zstd (by its magic number), else raw deflate, else zlib.
fn inflate(data: &[u8]) -> Result<Vec<u8>, FormatError> {
fn capped(r: impl Read) -> Option<Vec<u8>> {
inflate_within(data, MAX_CHUNK)
}
/// [`inflate`] refusing a chunk that unpacks to more than `max` bytes.
fn inflate_within(data: &[u8], max: u64) -> Result<Vec<u8>, FormatError> {
let capped = |r: &mut dyn Read| -> Option<Vec<u8>> {
let mut out = Vec::new();
r.take(MAX_CHUNK + 1).read_to_end(&mut out).ok()?;
(out.len() as u64 <= MAX_CHUNK).then_some(out)
}
r.take(max.saturating_add(1)).read_to_end(&mut out).ok()?;
(out.len() as u64 <= max).then_some(out)
};
if data.starts_with(&[0x28, 0xB5, 0x2F, 0xFD]) {
let mut src = data;
let dec = ruzstd::decoding::StreamingDecoder::new(&mut src).map_err(|e| corrupt(format!("zstd: {e}")))?;
return capped(dec).ok_or_else(|| corrupt("damaged or oversized zstd chunk"));
let mut dec = ruzstd::decoding::StreamingDecoder::new(&mut src).map_err(|e| corrupt(format!("zstd: {e}")))?;
return capped(&mut dec).ok_or_else(|| corrupt("damaged or oversized zstd chunk"));
}
capped(flate2::read::DeflateDecoder::new(data))
.or_else(|| capped(flate2::read::ZlibDecoder::new(data)))
capped(&mut flate2::read::DeflateDecoder::new(data))
.or_else(|| capped(&mut flate2::read::ZlibDecoder::new(data)))
.ok_or_else(|| corrupt("damaged or oversized deflate chunk"))
}
+1 -1
View File
@@ -234,7 +234,7 @@ impl<'d> Exporter<'d> {
}
EffectKind::Glass(g) => {
self.warn("glass effects keep only their frost");
f.extend([("type", e("GLASS")), ("radius", fl(g.frost))]);
f.extend([("type", e("GLASS")), ("radius", fl(g.sanitized().frost))]);
}
}
o(f)
+19
View File
@@ -1243,6 +1243,25 @@ fn local_samples_round_trip() {
}
}
#[test]
fn a_glass_frost_is_written_as_it_draws() {
// A frost saved before the 0–100 range (or not a number) is written at what it draws: 100, or
// the default.
for (frost, written) in [(12.0, 12.0), (500.0, Glass::MAX_FROST), (f64::NAN, Glass::default().frost)] {
let mut doc = Document::new("glass");
let page = doc.first_page().unwrap();
let r = doc.create(page, NodeKind::Rectangle, "Pane", 0.0, 0.0, 24.0, 24.0).unwrap();
doc.get_mut(r).unwrap().effects = vec![Effect { kind: EffectKind::Glass(Glass { frost, ..Glass::default() }), visible: true }];
let (bytes, _) = export(&doc, None).unwrap();
let archive = Archive::open(&bytes).unwrap();
let (schema, message) = decode_canvas(&archive.canvas).unwrap();
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
let pane = msg.get("nodeChanges").unwrap().list().iter().find(|nc| nc.get("name").and_then(Kv::str) == Some("Pane")).unwrap();
let radius = pane.get("effects").unwrap().list()[0].get("radius").and_then(Kv::f64).unwrap();
assert_eq!(radius, written, "frost {frost}");
}
}
#[test]
fn a_vector_drawable_export_setting_is_written_as_svg_with_a_warning() {
let mut doc = Document::new("vd");
+29 -5
View File
@@ -6,6 +6,7 @@
//! Everything here reads untrusted bytes: reads are bounds-checked, nesting is capped, array
//! counts can't exceed the bytes left, and the total number of decoded values is capped.
use std::collections::HashMap;
use std::fmt;
/// Deepest struct/message nesting decoded.
@@ -32,6 +33,9 @@ const MAX_VALUES: usize = 32 << 20;
/// Most definitions in a schema, and fields in one definition.
const MAX_DEFS: usize = 10_000;
const MAX_FIELDS: usize = 10_000;
/// Most fields in a schema, every definition together: Figma's own schema has a few thousand, and
/// without a total a schema of [`MAX_DEFS`] wide definitions could hold 100 million field records.
const MAX_SCHEMA_FIELDS: usize = 200_000;
#[derive(Debug, PartialEq, Eq)]
pub struct KiwiError(pub String);
@@ -252,6 +256,7 @@ impl Schema {
}
let mut defs = Vec::with_capacity(count);
let mut raw_types = Vec::new();
let mut total_fields = 0usize;
for _ in 0..count {
let name = r.string()?;
let kind = match r.byte()? {
@@ -261,7 +266,8 @@ impl Schema {
k => return err(format!("schema: unknown definition kind {k}")),
};
let n = r.var_uint()? as usize;
if n > MAX_FIELDS || n > r.left() {
total_fields = total_fields.saturating_add(n);
if n > MAX_FIELDS || n > r.left() || total_fields > MAX_SCHEMA_FIELDS {
return err("schema: too many fields");
}
let mut fields = Vec::with_capacity(n);
@@ -308,7 +314,7 @@ impl Schema {
/// [`Self::decode_message`] within `budget` values (see [`value_budget`]).
pub fn decode_message_within<'s>(&'s self, root: usize, data: &[u8], budget: usize) -> Result<Kv<'s>, KiwiError> {
let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget };
let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget, ids: vec![None; self.defs.len()] };
d.def(root, 0)
}
}
@@ -322,6 +328,10 @@ struct Decoder<'s, 'b> {
schema: &'s Schema,
r: Reader<'b>,
budget: usize,
/// Per definition, the index of the first field with each id (message field ids, enum
/// values), built the first time the definition is decoded: a message can carry many tags
/// against a wide definition, and a scan per tag would multiply the two.
ids: Vec<Option<HashMap<u32, usize>>>,
}
impl<'s> Decoder<'s, '_> {
@@ -330,6 +340,19 @@ impl<'s> Decoder<'s, '_> {
Ok(())
}
/// The first field of definition `i` with id (or enum value) `id`.
fn field_by_id(&mut self, i: usize, id: u32) -> Option<&'s Field> {
let fields = &self.schema.defs.get(i)?.fields;
let ids = self.ids.get_mut(i)?.get_or_insert_with(|| {
let mut m = HashMap::with_capacity(fields.len());
for (j, f) in fields.iter().enumerate() {
m.entry(f.value).or_insert(j);
}
m
});
fields.get(*ids.get(&id)?)
}
fn def(&mut self, i: usize, depth: usize) -> Result<Kv<'s>, KiwiError> {
if depth > MAX_DEPTH {
return err("message: nested too deeply");
@@ -340,7 +363,7 @@ impl<'s> Decoder<'s, '_> {
match d.kind {
Kind::Enum => {
let v = self.r.var_uint()?;
Ok(d.fields.iter().find(|f| f.value == v).map_or(Kv::Uint(u64::from(v)), |f| Kv::Enum(f.name.as_str())))
Ok(self.field_by_id(i, v).map_or(Kv::Uint(u64::from(v)), |f| Kv::Enum(f.name.as_str())))
}
Kind::Struct => {
let mut out = Vec::with_capacity(d.fields.len());
@@ -358,7 +381,7 @@ impl<'s> Decoder<'s, '_> {
out.shrink_to_fit();
return Ok(Kv::Object(out));
}
let f = d.fields.iter().find(|f| f.value == id).ok_or_else(|| KiwiError(format!("message: {} has no field {id}", d.name)))?;
let f = self.field_by_id(i, id).ok_or_else(|| KiwiError(format!("message: {} has no field {id}", d.name)))?;
out.push((f.name.as_str(), self.field(f, depth)?));
}
}
@@ -379,8 +402,9 @@ impl<'s> Decoder<'s, '_> {
if n > self.r.left() {
return err("message: array longer than the data");
}
// The field itself counts, so empty arrays can't pile up for free.
self.spend(1)?;
if ty == Type::Byte {
self.spend(1)?;
return Ok(Kv::Bytes(self.r.take(n)?.to_vec()));
}
self.spend(n)?;
+54
View File
@@ -1179,6 +1179,60 @@ fn scalar_fields_count_against_the_value_budget() {
assert!(sc.decode_message_within(0, &data, 1001).is_ok());
}
#[test]
fn empty_arrays_count_against_the_value_budget() {
// 1000 empty `uint[]` fields: each field is a value even with no elements, so a small budget
// can't be outgrown by piling up empty containers.
let fields = vec![Field { name: "a".into(), ty: Some(Type::Uint), array: true, value: 1 }];
let sc = Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields }] };
let mut data = [1u8, 0].repeat(1000);
data.push(0);
let e = sc.decode_message_within(0, &data, 10).unwrap_err();
assert_eq!(e.0, "message: too many values");
assert!(sc.decode_message_within(0, &data, 1001).is_ok());
}
#[test]
fn message_tags_find_their_field_by_id_in_a_wide_definition() {
// A wide definition: every tag is looked up by id, and the first field with an id wins, as
// before the lookup was indexed.
let mut fields: Vec<Field> = (1..=5000).map(|i| Field { name: format!("f{i}"), ty: Some(Type::Bool), array: false, value: i }).collect();
fields.push(Field { name: "shadow".into(), ty: Some(Type::Bool), array: false, value: 7 });
let sc = Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields }] };
let mut w = encode::Writer::default();
for _ in 0..20_000 {
w.var_uint(5000);
w.byte(1);
}
w.var_uint(7);
w.byte(0);
w.var_uint(0);
let Kv::Object(out) = sc.decode_message(0, &w.0).unwrap() else { panic!() };
assert_eq!(out.len(), 20_001);
assert!(out.iter().take(20_000).all(|(n, v)| *n == "f5000" && *v == Kv::Bool(true)));
assert_eq!(out.last().map(|(n, _)| *n), Some("f7"));
}
#[test]
fn a_schema_is_refused_past_its_total_field_count() {
// 25 definitions of 10 000 fields each: every one is within the per-definition cap, but
// together they pass the schema's total.
let mut w = encode::Writer::default();
w.var_uint(25);
for d in 0..25 {
w.string(&format!("D{d}"));
w.byte(0);
w.var_uint(10_000);
for i in 0..10_000u32 {
w.string("");
w.var_int(0);
w.byte(0);
w.var_uint(i);
}
}
assert_eq!(Schema::decode(&w.0).unwrap_err().0, "schema: too many fields");
}
#[test]
fn hostile_input_is_refused() {
// A message that contains itself, nested past the depth cap.
+5
View File
@@ -1028,6 +1028,11 @@ pub fn write_file_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Resul
let tmp = std::path::PathBuf::from(name);
match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) {
Ok(mut file) => {
// The replacement keeps the destination's permissions from the start, so a private
// file's content is never readable under default ones (the open handle still writes).
if let Ok(old) = std::fs::metadata(path) {
let _ = file.set_permissions(old.permissions());
}
if let Err(e) = file.write_all(bytes) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
+5
View File
@@ -226,11 +226,16 @@ impl<'b> Archive<'b> {
};
let refs = document.get("pages").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
let mut pages = Vec::new();
let mut seen = std::collections::HashSet::new();
for r in refs.iter().take(MAX_PAGES) {
let Some(id) = r.get("_ref").and_then(Value::as_str).and_then(|s| s.strip_prefix("pages/")).filter(|id| valid_id(id)) else {
warnings.push("pages the document lists without a valid reference were skipped".into());
continue;
};
// A page listed twice is read once: a repeated reference must not repeat the unpacking.
if !seen.insert(id) {
continue;
}
match json_member(&mut z, &format!("pages/{id}.json"), max_member, &mut total) {
Ok(page) if page.is_object() => pages.push((id.to_string(), page)),
Ok(_) => warnings.push("pages that aren't JSON objects were skipped".into()),
+7
View File
@@ -35,6 +35,13 @@ fn pages_become_pages_in_document_order() {
assert_eq!(page_names(&doc), ["Beta", "Alpha"]);
}
#[test]
fn a_page_listed_twice_is_imported_once() {
let z = sketch_zip("50.2", 103, document(&["A", "A", "A"]), &[page("A", "Alpha", vec![])], &[]);
let (doc, _) = import(&z, "f").unwrap();
assert_eq!(page_names(&doc), ["Alpha"]);
}
#[test]
fn older_than_48_is_refused() {
let z = sketch_zip("47.1", 95, document(&["P"]), &[page("P", "P", vec![])], &[]);
+14
View File
@@ -123,3 +123,17 @@ fn a_failed_write_leaves_the_destination_alone_and_cleans_up() {
}
let _ = std::fs::remove_dir_all(&d);
}
#[cfg(unix)]
#[test]
fn a_replaced_file_keeps_its_permissions() {
use std::os::unix::fs::PermissionsExt;
let d = dir("mode");
let path = d.join("a.figcraft");
std::fs::write(&path, b"old").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
write_file_atomic(&path, b"new").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
let _ = std::fs::remove_dir_all(&d);
}
+12
View File
@@ -629,14 +629,26 @@ fn distribute(items: &mut [Item], mut open: Vec<usize>, free: f64) {
.collect();
let violation: f64 = sized.iter().map(|(_, want, v)| v - want).sum();
let frozen = |want: f64, v: f64| violation.abs() < 1e-9 || (violation > 0.0 && v > want + 1e-9) || (violation < 0.0 && v < want - 1e-9);
let mut progressed = false;
for (i, want, v) in &sized {
if frozen(*want, *v)
&& let Some(it) = items.get_mut(*i)
{
it.main = *v;
free -= *v;
progressed = true;
}
}
if !progressed {
// Several clampings each under the tolerance added up past it, so none froze: settle
// them all as clamped rather than go round again with nothing changed.
for (i, _, v) in &sized {
if let Some(it) = items.get_mut(*i) {
it.main = *v;
}
}
break;
}
free = free.max(0.0);
open = sized.into_iter().filter(|(_, want, v)| !frozen(*want, *v)).map(|(i, _, _)| i).collect();
}
+21
View File
@@ -447,3 +447,24 @@ fn grid_hostile_values() {
assert!(x.is_finite() && y.is_finite() && w.is_finite() && h.is_finite());
}
}
#[test]
fn fill_children_whose_tiny_clampings_add_up_past_the_tolerance_still_settle() {
// Three fill children sharing 300 px, each with a minimum 5e-10 over its 100 px share: no
// single clamping passes the 1e-9 tolerance, but together they do, so no child froze and the
// distribution loop went round forever without changing anything.
let mut d = Document::new("t");
let p = d.first_page().unwrap();
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 300.0, 100.0).unwrap();
d.get_mut(f).unwrap().layout = Some(AutoLayout { mode: LayoutMode::Horizontal, gap: 0.0, padding: [0.0; 4], ..Default::default() });
let kids: Vec<NodeId> = (0..3).map(|i| d.create(f, NodeKind::Rectangle, "K", 0.0, 0.0, 10.0, 20.0 + i as f64).unwrap()).collect();
for k in &kids {
let n = d.get_mut(*k).unwrap();
n.child_layout.sizing_h = Sizing::Fill;
n.child_layout.min_w = Some(100.0 + 5e-10);
}
layout_frame(&mut d, f, &NoMeasure);
for k in &kids {
assert!((pos(&d, *k).2 - 100.0).abs() < 1e-6, "{:?}", pos(&d, *k));
}
}
+44 -2
View File
@@ -128,7 +128,7 @@ pub fn get(name: &str, args: &Value) -> Result<Value, String> {
if !(args.is_object() || args.is_null()) {
return Err("`arguments` must be an object of strings".into());
}
let mut text = p.template.replace("{verify}", VERIFY);
let mut values: Vec<(&str, String)> = vec![("verify", VERIFY.to_string())];
for a in p.args {
let v = match args.get(a.name) {
Some(Value::String(s)) if !s.trim().is_empty() => s.clone(),
@@ -142,7 +142,49 @@ pub fn get(name: &str, args: &Value) -> Result<Value, String> {
Some(Value::String(_)) => p.defaults.iter().find(|(k, _)| *k == a.name).map(|(_, d)| (*d).to_string()).unwrap_or_default(),
Some(_) => return Err(format!("argument `{}` must be a string", a.name)),
};
text = text.replace(&format!("{{{}}}", a.name), &v);
values.push((a.name, v));
}
// One pass over the template, so an argument's text is never read as `{…}` syntax itself (a
// value holding placeholders used to be expanded again by every later replacement).
let mut text = String::with_capacity(p.template.len());
let mut rest = p.template;
while let Some(open) = rest.find('{') {
text.push_str(rest.get(..open).unwrap_or_default());
let after = rest.get(open..).unwrap_or_default();
let found = after.find('}').and_then(|close| {
let key = after.get(1..close)?;
values.iter().find(|(k, _)| *k == key).map(|(_, v)| (close, v.as_str()))
});
match found {
Some((close, v)) => {
text.push_str(v);
rest = after.get(close + 1..).unwrap_or_default();
}
None => {
text.push('{');
rest = after.get(1..).unwrap_or_default();
}
}
}
text.push_str(rest);
Ok(json!({"description": p.description, "messages": [{"role": "user", "content": {"type": "text", "text": text}}]}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn arguments_are_text_not_template_syntax() {
let text = |args: Value| get("mobile_screen", &args).unwrap()["messages"][0]["content"]["text"].as_str().unwrap().to_string();
let plain = text(json!({"screen": "login"}));
assert!(plain.contains("Design a login screen") && plain.contains("iPhone 16") && plain.ends_with(VERIFY), "{plain}");
assert!(!plain.contains("{screen}") && !plain.contains("{verify}"), "{plain}");
// A value made of placeholders stays as typed, however many there are: it is never
// expanded again by the replacements after it (that multiplied the output without bound).
let bomb = "{device}{brand_color}{verify}".repeat(1000);
let out = text(json!({"screen": bomb.clone()}));
assert!(out.contains(&bomb));
assert!(out.len() < 3 * bomb.len(), "{}", out.len());
}
}
+3 -1
View File
@@ -131,7 +131,9 @@ pub fn outline(path: &BezPath, width: f64, props: &StrokeProps, brush: &Brush) -
let p = preset(&brush.preset)?;
let unit = shape(p.id)?;
let lines = polylines(path)?;
let scale = |t: f64| width * props.profile().map_or(1.0, |pr| pr.scale_at(t));
// Sanitized once: at most 64 points, so each sample's symmetry scan stays cheap.
let profile = props.profile().map(|pr| pr.sanitized());
let scale = |t: f64| width * profile.as_ref().map_or(1.0, |pr| pr.scale_at(t));
let mut out = BezPath::new();
match p.kind {
BrushKind::Stretch => {
+11
View File
@@ -72,3 +72,14 @@ fn a_width_profile_scales_the_brush() {
let (first, last) = (parts.first().unwrap().bounding_box(), parts.last().unwrap().bounding_box());
assert!(last.width() > first.width() * 3.0, "{first:?} {last:?}");
}
#[test]
fn an_unsanitized_profile_is_bounded_before_sampling() {
// 100k points, all past the 64-point cap and out of range: the brush reads the clamped profile.
let raw = WidthProfile { points: (0..100_000).map(|i| figcraft_doc::WidthPoint::new(f64::from(i), 1e300)).collect() };
let b = brush("taper");
let mut p = props(1.0, &b);
p.width_profile = Some(raw);
let out = outline(&line(50.0), 1.0, &p, &b).unwrap();
assert!(out.bounding_box().height() <= 2.0 * WidthProfile::MAX_SCALE);
}
+13 -3
View File
@@ -1,7 +1,7 @@
//! Text: real, selectable text in embedded (subset) fonts, positioned glyph by glyph from the
//! `figcraft_text` layout the renderer uses, with outline fallbacks.
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use std::ops::Range;
use std::sync::Arc;
@@ -56,14 +56,24 @@ pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine
let styles: BTreeSet<usize> = layout.glyphs.iter().map(|g| g.style).chain(layout.decorations.iter().map(|d| d.style)).collect();
// Every run's fills, then the inner shadows over them, then the strokes on top.
let mut stroked = Vec::new();
// Glyphs and decorations are grouped by style in one pass; each style's paths are built (and
// dropped) inside its own iteration, so only one style's paths are held at a time.
let mut decoration_of: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
for (i, d) in layout.decorations.iter().enumerate() {
decoration_of.entry(d.style).or_default().push(i);
}
let mut glyph_of: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
for (i, g) in layout.glyphs.iter().enumerate() {
glyph_of.entry(g.style).or_default().push(i);
}
for k in styles {
let fills = layout.styles.get(k).and_then(|st| st.fills.as_ref()).unwrap_or(&n.fills);
let mut decorations = BezPath::new();
for d in layout.decorations.iter().filter(|d| d.style == k) {
for d in decoration_of.remove(&k).unwrap_or_default().into_iter().filter_map(|i| layout.decorations.get(i)) {
decorations.extend(d.path());
}
let mut outlines = BezPath::new();
for g in layout.glyphs.iter().filter(|g| g.style == k) {
for g in glyph_of.remove(&k).unwrap_or_default().into_iter().filter_map(|i| layout.glyphs.get(i)) {
outlines.extend(g.path());
}
outlines.extend(decorations.iter());
+1 -1
View File
@@ -160,7 +160,7 @@ pub fn effects(n: &Node, frame: EffectFrame<'_>) -> Option<Pixmap> {
fx::mask_by(&mut l, cov.as_ref()?, false);
l
}
EffectKind::Glass(g) => glass::glass(g, frame)?,
EffectKind::Glass(g) => glass::glass(g, frame, glass::RoundedRect::of(n))?,
_ => continue,
};
match out.as_mut() {
+53 -4
View File
@@ -254,16 +254,65 @@ fn glass_edges_bend_split_and_catch_the_light() {
let (rim, middle) = changed(&split, &bent);
assert!(rim > 0 && middle == 0, "dispersion: rim {rim}, middle {middle}");
// The light (from the top left by default) brightens the top-left rim more than the
// bottom-right one, and only near the edge.
// The light (from the top left by default) is a line on the 1-unit bevel: the top-left corner
// catches it all, the bottom-right one (facing away) 0.6 as much, the top side (45° from it)
// 0.574 and the bottom side 0.6 × 0.574, and the top-right corner (side on) none. Further in
// only the faint gradient across the curve shows, and nothing past `depth`.
let (d, f, _) = glass_scene(Glass { light_intensity: 1.0, ..plain });
let lit = render_node(&d, f, 1.0).unwrap();
let gain = |x: u32, y: u32| i32::from(px(&lit, x, y)[1]) - i32::from(px(&flat, x, y)[1]);
assert!(gain(82, 42) > gain(197, 117), "{} vs {}", gain(82, 42), gain(197, 117));
assert!(gain(82, 42) > 40, "{}", gain(82, 42));
let (top_left, bottom_right, top, bottom, top_right) = (gain(80, 40), gain(199, 119), gain(140, 40), gain(140, 119), gain(199, 40));
assert!(
top_left > bottom_right && bottom_right > top && top > bottom && bottom > top_right.abs(),
"{top_left} {bottom_right} {top} {bottom} {top_right}"
);
assert!(top_left > 120 && top_right.abs() <= 2, "{top_left} {top_right}");
assert!(gain(140, 44).abs() <= 13, "4 px in: the gradient only, {}", gain(140, 44));
assert_eq!(gain(140, 80), 0);
}
#[test]
fn splay_turns_the_bend_towards_the_centre() {
// Refraction only, so the bend alone moves the stripes' edges.
let bend = Glass { frost: 0.0, refraction: 1.0, dispersion: 0.0, light_intensity: 0.0, depth: 16.0, ..Glass::default() };
let (d, f, _) = glass_scene(bend.clone());
let edge = render_node(&d, f, 1.0).unwrap();
let (d, f, _) = glass_scene(Glass { splay: 1.0, ..bend });
let centre = render_node(&d, f, 1.0).unwrap();
let diff = |xs: std::ops::Range<u32>, ys: std::ops::Range<u32>| {
let mut most = 0;
for y in ys {
for x in xs.clone() {
most = most.max(px(&edge, x, y).iter().zip(px(&centre, x, y)).map(|(a, b)| a.abs_diff(b)).max().unwrap());
}
}
most
};
// Halfway along the left side the edge's normal and the way from the centre (140, 80) agree:
// nearly the same pixels. In the top-left corner they part, and so does what shows there.
assert!(diff(80..96, 78..82) <= 8, "mid-left: {}", diff(80..96, 78..82));
assert!(diff(80..96, 40..56) > 40, "corner: {}", diff(80..96, 40..56));
}
/// Glass with nothing under it (an export of the pane alone): the light on the bevel still shows,
/// the alpha rising with it, and the flat inside and the outside stay clear.
#[test]
fn glass_over_nothing_still_catches_the_light() {
let mut d = Document::new("glass");
let p = d.first_page().unwrap();
let pane = d.create(p, NodeKind::Rectangle, "Glass", 40.0, 30.0, 120.0, 80.0).unwrap();
let n = d.get_mut(pane).unwrap();
n.fills.clear();
n.effects = vec![Effect { kind: EffectKind::Glass(Glass { light_intensity: 1.0, ..Glass::default() }), visible: true }];
let out = render_area(&d, &[pane], Rect::new(0.0, 0.0, 200.0, 140.0), 1.0).unwrap();
// The top-left corner faces the light (from the top left): white, as opaque as it is bright.
let corner = px(&out, 40, 30);
assert!(corner[3] > 100 && corner.iter().all(|v| v.abs_diff(corner[3]) <= 2), "{corner:?}");
assert!(out.data().chunks_exact(4).all(|q| q[0] <= q[3] && q[1] <= q[3] && q[2] <= q[3]), "premultiplied");
assert_eq!(px(&out, 100, 70), [0; 4]);
assert_eq!(px(&out, 10, 10), [0; 4]);
}
#[test]
fn glass_and_backdrop_blur_survive_bands_and_partial_renders() {
let (d, _, _) = glass_scene(Glass::default());
+5
View File
@@ -1031,6 +1031,11 @@ fn masked_area(ctx: &Ctx, pix: &Pixmap, mask_id: NodeId, content: &[NodeId], xf:
/// Mask `m` drawn as if it weren't a mask into a `w` × `h` pixmap at `xf` (a vector mask: its
/// outline, fully opaque).
fn mask_pixels(ctx: &Ctx, m: &Node, (w, h): (u32, u32), xf: Affine, depth: usize) -> Option<Pixmap> {
// A mask's content is drawn here, not through `draw_layer`, so a chain of masks inside
// masks needs the nesting limit checked here too, or it runs down the stack.
if depth > crate::MAX_EXPORT_DEPTH {
return None;
}
let mut mask_px = Pixmap::new(w, h)?;
let mut m2 = m.clone();
m2.is_mask = false;
+27
View File
@@ -78,6 +78,20 @@ fn decoding_is_capped() {
assert!(images::decode_frames(&huge).is_none());
}
#[test]
fn a_large_partial_frame_plays_when_two_whole_frames_fit() {
// Canvas 4000 x 3000 (48 MB a frame, two fit the cap) with a 3500 x 2800 second frame: the
// decoder's canvas, composited output and partial frame together need more than the cap.
let mut out = Vec::new();
{
let mut enc = GifEncoder::new(&mut out);
enc.set_repeat(Repeat::Infinite).unwrap();
let frame = |w, h, c| Frame::from_parts(RgbaImage::from_pixel(w, h, image::Rgba(c)), 0, 0, Delay::from_numer_denom_ms(100, 1));
enc.encode_frames([frame(4000, 3000, RED), frame(3500, 2800, GREEN)]).unwrap();
}
assert_eq!(images::decode_frames(&out).map(|f| f.len()), Some(2));
}
#[test]
fn the_first_frame_draws_unless_time_moves_on() {
let mut d = Document::new("t");
@@ -98,3 +112,16 @@ fn the_first_frame_draws_unless_time_moves_on() {
d.get_mut(id).unwrap().visible = false;
assert!(images::animation_clock(&d, page, 0.15).is_none(), "hidden layers don't keep the presenter awake");
}
#[test]
fn a_frame_descriptor_bigger_than_the_canvas_is_refused_before_it_is_allocated() {
// A 4 × 4 canvas whose first frame descriptor claims 16,383 × 16,383 (a gigabyte of RGBA):
// the decoder's limits refuse the frame buffer instead of allocating it.
let mut bytes = gif(&[(RED, 100), (GREEN, 100)], 4, 4);
let at = bytes.windows(9).position(|w| w == [0x2c, 0, 0, 0, 0, 4, 0, 4, 0]).expect("an image descriptor for a 4 × 4 frame");
let (w, h) = ((16_383u16).to_le_bytes(), (16_383u16).to_le_bytes());
bytes[at + 5..at + 9].copy_from_slice(&[w[0], w[1], h[0], h[1]]);
let start = std::time::Instant::now();
assert!(images::decode_frames(&bytes).is_none());
assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed());
}

Some files were not shown because too many files have changed in this diff Show More