diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index da85536c4..0a91cc050 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -29,6 +29,10 @@ jobs: name: Windows installation policy (no app build) runs-on: windows-latest steps: + - name: Harden runner + uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4 + with: + egress-policy: audit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d686da692..026d5aac3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,10 @@ name: Release on: + # A nightly dry run on main: every release build except macOS, nothing signed or released + # (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day. + schedule: + - cron: "40 1 * * *" push: branches: [release] workflow_dispatch: @@ -30,6 +34,7 @@ jobs: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/release.yml@main with: + dry-run: ${{ github.event_name == 'schedule' }} app-name: figcraft app-title: FigCraft # Combined Latin presentation and core UI catalog. Required web builds enforce diff --git a/Cargo.lock b/Cargo.lock index 8ca91ac04..306660864 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1134,7 +1134,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" +source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" dependencies = [ "chrono", "dunce", @@ -1144,6 +1144,7 @@ dependencies = [ "image", "log", "plist", + "rustix 1.1.5", "semver", "serde", "serde_json", @@ -1546,6 +1547,7 @@ dependencies = [ "raw-window-handle", "smithay-clipboard", "web-time", + "webbrowser", "windows-sys 0.61.2", "winit", ] @@ -1783,7 +1785,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "figcraft" -version = "0.15.0" +version = "0.18.0" dependencies = [ "arboard", "craft-menu", @@ -1815,7 +1817,7 @@ dependencies = [ [[package]] name = "figcraft-cli" -version = "0.15.0" +version = "0.18.0" dependencies = [ "clap", "clap_complete", @@ -1833,7 +1835,7 @@ dependencies = [ [[package]] name = "figcraft-collab" -version = "0.15.0" +version = "0.18.0" dependencies = [ "base64 0.22.1", "chrono", @@ -1851,7 +1853,7 @@ dependencies = [ [[package]] name = "figcraft-collab-net" -version = "0.15.0" +version = "0.18.0" dependencies = [ "base64 0.22.1", "figcraft-collab", @@ -1874,7 +1876,7 @@ dependencies = [ [[package]] name = "figcraft-color" -version = "0.15.0" +version = "0.18.0" dependencies = [ "kurbo", "serde", @@ -1883,7 +1885,7 @@ dependencies = [ [[package]] name = "figcraft-doc" -version = "0.15.0" +version = "0.18.0" dependencies = [ "figcraft-color", "figcraft-geom", @@ -1896,7 +1898,7 @@ dependencies = [ [[package]] name = "figcraft-engine" -version = "0.15.0" +version = "0.18.0" dependencies = [ "base64 0.22.1", "craft-docpath", @@ -1930,7 +1932,7 @@ dependencies = [ [[package]] name = "figcraft-format" -version = "0.15.0" +version = "0.18.0" dependencies = [ "figcraft-doc", "figcraft-layout", @@ -1947,7 +1949,7 @@ dependencies = [ [[package]] name = "figcraft-geom" -version = "0.15.0" +version = "0.18.0" dependencies = [ "kurbo", "serde", @@ -1955,7 +1957,7 @@ dependencies = [ [[package]] name = "figcraft-layout" -version = "0.15.0" +version = "0.18.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1963,7 +1965,7 @@ dependencies = [ [[package]] name = "figcraft-mcp" -version = "0.15.0" +version = "0.18.0" dependencies = [ "base64 0.22.1", "figcraft-collab-net", @@ -1979,7 +1981,7 @@ dependencies = [ [[package]] name = "figcraft-pathops" -version = "0.15.0" +version = "0.18.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1988,7 +1990,7 @@ dependencies = [ [[package]] name = "figcraft-pdf" -version = "0.15.0" +version = "0.18.0" dependencies = [ "figcraft-color", "figcraft-doc", @@ -2005,7 +2007,7 @@ dependencies = [ [[package]] name = "figcraft-plugin" -version = "0.15.0" +version = "0.18.0" dependencies = [ "figcraft-color", "serde", @@ -2018,7 +2020,7 @@ dependencies = [ [[package]] name = "figcraft-render" -version = "0.15.0" +version = "0.18.0" dependencies = [ "crc32fast", "figcraft-color", @@ -2037,7 +2039,7 @@ dependencies = [ [[package]] name = "figcraft-server" -version = "0.15.0" +version = "0.18.0" dependencies = [ "chrono", "crc32fast", @@ -2062,7 +2064,7 @@ dependencies = [ [[package]] name = "figcraft-svg" -version = "0.15.0" +version = "0.18.0" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -2078,7 +2080,7 @@ dependencies = [ [[package]] name = "figcraft-text" -version = "0.15.0" +version = "0.18.0" dependencies = [ "core-text", "craft-font-catalog", @@ -2096,7 +2098,7 @@ dependencies = [ [[package]] name = "figcraft-ui-egui" -version = "0.15.0" +version = "0.18.0" dependencies = [ "base64 0.22.1", "chrono", @@ -2124,7 +2126,7 @@ dependencies = [ [[package]] name = "figcraft-vectordrawable" -version = "0.15.0" +version = "0.18.0" dependencies = [ "figcraft-color", "figcraft-doc", @@ -2138,7 +2140,7 @@ dependencies = [ [[package]] name = "figcraft-web" -version = "0.15.0" +version = "0.18.0" dependencies = [ "base64 0.22.1", "craft-web-api", @@ -6761,7 +6763,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.15.0" +version = "0.18.0" dependencies = [ "figcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index af0dcaa8b..4a5c019be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ exclude = ["plugins"] # default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"] [workspace.package] -version = "0.15.0" +version = "0.18.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -43,7 +43,7 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l # pull a second egui family version). craft-window = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" } # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] } # The suite's font store: exact lookup of a missing font in the pinned Google Fonts and Noto # indexes, and checksum-verified installs into the one folder every Craft app reads (craft-libs). craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" } diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index f2ea73ba8..386cb1f33 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -23,7 +23,9 @@ craft-window = { workspace = true } # The suite's settings folder: `FIGCRAFT_CONFIG_DIR`, a portable marker, then the platform folder. craft-settings = { workspace = true } egui = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11"] } +# `links`: without it eframe drops every `ctx.open_url` and hyperlink click (the update prompt's +# What's new and Download, About, Credits) with only a log warning. +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "links", "wayland", "x11"] } serde_json = { workspace = true } log = { workspace = true } rfd = { workspace = true } diff --git a/apps/figcraft/src/services_tests.rs b/apps/figcraft/src/services_tests.rs index 970f1729e..0d197f5ce 100644 --- a/apps/figcraft/src/services_tests.rs +++ b/apps/figcraft/src/services_tests.rs @@ -142,3 +142,13 @@ fn the_next_dialog_starts_in_the_folder_of_the_last_pick() { let _ = std::fs::remove_dir_all(&dir); assert_eq!(start_folder(Some(dir)), None, "a folder deleted since: the system's own"); } + +/// The update prompt's What's new and Download, and the About and Credits links, open through +/// `ctx.open_url`. eframe drops those with only a log warning unless it is built with `links`. +#[test] +fn links_in_the_interface_open_in_the_browser() { + let manifest = include_str!("../Cargo.toml"); + let eframe: Vec<&str> = manifest.lines().filter(|l| l.trim_start().starts_with("eframe =")).collect(); + assert!(!eframe.is_empty(), "the app depends on eframe"); + assert!(eframe.iter().any(|l| l.contains("\"links\"")), "eframe is built with `links`: {eframe:?}"); +} diff --git a/crates/engine/src/automation/batch.rs b/crates/engine/src/automation/batch.rs index e0f22d870..62810df36 100644 --- a/crates/engine/src/automation/batch.rs +++ b/crates/engine/src/automation/batch.rs @@ -14,6 +14,40 @@ use crate::{Result, bad}; /// Deepest params nesting the resolver walks before giving up. const MAX_DEPTH: usize = 64; +/// How much referenced data (values and string bytes) the params of one step may copy in. +const MAX_EXPANDED: usize = 8 << 20; + +/// What copying `v` costs: its values and string bytes, counting stops past `limit`. +fn weight(v: &Value, limit: usize) -> usize { + let mut total = 0usize; + let mut stack = vec![v]; + while let Some(v) = stack.pop() { + total += 1; + match v { + Value::String(s) => total += s.len(), + // Checked before they are queued, so a huge list can't grow the worklist past the limit. + Value::Array(a) => { + if total + a.len() > limit { + return total + a.len(); + } + stack.extend(a); + } + Value::Object(o) => { + total += o.keys().map(String::len).sum::(); + if total + o.len() > limit { + return total + o.len(); + } + stack.extend(o.values()); + } + _ => {} + } + if total > limit { + return total; + } + } + total +} + /// One step of a batch: a command, its params and the name later steps can call it by. #[derive(Clone, Debug, PartialEq)] pub struct BatchStep { @@ -99,30 +133,32 @@ impl BatchScope { /// `params` of the next step with every reference in it replaced. pub(crate) fn resolve(&self, params: &Value) -> Result { - self.resolve_in(params, 0) + let mut left = MAX_EXPANDED; + self.resolve_in(params, 0, &mut left) } - fn resolve_in(&self, v: &Value, depth: usize) -> Result { + /// `left`: how much referenced data one step's params may still copy in. + fn resolve_in(&self, v: &Value, depth: usize, left: &mut usize) -> Result { let step = self.next_step(); if depth > MAX_DEPTH { return Err(bad("command.batch", format!("step {step}: params nest deeper than {MAX_DEPTH} levels"))); } match v { - Value::Object(o) if o.contains_key("$step") || o.contains_key("$ref") => self.reference(o), + Value::Object(o) if o.contains_key("$step") || o.contains_key("$ref") => self.reference(o, left), Value::Object(o) => { let mut out = Map::with_capacity(o.len()); for (k, x) in o { - out.insert(k.clone(), self.resolve_in(x, depth + 1)?); + out.insert(k.clone(), self.resolve_in(x, depth + 1, left)?); } Ok(Value::Object(out)) } - Value::Array(a) => a.iter().map(|x| self.resolve_in(x, depth + 1)).collect::>>().map(Value::Array), + Value::Array(a) => a.iter().map(|x| self.resolve_in(x, depth + 1, left)).collect::>>().map(Value::Array), _ => Ok(v.clone()), } } /// The value a `{"$step": n}` or `{"$ref": "name"}` object (with an optional `field`) stands for. - fn reference(&self, o: &Map) -> Result { + fn reference(&self, o: &Map, left: &mut usize) -> Result { let step = self.next_step(); let fail = |msg: String| bad("command.batch", format!("step {step}: {msg}")); let key = if o.contains_key("$step") { "$step" } else { "$ref" }; @@ -153,7 +189,10 @@ impl BatchScope { Some(_) => return Err(fail("`field` must be a string".into())), }; let result = self.results.get(n).ok_or_else(|| fail(format!("{who} has no result")))?; - lookup(result, path).cloned().map_err(|why| fail(format!("{who}'s result has no `{path}`{why}"))) + let value = lookup(result, path).map_err(|why| fail(format!("{who}'s result has no `{path}`{why}")))?; + // Counted before copying: a few references to one big result can't multiply it. + *left = left.checked_sub(weight(value, *left)).ok_or_else(|| fail("the references copy more than the batch may expand".into()))?; + Ok(value.clone()) } /// Most step names a missing-reference error lists. @@ -218,3 +257,27 @@ fn kind(v: &Value) -> &'static str { Value::Object(_) => "an object", } } + +#[cfg(test)] +mod expansion_tests { + use super::*; + use serde_json::json; + + #[test] + fn references_copy_a_bounded_amount_into_one_step() { + let steps = [BatchStep::new("a", json!({})), BatchStep::new("b", json!({}))]; + let mut scope = BatchScope::new(&steps).unwrap(); + // A result of about 1 MB. + scope.push(json!({"ids": vec!["x".repeat(1000); 1000]})); + let refs = |n: usize| json!({"all": vec![json!({"$step": 0, "field": "ids"}); n]}); + assert!(scope.resolve(&refs(4)).is_ok()); + let err = scope.resolve(&refs(200)).unwrap_err().to_string(); + assert!(err.contains("more than the batch may expand"), "{err}"); + } + + #[test] + fn a_huge_list_is_refused_before_it_is_queued() { + assert!(weight(&json!(vec![0; 100_000]), 1000) > 1000); + assert!(weight(&json!(vec![0; 10]), 1000) <= 1000); + } +} diff --git a/crates/engine/src/automation/inspect.rs b/crates/engine/src/automation/inspect.rs index 324635071..931280dba 100644 --- a/crates/engine/src/automation/inspect.rs +++ b/crates/engine/src/automation/inspect.rs @@ -165,6 +165,14 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { /// A node's selected properties, applied at every level of its subtree. pub fn node_json_with_options(doc: &Document, id: NodeId, depth: usize, options: &InspectOptions) -> Value { + node_json_in(doc, id, depth, options, &GridCells::default()) +} + +/// The cells of the grid frames met so far in one inspection, so a grid's geometry is worked out +/// once, not once per child. +type GridCells = std::cell::RefCell>>; + +fn node_json_in(doc: &Document, id: NodeId, depth: usize, options: &InspectOptions, grids: &GridCells) -> Value { let Some(n) = doc.node(id) else { return Value::Null }; let mut v = json!({"id": n.id}); let Some(o) = v.as_object_mut() else { return v }; @@ -176,7 +184,7 @@ pub fn node_json_with_options(doc: &Document, id: NodeId, depth: usize, options: if !n.is_page() { base_props(doc, n, id, o, options); paint_props(n, o, options); - layout_props(doc, n, o, options); + layout_props(doc, n, o, options, grids); dev_props(doc, n, o, options); } // Layer data written with `node.set {props: {pluginData}}` (pages too). @@ -189,7 +197,7 @@ pub fn node_json_with_options(doc: &Document, id: NodeId, depth: usize, options: } if !n.children.is_empty() { if depth > 0 { - o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json_with_options(doc, *c, depth - 1, options)).collect())); + o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json_in(doc, *c, depth - 1, options, grids)).collect())); } else { o.insert("childCount".into(), json!(n.children.len())); } @@ -286,7 +294,7 @@ fn paint_props(n: &Node, o: &mut serde_json::Map, options: &Inspe } } -fn layout_props(doc: &Document, n: &Node, o: &mut serde_json::Map, options: &InspectOptions) { +fn layout_props(doc: &Document, n: &Node, o: &mut serde_json::Map, options: &InspectOptions, grids: &GridCells) { if let Some(l) = &n.layout { insert_prop(o, options, "layout", || camel(serde_json::to_value(l).unwrap_or(Value::Null))); } @@ -303,11 +311,14 @@ fn layout_props(doc: &Document, n: &Node, o: &mut serde_json::Map insert_prop(o, options, k, || json!(v)); } } - if let Some(cell) = (parent_al && options.includes("gridCell")) - .then(|| n.parent.and_then(|p| figcraft_layout::grid_geometry(doc, p))) - .flatten() - .and_then(|g| g.cells.into_iter().find(|c| c.id == n.id)) - { + let cell = (parent_al && options.includes("gridCell")).then_some(n.parent).flatten().and_then(|p| { + let mut grids = grids.borrow_mut(); + let cells = grids + .entry(p) + .or_insert_with(|| figcraft_layout::grid_geometry(doc, p).map(|g| g.cells.into_iter().map(|c| (c.id, c)).collect()).unwrap_or_default()); + cells.get(&n.id).copied() + }); + if let Some(cell) = cell { insert_prop(o, options, "gridCell", || { json!({"column": cell.column, "row": cell.row, "columnSpan": cell.column_span, "rowSpan": cell.row_span, "anchored": cl.grid_column.is_some() || cl.grid_row.is_some(), "alignH": cl.grid_align_h, "alignV": cl.grid_align_v}) diff --git a/crates/engine/src/board_tables_tests.rs b/crates/engine/src/board_tables_tests.rs index 2c0d07bbc..3e7dbad80 100644 --- a/crates/engine/src/board_tables_tests.rs +++ b/crates/engine/src/board_tables_tests.rs @@ -182,6 +182,9 @@ fn table_edits_add_and_remove_rows_and_columns_keeping_text() { #[test] fn a_double_click_types_in_the_cell_and_its_row_grows_with_the_text() { + // Measure with system fonts when the optional craft-fonts checkout is absent, + // without depending on another test having scanned them first. + figcraft_text::scan_system_fonts(); let mut s = board(); let r = s.execute("board.table", &json!({"x": 0, "y": 0})).unwrap(); let t = id(&r, "id"); diff --git a/crates/engine/src/cmd/edit/select.rs b/crates/engine/src/cmd/edit/select.rs index ca1da3bbc..27eaf7d83 100644 --- a/crates/engine/src/cmd/edit/select.rs +++ b/crates/engine/src/cmd/edit/select.rs @@ -174,7 +174,8 @@ pub(super) fn nudge(s: &mut Session, p: &Value) -> Result { // no undo step. let mut work = s.doc()?.clone(); let doc = &mut work; - let ids = roots(doc, &ids); + // A locked layer (or one in a locked layer) ignores the arrow keys. + let ids: Vec = roots(doc, &ids).into_iter().filter(|i| !crate::tools::locked_in(doc, *i)).collect(); let mut changed = false; // Children in an auto layout flow move one place instead (see `al_keys`). for i in &ids { @@ -209,6 +210,9 @@ pub(super) fn nudge_size(s: &mut Session, p: &Value) -> Result { let ids = req_targets("transform.nudgeSize", s, p)?; let ids = roots(s.doc()?, &ids); for i in ids { + if crate::tools::locked_in(s.doc()?, i) { + continue; + } let Some((w, h)) = s.doc()?.node(i).map(|n| (n.width, n.height)) else { continue }; let mut q = serde_json::Map::new(); q.insert("ids".into(), json!([i])); @@ -231,9 +235,16 @@ pub(super) fn toggle(s: &mut Session, p: &Value, what: &str) -> Result { // Figma: if any is visible, hide all (and vice versa). let value = explicit.unwrap_or_else(|| match what { "visible" => !ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.visible)), - _ => !ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.locked)), + // A child of a locked layer counts as locked: its own flag can't be toggled while the + // parent is locked. + _ => !ids.iter().all(|i| crate::tools::locked_in(doc, *i)), }); - for i in &ids { + // Lock/Unlock leaves a child of a locked layer alone, as its padlock does: its own flag must + // still hold once the parent is unlocked. An explicit `locked` (scripts) sets it anyway. + let under_lock = |i: NodeId| doc.ancestors(i).into_iter().any(|a| doc.node(a).is_some_and(|n| n.locked)); + let skip: Vec = + if what == "locked" && explicit.is_none() { ids.iter().copied().filter(|i| under_lock(*i)).collect() } else { Vec::new() }; + for i in ids.iter().filter(|i| !skip.contains(i)) { let n = doc.get_mut(*i)?; if what == "visible" { n.visible = value; diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index fc40cee88..6403dfd4b 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -270,6 +270,13 @@ mod tests { assert_eq!(info["gridCell"]["column"], json!(2)); assert_eq!(info["gridCell"]["row"], json!(1)); assert_eq!(info["gridCell"]["anchored"], json!(true)); + // Inspected together with the frame, each child still gets its own cell. + let all = crate::inspect::node_json(s.doc().unwrap(), f, 1); + let cells: Vec<(Value, Value)> = + all["children"].as_array().unwrap().iter().map(|c| (c["gridCell"]["column"].clone(), c["gridCell"]["row"].clone())).collect(); + assert_eq!(cells[3], (json!(2), json!(1))); + assert_eq!(cells[0].0, json!(0)); + assert!(cells.iter().all(|(c, r)| c.is_number() && r.is_number()), "{cells:?}"); ex(&mut s, "node.set", json!({"id": kids[3], "props": {"gridColumn": "auto", "gridRow": null}})); assert_eq!(node(&s, kids[3]).child_layout.grid_column, None); // Fewer columns drop the extra tracks. diff --git a/crates/engine/src/code_connect_tests.rs b/crates/engine/src/code_connect_tests.rs index 69f655ec6..4474fe03a 100644 --- a/crates/engine/src/code_connect_tests.rs +++ b/crates/engine/src/code_connect_tests.rs @@ -285,3 +285,16 @@ fn vue_layer_trees_import_connected_components_in_script_setup() { assert!(code.starts_with(want), "{code}"); assert_eq!(code.matches("import Card").count(), 1, "{code}"); } + +#[test] +fn many_entries_for_one_component_each_link() { + let mut s = Session::new(); + let (_, hover) = button(&mut s); + let links: Vec = (0..crate::code_connect::MAX_FILE_LINKS) + .map(|i| json!({"component": "Button", "language": if i % 2 == 0 { "react" } else { "vue" }, "name": "Button", "template": "