Merge main into #1462

This commit is contained in:
bflatastic
2026-10-29 09:54:48 +07:00
66 changed files with 2065 additions and 237 deletions
Generated
+81 -8
View File
@@ -253,7 +253,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -972,6 +972,40 @@ dependencies = [
"writeable",
]
[[package]]
name = "craft-keymap"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"egui",
"serde",
"serde_json",
]
[[package]]
name = "craft-menu"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"craft-keymap",
"craft-quit",
"egui",
"muda",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
]
[[package]]
name = "craft-quit"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"dispatch2",
"egui",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
@@ -1034,6 +1068,15 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.8"
@@ -1526,7 +1569,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1596,6 +1639,7 @@ name = "figcraft"
version = "0.12.1"
dependencies = [
"arboard",
"craft-menu",
"craft-update",
"craft-window",
"eframe",
@@ -1646,6 +1690,7 @@ dependencies = [
"postcard",
"serde",
"serde_json",
"sha2",
"thiserror 2.0.21",
]
@@ -2865,6 +2910,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "keyboard-types"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
dependencies = [
"bitflags 2.13.2",
"serde",
]
[[package]]
name = "khronos-egl"
version = "6.0.0"
@@ -3077,6 +3132,24 @@ dependencies = [
"pxfm",
]
[[package]]
name = "muda"
version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03"
dependencies = [
"crossbeam-channel",
"dpi",
"keyboard-types",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"png",
"thiserror 2.0.21",
"windows-sys 0.61.2",
]
[[package]]
name = "naga"
version = "30.0.1"
@@ -3575,7 +3648,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4249,7 +4322,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4306,7 +4379,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4869,10 +4942,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5992,7 +6065,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
+3
View File
@@ -44,6 +44,9 @@ craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392c
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
# Shared dropdown menus: a combo box whose menu grows back after shrinking (craft-libs).
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# The macOS app menu whose Quit (⌘Q) asks the app before quitting, like the window's close button
# (craft-libs; the menu it replaces quits straight through `terminate:` and loses unsaved work).
craft-menu = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
+5 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (.fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -132,6 +132,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
- **2026-10-28** · M6, #1059: `.fig` import copies each image carried inside the message once per blob, shared by every hash that names it, instead of once per hash: a file pointing many hashes at one large blob no longer multiplies it in memory (100 hashes on a 250 MB blob were 25 GB). Regression test. Numbers unchanged.
- **2026-10-28** · M1, #1476: page ruler guides survive save and reopen. A page kept its guides in the page kind while the layer's own `guides` field (frame guides, #70) took that key when a file was read, so a reopened page's guides vanished, and saving it wrote `guides` twice, which then refused to open. Every layer now keeps its guides in the one field, and a page with two `guides` lists opens with both. Regressions in the doc and format crates. Numbers unchanged.
- **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged.
- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
@@ -348,6 +350,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-28 | minor | M6, #1059: `.fig` import copies embedded image blobs once per blob, not once per hash naming them. Numbers unchanged. |
| 2026-10-28 | minor | M1, #1476: page ruler guides survive save and reopen; files written with a page's `guides` key twice open again with both lists. Numbers unchanged. |
| 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged |
| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
+4
View File
@@ -48,6 +48,10 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block
# unsafe, which the workspace forbids). Pinned exactly: review the diff before bumping.
[target.'cfg(target_os = "macos")'.dependencies]
fmv-macos-events = "=0.1.0"
# A macOS app menu whose Quit (⌘Q) asks the app first (sends the root window a close request, the
# same guarded path as the close button); winit's default Quit ends the process through
# `terminate:`, losing unsaved changes without a word (issue #857).
craft-menu = { workspace = true }
[dev-dependencies]
wat = { workspace = true }
+9
View File
@@ -271,6 +271,15 @@ fn main() -> eframe::Result {
options,
Box::new(move |cc| {
with_app(|fig| fig.services.set_egui_context(&cc.egui_ctx));
// winit's own Quit (⌘Q) ends the process through `terminate:` without ever asking the
// app, so unsaved changes are lost with no warning (issue #857). This menu's Quit
// sends the root window a close request instead, and the unsaved-changes question
// asks just as it does for the close button. Failure keeps winit's menu: the app
// runs, but ⌘Q ends it without asking.
#[cfg(target_os = "macos")]
if let Err(e) = craft_menu::mac::install_app_menu(&cc.egui_ctx, "FigCraft") {
log::warn!("app menu: kept winit's Quit, which ends the app without asking: {e}");
}
Ok(Box::new(App {
queue,
ctx_slot,
+23 -18
View File
@@ -2,9 +2,10 @@
//! target: a [`Driver`] reacts to socket events and to the clock the way the native guest's
//! threads do — hello on open (resuming as the same client after a drop), presence held until the
//! welcome and changes made for an earlier connection dropped (the sync client sends again what
//! the server didn't get), presence throttled to about 30 a second (latest wins), a ping when quiet, a connection that goes silent counted as
//! dropped, and reconnects with growing pauses after a live connection drops. The browser layer
//! (`web.rs`) feeds it the WebSocket's events and wakes it at [`Driver::deadline`].
//! the server didn't get), presence throttled to about 30 a second (latest wins), a ping every
//! keep-alive interval, a connection that goes silent counted as dropped, and reconnects with
//! growing pauses after a live connection drops. The browser layer (`web.rs`) feeds it the
//! WebSocket's events and wakes it at [`Driver::deadline`].
//!
//! A join by code ([`GuestConfig::code`]) first asks the server which file the code opens on a
//! connection of its own, then joins that file as with a link; a server that doesn't know the
@@ -36,13 +37,15 @@ struct Conn {
opened: bool,
welcomed: bool,
seen: Seen,
last_sent: Instant,
/// The last ping (or the connection's start): one goes out every keep-alive interval, whatever
/// else is sent, since presence gets no answer and only what the host sends counts as heard.
last_ping: Instant,
last_heard: Instant,
}
impl Conn {
fn new(now: Instant) -> Self {
Self { started: now, opened: false, welcomed: false, seen: Seen::default(), last_sent: now, last_heard: now }
Self { started: now, opened: false, welcomed: false, seen: Seen::default(), last_ping: now, last_heard: now }
}
}
@@ -106,7 +109,7 @@ impl<S: Socket> Driver<S> {
Some(code) => ClientMsg::Resolve(Resolve { protocol: PROTOCOL, client_version: message::client_version(), code: code.clone() }),
None => hello(&self.cfg, &self.resume),
};
if !self.write(&hello, now) {
if !self.write(&hello) {
self.end(Outcome::Retry("the connection dropped".into()), now);
}
}
@@ -173,7 +176,7 @@ impl<S: Socket> Driver<S> {
return;
}
if let Some(p) = self.throttle.offer(p, now)
&& !self.write(&ClientMsg::Presence(p), now)
&& !self.write(&ClientMsg::Presence(p))
{
self.end(Outcome::Retry("the connection dropped".into()), now);
}
@@ -186,7 +189,7 @@ impl<S: Socket> Driver<S> {
if !self.conn.as_ref().is_some_and(|c| c.welcomed) {
return;
}
if !self.write(&msg, now) {
if !self.write(&msg) {
self.end(Outcome::Retry("the connection dropped".into()), now);
}
}
@@ -204,7 +207,7 @@ impl<S: Socket> Driver<S> {
let l = &self.cfg.limits;
let conn = self.conn.as_ref().map(|c| {
if c.opened {
[Some(c.last_sent + l.keepalive), Some(c.last_heard + l.idle_timeout), if c.welcomed { self.throttle.due() } else { None }]
[Some(c.last_ping + l.keepalive), Some(c.last_heard + l.idle_timeout), if c.welcomed { self.throttle.due() } else { None }]
} else {
[Some(c.started + self.cfg.connect_timeout), None, None]
}
@@ -223,7 +226,7 @@ impl<S: Socket> Driver<S> {
}
let l = self.cfg.limits;
let Some(c) = self.conn.as_ref() else { return };
let (opened, started, last_heard, last_sent) = (c.opened, c.started, c.last_heard, c.last_sent);
let (opened, started, last_heard, last_ping) = (c.opened, c.started, c.last_heard, c.last_ping);
if !opened {
if now >= started + self.cfg.connect_timeout {
self.end(Outcome::Retry(format!("no answer from {}", self.cfg.invite.server.display())), now);
@@ -233,21 +236,23 @@ impl<S: Socket> Driver<S> {
if now >= last_heard + l.idle_timeout {
return self.end(Outcome::Retry("the host stopped answering".into()), now);
}
let ok = match self.throttle.take_due(now) {
Some(p) => self.write(&ClientMsg::Presence(p), now),
None if now >= last_sent + l.keepalive => self.write(&ClientMsg::Ping, now),
None => true,
};
let ok = self.throttle.take_due(now).is_none_or(|p| self.write(&ClientMsg::Presence(p)));
let ok = ok && (now < last_ping + l.keepalive || self.ping(now));
if !ok {
self.end(Outcome::Retry("the connection dropped".into()), now);
}
}
/// Send one message; false when it couldn't go.
fn write(&mut self, msg: &ClientMsg, now: Instant) -> bool {
/// Send a ping; false when it couldn't go.
fn ping(&mut self, now: Instant) -> bool {
if let Some(c) = self.conn.as_mut() {
c.last_sent = now;
c.last_ping = now;
}
self.write(&ClientMsg::Ping)
}
/// Send one message; false when it couldn't go.
fn write(&mut self, msg: &ClientMsg) -> bool {
message::encode(msg).is_ok_and(|b| self.sock.send(&b))
}
+34 -2
View File
@@ -142,9 +142,10 @@ fn presence_is_throttled_and_quiet_connections_ping() {
d.tick(due);
assert_eq!(d.sock.sent.last(), Some(&ClientMsg::Presence(cursor(3.0))));
let n = d.sock.sent.len();
// Quiet: a ping after the keep-alive interval (the host answers, which counts as traffic).
// A ping every keep-alive interval from the connection's start, presence or not (the host
// answers, which counts as traffic).
let ping_at = d.deadline().unwrap();
assert_eq!(ping_at, due + Duration::from_secs(15));
assert_eq!(ping_at, t0 + Duration::from_secs(15));
d.tick(ping_at - ms(1));
assert_eq!(d.sock.sent.len(), n, "not yet");
d.message(&encode(&ServerMsg::Pong), ping_at - ms(1));
@@ -152,6 +153,37 @@ fn presence_is_throttled_and_quiet_connections_ping() {
assert_eq!(d.sock.sent.last(), Some(&ClientMsg::Ping));
}
/// A viewer moving the pointer sends presence all the time, which the host doesn't answer: the
/// pings still go out, and the host's pongs keep the connection (before, each presence put the
/// next ping off, nothing was heard for the idle timeout, and the client dropped the connection).
#[test]
fn pings_go_out_while_presence_does_and_the_pongs_keep_the_connection() {
let t0 = Instant::now();
let (mut d, inbox) = start(Script::default(), t0);
d.opened(t0);
d.message(&welcome(), t0);
inbox.drain();
let mut answered = 0;
for i in 1..=900u32 {
let now = t0 + ms(100 * u64::from(i));
d.presence(cursor(f64::from(i)), now);
while let Some(due) = d.deadline().filter(|t| *t <= now) {
d.tick(due);
}
// The host answers every ping (and nothing else) at once.
let pinged = d.sock.sent[answered..].contains(&ClientMsg::Ping);
answered = d.sock.sent.len();
if pinged {
d.message(&encode(&ServerMsg::Pong), now);
}
}
assert!(!d.done());
assert_eq!(d.sock.closes, 0, "90 s of presence and pongs: still connected");
assert!(statuses(&inbox.drain()).is_empty());
let pings = d.sock.sent.iter().filter(|m| **m == ClientMsg::Ping).count();
assert!(pings >= 5, "a ping every 15 s ({pings})");
}
#[test]
fn a_silent_host_counts_as_dropped_and_the_client_reconnects() {
let t0 = Instant::now();
+15 -12
View File
@@ -1,7 +1,8 @@
//! Joining a session: the client end of the transport, with reconnect.
//!
//! A supervisor thread connects, says hello, then writes (presence throttled to about 30 a
//! second, latest wins; a ping when quiet) while a reader thread takes in what the host sends.
//! second, latest wins; a ping every keep-alive interval) while a reader thread takes in what the
//! host sends.
//! When the connection drops after having been live, it reconnects with growing pauses; a
//! refusal or the host ending the session is final.
//!
@@ -280,13 +281,15 @@ fn session(
}
}
};
let mut last_sent = Instant::now();
// A ping every keep-alive interval, whatever else is sent: presence gets no answer, and the
// reader counts only what the host sends.
let mut last_ping = Instant::now();
let hello = hello(cfg, resume);
let mut welcomed = false;
let mut throttle = Throttle::new(PRESENCE_INTERVAL);
let outcome = if send(&mut writer, &hello, &mut last_sent) {
let outcome = if send(&mut writer, &hello) {
loop {
let ping_at = last_sent + cfg.limits.keepalive;
let ping_at = last_ping + cfg.limits.keepalive;
let wake = earliest([throttle.due(), Some(ping_at)]).unwrap_or(ping_at);
let mut out = None;
let mut now_send = Vec::new();
@@ -312,16 +315,17 @@ fn session(
Ok(Cmd::Welcomed(_) | Cmd::Lost(..)) => {}
Err(RecvTimeoutError::Timeout) => {}
}
if !now_send.iter().all(|m| send(&mut writer, m, &mut last_sent)) {
if !now_send.iter().all(|m| send(&mut writer, m)) {
break Outcome::Retry("the connection dropped".into());
}
let now = Instant::now();
let out = out.or_else(|| throttle.take_due(now));
let ok = match out {
Some(p) => send(&mut writer, &ClientMsg::Presence(p), &mut last_sent),
None if now >= last_sent + cfg.limits.keepalive => send(&mut writer, &ClientMsg::Ping, &mut last_sent),
None => true,
};
let ok = out.is_none_or(|p| send(&mut writer, &ClientMsg::Presence(p)));
let ok = ok
&& (now < last_ping + cfg.limits.keepalive || {
last_ping = now;
send(&mut writer, &ClientMsg::Ping)
});
if !ok {
break Outcome::Retry("the connection dropped".into());
}
@@ -335,8 +339,7 @@ fn session(
}
/// Send one message; false when the connection is gone.
fn send(ws: &mut WebSocket<Duplex>, msg: &ClientMsg, last_sent: &mut Instant) -> bool {
*last_sent = Instant::now();
fn send(ws: &mut WebSocket<Duplex>, msg: &ClientMsg) -> bool {
message::encode(msg).is_ok_and(|b| ws.send(Message::Binary(Bytes::from(b))).is_ok())
}
+1 -1
View File
@@ -30,7 +30,7 @@ pub struct NetLimits {
/// answer, so this is longer than `idle_timeout` (a large project on a slow connection can
/// easily need several minutes).
pub request_reply_timeout: Duration,
/// A quiet client pings this often (keeps it under `idle_timeout`).
/// A client pings this often, whatever else it sends (keeps it under `idle_timeout`).
pub keepalive: Duration,
/// Messages queued for one client before it counts as stuck and is disconnected.
pub queue: usize,
+1
View File
@@ -17,6 +17,7 @@ chrono = { workspace = true }
imbl = { workspace = true }
base64 = { workspace = true }
postcard = { workspace = true }
sha2 = { workspace = true }
[dev-dependencies]
figcraft-color = { workspace = true }
+11
View File
@@ -245,6 +245,10 @@ pub enum Entry {
/// [`DevSnapshot`](figcraft_doc::DevSnapshot)).
DevSnapshot(NodeId),
/// An image by content hash (value: `{width, height, bytes}` with the encoded bytes in base64).
/// A key with the shape of a content hash (64 hex chars) must be exactly the lowercase sha256
/// hex `figcraft_format::image_hash` produces for the bytes; every key must be a plain
/// archive member name ([`figcraft_doc::ImageData::is_valid_key`]), and dimensions and byte
/// size are bounded as the entry applies (`image_of` in `docprops.rs`).
Image(String),
/// A voter's ballot in a board's vote (value: the layer ids voted on).
Ballot(String),
@@ -309,6 +313,13 @@ impl DocProp {
}
}
impl Change {
/// Whether this puts or removes an image entry.
pub(crate) fn is_image(&self) -> bool {
matches!(self, Change::Put { entry: Entry::Image(_), .. } | Change::Remove { entry: Entry::Image(_) })
}
}
impl Entry {
/// 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
+63 -2
View File
@@ -8,6 +8,7 @@ use base64::Engine as _;
use figcraft_doc::{Collection, Comment, DevSnapshot, Document, ImageData, Style, Variable};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
use crate::Error;
use crate::change::{Change, DocProp, Entry, Family, Json};
@@ -164,13 +165,68 @@ struct ImageValue {
bytes: String,
}
/// Largest image side: JPEG and GIF size fields are 16-bit; PNG can claim more, but no
/// image this app realistically opens reaches it — an entry claiming a bigger side is
/// refused rather than trusted. Zero is allowed: the format crate stores 0×0 for bytes it
/// couldn't probe (`image_data`).
pub(crate) const MAX_IMAGE_SIDE: u32 = 65_535;
/// Lowercase sha256 hex, the same form `figcraft_format::image_hash` produces.
fn sha256_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
/// The image an entry puts under `key`, which now holds `held`: [`image_of`], and a key that
/// already holds an image can't be given other bytes. A key always names the same bytes
/// (producers key images by their bytes; the REST importer's placeholder keys always hold the
/// same grey picture), so nobody can swap the picture under a key someone else added.
fn image_for(held: Option<&ImageData>, key: &str, value: &Json) -> Result<ImageData, Error> {
let img = image_of(key, value)?;
if held.is_some_and(|old| old.bytes != img.bytes) {
return Err(Error::Invalid(format!("image: {key} already holds other bytes")));
}
Ok(img)
}
/// The JSON value an image entry carries: its size and its encoded bytes in base64.
fn image_json(img: &ImageData) -> Result<Json, Error> {
Json::of(&ImageValue { width: img.width, height: img.height, bytes: base64::engine::general_purpose::STANDARD.encode(img.bytes.as_slice()) })
}
fn image_of(value: &Json) -> Result<ImageData, Error> {
/// The image a change carries, validated as a collaborator's claim (an invalid entry
/// rejects its batch, leaving every replica's store clean): a key that can name an archive
/// member ([`ImageData::is_valid_key`]: a saved file writes it as `images/<key>`), dimensions within
/// [`MAX_IMAGE_SIDE`], decoded bytes within [`ImageData::MAX_BYTES`] (what the importers read;
/// the encoded length is capped before decoding so hostile text can't force a large
/// allocation), and a key that claims a content hash ([`ImageData::claims_content_hash`]) must
/// be exactly the lowercase sha256 of these bytes, the form `figcraft_format::image_hash`
/// writes — a poisoned content-addressed store would show the wrong image everywhere at once
/// (#1086). Other keys (the svg importer's FNV-128 keys, the REST importer's placeholders)
/// claim no content address, so only the name and size rules apply to them.
fn image_of(key: &str, value: &Json) -> Result<ImageData, Error> {
image_within(key, value, ImageData::MAX_BYTES)
}
/// [`image_of`] with `max_bytes` for the decoded size.
pub(crate) fn image_within(key: &str, value: &Json, max_bytes: u64) -> Result<ImageData, Error> {
if !ImageData::is_valid_key(key) {
return Err(Error::Invalid(format!("image: {key:?} can't name an image (1–128 letters, digits, - or _)")));
}
let v: ImageValue = value.read("image")?;
if v.width > MAX_IMAGE_SIDE || v.height > MAX_IMAGE_SIDE {
return Err(Error::Invalid(format!("image: {}×{} passes the {} a side may be", v.width, v.height, MAX_IMAGE_SIDE)));
}
let max_encoded = (max_bytes / 3).saturating_mul(4).saturating_add(4);
if u64::try_from(v.bytes.len()).unwrap_or(u64::MAX) > max_encoded {
return Err(Error::Invalid(format!("image: base64 text longer than {max_encoded} bytes for {max_bytes} of bytes")));
}
let bytes = base64::engine::general_purpose::STANDARD.decode(v.bytes.as_bytes()).map_err(|e| Error::Invalid(format!("image: {e}")))?;
if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > max_bytes {
return Err(Error::Invalid(format!("image: {} bytes pass the {max_bytes} an image may be", bytes.len())));
}
if ImageData::claims_content_hash(key) && key != sha256_hex(&bytes) {
return Err(Error::Invalid(format!("image: the bytes aren't what their key {key} hashes to")));
}
Ok(ImageData { bytes: Arc::new(bytes), width: v.width, height: v.height })
}
@@ -286,7 +342,7 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result<bool, Error>
doc.dev_snapshots.insert(*k, Arc::new(snap));
}
Entry::Image(k) => {
let img = image_of(value)?;
let img = image_for(doc.images.get(k), k, value)?;
doc.images.insert(k.clone(), img);
}
Entry::Ballot(k) => {
@@ -302,6 +358,11 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result<bool, Error>
doc.dev_snapshots.remove(k);
}
Entry::Image(k) => {
// An image a layer or style shows stays: removing it would let other bytes go
// under its key, swapping the picture that layer shows ([`image_for`]).
if figcraft_doc::used_images(doc).contains(k) {
return Err(Error::Invalid(format!("image: {k} is still in use")));
}
doc.images.remove(k);
}
Entry::Ballot(k) => {
+1 -11
View File
@@ -311,17 +311,7 @@ impl Step {
apply_all(&mut work, &mut out, deletes);
// Changes that apply one by one can still fail the batch's checks together (rare): keep
// only those that pass them in order.
if view.clone().apply(&out, Mode::Strict).is_err() {
let mut w = view.clone();
out.retain(|c| {
let mut t = w.clone();
let ok = t.apply(std::slice::from_ref(c), Mode::Strict).is_ok();
if ok {
w = t;
}
ok
});
}
view.clone().apply_kept(&mut out, |_| true)?;
Ok(out)
}
}
+258
View File
@@ -0,0 +1,258 @@
//! Image entries are checked as they apply: bytes carried under a content-hash key must be
//! exactly what that key hashes to (lowercase sha256, the form `figcraft_format::image_hash`
//! produces), a side stays within the 65 535 px bound the implementation enforces and decoded
//! bytes within its 32 MiB cap. Without this a collaborator could poison the shared
//! content-addressed image store or balloon memory (#1086).
use base64::Engine as _;
use figcraft_doc::Document;
use serde_json::json;
use sha2::{Digest as _, Sha256};
use crate::change::{Change, Entry, Json};
use crate::docprops::apply as apply_doc;
use crate::docprops::{MAX_IMAGE_SIDE, image_within};
/// The side bound the implementation enforces.
const MAX_SIDE: u32 = MAX_IMAGE_SIDE;
/// sha256 hex, computed here independently of the implementation under test.
pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
/// An image entry putting `bytes` (`width` × `height`) under `key`.
pub(crate) fn put(key: &str, width: u32, height: u32, bytes: &[u8]) -> Change {
Change::Put {
entry: Entry::Image(key.into()),
value: Json(json!({
"width": width,
"height": height,
"bytes": base64::engine::general_purpose::STANDARD.encode(bytes),
})),
}
}
/// Bytes a collaborator hashed into the key, and different bytes they send under it.
pub(crate) fn forged() -> (String, &'static [u8]) {
(sha256_hex(b"the image the key actually names"), b"someone else's bytes")
}
#[test]
fn bytes_that_do_not_hash_to_their_content_hash_key_are_refused() {
let (key, bytes) = forged();
let mut doc = Document::new("t");
let e = match apply_doc(&mut doc, &put(&key, 2, 1, bytes)) {
Ok(_) => panic!("bytes that don't hash to their key must be refused"),
Err(e) => e,
};
assert!(e.to_string().contains("image"), "not an image refusal: {e}");
assert!(doc.images.is_empty(), "the image store was poisoned under key {key}");
}
/// An uppercase key looks like a content hash but isn't one: producers store the lowercase
/// form, so bytes matching under the uppercase form would file a second copy under a name
/// no honest lookup reaches. Refused (#1086).
#[test]
fn an_uppercase_content_hash_shaped_key_is_refused() {
let bytes = b"honestly keyed, uppercase claim".as_slice();
let mut doc = Document::new("t");
let upper = sha256_hex(bytes).to_uppercase();
let e = match apply_doc(&mut doc, &put(&upper, 2, 1, bytes)) {
Ok(_) => panic!("an uppercase key claims a content address it doesn't have"),
Err(e) => e,
};
assert!(e.to_string().contains("image"), "not an image refusal: {e}");
assert!(doc.images.is_empty(), "the store took an uppercase-keyed copy");
}
#[test]
fn an_image_that_hashes_to_its_key_applies() {
let bytes = b"a real image, honestly keyed".as_slice();
let mut doc = Document::new("t");
apply_doc(&mut doc, &put(&sha256_hex(bytes), 2, 1, bytes)).expect("an honest image must apply unchanged");
let img = doc.images.get(&sha256_hex(bytes)).expect("the image is stored");
assert_eq!((img.width, img.height), (2, 1));
assert_eq!(img.bytes.as_slice(), bytes);
}
#[test]
fn absurd_dimensions_are_refused() {
let bytes = b"honest bytes, dishonest size".as_slice();
let key = sha256_hex(bytes);
for (w, h) in [(MAX_SIDE + 1, 10), (10, MAX_SIDE + 1), (u32::MAX, u32::MAX)] {
let mut doc = Document::new("t");
apply_doc(&mut doc, &put(&key, w, h, bytes))
.err()
.unwrap_or_else(|| panic!("a {w}×{h} image must be refused, but it applied to {:?} keys", doc.images.len()));
assert!(doc.images.is_empty());
}
}
/// Bytes past the cap are refused, and so is base64 text too long to decode within it (checked
/// before decoding). The cap is `figcraft_doc::ImageData::MAX_BYTES` (256 MiB); a small one keeps
/// the test light.
#[test]
fn oversized_image_bytes_are_refused() {
let value = |bytes: &[u8]| json!({"width": 1, "height": 1, "bytes": base64::engine::general_purpose::STANDARD.encode(bytes)});
let at = vec![0u8; 1024];
image_within(&sha256_hex(&at), &Json(value(&at)), 1024).expect("exactly the cap applies");
let past = vec![0u8; 1025];
assert!(image_within(&sha256_hex(&past), &Json(value(&past)), 1024).is_err(), "one byte past the cap");
let long_text = json!({"width": 1, "height": 1, "bytes": "A".repeat(4096)});
let e = image_within("img1", &Json(long_text), 1024).err().map(|e| e.to_string()).unwrap_or_default();
assert!(e.contains("base64 text longer"), "refused before decoding: {e:?}");
}
#[test]
fn a_zero_sized_image_still_applies() {
// The format crate stores 0×0 for bytes it can't probe (`image_data` in `figcraft_format`),
// so a shared document can legitimately carry one; the bytes still have to be honest.
let bytes = b"undecodable bytes kept from a file load".as_slice();
let mut doc = Document::new("t");
apply_doc(&mut doc, &put(&sha256_hex(bytes), 0, 0, bytes)).expect("0×0 means the size is unknown, not absurd");
assert!(doc.images.contains_key(&sha256_hex(bytes)));
}
#[test]
fn a_key_that_is_not_a_content_hash_shape_still_applies() {
// Legacy producers key images without sha256: the svg importer stores FNV-128 keys (32 hex
// chars, re-keyed by sha256 on save), and the REST importer keeps a placeholder under the
// imageRef itself. They are not content-addressed references, so no hash is claimed; the
// dimension and size bounds still hold.
for key in ["rest-image-missing", "hash", &"f".repeat(32), &"ab".repeat(20)] {
let mut doc = Document::new("t");
apply_doc(&mut doc, &put(key, 2, 1, b"placeholder bytes")).unwrap_or_else(|e| panic!("legacy key {key:?} refused: {e}"));
assert!(doc.images.contains_key(key));
let mut big = Document::new("t");
assert!(apply_doc(&mut big, &put(key, MAX_SIDE + 1, 1, b"placeholder bytes")).is_err(), "a legacy key must still bound dimensions");
}
}
/// A key becomes an archive member name when the file is saved (`images/<key>`), so a key that
/// isn't plain letters and digits is refused as it applies: a collaborator could otherwise write
/// `images/../../x` into everyone's saved file, which the next load drops as damaged (#1086).
#[test]
fn a_key_that_cannot_name_an_archive_member_is_refused() {
for key in ["../../evil", "a/b", "x.png", "", "with space", "\\server", &"a".repeat(129)] {
let mut doc = Document::new("t");
assert!(apply_doc(&mut doc, &put(key, 2, 1, b"placeholder bytes")).is_err(), "key {key:?} must be refused");
assert!(doc.images.is_empty(), "key {key:?} reached the image store");
}
}
/// The cap matches what the importers read (`figcraft_doc::ImageData::MAX_BYTES`): an image a
/// document took from a `.fig`, `.sketch` or REST import reaches everyone in a live session.
#[test]
fn an_image_as_large_as_an_import_takes_still_applies() {
let bytes = vec![0u8; 40 << 20];
let mut doc = Document::new("t");
apply_doc(&mut doc, &put(&sha256_hex(&bytes), 0, 0, &bytes)).expect("a 40 MiB image is within what imports read");
assert_eq!(doc.images.len(), 1);
}
/// An image key is content-derived by every producer, so its bytes never change: a collaborator
/// can't swap the picture under a key another one added.
#[test]
fn an_image_cannot_be_replaced_under_its_key() {
let mut doc = Document::new("t");
apply_doc(&mut doc, &put("img1", 2, 1, b"the first bytes")).unwrap();
assert!(apply_doc(&mut doc, &put("img1", 2, 1, b"other bytes")).is_err(), "replaced under the same key");
assert_eq!(doc.images.get("img1").map(|i| i.bytes.as_slice()), Some(b"the first bytes".as_slice()));
apply_doc(&mut doc, &put("img1", 2, 1, b"the first bytes")).expect("the same image again is fine (undo, redo)");
}
/// A snapshot written before these checks may hold an image they refuse: the session still
/// opens, without that image, instead of not opening at all.
#[test]
fn a_snapshot_holding_an_image_the_checks_refuse_still_opens_without_it() {
let mut snapshot = crate::Replica::new(crate::sim_tests::base()).snapshot().unwrap();
let (key, bytes) = forged();
snapshot.push(put(&key, 2, 1, bytes));
let r = crate::Replica::from_snapshot(&snapshot).expect("the snapshot opens");
assert!(!r.document().images.contains_key(&key));
}
/// A server's log written before these checks may hold an image they refuse: a restart replays
/// the edits after it instead of dropping them with the rest of the log.
#[test]
fn a_restart_keeps_the_edits_after_an_image_the_checks_refuse() {
let s = crate::Server::new(crate::sim_tests::base(), "", crate::Limits::default());
let mut r = crate::Server::restore(s.state().unwrap(), "", crate::Limits::default()).unwrap();
let (key, bytes) = forged();
let good = b"an honest image after it".as_slice();
let accepted = |seq, changes| crate::Journal::Accepted(crate::message::Accepted { seq, author: 1, batch_id: seq, changes });
for j in [crate::Journal::Issued { client: 1 }, accepted(1, vec![put(&key, 2, 1, bytes)]), accepted(2, vec![put(&sha256_hex(good), 2, 1, good)])]
{
r.replay(j).expect("the log replays");
}
assert_eq!(r.seq(), 2, "both batches count");
assert!(!r.document().images.contains_key(&key), "the refused image is left out");
assert!(r.document().images.contains_key(&sha256_hex(good)), "the edit after it is kept");
}
/// A server's log written before these checks may give an image other bytes under its key (the
/// apply step allowed it then): a restart replays past that batch, keeping the first bytes and
/// every edit after it, instead of stopping there and dropping the rest of the log.
#[test]
fn a_restart_replays_past_a_logged_image_replacement() {
let s = crate::Server::new(crate::sim_tests::base(), "", crate::Limits::default());
let mut r = crate::Server::restore(s.state().unwrap(), "", crate::Limits::default()).unwrap();
let good = b"an honest image after it".as_slice();
let accepted = |seq, changes| crate::Journal::Accepted(crate::message::Accepted { seq, author: 1, batch_id: seq, changes });
let journal = [
crate::Journal::Issued { client: 1 },
accepted(1, vec![put("img1", 2, 1, b"the first bytes")]),
accepted(2, vec![put("img1", 2, 1, b"other bytes")]),
accepted(3, vec![put(&sha256_hex(good), 2, 1, good)]),
];
for j in journal {
r.replay(j).expect("the log replays");
}
assert_eq!(r.seq(), 3, "every batch counts");
assert_eq!(r.document().images.get("img1").map(|i| i.bytes.as_slice()), Some(b"the first bytes".as_slice()), "the replacement is left out");
assert!(r.document().images.contains_key(&sha256_hex(good)), "the edit after it is kept");
}
/// A document whose rectangle shows `img1` (the first bytes), and that rectangle.
fn in_use() -> (Document, figcraft_doc::NodeId) {
let mut doc = Document::new("t");
let page = doc.first_page().unwrap();
let id = doc.create(page, figcraft_doc::NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
doc.get_mut(id).unwrap().fills = vec![figcraft_color::Paint::image("img1", figcraft_color::ImageScaleMode::Fill)];
apply_doc(&mut doc, &put("img1", 2, 1, b"the first bytes")).unwrap();
(doc, id)
}
/// An image a layer or style still shows can't be removed: removing it and putting other bytes
/// under its key would swap the picture that layer shows, which giving the key other bytes
/// directly can't do (#1086).
#[test]
fn an_image_in_use_cannot_be_removed() {
let (mut doc, _) = in_use();
let remove = Change::Remove { entry: Entry::Image("img1".into()) };
assert!(apply_doc(&mut doc, &remove).is_err(), "removed while a layer shows it");
assert!(apply_doc(&mut doc, &put("img1", 2, 1, b"other bytes")).is_err());
assert_eq!(doc.images.get("img1").map(|i| i.bytes.as_slice()), Some(b"the first bytes".as_slice()));
}
/// A batch that first stops using an image, then removes it (the order a diff makes), applies.
#[test]
fn an_image_no_longer_in_use_can_be_removed() {
let (doc, id) = in_use();
let mut r = crate::Replica::new(doc);
let batch = [Change::Delete { id }, Change::Remove { entry: Entry::Image("img1".into()) }];
r.apply(&batch, crate::Mode::Strict).expect("the layer goes first, then its image");
assert!(r.document().images.is_empty());
}
/// Only image entries are left out of saved history: a snapshot whose other changes don't apply
/// is still an error, not a document missing them.
#[test]
fn a_snapshot_with_a_broken_layer_change_still_fails_to_open() {
let mut snapshot = crate::Replica::new(crate::sim_tests::base()).snapshot().unwrap();
// A property of a layer that isn't there: an error when applied (unlike deleting it).
let missing = figcraft_doc::NodeId(u64::MAX);
snapshot.push(Change::Set { id: missing, prop: crate::change::Prop::Name, value: Json(json!("x")) });
assert!(crate::Replica::from_snapshot(&snapshot).is_err());
}
+149
View File
@@ -174,3 +174,152 @@ fn a_journal_with_a_gap_is_refused() {
assert_eq!(errors.len(), 1, "the batch after the gap");
assert_eq!(r.seq(), 0);
}
/// Restores `state` with one more batch from another client in its log (and in its snapshot,
/// `in_snapshot`, as a server that accepted it saved it), then has `a` resume into it.
fn resume_past(s: &Server, a: &mut Client, batch: Vec<crate::Change>, in_snapshot: bool) -> Server {
let mut state = s.state().unwrap();
if in_snapshot {
state.snapshot.retain(|c| {
!batch.iter().any(|b| matches!((b, c), (crate::Change::Put { entry: x, .. }, crate::Change::Put { entry: y, .. }) if x == y))
});
state.snapshot.extend(batch.iter().cloned());
}
state.seq += 1;
state.log.push(message::Accepted { seq: state.seq, author: a.id() + 1, batch_id: 1, changes: batch });
let mut r = Server::restore(state, "", Limits::default()).unwrap();
a.disconnect();
let ClientMsg::Hello(h) = a.reconnect(hello()) else { panic!("not a hello") };
let (_, out) = r.join(h).unwrap();
deliver(a, &out);
r
}
/// A saved log may hold an image a newer server refuses: a client resuming from before it still
/// catches up, without that image (#1086).
#[test]
fn a_client_resuming_past_a_refused_image_in_the_saved_log_catches_up() {
let mut s = Server::new(base(), "", Limits::default());
let mut a = join_as(&mut s, Access::Edit);
let (key, bytes) = crate::image_tests::forged();
let r = resume_past(&s, &mut a, vec![crate::image_tests::put(&key, 2, 1, bytes)], false);
assert!(!a.view().document().images.contains_key(&key));
assert_eq!(a.view(), r.replica());
}
/// A saved log may give an image other bytes under a key the resuming client already holds (an
/// older server allowed it): the client still catches up, to what the server holds (#1086).
#[test]
fn a_client_resuming_past_a_replaced_image_in_the_saved_log_catches_up() {
let mut s = Server::new(base(), "", Limits::default());
let mut a = join_as(&mut s, Access::Edit);
let m = edit(&mut a, |d| {
d.images.insert("img1".into(), figcraft_doc::ImageData { bytes: std::sync::Arc::new(b"the first bytes".to_vec()), width: 2, height: 1 });
});
send(&mut s, a.id(), m, &mut [&mut a]);
let r = resume_past(&s, &mut a, vec![crate::image_tests::put("img1", 2, 1, b"other bytes")], true);
assert_eq!(a.view(), r.replica());
}
/// `a` adds a rectangle showing image `img1`; returns the rectangle.
fn add_image_layer(s: &mut Server, a: &mut Client) -> figcraft_doc::NodeId {
let page = s.document().first_page().unwrap();
let mut id = None;
let m = edit(a, |d| {
let r = d.create(page, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
d.get_mut(r).unwrap().fills = vec![figcraft_color::Paint::image("img1", figcraft_color::ImageScaleMode::Fill)];
d.images.insert("img1".into(), figcraft_doc::ImageData { bytes: std::sync::Arc::new(b"the first bytes".to_vec()), width: 2, height: 1 });
id = Some(r);
});
send(s, a.id(), m, &mut [a]);
id.unwrap()
}
/// Undoing an image's insertion still works with images in use protected from removal (#1086).
#[test]
fn undoing_an_image_layer_still_works() {
let mut s = Server::new(base(), "", Limits::default());
let mut a = join_as(&mut s, Access::Edit);
let id = add_image_layer(&mut s, &mut a);
for m in a.undo(&NoRecompute).unwrap() {
send(&mut s, a.id(), m, &mut [&mut a]);
}
assert!(s.document().node(id).is_none(), "the layer is gone");
assert_eq!(a.view(), s.replica());
}
/// A server's log written before the image checks may remove an image a layer still shows: a
/// restart replays past it, keeping the image and every edit after it (#1086).
#[test]
fn a_restart_replays_past_a_logged_removal_of_an_image_in_use() {
let mut s = Server::new(base(), "", Limits::default());
s.journaling(true);
let mut a = join_as(&mut s, Access::Edit);
add_image_layer(&mut s, &mut a);
let saved = s.state().unwrap();
let page = s.document().first_page().unwrap();
let mut journal = s.take_journal();
let base_seq = saved.seq;
let next = |n: u64| base_seq + n;
journal.push(Journal::Accepted(message::Accepted {
seq: next(1),
author: a.id(),
batch_id: 90,
changes: vec![crate::Change::Remove { entry: crate::change::Entry::Image("img1".into()) }],
}));
let rename = crate::Change::Set { id: page, prop: crate::change::Prop::Name, value: crate::change::Json(serde_json::json!("Renamed")) };
journal.push(Journal::Accepted(message::Accepted { seq: next(2), author: a.id(), batch_id: 91, changes: vec![rename] }));
let mut r = Server::restore(saved, "", Limits::default()).unwrap();
for j in journal {
r.replay(j).expect("the log replays");
}
assert_eq!(r.seq(), next(2));
assert!(r.document().images.contains_key("img1"), "the image a layer shows stays");
assert_eq!(r.document().node(page).unwrap().name, "Renamed", "the edit after it is kept");
}
/// What a resuming client is told to catch up with.
fn catch_up_of(r: &mut Server, a: &mut Client) -> message::CatchUp {
a.disconnect();
let ClientMsg::Hello(h) = a.reconnect(hello()) else { panic!("not a hello") };
let (id, out) = r.join(h).unwrap();
out.into_iter()
.find_map(|o| match o {
Outgoing::To(t, ServerMsg::Welcome { catch_up, .. }) if t == id => Some(catch_up),
_ => None,
})
.expect("a welcome")
}
/// A restart that left an image out of the saved history (an older server accepted it) catches
/// every resuming client up with the snapshot, even one at the same sequence number: a client
/// that kept the image from before would otherwise go on showing what the session no longer
/// has (#1086).
#[test]
fn a_resume_after_history_was_left_out_catches_up_with_the_snapshot() {
let mut s = Server::new(base(), "", Limits::default());
let mut a = join_as(&mut s, Access::Edit);
let mut state = s.state().unwrap();
let (key, bytes) = crate::image_tests::forged();
state.snapshot.push(crate::image_tests::put(&key, 2, 1, bytes));
let mut r = Server::restore(state, "", Limits::default()).unwrap();
assert!(matches!(catch_up_of(&mut r, &mut a), message::CatchUp::Snapshot(_)), "left out on restore");
// Left out on replay.
let mut s = Server::new(base(), "", Limits::default());
let mut a = join_as(&mut s, Access::Edit);
let state = s.state().unwrap();
let mut r = Server::restore(state.clone(), "", Limits::default()).unwrap();
let batch = vec![crate::image_tests::put(&key, 2, 1, bytes)];
r.replay(Journal::Accepted(message::Accepted { seq: state.seq + 1, author: a.id(), batch_id: 90, changes: batch })).unwrap();
assert!(matches!(catch_up_of(&mut r, &mut a), message::CatchUp::Snapshot(_)), "left out on replay");
}
/// A restart that left nothing out keeps resuming clients on the cheaper delta.
#[test]
fn a_resume_after_a_clean_restart_catches_up_with_the_log() {
let mut s = Server::new(base(), "", Limits::default());
let mut a = join_as(&mut s, Access::Edit);
let mut r = Server::restore(s.state().unwrap(), "", Limits::default()).unwrap();
assert!(matches!(catch_up_of(&mut r, &mut a), message::CatchUp::Delta(_)));
}
+2
View File
@@ -136,6 +136,8 @@ mod entry_ids_tests;
#[cfg(test)]
mod frac_tests;
#[cfg(test)]
mod image_tests;
#[cfg(test)]
mod journal_tests;
#[cfg(test)]
mod merge_list_tests;
+37 -2
View File
@@ -72,12 +72,47 @@ impl Replica {
/// Rebuild a replica from a [`snapshot`](Self::snapshot).
pub fn from_snapshot(changes: &[Change]) -> Result<Replica, Error> {
Self::from_snapshot_leaving_out(changes).map(|(r, _)| r)
}
/// [`from_snapshot`](Self::from_snapshot), and how many image entries it left out: a
/// snapshot saved before the image checks may hold one they refuse, and opens without it.
pub(crate) fn from_snapshot_leaving_out(changes: &[Change]) -> Result<(Replica, usize), Error> {
let mut r = Replica { doc: Document::without_pages(""), positions: Positions::new() };
r.apply(changes, Mode::Strict)?;
let mut changes = changes.to_vec();
let left_out = r.apply_kept(&mut changes, Change::is_image)?;
if r.doc.pages.is_empty() {
return Err(Error::Invalid("a snapshot needs a page".into()));
}
Ok(r)
Ok((r, left_out))
}
/// Apply `changes` strictly. When they don't apply together — history written under older
/// rules, or changes that apply one by one but fail the batch's checks together — apply those
/// that do, one by one in order, and leave the rest out of `changes`, as long as `may_leave_out`
/// allows it (otherwise that change's error). Returns how many were left out.
pub(crate) fn apply_kept(&mut self, changes: &mut Vec<Change>, may_leave_out: impl Fn(&Change) -> bool) -> Result<usize, Error> {
let mut all = self.clone();
if all.apply(changes, Mode::Strict).is_ok() {
*self = all;
return Ok(0);
}
let given = changes.len();
let mut kept = Vec::with_capacity(given);
for c in changes.drain(..) {
let mut t = self.clone();
match t.apply(std::slice::from_ref(&c), Mode::Strict) {
Ok(_) => {
*self = t;
kept.push(c);
}
Err(e) if !may_leave_out(&c) => return Err(e),
Err(_) => {}
}
}
let left_out = given - kept.len();
*changes = kept;
Ok(left_out)
}
/// The whole state as changes that build it from nothing (pages and their trees in order,
+22 -3
View File
@@ -77,6 +77,10 @@ pub struct Server {
signals: BTreeSet<u32>,
/// Who spotlights now (a newcomer is told).
spotlight: Option<u32>,
/// Saved history this server restored or replayed left image entries out (or may give a
/// resuming client other bytes under a key it holds): every resuming client catches up with
/// the snapshot, not the log.
resync: bool,
}
/// The most client ids a session remembers for reconnecting (the oldest disconnected ones go
@@ -127,6 +131,7 @@ impl Server {
journal: None,
signals: BTreeSet::new(),
spotlight: None,
resync: false,
}
}
@@ -148,7 +153,7 @@ impl Server {
/// reconnect with their ids). A log that doesn't lead up to the sequence number is dropped
/// (clients then catch up with a snapshot).
pub fn restore(state: SessionState, token: impl Into<String>, limits: Limits) -> Result<Server, Error> {
let replica = Replica::from_snapshot(&state.snapshot)?;
let (replica, left_out) = Replica::from_snapshot_leaving_out(&state.snapshot)?;
let mut server = Server::new(replica.document().clone(), token, limits);
server.state = replica;
server.session = state.session;
@@ -158,6 +163,12 @@ impl Server {
server.deleted = state.deleted;
server.removed = state.removed;
let contiguous = state.log.windows(2).all(|w| matches!(w, [a, b] if a.seq + 1 == b.seq));
// History saved before the image checks may hold image entries they refuse. The snapshot
// opens without them, and a saved log putting images may give a resuming client other
// bytes under a key it holds, which this state can't check: either way resuming clients
// catch up with the snapshot.
let puts_images = state.log.iter().flat_map(|a| &a.changes).any(Change::is_image);
server.resync = left_out > 0 || puts_images;
if contiguous && state.log.last().is_none_or(|a| a.seq == state.seq) {
for a in state.log {
server.remember(a);
@@ -198,10 +209,16 @@ impl Server {
Ok(())
}
Journal::Accepted(acc) if acc.seq <= self.seq => Ok(()),
Journal::Accepted(acc) => {
Journal::Accepted(mut acc) => {
if acc.seq != self.seq + 1 {
return Err(Error::Protocol(format!("batch {} after {}", acc.seq, self.seq)));
}
// A log written before the image checks may hold an image entry they refuse: the
// batch replays without it, so the edits after it aren't dropped with the rest of
// the log, and resuming clients catch up with the snapshot.
if acc.changes.iter().any(Change::is_image) && self.state.clone().apply_kept(&mut acc.changes, Change::is_image)? > 0 {
self.resync = true;
}
self.accept(acc.author, self.seq, &acc.changes)?;
self.issued.entry(acc.author).or_default().note(acc.batch_id, false);
self.next_client = self.next_client.max(acc.author.saturating_add(1));
@@ -284,7 +301,9 @@ impl Server {
// The log holds every batch after `floor`.
let floor = self.log.front().map_or(self.seq, |a| a.seq.saturating_sub(1));
let catch_up = match resume {
Some(r) if r.seq <= self.seq && r.seq >= floor => CatchUp::Delta(self.log.iter().filter(|a| a.seq > r.seq).cloned().collect()),
Some(r) if !self.resync && r.seq <= self.seq && r.seq >= floor => {
CatchUp::Delta(self.log.iter().filter(|a| a.seq > r.seq).cloned().collect())
}
_ => CatchUp::Snapshot(self.state.snapshot().map_err(|e| refuse(&e.to_string()))?),
};
let (id, secret) = match resume {
+3 -1
View File
@@ -104,13 +104,15 @@ fn place_keeps_the_local_transform_and_refuses_bad_moves() {
assert!(d.validate().is_empty());
}
/// A page-less document takes pages inserted with their own ids, refuses a duplicate or a layer
/// that isn't a page, and repairs into a file with a page.
#[test]
fn pages_can_be_inserted_onto_a_page_less_base() {
let mut d = Document::without_pages("snapshot");
assert!(d.pages.is_empty() && d.node_count() == 0);
let p = Node::new(
NodeId(C3 | 1),
NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: vec![], prototype: Default::default(), guides: vec![] },
NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: vec![], prototype: Default::default() },
"P",
0.0,
0.0,
+61
View File
@@ -0,0 +1,61 @@
//! Ruler guides survive a save and reopen (#1476).
use crate::{Document, Guide, Node, NodeKind};
/// A guide: vertical (at an x offset) or horizontal (at a y offset).
fn guide(vertical: bool, offset: f64) -> Guide {
Guide { vertical, offset }
}
/// A page's guides are written once and read back onto the page.
#[test]
fn page_guides_round_trip_through_json() {
let mut d = Document::new("a");
let page = d.first_page().unwrap();
d.node_mut(page).unwrap().guides_mut().push(guide(true, 120.0));
let json = serde_json::to_string(d.node(page).unwrap()).unwrap();
assert_eq!(json.matches("\"guides\"").count(), 1, "{json}");
let back: Node = serde_json::from_str(&json).unwrap();
assert_eq!(back.guides(), &[guide(true, 120.0)]);
assert_eq!(back, *d.node(page).unwrap());
}
/// A frame's guides round trip too, and a layer without guides writes no `guides` key.
#[test]
fn frame_guides_round_trip_and_none_are_left_out() {
let mut d = Document::new("a");
let page = d.first_page().unwrap();
let frame = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
for id in [page, frame] {
let json = serde_json::to_value(d.node(id).unwrap()).unwrap();
assert!(json.get("guides").is_none(), "{json}");
}
d.node_mut(frame).unwrap().guides_mut().push(guide(false, 40.0));
let json = serde_json::to_string(d.node(frame).unwrap()).unwrap();
assert_eq!(json.matches("\"guides\"").count(), 1, "{json}");
let back: Node = serde_json::from_str(&json).unwrap();
assert_eq!(back, *d.node(frame).unwrap());
}
/// A page saved with two `guides` keys reads both lists, in file order, and writes them once.
#[test]
fn a_page_written_with_two_guides_lists_opens_with_both() {
// How a page was saved after its file had been reopened: the page kind's list (guides added
// since), then the layer's (the ones the file had).
let json = r#"{"id": 1, "name": "Page 1", "type": "page", "guides": [{"vertical": true, "offset": 5.0}],
"width": 0.0, "height": 0.0, "guides": [{"vertical": false, "offset": 7.0}]}"#;
let n: Node = serde_json::from_str(json).unwrap();
assert!(matches!(n.kind, NodeKind::Page { .. }));
assert_eq!(n.guides(), &[guide(true, 5.0), guide(false, 7.0)]);
let again: Node = serde_json::from_str(&serde_json::to_string(&n).unwrap()).unwrap();
assert_eq!(again.guides(), n.guides());
}
/// A `guides` value that isn't a list of guides is still refused.
#[test]
fn a_bad_guides_list_is_still_an_error() {
for bad in [r#"3"#, r#"[{"vertical": "no"}]"#, r#"{"offset": 1}"#] {
let json = format!(r#"{{"id": 1, "name": "F", "type": "frame", "width": 1.0, "height": 1.0, "guides": {bad}}}"#);
assert!(serde_json::from_str::<Node>(&json).is_err(), "{bad}");
}
}
+20
View File
@@ -85,3 +85,23 @@ fn validate_is_linear_in_a_wide_parent() {
doc.get_mut(page).unwrap().children.retain(|c| *c != kid);
assert!(doc.validate().iter().any(|m| m.contains("not listed in parent")));
}
/// Repair lists every unlisted page once, by set lookups rather than a scan of the page list per
/// page (#1078: a file with many pages made repair quadratic).
#[test]
fn repair_relists_many_unlisted_pages_once_each() {
let mut doc = Document::new("d");
for i in 0..5_000 {
doc.add_page(&format!("P{i}"));
}
let all: Vec<_> = doc.pages.clone();
// Half the pages fall off the list.
doc.pages.retain(|p| p.0 % 2 == 0);
doc.repair();
let mut listed = doc.pages.clone();
listed.sort_unstable();
let mut want = all;
want.sort_unstable();
assert_eq!(listed, want, "every page is listed again, once");
assert!(doc.validate().is_empty());
}
+27 -8
View File
@@ -161,6 +161,26 @@ pub struct ImageData {
pub height: u32,
}
impl ImageData {
/// Largest encoded image a document takes from outside: an image member of a `.fig` or
/// `.sketch` file, an image file a REST import reads, an image entry from a live session.
pub const MAX_BYTES: u64 = 256 * 1024 * 1024;
/// Whether `key` claims to be a content hash: 64 hex digits, the shape of the sha256 hex
/// `figcraft_format::image_hash` makes. Bytes stored under such a key must hash to it.
pub fn claims_content_hash(key: &str) -> bool {
key.len() == 64 && key.bytes().all(|b| b.is_ascii_hexdigit())
}
/// Whether `key` can name an image: 1–128 ASCII letters, digits, `-` and `_` (content hashes,
/// the svg importer's FNV keys, the REST importer's `rest-image-missing` placeholder). A key
/// becomes an archive member name (`images/<key>`) when the file is saved, so only these are
/// read back from a file or taken from a collaborator: never `/`, `.` or other path text.
pub fn is_valid_key(key: &str) -> bool {
(1..=128).contains(&key.len()) && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
}
/// A whole design file.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Document {
@@ -407,13 +427,8 @@ impl Document {
/// Add a page at the end.
pub fn add_page(&mut self, name: &str) -> NodeId {
let id = self.alloc_id();
let node = Node::new(
id,
NodeKind::Page { background: Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default(), guides: Vec::new() },
name,
0.0,
0.0,
);
let node =
Node::new(id, NodeKind::Page { background: Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default() }, name, 0.0, 0.0);
self.nodes.insert(id, Arc::new(node));
self.dirty.touch(id);
self.pages.push(id);
@@ -1143,8 +1158,10 @@ impl Document {
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);
// 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() && !self.pages.contains(k) && Some(*k) != store {
if n.is_page() && Some(*k) != store && listed.insert(*k) {
self.pages.push(*k);
}
}
@@ -1258,6 +1275,8 @@ mod diff_tests;
#[cfg(test)]
mod effects_tests;
#[cfg(test)]
mod guides_tests;
#[cfg(test)]
mod handoff_tests;
#[cfg(test)]
mod ids_tests;
+1 -1
View File
@@ -260,7 +260,7 @@ impl Document {
let id = self.alloc_id();
let node = Node::new(
id,
NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default(), guides: Vec::new() },
NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default() },
STORE_NAME,
0.0,
0.0,
+79 -12
View File
@@ -66,6 +66,9 @@ pub enum PropDef {
}
/// What a node is, with per-type data.
// Text sets the size; the lint counts the gap to the next largest variant, which grew only because
// Page's guides moved to the node (#1476).
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum NodeKind {
@@ -76,8 +79,6 @@ pub enum NodeKind {
flows: Vec<crate::proto::Flow>,
#[serde(default)]
prototype: crate::proto::PrototypeSettings,
#[serde(default)]
guides: Vec<Guide>,
},
Frame,
Group,
@@ -472,8 +473,8 @@ pub struct Node {
pub mask_type: MaskType,
#[serde(default, skip_serializing_if = "is_default")]
pub grids: Vec<LayoutGrid>,
/// A frame's ruler guides, in its own coordinates (a page's are in [`NodeKind::Page`]).
#[serde(default, skip_serializing_if = "is_default")]
/// A page's or frame's ruler guides, in its own coordinates (see [`guides_key`]).
#[serde(flatten, with = "guides_key")]
pub guides: Vec<Guide>,
#[serde(default, skip_serializing_if = "is_default")]
pub exports: Vec<ExportSetting>,
@@ -578,6 +579,77 @@ fn is_identity(v: &Affine) -> bool {
*v == identity()
}
/// A layer's `guides` key: left out when there are none, and every `guides` list the layer has is
/// read. Pages used to keep their guides in [`NodeKind::Page`] while the layer field took the
/// `guides` key on reading, so a reopened page lost its guides, and saving it then wrote two
/// `guides` keys, which a plain field refuses (#1476). Both lists are kept, in file order.
mod guides_key {
use super::Guide;
use serde::de::{IgnoredAny, MapAccess, Visitor};
use serde::ser::SerializeMap;
use serde::{Deserialize, Deserializer, Serializer};
use std::fmt;
const KEY: &str = "guides";
/// Write `guides` as the layer's `guides` key, or nothing when there are none.
pub fn serialize<S: Serializer>(guides: &[Guide], s: S) -> Result<S::Ok, S::Error> {
let mut map = s.serialize_map(None)?;
if !guides.is_empty() {
map.serialize_entry(KEY, guides)?;
}
map.end()
}
/// Read every `guides` list among the layer's fields, joined in file order.
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<Guide>, D::Error> {
d.deserialize_map(Lists)
}
/// Reads the `guides` lists of a layer's fields and skips the other fields.
struct Lists;
impl<'de> Visitor<'de> for Lists {
type Value = Vec<Guide>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a layer's fields")
}
/// Every `guides` value joined, in order; a malformed one is an error.
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Vec<Guide>, A::Error> {
let mut guides = Vec::new();
while let Some(key) = map.next_key::<Key>()? {
if key.0 {
guides.extend(map.next_value::<Vec<Guide>>()?);
} else {
map.next_value::<IgnoredAny>()?;
}
}
Ok(guides)
}
}
/// Whether a field name is `guides` (without copying the name).
struct Key(bool);
impl<'de> Deserialize<'de> for Key {
/// Compare a field name with `guides` as it is read.
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct Name;
impl Visitor<'_> for Name {
type Value = Key;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a field name")
}
/// `guides` or another field.
fn visit_str<E: serde::de::Error>(self, s: &str) -> Result<Key, E> {
Ok(Key(s == KEY))
}
}
d.deserialize_identifier(Name)
}
}
}
impl Node {
/// A node with Figma's defaults for its kind.
pub fn new(id: NodeId, kind: NodeKind, name: impl Into<String>, width: f64, height: f64) -> Self {
@@ -712,17 +784,12 @@ impl Node {
/// The ruler guides on this page or frame, in its own coordinates.
pub fn guides(&self) -> &[Guide] {
match &self.kind {
NodeKind::Page { guides, .. } => guides,
_ => &self.guides,
}
&self.guides
}
/// The ruler guides on this page or frame, to edit.
pub fn guides_mut(&mut self) -> &mut Vec<Guide> {
match &mut self.kind {
NodeKind::Page { guides, .. } => guides,
_ => &mut self.guides,
}
&mut self.guides
}
/// Children in paint order, bottom to top: the child list, reversed in an auto layout frame
+8 -1
View File
@@ -39,7 +39,14 @@ pub fn show(s: &mut Session, command: &str, params: &Value) -> Result<()> {
ds.text_edit = text_edit;
match result {
Ok(_) if !Arc::ptr_eq(&ds.doc, &base) => {
normalize(ds.doc_mut(), &base);
// A panic while normalising shows no preview (#1085).
if let Err(e) = guard::run(command, || {
normalize(ds.doc_mut(), &base);
Ok(())
}) {
ds.doc = base;
return Err(e);
}
ds.text_preview = Some(base);
ds.revision += 1;
Ok(())
+1 -1
View File
@@ -351,7 +351,7 @@ fn link_instances(doc: &mut Document) {
/// Largest image file read for a REST API file JSON import.
#[cfg(not(target_arch = "wasm32"))]
const MAX_IMAGE_FILE: u64 = 256 * 1024 * 1024;
const MAX_IMAGE_FILE: u64 = figcraft_doc::ImageData::MAX_BYTES;
/// Image bytes for a REST API file JSON's `imageRef`: `images/<imageRef>[.png|.jpg|…]` next to
/// the file.
+5 -3
View File
@@ -33,6 +33,7 @@ fn page_target(s: &Session, cmd: &str, p: &Value) -> crate::Result<NodeId> {
Ok(target)
}
/// The page and ruler guide commands.
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("page.add", "Add page", "{name?} (a name starting with a hyphen adds a divider)", true, |s, p| {
@@ -106,8 +107,10 @@ pub fn specs() -> Vec<CommandSpec> {
copies.extend(doc.subtree(*c).into_iter().zip(doc.subtree(*copy)));
}
// The copy keeps the page's colour, flows (pointing at the copied frames), prototype settings and guides.
if let NodeKind::Page { background, flows, prototype, guides } = doc.get(src)?.kind.clone()
&& let NodeKind::Page { background: b, flows: f, prototype: pr, guides: g } = &mut doc.get_mut(np)?.kind
let guides = doc.get(src)?.guides.clone();
doc.get_mut(np)?.guides = guides;
if let NodeKind::Page { background, flows, prototype } = doc.get(src)?.kind.clone()
&& let NodeKind::Page { background: b, flows: f, prototype: pr } = &mut doc.get_mut(np)?.kind
{
*b = background;
*f = flows
@@ -115,7 +118,6 @@ pub fn specs() -> Vec<CommandSpec> {
.filter_map(|fl| copies.iter().find(|(old, _)| *old == fl.start).map(|(_, new)| Flow { start: *new, ..fl }))
.collect();
*pr = prototype;
*g = guides;
}
// Keep the new page right after the source.
if let Some(si) = doc.pages.iter().position(|x| *x == src) {
+1 -1
View File
@@ -161,7 +161,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"proto.linkNotice",
"Notice before leaving for a link",
"{show? (default true; false skips it)} whether a presented prototype's Open link to a website first says that it leaves the prototype, with Open link and Cancel; kept with the preferences → {shown}",
"{show? (default true; false skips it)} whether a presented prototype's Open link the viewer clicked (or fired by a key) first says that it leaves the prototype, with Open link and Cancel (a link a delay or hover fired always does); kept with the preferences → {shown}",
false,
|s, p| {
let show = boolean(p, "show").unwrap_or(true);
+19
View File
@@ -10,9 +10,28 @@ pub fn changed_nodes(doc: &Document, before: &Document) -> Vec<NodeId> {
.collect()
}
#[cfg(test)]
thread_local! {
/// Tests: normalising panics, standing for a bug in it (a command's edit is rolled back).
pub(crate) static PANIC: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Tests: panic when [`PANIC`] is set, as a bug in normalising would.
#[cfg(test)]
fn test_panic() {
if PANIC.with(std::cell::Cell::get) {
panic!("normalize: test panic");
}
}
/// Shipped builds: nothing (the test hook compiles away).
#[cfg(not(test))]
fn test_panic() {}
/// Keep derived state consistent after edits: text boxes resize to their content, groups wrap
/// their children, instances follow their main components, auto layout re-flows.
pub fn normalize(doc: &mut Document, before: &Document) {
test_panic();
// Only what was touched since the last normalisation (edited, added or removed).
let mut touched: BTreeSet<NodeId> = doc.take_dirty();
// A variable or style edit changes no layer directly, but bound and linked layers must follow it.
+2
View File
@@ -346,6 +346,8 @@ mod nested_override_tests;
#[cfg(test)]
mod node_set_refusals_tests;
#[cfg(test)]
mod normalize_guard_tests;
#[cfg(test)]
mod online_tests;
#[cfg(test)]
mod open_issues_tests;
@@ -0,0 +1,72 @@
//! A panic while normalising what a command changed is caught like one in the command itself
//! (#1085): the command fails with `EngineError::Internal`, the document is as it was, no undo step
//! is pushed, and the session keeps working.
use serde_json::json;
use crate::{EngineError, Session, derived};
/// Run `f` with normalisation panicking.
fn panicking<T>(f: impl FnOnce() -> T) -> T {
derived::PANIC.with(|p| p.set(true));
let out = f();
derived::PANIC.with(|p| p.set(false));
out
}
/// The layer id a creating command returned.
fn id(v: &serde_json::Value) -> u64 {
v["id"].as_u64().unwrap()
}
/// A command whose edit panics while normalising fails with `EngineError::Internal`, leaves the
/// document and the undo history as they were, and the same command then runs as usual.
#[test]
fn a_panic_while_normalising_rolls_the_command_back() {
let mut s = Session::new();
let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
let before = s.doc().unwrap().clone();
let undo = s.undo_depth();
let result = panicking(|| s.execute("node.set", &json!({"ids": [r], "props": {"name": "Renamed"}})));
assert!(matches!(result, Err(EngineError::Internal { .. })), "{result:?}");
assert_eq!(s.doc().unwrap(), &before, "the edit is rolled back");
assert_eq!(s.undo_depth(), undo, "no undo step for the failed command");
// The next command runs as usual.
s.execute("node.set", &json!({"ids": [r], "props": {"name": "Renamed"}})).unwrap();
assert_eq!(s.doc().unwrap().node(figcraft_doc::NodeId(r)).unwrap().name, "Renamed");
assert_eq!(s.undo_depth().0, undo.0 + 1);
}
/// The step fails like any other failed step: the batch's group restores what it set aside for
/// its steps (the history limit, the group depth), which a panic unwinding through it skipped.
#[test]
fn a_panic_while_normalising_a_batch_step_rolls_the_batch_back() {
let mut s = Session::new();
let before = s.doc().unwrap().clone();
let undo = s.undo_depth();
let limit = s.ds().unwrap().history.limit;
let result = panicking(|| {
s.execute("command.batch", &json!({"commands": [{"command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": 10, "height": 10}}]}))
});
assert!(result.is_err(), "{result:?}");
assert_eq!(s.doc().unwrap(), &before);
assert_eq!(s.undo_depth(), undo);
assert_eq!(s.ds().unwrap().history.limit, limit, "the undo limit is back");
assert_eq!(s.group_depth, 0, "the group ended");
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
}
/// A live text preview whose normalising panics fails and shows nothing: the document is as it
/// was, and the same preview then shows.
#[test]
fn a_panic_while_normalising_a_text_preview_shows_none() {
let mut s = Session::new();
let t = id(&s.execute("text.create", &json!({"x": 0, "y": 0, "text": "Preview"})).unwrap());
let before = s.doc().unwrap().clone();
let result = panicking(|| s.execute("text.preview", &json!({"command": "text.style", "params": {"ids": [t], "fontSize": 32}})));
assert!(matches!(result, Err(EngineError::Internal { .. })), "{result:?}");
assert_eq!(s.doc().unwrap(), &before);
assert!(!crate::textpreview::active(&s), "no preview shows");
s.execute("text.preview", &json!({"command": "text.style", "params": {"ids": [t], "fontSize": 32}})).unwrap();
assert!(crate::textpreview::active(&s));
}
+4 -2
View File
@@ -82,8 +82,10 @@ pub struct Prefs {
/// The Getting started tips on the home screen were closed (Help ▸ Getting started shows them
/// again).
pub getting_started_dismissed: bool,
/// A prototype's Open link goes to a website without first telling the viewer that it leaves
/// the prototype (the notice's "Don't show this again"; `proto.linkNotice` shows it again).
/// A prototype's Open link — to a website or to the mail app — that the viewer's own click,
/// press, drag or key fired opens without first telling them that it leaves the prototype
/// (the notice's "Don't show this again"; `proto.linkNotice` shows it again). A link a delay
/// or the pointer passing over a layer fired still shows the notice (#1096).
pub link_notice_skipped: bool,
/// How each home screen section shows its files, by section id (`"recents"`): `"grid"` (cards
/// with the picture each file carries) or `"list"` (a table). Missing: the grid.
+22 -9
View File
@@ -51,7 +51,14 @@ impl Session {
.and_then(|v| self.check_instance_structure(id, &before).map(|()| v))
.and_then(|v| self.check_preview(id, &before).map(|()| v))
.and_then(|v| self.check_live_access(id, &before).map(|()| v))
.and_then(|v| self.check_finite_geometry(id, &before).map(|()| v));
.and_then(|v| self.check_finite_geometry(id, &before).map(|()| v))
// Normalising is part of the command: a panic in it rolls the edit back too (#1085).
.and_then(|v| {
guard::run(id, || {
self.normalize_edit(spec, &before);
Ok(v)
})
});
match &result {
Ok(_) => {
self.after_command(spec, before);
@@ -141,7 +148,19 @@ impl Session {
.map_err(|m| EngineError::Disabled(id.to_string(), m))
}
/// Bookkeeping after a successful command: normalise what changed, push undo, bump revision.
/// Normalise what a command changed in the document it ran on (text boxes, groups, instances,
/// auto layout…).
fn normalize_edit(&mut self, spec: &CommandSpec, before: &Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) {
let Some((bdoc, _, _, uid)) = before else { return };
let Some(ds) = self.docs.iter_mut().find(|d| d.uid == *uid) else { return };
// Saving may adopt the chosen filename as the document title. The encoded snapshot is
// already final; normalising it again would detach it from the saved baseline.
if !Arc::ptr_eq(&ds.doc, bdoc) && !matches!(spec.id, "file.save" | "file.saveAs") {
normalize(ds.doc_mut(), bdoc);
}
}
/// Bookkeeping after a successful command (normalised already): push undo, bump revision.
fn after_command(&mut self, spec: &CommandSpec, before: Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) {
let Some((bdoc, bsel, bpage, uid)) = before else {
if let Ok(ds) = self.ds_mut() {
@@ -150,13 +169,7 @@ impl Session {
return;
};
let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return };
let changed = !Arc::ptr_eq(&ds.doc, &bdoc);
// Saving may adopt the chosen filename as the document title. The encoded snapshot is
// already final; normalising it again would detach it from the saved baseline.
if changed && !matches!(spec.id, "file.save" | "file.saveAs") {
normalize(ds.doc_mut(), &bdoc);
}
let edit = changed && ds.interaction.is_none();
let edit = !Arc::ptr_eq(&ds.doc, &bdoc) && ds.interaction.is_none();
ds.clean_selection();
ds.revision += 1;
if edit {
+2 -4
View File
@@ -616,13 +616,11 @@ fn document_colors_are_counted_per_page() {
assert!(v["colors"].as_array().unwrap().is_empty());
}
/// `guide.add`, `guide.move` and `guide.remove` edit the current page's guides by index.
#[test]
fn guides_add_move_remove() {
let mut s = sess();
let guides = |s: &Session| match &s.doc().unwrap().node(s.page().unwrap()).unwrap().kind {
NodeKind::Page { guides, .. } => guides.clone(),
_ => vec![],
};
let guides = |s: &Session| s.doc().unwrap().node(s.page().unwrap()).unwrap().guides.clone();
let r = s.execute("guide.add", &json!({"vertical": true, "offset": 120})).unwrap();
assert_eq!(r["index"], json!(0));
s.execute("guide.add", &json!({"vertical": false, "offset": 40.5})).unwrap();
+8 -3
View File
@@ -183,6 +183,8 @@ fn text_drawn_on_a_dark_page_is_white_and_on_a_light_page_black() {
assert_eq!(s.doc().unwrap().node(made).unwrap().fills, vec![Paint::solid(Color::WHITE)]);
}
/// `page.duplicate` copies the page's flows (pointing at the copied frames), prototype settings
/// and guides.
#[test]
fn a_copy_of_a_page_keeps_its_flows_prototype_settings_and_guides() {
let mut s = Session::new();
@@ -195,9 +197,12 @@ fn a_copy_of_a_page_keeps_its_flows_prototype_settings_and_guides() {
let source = s.page().unwrap();
let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap();
let frames = s.doc().unwrap().node(copy).unwrap().children.clone();
let state = |s: &Session, id: NodeId| match &s.doc().unwrap().node(id).unwrap().kind {
NodeKind::Page { flows, prototype, guides, .. } => (flows.clone(), prototype.clone(), guides.clone()),
_ => panic!("expected a page"),
let state = |s: &Session, id: NodeId| {
let n = s.doc().unwrap().node(id).unwrap();
match &n.kind {
NodeKind::Page { flows, prototype, .. } => (flows.clone(), prototype.clone(), n.guides.clone()),
_ => panic!("expected a page"),
}
};
let (flows, prototype, guides) = state(&s, copy);
let (src_flows, src_prototype, src_guides) = state(&s, source);
+33 -12
View File
@@ -47,6 +47,7 @@ mod text_style_tests;
use std::collections::BTreeMap;
use std::io::Read;
use std::sync::Arc;
use figcraft_doc::Document;
@@ -59,7 +60,7 @@ use kiwi::{Kv, Schema};
const MAX_CANVAS: u64 = 256 * 1024 * 1024;
const MAX_CHUNK: u64 = 512 * 1024 * 1024;
/// Largest image member read.
const MAX_IMAGE: u64 = 256 * 1024 * 1024;
const MAX_IMAGE: u64 = figcraft_doc::ImageData::MAX_BYTES;
/// Most chunks read from `canvas.fig`.
const MAX_CHUNKS: usize = 16;
/// Most layers imported from a `.fig` file: one per [`PACKED_BYTES_PER_LAYER`] bytes of its
@@ -109,9 +110,9 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec<String>), Forma
let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name);
let (json, mut warnings, networks) = map::to_rest(&msg, file_name, max_nodes);
// The message goes before the layers are built, so the images it carries are copied out first.
let embedded: BTreeMap<String, Vec<u8>> = embedded_images(&msg).into_iter().map(|(hash, bytes)| (hash, bytes.to_vec())).collect();
let embedded = embedded_copies(&msg);
drop(msg);
let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).cloned());
let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).map(|b| b.to_vec()));
let (doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?;
// Both stages stop at the same cap and say so in the same words: one note (#981).
for w in more.into_iter().filter_map(map::adapt_warning) {
@@ -151,17 +152,17 @@ fn corrupt(msg: impl Into<String>) -> FormatError {
/// Image bytes that travel inside the message, by image hash (lowercase hex): a paint's
/// `image.dataBlob` indexes the message's `blobs`. Copies written by plugins and other tools embed
/// their images this way, and a bare canvas (what the clipboard carries) has no `images/`. The
/// walk is iterative (no recursion on nested data) and skips blobs larger than [`MAX_IMAGE`]. The
/// bytes stay borrowed from the message.
fn embedded_images<'m>(msg: &'m Kv) -> BTreeMap<String, &'m [u8]> {
/// walk is iterative (no recursion on nested data) and skips blobs larger than [`MAX_IMAGE`].
/// Returns the index of each hash's blob.
fn embedded_images(msg: &Kv) -> BTreeMap<String, usize> {
let blobs = msg.get("blobs").map(Kv::list).unwrap_or_default();
let mut found = BTreeMap::new();
let mut pending = vec![msg];
while let Some(value) = pending.pop() {
match value {
Kv::Object(fields) => {
if let Some((hash, bytes)) = value.get("image").and_then(|image| embedded_image(image, blobs)) {
found.entry(hash).or_insert(bytes);
if let Some((hash, index)) = value.get("image").and_then(|image| embedded_image(image, blobs)) {
found.entry(hash).or_insert(index);
}
pending.extend(fields.iter().filter(|(name, _)| *name != "blobs").map(|(_, field)| field));
}
@@ -172,15 +173,35 @@ fn embedded_images<'m>(msg: &'m Kv) -> BTreeMap<String, &'m [u8]> {
found
}
/// The hash and bytes of an `Image` value whose `dataBlob` points at a usable blob.
fn embedded_image<'k>(image: &Kv, blobs: &'k [Kv]) -> Option<(String, &'k [u8])> {
/// The images that travel inside the message ([`embedded_images`]), copied out of it once per
/// blob: every hash naming the same blob shares its copy. A file can point any number of hashes
/// at one large blob, and a copy for each would multiply it (#1059).
fn embedded_copies(msg: &Kv) -> BTreeMap<String, Arc<[u8]>> {
let blobs = msg.get("blobs").map(Kv::list).unwrap_or_default();
let mut copies: BTreeMap<usize, Arc<[u8]>> = BTreeMap::new();
let mut out = BTreeMap::new();
for (hash, index) in embedded_images(msg) {
let Some(bytes) = blob(blobs, index) else { continue };
out.insert(hash, Arc::clone(copies.entry(index).or_insert_with(|| Arc::from(bytes))));
}
out
}
/// The hash and blob index of an `Image` value whose `dataBlob` points at a usable blob.
fn embedded_image(image: &Kv, blobs: &[Kv]) -> Option<(String, usize)> {
let hash = image.get("hash").and_then(Kv::bytes).filter(|h| !h.is_empty() && h.len() <= 64)?;
let Some(Kv::Uint(index)) = image.get("dataBlob") else { return None };
let bytes = blobs.get(usize::try_from(*index).ok()?)?.get("bytes").and_then(Kv::bytes)?;
let index = usize::try_from(*index).ok()?;
let bytes = blob(blobs, index)?;
if bytes.is_empty() || bytes.len() as u64 > MAX_IMAGE {
return None;
}
Some((hash.iter().map(|b| format!("{b:02x}")).collect(), bytes))
Some((hash.iter().map(|b| format!("{b:02x}")).collect(), index))
}
/// The bytes of blob `index`.
fn blob<'k>(blobs: &'k [Kv], index: usize) -> Option<&'k [u8]> {
blobs.get(index)?.get("bytes").and_then(Kv::bytes)
}
/// The parts of a `.fig` archive the import reads.
+2 -2
View File
@@ -19,11 +19,11 @@ impl<'d> Exporter<'d> {
("phase", e("CREATED")),
("parentIndex", o(vec![("guid", parent_guid), ("position", s(&position(index)))])),
];
let children = if let NodeKind::Page { background, guides, prototype, .. } = &n.kind {
let children = if let NodeKind::Page { background, prototype, .. } = &n.kind {
f.extend([("type", e("CANVAS")), ("name", s(&n.name))]);
f.extend(root_fields());
f.extend([("backgroundColor", color(*background)), ("backgroundOpacity", fl(background.a.into())), ("backgroundEnabled", b(true))]);
if !guides.is_empty() {
if !n.guides.is_empty() {
self.warn("ruler guides were left out");
}
if *prototype != Default::default() {
+17
View File
@@ -777,6 +777,23 @@ fn a_data_blob_past_the_blobs_stays_a_placeholder() {
assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}");
}
/// A file can point any number of image hashes at one large blob; each used to get its own copy
/// of it (#1059).
#[test]
fn hashes_naming_one_blob_share_one_copy_of_it() {
let image = |hash: u8, blob: u64| o(vec![("image", o(vec![("hash", Kv::Bytes(vec![hash; 20])), ("dataBlob", u(blob))]))]);
let msg = o(vec![
("blobs", Kv::Array(vec![o(vec![("bytes", Kv::Bytes(png()))]), o(vec![("bytes", Kv::Bytes(vec![7; 16]))])])),
("nodeChanges", Kv::Array((1..=50).map(|h| image(h, 0)).chain([image(99, 1), image(98, 2)]).collect())),
]);
let copies = embedded_copies(&msg);
assert_eq!(copies.len(), 51, "the hash naming no blob is left out");
let first = &copies[&"01".repeat(20)];
assert_eq!(**first, *png());
assert_eq!(copies.values().filter(|c| Arc::ptr_eq(c, first)).count(), 50);
assert_eq!(*copies[&"63".repeat(20)], [7; 16]);
}
#[test]
fn other_files_are_not_fig() {
assert!(try_import(b"{\"document\": {}}", "x").is_none());
+45
View File
@@ -0,0 +1,45 @@
//! Ruler guides in `.figcraft` files: a reopened page keeps its guides, and files saved with a
//! page's guides written twice open again (#1476).
use figcraft_doc::{Document, Guide, NodeKind};
use super::*;
/// A guide: vertical (at an x offset) or horizontal (at a y offset).
fn guide(vertical: bool, offset: f64) -> Guide {
Guide { vertical, offset }
}
/// Page and frame guides survive saving, reopening, saving the reopened file and opening it again.
#[test]
fn page_and_frame_guides_survive_save_and_reopen() {
let mut d = Document::new("ruler");
let page = d.first_page().unwrap();
let frame = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
d.node_mut(page).unwrap().guides_mut().extend([guide(true, 120.0), guide(false, -30.5)]);
d.node_mut(frame).unwrap().guides_mut().push(guide(true, 12.0));
let (once, issues) = load_bytes(&save_bytes(&d, None).unwrap()).unwrap();
assert!(issues.is_empty(), "{issues:?}");
assert_eq!(once.node(page).unwrap().guides(), d.node(page).unwrap().guides());
assert_eq!(once.node(frame).unwrap().guides(), d.node(frame).unwrap().guides());
// Saving the reopened file and opening it again (where the file stopped opening).
let text = serde_json::to_string(&to_json(&once).unwrap()).unwrap();
assert_eq!(text.matches("\"guides\"").count(), 2, "one list on the page, one on the frame: {text}");
let (twice, _) = load_bytes(&save_bytes(&once, None).unwrap()).unwrap();
assert_eq!(twice.node(page).unwrap().guides(), d.node(page).unwrap().guides());
}
/// A `document.json` with a page's `guides` key twice (as saving a reopened page wrote it) opens
/// with both lists.
#[test]
fn a_file_with_a_page_guides_list_written_twice_opens() {
let mut d = Document::new("twice");
let page = d.first_page().unwrap();
d.node_mut(page).unwrap().guides_mut().push(guide(false, 7.0));
let text = serde_json::to_string(&to_json(&d).unwrap()).unwrap();
// What saving a reopened page wrote: the guides added since, then the ones the file had.
let broken = text.replacen("\"guides\":[", "\"guides\":[{\"vertical\":true,\"offset\":5.0}],\"guides\":[", 1);
assert_eq!(broken.matches("\"guides\"").count(), 2);
let (back, _) = from_json(&broken).unwrap();
assert_eq!(back.node(page).unwrap().guides(), &[guide(true, 5.0), guide(false, 7.0)]);
}
+48
View File
@@ -112,3 +112,51 @@ fn image_members_with_path_like_names_are_dropped_and_not_saved_back() {
assert!(z.file_names().all(|n| !n.contains("..") && n.matches('/').count() <= 1), "{:?}", z.file_names().collect::<Vec<_>>());
let _ = hash;
}
/// An unresolved `imageRef` from a hostile REST file never becomes the image's own key: the
/// save writer names archive members after keys, so `../../evil` would inject an
/// `images/../../evil` member into the next save. Such refs store the shared placeholder
/// under its own key instead.
#[test]
fn a_path_like_rest_image_ref_never_names_an_archive_member() {
let rect = json!({"id": "X", "name": "X", "type": "RECTANGLE", "relativeTransform": [[1, 0, 0], [0, 1, 0]], "size": {"x": 10, "y": 10},
"fills": [{"type": "IMAGE", "imageRef": "../../evil", "scaleMode": "FIT"}]});
let file = json!({"name": "Hostile", "schemaVersion": 0, "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT",
"children": [{"id": "0:Page", "name": "Page", "type": "CANVAS", "backgroundColor": {"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0}, "children": [rect]}]}});
let (doc, _warnings) = crate::rest::import(&file, &crate::rest::no_images).unwrap();
assert!(!doc.images.contains_key("../../evil"), "the raw imageRef must not name an image: {:?}", doc.images.keys().collect::<Vec<_>>());
assert!(doc.images.contains_key("rest-image-missing"), "the shared placeholder stores it instead: {:?}", doc.images.keys().collect::<Vec<_>>());
let bytes = save_bytes(&doc, None).unwrap();
let z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
assert!(z.file_names().all(|n| !n.contains("..") && n.matches('/').count() <= 1), "{:?}", z.file_names().collect::<Vec<_>>());
assert!(
z.file_names().any(|n| n == "images/rest-image-missing"),
"the placeholder is saved under its own name: {:?}",
z.file_names().collect::<Vec<_>>()
);
}
/// A key with `-` or `_` is a plain archive member name too: the REST importer's placeholder
/// (`rest-image-missing`) comes back after a save and load instead of being dropped as damaged.
#[test]
fn an_image_keyed_with_dashes_comes_back_after_a_save() {
let mut d = Document::new("t");
d.images.insert("rest-image-missing".into(), figcraft_doc::ImageData { bytes: std::sync::Arc::new(png()), width: 2, height: 2 });
let loaded = load_bytes_with_versions(&save_bytes(&d, None).unwrap()).unwrap();
assert!(loaded.doc.images.contains_key("rest-image-missing"), "{:?}", loaded.issues);
}
/// An unresolved `imageRef` shaped like a content hash would claim the placeholder's bytes hash
/// to it, which they don't: a live session would refuse that image. It shares the placeholder
/// instead.
#[test]
fn a_hash_shaped_rest_image_ref_never_names_its_placeholder() {
for image_ref in ["ab".repeat(32), "A".repeat(64)] {
let rect = json!({"id": "X", "name": "X", "type": "RECTANGLE", "relativeTransform": [[1, 0, 0], [0, 1, 0]], "size": {"x": 10, "y": 10},
"fills": [{"type": "IMAGE", "imageRef": image_ref, "scaleMode": "FIT"}]});
let file = json!({"name": "F", "schemaVersion": 0, "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT",
"children": [{"id": "0:Page", "name": "Page", "type": "CANVAS", "backgroundColor": {"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0}, "children": [rect]}]}});
let (doc, _) = crate::rest::import(&file, &crate::rest::no_images).unwrap();
assert_eq!(doc.images.keys().collect::<Vec<_>>(), ["rest-image-missing"], "{image_ref}");
}
}
+4 -2
View File
@@ -720,9 +720,9 @@ fn read_images<R: Read + Seek>(z: &mut zip::ZipArchive<R>, own: Option<&BTreeSet
hashes.sort_by_key(|h| !own.is_some_and(|o| o.contains(h)));
let mut images = BTreeMap::new();
let mut unread = Vec::new();
// Member names that aren't plain hashes (`../x`, `a/b`) are never read, so never written back.
// Member names that can't be real image keys (`../x`, `a/b`) are never read, so never written back.
hashes.retain(|h| {
let ok = library::is_valid_hash(h);
let ok = ImageData::is_valid_key(h);
if !ok {
unread.push((h.clone(), FormatError::Corrupt("its name isn't a valid image hash".to_string())));
}
@@ -977,6 +977,8 @@ pub fn load_file(path: &std::path::Path) -> Result<(Document, Vec<String>), Form
#[cfg(test)]
mod branch_tests;
#[cfg(test)]
mod guides_tests;
#[cfg(test)]
mod image_budget_tests;
#[cfg(test)]
mod image_tests;
+9 -10
View File
@@ -38,18 +38,13 @@ pub fn is_valid_library_id(id: &str) -> bool {
(1..=MAX_ID).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
/// Image hashes become archive member names: only plain hex-like names are looked up.
pub(crate) fn is_valid_hash(h: &str) -> bool {
(1..=128).contains(&h.len()) && h.bytes().all(|b| b.is_ascii_alphanumeric())
}
/// Write the published library's entries. `written` are the images already in `images/`.
pub(crate) fn write<W: Write + Seek>(z: &mut zip::ZipWriter<W>, lib: &PublishedLibrary, written: &BTreeSet<&String>) -> Result<(), FormatError> {
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
put(z, MANIFEST, json_options(), &serde_json::to_vec(&lib.manifest).map_err(io_err)?)?;
put_json(z, SNAPSHOT, &lib.doc)?;
for (hash, img) in &lib.doc.images {
if !written.contains(hash) && is_valid_hash(hash) {
if !written.contains(hash) && ImageData::is_valid_key(hash) {
put(z, &format!("{IMAGES}{hash}"), stored, &img.bytes)?;
}
}
@@ -75,7 +70,7 @@ pub(crate) fn read<R: Read + Seek>(
let mut budget = budget.saturating_sub(text.len() as u64).min(MAX_IMAGE_BYTES);
let (mut doc, _) = from_json(&text)?;
for hash in used_images(&doc) {
if !is_valid_hash(&hash) {
if !ImageData::is_valid_key(&hash) {
continue;
}
if let Some(img) = own.get(&hash) {
@@ -90,9 +85,13 @@ pub(crate) fn read<R: Read + Seek>(
}
// Keep what the snapshot really holds; clip what people see.
manifest.components.retain(|a| doc.node(a.id).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. })));
manifest.styles.retain(|s| doc.styles.iter().any(|x| x.id == s.id));
manifest.collections.retain(|c| doc.variables.collections.iter().any(|x| x.id == c.id));
manifest.variables.retain(|v| doc.variables.variables.iter().any(|x| x.id == v.id && x.collection == v.collection));
// Membership by set lookups: a long manifest against a large snapshot stays linear (#1078).
let styles: BTreeSet<&str> = doc.styles.iter().map(|x| x.id.as_str()).collect();
let collections: BTreeSet<&str> = doc.variables.collections.iter().map(|x| x.id.as_str()).collect();
let variables: BTreeSet<(&str, &str)> = doc.variables.variables.iter().map(|x| (x.id.as_str(), x.collection.as_str())).collect();
manifest.styles.retain(|s| styles.contains(s.id.as_str()));
manifest.collections.retain(|c| collections.contains(c.id.as_str()));
manifest.variables.retain(|v| variables.contains(&(v.id.as_str(), v.collection.as_str())));
manifest.name = manifest.name.chars().take(MAX_NAME).collect();
manifest.description = manifest.description.chars().take(MAX_DESCRIPTION).collect();
Ok(Some(PublishedLibrary { manifest, doc: Arc::new(doc) }))
+12 -3
View File
@@ -947,7 +947,10 @@ impl<'a> Ctx<'a> {
}
/// The document image key for an `imageRef`: its bytes when the resolver has them, else a
/// grey placeholder stored under the `imageRef` itself.
/// grey placeholder stored under the `imageRef` itself when that name can legitimately be an
/// archive member (`is_valid_key`) and claims no content hash (`claims_content_hash`: the
/// placeholder's bytes don't hash to it); any other name shares the `rest-image-missing`
/// placeholder.
pub(crate) fn image_key(&mut self, image_ref: &str) -> String {
if let Some(k) = self.image_keys.get(image_ref) {
return k.clone();
@@ -957,7 +960,13 @@ impl<'a> Ctx<'a> {
Some((hash, _, _)) => hash,
None => {
self.missing_images += 1;
let key = if image_ref.is_empty() { MISSING_IMAGE } else { clip_str(image_ref) }.to_string();
// An `imageRef` arrives from an untrusted file, and keys become archive member
// names on save: a name that can't legitimately be one must not reach the map,
// nor one claiming a content hash the placeholder's bytes don't have.
let candidate = if image_ref.is_empty() { MISSING_IMAGE } else { clip_str(image_ref) };
let named = ImageData::is_valid_key(candidate) && !ImageData::claims_content_hash(candidate);
let key = if named { candidate } else { MISSING_IMAGE };
let key = key.to_string();
if !self.doc.images.contains_key(&key)
&& let Some(png) = placeholder_png()
{
@@ -1018,7 +1027,7 @@ impl<'a> Ctx<'a> {
let n = self.missing_images;
let (s, verb) = if n == 1 { ("", "shows") } else { ("s", "show") };
self.warn(format!(
"{n} image{s} without image data {verb} a grey placeholder named by its imageRef (put the downloaded images, named by imageRef, in an `images` folder next to the JSON)"
"{n} image{s} without image data {verb} a grey placeholder, named by its imageRef when that can legitimately name a file and by the shared name otherwise (put the downloaded images, named by imageRef, in an `images` folder next to the JSON)"
));
}
let unused = self.style_meta.keys().filter(|k| !self.doc.styles.iter().any(|s| s.id == **k)).count();
+1 -1
View File
@@ -66,7 +66,7 @@ const MAX_JSON_TOTAL: u64 = 2 * 1024 * 1024 * 1024;
#[cfg(target_pointer_width = "32")]
const MAX_JSON_TOTAL: u64 = 512 * 1024 * 1024;
/// Largest image member read.
const MAX_IMAGE: u64 = 256 * 1024 * 1024;
const MAX_IMAGE: u64 = figcraft_doc::ImageData::MAX_BYTES;
/// Most pages read from one file.
const MAX_PAGES: usize = 1000;
+1
View File
@@ -1487,6 +1487,7 @@ name = "English"
"This frame is new on the branch." = "This frame is new on the branch."
"This frame isn't on the branch." = "This frame isn't on the branch."
"This link opens a website outside the prototype:" = "This link opens a website outside the prototype:"
"This link opens your mail app to write a message:" = "This link opens your mail app to write a message:"
"This server is open to the public: no key needed." = "This server is open to the public: no key needed."
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)."
"This server takes files only with its key: see Advanced." = "This server takes files only with its key: see Advanced."
+77 -1
View File
@@ -186,6 +186,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Both directions" = "ორივე მიმართულება"
"Bottom" = "ქვემოთ"
"Bottom left radius" = "ქვედა მარცხენა რადიუსი"
"Bottom padding" = "ქვედა შიდა დაშორება"
"Bottom right radius" = "ქვედა მარჯვენა რადიუსი"
"Brainstorm" = "გონებრივი იერიში"
"Branch" = "განშტოება"
@@ -219,10 +220,11 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Chevron" = "შევრონი"
"Clear from this list" = "ამ სიიდან ამოღება"
"Clear results" = "შედეგების გასუფთავება"
"Click objects to vote; click your badge to take a vote back" = "ხმის მისაცემად დააწკაპუნეთ ობიექტებზე; ხმის დასაბრუნებლად დააწკაპუნეთ თქვენს ნიშანზე"
"Click objects to vote; click your badge to take a vote back" = "ხმის მისაცემად დაწკაპეთ ობიექტებზე; ხმის დასაბრუნებლად დაწკაპეთ თქვენს ნიშანზე"
"Code or invite link" = "კოდი ან მოსაწვევი ბმული"
"Color profile: {name}" = "ფერის პროფილი: {name}"
"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "სტილები, ღია და მუქი ცვლადები, ღილაკები და ბარათები"
"Compare with" = "შესადარებელი ვერსია"
"Connector" = "შემაერთებელი"
"Convert to frame" = "ჩარჩოდ გარდაქმნა"
"Convert to section" = "სექციად გარდაქმნა"
@@ -337,6 +339,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Corner smoothing" = "კუთხეების დარბილება"
"Couldn't download the variables" = "ცვლადების ჩამოტვირთვა ვერ მოხერხდა"
"Couldn't reach {server}: showing what this computer remembers." = "{server}-თან დაკავშირება ვერ მოხერხდა: ნაჩვენებია ის, რაც ამ კომპიუტერს ახსოვს."
"Couldn't read {name}." = "{name}-ის წაკითხვა ვერ მოხერხდა."
"Couldn't save automatically: {error}" = "ავტომატურად შენახვა ვერ მოხერხდა: {error}"
"Couldn't save the file" = "ფაილის შენახვა ვერ მოხერხდა"
"Count" = "რაოდენობა"
@@ -408,6 +411,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Detach variable" = "ცვლადის გამოყოფა"
"Details" = "დეტალები"
"Dev Mode" = "დეველოპერის რეჟიმი"
"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "დეველოპერის რეჟიმში მხოლოდ ინსპექტირებაა შესაძლებელი: რედაქტირებისთვის გადაერთეთ დიზაინზე (Shift+D)"
"Dev resources" = "დეველოპერის რესურსები"
"Development" = "დეველოპმენტი"
"Device" = "მოწყობილობა"
@@ -660,6 +664,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Included" = "ჩართული"
"Included in layout" = "განლაგებაში ჩართული"
"Individual corners" = "ცალკეული კუთხეები"
"Individual padding" = "ცალკეული შიდა დაშორებები"
"Individual strokes" = "ცალკეული კონტურები"
"Ink" = "მელანი"
"Inner shadow" = "შიდა ჩრდილი"
@@ -681,6 +686,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Interaction" = "ურთიერთქმედება"
"Interactions" = "ურთიერთქმედებები"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "ინტერფეისის დიზაინი და პროტოტიპირება. უფასო, ღია კოდის, დამოუკიდებლად (clean-room) შექმნილი დიზაინის ხელსაწყო, ნულიდან დაწერილი Rust-ზე, ArtCraft Crafting Apps-ის ნაწილი."
"Interface size" = "ინტერფეისის ზომა"
"Intersect" = "გადაკვეთა"
"Intersects" = "გადაკვეთები"
"Invalid color" = "არასწორი ფერი"
@@ -732,6 +738,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Leaves" = "ფოთლები"
"Left" = "მარცხნივ"
"Left & right" = "მარცხნივ და მარჯვნივ"
"Left padding" = "მარცხენა შიდა დაშორება"
"Left side" = "მარცხენა მხარე"
"Left side width" = "მარცხენა მხარის სიგანე"
"Letter case" = "ასოთა რეგისტრი"
@@ -913,6 +920,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Not read yet" = "ჯერ არ არის წაკითხული"
"Not shared" = "არ არის გაზიარებული"
"Note {n}" = "შენიშვნა {n}"
"Nothing changed since this version." = "ამ ვერსიის შემდეგ არაფერი შეცვლილა."
"Nothing changed since this was marked ready for dev." = "მას შემდეგ, რაც მზად დეველოპერისთვის მოინიშნა, არაფერი შეცვლილა."
"Nothing here yet. Set a frame's status to list it." = "აქ ჯერ არაფერია. ჩარჩოს სიაში გამოსაჩენად მიანიჭეთ სტატუსი."
"Nothing online yet. Use Share on a file to put it online and it is listed here." = "ონლაინ ჯერ არაფერია. ფაილის ონლაინ განსათავსებლად გამოიყენეთ „გაზიარება“ და ის აქ გამოჩნდება."
@@ -956,6 +964,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Open link" = "ბმულის გახსნა"
"Open link {url}" = "ბმულის გახსნა {url}"
"Open overlay" = "გადაფარვის გახსნა"
"Open presentations" = "გახსნილი პრეზენტაციები"
"Open prototype preview" = "პროტოტიპის წინასწარი ნახვის გახსნა"
"Open variables ({count})" = "ცვლადების გახსნა ({count})"
"Opened as a new document" = "გაიხსნა ახალ დოკუმენტად"
@@ -985,6 +994,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"PDF print settings" = "PDF-ის ბეჭდვის პარამეტრები"
"Padding" = "შიდა დაშორება"
"Page" = "გვერდი"
"Page deleted" = "გვერდი წაიშალა"
"Pages" = "გვერდები"
"Pages added to the document" = "გვერდები დაემატა დოკუმენტს"
"Pause" = "პაუზა"
@@ -1101,6 +1111,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Refraction" = "გარდატეხა"
"Regular expression" = "რეგულარული გამოსახულება"
"Relative to" = "მიმართ"
"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "სისტემის ეკრანის მასშტაბირებასთან მიმართებით (100% სისტემის საკუთარი ზომაა). დოკუმენტის მასშტაბი ცალკე რეგულირდება."
"Remove" = "წაშლა"
"Remove all" = "ყველას წაშლა"
"Remove annotation" = "ანოტაციის წაშლა"
@@ -1132,6 +1143,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Removed" = "წაშლილია"
"Removed from the server" = "წაიშალა სერვერიდან"
"Removed since it was marked" = "წაიშალა მონიშვნის შემდეგ"
"Removed since this version" = "წაშლილია ამ ვერსიის შემდეგ"
"Rename" = "სახელის შეცვლა"
"Rename duplicated layers" = "დუბლირებული შრეების სახელის შეცვლა"
"Rename folder" = "საქაღალდის სახელის შეცვლა"
@@ -1142,6 +1154,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Rename on the server…" = "სახელის შეცვლა სერვერზე…"
"Rename page" = "გვერდის სახელის შეცვლა"
"Rename to" = "ახალი სახელი"
"Reorder selection" = "მონიშნულის თანმიმდევრობის შეცვლა"
"Replace all" = "ყველას ჩანაცვლება"
"Replace mixed export settings" = "შერეული ექსპორტის პარამეტრების ჩანაცვლება"
"Replace with" = "ჩანაცვლება:"
@@ -1180,6 +1193,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Review and merge" = "განხილვა და შერწყმა"
"Ribbon" = "ლენტი"
"Right" = "მარჯვნივ"
"Right padding" = "მარჯვენა შიდა დაშორება"
"Right side" = "მარჯვენა მხარე"
"Right side width" = "მარჯვენა მხარის სიგანე"
"Rotate 90°" = "90°-ით შემობრუნება"
@@ -1202,6 +1216,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Run {label}" = "გაშვება: {label}"
"Save file" = "ფაილის შენახვა"
"Save files automatically" = "ფაილების ავტომატურად შენახვა"
"Search pages" = "გვერდების ძიება"
"Search templates" = "შაბლონების ძიება"
"See all templates" = "ყველა შაბლონის ნახვა"
"Select layers and they space themselves as their content changes" = "შრეები შიგთავსის ცვლილებისას თავად დაშორდება"
@@ -1350,6 +1365,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Start from a blank canvas" = "ცარიელი ტილოთი დაწყება"
"Start from a template" = "შაბლონით დაწყება"
"Start on path" = "დასაწყისი ტრაექტორიაზე"
"Start on the Home screen" = "საწყისი ეკრანით გაშვება"
"Start point" = "საწყისი წერტილი"
"Start sharing" = "გაზიარების დაწყება"
"Start sharing with the Share button, then pass on the code it gives" = "დააჭირეთ „გაზიარებას“ და გადაეცით მიღებული კოდი"
@@ -1431,6 +1447,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"The selection uses several text styles: click to apply one" = "მონიშნული ტექსტის რამდენიმე სტილს იყენებს: დაწკაპეთ ერთის გამოსაყენებლად"
"The server deletes this file if nobody opens it before {date}." = "სერვერი ამ ფაილს წაშლის, თუ მას {date}-მდე არავინ გახსნის."
"The whole file" = "მთელი ფაილი"
"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "ფანჯარა {size}%-ისთვის ძალიან პატარაა, ამიტომ ინტერფეისი {drawn}%-ით არის დახატული. {size}%-ის გამოსაყენებლად გაადიდეთ ფანჯარა."
"Theme" = "თემა"
"There's no destination for that action" = "ამ მოქმედებას დანიშნულება არ აქვს"
"Thickness" = "სისქე"
@@ -1441,6 +1458,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"This condition is edited as an expression" = "ეს პირობა ექსპრესიად რედაქტირდება"
"This file" = "ეს ფაილი"
"This file has unsaved changes" = "ამ ფაილს შეუნახავი ცვლილებები აქვს"
"This file is too large to join here (up to {mb} MB)" = "ეს ფაილი აქ შესაერთებლად ძალიან დიდია (მაქსიმუმ {mb} მბ)"
"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "ეს ფაილი იყენებს შრიფტებს, რომლებიც ამ კომპიუტერზე დაინსტალირებული არ არის. მათი ტექსტი სათადარიგო შრიფტით გამოისახება, სანამ მათ არ დააინსტალირებთ."
"This file is on {server}, open only to you. Share it to let others in with a new code." = "ეს ფაილი {server}-ზეა და მხოლოდ თქვენთვისაა ღია. გააზიარეთ, რომ სხვები ახალი კოდით შემოუშვათ."
"This frame is new on the branch." = "ეს ჩარჩო განშტოებაში ახალია."
@@ -1448,6 +1466,8 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"This server is open to the public: no key needed." = "ეს სერვერი საჯაროა: გასაღები საჭირო არ არის."
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "ამ სერვერს გასაღები სჭირდება: მისამართს დაუმატეთ ?key=… (`figcraft-server key` გამოიტანს მას სერვერის კომპიუტერზე)."
"This server takes files only with its key: see Advanced." = "ეს სერვერი ფაილებს მხოლოდ გასაღებით იღებს: იხილეთ „დამატებითი“."
"This version can't be read." = "ამ ვერსიის წაკითხვა ვერ ხერხდება."
"This version doesn't have it: all of it is new." = "ეს ამ ვერსიაში არ არის: მთლიანად ახალია."
"Thumbs up" = "ცერი ზემოთ"
"Tidy up" = "მოწესრიგება"
"Tile" = "ფილებად"
@@ -1465,6 +1485,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Top" = "ზემოთ"
"Top & bottom" = "ზემოთ და ქვემოთ"
"Top left radius" = "ზედა მარცხენა რადიუსი"
"Top padding" = "ზედა შიდა დაშორება"
"Top right radius" = "ზედა მარჯვენა რადიუსი"
"Transform" = "გარდაქმნა"
"Triangle" = "სამკუთხედი"
@@ -1515,6 +1536,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Use number keys for opacity" = "გაუმჭვირვალობისთვის ციფრული კლავიშების გამოყენება"
"Use the .figcraft or .fig extension." = "გამოიყენეთ .figcraft ან .fig გაფართოება."
"Use the layer's fill" = "შრის შევსების გამოყენება"
"Use your browser's zoom to change the interface size." = "ინტერფეისის ზომის შესაცვლელად გამოიყენეთ ბრაუზერის მასშტაბირება."
"Used in {files} · {instances}" = "გამოიყენება: {files} · {instances}"
"Username" = "მომხმარებლის სახელი"
"Value" = "მნიშვნელობა"
@@ -1562,11 +1584,13 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Wedge" = "სოლი"
"Weekly planner" = "კვირის გეგმა"
"Weight" = "სისქე"
"What changed since a saved version" = "რა შეიცვალა შენახული ვერსიის შემდეგ"
"What you try here changes this preview and the code below, not the file" = "რასაც აქ ცდით, ცვლის ამ გადახედვას და ქვემოთ კოდს და არა ფაილს"
"What changed since it was marked ready for dev" = "რა შეიცვალა მზად დეველოპერისთვის მონიშვნის შემდეგ"
"What is wrong with this file? The server's operator gets your report." = "რა არის ამ ფაილში არასწორი? სერვერის ოპერატორი მიიღებს თქვენს საჩივარს."
"What it's for" = "რისთვისაა"
"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "არ არის ცნობილი, რისგან შეიცვალა: ცვლილება მოხდა მანამ, სანამ FigCraft მზად დეველოპერისთვის ჩარჩოების ასლებს შეინახავდა. გადაწყვიტეთ ცვლილება (ან ხელახლა მონიშნეთ მზადად), რომ ამიერიდან შედარდეს."
"When marked ready" = "მზადად მონიშვნისას"
"While hovering" = "მაჩვენებლის მიტანისას"
"While pressing" = "დაჭერისას"
"White" = "თეთრი"
@@ -1745,6 +1769,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"{name} ({layers} layers)" = "{name} (შრე: {layers})"
"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, ნაგულისხმევი {default}. სტილის მნიშვნელობაზე დასაბრუნებლად ორჯერ დაწკაპეთ სლაიდერზე; Escape გადათრევისას აუქმებს."
"{name} from {path}" = "{name} — {path}"
"{name} is too large to open in the browser (over 512 MB)." = "{name} ბრაუზერში გასახსნელად ძალიან დიდია (512 მბ-ზე მეტი)."
"{name} was removed from the server" = "{name} წაიშალა სერვერიდან"
"{name} · deleted on main" = "{name} · წაშლილია მთავარში"
"{name} · deleted on the branch" = "{name} · წაშლილია განშტოებაში"
@@ -1794,6 +1819,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"{n}m" = "{n} წთ"
"{n}w" = "{n} კვ"
"{page}, nothing selected" = "{page}, არაფერია მონიშნული"
"{percent}%" = "{percent}%"
"{profile} (current)" = "{profile} (მიმდინარე)"
"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} მისი მნიშვნელობაა; {property ? \"a\" : \"b\"} ირჩევს სტრიქონს. ნებისმიერი სხვა {…} რჩება დაწერილი სახით."
"{server} can't say what became of your files (it is older than this app)." = "{server} ვერ გეტყვით, რა მოუვიდა თქვენს ფაილებს (ის ამ აპზე ძველია)."
@@ -1812,6 +1838,36 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"⌘ click" = "⌘ დაწკაპუნება"
"⌘ scroll" = "⌘ გადახვევა"
"⌥ drag" = "⌥ გადათრევა"
"Android Vector Drawable" = "Android Vector Drawable"
"Best effort" = "მაქსიმალური მიახლოება"
"Check for Updates" = "განახლებების შემოწმება"
"Check for updates…" = "განახლებების შემოწმება…"
"Checking for updates…" = "განახლებების შემოწმება…"
"Compatibility" = "თავსებადობა"
"Dart constants for Flutter…" = "Dart-ის კონსტანტები Flutter-ისთვის…"
"Copy effect" = "ეფექტის კოპირება"
"Copy fill" = "შევსების კოპირება"
"Copy stroke" = "კონტურის კოპირება"
"Could not check for updates:\n{error}" = "განახლებების შემოწმება ვერ მოხერხდა:\n{error}"
"Couldn't download {name}" = "{name} ვერ ჩამოიტვირთა"
"Don't show this again" = "აღარ აჩვენო"
"Exact only" = "მხოლოდ ზუსტი"
"Exported {name}; {n} kinds of content were changed or left out" = { one = "ექსპორტირებულია: {name}; შეცვლილი ან გამოტოვებული შიგთავსის სახეობა: {n}", other = "ექსპორტირებულია: {name}; შეცვლილი ან გამოტოვებული შიგთავსის სახეობა: {n}" }
"Exported {n} files; some content was changed or left out" = { one = "ექსპორტირებულია ფაილი: {n}; ზოგი შიგთავსი შეიცვალა ან გამოტოვებულია", other = "ექსპორტირებულია ფაილი: {n}; ზოგი შიგთავსი შეიცვალა ან გამოტოვებულია" }
"FigCraft {version} is currently the newest version available." = "FigCraft {version} ამჟამად უახლესი ხელმისაწვდომი ვერსიაა."
"Format" = "ფორმატი"
"Ignore unsupported" = "მხარდაუჭერელის უგულებელყოფა"
"Leaving the prototype" = "პროტოტიპიდან გასვლა"
"Paste effect" = "ეფექტის ჩასმა"
"Paste fill" = "შევსების ჩასმა"
"Paste stroke" = "კონტურის ჩასმა"
"Server key" = "სერვერის გასაღები"
"Set corner smoothing to 60%" = "კუთხეების დარბილების 60%-ზე დაყენება"
"Text on a path" = "ტექსტი ტრაექტორიაზე"
"This link opens a website outside the prototype:" = "ეს ბმული პროტოტიპის გარეთ ვებსაიტს ხსნის:"
"Update check is not supported in the web browser." = "ვებბრაუზერში განახლებების შემოწმება მხარდაჭერილი არ არის."
"instance swap properties whose default component was past the layer limit were skipped" = "ინსტანსის ჩანაცვლების თვისებები, რომელთა ნაგულისხმევი კომპონენტი შრეების ზღვარს მიღმა იყო, გამოტოვებულია"
"instances whose component was past the layer limit were imported as frames" = "ინსტანსები, რომელთა კომპონენტი შრეების ზღვარს მიღმა იყო, ჩარჩოებად იმპორტირდა"
[commands]
"align.bottom" = "ქვემოთ სწორება"
@@ -1836,6 +1892,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"arrange.front" = "წინა პლანზე გადმოტანა"
"arrange.packH" = "ჰორიზონტალურად შეკვრა"
"arrange.packV" = "ვერტიკალურად შეკვრა"
"arrange.reorder" = "მონიშნულის თანმიმდევრობის შეცვლა"
"arrange.roundToPixels" = "პიქსელებამდე დამრგვალება"
"arrange.spacing" = "ინტერვალი"
"arrange.tidy" = "მოწესრიგება"
@@ -1984,6 +2041,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"file.exportLayers" = "შრეების ექსპორტირება"
"file.exportSvg" = "მონიშნულის SVG-ად ექსპორტირება"
"file.import" = "იმპორტი"
"file.moveTab" = "ჩანართის გადატანა"
"file.new" = "ახალი დიზაინის ფაილი"
"file.newBoard" = "ახალი დაფა"
"file.newFromTemplate" = "შაბლონიდან შექმნა"
@@ -2127,6 +2185,8 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"pref.reduceMotion" = "მოძრაობის შემცირება"
"pref.renameDuplicates" = "დუბლირებული შრეების სახელის შეცვლა"
"pref.showDimensions" = "ზომების ჩვენება ობიექტებზე"
"pref.startOnHome" = "საწყისი ეკრანით გაშვება"
"pref.uiScale" = "ინტერფეისის ზომა"
"pref.userName" = "თქვენი სახელი კომენტარებში"
"proto.action.add" = "პროტოტიპის მოქმედების დამატება"
"proto.action.move" = "პროტოტიპის მოქმედების გადალაგება"
@@ -2235,6 +2295,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"text.listIndent" = "სიის შეწევის გაზრდა"
"text.listOutdent" = "სიის შეწევის შემცირება"
"text.listStyle" = "სიის სტილი"
"text.loadFont" = "შრიფტის ფაილის ჩატვირთვა"
"text.maxLines" = "მაქს. სტრიქონები"
"text.missingFonts" = "მიუწვდომელი შრიფტები"
"text.numberList" = "დანომრილი სია"
@@ -2243,7 +2304,9 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"text.paragraphSpacing" = "აბზაცთა ინტერვალი"
"text.pathOptions" = "ტრაექტორიაზე ტექსტის პარამეტრები"
"text.preview" = "ტექსტის ცვლილების გადახედვა"
"text.refit" = "ტექსტის მორგება შრიფტებზე"
"text.removeFromPath" = "ტექსტის ტრაექტორიიდან მოხსნა"
"text.rescanFonts" = "დაინსტალირებული შრიფტების ხელახლა სკანირება"
"text.set" = "ტექსტის დაყენება"
"text.setFeatures" = "OpenType შესაძლებლობები"
"text.setVariations" = "ცვლადი შრიფტის ღერძები"
@@ -2344,11 +2407,13 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"vector.edit.tool" = "ვექტორის რედაქტირების ხელსაწყო"
"vector.edit.transform" = "წერტილების გარდაქმნა"
"vector.edit.widthPoint" = "სიგანის წერტილი"
"vector.fillRule" = "შევსების წესის დაყენება"
"vector.flatten" = "გაბრტყელება"
"vector.join" = "მონიშნულის შეერთება"
"vector.offset" = "ტრაექტორიის წანაცვლება"
"vector.outlineStroke" = "კონტურის ფიგურად გარდაქმნა"
"vector.reverse" = "ტრაექტორიის მიმართულების შებრუნება"
"vector.reverseLoop" = "ჩაკეტილი ტრაექტორიის შებრუნება"
"vector.simplify" = "ვექტორის გამარტივება"
"vector.smoothJoin" = "მონიშნულის გლუვი შეერთება"
"version.autosave" = "ვერსიის ავტოშენახვა"
@@ -2414,6 +2479,17 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"widget.setText" = "ვიჯეტის ველის რედაქტირება"
"window.focus" = "ფანჯრის წინა პლანზე გადმოტანა"
"window.new" = "ახალი ფანჯარა"
"fill.copy" = "შევსების კოპირება"
"fill.paste" = "შევსების ჩასმა"
"stroke.copy" = "კონტურის კოპირება"
"stroke.paste" = "კონტურის ჩასმა"
"effect.copy" = "ეფექტის კოპირება"
"effect.paste" = "ეფექტის ჩასმა"
"proto.linkNotice" = "გაფრთხილება ბმულზე გადასვლამდე"
"tool.textOnPath" = "ტექსტი ტრაექტორიაზე"
"file.markSaved" = "შენახულად მარკირება"
"text.fit" = "ტექსტის ჩატევა თავის საზღვრებში"
"data.fill" = "ასლების მონაცემებით შევსება"
[menu]
"bool.exclude" = "გამორიცხვა"
+62 -15
View File
@@ -7,9 +7,6 @@
name = "Svenska"
[ui]
"Relative to" = "Relativt till"
"Trigger layer" = "Utlösande lager"
"Whole screen" = "Hela skärmen"
"% of all commits" = "% av alla commits"
"(no longer published)" = "(publiceras inte längre)"
@@ -112,6 +109,7 @@ name = "Svenska"
"An invite link that opens FigCraft and joins" = "En inbjudningslänk som öppnar FigCraft och ansluter"
"Anchor" = "Ankare"
"Android" = "Android"
"Android Vector Drawable" = "Android Vector Drawable"
"Angular" = "Vinkelmått"
"Animation" = "Animering"
"Annotations" = "Annoteringar"
@@ -169,6 +167,7 @@ name = "Svenska"
"Basics" = "Grunder"
"Before" = "Före"
"Bend" = "Böj"
"Best effort" = "Bästa möjliga"
"Between the selected layers" = "Mellan de markerade lagren"
"Bevel" = "Fasning"
"Binary assets added" = "Binära resurser tillagda"
@@ -215,6 +214,9 @@ name = "Svenska"
"CMYK colour" = "CMYK-färg"
"Can view" = "Kan visa"
"Check" = "Bock"
"Check for Updates" = "Sök efter uppdateringar"
"Check for updates…" = "Sök efter uppdateringar…"
"Checking for updates…" = "Söker efter uppdateringar…"
"Checking the fonts installed on this computer…" = "Kontrollerar vilka typsnitt som är installerade på den här datorn…"
"Checking the server…" = "Kontrollerar servern…"
"Chevron" = "Vinkel"
@@ -224,7 +226,12 @@ name = "Svenska"
"Code or invite link" = "Kod eller inbjudningslänk"
"Color profile: {name}" = "Färgprofil: {name}"
"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Färg- och textstilar, ljusa och mörka variabler, knappar, inmatningsfält och kort"
"Compatibility" = "Kompatibilitet"
"Connector" = "Koppling"
"Convert to frame" = "Konvertera till frame"
"Convert to section" = "Konvertera till avsnitt"
"CSS custom properties…" = "Anpassade CSS-egenskaper…"
"Dart constants for Flutter…" = "Dart-konstanter för Flutter…"
"Can edit" = "Kan redigera"
"Can't read this library: {e}" = "Det går inte att läsa det här biblioteket: {e}"
"Cancel" = "Avbryt"
@@ -292,6 +299,7 @@ name = "Svenska"
"Commits" = "Commits"
"Company" = "Företag"
"Compare changes" = "Jämför ändringar"
"Compare with" = "Jämför med"
"Compatibility notes" = "Kompatibilitetsanteckningar"
"Completed" = "Slutförd"
"Component" = "Komponent"
@@ -307,7 +315,6 @@ name = "Svenska"
"Connected" = "Kopplad"
"Connected to code: {from}" = "Kopplad till kod: {from}"
"Connection lost, reconnecting (attempt {attempt})…" = "Anslutningen bröts, återansluter (försök {attempt})…"
"Connector" = "Koppling"
"Constrain proportions" = "Behåll proportioner"
"Constraints" = "Constraints"
"Content" = "Innehåll"
@@ -317,8 +324,6 @@ name = "Svenska"
"Contributors" = "Bidragsgivare"
"Contributors: {people} · Commits: {commits}" = "Bidragsgivare: {people} · Commits: {commits}"
"Controller" = "Handkontroll"
"Convert to frame" = "Konvertera till frame"
"Convert to section" = "Konvertera till avsnitt"
"Cool" = "Kall"
"Copied as CSS" = "Kopierat som CSS"
"Copied as PNG" = "Kopierat som PNG"
@@ -331,12 +336,17 @@ name = "Svenska"
"Copy as PNG" = "Kopiera som PNG"
"Copy as SVG" = "Kopiera som SVG"
"Copy code" = "Kopiera kod"
"Copy effect" = "Kopiera effekt"
"Copy fill" = "Kopiera fyllning"
"Copy link" = "Kopiera länk"
"Copy stroke" = "Kopiera kontur"
"Copy the {label}" = "Kopiera {label}"
"Copy {value}" = "Kopiera {value}"
"Corner radius" = "Hörnradie"
"Corner smoothing" = "Hörnutjämning"
"Could not check for updates:\n{error}" = "Det gick inte att söka efter uppdateringar:\n{error}"
"Couldn't download the variables" = "Det gick inte att hämta variablerna"
"Couldn't download {name}" = "Det gick inte att hämta {name}"
"Couldn't reach {server}: showing what this computer remembers." = "Det gick inte att nå {server}: visar det som den här datorn minns."
"Couldn't read {name}." = "Det gick inte att läsa {name}."
"Couldn't save automatically: {error}" = "Det gick inte att spara automatiskt: {error}"
@@ -410,6 +420,7 @@ name = "Svenska"
"Detach variable" = "Koppla bort variabel"
"Details" = "Detaljer"
"Dev Mode" = "Utvecklarläge"
"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "Utvecklarläget kan bara granska: växla till Design (Shift+D) för att redigera"
"Dev resources" = "Utvecklarresurser"
"Development" = "Utveckling"
"Device" = "Enhet"
@@ -427,6 +438,7 @@ name = "Svenska"
"Distribute vertical spacing" = "Fördela vertikalt avstånd"
"Divider" = "Avdelare"
"Don't Save" = "Spara inte"
"Don't show this again" = "Visa inte detta igen"
"Don't truncate" = "Korta inte av"
"Done" = "Klart"
"Done editing" = "Klar med redigeringen"
@@ -482,6 +494,7 @@ name = "Svenska"
"Essential" = "Grundläggande"
"Every conflict has a choice." = "Alla konflikter har ett val."
"Every top-level frame already starts a flow" = "Varje frame på översta nivån startar redan ett flöde"
"Exact only" = "Endast exakt"
"Exact opacity (4 then 5: 45%)" = "Exakt opacitet (4 och sedan 5: 45 %)"
"Exclude" = "Uteslut"
"Excluded" = "Utesluten"
@@ -500,8 +513,10 @@ name = "Svenska"
"Export {n} layers" = { one = "Exportera {n} lager", other = "Exportera {n} lager" }
"Exported {file}" = "Exporterade {file}"
"Exported {name}" = "Exporterade {name}"
"Exported {name}; {n} kinds of content were changed or left out" = { one = "Exporterade {name}; {n} typ av innehåll ändrades eller utelämnades", other = "Exporterade {name}; {n} typer av innehåll ändrades eller utelämnades" }
"Exported {name}; {n} kinds of content were left out" = { one = "Exporterade {name}; {n} typ av innehåll utelämnades", other = "Exporterade {name}; {n} typer av innehåll utelämnades" }
"Exported {n} files" = { one = "Exporterade {n} fil", other = "Exporterade {n} filer" }
"Exported {n} files; some content was changed or left out" = { one = "Exporterade {n} fil; en del innehåll ändrades eller utelämnades", other = "Exporterade {n} filer; en del innehåll ändrades eller utelämnades" }
"Export…" = "Exportera…"
"Expose properties to instances" = "Exponera egenskaper för instanser"
"Exposure" = "Exponering"
@@ -516,6 +531,7 @@ name = "Svenska"
"Fetching from the server…" = "Hämtar från servern…"
"FigCraft couldn't open its window: {error}" = "FigCraft kunde inte öppna sitt fönster: {error}"
"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft stöder ännu inte alla funktioner i de här filerna. Anteckningarna visar vad som utelämnades eller ändrades i den importerade kopian; originalfilen är oförändrad."
"FigCraft {version} is currently the newest version available." = "FigCraft {version} är den senaste tillgängliga versionen."
"File" = "Fil"
"File name" = "Filnamn"
"File not found" = "Filen hittades inte"
@@ -566,6 +582,7 @@ name = "Svenska"
"Font size" = "Teckenstorlek"
"Font style" = "Teckenstil"
"Forget on this computer" = "Glöm på den här datorn"
"Format" = "Format"
"Four screens of a trail app with components and a prototype flow" = "Fyra skärmar i en vandringsapp med komponenter och ett prototypflöde"
"Fractions" = "Bråk"
"Frame" = "Frame"
@@ -643,6 +660,7 @@ name = "Svenska"
"If {condition}" = "Om {condition}"
"Ignore auto layout (absolute position)" = "Ignorera auto layout (absolut position)"
"Ignore overlapping layers" = "Ignorera överlappande lager"
"Ignore unsupported" = "Ignorera det som inte stöds"
"Image" = "Bild"
"Images" = "Bilder"
"Import" = "Importera"
@@ -734,6 +752,7 @@ name = "Svenska"
"Layout grids" = "Layoutrutnät"
"Leave session" = "Lämna sessionen"
"Leaves" = "Löv"
"Leaving the prototype" = "Du lämnar prototypen"
"Left" = "Vänster"
"Left & right" = "Vänster och höger"
"Left padding" = "Vänster padding"
@@ -853,8 +872,8 @@ name = "Svenska"
"New board" = "Ny whiteboard"
"New design file" = "Ny designfil"
"Newest first" = "Nyaste först"
"Next frame" = "Nästa frame"
"Next or previous control in the focused region" = "Nästa eller föregående kontroll i den fokuserade regionen"
"Next frame" = "Nästa frame"
"Next ready for dev" = "Nästa redo för utveckling"
"Next size option" = "Nästa storleksalternativ"
"No assets: no export settings, icons or images. Add export settings in the Design tab." = "Inga resurser: inga exportinställningar, ikoner eller bilder. Lägg till exportinställningar på fliken Design."
@@ -872,8 +891,8 @@ name = "Svenska"
"No conflicts." = "Inga konflikter."
"No contributor data was built into this copy." = "Inga uppgifter om bidragsgivare finns inbyggda i den här kopian."
"No device" = "Ingen enhet"
"No files or templates match your search" = "Inga filer eller mallar matchar sökningen"
"No fonts match" = "Inga typsnitt matchar"
"No files or templates match your search" = "Inga filer eller mallar matchar sökningen"
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Ingen anslutningskod här (servern är äldre än anslutningskoder, eller så kan den här adressen inte göras om till en): dela länken i stället."
"No layer changes to show." = "Inga lagerändringar att visa."
"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Inga bibliotek än. Lägg till en .figcraft-fil som har publicerats som bibliotek, eller klistra in länken till ett teambibliotek."
@@ -918,6 +937,7 @@ name = "Svenska"
"Not read yet" = "Inte läst än"
"Not shared" = "Inte delad"
"Note {n}" = "Anteckning {n}"
"Nothing changed since this version." = "Ingenting har ändrats sedan den här versionen."
"Nothing changed since this was marked ready for dev." = "Inget har ändrats sedan detta markerades som klart för utveckling."
"Nothing here yet. Set a frame's status to list it." = "Inget här än. Ange en frames status för att lista den."
"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Inget online än. Använd Dela på en fil för att lägga ut den online, så visas den här."
@@ -972,8 +992,8 @@ name = "Svenska"
"Options" = "Alternativ"
"Orange" = "Orange"
"Order" = "Ordning"
"Other" = "Annat"
"Other apps may not open every .fig file exported here." = "Andra appar kanske inte kan öppna alla .fig-filer som exporteras här."
"Other" = "Annat"
"Others lose access and keep their copy; the file stays on the server for you" = "Andra förlorar åtkomst och behåller sin kopia; filen ligger kvar på servern åt dig"
"Others type this under Share ▸ Join with a code" = "Andra skriver in den under Dela ▸ Anslut med en kod"
"Outline stroke" = "Konvertera kontur till form"
@@ -1009,7 +1029,10 @@ name = "Svenska"
"Pass through" = "Genomsläpp"
"Paste a link (https://…)" = "Klistra in en länk (https://…)"
"Paste a team library link (a view link)" = "Klistra in en länk till ett teambibliotek (en visningslänk)"
"Paste effect" = "Klistra in effekt"
"Paste fill" = "Klistra in fyllning"
"Paste here" = "Klistra in här"
"Paste stroke" = "Klistra in kontur"
"Paste to replace" = "Klistra in och ersätt"
"Pasted design layers; {n} kinds of content were left out" = { one = "Designlager inklistrade; {n} typ av innehåll utelämnades", other = "Designlager inklistrade; {n} typer av innehåll utelämnades" }
"Pattern" = "Mönster"
@@ -1035,9 +1058,9 @@ name = "Svenska"
"Place automatically" = "Placera automatiskt"
"Place image" = "Placera bild"
"Plugin" = "Plugin"
"Plugins & widgets" = "Plugins och widgetar"
"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Plugins körs i FigCrafts sandlåda, utan filer, nätverk eller klocka. Installera bara plugins du litar på."
"Plugins" = "Plugins"
"Plugins & widgets" = "Plugins och widgetar"
"Plus darker" = "Plus mörkare"
"Plus lighter" = "Plus ljusare"
"Plus one" = "Plus ett"
@@ -1107,6 +1130,7 @@ name = "Svenska"
"Reduce motion" = "Minska rörelse"
"Refraction" = "Brytning"
"Regular expression" = "Reguljärt uttryck"
"Relative to" = "Relativt till"
"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relativt systemets skalning av skärmen (100 % är systemets egen storlek). Dokumentets zoom är separat."
"Remove" = "Ta bort"
"Remove all" = "Ta bort alla"
@@ -1139,6 +1163,7 @@ name = "Svenska"
"Removed" = "Borttaget"
"Removed from the server" = "Borttagen från servern"
"Removed since it was marked" = "Borttaget sedan det märktes"
"Removed since this version" = "Borttaget sedan den här versionen"
"Rename" = "Byt namn"
"Rename duplicated layers" = "Byt namn på duplicerade lager"
"Rename folder" = "Byt namn på mapp"
@@ -1211,9 +1236,6 @@ name = "Svenska"
"Run {label}" = "Kör {label}"
"Save file" = "Spara fil"
"Save files automatically" = "Spara filer automatiskt"
"Search files and templates" = "Sök efter filer och mallar"
"Search pages" = "Sök sidor"
"Search results" = "Sökresultat"
"Search templates" = "Sök mallar"
"See all templates" = "Se alla mallar"
"Select layers and they space themselves as their content changes" = "Markera lager så fördelar de avståndet själva när innehållet ändras"
@@ -1240,8 +1262,11 @@ name = "Svenska"
"Search actions, layers and tools" = "Sök åtgärder, lager och verktyg"
"Search assets" = "Sök resurser"
"Search comments" = "Sök i kommentarer"
"Search files and templates" = "Sök efter filer och mallar"
"Search results" = "Sökresultat"
"Search fonts" = "Sök typsnitt"
"Search layers" = "Sök lager"
"Search pages" = "Sök sidor"
"Search shortcuts" = "Sök kortkommandon"
"Section" = "Avsnitt"
"Section color" = "Avsnittsfärg"
@@ -1257,10 +1282,10 @@ name = "Svenska"
"Select overlapping shapes" = "Markera överlappande former"
"Select points to edit their position, radius and mirroring" = "Markera punkter för att redigera deras position, radie och spegling"
"Select slot" = "Markera plats"
"Selection colors" = "Markeringsfärger"
"Selection" = "Markering"
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Markeringsruta, {w} gånger {h} vid {x}, {y}: piltangenterna flyttar den, Shift+pilar eller {cmd}+pilar ändrar storleken, Enter markerar, Escape avbryter"
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Markeringsruta: piltangenterna flyttar den, Shift+pilar eller {cmd}+pilar ändrar storleken, Enter markerar, Escape avbryter"
"Selection colors" = "Markeringsfärger"
"Send backward" = "Flytta bakåt"
"Send report" = "Skicka rapport"
"Send to back" = "Flytta längst bak"
@@ -1268,7 +1293,9 @@ name = "Svenska"
"Server address (ws://host:47900/)" = "Serveradress (ws://host:47900/)"
"Server address" = "Serveradress"
"Server for online sharing" = "Server för delning online"
"Server key" = "Servernyckel"
"Set as default" = "Ange som standard"
"Set corner smoothing to 60%" = "Ställ in hörnutjämning på 60 %"
"Set variable" = "Ange variabel"
"Set variable mode" = "Ange variabelläge"
"Set variable {name}" = "Ange variabel {name}"
@@ -1424,6 +1451,7 @@ name = "Svenska"
"Text" = "Text"
"Text align" = "Textjustering"
"Text content" = "Textinnehåll"
"Text on a path" = "Text längs en bana"
"Text size" = "Textstorlek"
"Text style" = "Textstil"
"Text styles" = "Textstilar"
@@ -1453,15 +1481,18 @@ name = "Svenska"
"This condition is edited as an expression" = "Det här villkoret redigeras som ett uttryck"
"This file" = "Den här filen"
"This file has unsaved changes" = "Filen har osparade ändringar"
"This file is on {server}, open only to you. Share it to let others in with a new code." = "Filen ligger på {server} och är bara öppen för dig. Dela den för att släppa in andra med en ny kod."
"This file is too large to join here (up to {mb} MB)" = "Den här filen är för stor för att ansluta till här (högst {mb} MB)"
"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Den här filen använder typsnitt som inte är installerade på den här datorn. Deras text ritas med ett reservtypsnitt tills du installerar dem."
"This file is on {server}, open only to you. Share it to let others in with a new code." = "Filen ligger på {server} och är bara öppen för dig. Dela den för att släppa in andra med en ny kod."
"This frame is new on the branch." = "Den här framen är ny på grenen."
"This frame isn't on the branch." = "Den här framen finns inte på grenen."
"This link opens a website outside the prototype:" = "Den här länken öppnar en webbplats utanför prototypen:"
"This server is open to the public: no key needed." = "Servern är öppen för alla: ingen nyckel behövs."
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Servern kräver sin nyckel: lägg till ?key=… i adressen (`figcraft-server key` skriver ut den på serverns dator)."
"This server takes files only with its key: see Advanced." = "Servern tar bara emot filer med sin nyckel: se Avancerat."
"Thumbs up" = "Tummen upp"
"This version can't be read." = "Den här versionen kan inte läsas."
"This version doesn't have it: all of it is new." = "Den här versionen saknar det: allt är nytt."
"Tidy up" = "Snygga till"
"Tile" = "Sida vid sida"
"Tile type" = "Upprepningstyp"
@@ -1484,6 +1515,7 @@ name = "Svenska"
"Triangle" = "Triangel"
"Triangle arrow" = "Triangelpil"
"Trigger" = "Utlösare"
"Trigger layer" = "Utlösande lager"
"True" = "Sant"
"Truncate text" = "Korta av text"
"Truncate with an ellipsis" = "Korta av med ellips"
@@ -1511,6 +1543,7 @@ name = "Svenska"
"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Osparade ändringar: spara först ({save}), eller stäng fliken med {alt}-klick för att slänga dem"
"Unsaved changes: save first, or close again with {key} to discard" = "Osparade ändringar: spara först, eller stäng igen med {key} för att slänga dem"
"Up" = "Upp"
"Update check is not supported in the web browser." = "Sökning efter uppdateringar stöds inte i webbläsaren."
"Update plugin" = "Uppdatera plugin"
"Update" = "Uppdatera"
"Update from main" = "Uppdatera från main"
@@ -1576,15 +1609,18 @@ name = "Svenska"
"Wedge" = "Kil"
"Weekly planner" = "Veckoplanering"
"Weight" = "Tjocklek"
"What changed since a saved version" = "Vad som har ändrats sedan en sparad version"
"What you try here changes this preview and the code below, not the file" = "Det du provar här ändrar den här förhandsgranskningen och koden nedan, inte filen"
"What changed since it was marked ready for dev" = "Vad som har ändrats sedan den markerades som klar för utveckling"
"What is wrong with this file? The server's operator gets your report." = "Vad är fel med den här filen? Serverns operatör får din rapport."
"What it's for" = "Vad den är till för"
"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "Vad detta ändrades från är okänt: ändringen gjordes innan FigCraft började spara en kopia av frames som är klara för utveckling. Lös ändringen (eller markera den som klar igen) för att kunna jämföra från och med nu."
"When marked ready" = "När den märktes som klar"
"While hovering" = "Vid hovring"
"While pressing" = "Vid nedtryckning"
"White" = "Vit"
"Who runs it, its terms and how long it keeps files" = "Vem som driver den, dess villkor och hur länge den sparar filer"
"Whole screen" = "Hela skärmen"
"Why it changed" = "Varför det ändrades"
"Widget" = "Widget"
"Widgets" = "Widgetar"
@@ -1663,10 +1699,12 @@ name = "Svenska"
"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "bildfiltren är tvetydiga eftersom båda filterbehållarna finns; filterkonverteringen hoppades över"
"image filters use FigCraft's approximate renderer" = "bildfilter använder FigCrafts ungefärliga rendering"
"instance swap properties whose default component isn't in the file were skipped" = "egenskaper för instansbyte vars standardkomponent inte finns i filen hoppades över"
"instance swap properties whose default component was past the layer limit were skipped" = "egenskaper för instansbyte vars standardkomponent låg över lagergränsen hoppades över"
"instances inside their own main component were imported as frames" = "instanser inuti sin egen huvudkomponent importerades som frames"
"instances of components that aren't in the file show no layers" = "instanser av komponenter som inte finns i filen visar inga lager"
"instances of components that aren't in the file were imported as frames" = "instanser av komponenter som inte finns i filen importerades som frames"
"instances that would nest a component inside itself were imported as frames" = "instanser som skulle nästla en komponent i sig själv importerades som frames"
"instances whose component was past the layer limit were imported as frames" = "instanser vars komponent låg över lagergränsen importerades som frames"
"instances without a main component were imported as empty frames" = "instanser utan huvudkomponent importerades som tomma frames"
"invalid internal .fig text path data was ignored" = "ogiltiga interna .fig-data för text på bana ignorerades"
"invalid or oversized vector networks import as outlines" = "ogiltiga eller för stora vektornätverk importeras som former"
@@ -1947,6 +1985,7 @@ name = "Svenska"
"component.resetInstance" = "Återställ alla ändringar"
"component.resetOverride" = "Återställ override"
"component.restoreMain" = "Återställ huvudkomponent"
"data.fill" = "Fyll kopior med data"
"dev.compare" = "Jämför ändringar"
"dev.focus" = "Fokusvy"
"dev.resolveChange" = "Lös ändring"
@@ -1987,7 +2026,9 @@ name = "Svenska"
"edit.setDefaultProperties" = "Ange som standardegenskaper"
"edit.undo" = "Ångra"
"effect.add" = "Lägg till effekt"
"effect.copy" = "Kopiera effekt"
"effect.move" = "Flytta effekt"
"effect.paste" = "Klistra in effekt"
"effect.remove" = "Ta bort effekt"
"effect.set" = "Ange effekt"
"export.add" = "Lägg till export"
@@ -2001,6 +2042,7 @@ name = "Svenska"
"file.exportLayers" = "Exportera lager"
"file.exportSvg" = "Exportera som SVG"
"file.import" = "Importera"
"file.markSaved" = "Märk som sparad"
"file.moveTab" = "Flytta flik"
"file.new" = "Nytt"
"file.newBoard" = "Ny whiteboard"
@@ -2016,7 +2058,9 @@ name = "Svenska"
"file.saveVersion" = "Spara version"
"file.versionHistory" = "Versionshistorik"
"fill.add" = "Lägg till fyllning"
"fill.copy" = "Kopiera fyllning"
"fill.move" = "Flytta fyllning"
"fill.paste" = "Klistra in fyllning"
"fill.pattern" = "Mönster"
"fill.remove" = "Ta bort fyllning"
"fill.set" = "Ange fyllning"
@@ -2208,7 +2252,9 @@ name = "Svenska"
"shapeBuilder.remove" = "Ta bort områden"
"stroke.add" = "Lägg till kontur"
"stroke.brush" = "Pensel"
"stroke.copy" = "Kopiera kontur"
"stroke.move" = "Flytta kontur"
"stroke.paste" = "Klistra in kontur"
"stroke.remove" = "Ta bort kontur"
"stroke.set" = "Ange kontur"
"stroke.widthProfile" = "Breddprofil"
@@ -2247,6 +2293,7 @@ name = "Svenska"
"text.edit.selectWord" = "Markera ord"
"text.edit.selection" = "Textmarkering"
"text.endPreview" = "Avsluta förhandsgranskning av text"
"text.fit" = "Anpassa texten till rutan"
"text.fonts" = "Lista typsnitt"
"text.italic" = "Kursiv"
"text.lineHeightDown" = "Minska radhöjden"
+11 -6
View File
@@ -247,16 +247,21 @@ fn send(app: &mut FigApp, kind: PointerKind, pos: Pos2, m: &egui::Modifiers) {
}
/// Right-click at screen `pos`: select what's under the pointer (`deep`: the nested layer), then
/// open the context menu there.
/// open the context menu there. A frame's name label counts as the frame, as on a press, so a
/// label drawn over its section picks the frame, not the section behind it. A layer that is
/// already selected keeps the whole selection.
pub fn open_context_menu(app: &mut FigApp, pos: Pos2, deep: bool) {
let p = app.to_page(pos);
if let Ok(ds) = app.session.ds() {
let (doc, page, sel, zoom) = (ds.doc.clone(), ds.page, ds.selection.clone(), ds.view.zoom.max(1e-6));
if let Some(leaf) = app.session.hit(&doc, page, p, 2.0 / zoom) {
let target = figcraft_doc::geometry::select_target(&doc, leaf, &sel, deep);
if !sel.contains(&target) {
app.session.set_selection(vec![target]);
}
// A frame's name label is the frame, as on a press, even where it sits over a section.
let label = figcraft_engine::tools::label_at(&doc, page, &app.session.tool.labels, p);
let hit = app.session.hit(&doc, page, p, 2.0 / zoom);
let target = label.or_else(|| hit.map(|leaf| figcraft_doc::geometry::select_target(&doc, leaf, &sel, deep)));
if let Some(target) = target
&& !sel.contains(&target)
{
app.session.set_selection(vec![target]);
}
}
app.ui.context_menu = Some((pos, app.session.selection()));
+50 -4
View File
@@ -12,6 +12,10 @@
//! renders) nothing is spent up front: on the desktop a watchdog thread renders the preview once
//! the render from scratch has run past [`PREVIEW_AFTER_MS`], beside it (the full render keeps the
//! other cores), and drops it if the full image is there first.
//! The browser renders on the UI thread, so a zoom's full render blocks the page: there a preview
//! is refined only once no newer request has come for [`SETTLE_MS`] (the zoom has paused), or for
//! as long as the last full render took when that is shorter (a wait never costs more than the
//! render it may save).
use std::sync::Arc;
@@ -25,6 +29,10 @@ use web_time::Instant;
pub const PREVIEW_FACTOR: f64 = 4.0;
/// Full renders quicker than this (about a frame) show without a preview.
pub const PREVIEW_AFTER_MS: f64 = 12.0;
/// The browser refines a zoom's preview only once no newer request came for this long (or for as
/// long as the last full render took, when shorter): a full render there blocks the page, and one
/// between every two wheel steps of a zoom held it up for seconds.
pub const SETTLE_MS: u64 = 120;
#[derive(Clone)]
pub struct Job {
@@ -334,14 +342,25 @@ impl Worker {
}
/// The browser's worker: renders inline, one image a frame: a preview first when one is due, the
/// refined image in the next frame unless a newer request replaced the job. Built everywhere so
/// tests run it.
/// refined image once no newer request came for [`SETTLE_MS`] (a zoom has paused), right away
/// when no preview came first (a pan, an edit). Built everywhere so tests run it.
pub struct Inline {
ctx: egui::Context,
pending: Option<Job>,
/// Requests submitted so far.
submitted: u64,
previewed: bool,
/// Whether the pending job's preview was shown (its refinement then waits to settle).
preview_shown: bool,
/// When that preview was handed over: the wait counts from there, not from the request, since
/// rendering the preview itself can take longer than the wait (the page is blocked meanwhile,
/// so no newer request could have come).
since: Instant,
/// How long to wait after a preview, at most: `wait` is this capped by the last full render's
/// time, so a page whose renders are quick is not held coarse for longer than one would take.
settle: std::time::Duration,
/// The wait for the pending job's preview.
wait: std::time::Duration,
renders: Renders,
}
@@ -350,13 +369,29 @@ pub type Worker = Inline;
impl Inline {
pub fn new(ctx: egui::Context) -> Self {
Self { ctx, pending: None, submitted: 0, previewed: false, renders: Renders::default() }
Self::with_settle(ctx, std::time::Duration::from_millis(SETTLE_MS))
}
/// Refining a preview after `settle` without newer requests (tests: zero, the next frame).
pub fn with_settle(ctx: egui::Context, settle: std::time::Duration) -> Self {
Self {
ctx,
pending: None,
submitted: 0,
previewed: false,
preview_shown: false,
since: Instant::now(),
settle,
wait: settle,
renders: Renders::default(),
}
}
pub fn submit(&mut self, job: Job) {
self.pending = Some(job);
self.submitted += 1;
self.previewed = false;
self.preview_shown = false;
}
/// Whether a request is still to be refined (a preview's wait counts).
pub fn in_flight(&self) -> bool {
self.pending.is_some()
}
@@ -364,10 +399,21 @@ impl Inline {
if !self.previewed {
self.previewed = true;
if let Some(d) = self.pending.as_ref().and_then(|job| self.renders.preview(job)) {
self.ctx.request_repaint();
self.preview_shown = true;
self.since = Instant::now();
let full = std::time::Duration::try_from_secs_f64(self.renders.full_ms / 1000.0);
self.wait = full.map_or(self.settle, |full| full.min(self.settle));
self.ctx.request_repaint_after(self.wait);
return Some(answering(d, self.submitted));
}
}
if self.pending.is_some() && self.preview_shown {
let waited = self.since.elapsed();
if waited < self.wait {
self.ctx.request_repaint_after(self.wait - waited);
return None;
}
}
let job = self.pending.take()?;
self.renders.refine(&job).map(|d| answering(d, self.submitted))
}
+105 -3
View File
@@ -17,7 +17,9 @@ use serde_json::json;
use super::{CanvasState, shows};
use figcraft_render::Retained;
use crate::render_worker::{Done, Inline, Job, PREVIEW_AFTER_MS, PREVIEW_FACTOR, Renders, Worker, better, coarse_view, preview_with, render_now};
use crate::render_worker::{
Done, Inline, Job, PREVIEW_AFTER_MS, PREVIEW_FACTOR, Renders, SETTLE_MS, Worker, better, coarse_view, preview_with, render_now,
};
/// A page of ellipses with a rectangle on top; the session and the rectangle.
fn document() -> (Session, NodeId) {
@@ -392,8 +394,8 @@ fn the_browser_worker_previews_by_measured_render_time_then_refines_the_next_fra
let s = slow_scene(&[view(2.0), view(1.0)], 3.0 * PREVIEW_AFTER_MS);
let ds = s.ds().unwrap();
let at = |scale| job(&ds.doc, ds.page, ds.uid, view(scale));
// The production threshold, as the browser has it.
let mut w = Inline::new(egui::Context::default());
// The production threshold, as the browser has it; refined the next frame (settling: below).
let mut w = Inline::with_settle(egui::Context::default(), std::time::Duration::ZERO);
w.submit(at(2.0));
let first = w.poll().expect("the first image");
assert!(!first.coarse && !w.in_flight(), "the first render has no measure to preview by");
@@ -414,3 +416,103 @@ fn the_browser_worker_previews_by_measured_render_time_then_refines_the_next_fra
assert!(w.poll().is_some_and(|d| !d.coarse && d.view.scale == 0.25));
assert!(w.poll().is_none() && !w.in_flight());
}
#[test]
fn the_browser_worker_refines_a_zoom_only_once_it_settles() {
let view = |scale| view_at((300.0, 200.0), scale, 1200, 1200);
let s = slow_scene(&[view(2.0), view(1.0)], 3.0 * PREVIEW_AFTER_MS);
let ds = s.ds().unwrap();
let at = |scale| job(&ds.doc, ds.page, ds.uid, view(scale));
let settle = std::time::Duration::from_millis(SETTLE_MS);
let mut w = Inline::with_settle(egui::Context::default(), settle);
w.submit(at(2.0));
assert!(w.poll().is_some_and(|d| !d.coarse), "the first render: no preview, no wait");
// Zooming: each step shows its preview, never a full render in between.
for scale in [1.5, 1.0] {
w.submit(at(scale));
assert!(w.poll().is_some_and(|d| d.coarse));
assert!(w.poll().is_none() && w.in_flight(), "not refined while the zoom may go on");
}
std::thread::sleep(settle);
let d = w.poll().expect("refined once it settled");
assert!(!d.coarse && d.view.scale == 1.0 && !w.in_flight());
// A pan at the same zoom has no preview and is refined at once.
w.submit(job(&ds.doc, ds.page, ds.uid, view_at((340.0, 200.0), 1.0, 1200, 1200)));
assert!(w.poll().is_some_and(|d| !d.coarse));
}
/// The wait counts from the preview being shown, not from the request: rendering the preview can
/// take longer than the wait (the page is blocked meanwhile, so no newer request could come), and
/// counted from the request the refinement would follow in the very next frame. The time the
/// preview's render would take is stood in for by a sleep between the request and the poll.
#[test]
fn the_wait_counts_from_the_preview_not_from_the_request() {
let view = |scale| view_at((300.0, 200.0), scale, 1200, 1200);
// Renders from scratch of at least the settle, so the wait is the whole settle (see the cap).
let s = slow_scene(&[view(2.0), view(1.0)], SETTLE_MS as f64);
let ds = s.ds().unwrap();
let at = |scale| job(&ds.doc, ds.page, ds.uid, view(scale));
let settle = std::time::Duration::from_millis(SETTLE_MS);
let mut w = Inline::with_settle(egui::Context::default(), settle);
w.submit(at(2.0));
assert!(w.poll().is_some_and(|d| !d.coarse));
w.submit(at(1.0));
std::thread::sleep(settle + std::time::Duration::from_millis(30));
assert!(w.poll().is_some_and(|d| d.coarse), "the preview, longer after the request than the wait");
assert!(w.poll().is_none() && w.in_flight(), "the wait starts only now");
std::thread::sleep(settle);
assert!(w.poll().is_some_and(|d| !d.coarse && d.view.scale == 1.0));
}
/// Until the zoom's full render has run, the kept image is of the old zoom, so a pan or an edit
/// at the new zoom renders from scratch too: it shows a preview and restarts the wait rather than
/// running a full render between it and the next step. Once refined, requests at that zoom build
/// on the kept image: no preview, no wait.
#[test]
fn same_zoom_requests_before_the_zoom_refines_keep_waiting_and_refine_at_once_after() {
let view = |c, scale| view_at(c, scale, 1200, 1200);
let s = slow_scene(&[view((300.0, 200.0), 2.0), view((300.0, 200.0), 1.0)], 3.0 * PREVIEW_AFTER_MS);
let ds = s.ds().unwrap();
let at = |c, scale| job(&ds.doc, ds.page, ds.uid, view(c, scale));
let settle = std::time::Duration::from_millis(SETTLE_MS);
let mut w = Inline::with_settle(egui::Context::default(), settle);
w.submit(at((300.0, 200.0), 2.0));
assert!(w.poll().is_some_and(|d| !d.coarse), "the first render: no preview, no wait");
// A zoom step, then a pan at the new zoom before the pause: a preview each, no full render.
w.submit(at((300.0, 200.0), 1.0));
assert!(w.poll().is_some_and(|d| d.coarse));
assert!(w.poll().is_none() && w.in_flight());
w.submit(at((340.0, 200.0), 1.0));
assert!(w.poll().is_some_and(|d| d.coarse), "from scratch still: the kept image is of the old zoom");
assert!(w.poll().is_none() && w.in_flight(), "the wait starts over");
std::thread::sleep(settle);
let d = w.poll().expect("refined once it settled");
assert!(!d.coarse && d.view.scale == 1.0 && !w.in_flight());
// From then on a pan at this zoom builds on the kept image: no preview, refined at once.
w.submit(at((380.0, 200.0), 1.0));
let d = w.poll().expect("the refined image");
assert!(!d.coarse && !w.in_flight());
}
/// The wait never costs more than the full render it may save: a preview is refined once the
/// last full render's time has passed when that is shorter than `SETTLE_MS`, so a key zoom or a
/// page switch on a page whose renders are quick is not held coarse for longer than before.
#[test]
fn the_wait_is_capped_by_the_time_of_the_last_full_render() {
let view = |scale| view_at((300.0, 200.0), scale, 1200, 1200);
let s = slow_scene(&[view(2.0), view(1.0)], 3.0 * PREVIEW_AFTER_MS);
let ds = s.ds().unwrap();
let at = |scale| job(&ds.doc, ds.page, ds.uid, view(scale));
// A settle far longer than this page's renders: the wait is their time instead.
let settle = std::time::Duration::from_secs(10);
let mut w = Inline::with_settle(egui::Context::default(), settle);
w.submit(at(2.0));
let first = w.poll().expect("the first image");
assert!(!first.coarse && first.ms >= PREVIEW_AFTER_MS, "slow enough to preview: {} ms", first.ms);
w.submit(at(1.0));
assert!(w.poll().is_some_and(|d| d.coarse));
assert!(w.poll().is_none() && w.in_flight(), "not refined in the very next frame");
std::thread::sleep(std::time::Duration::from_secs_f64(first.ms / 1000.0));
let d = w.poll().expect("refined once the last full render's time has passed, not the settle");
assert!(!d.coarse && d.view.scale == 1.0 && !w.in_flight());
}
+43
View File
@@ -84,3 +84,46 @@ fn copying_a_link_with_nothing_selected_copies_nothing() {
assert!(copied.lock().unwrap().is_empty(), "no link to node 0");
assert!(u.app.ui.toast.is_none());
}
/// With a section selected, a right-click on either frame inside it links that frame, not the
/// section that was selected before.
#[test]
fn copying_a_link_on_a_frame_inside_a_selected_section_links_that_frame() {
let mut s = Session::new();
let sec = id(&s.execute("node.create", &json!({"type": "section", "x": 0, "y": 0, "width": 800, "height": 400, "name": "Flow"})).unwrap());
let a = id(&s.execute("frame.create", &json!({"x": 20, "y": 60, "width": 300, "height": 200, "name": "main", "parent": sec})).unwrap());
let b = id(&s.execute("frame.create", &json!({"x": 400, "y": 60, "width": 300, "height": 200, "name": "dropdown", "parent": sec})).unwrap());
s.set_selection(vec![sec]);
let copied = Arc::new(Mutex::new(Vec::new()));
let mut u = Ui::new(s);
u.app.services = Box::new(Copied(copied.clone()));
u.frame(vec![]);
for (frame, x) in [(a, 170.0), (b, 550.0)] {
let at = u.app.to_screen(x, 160.0);
crate::canvas::open_context_menu(&mut u.app, at, false);
crate::menus::invoke(&mut u.app, "edit.copyLink");
let last = copied.lock().unwrap().last().cloned();
assert_eq!(last, Some(format!("figcraft://node/{}", frame.0)), "the frame at x={x}");
}
}
/// A frame inside a section has its label above it, inside the section: a right-click there picks
/// the frame whose label it is, not the section behind the label.
#[test]
fn copying_a_link_from_a_frames_name_label_links_that_frame() {
let mut s = Session::new();
let sec = id(&s.execute("node.create", &json!({"type": "section", "x": 0, "y": 0, "width": 800, "height": 400, "name": "Flow"})).unwrap());
let a = id(&s.execute("frame.create", &json!({"x": 20, "y": 80, "width": 300, "height": 200, "name": "main", "parent": sec})).unwrap());
s.set_selection(vec![sec]);
let copied = Arc::new(Mutex::new(Vec::new()));
let mut u = Ui::new(s);
u.app.services = Box::new(Copied(copied.clone()));
u.frame(vec![]);
let quad = u.app.session.tool.labels.iter().find(|(i, _)| *i == a).map(|(_, q)| *q).expect("the frame has a label");
let (cx, cy) = (quad.iter().map(|p| p.x).sum::<f64>() / 4.0, quad.iter().map(|p| p.y).sum::<f64>() / 4.0);
let at = u.app.to_screen(cx, cy);
crate::canvas::open_context_menu(&mut u.app, at, false);
assert_eq!(u.app.session.selection(), vec![a], "the label's frame is selected");
crate::menus::invoke(&mut u.app, "edit.copyLink");
assert_eq!(copied.lock().unwrap().last().cloned(), Some(format!("figcraft://node/{}", a.0)));
}
+2
View File
@@ -187,6 +187,8 @@ mod shortcut_keys_tests;
#[cfg(test)]
mod shortcuts_tests;
#[cfg(test)]
mod stroke_row_tests;
#[cfg(test)]
mod style_folder_rename_tests;
#[cfg(test)]
mod text_enter_tests;
+8 -5
View File
@@ -348,7 +348,12 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI
if !fills && !empty {
let weight = common(doc, sel, |n| n.stroke.weight);
let align = common(doc, sel, |n| n.stroke.align);
let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0;
let sided = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Rectangle)));
// The position and the weight share what the buttons (Advanced, and per-side strokes for
// layers with sides) leave: halving the whole row left the weight too narrow for its value
// beside the variable button and ran the row past the panel's edge (#1452).
let buttons = if sided { 2.0 } else { 1.0 } * (24.0 + 8.0);
let field_w = ((ui.available_width() - PAD * 2.0 - 8.0 - buttons) / 2.0).max(40.0);
row(ui, 32.0, |ui| {
let resp = widgets::dropdown(
ui,
@@ -358,7 +363,7 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI
Some(StrokeAlign::Outside) => tr("Outside"),
None => tr("Mixed"),
},
half,
field_w,
None,
);
menu_popup(&resp).show(|ui| {
@@ -369,8 +374,6 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI
}
}
});
let sided = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Rectangle)));
let weight_w = half - if sided { 60.0 } else { 32.0 };
let e = crate::variable_ui::number_field_mixed(
app,
ui,
@@ -380,7 +383,7 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI
Lead::Icon(Icon::Menu, tr("Stroke weight")),
weight,
"",
weight_w,
field_w,
);
app.field_of(&ids, "strokeWeight", e, |n| n.stroke.weight, |v| json!(v.max(0.0)));
if sided {
+8 -4
View File
@@ -299,10 +299,14 @@ pub struct Presenter {
clock: f64,
/// The layer the Inspect panel shows (the last one under the pointer).
inspected: Option<NodeId>,
urls: Vec<String>,
/// A link to a website waiting for the viewer to open it ([`link_notice`]), and whether "Don't
/// show this again" is ticked.
link_notice: Option<(String, bool)>,
/// The links the last frame's actions opened, and whether the viewer's own click, press, drag
/// or key fired each; the frame-end queues them behind the notice, but for one the viewer
/// fired while choosing to skip the notice (#1096).
urls: Vec<(String, bool)>,
/// A link waiting for the viewer to open it ([`link_notice`]), and whether "Don't show this
/// again" is ticked; `None` when the notice doesn't offer it (the viewer didn't fire the link,
/// so skipping the notice wouldn't cover it, or they skip it already).
link_notice: Option<(String, Option<bool>)>,
/// The editor's document as last copied (the player copies it again when it changes).
source: Option<Arc<Document>>,
/// Scrolling by dragging: what it scrolls and its offset when the drag began.
+23 -4
View File
@@ -36,14 +36,23 @@ impl Presenter {
self.waiting.retain(|w| w.root == root);
let mut did = false;
for w in due.into_iter().filter(|w| w.root == root) {
// An earlier one may have left the screen: the rest are dropped with it.
did = did || self.fire(w.node, w.k, w.rect, now);
// An earlier one may have left the screen: the rest are dropped with it. The gesture
// is long over: what these open is not the viewer's doing (#1096).
did = did || self.fire_from(w.node, w.k, w.rect, now, false);
}
did
}
/// Run the actions of interaction `k` on `node`. `rect` is the layer's screen rect.
pub(crate) fn fire(&mut self, node: NodeId, k: usize, rect: Option<Rect>, now: f64) -> bool {
self.fire_from(node, k, rect, now, true)
}
/// [`fire`](Self::fire); `live`: the viewer's input fired it just now (not a delay it waited
/// out). The links it opens are the viewer's own only when live from a click, press, drag or
/// key: a delay, the pointer passing over a layer and a timed mouse trigger fire without the
/// viewer asking for anything ([`Presenter::urls`], #1096).
fn fire_from(&mut self, node: NodeId, k: usize, rect: Option<Rect>, now: f64, live: bool) -> bool {
let Some(it) = self.doc.as_ref().and_then(|d| d.node(node)).and_then(|n| n.interactions.get(k)).cloned() else { return false };
let dur = it.easing.seconds(it.duration_ms);
let temp = match it.trigger {
@@ -53,9 +62,18 @@ impl Presenter {
};
let snap = if temp.is_some() && self.temp.is_none() { self.snapshot() } else { None };
let mut did = false;
let first_link = self.urls.len();
for a in &it.actions {
did |= self.perform(a, node, it.transition, dur, it.easing, rect, now);
}
let asked = live
&& matches!(
it.trigger,
Trigger::OnClick | Trigger::OnPress | Trigger::OnDrag | Trigger::MouseDown | Trigger::MouseUp | Trigger::KeyPress { .. }
);
for link in self.urls.iter_mut().skip(first_link) {
link.1 = asked;
}
if did && let (Some(press), Some(snap), Some(rect)) = (temp, snap, rect) {
self.temp = Some(Temp { rect, press, snap, transition: it.transition, dur, easing: it.easing });
}
@@ -178,9 +196,10 @@ impl Presenter {
Action::CloseOverlay => self.close_overlay(now),
Action::OpenUrl { url } => {
// The URL comes from the document and this action fires from whatever trigger the
// prototype sets, including a delay, so no click is implied: only a web link opens.
// prototype sets, including a delay, so no click is implied: only a web or mail
// link is kept, as not the viewer's doing until `fire_from` says otherwise.
if figcraft_engine::doc_urls::openable(url) {
self.urls.push(url.clone());
self.urls.push((url.clone(), false));
}
true
}
+29 -13
View File
@@ -239,15 +239,23 @@ fn poll_pads(app: &mut FigApp, p: &mut Presenter, ctx: &egui::Context) {
crate::repaint_in(ctx, PAD_POLL_SECS);
}
/// After a player's frame: the links it opens and the widget clicks it took. A link to a website
/// waits in [`link_notice`] unless the viewer chose to skip the notice; a second link to a website
/// that fires while the notice shows is dropped.
/// After a player's frame: the links it opens and the widget clicks it took. Every link waits in
/// [`link_notice`] — to a website or to the mail app (#1096). Skipping the notice opens at once
/// only a link the viewer's own click, press, drag or key fired, one per frame, and never while a
/// notice shows: a delay or the pointer passing over a layer never opens anything unasked. A
/// further link that fires while the notice shows is dropped, whatever fired it.
fn finish(app: &mut FigApp, p: &mut Presenter) {
for u in p.urls.drain(..) {
if app.session.prefs.link_notice_skipped || !goes_to_a_website(&u) {
let skipped = app.session.prefs.link_notice_skipped;
let mut opened = false;
for (u, asked) in p.urls.drain(..) {
if skipped && asked && !opened && p.link_notice.is_none() {
app.services.open_url(&u);
opened = true;
} else if p.link_notice.is_none() {
p.link_notice = Some((u, false));
p.link_notice = Some((u, (asked && !skipped).then_some(false)));
// A press under way ends here: its release would otherwise reach the prototype
// behind the notice, which takes no input while it shows.
p.press = None;
}
}
if let Some(e) = p.click_widgets(&app.session) {
@@ -255,24 +263,32 @@ fn finish(app: &mut FigApp, p: &mut Presenter) {
}
}
/// Whether `url` is an `http` or `https` link (a `mailto:` link opens the mail app instead).
/// Whether `url` is an `http` or `https` link (of the openable URLs, the others are `mailto:`;
/// [`figcraft_engine::doc_urls`]).
fn goes_to_a_website(url: &str) -> bool {
url.trim().split_once(':').is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https"))
}
/// The notice before a link leaves the prototype for a website: the link, "Don't show this
/// again" (saved with the preferences when Open link is pressed), Open link and Cancel. Escape or
/// the close button cancels.
/// The notice before a link leaves the prototype — for a website, or for the mail app: the link,
/// "Don't show this again" for a link the viewer fired (saved with the preferences when Open link
/// is pressed), Open link and Cancel. Escape or the close button cancels.
fn link_notice(app: &mut FigApp, p: &mut Presenter, ctx: &egui::Context, id: Id) {
let Some((url, skip)) = p.link_notice.as_mut() else { return };
let t = Tokens::get(ctx);
let (mut open, mut go, mut cancel) = (true, false, false);
crate::dialogs::window(tr("Leaving the prototype"), ctx).id(id).open(&mut open).show(ctx, |ui| {
ui.set_width(340.0);
widgets::label(ui, tr("This link opens a website outside the prototype:"), t.text);
let what = if goes_to_a_website(url) {
tr("This link opens a website outside the prototype:")
} else {
tr("This link opens your mail app to write a message:")
};
widgets::label(ui, what, t.text);
ui.add(egui::Label::new(egui::RichText::new(url.as_str()).font(regular(11.0)).color(t.text_secondary)).wrap());
ui.add_space(8.0);
if widgets::checkbox(ui, *skip, tr("Don't show this again")) {
if let Some(skip) = skip.as_mut()
&& widgets::checkbox(ui, *skip, tr("Don't show this again"))
{
*skip = !*skip;
}
ui.add_space(12.0);
@@ -284,7 +300,7 @@ fn link_notice(app: &mut FigApp, p: &mut Presenter, ctx: &egui::Context, id: Id)
cancel |= !open || ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape));
if go {
let Some((url, skip)) = p.link_notice.take() else { return };
if skip {
if skip == Some(true) {
app.run("proto.linkNotice", json!({"show": false}));
app.services.save_prefs(&app.session.prefs);
}
+242 -29
View File
@@ -1,9 +1,13 @@
//! A prototype's Open link action carries a URL from the document and fires from whatever trigger
//! the prototype sets, including a delay, so the player hands the opener only a web link
//! ([`figcraft_engine::doc_urls`]). Without that, a saved document opens `javascript:` in the web
//! build's own origin (stored XSS) and `smb:`/`ms-…:` through the desktop's handlers. A link to a
//! website then waits behind a notice that it leaves the prototype, unless the viewer chose to skip
//! the notice.
//! build's own origin (stored XSS) and `smb:`/`ms-…:` through the desktop's handlers. Every
//! openable link waits behind a notice that it leaves the prototype — to a website or to the
//! mail app (#1096). Skipping the notice opens at once only a link the viewer's own click, press,
//! drag or key fired, one per frame; a delay or the pointer passing over a layer never opens
//! anything unasked. Before, a mail link opened at once however it was fired, and with the notice
//! skipped any trigger opened any number of links: an unattended presentation could open links and
//! mail messages one after another.
use std::sync::{Arc, Mutex};
@@ -35,46 +39,76 @@ fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
/// A presented frame whose layer opens `url` 100ms after the screen appears, with no click
/// anywhere, and with the notice skipped or not. Returns the harness and what the player opened.
/// Frame A presented by `cmd` (`proto.present` or `proto.preview`) with the notice skipped or
/// not, its `interaction` (a trigger and maybe `delayMs`) opening each of `urls`. Returns the
/// harness and what the player opened.
fn playing(cmd: &str, skip_notice: bool, interaction: Value, urls: &[&str]) -> (Harness, Opened) {
let mut s = Session::new();
s.prefs.link_notice_skipped = skip_notice;
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "A"})));
let mut it = interaction;
it["id"] = json!(f);
it["actions"] = urls.iter().map(|u| json!({"type": "openUrl", "url": u})).collect();
it["transition"] = json!({"type": "instant"});
ex(&mut s, "proto.interaction.add", it);
present(cmd, s, f)
}
/// `s` presented by `cmd` from frame `start`, with the opener and the preferences recorded.
fn present(cmd: &str, s: Session, start: figcraft_doc::NodeId) -> (Harness, Opened) {
let seen = Opened::default();
let mut u = Harness::new(s);
u.app.services = Box::new(Opened(seen.0.clone(), seen.1.clone()));
u.ctx.enable_accesskit();
u.run(cmd, json!({"start": start}));
u.frames(2, Vec::new);
(u, seen)
}
/// A presented frame that opens `url` 100ms after the screen appears, with no click anywhere,
/// and with the notice skipped or not. Returns the harness and what the player opened.
fn presented(url: &str, skip_notice: bool) -> (Harness, Opened) {
played("proto.present", url, skip_notice)
}
/// [`presented`], played by `cmd` (`proto.present` or `proto.preview`).
fn played(cmd: &str, url: &str, skip_notice: bool) -> (Harness, Opened) {
let mut s = Session::new();
s.prefs.link_notice_skipped = skip_notice;
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "A"})));
let layer = id(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30, "parent": f})));
ex(
&mut s,
"proto.interaction.add",
json!({"id": layer, "trigger": {"type": "afterDelay", "ms": 100}, "action": {"type": "openUrl", "url": url}, "transition": {"type": "instant"}}),
);
let seen = Opened::default();
let mut u = Harness::new(s);
u.app.services = Box::new(Opened(seen.0.clone(), seen.1.clone()));
u.ctx.enable_accesskit();
u.run(cmd, json!({"start": f}));
let (mut u, seen) = playing(cmd, skip_notice, json!({"trigger": {"type": "afterDelay", "ms": 100}}), &[url]);
// Well past the delay.
u.frames(40, Vec::new);
(u, seen)
}
/// A click on the presented frame, and a few frames for what it fires.
fn click_the_frame(u: &mut Harness) {
let at = egui::pos2(700.0, 290.0);
u.button(at, true);
u.button(at, false);
u.frames(3, Vec::new);
}
/// What a click on a frame whose click opens `url` opens, with the notice skipped.
fn opens_on_a_click(url: &str) -> Vec<String> {
let (mut u, seen) = playing("proto.present", true, json!({"trigger": {"type": "onClick"}}), &[url]);
click_the_frame(&mut u);
opened(&seen)
}
fn opened(seen: &Opened) -> Vec<String> {
seen.0.lock().map(|v| v.clone()).unwrap_or_default()
}
fn opens_after_a_delay(url: &str) -> Vec<String> {
opened(&presented(url, true).1)
}
/// The URL the presenter's notice waits to open.
fn notice(u: &Harness) -> Value {
u.app.inspect()["presenter"]["linkNotice"].clone()
}
/// The labels of the controls drawn now.
fn labels(u: &mut Harness) -> Vec<String> {
let tree = u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes;
tree.into_iter().filter_map(|(_, n)| n.label().map(str::to_string)).collect()
}
/// Clicks the control labeled `label`.
fn click(u: &mut Harness, label: &str) {
let tree = u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes;
@@ -87,10 +121,128 @@ fn click(u: &mut Harness, label: &str) {
#[test]
fn a_prototype_opens_web_links() {
assert_eq!(opens_after_a_delay("https://example.com/a"), ["https://example.com/a"]);
assert_eq!(opens_after_a_delay("mailto:a@b.c"), ["mailto:a@b.c"]);
// A mail link opens the mail app without the notice.
assert_eq!(opened(&presented("mailto:a@b.c", false).1), ["mailto:a@b.c"]);
// The viewer chose to skip the notice: their yes, kept, for the links they click.
assert_eq!(opens_on_a_click("https://example.com/a"), ["https://example.com/a"]);
assert_eq!(opens_on_a_click("mailto:a@b.c"), ["mailto:a@b.c"]);
// With the notice on a mail link waits behind it like a website: nothing reaches the mail
// app, or any opener, unasked (#1096).
let (mut u, seen) = presented("mailto:a@b.c", false);
assert!(opened(&seen).is_empty(), "a mail link fired without a click waits behind the notice");
assert_eq!(notice(&u), "mailto:a@b.c");
click(&mut u, "Open link");
assert_eq!(opened(&seen), ["mailto:a@b.c"]);
assert!(notice(&u).is_null());
}
/// Skipping the notice covers what the viewer does, not what the prototype does by itself: a link
/// on a delay still waits behind the notice, which then offers no "Don't show this again", as
/// that couldn't cover it (#1096).
#[test]
fn a_skipped_notice_still_shows_for_a_link_no_click_fired() {
for url in ["https://example.com/a", "mailto:a@b.c"] {
let (mut u, seen) = presented(url, true);
assert!(opened(&seen).is_empty(), "{url}: a delay opened it with the notice skipped");
assert_eq!(notice(&u), url);
assert!(!labels(&mut u).iter().any(|l| l == "Don't show this again"), "{url}: the checkbox can't skip this notice");
click(&mut u, "Open link");
assert_eq!(opened(&seen), [url]);
}
}
/// A clicked link the notice first shows for offers "Don't show this again" (#1096).
#[test]
fn the_notice_for_a_clicked_link_offers_to_skip_it() {
let (mut u, _) = playing("proto.present", false, json!({"trigger": {"type": "onClick"}}), &["https://example.com/a"]);
click_the_frame(&mut u);
assert_eq!(notice(&u), "https://example.com/a");
assert!(labels(&mut u).iter().any(|l| l == "Don't show this again"));
}
/// With the notice skipped one click opens one link, however many its interaction opens: the
/// next waits behind the notice and the rest are dropped (#1096).
#[test]
fn a_click_opens_one_link_with_the_notice_skipped() {
let urls = ["https://example.com/1", "https://example.com/2", "mailto:a@b.c"];
let (mut u, seen) = playing("proto.present", true, json!({"trigger": {"type": "onClick"}}), &urls);
click_the_frame(&mut u);
assert_eq!(opened(&seen), ["https://example.com/1"]);
assert_eq!(notice(&u), "https://example.com/2");
}
/// The pointer passing over a layer is not the viewer asking for a link: with the notice skipped,
/// a mouse enter's link still waits behind it (#1096).
#[test]
fn a_link_the_pointer_fires_by_passing_waits_with_the_notice_skipped() {
let (mut u, seen) = playing("proto.present", true, json!({"trigger": {"type": "mouseEnter"}}), &["https://example.com/hover"]);
u.frame(vec![egui::Event::PointerMoved(egui::pos2(700.0, 290.0))]);
u.frames(3, Vec::new);
assert!(opened(&seen).is_empty(), "passing over the layer opened it");
assert_eq!(notice(&u), "https://example.com/hover");
}
/// A presentation whose prototype queues several mail links on delays opens none of them by
/// itself: the first waits behind the notice and the rest are dropped with it (#1096).
#[test]
fn delayed_opens_do_not_pile_up_unattended() {
let mut s = Session::new();
s.prefs.link_notice_skipped = true;
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "A"})));
for ms in [100u32, 200, 300] {
let layer = id(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30, "parent": f})));
ex(
&mut s,
"proto.interaction.add",
json!({"id": layer, "trigger": {"type": "afterDelay", "ms": ms}, "action": {"type": "openUrl", "url": format!("mailto:victim@example.com?subject=spam{ms}")}}),
);
}
let (mut u, seen) = present("proto.present", s, f);
u.frames(40, Vec::new); // no click, no key: the presentation alone
assert!(opened(&seen).is_empty(), "a presentation by itself opens nothing, the notice skipped or not");
assert_eq!(notice(&u), "mailto:victim@example.com?subject=spam100", "the first waits, the rest are dropped");
click(&mut u, "Open link");
assert_eq!(opened(&seen), ["mailto:victim@example.com?subject=spam100"]);
}
/// A mail link the viewer clicks is still leaving the prototype: it waits behind the notice like
/// every other link (#1096). Before, a mail link opened straight away, trigger or no trigger.
#[test]
fn a_clicked_mail_link_waits_behind_the_notice() {
let (mut u, seen) = playing("proto.present", false, json!({"trigger": {"type": "onClick"}}), &["mailto:a@b.c?subject=hello"]);
click_the_frame(&mut u);
assert!(opened(&seen).is_empty(), "the notice comes first, whatever fired the link");
assert_eq!(notice(&u), "mailto:a@b.c?subject=hello");
click(&mut u, "Open link");
assert_eq!(opened(&seen), ["mailto:a@b.c?subject=hello"]);
}
/// A mouse-up whose actions run on a delay fires once the click is long over; its link waits
/// behind the notice like a timed one does, the notice skipped or not (#1096).
#[test]
fn a_link_a_click_queued_to_fire_later_waits_behind_the_notice() {
for skip in [false, true] {
let (mut u, seen) = playing("proto.present", skip, json!({"trigger": {"type": "mouseUp"}, "delayMs": 150}), &["https://example.com/late"]);
click_the_frame(&mut u);
u.frames(20, Vec::new); // well past the 150 ms the click queued
assert!(opened(&seen).is_empty(), "skip {skip}: the actions fire after the click is over: not a gesture");
assert_eq!(notice(&u), "https://example.com/late");
}
}
/// A link a key trigger fires waits behind the notice too, and opens at once with it skipped: a
/// key is the viewer's own doing (#1096).
#[test]
fn a_link_fired_by_a_key_waits_behind_the_notice() {
for skip in [false, true] {
let (mut u, seen) = playing("proto.present", skip, json!({"trigger": {"type": "keyPress", "key": "K"}}), &["https://example.com/keyed"]);
u.key(egui::pos2(700.0, 450.0), egui::Key::K);
u.frames(3, Vec::new);
if skip {
assert_eq!(opened(&seen), ["https://example.com/keyed"]);
} else {
assert!(opened(&seen).is_empty(), "a key is not a click");
assert_eq!(notice(&u), "https://example.com/keyed");
}
}
}
#[test]
@@ -192,7 +344,8 @@ fn the_prototype_takes_no_controller_buttons_while_the_notice_shows() {
#[test]
fn dont_show_this_again_is_saved_with_the_preferences() {
let (mut u, seen) = presented("https://example.com/a", false);
let (mut u, seen) = playing("proto.present", false, json!({"trigger": {"type": "onClick"}}), &["https://example.com/a"]);
click_the_frame(&mut u);
click(&mut u, "Don't show this again");
click(&mut u, "Open link");
assert_eq!(opened(&seen), ["https://example.com/a"]);
@@ -216,7 +369,7 @@ fn a_prototype_cannot_open_a_script_or_handler_url() {
"ms-msdt:/id PCWDiagnostic",
"figcraft://join?host=127.0.0.1:47900&file=f&token=t",
] {
assert!(opens_after_a_delay(url).is_empty(), "{url:?} must never reach the opener");
assert!(opens_on_a_click(url).is_empty(), "{url:?} must never reach the opener");
}
}
@@ -251,3 +404,63 @@ fn the_prototype_takes_no_clicks_while_the_notice_shows() {
u.frames(5, Vec::new);
assert_eq!(u.app.inspect()["presenter"]["current"], json!(b), "with the notice gone the same click navigates");
}
/// While a notice shows nothing else opens, even a link the viewer fired with the notice skipped:
/// a press that opened one link and put the next behind the notice then releases over a
/// mouse-up link, which is dropped with the notice up (#1096).
#[test]
fn nothing_opens_while_a_notice_shows_with_the_notice_skipped() {
let mut s = Session::new();
s.prefs.link_notice_skipped = true;
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "A"})));
let open = |u: &str| json!({"type": "openUrl", "url": u});
ex(
&mut s,
"proto.interaction.add",
json!({"id": f, "trigger": {"type": "mouseDown"}, "actions": [open("https://example.com/1"), open("https://example.com/2")], "transition": {"type": "instant"}}),
);
ex(
&mut s,
"proto.interaction.add",
json!({"id": f, "trigger": {"type": "mouseUp"}, "actions": [open("https://example.com/3")], "transition": {"type": "instant"}}),
);
let (mut u, seen) = present("proto.present", s, f);
let at = egui::pos2(700.0, 290.0);
u.button(at, true);
u.frames(2, Vec::new);
assert_eq!(opened(&seen), ["https://example.com/1"]);
assert_eq!(notice(&u), "https://example.com/2");
u.button(at, false);
u.frames(3, Vec::new);
assert_eq!(opened(&seen), ["https://example.com/1"], "the release opened a link with the notice up");
assert_eq!(notice(&u), "https://example.com/2");
}
/// The prototype takes no input while a notice shows, including the release of a press that
/// began before it: a mouse-up that navigates doesn't run behind the notice (#1096).
#[test]
fn a_press_that_raised_a_notice_doesnt_release_behind_it() {
let mut s = Session::new();
s.prefs.link_notice_skipped = true;
let a = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "A"})));
let b = id(&ex(&mut s, "frame.create", json!({"x": 400, "y": 0, "width": 300, "height": 400, "name": "B"})));
let open = |u: &str| json!({"type": "openUrl", "url": u});
ex(
&mut s,
"proto.interaction.add",
json!({"id": a, "trigger": {"type": "mouseDown"}, "actions": [open("https://example.com/1"), open("https://example.com/2")], "transition": {"type": "instant"}}),
);
ex(
&mut s,
"proto.interaction.add",
json!({"id": a, "trigger": {"type": "mouseUp"}, "action": {"type": "navigate", "dest": b}, "transition": {"type": "instant"}}),
);
let (mut u, _) = present("proto.present", s, a);
let at = egui::pos2(700.0, 290.0);
u.button(at, true);
u.frames(2, Vec::new);
assert_eq!(notice(&u), "https://example.com/2");
u.button(at, false);
u.frames(3, Vec::new);
assert_eq!(u.app.inspect()["presenter"]["current"], json!(a), "the release navigated behind the notice");
}
+82
View File
@@ -0,0 +1,82 @@
//! The stroke's position / weight row in the Design panel, driven headless at the panel's default
//! width: the weight field keeps room for its value beside the variable button, whose click opens
//! the variable picker, and the row's buttons stay inside the panel (#1452: the weight field was
//! so narrow that the variable button sat on its icon and hid the value).
use egui::accesskit::Role;
use egui::{Id, Pos2, Rect};
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
/// A stroked rectangle (`rect`) or ellipse, selected.
fn scene(kind: &str) -> Ui {
let mut s = Session::new();
let made = s.execute(&format!("shape.{kind}"), &json!({"x": 0, "y": 0, "width": 200, "height": 120})).unwrap();
let id = made["id"].as_u64().unwrap();
s.execute("stroke.add", &json!({"ids": [id]})).unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
let mut u = Ui::new(s);
u.frame(vec![]);
u
}
/// The area of part `part` ("lead", "field" or "bind") of the stroke weight field.
fn weight(u: &Ui, part: &str) -> Rect {
u.ctx.read_response(Id::unique("stroke_w").with(part)).unwrap_or_else(|| panic!("the weight field's {part} shows")).rect
}
/// The bounds of the button named `label`, found in the AccessKit tree.
fn named_button(u: &mut Ui, label: &str) -> Rect {
u.ctx.enable_accesskit();
u.frame(vec![]);
let out = u.frame_output(vec![]);
let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes;
let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::Button && n.label() == Some(label)).expect(label);
let b = n.bounds().expect("bounds");
Rect::from_min_max(Pos2::new(b.x0 as f32, b.y0 as f32), Pos2::new(b.x1 as f32, b.y1 as f32))
}
/// The variable button clears the weight's icon and leaves the value room for a few digits, for
/// layers with per-side strokes (one more button in the row) and without.
#[test]
fn weight_field_keeps_its_value_beside_the_variable_button() {
for kind in ["rectangle", "ellipse"] {
let u = scene(kind);
let (lead, field, bind) = (weight(&u, "lead"), weight(&u, "field"), weight(&u, "bind"));
assert!(bind.min.x >= lead.max.x + 4.0, "{kind}: the variable button {bind:?} sits on the icon {lead:?}");
assert!(bind.min.x - field.min.x >= 16.0, "{kind}: no room for the value left of the variable button in {field:?} ({bind:?})");
}
}
/// The row's last button (Advanced) stays inside the panel's padding, in line with the panel's
/// icon column, also with the per-side button beside the weight.
#[test]
fn stroke_row_buttons_stay_inside_the_panel() {
for kind in ["rectangle", "ellipse"] {
let mut u = scene(kind);
let advanced = named_button(&mut u, "Advanced stroke");
let panel_right = u.ctx.content_rect().max.x - crate::widgets::PAD;
assert!(advanced.max.x <= panel_right + 0.5, "{kind}: Advanced at {advanced:?} runs past the panel's edge {panel_right}");
if kind == "rectangle" {
let sides = named_button(&mut u, "Individual strokes");
assert!(sides.min.x >= weight(&u, "field").max.x, "{kind}: the per-side button {sides:?} overlaps the weight");
assert!(sides.max.x <= advanced.min.x, "{kind}: the per-side button {sides:?} overlaps Advanced {advanced:?}");
}
}
}
/// A click on the variable button opens the variable picker for the weight.
#[test]
fn variable_button_opens_the_picker() {
let mut u = scene("rectangle");
let field = weight(&u, "field");
let at = weight(&u, "bind").center();
// The button shows while the pointer is over the field.
u.frame(vec![egui::Event::PointerMoved(field.center())]);
u.button(at, true);
u.button(at, false);
u.frame(vec![]);
assert!(crate::variable_ui::picker_open(&u.ctx), "the variable picker opened");
}
+6 -1
View File
@@ -396,7 +396,12 @@ pub(crate) fn request_close(app: &mut FigApp, ctx: &Context) {
}
fn close_current(app: &mut FigApp, ctx: &Context) {
app.windows.closing.push(app.windows.current);
// Once only: a second close of the same window before [`begin`] runs (two quits while the
// main window is hidden, when only the app's logic runs) would act on the window that took its
// place, without asking about that window's files.
if !app.windows.closing.contains(&app.windows.current) {
app.windows.closing.push(app.windows.current);
}
ctx.request_repaint_of(ViewportId::ROOT);
}
+60 -6
View File
@@ -10,18 +10,19 @@ use serde_json::{Value, json};
use crate::control::{ControlReply, ControlRequest};
use crate::{FigApp, NoServices};
/// One main-window frame, as the desktop app runs it; `close` asks the window to close.
fn frame(ctx: &egui::Context, app: &mut FigApp, close: bool) {
/// One main-window frame, as the desktop app runs it; `close` asks the window to close. Returns
/// the commands the frame sent the main window.
fn frame(ctx: &egui::Context, app: &mut FigApp, close: bool) -> Vec<egui::ViewportCommand> {
let mut input = RawInput::default();
if close {
input.viewports.insert(ViewportId::ROOT, ViewportInfo { events: vec![ViewportEvent::Close], ..Default::default() });
}
ctx.run_ui(input, |ui| {
let mut out = ctx.run_ui(input, |ui| {
crate::windows::on_close_request(app, ui.ctx());
app.ui(ui);
})
.textures_delta
.clear();
});
out.textures_delta.clear();
out.viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default()
}
/// A control request queued for the UI, and where its reply arrives.
@@ -501,3 +502,56 @@ fn enter_answers_save() {
assert!(app.ui.quit);
std::fs::remove_dir_all(&d).ok();
}
/// macOS's Quit (⌘Q, the app menu's Quit item, the Dock) reaches the app as a close request on
/// the main window (#857: the craft-menu app menu replaces winit's, whose Quit ended the process
/// at once). With unsaved changes the window cancels the close, so the process stays, and asks;
/// Don't Save then closes it.
#[test]
fn a_quit_with_unsaved_changes_waits_for_the_answer() {
let mut s = Session::new();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
let ctx = egui::Context::default();
let mut app = FigApp::new(s, Box::new(NoServices));
frame(&ctx, &mut app, false);
let sent = frame(&ctx, &mut app, true);
assert!(sent.contains(&egui::ViewportCommand::CancelClose), "the quit is held: {sent:?}");
assert!(app.ui.confirm_close && !app.ui.quit, "and the question asks");
crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::DontSave);
let sent = frame(&ctx, &mut app, false);
assert!(app.ui.quit && sent.contains(&egui::ViewportCommand::Close), "Don't Save quits: {sent:?}");
}
/// A quit while the main window is hidden reaches only the app's logic, which runs without the
/// frame that acts on it; a second quit before the window draws again must not count twice. The
/// main window has no unsaved changes and another window has: one quit lets the main window take
/// that window over (its files keep their changes), and a doubled one must not then quit the app
/// as if those files had been answered for.
#[test]
fn quitting_twice_before_the_window_draws_closes_it_once() {
let mut s = Session::empty();
for n in ["A", "B"] {
s.execute("file.new", &json!({"name": n})).unwrap();
}
let ctx = egui::Context::default();
ctx.set_embed_viewports(false);
let mut app = FigApp::new(s, Box::new(NoServices));
frame(&ctx, &mut app, false);
crate::control::handle(&mut app, &ctx, "window.new", &json!({"index": 0})).unwrap();
frame(&ctx, &mut app, false);
// A, in the other window, has unsaved changes; B, in the main window, has none.
let a = app.session.docs.iter().position(|d| d.doc.name == "A").unwrap();
app.session.execute("doc.switch", &json!({"index": a})).unwrap();
app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
frame(&ctx, &mut app, false);
// Two quits while the main window is hidden: only the logic runs.
for _ in 0..2 {
let mut input = RawInput::default();
input.viewports.insert(ViewportId::ROOT, ViewportInfo { events: vec![ViewportEvent::Close], occluded: Some(true), ..Default::default() });
let _ = ctx.run_logic(&input, |ctx| crate::windows::on_close_request(&mut app, ctx));
}
frame(&ctx, &mut app, false);
frame(&ctx, &mut app, false);
assert!(app.session.docs.iter().any(|d| d.doc.name == "A" && d.is_dirty()), "A keeps its changes: {:?}", names(&app));
assert!(!app.ui.quit, "the app didn't quit over A's unsaved changes");
}
+12 -1
View File
@@ -151,7 +151,18 @@ As built:
colour profile, image presets, annotation categories and library data (whole values); styles,
comment threads, variable collections and variables (one entry per id plus the order of ids;
the whole list when ids aren't unique); dev-mode snapshots (per layer); images (per content
hash, the encoded bytes as base64); a board's timer and vote (whole values, `DocProp::Timer` and
hash, the encoded bytes as base64; as an entry applies the claim is checked: every key is
1–128 ASCII letters, digits, `-` or `_` (a saved file names the image `images/<key>`), a key
with the shape of a content hash (64 hex chars) must be exactly the lowercase sha256 hex of
those bytes (as the app writes them); a side is at most 65 535 px and the decoded bytes at
most 256 MiB (`ImageData::MAX_BYTES`, what the importers read), base64 that can't decode is
refused, a key that already holds an image can't be given other bytes, and an image a layer or
style still shows can't be removed; a snapshot or a server log written before these checks
opens without the image entries they refuse — judged against the state they apply to, so a
logged replacement or removal is left out too — rather than failing, and a file published to a
server is kept without them; a restarted server that left image entries out, or whose saved log
puts images, catches every resuming client up with its snapshot instead of that log); a
board's timer and vote (whole values, `DocProp::Timer` and
`DocProp::Vote`) and the vote's ballots (`Entry::Ballot(voter)`, one per voter, so people voting
at once write different entries). The timer travels as its end time (or, paused, the time left),
never as ticks: each client counts down on its own clock. The page order is the pages'
+3 -2
View File
@@ -1,6 +1,6 @@
# Collaboration parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version
history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model,
@@ -12,7 +12,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
| Feature | Figma | FigCraft | Hours |
|---|---|---|---|
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Left: server archives beyond Linux | 25–55 |
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/<key>` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). Left: server archives beyond Linux | 25–55 |
| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 |
| Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 |
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen; the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 |
@@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
| Date | Change | Summary |
|---|---|---|
| 2026-10-29 | minor | Image entries are checked as they apply (#1086, from a security audit): a key must be 1–128 ASCII letters, digits, `-` or `_` — the same rule a file load reads `images/<key>` members by (`figcraft_doc::ImageData::is_valid_key`, shared with the format crate, which now also reads `-` and `_` names back, so the REST importer's `rest-image-missing` placeholder survives a save) — so no collaborator can write `images/../../x` into everyone's saved file; bytes carried under a key with the shape of a content hash must be what that key hashes to (lowercase sha256 hex, exactly the form `figcraft_format::image_hash` produces — an uppercase look-alike is refused), a side is at most 65 535 px (JPEG and GIF size fields are 16-bit and no image this app realistically opens reaches beyond it), and the decoded bytes at most 256 MiB (`figcraft_doc::ImageData::MAX_BYTES`, the cap the `.fig`, `.sketch` and REST importers read images with, so an image a document imported still reaches everyone) — with the encoded text capped before decoding so a hostile payload can't force a large allocation. A key that already holds an image can't be given other bytes (producers key images by their bytes, and the REST importer's placeholder keys always hold the same picture). A refused entry refuses its batch like any invalid change, so every replica's image store stays clean. History written before these checks — a server's snapshot or log, a file published to a server, a snapshot a client joins with — applies without the image entries they refuse (judged against the state they apply to, so a logged replacement under an existing key, or removal of an image in use, is left out too), instead of the file not opening or a restart dropping every edit after the image; a restarted server that left image entries out, or whose saved log puts images, catches every resuming client up with its snapshot instead of that log, which it can't check against what each client holds. An image a layer or style still shows can't be removed: removing it and putting other bytes under its key would otherwise swap the picture. Keys that aren't content-hash shaped keep working within the bounds (the svg importer's FNV-128 keys, the REST importer's placeholder under an imageRef), and 0×0 entries — what the format crate stores for sizes it can't probe — stay valid. Tests in `image_tests`. Numbers unchanged |
| 2026-10-24 | minor | Branches in live sessions (gap 17): creating, editing, reviewing, updating from main and merging a branch work while its file is in a session. The branch stays with its author and makes its ids in their own client space 2^24 above the main file's, so the server takes the layers it adds; merging is one synced edit and one step of the author's undo history; others' edits on main merge against the branch's own base and conflicts resolve as offline; a view link still refuses merging; a branch made outside the session that adds layers, styles, variables or comments is refused with a reason. A branch's history gets entries for its creation and updates from main. No wire format change. Row wording also brought up to date with #798 (export settings and guides merge item by item). Numbers unchanged |
| 2026-10-17 | minor | From community batch #735: server message limits follow `max_file_mb`, so files above 64 MiB publish and open, the client takes at most 1 GiB and the server warns about large limits with open publishing (#705); a damaged generation field no longer drops a log's flushed edits (#687); the web build joins a session from a `#join=` link in its URL (#712, unit-tested and built for wasm, not run in a browser). Percentages and hours unchanged |
| 2026-10-14 | minor | Removing someone from a live session (M9, from the owner's use: whoever joined with the invite couldn't be put out short of ending the session for everyone): right-click a person's avatar ▸ "Remove <name> from session", or `collab.removePerson {client}`, when you host the session or own the file on a server (owner token). The hub closes their connection as the session's end for them (they keep their copy and don't reconnect) and bars their address from that file while the session or server runs, so the code or link they hold no longer gets them in; everyone else stays. Protocol addition within version 2 (`Admin removePeer`; older servers say they can't). Addresses are the only identity without accounts: people behind one address are barred together. Percentages and hours unchanged |
+3 -2
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
@@ -21,7 +21,7 @@ and prototype interactions are still unchecked there.
| `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP; Open With from Finder, Explorer and Linux file managers once installed; recent files show its `previews/preview.png`) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session) |
| `.jam` (whiteboards), `.deck` (Slides) | native to other Figma products | no | no | `.jam`: not read or written (boards are FigCraft files, see [boards-parity](boards-parity.md)); `.deck` out of scope | — |
| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — |
| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305); Save and auto-save refuse to write over a file another app (figcraft-cli, say) changed since it was opened or saved here, judged by its size and modification time: Revert to saved loads those changes, Save as keeps both, `file.save {overwrite: true}` replaces them (#1330); `kind: "board"` marks a board (left out for design files, so files from before boards are design files; the home screen reads it for the file's mark); board objects keep their `board` metadata on the layer; Dev Mode measurements are kept on the layer they start from (`measurements: [{to} | {side}]`, left out when there are none, so older files open with none) | — |
| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`); image members are `images/<key>`, and a key that can't be one — path text, a space, over 128 chars — is left out with a complaint instead of loaded (`image_tests`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305); Save and auto-save refuse to write over a file another app (figcraft-cli, say) changed since it was opened or saved here, judged by its size and modification time: Revert to saved loads those changes, Save as keeps both, `file.save {overwrite: true}` replaces them (#1330); `kind: "board"` marks a board (left out for design files, so files from before boards are design files; the home screen reads it for the file's mark); board objects keep their `board` metadata on the layer; Dev Mode measurements are kept on the layer they start from (`measurements: [{to} | {side}]`, left out when there are none, so older files open with none) | — |
| Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) |
## Import (place / paste / drop)
@@ -181,6 +181,7 @@ each.
| Date | Change | Summary |
|---|---|---|
| 2026-10-29 | minor | Image key names (#1086): one rule now covers the archive reader, the collab entries and the REST importer's placeholders — 1 to 128 ASCII letters, digits, `-` or `_`. The reader accepts `-` and `_` from now on, so the REST importer's `rest-image-missing` placeholder survives a save and load instead of being dropped as damaged; a placeholder whose `imageRef` can't name an archive member (path text, an over-long ref) or is shaped like a content hash (64 hex digits, which the placeholder's bytes don't hash to) now shares that placeholder instead of being keyed by its raw name. The importers' largest image (256 MiB) is one constant, `figcraft_doc::ImageData::MAX_BYTES`, which live sessions use too. Numbers unchanged |
| 2026-10-26 | minor | Changes made to the open file by another app are no longer lost on save (#1330): the app now remembers the file's size and modification time when it opens, reverts or saves it, and Save and auto-save refuse while the file on disk differs, with a message pointing to Revert to saved or Save as (the tab stays unsaved; closing it stays open). Scripts pass `file.save {overwrite: true}` to replace the file anyway; Save as and a save to an explicit `path` write as before. Not detected: a change of the same size inside one modification-time tick of a coarse file system. No prompt with choices yet, and a change isn't noticed until a save (`save_disk_changes_tests`). Numbers unchanged |
| 2026-10-25 | patch | PNG: the export settings were listed as "contents only" and "include bounding box (the layer's own box)"; the row now names ignore overlapping layers, on by default, and says that the Export section offers include bounding box for text layers (#1307), while `file.export` and `export.update` take it for any layer. Numbers unchanged |
| 2026-10-24 | patch | Fonts: the missing-font row said "warnings only (REST import)"; it now names the Missing fonts window (#752), `text.missingFonts` and the `missing font` lines of `figcraft-cli info`. Replacing a missing font is still missing. Numbers unchanged |
+7 -1
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -1,6 +1,6 @@
# Hardware and platform parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)
How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is
Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out
@@ -39,7 +39,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
| Platform | Figma | FigCraft | Notes |
|---|---|---|---|
| macOS | desktop app (Intel and Apple silicon) | universal `.dmg`, signed and notarized (v0.2.0) | where nearly all QA happens. Quitting ends the process once the preferences, recovery copies and live sessions are taken care of: the window's teardown crashed on Macs with a Touch Bar (#781); Quit from the menu bar, which asks nothing, keeps unsaved files' recovery copies for the next start |
| macOS | desktop app (Intel and Apple silicon) | universal `.dmg`, signed and notarized (v0.2.0) | where nearly all QA happens. Quitting ends the process once the preferences, recovery copies and live sessions are taken care of: the window's teardown crashed on Macs with a Touch Bar (#781); the Quit menu and ⌘Q ask about unsaved changes like the main window's close button (#857), and like it close the main window only: with several windows open, each quit closes one; a quit that never asked — killed or crashed — still leaves recovery copies for the next start |
| Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR |
| Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here. Clipboard (also the BSDs): reads run on a helper thread that gives up after 2 s, and images copied in other apps paste under Wayland through the data-control protocol (#693). Under Wayland the app can't tell its window is minimised, so control requests that need a drawn frame may still wait there (#720). None of this run on a Linux desktop here. On a Wayland compositor without server-side decorations (GNOME) the window gets winit's Adwaita controls (#592: checked by its author in a GNOME Wayland session; KDE not run) |
| FreeBSD | none | tarball | |
@@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
| Date | Change | Summary |
|---|---|---|
| 2026-10-29 | minor | macOS quit confirmation (#857): winit's default Quit menu item (⌘Q) ended the process through AppKit's `terminate:` at once, so a dirty app died without asking anything. The app now installs the craft-libs app menu (craft-menu): its Quit sends the main window a close request — the same path as its close button — so the unsaved-changes question asks before anything is lost (`windows_tests::a_quit_with_unsaved_changes_waits_for_the_answer`; checked in the app on macOS). Like the close button it closes the main window only, so with several windows open each quit closes one. A second quit before the window draws again (two quits while it is minimised, when only the app's logic runs) counts once: before, it could quit the app over another window's unsaved changes (`windows_tests::quitting_twice_before_the_window_draws_closes_it_once`). Numbers unchanged |
| 2026-10-25 | minor | egui/eframe 0.37 dependency migration: the workspace pins the egui family at 0.37, and the wide-gamut and several-windows rows now cite 0.37. The limitations are unchanged: egui-wgpu 0.37 offers no surface colour-space option, so the screen still shows sRGB; eframe 0.37 still gives only the main window an AccessKit adapter, so files stay tabs while a screen reader runs. Numbers unchanged |
| 2026-10-25 | minor | Hidden or unknown-visibility screenshots request a hidden paint and expire at their 60-second admission deadline if unfinished; injected pointer/key/text input waits for a real UI frame. Browser and Wayland hidden-paint behavior remain unqualified. Numbers unchanged. |
| 2026-10-15 | minor | Community fixes (#290, #306, #312, #313, #328, #332, #346, #474): coarse zoom previews when renders from scratch are slow; a minimised or covered window answers control and MCP requests (screenshots and input fail at once while it is hidden); `.figcraft` files open from Finder on macOS; a message box when the window can't open on Windows; on Linux the window id matches the desktop entry, the RPM requires glibc 2.35 and user fonts under `XDG_DATA_HOME` are found. Numbers unchanged |
+3 -2
View File
@@ -1,6 +1,6 @@
# Prototyping parity
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** patch (overlay positions relative to the layer that opens them; the Triggers row no longer lists delays on mouse triggers as missing; top-level frames bigger than the device's screen scroll in the player; Change to, set variable and set variable mode lay out what they change in the player, as the canvas does; presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** patch (Open link notices now cover mail links, and skipping them covers only links the viewer fired (#1096); overlay positions relative to the layer that opens them; the Triggers row no longer lists delays on mouse triggers as missing; top-level frames bigger than the device's screen scroll in the player; Change to, set variable and set variable mode lay out what they change in the player, as the canvas does; presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7)
**Breadth ~92%, ready ~74% (estimated).** Every Figma trigger except media, every action except
media control, every transition and easing exist; key/gamepad triggers take controller buttons,
@@ -94,7 +94,7 @@ tool; opt-in), **silent** (the documentation doesn't say; the case pins the curr
| Hotspot hints, inspect panel in the presenter, comments in the presenter | done | |
| Preview pane following edits live | done | |
| Edit interactions on several layers at once | done | |
| Open link: a notice before a website opens, with a checkbox to skip it on this device | done | `http` and `https` links (a mail link opens at once); Open link opens it, Cancel or Escape drops it; the desktop app saves the checkbox with its preferences, and the web build keeps it until the page reloads; `proto.linkNotice {show}` turns it on or off (`present_links_tests`) |
| Open link: a notice before a link opens, with a checkbox to skip it on this device | done | every openable link — `http`, `https` and `mailto` — and whatever fired it, a click, a delay, a key (a further link that fires while the notice shows is dropped); skipping it opens at once only a link the viewer's own click, press, drag or key fired, one per frame and never while a notice shows — a delay, the pointer passing over a layer or a timed mouse trigger still shows the notice, without the checkbox; a press under way when the notice appears ends there, so its release doesn't reach the prototype behind it; Open link opens it, Cancel or Escape drops it; the desktop app saves the checkbox with its preferences, and the web build keeps it until the page reloads; `proto.linkNotice {show}` turns it on or off (`present_links_tests`) |
| Opening a link in place | missing | |
| Controllers on macOS, Linux and FreeBSD | missing | the usual Rust controller library links the system's libudev on Linux; the pure-Rust desktop builds read none there yet |
| Prototype in a separate window; mirror to a phone | missing | see [hardware-parity](hardware-parity.md) |
@@ -105,6 +105,7 @@ tool; opt-in), **silent** (the documentation doesn't say; the case pins the curr
| Date | Change | Summary |
|---|---|---|
| 2026-10-29 | patch | The Open link notice covers mail links, and skipping it covers only links the viewer fired: before, a mail link opened the mail app at once, also from an "After delay" trigger, and while the notice was skipped any trigger opened any number of web links. Now every openable link waits behind the notice; with it skipped, a link the viewer's own click, press, drag or key fired opens at once, one per frame, while a delay, the pointer passing over a layer or a timed mouse trigger still shows the notice, which then offers no "Don't show this again". Nothing opens while a notice shows, and a press under way when it appears ends there: before, its release could still open a link or navigate behind the notice. The mail notice says the link opens the mail app to write a message (#1096, `crates/ui-egui/src/present_links_tests.rs`). Numbers unchanged |
| 2026-10-24 | patch | The Triggers row's Missing column no longer lists delays on mouse enter, leave, down and up, which landed on 2026-10-13 (`crates/engine/src/proto_delay_tests.rs`, `crates/ui-egui/src/present/delay_tests.rs`). Numbers unchanged |
| 2026-10-24 | patch | Open link to a website first shows a notice that it leaves the prototype, with the link, Open link, Cancel and "Don't show this again", which the desktop app saves with its preferences and the web build keeps until the page reloads (`proto.linkNotice` turns the notice back on); a second link to a website that fires while the notice shows is dropped, and a mail link still opens. Before, the player opened the link at once, also from an After delay trigger. Numbers unchanged |
| 2026-10-23 | patch | A top-level frame taller (or wider) than the device's screen scrolls with no overflow setting, as Figma's documentation says; before, "No scrolling" frames such as the landing page template's Phone frame only clipped. Content past a frame's own edges still scrolls only as its overflow allows. Checked in the app on macOS. Numbers unchanged |