mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-22 19:08:51 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47438dd4a2 | ||
|
|
84a86e1d84 | ||
|
|
c846f157dc | ||
|
|
3f5f673ebc | ||
|
|
c7acd1a81d | ||
|
|
f7188de93d | ||
|
|
37419afe14 | ||
|
|
d574eaac1d |
No files matched your search
Generated
+27
-21
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}") }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('/')
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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")),
|
||||
|
||||
Reference in new issue
Block a user