Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor dfe4646477 chore: bump version to 0.20.1 for the Flatpak update fix 2026-11-11 14:39:16 -05:00
Dylan M. Taylor dc7f382cec Fix Flatpak permissions for update checks and self-updates (#3075)
* fix(flatpak): allow release checks and self-update helpers

* test: keep Flatpak permission parsing within strict Clippy rules
2026-11-11 14:24:01 -05:00
FredApps e5021f0175 Merge pull request #3047 from V1dalTheAbidal/pr/bsaveas-source
Block Editor: BSAVEAS keeps the source block's last saved definition
2026-11-11 18:56:45 +01:00
Dylan M. Taylor 2d25308cfc Merge pull request #3068 from storytold/chore/craft-update-6e03f65
Updates: take craft-update from craft-libs main at 6e03f65bf
2026-11-11 12:43:38 -05:00
Dylan M. Taylor 20b4472f0b Updates: take craft-update from craft-libs main at 6e03f65bf 2026-11-11 12:43:30 -05:00
Dylan M. Taylor ecb362aa55 ci: run Harden-Runner on every job (#3058)
Adds step-security/harden-runner (audit, pinned to v2.22.4 like
craft-common-actions) as the first step of each job that did not have it,
so every workflow in the org's public repos reports its egress to
StepSecurity. Audit, not block: block-mode allow lists come from each
job's observed traffic.
2026-11-11 12:09:19 -05:00
V1dalTheAbidal f132d81609 Document the BSAVEAS source test 2026-11-11 11:14:22 +02:00
V1dalTheAbidal 650ae383f2 Block Editor: BSAVEAS keeps the source block's last saved definition
After BSAVE the editor's block table can still hold the older source definition.
Restore the saved source block from the host when composing a copy and when
reconciling the editor and its history, so a later save of the copy keeps it.
2026-11-11 01:32:16 +02:00
13 changed files with 129 additions and 97 deletions
+4
View File
@@ -27,6 +27,10 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
+4
View File
@@ -27,6 +27,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
+4
View File
@@ -30,6 +30,10 @@ jobs:
permissions:
contents: read
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
Generated
+18 -17
View File
@@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadcraft"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -645,7 +645,7 @@ dependencies = [
[[package]]
name = "cadcraft-cli"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -661,14 +661,14 @@ dependencies = [
[[package]]
name = "cadcraft-color"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-constraints"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"cadcraft-doc",
"cadcraft-geom",
@@ -678,7 +678,7 @@ dependencies = [
[[package]]
name = "cadcraft-doc"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"cadcraft-color",
"cadcraft-geom",
@@ -689,7 +689,7 @@ dependencies = [
[[package]]
name = "cadcraft-dwg"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"acadrust",
"log",
@@ -697,7 +697,7 @@ dependencies = [
[[package]]
name = "cadcraft-dxf"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"encoding_rs",
"log",
@@ -706,7 +706,7 @@ dependencies = [
[[package]]
name = "cadcraft-engine"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"cadcraft-color",
"cadcraft-constraints",
@@ -727,7 +727,7 @@ dependencies = [
[[package]]
name = "cadcraft-fonts"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"cadcraft-dxf",
"cadcraft-geom",
@@ -740,14 +740,14 @@ dependencies = [
[[package]]
name = "cadcraft-geom"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-io"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"acadrust",
"cadcraft-color",
@@ -769,7 +769,7 @@ dependencies = [
[[package]]
name = "cadcraft-mcp"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -784,7 +784,7 @@ dependencies = [
[[package]]
name = "cadcraft-render"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -798,7 +798,7 @@ dependencies = [
[[package]]
name = "cadcraft-ui-egui"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -825,7 +825,7 @@ dependencies = [
[[package]]
name = "cadcraft-web"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"cadcraft-engine",
"cadcraft-fonts",
@@ -1214,7 +1214,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",
@@ -1224,6 +1224,7 @@ dependencies = [
"image",
"log",
"plist",
"rustix 1.1.5",
"semver",
"serde",
"serde_json",
@@ -5993,7 +5994,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"chrono",
"serde_json",
+3 -3
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
# default-members
[workspace.package]
version = "0.20.0"
version = "0.20.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -37,7 +37,7 @@ clap_mangen = { version = "0.3.3", features = ["env"] }
thiserror = "2"
same-file = "1"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip", "raw_value"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
@@ -71,7 +71,7 @@ egui-wgpu = "0.37"
# Monotonic time on native and web (performance.now on wasm).
web-time = "1.1"
# 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"] }
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
+16 -52
View File
@@ -221,17 +221,12 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize, h
}
}
/// Longest request `id` (it is copied into the reply).
const MAX_ID_BYTES: usize = 4096;
/// One request line, parsed.
enum Line<'a> {
enum Line {
Request {
id: Value,
method: String,
/// Left as text until the token is checked: decoding it first would let an unauthenticated
/// peer turn a flat array into many owned JSON values.
params: Option<&'a serde_json::value::RawValue>,
params: Value,
token: Option<String>,
},
Blank,
@@ -240,7 +235,7 @@ enum Line<'a> {
}
/// Parse a UTF-8 JSON request and its optional token, distinguishing blank lines from rejected input.
fn parse_line(bytes: &[u8]) -> Line<'_> {
fn parse_line(bytes: &[u8]) -> Line {
let Ok(text) = std::str::from_utf8(bytes) else {
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
};
@@ -251,28 +246,23 @@ fn parse_line(bytes: &[u8]) -> Line<'_> {
if is_http_request_line(text) {
return Line::Reject("HTTP is not served here: this port takes JSON lines; closing the connection".into());
}
// Checked without building values; then only the object's own keys are read, each value kept as text.
if let Err(e) = serde_json::from_str::<&serde_json::value::RawValue>(text) {
return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection"));
}
let Ok(mut obj) = serde_json::from_str::<std::collections::BTreeMap<String, &serde_json::value::RawValue>>(text) else {
let msg = match serde_json::from_str::<Value>(text) {
Ok(v) => v,
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
};
let Value::Object(mut obj) = msg else {
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
};
// `method` and `token` are decoded as strings only (anything else is no string); `id` is echoed back
// in replies, so it is decoded but bounded.
let string = |v: Option<&serde_json::value::RawValue>| v.and_then(|r| serde_json::from_str::<String>(r.get()).ok());
let Some(method) = string(obj.remove("method")) else {
let Some(Value::String(method)) = obj.remove("method") else {
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
};
let id = match obj.remove("id") {
None => Value::Null,
Some(r) if r.get().len() <= MAX_ID_BYTES => match serde_json::from_str(r.get()) {
Ok(v) => v,
Err(e) => return Line::Reject(format!("bad `id` ({e}); closing the connection")),
},
Some(_) => return Line::Reject(format!("`id` is longer than {MAX_ID_BYTES} bytes; closing the connection")),
};
Line::Request { id, method, params: obj.remove("params"), token: string(obj.remove("token")) }
// Moved out of the parsed object, not cloned: `params` can be most of the line.
Line::Request {
id: obj.remove("id").unwrap_or(Value::Null),
method,
params: obj.remove("params").unwrap_or(json!({})),
token: obj.remove("token").and_then(|t| if let Value::String(t) = t { Some(t) } else { None }),
}
}
/// An HTTP/1 request line (`POST / HTTP/1.1`) or the HTTP/2 preface (`PRI * HTTP/2.0`).
@@ -315,16 +305,6 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
let _ = writeln!(out, "{r}");
break;
}
// Absent `params` is `{}`; ones that cannot be decoded (an unpaired surrogate escape) are an
// error, not a request run with defaults.
let params = match params.map(|r| serde_json::from_str::<Value>(r.get())) {
None => json!({}),
Some(Ok(v)) => v,
Some(Err(e)) => {
let _ = writeln!(out, "{}", json!({"ok": false, "error": format!("bad params ({e}); closing the connection"), "id": id}));
break;
}
};
let (req, pending) = ControlRequest::new(method, params);
let mut r = match tx.try_send(req) {
Ok(()) => {
@@ -392,22 +372,6 @@ mod tests {
assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread");
}
/// The parameters stay text until the token is checked, and a request decodes as before.
#[test]
fn params_are_decoded_only_as_text_by_the_parser() {
let line = br#"{"id":7,"method":"m","token":"t","params":[1,2,{"a":[3]}],"extra":null}"#;
let Line::Request { id, method, params, token } = parse_line(line) else { panic!("a request") };
assert_eq!((id, method.as_str(), token.as_deref()), (json!(7), "m", Some("t")));
assert_eq!(params.map(|p| p.get()), Some(r#"[1,2,{"a":[3]}]"#));
assert!(matches!(parse_line(b"[1,2,3]"), Line::Reject(e) if e.contains("expected a JSON object")));
assert!(matches!(parse_line(b"{\"method\":"), Line::Reject(e) if e.contains("bad JSON")));
assert!(matches!(parse_line(br#"{"id":[1],"method":5}"#), Line::Reject(e) if e.contains("missing string `method`")));
let long_id = format!(r#"{{"id":"{}","method":"m"}}"#, "x".repeat(MAX_ID_BYTES));
assert!(matches!(parse_line(long_id.as_bytes()), Line::Reject(e) if e.contains("`id` is longer")));
assert!(matches!(parse_line(br#"{"id":"\ud800","method":"m"}"#), Line::Reject(e) if e.contains("bad `id`")));
assert!(matches!(parse_line(br#"{"id":1}"#), Line::Reject(e) if e.contains("missing string `method`")));
}
fn held() -> Held {
Held::new(Budget::new(usize::MAX))
}
+9 -12
View File
@@ -144,10 +144,10 @@ impl BlockEdit {
}
let host = &self.host.doc;
let mut d = editor.clone();
// When the block is saved under another name, the edited block keeps the drawing's
// description and block `name` gets the description changed in the editor.
// When saving a copy, the source keeps its last saved definition. The editor's own
// table entry can still be the one from before BSAVE; the copy gets the edited data.
if !name.eq_ignore_ascii_case(&self.name) {
keep_description(&mut d, host, &self.name);
restore_saved_definition(&mut d, host, &self.name);
}
d.model = host.model.clone();
d.layouts = host.layouts.clone();
@@ -176,16 +176,13 @@ impl BlockEdit {
}
}
/// Give block `name` of `d` the description `host` has for it, or, when `host` has no such block,
/// drop the empty definition a description gives a block the editor started and never saved.
fn keep_description(d: &mut Drawing, host: &Drawing, name: &str) {
/// Keep the source block's saved objects and metadata when the edited objects are saved as a copy.
/// Without a saved source, drop the empty definition an editor description may have created.
fn restore_saved_definition(d: &mut Drawing, host: &Drawing, name: &str) {
match host.block(name) {
Some(h) => {
if let Some((_, b)) = d.blocks.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case(name))
&& b.description != h.description
{
Arc::make_mut(b).description = h.description.clone();
}
let key = d.blocks.keys().find(|key| key.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| h.name.clone());
d.blocks.insert(key, h.clone());
}
None => d.blocks.retain(|k, b| !(k.eq_ignore_ascii_case(name) && b.entities.is_empty())),
}
@@ -302,7 +299,7 @@ impl DocState {
let was = be.name.clone();
let reconcile = |doc: &mut Arc<Drawing>| {
let editor = Arc::make_mut(doc);
keep_description(editor, &d, &was);
restore_saved_definition(editor, &d, &was);
editor.blocks.retain(|k, _| !k.eq_ignore_ascii_case(&name));
editor.blocks.insert(name.clone(), b.clone());
};
+37
View File
@@ -0,0 +1,37 @@
//! Saving an edited block as a copy must retain its source's last saved definition.
use cadcraft_engine::Session;
use serde_json::json;
/// Add a third line to a block and BSAVE it, BSAVEAS a copy, then edit and save the copy: the
/// source block keeps the definition of its own last save.
#[test]
fn save_as_preserves_the_last_saved_source_definition() {
let mut s = Session::new();
let mut handles = Vec::new();
for y in [5, 9] {
let line = s.execute("line", &json!({ "points": [[5, y], [15, y]] })).unwrap();
handles.push(line["handles"][0].clone());
}
s.execute("block", &json!({ "name": "Frame", "base": [5, 5], "handles": handles, "keep": "delete" })).unwrap();
s.execute("insert", &json!({ "name": "Frame", "at": [100, 20] })).unwrap();
s.execute("bedit", &json!({ "name": "Frame" })).unwrap();
assert_eq!(s.doc().unwrap().model.len(), 2);
s.execute("line", &json!({ "points": [[0, 3], [3, 3]] })).unwrap();
s.execute("bsave", &json!({})).unwrap();
let saved_source = s.state().unwrap().drawing().block("Frame").unwrap().as_ref().clone();
assert_eq!(saved_source.entities.len(), 3);
s.execute("bsaveas", &json!({ "name": "Copy" })).unwrap();
assert_eq!(s.state().unwrap().block_editor(), Some("Copy"));
assert_eq!(s.state().unwrap().drawing().block("Frame").unwrap().as_ref(), &saved_source, "copy composition keeps the saved source");
// A later save of Copy must not reintroduce the editor table's older source definition.
s.execute("line", &json!({ "points": [[0, 6], [4, 6]] })).unwrap();
s.execute("bsave", &json!({})).unwrap();
s.execute("bclose", &json!({ "save": false })).unwrap();
assert_eq!(s.doc().unwrap().block("Frame").unwrap().as_ref(), &saved_source, "editor reconciliation keeps the saved source");
assert_eq!(s.doc().unwrap().block("Copy").unwrap().entities.len(), 4, "the copy keeps its own later edit");
s.execute("bedit", &json!({ "name": "Frame" })).unwrap();
assert_eq!(s.doc().unwrap().model.len(), 3);
}
+1 -10
View File
@@ -371,16 +371,7 @@ fn fill(store: &mut EntityStore, by_name: &HashMap<String, MLineStyle>) {
&& let Some(st) = by_name.get(&key(&m.style))
&& st.elements.len() == m.def.elements.len()
{
// The description is the style table's; copying it into every multiline would multiply a long one.
m.def = MLineStyle {
name: st.name.clone(),
description: String::new(),
elements: st.elements.clone(),
start_caps: st.start_caps,
end_caps: st.end_caps,
joints: st.joints,
fill: st.fill,
};
m.def = st.clone();
}
});
}
+2 -3
View File
@@ -59,8 +59,7 @@ fn mlines_and_styles_survive_save_and_reopen() {
let back = cadcraft_io::read_dxf(saved.as_bytes()).unwrap();
let [a, b] = mlines(&back).try_into().unwrap();
// The geometry as drawn is the same, breaks included.
// The description lives in the style table (checked below), not copied into every multiline.
assert_eq!(a.def, MLineStyle { description: String::new(), ..wall() });
assert_eq!(a.def, wall());
assert_eq!(
(a.style.as_str(), a.justification, a.scale, a.closed, a.no_start_caps, a.no_end_caps),
("Wall", MLineJust::Zero, 2.0, false, false, true)
@@ -102,7 +101,7 @@ fn mlines_and_styles_survive_save_and_reopen() {
// Saving again is stable.
let again = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&back).as_bytes()).unwrap();
assert_eq!(mlines(&again).len(), 2);
assert_eq!(mlines(&again)[0].def, MLineStyle { description: String::new(), ..wall() });
assert_eq!(mlines(&again)[0].def, wall());
}
/// A multiline as another program writes it: style `Duo` (offsets 0.25 and -0.25, the first red)
+27
View File
@@ -0,0 +1,27 @@
//! Release and source Flatpaks must allow release checks and host update helpers.
fn finish_args(manifest: &str) -> Vec<&str> {
manifest
.split("finish-args:")
.nth(1)
.unwrap_or_default()
.lines()
.take_while(|line| line.is_empty() || line.starts_with(' ') || line.starts_with('#'))
.filter_map(|line| line.trim().strip_prefix("- "))
.map(|arg| arg.trim_matches(['\'', '"']))
.collect()
}
#[test]
fn flatpak_update_permissions() {
let manifests = [
include_str!("../../../packaging/linux/flatpak/ai.storyteller.cadcraft.bundle.yml"),
include_str!("../../../packaging/linux/flatpak/ai.storyteller.cadcraft.yml"),
];
let args = manifests.map(finish_args);
assert_eq!(args[0], args[1], "source and release permissions must agree");
for permissions in args {
for required in ["--share=network", "--talk-name=org.freedesktop.Flatpak"] {
assert!(permissions.contains(&required), "missing update permission: {required}");
}
}
}
@@ -17,6 +17,8 @@ runtime-version: '26.08'
sdk: org.freedesktop.Sdk
command: cadcraft
finish-args:
# The shared updater inspects and updates this installation via flatpak-spawn --host.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=xdg-data/ArtCraft/versions:ro
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland
@@ -18,6 +18,8 @@ sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
command: cadcraft
finish-args:
# The shared updater inspects and updates this installation via flatpak-spawn --host.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=xdg-data/ArtCraft/versions:ro
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland