Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 47438dd4a2 Release 0.7.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 21:43:28 -04:00
NathanaelandClaude Opus 5.5 84a86e1d84 fix(ui): Rotation gets its own icon, not Corner radius's (#256) (#769)
* fix(ui): Rotation gets its own icon, not Corner radius's (#256)

The Rotation field showed the corner radius icon, so the two fields
looked alike. Rotation now shows an angle: two rays from a vertex with
the arc between them, drawn in code like the rest of the icon set.

Refs #256 ("The icon for rotating objects is the same as for rounding
corners").

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(ui): the angle icon is drawn unhinted and centred; its test sits with the icons (#256)

From review: the rays and the arc are both unhinted, so the arc meets
the rays at the 14 px field size; the glyph moves up half a unit to
centre it like the other leads; the test moves from a11y_tests.rs to
the icon tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:35:47 -06:00
NathanaelandClaude Opus 5.5 c846f157dc fix(ui): SVG and PDF export rows offer no size (#256) (#768)
* fix(ui): SVG and PDF export rows offer no size (#256)

The size picker (1x, 2x, 512w…) showed on every export row, but SVG
and PDF are vector and their export ignores it (export_bytes_with). It
now shows for PNG, JPG and WebP only, and the suffix field takes its
width.

Refs #256 ("exporting to SVG is offered in different resolutions,
although this should not be the case").

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(ui): export size field as an Option; test WebP too (#256)

From review: the size field's width is an Option instead of a negative
width, and the test checks WebP as well as JPG.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:34:38 -06:00
skylar taylor-barrick 3f5f673ebc Merge pull request #777 from skylartaylor/document-link-schemes
Open only web links and local libraries from a document
2026-10-17 14:34:58 -06:00
skylar taylor-barrick c7acd1a81d chore: clear the rustc 1.96 clippy lints that fail xtask ci
Unrelated to the security changes in this branch; `main` already failed
`clippy --workspace --all-targets -- -D warnings` without them. Eight
clippy::nonminimal_bool sites across seven files, each taking clippy's own
suggested rewrite.

Every rewrite is pure boolean algebra with the negation kept outside, so the
float comparisons and their NaN behaviour are untouched: !A && !B becomes
!(A || B), and !opt.is_some_and(f) becomes opt.is_none_or(!f). Two sites needed
explicit parentheses the suggestion omitted, where the rewritten term is the left
operand of an && and || binds looser.

designcraft cleared the same lint in bc840130; this is figcraft's equivalent.
2026-10-17 13:35:44 -06:00
skylar taylor-barrick f7188de93d Drop a needless borrow in the dev-resource link gate
clippy::needless_borrow; `url` is already a reference. Missed on review because a
workspace clippy run aborts at the first failing crate, which was figcraft-format.
2026-10-17 13:35:44 -06:00
skylar taylor-barrick 37419afe14 Open only web links and local libraries from a document
A document carries things the app acts on for whoever opens it. Three of them were
taken as given.

Prototype Open link and Dev Mode dev resources both reached the platform's opener
with any scheme. In the web build the opener is window.open, so a saved document
could run script in the app's own origin; on the desktop any scheme went to the
system's handler. Both now pass through the new engine module `doc_urls`, which
allows http, https and mailto and nothing else. It is an allow-list on purpose: a
list of known-bad schemes is outgrown by every handler a platform adds.

A figcraft:// invite link joined the session at once. The system hands the app
whatever such link was followed, which may come from a page or a message, so the
Join dialog now opens with the link filled in and the person confirms.

A library link stored in a document was loaded from wherever it pointed. Only a
path on this computer is followed now, so opening someone else's file cannot make
the app contact their server (which on Windows would also send credentials).

Tests: each change has one that fails without it, verified by reverting. The
prototype test fires through an afterDelay trigger, since no click is needed, and
keeps a positive case so a refusal cannot pass vacuously.
2026-10-17 13:19:13 -06:00
BFlat d574eaac1d Merge pull request #775 from storytold/community/batch-15
Batch 15: .fig distributed alignment (#552), REST grid tracks, macOS CJK fonts, pen Esc
2026-10-18 02:18:23 +07:00
25 changed files with 402 additions and 61 deletions

No files matched your search

Generated
+27 -21
View File
@@ -856,6 +856,11 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -1408,7 +1413,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "figcraft"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"arboard",
"eframe",
@@ -1435,7 +1440,7 @@ dependencies = [
[[package]]
name = "figcraft-cli"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-engine",
@@ -1449,7 +1454,7 @@ dependencies = [
[[package]]
name = "figcraft-collab"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@@ -1464,7 +1469,7 @@ dependencies = [
[[package]]
name = "figcraft-collab-net"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab",
@@ -1487,7 +1492,7 @@ dependencies = [
[[package]]
name = "figcraft-color"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"kurbo",
"serde",
@@ -1496,7 +1501,7 @@ dependencies = [
[[package]]
name = "figcraft-doc"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-color",
"figcraft-geom",
@@ -1508,9 +1513,10 @@ dependencies = [
[[package]]
name = "figcraft-engine"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"craft-docpath",
"figcraft-collab",
"figcraft-color",
"figcraft-doc",
@@ -1535,7 +1541,7 @@ dependencies = [
[[package]]
name = "figcraft-format"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-pathops",
@@ -1550,7 +1556,7 @@ dependencies = [
[[package]]
name = "figcraft-geom"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"kurbo",
"serde",
@@ -1558,7 +1564,7 @@ dependencies = [
[[package]]
name = "figcraft-layout"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1566,7 +1572,7 @@ dependencies = [
[[package]]
name = "figcraft-mcp"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab-net",
@@ -1582,7 +1588,7 @@ dependencies = [
[[package]]
name = "figcraft-pathops"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1591,7 +1597,7 @@ dependencies = [
[[package]]
name = "figcraft-pdf"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-color",
"figcraft-doc",
@@ -1608,7 +1614,7 @@ dependencies = [
[[package]]
name = "figcraft-plugin"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-color",
"serde",
@@ -1621,7 +1627,7 @@ dependencies = [
[[package]]
name = "figcraft-render"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"crc32fast",
"figcraft-color",
@@ -1639,7 +1645,7 @@ dependencies = [
[[package]]
name = "figcraft-server"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"crc32fast",
"ctrlc",
@@ -1663,7 +1669,7 @@ dependencies = [
[[package]]
name = "figcraft-svg"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@@ -1679,7 +1685,7 @@ dependencies = [
[[package]]
name = "figcraft-text"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1693,7 +1699,7 @@ dependencies = [
[[package]]
name = "figcraft-ui-egui"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"egui",
@@ -1718,7 +1724,7 @@ dependencies = [
[[package]]
name = "figcraft-web"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"eframe",
"egui",
@@ -5928,7 +5934,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-engine",
"serde_json",
+4 -1
View File
@@ -6,7 +6,7 @@ exclude = ["plugins"]
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
[workspace.package]
version = "0.6.0"
version = "0.7.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -32,6 +32,9 @@ figcraft-mcp = { path = "crates/mcp" }
figcraft-collab-net = { path = "crates/collab-net" }
figcraft-plugin = { path = "crates/plugin" }
# Which paths stored in a document may be followed (craft-libs).
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
thiserror = "2"
+1 -1
View File
@@ -182,7 +182,7 @@ impl Config {
return Err("`idle_days` must be from 0 to 3650".into());
}
let terms = self.terms_url.trim();
if !terms.is_empty() && !(terms.starts_with("https://") || terms.starts_with("http://")) || terms.contains(char::is_whitespace) {
if !(terms.is_empty() || terms.starts_with("https://") || terms.starts_with("http://")) || terms.contains(char::is_whitespace) {
return Err("`terms_url` must be a web address (https://…)".into());
}
if self.operator_contact.chars().count() > 120 || self.operator_contact.chars().any(char::is_control) || terms.len() > 500 {
+1 -1
View File
@@ -203,7 +203,7 @@ fn pairs(query: &str) -> Result<Vec<(String, String)>, String> {
fn with_port(authority: &str, default: u16) -> String {
let has_port = match authority.rsplit_once(':') {
// `[v6]` alone has colons inside the brackets only.
Some((h, p)) => !(h.starts_with('[') && !h.ends_with(']')) && !p.contains(']'),
Some((h, p)) => (!h.starts_with('[') || h.ends_with(']')) && !p.contains(']'),
None => false,
};
if has_port { authority.to_string() } else { format!("{authority}:{default}") }
+1
View File
@@ -20,6 +20,7 @@ figcraft-render = { workspace = true }
figcraft-format = { workspace = true }
figcraft-svg = { workspace = true }
figcraft-pdf = { workspace = true }
craft-docpath = { workspace = true }
figcraft-plugin = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+1 -3
View File
@@ -691,9 +691,7 @@ fn push_changes(s: &mut Session, p: &Value) -> Result<Value> {
let groups: BTreeSet<String> = n
.overrides
.iter()
.filter(|g| {
!matches!(g.as_str(), "size" | "resizing" | "position" | "component" | "props" | SLOT) && !(d == inst && g.as_str() == "name")
})
.filter(|g| !(matches!(g.as_str(), "size" | "resizing" | "position" | "component" | "props" | SLOT) || d == inst && g.as_str() == "name"))
.cloned()
.collect();
for g in &groups {
+1 -1
View File
@@ -1356,7 +1356,7 @@ fn pattern_fill(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "fill.pattern";
let src = req_id(CMD, p, "source")?;
let doc = s.doc()?;
if !doc.node(src).is_some_and(|n| !n.is_page()) {
if doc.node(src).is_none_or(|n| n.is_page()) {
return Err(bad(CMD, "`source` must be a layer"));
}
if req_targets(CMD, s, p)?.iter().any(|t| doc.is_ancestor(*t, src)) {
+1 -1
View File
@@ -1498,7 +1498,7 @@ fn patch_copies(doc: &mut Document, seeds: &[(NodeId, Vec<&'static str>)]) -> bo
let gs: Vec<&'static str> = groups
.iter()
.copied()
.filter(|g| !cn.overrides.contains(*g) && !(root && matches!(*g, "visible" | "name" | "position" | "exports" | "interactions")))
.filter(|g| !(cn.overrides.contains(*g) || root && matches!(*g, "visible" | "name" | "position" | "exports" | "interactions")))
.collect();
if gs.is_empty() {
continue;
+48
View File
@@ -0,0 +1,48 @@
//! Which URLs stored in a document may be handed to the browser or the system opener.
//!
//! A document can carry URLs that the app opens for whoever views it: a prototype's Open link
//! action ([`crate::proto_run`]) and Dev Mode's dev resources ([`crate::dev_resources`]). Both end
//! up at the platform's opener, so a document that someone else made chooses what that opener is
//! asked to do. Only the schemes a browser would follow on a plain link are allowed:
//!
//! - `javascript:` runs script. In the web build the opener is `window.open`, which executes it in
//! the app's own origin, so a saved document becomes stored XSS.
//! - `file:` and `smb:` reach the file system; on Windows a UNC host also sends credentials.
//! - Windows hands `ms-…:` schemes to application handlers, and macOS and Linux hand unknown
//! schemes to whichever application registered them.
//!
//! The check is lexical and does no I/O. It is deliberately an allow-list: a deny-list of known
//! dangerous schemes is outgrown by every new handler a platform adds.
/// The schemes a URL stored in a document may use.
const WEB_SCHEMES: [&str; 3] = ["http", "https", "mailto"];
/// Whether `url`, stored in a document, may be opened.
///
/// Requires a scheme from [`WEB_SCHEMES`], compared ignoring ASCII case. A URL with no scheme is
/// refused: the opener would resolve it against the app's own page on the web. Control characters
/// are refused anywhere, because browsers ignore some of them inside a scheme (`java\tscript:`
/// reaches a `javascript:` handler), and `http`/`https` must name a host.
pub fn openable(url: &str) -> bool {
let url = url.trim();
if url.is_empty() || url.chars().any(char::is_control) {
return false;
}
let Some((scheme, rest)) = url.split_once(':') else { return false };
// An RFC 3986 scheme, so a stray colon in text cannot look like one.
let mut chars = scheme.chars();
if !chars.next().is_some_and(|c| c.is_ascii_alphabetic()) || !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) {
return false;
}
if !WEB_SCHEMES.iter().any(|s| scheme.eq_ignore_ascii_case(s)) {
return false;
}
if scheme.eq_ignore_ascii_case("mailto") {
// `mailto:` names an address.
return !rest.is_empty();
}
// `http` and `https` name a host, so the authority is required: `http:x` is not a web link,
// and `http:///p` has no host.
let Some(after) = rest.strip_prefix("//") else { return false };
!after.is_empty() && !after.starts_with('/')
}
+87
View File
@@ -0,0 +1,87 @@
//! A URL stored in a document reaches the browser or the system opener, so only the schemes a
//! browser follows on a plain link are allowed ([`crate::doc_urls`]).
use crate::doc_urls::openable;
#[test]
fn web_links_are_opened() {
for url in [
"http://example.com",
"https://example.com",
"https://example.com/a/b?c=d#e",
"HTTPS://EXAMPLE.COM",
"HtTp://example.com",
"mailto:[email protected]",
"mailto:[email protected]?subject=hi",
" https://example.com ",
"https://user:[email protected]:8443/p",
] {
assert!(openable(url), "{url:?} should open");
}
}
#[test]
fn script_and_handler_schemes_are_refused() {
for url in [
// Runs in the app's own origin on the web build: a saved document would be stored XSS.
"javascript:alert(1)",
"JavaScript:alert(1)",
"jAvAsCrIpT:alert(document.cookie)",
"vbscript:msgbox(1)",
"data:text/html,<script>alert(1)</script>",
"blob:https://example.com/x",
// Reach the file system; a UNC host also sends Windows credentials.
"file:///etc/passwd",
"file://attacker.example/share/x",
"smb://attacker.example/share/x",
"webdav://attacker.example/x",
// Windows application handlers.
"ms-msdt:/id PCWDiagnostic",
"ms-officecmd:x",
"search-ms:query=x",
"shell:startup",
// Anything else a platform may have registered.
"tel:+1234",
"sms:+1234",
"ssh://host",
"ftp://host/x",
"figcraft://join?host=127.0.0.1",
"zoommtg://zoom.us/join?x",
"view-source:https://example.com",
"jar:https://example.com/x!/y",
] {
assert!(!openable(url), "{url:?} must be refused");
}
}
#[test]
fn a_scheme_is_required_and_control_characters_are_refused() {
for url in [
// No scheme: the web opener would resolve these against the app's own page.
"",
" ",
"example.com",
"/etc/passwd",
"//example.com",
"\\\\host\\share\\x",
"about",
// Browsers ignore some control characters inside a scheme, so a smuggled one must not
// leave `javascript` looking like an unknown scheme that we then refuse by luck.
"java\tscript:alert(1)",
"java\nscript:alert(1)",
"java\rscript:alert(1)",
"javascript\u{0}:alert(1)",
"https://example.com/\u{0}",
// A colon in ordinary text is not a scheme.
"see page 2: the diagram",
"1https://example.com",
// The allowed schemes still need their own part.
"mailto:",
"http:",
"http:example.com",
"http:///p",
"https://",
] {
assert!(!openable(url), "{url:?} must be refused");
}
}
+5
View File
@@ -26,6 +26,7 @@ pub mod dev_resources;
pub mod devcompare;
pub mod devinspect;
pub mod distance;
pub mod doc_urls;
pub mod drop;
pub mod file_access;
pub mod guard;
@@ -1576,6 +1577,8 @@ mod devfocus_tests;
#[cfg(test)]
mod devinspect_tests;
#[cfg(test)]
mod doc_urls_tests;
#[cfg(test)]
mod double_click_tests;
#[cfg(test)]
mod draw_tools_tests;
@@ -1636,6 +1639,8 @@ mod invite_links_tests;
#[cfg(test)]
mod keymap_tests;
#[cfg(test)]
mod library_path_tests;
#[cfg(test)]
mod library_range_styles_tests;
#[cfg(test)]
mod library_variables_tests;
+30
View File
@@ -0,0 +1,30 @@
//! A library link is stored in the document, so it is followed only on this computer
//! ([`crate::library_server`]): a path on another server would make opening someone else's file
//! contact that server, and on Windows send the credentials of whoever opened it.
use crate::Session;
#[test]
fn a_library_is_opened_only_from_this_computer() {
let mut s = Session::new();
for path in [
r"\\attacker.example\share\lib.figcraft",
"//attacker.example/share/lib.figcraft",
"file://attacker.example/share/lib.figcraft",
r"\\?\UNC\attacker.example\share\lib.figcraft",
r"\\.\pipe\lib",
"https://attacker.example/lib.figcraft",
] {
let e = s.fetch_library(path, false).expect_err(&format!("{path} must be refused"));
assert!(e.to_string().contains("only from this computer"), "{path}: {e}");
}
}
#[test]
fn a_local_library_path_is_still_followed() {
let mut s = Session::new();
// Refused for being absent, not for where it is: the gate let it through.
let missing = std::env::temp_dir().join("figcraft-no-such-library.figcraft");
let e = s.fetch_library(&missing.to_string_lossy(), false).expect_err("the file does not exist");
assert!(!e.to_string().contains("only from this computer"), "a local path must not be refused as remote: {e}");
}
+7
View File
@@ -129,6 +129,13 @@ impl Session {
/// document uses the library.
pub fn fetch_library(&mut self, path: &str, fresh: bool) -> Result<Arc<PublishedLibrary>> {
if !is_server_link(path) {
// A library link is stored in the document, so opening someone else's file would
// otherwise reach whatever path it names. Only a path on this computer is followed: a
// network share would make opening a document contact that server, and on Windows
// send the credentials of whoever opened it.
if !craft_docpath::is_local(path) {
return Err(EngineError::Other(format!("{path}: a library is opened only from this computer")));
}
return self.libraries.load(path);
}
let have = match self.libraries.cached(path) {
+8 -7
View File
@@ -1736,7 +1736,7 @@ impl<'d> Exporter<'d> {
let sp = &n.stroke;
let (w, h, weight) = (n.width, n.height, sp.weight);
let plain = n.strokes.iter().any(|p| p.visible) && sp.dashes.is_empty() && sp.sides.is_none() && sp.profile().is_none() && sp.brush.is_none();
if !plain || !(weight.is_finite() && weight > 0.0 && w.is_finite() && h.is_finite()) {
if !(plain && weight.is_finite() && weight > 0.0 && w.is_finite() && h.is_finite()) {
return;
}
let whole = matches!(n.kind, NodeKind::Ellipse { sweep, inner, .. } if inner == 0.0 && sweep.abs() >= std::f64::consts::TAU - 1e-9);
@@ -2217,12 +2217,13 @@ fn outlined(n: &Node) -> Option<(Node, String)> {
NodeKind::Line | NodeKind::Vector { .. } | NodeKind::Ellipse { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Rectangle
);
let shown = |paints: &[Paint]| paints.iter().any(|p| p.visible);
if !shape
|| sp.brush.is_some()
|| !sp.profile().is_some_and(one_sided)
|| !(sp.weight.is_finite() && sp.weight > 0.0)
|| !shown(&n.strokes)
|| shown(&n.fills)
if !(shape
&& sp.brush.is_none()
&& sp.profile().is_some_and(one_sided)
&& sp.weight.is_finite()
&& sp.weight > 0.0
&& shown(&n.strokes)
&& !shown(&n.fills))
{
return None;
}
+1 -1
View File
@@ -2048,7 +2048,7 @@ impl<'a> Mapper<'a> {
self.warn("slot content assignment uses the empty GUID sentinel and was skipped");
continue;
}
if !self.nodes.get(&target).is_some_and(|n| n.get("isSlotContent").and_then(Kv::bool) == Some(true)) {
if self.nodes.get(&target).is_none_or(|n| n.get("isSlotContent").and_then(Kv::bool) != Some(true)) {
self.warn("slot content assignment target is missing or is not slot content");
continue;
}
+3 -2
View File
@@ -635,8 +635,9 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id:
};
ui.painter().text(pos2(x, r.center().y + 7.0), Align2::LEFT_CENTER, widgets::elide(ui, &below, text_w), regular(10.0), t.text_secondary);
crate::a11y::link(&resp, name);
// Opened only on a click, never fetched.
if resp.on_hover_text(url.as_str()).clicked() {
// Opened only on a click, never fetched, and a dev resource's URL comes from the
// document, so only a web link opens.
if resp.on_hover_text(url.as_str()).clicked() && figcraft_engine::doc_urls::openable(url) {
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
}
if let Some(i) = item.index {
+13 -10
View File
@@ -60,20 +60,23 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
}
}
/// One export setting: size (typed or picked), suffix, format, advanced options and remove; a
/// PDF's print settings under it.
/// One export setting: size (typed or picked; images only, SVG and PDF are vector), suffix,
/// format, advanced options and remove; a PDF's print settings under it.
fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &ExportSetting) {
row(ui, 32.0, |ui| {
// Tighter spacing so the advanced options and remove buttons fit beside the fields.
ui.spacing_mut().item_spacing.x = 4.0;
let (size_w, format_w, button, gap) = (52.0, 54.0, 22.0, ui.spacing().item_spacing.x);
let suffix_w = (ui.available_width() - size_w - format_w - button * 2.0 - gap * 4.0).max(32.0);
let presets: Vec<String> =
ExportSize::SCALES.iter().map(|value| ExportSize::Scale { value: *value }.label()).chain(["512w".into(), "512h".into()]).collect();
if let Some(s) = widgets::combo_field(ui, Id::new(("export_size", i)), &e.size.label(), size_w, &presets)
&& ExportSize::parse(&s) != Some(e.size)
{
app.run("export.update", json!({"ids": ids, "index": i, "size": s}));
let (format_w, button, gap) = (54.0, 22.0, ui.spacing().item_spacing.x);
let size_w = e.format.is_raster().then_some(52.0);
let suffix_w = (ui.available_width() - size_w.map_or(0.0, |w| w + gap) - format_w - button * 2.0 - gap * 3.0).max(32.0);
if let Some(size_w) = size_w {
let presets: Vec<String> =
ExportSize::SCALES.iter().map(|value| ExportSize::Scale { value: *value }.label()).chain(["512w".into(), "512h".into()]).collect();
if let Some(s) = widgets::combo_field(ui, Id::new(("export_size", i)), &e.size.label(), size_w, &presets)
&& ExportSize::parse(&s) != Some(e.size)
{
app.run("export.update", json!({"ids": ids, "index": i, "size": s}));
}
}
if let Some(sfx) = widgets::text_field(ui, Id::new(("export_suffix", i)), &e.suffix, suffix_w, tr("Suffix"))
&& sfx != e.suffix
+27 -1
View File
@@ -1,6 +1,7 @@
//! The Export section's Preview row: a slice paints nothing itself, so its preview must show the
//! page content under it — the same thing exporting the slice writes — and not a blank
//! transparency checkerboard next to a correct export.
//! transparency checkerboard next to a correct export. And its setting rows: a size only for
//! images.
use figcraft_doc::{Document, NodeId};
use figcraft_engine::Session;
@@ -50,3 +51,28 @@ fn other_layers_preview_themselves() {
assert_eq!([pix.width(), pix.height()], [200, 100], "the layer's own area");
assert!(paints(&pix));
}
/// SVG and PDF are vector: their export rows offer no size, which their export ignores (#256).
#[test]
fn export_rows_offer_a_size_for_images_only() {
let mut s = Session::new();
let r = crate::present_tests::id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap());
s.execute("select.set", &json!({"ids": [r]})).unwrap();
s.execute("export.add", &json!({"ids": [r]})).unwrap();
let mut u = crate::layers_drag_tests::Ui::new(s);
let size_menu = |u: &crate::layers_drag_tests::Ui| u.ctx.read_response(egui::Id::new(("export_size", 0)).with("menu")).is_some();
u.frame(vec![]);
assert!(size_menu(&u), "PNG has sizes");
for format in ["svg", "pdf"] {
u.app.run("export.update", json!({"ids": [r], "index": 0, "format": format}));
u.frame(vec![]);
u.frame(vec![]);
assert!(!size_menu(&u), "{format} is vector: no size");
}
for format in ["jpg", "webp"] {
u.app.run("export.update", json!({"ids": [r], "index": 0, "format": format}));
u.frame(vec![]);
u.frame(vec![]);
assert!(size_menu(&u), "{format} has sizes again");
}
}
+45
View File
@@ -83,6 +83,7 @@ icons! {
FlipH,
FlipV,
CornerRadius,
Angle,
IndependentCorners,
LinkOn,
LinkOff,
@@ -694,6 +695,12 @@ fn draw(p: &P, icon: Icon) {
p.fill(&[(3.0, 9.0), (13.0, 9.0), (13.0, 14.0)]);
}
CornerRadius => p.corner(2.5, 2.5, 1.0, 1.0, 11.5, 6.0),
Angle => {
// Two rays from a vertex, the arc between them marking the angle (all unhinted, so
// the arc meets the rays at every size).
p.path(&[(13.5, 13.0), (2.5, 13.0), (10.5, 3.0)], false);
p.path(&arc_pts(2.5, 13.0, 6.0, (-10.0f32).atan2(8.0), 0.0).collect::<Vec<_>>(), false);
}
IndependentCorners => {
for (x, y, dx, dy) in [(2.5, 2.5, 1.0, 1.0), (13.5, 2.5, -1.0, 1.0), (2.5, 13.5, 1.0, -1.0), (13.5, 13.5, -1.0, -1.0)] {
p.corner(x, y, dx, dy, 4.0, 2.0);
@@ -1181,4 +1188,42 @@ mod tests {
assert_eq!(triangulate(&rev).len(), (arrow.len() - 2) * 3);
assert!(triangulate(&arrow[..2]).is_empty());
}
/// The points painted inside `area`, relative to the top left of what's painted there (in
/// quarter points), so the same icon gives the same marks wherever its field puts it.
fn marks(shapes: &[egui::epaint::ClippedShape], area: Rect) -> Vec<(i32, i32)> {
fn walk(shape: &Shape, out: &mut Vec<Pos2>) {
match shape {
Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
Shape::LineSegment { points, .. } => out.extend(points),
Shape::Path(p) => out.extend(&p.points),
Shape::Circle(c) => out.push(c.center),
_ => {}
}
}
let mut pts = Vec::new();
shapes.iter().for_each(|c| walk(&c.shape, &mut pts));
pts.retain(|p| area.contains(*p));
let x0 = pts.iter().map(|p| p.x).fold(f32::INFINITY, f32::min);
let y0 = pts.iter().map(|p| p.y).fold(f32::INFINITY, f32::min);
let mut out: Vec<(i32, i32)> = pts.into_iter().map(|p| (((p.x - x0) * 4.0).round() as i32, ((p.y - y0) * 4.0).round() as i32)).collect();
out.sort_unstable();
out.dedup();
out
}
/// The Design panel's Rotation field shows an icon of its own, not Corner radius's (#256).
#[test]
fn rotation_and_corner_radius_fields_show_different_icons() {
let mut s = figcraft_engine::Session::new();
let r = s.execute("shape.rectangle", &serde_json::json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap()["id"].clone();
s.execute("select.set", &serde_json::json!({"ids": [r]})).unwrap();
let mut u = crate::layers_drag_tests::Ui::new(s);
u.frame(vec![]);
let shapes = u.frame_output(vec![]).shapes;
let lead = |name: &str| u.ctx.read_response(egui::Id::new(name).with("lead")).map(|r| r.rect).unwrap_or_else(|| panic!("{name} is shown"));
let (rotation, radius) = (marks(&shapes, lead("pos_rot")), marks(&shapes, lead("ap_rad")));
assert!(!rotation.is_empty() && !radius.is_empty(), "both draw an icon");
assert_ne!(rotation, radius);
}
}
+2
View File
@@ -173,6 +173,8 @@ mod plugins_browse_tests;
#[cfg(test)]
mod popup_width_tests;
#[cfg(test)]
mod present_links_tests;
#[cfg(test)]
mod present_tests;
#[cfg(test)]
mod present_view_tests;
+7 -5
View File
@@ -1047,12 +1047,14 @@ pub fn open_join(app: &mut FigApp, link: &str) {
app.ui.live.focus_join = true;
}
/// An invite link the app was opened with (`figcraft figcraft://join?…`): join at once; a link
/// that can't be used shows in the Join dialog with the reason.
/// An invite link the app was opened with (`figcraft figcraft://join?…`): the Join dialog opens
/// with the link filled in, so joining is the person's own choice.
///
/// The system hands the app any `figcraft://` link that is followed, which may be a page, a
/// message or a document rather than something the person meant to act on, so the link is shown
/// and confirmed rather than acted on.
pub fn open_invite(app: &mut FigApp, link: &str) {
if app.run("collab.join", json!({"link": link.trim()})).is_none() {
open_join(app, link.trim());
}
open_join(app, link.trim());
}
/// Escape closes the Join or Publish dialog (before the canvas's shortcuts see it: it doesn't
+7 -4
View File
@@ -47,12 +47,14 @@ fn escape_closes_the_join_dialog_and_keeps_the_selection() {
}
#[test]
fn opening_the_app_with_an_invite_link_joins() {
fn opening_the_app_with_an_invite_link_asks_before_joining() {
let (mut h, _) = Harness::new();
let link = "figcraft://join?host=127.0.0.1:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
crate::live::open_invite(&mut h.app, &format!(" {link} "));
assert_eq!(h.app.session.live().status, Status::Connecting);
assert!(h.app.ui.live.join.is_none(), "no dialog: it joins at once");
// The system hands the app whatever `figcraft://` link was followed, which may come from a
// page or a message, so the link is shown and confirmed rather than acted on.
assert_eq!(h.app.ui.live.join.as_deref(), Some(link), "the dialog opens with the link filled in");
assert_eq!(h.app.session.live().status, Status::Off, "nothing is joined until the person confirms");
}
/// The browser's link: joins, can't host; joining fails here to show the dialog path.
@@ -82,7 +84,8 @@ fn a_link_that_cant_be_used_opens_the_dialog_with_it() {
h.app.session.live.install(Box::new(Browser));
crate::live::open_invite(&mut h.app, "figcraft://nope");
assert_eq!(h.app.ui.live.join.as_deref(), Some("figcraft://nope"), "the link stays in the field to fix");
assert!(h.app.ui.last_error.as_deref().is_some_and(|e| e.contains("not a live session invite")));
// The link is not tried until the person presses Join, so its reason shows then, not now.
assert!(h.app.ui.last_error.is_none());
assert_eq!(h.app.session.live().status, Status::Off);
}
+3 -1
View File
@@ -865,7 +865,9 @@ impl Presenter {
}
Action::CloseOverlay => self.close_overlay(now),
Action::OpenUrl { url } => {
if !url.trim().is_empty() {
// 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.
if figcraft_engine::doc_urls::openable(url) {
self.urls.push(url.clone());
}
true
+71
View File
@@ -0,0 +1,71 @@
//! 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.
use std::sync::{Arc, Mutex};
use figcraft_engine::Session;
use serde_json::{Value, json};
use crate::Services;
use crate::present_tests::{Ui as Harness, id};
/// Records every URL the player asks the platform to open.
#[derive(Default)]
struct Opened(Arc<Mutex<Vec<String>>>);
impl Services for Opened {
fn open_url(&mut self, url: &str) {
if let Ok(mut v) = self.0.lock() {
v.push(url.to_string());
}
}
}
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 — no click anywhere.
fn opens_after_a_delay(url: &str) -> Vec<String> {
let mut s = Session::new();
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 = Arc::new(Mutex::new(Vec::new()));
let mut u = Harness::new(s);
u.app.services = Box::new(Opened(seen.clone()));
u.run("proto.present", json!({"start": f}));
// Well past the delay.
u.frames(40, Vec::new);
seen.lock().map(|v| v.clone()).unwrap_or_default()
}
#[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:[email protected]"), ["mailto:[email protected]"]);
}
#[test]
fn a_prototype_cannot_open_a_script_or_handler_url() {
for url in [
// In the web build the opener is `window.open`, which runs this in the app's own origin.
"javascript:alert(document.cookie)",
"JavaScript:alert(1)",
"data:text/html,<script>alert(1)</script>",
// Reaches the file system; a UNC host also sends Windows credentials.
"file:///etc/passwd",
"smb://attacker.example/share/x",
// Handed to an application handler by the desktop.
"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");
}
}
+1 -1
View File
@@ -657,7 +657,7 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]
});
widgets::sublabels(ui, tr("Rotation"), None);
row(ui, 32.0, |ui| {
let e = widgets::number_field_mixed(ui, Id::new("pos_rot"), Lead::Icon(Icon::CornerRadius, tr("Rotation")), rot, "°", col, 1.0);
let e = widgets::number_field_mixed(ui, Id::new("pos_rot"), Lead::Icon(Icon::Angle, tr("Rotation")), rot, "°", col, 1.0);
app.field_of(&ids, "rotation", e, rot_of, |v| json!(v));
let items = [
(Icon::Rotate, tr("Rotate 90° right")),