From d0b79c707d1a8b78d7f3f82dd870cea88f067494 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 17:48:37 +0000 Subject: [PATCH 01/33] Add the .kpk wallpaper package and `kirie pack` A new kirie-pack crate reads and writes kirie's own wallpaper package: a 64-byte header, entries aligned to 4 KiB so a player can read video and textures in place, then a JSON manifest and index. Every entry carries a blake3 hash of its stored bytes and the header hashes the manifest and index, so damaged or altered packages are refused. Header flags are reserved for encryption and signing; v1 readers refuse packages that set them rather than misreading them. The manifest names the kind (video, image, web, scene), the entry file, preview, tags, mature flag, user properties and provenance. Converted packages are marked so they can never be published. `kirie pack ` turns a folder with kirie.json into a package and `kirie pack --inspect ` shows one and checks every hash. Playback of packages is not wired up yet. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Kg1KotRgE7Ws6DQwhsgTpj --- Cargo.lock | 12 + Cargo.toml | 2 + crates/kirie-pack/Cargo.toml | 17 ++ crates/kirie-pack/src/error.rs | 52 ++++ crates/kirie-pack/src/lib.rs | 141 ++++++++++ crates/kirie-pack/src/manifest.rs | 377 +++++++++++++++++++++++++++ crates/kirie-pack/src/read.rs | 239 +++++++++++++++++ crates/kirie-pack/src/write.rs | 219 ++++++++++++++++ crates/kirie-pack/tests/roundtrip.rs | 194 ++++++++++++++ crates/kirie/Cargo.toml | 1 + crates/kirie/src/lib.rs | 22 ++ crates/kirie/src/pack.rs | 80 ++++++ docs/PACKAGE.md | 63 +++++ 13 files changed, 1419 insertions(+) create mode 100644 crates/kirie-pack/Cargo.toml create mode 100644 crates/kirie-pack/src/error.rs create mode 100644 crates/kirie-pack/src/lib.rs create mode 100644 crates/kirie-pack/src/manifest.rs create mode 100644 crates/kirie-pack/src/read.rs create mode 100644 crates/kirie-pack/src/write.rs create mode 100644 crates/kirie-pack/tests/roundtrip.rs create mode 100644 crates/kirie/src/pack.rs create mode 100644 docs/PACKAGE.md diff --git a/Cargo.lock b/Cargo.lock index 4d605e3..ef80323 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2334,6 +2334,7 @@ dependencies = [ "kirie-bake", "kirie-formats", "kirie-ipc", + "kirie-pack", "kirie-platform", "kirie-render", "kirie-steam-helper", @@ -2417,6 +2418,17 @@ dependencies = [ "zstd", ] +[[package]] +name = "kirie-pack" +version = "0.1.0" +dependencies = [ + "blake3", + "lz4_flex", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "kirie-platform" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index c9e234a..67fa711 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/kirie-formats", "crates/kirie-scene", "crates/kirie-bake", + "crates/kirie-pack", "crates/kirie-render", "crates/kirie-shader", "crates/kirie-audio", @@ -25,6 +26,7 @@ repository = "https://github.com/UnhingedSoftware/kirie" [workspace.dependencies] kirie-formats = { path = "crates/kirie-formats" } +kirie-pack = { path = "crates/kirie-pack" } serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["float_roundtrip"] } thiserror = "2" diff --git a/crates/kirie-pack/Cargo.toml b/crates/kirie-pack/Cargo.toml new file mode 100644 index 0000000..0ed1c98 --- /dev/null +++ b/crates/kirie-pack/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "kirie-pack" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "The .kpk wallpaper package: one file holding a JSON manifest and hashed, 4 KiB-aligned entries" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +blake3 = { workspace = true } +lz4_flex = { workspace = true } + +[lints] +workspace = true diff --git a/crates/kirie-pack/src/error.rs b/crates/kirie-pack/src/error.rs new file mode 100644 index 0000000..b3b4bd6 --- /dev/null +++ b/crates/kirie-pack/src/error.rs @@ -0,0 +1,52 @@ +use std::path::PathBuf; + +#[derive(Debug, thiserror::Error)] +pub enum PackError { + #[error("{0}")] + Io(#[from] std::io::Error), + + #[error("reading {path}: {source}")] + File { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("not a kirie package (the file does not start with KIRIEPKG)")] + NotAPackage, + + #[error("this package is format version {0}; this kirie reads version 1 only")] + UnsupportedVersion(u16), + + #[error( + "this package uses features this kirie does not have (flags {0:#06x}); it may be encrypted or signed" + )] + UnsupportedFlags(u16), + + #[error("the package is damaged: {0}")] + Corrupt(String), + + #[error("the manifest is not valid: {0}")] + BadManifest(String), + + #[error("entry path {path:?} is not allowed: {why}")] + BadPath { path: String, why: &'static str }, + + #[error("the package has two entries named {0:?}")] + DuplicatePath(String), + + #[error("the package has no entry named {0:?}")] + NoSuchEntry(String), + + #[error("entry {path:?} does not match its hash; the file was damaged or changed")] + HashMismatch { path: String }, + + #[error("entry {path:?} is stored with {what}, which this kirie cannot read")] + UnsupportedStorage { path: String, what: String }, +} + +impl PackError { + pub(crate) fn corrupt(why: impl Into) -> Self { + PackError::Corrupt(why.into()) + } +} diff --git a/crates/kirie-pack/src/lib.rs b/crates/kirie-pack/src/lib.rs new file mode 100644 index 0000000..544fbbc --- /dev/null +++ b/crates/kirie-pack/src/lib.rs @@ -0,0 +1,141 @@ +//! The `.kpk` wallpaper package. +//! +//! One wallpaper is one file. It starts with a fixed 64-byte header, then the +//! entries (the wallpaper's own files), each starting on a 4 KiB boundary so a +//! player can hand a video stream or texture to its decoder straight from the +//! file, then the manifest and the index, both JSON, at the end. Putting the +//! directory last lets a writer stream entries out without knowing the +//! directory's size first; the header is patched with its position at the end. +//! +//! ```text +//! 0 header (64 bytes, zero-padded to 4096) +//! 4096 entry 0, zero-padded to the next 4096 +//! ... entry n +//! manifest JSON +//! index JSON +//! ``` +//! +//! Header, all little-endian: +//! +//! | offset | size | field | +//! | --- | --- | --- | +//! | 0 | 8 | magic `KIRIEPKG` | +//! | 8 | 2 | format version, 1 | +//! | 10 | 2 | flags, 0 in v1 (bits reserved for encryption and signing) | +//! | 12 | 4 | reserved, 0 | +//! | 16 | 8 | manifest offset | +//! | 24 | 8 | manifest length | +//! | 32 | 8 | index offset | +//! | 40 | 8 | index length | +//! | 48 | 16 | first 16 bytes of blake3(manifest bytes, then index bytes) | +//! +//! Every entry carries the blake3 hash of the bytes as stored, so a damaged or +//! altered download is refused before anything decodes it. + +mod error; +mod manifest; +mod read; +mod write; + +pub use error::PackError; +pub use manifest::{Choice, Kind, Manifest, Property, PropertyValue, Provenance}; +pub use read::{Entry, Package, Span}; +pub use write::{Builder, Compression, Summary, pack_dir}; + +/// The extension a package file carries. +pub const EXTENSION: &str = "kpk"; + +/// The name of the manifest file in a folder `pack_dir` turns into a package. +pub const MANIFEST_FILE: &str = "kirie.json"; + +pub(crate) const MAGIC: [u8; 8] = *b"KIRIEPKG"; +pub(crate) const FORMAT_VERSION: u16 = 1; +pub(crate) const HEADER_LEN: usize = 64; +pub(crate) const ALIGN: u64 = 4096; + +/// A manifest larger than this is refused rather than parsed. +pub(crate) const MAX_MANIFEST_LEN: u64 = 1 << 20; +/// An index larger than this is refused rather than parsed. +pub(crate) const MAX_INDEX_LEN: u64 = 64 << 20; +/// A compressed entry may not claim to expand beyond this, so a hostile file +/// cannot make the reader allocate without limit. +pub(crate) const MAX_UNPACKED_ENTRY_LEN: u64 = 1 << 30; + +pub(crate) fn align_up(n: u64) -> u64 { + n.div_ceil(ALIGN) * ALIGN +} + +pub(crate) fn directory_hash(manifest: &[u8], index: &[u8]) -> [u8; 16] { + let mut hasher = blake3::Hasher::new(); + hasher.update(manifest); + hasher.update(index); + let mut out = [0u8; 16]; + out.copy_from_slice(&hasher.finalize().as_bytes()[..16]); + out +} + +/// Check that `path` is a relative, `/`-separated path that cannot name +/// anything outside the package when a tool unpacks it on any platform. +pub(crate) fn check_entry_path(path: &str) -> Result<(), PackError> { + let bad = |why: &'static str| PackError::BadPath { + path: path.to_owned(), + why, + }; + if path.is_empty() { + return Err(bad("it is empty")); + } + if path.len() > 1024 { + return Err(bad("it is longer than 1024 bytes")); + } + if path.starts_with('/') { + return Err(bad("it is absolute")); + } + if path.contains('\\') || path.contains(':') { + return Err(bad("it contains \\ or :")); + } + if path.chars().any(char::is_control) { + return Err(bad("it contains a control character")); + } + for part in path.split('/') { + match part { + "" => return Err(bad("it has an empty component")), + "." | ".." => return Err(bad("it has a . or .. component")), + _ => {} + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn paths_that_could_escape_are_refused() { + for bad in [ + "", + "/etc/passwd", + "../x", + "a/../b", + "a//b", + "a/./b", + "c:x", + "a\\b", + "a\nb", + "a/", + ] { + assert!(check_entry_path(bad).is_err(), "{bad:?} should be refused"); + } + for good in ["scene.bin", "shaders/water.spv", "web/index.html", "a b/c-d_e.f"] { + check_entry_path(good).unwrap(); + } + } + + #[test] + fn align_up_rounds_to_4k() { + assert_eq!(align_up(0), 0); + assert_eq!(align_up(1), 4096); + assert_eq!(align_up(4096), 4096); + assert_eq!(align_up(4097), 8192); + } +} diff --git a/crates/kirie-pack/src/manifest.rs b/crates/kirie-pack/src/manifest.rs new file mode 100644 index 0000000..fe09d15 --- /dev/null +++ b/crates/kirie-pack/src/manifest.rs @@ -0,0 +1,377 @@ +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; + +use crate::PackError; + +/// What a package is: everything a library or the Workshop needs to list it, +/// and what the player needs to start it. Stored as JSON so haru can read it +/// without linking the renderer. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Manifest { + /// Stable identifier the creator picks; the same across updates of one + /// wallpaper. Lowercase ASCII letters, digits, `-`, `_` and `.`. + pub id: String, + pub title: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub description: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub author: String, + pub kind: Kind, + /// The entry the player starts from: the video, the image, the web page + /// or the scene graph. + pub entry: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preview: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + #[serde(default)] + pub mature: bool, + /// Settings the user can change, in the order they are shown. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub properties: Vec, + /// The oldest kirie that can play this package, as `major.minor.patch`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min_kirie: Option, + #[serde(default)] + pub provenance: Provenance, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Kind { + Video, + Image, + Web, + Scene, +} + +impl Kind { + pub fn as_str(self) -> &'static str { + match self { + Kind::Video => "video", + Kind::Image => "image", + Kind::Web => "web", + Kind::Scene => "scene", + } + } + + fn entry_extensions(self) -> &'static [&'static str] { + match self { + Kind::Video => &["mp4", "webm", "mkv"], + Kind::Image => &["png", "jpg", "jpeg", "webp", "ktx2"], + Kind::Web => &["html", "htm"], + Kind::Scene => &["kscene"], + } + } +} + +/// Where a package came from. Converted packages stay on the machine that +/// converted them; only `Original` and `ConvertedOwn` may be published. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "origin", rename_all = "snake_case")] +pub enum Provenance { + #[default] + Original, + /// Converted from another format, from an item someone else made. + Converted { source: String, source_id: String }, + /// Converted from another format, from an item the publishing account is + /// confirmed to have made. + ConvertedOwn { source: String, source_id: String }, +} + +impl Provenance { + /// Whether a package with this provenance may be uploaded to the Workshop. + pub fn publishable(&self) -> bool { + !matches!(self, Provenance::Converted { .. }) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Property { + pub key: String, + pub label: String, + #[serde(flatten)] + pub value: PropertyValue, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum PropertyValue { + Slider { + min: f64, + max: f64, + step: f64, + default: f64, + }, + Toggle { + default: bool, + }, + /// Linear RGB, each 0 to 1. + Color { + default: [f32; 3], + }, + Choice { + options: Vec, + default: String, + }, + Text { + default: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Choice { + pub value: String, + pub label: String, +} + +impl Manifest { + /// Parse a manifest read from a package. Fields this version does not + /// know are ignored, so a newer package still lists; `min_kirie` is how a + /// package says it needs more than this version can do. + pub fn from_json(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes).map_err(|e| PackError::BadManifest(e.to_string())) + } + + /// Parse a manifest a person wrote, refusing top-level fields this + /// version does not know, so a misspelt field is reported instead of + /// silently dropped. + pub fn from_json_strict(bytes: &[u8]) -> Result { + const KNOWN: [&str; 12] = [ + "id", + "title", + "description", + "author", + "kind", + "entry", + "preview", + "tags", + "mature", + "properties", + "min_kirie", + "provenance", + ]; + let value: serde_json::Value = + serde_json::from_slice(bytes).map_err(|e| PackError::BadManifest(e.to_string()))?; + if let Some(fields) = value.as_object() + && let Some(unknown) = fields.keys().find(|k| !KNOWN.contains(&k.as_str())) + { + return Err(PackError::BadManifest(format!("unknown field {unknown:?}"))); + } + serde_json::from_value(value).map_err(|e| PackError::BadManifest(e.to_string())) + } + + pub fn to_json(&self) -> Vec { + serde_json::to_vec_pretty(self).expect("a manifest always serializes") + } + + /// Check the manifest on its own and against the entries the package + /// holds. + pub fn validate<'a>(&self, entries: impl IntoIterator) -> Result<(), PackError> { + let bad = |why: String| Err(PackError::BadManifest(why)); + let paths: HashSet<&str> = entries.into_iter().collect(); + + if self.id.is_empty() || self.id.len() > 128 { + return bad("id must be 1 to 128 characters".into()); + } + if !self + .id + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'_' | b'.')) + { + return bad(format!("id {:?} may only use a-z, 0-9, -, _ and .", self.id)); + } + if self.title.trim().is_empty() { + return bad("title is empty".into()); + } + if !paths.contains(self.entry.as_str()) { + return bad(format!("entry {:?} is not in the package", self.entry)); + } + let ext = self.entry.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()); + let allowed = self.kind.entry_extensions(); + if !ext.as_deref().is_some_and(|e| allowed.contains(&e)) { + return bad(format!( + "a {} package's entry must end in one of {}, not {:?}", + self.kind.as_str(), + allowed.join(", "), + self.entry + )); + } + if let Some(preview) = &self.preview + && !paths.contains(preview.as_str()) + { + return bad(format!("preview {preview:?} is not in the package")); + } + if let Some(v) = &self.min_kirie + && !is_version(v) + { + return bad(format!("min_kirie {v:?} is not major.minor.patch")); + } + if let Provenance::Converted { source, source_id } | Provenance::ConvertedOwn { source, source_id } = + &self.provenance + && (source.is_empty() || source_id.is_empty()) + { + return bad("a converted package must name its source and source_id".into()); + } + + let mut keys = HashSet::new(); + for p in &self.properties { + if p.key.is_empty() { + return bad("a property has an empty key".into()); + } + if !keys.insert(p.key.as_str()) { + return bad(format!("property {:?} appears twice", p.key)); + } + if let Err(why) = p.value.check() { + return bad(format!("property {:?}: {why}", p.key)); + } + } + Ok(()) + } +} + +impl PropertyValue { + fn check(&self) -> Result<(), String> { + match self { + PropertyValue::Slider { + min, + max, + step, + default, + } => { + if ![min, max, step, default].iter().all(|v| v.is_finite()) { + return Err("slider values must be finite".into()); + } + if min >= max { + return Err("slider min must be below max".into()); + } + if *step <= 0.0 { + return Err("slider step must be above 0".into()); + } + if default < min || default > max { + return Err("slider default is outside min..max".into()); + } + } + PropertyValue::Color { default } => { + if !default.iter().all(|c| (0.0..=1.0).contains(c)) { + return Err("colour channels must be between 0 and 1".into()); + } + } + PropertyValue::Choice { options, default } => { + if options.is_empty() { + return Err("a choice needs at least one option".into()); + } + if !options.iter().any(|o| &o.value == default) { + return Err(format!("default {default:?} is not one of the options")); + } + } + PropertyValue::Toggle { .. } | PropertyValue::Text { .. } => {} + } + Ok(()) + } +} + +fn is_version(v: &str) -> bool { + let parts: Vec<&str> = v.split('.').collect(); + parts.len() == 3 + && parts + .iter() + .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn video() -> Manifest { + Manifest { + id: "rain-city".into(), + title: "Rain city".into(), + description: String::new(), + author: String::new(), + kind: Kind::Video, + entry: "loop.mp4".into(), + preview: Some("preview.jpg".into()), + tags: vec![], + mature: false, + properties: vec![], + min_kirie: None, + provenance: Provenance::Original, + } + } + + const FILES: [&str; 2] = ["loop.mp4", "preview.jpg"]; + + #[test] + fn a_plain_video_manifest_is_valid() { + video().validate(FILES).unwrap(); + } + + #[test] + fn the_entry_must_exist_and_fit_the_kind() { + let mut m = video(); + m.entry = "missing.mp4".into(); + assert!(m.validate(FILES).is_err()); + let mut m = video(); + m.kind = Kind::Scene; + assert!(m.validate(FILES).is_err()); + } + + #[test] + fn ids_are_restricted() { + for bad in ["", "Rain", "rain city", "rain/city"] { + let mut m = video(); + m.id = bad.into(); + assert!(m.validate(FILES).is_err(), "{bad:?}"); + } + } + + #[test] + fn properties_are_checked() { + let slider = |min, max, default| Property { + key: "speed".into(), + label: "Speed".into(), + value: PropertyValue::Slider { + min, + max, + step: 0.1, + default, + }, + }; + let mut m = video(); + m.properties = vec![slider(0.0, 2.0, 1.0)]; + m.validate(FILES).unwrap(); + m.properties = vec![slider(0.0, 2.0, 3.0)]; + assert!(m.validate(FILES).is_err()); + m.properties = vec![slider(0.0, 2.0, 1.0), slider(0.0, 2.0, 1.0)]; + assert!(m.validate(FILES).is_err()); + } + + #[test] + fn json_round_trips_and_reads_what_a_person_writes() { + let text = br#"{ + "id": "rain-city", + "title": "Rain city", + "kind": "video", + "entry": "loop.mp4", + "properties": [ + {"key": "tint", "label": "Tint", "type": "color", "default": [1, 0.5, 0]}, + {"key": "mode", "label": "Mode", "type": "choice", "default": "day", + "options": [{"value": "day", "label": "Day"}, {"value": "night", "label": "Night"}]} + ], + "provenance": {"origin": "converted", "source": "wallpaper-engine", "source_id": "123"} + }"#; + let m = Manifest::from_json(text).unwrap(); + assert_eq!(m.properties.len(), 2); + assert!(!m.provenance.publishable()); + assert_eq!(Manifest::from_json(&m.to_json()).unwrap(), m); + } + + #[test] + fn unknown_fields_are_refused_only_when_strict() { + let text = br#"{"id":"a","title":"A","kind":"video","entry":"a.mp4","colour":"red"}"#; + assert!(Manifest::from_json_strict(text).is_err()); + Manifest::from_json(text).unwrap(); + } +} diff --git a/crates/kirie-pack/src/read.rs b/crates/kirie-pack/src/read.rs new file mode 100644 index 0000000..9be63ef --- /dev/null +++ b/crates/kirie-pack/src/read.rs @@ -0,0 +1,239 @@ +use std::collections::HashMap; +use std::fs::File; +use std::io::{BufReader, Read, Seek, SeekFrom}; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::{ + ALIGN, FORMAT_VERSION, HEADER_LEN, MAGIC, MAX_INDEX_LEN, MAX_MANIFEST_LEN, MAX_UNPACKED_ENTRY_LEN, + Manifest, PackError, check_entry_path, directory_hash, +}; + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct Index { + pub(crate) entries: Vec, +} + +/// One file inside a package, as the index describes it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Entry { + pub path: String, + /// Where the stored bytes start in the package file. + pub offset: u64, + pub stored_len: u64, + /// Length once decompressed. + pub len: u64, + /// `none` or `lz4`. + pub compression: String, + /// `none` in v1; reserved for encrypted packages. + #[serde(default = "none")] + pub cipher: String, + /// blake3 of the stored bytes, lowercase hex. + pub blake3: String, +} + +fn none() -> String { + "none".to_owned() +} + +/// Where an entry's bytes sit in the package file, for entries stored as is. +/// A player can hand this range to a decoder instead of copying the entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Span { + pub offset: u64, + pub len: u64, +} + +/// An opened package. The header, manifest and index are read and checked +/// on open; entries are read and verified on demand. +pub struct Package { + reader: R, + manifest: Manifest, + entries: Vec, + by_path: HashMap, +} + +impl Package> { + pub fn open(path: &Path) -> Result { + let file = File::open(path).map_err(|source| PackError::File { + path: path.to_owned(), + source, + })?; + Package::from_reader(BufReader::new(file)) + } +} + +impl Package { + pub fn from_reader(mut reader: R) -> Result { + let file_len = reader.seek(SeekFrom::End(0))?; + reader.seek(SeekFrom::Start(0))?; + let mut header = [0u8; HEADER_LEN]; + if file_len < HEADER_LEN as u64 { + return Err(PackError::NotAPackage); + } + reader.read_exact(&mut header)?; + if header[0..8] != MAGIC { + return Err(PackError::NotAPackage); + } + let u16_at = |at: usize| u16::from_le_bytes([header[at], header[at + 1]]); + let u64_at = |at: usize| u64::from_le_bytes(header[at..at + 8].try_into().expect("8 bytes")); + let version = u16_at(8); + if version != FORMAT_VERSION { + return Err(PackError::UnsupportedVersion(version)); + } + let flags = u16_at(10); + if flags != 0 { + return Err(PackError::UnsupportedFlags(flags)); + } + let (manifest_offset, manifest_len) = (u64_at(16), u64_at(24)); + let (index_offset, index_len) = (u64_at(32), u64_at(40)); + if manifest_len > MAX_MANIFEST_LEN || index_len > MAX_INDEX_LEN { + return Err(PackError::corrupt("the manifest or index is implausibly large")); + } + if manifest_offset < ALIGN + || index_offset != manifest_offset + manifest_len + || index_offset.checked_add(index_len) != Some(file_len) + { + return Err(PackError::corrupt("the header's offsets do not fit the file")); + } + + let manifest_bytes = read_at(&mut reader, manifest_offset, manifest_len)?; + let index_bytes = read_at(&mut reader, index_offset, index_len)?; + if directory_hash(&manifest_bytes, &index_bytes) != header[48..64] { + return Err(PackError::corrupt( + "the manifest or index does not match the header's hash", + )); + } + let manifest = Manifest::from_json(&manifest_bytes)?; + let index: Index = serde_json::from_slice(&index_bytes) + .map_err(|e| PackError::corrupt(format!("unreadable index: {e}")))?; + + let mut by_path = HashMap::with_capacity(index.entries.len()); + let mut spans: Vec<(u64, u64)> = Vec::with_capacity(index.entries.len()); + for (i, e) in index.entries.iter().enumerate() { + check_entry_path(&e.path)?; + if by_path.insert(e.path.clone(), i).is_some() { + return Err(PackError::DuplicatePath(e.path.clone())); + } + let end = e.offset.checked_add(e.stored_len); + if e.offset < ALIGN || e.offset % ALIGN != 0 || end.is_none_or(|end| end > manifest_offset) { + return Err(PackError::corrupt(format!( + "entry {:?} lies outside the entry area", + e.path + ))); + } + if e.compression == "none" && e.len != e.stored_len { + return Err(PackError::corrupt(format!( + "entry {:?} has two different lengths", + e.path + ))); + } + if e.blake3.len() != 64 { + return Err(PackError::corrupt(format!( + "entry {:?} has no usable hash", + e.path + ))); + } + spans.push((e.offset, e.stored_len)); + } + spans.sort_unstable(); + if spans.windows(2).any(|w| w[0].0 + w[0].1 > w[1].0) { + return Err(PackError::corrupt("two entries overlap")); + } + + manifest.validate(index.entries.iter().map(|e| e.path.as_str()))?; + Ok(Package { + reader, + manifest, + entries: index.entries, + by_path, + }) + } + + pub fn manifest(&self) -> &Manifest { + &self.manifest + } + + pub fn entries(&self) -> &[Entry] { + &self.entries + } + + pub fn entry(&self, path: &str) -> Option<&Entry> { + self.by_path.get(path).map(|&i| &self.entries[i]) + } + + /// Read an entry, check its hash and decompress it. + pub fn read(&mut self, path: &str) -> Result, PackError> { + let entry = self + .entry(path) + .ok_or_else(|| PackError::NoSuchEntry(path.to_owned()))? + .clone(); + if entry.cipher != "none" { + return Err(PackError::UnsupportedStorage { + path: entry.path, + what: format!("cipher {}", entry.cipher), + }); + } + let stored = read_at(&mut self.reader, entry.offset, entry.stored_len)?; + if blake3::hash(&stored).to_hex().as_str() != entry.blake3 { + return Err(PackError::HashMismatch { path: entry.path }); + } + match entry.compression.as_str() { + "none" => Ok(stored), + "lz4" => { + if entry.len > MAX_UNPACKED_ENTRY_LEN { + return Err(PackError::corrupt(format!( + "entry {:?} claims to unpack to {} bytes", + entry.path, entry.len + ))); + } + let bytes = lz4_flex::decompress(&stored, entry.len as usize) + .map_err(|e| PackError::corrupt(format!("entry {:?}: {e}", entry.path)))?; + if bytes.len() as u64 != entry.len { + return Err(PackError::corrupt(format!( + "entry {:?} unpacked to the wrong length", + entry.path + ))); + } + Ok(bytes) + } + other => Err(PackError::UnsupportedStorage { + path: entry.path, + what: format!("compression {other}"), + }), + } + } + + /// Where an entry stored as is lies in the file. `None` for a missing, + /// compressed or encrypted entry. The bytes in the range are not checked; + /// call `verify` first if the file may have changed since it was opened. + pub fn span(&self, path: &str) -> Option { + let e = self.entry(path)?; + (e.compression == "none" && e.cipher == "none").then_some(Span { + offset: e.offset, + len: e.stored_len, + }) + } + + /// Check every entry's hash. + pub fn verify(&mut self) -> Result<(), PackError> { + let paths: Vec = self.entries.iter().map(|e| e.path.clone()).collect(); + for path in paths { + self.read(&path)?; + } + Ok(()) + } +} + +fn read_at(reader: &mut R, offset: u64, len: u64) -> Result, PackError> { + let len = + usize::try_from(len).map_err(|_| PackError::corrupt("an entry is too large for this machine"))?; + reader.seek(SeekFrom::Start(offset))?; + let mut buf = vec![0u8; len]; + reader.read_exact(&mut buf).map_err(|e| match e.kind() { + std::io::ErrorKind::UnexpectedEof => PackError::corrupt("the file ends early"), + _ => PackError::Io(e), + })?; + Ok(buf) +} diff --git a/crates/kirie-pack/src/write.rs b/crates/kirie-pack/src/write.rs new file mode 100644 index 0000000..9b09f03 --- /dev/null +++ b/crates/kirie-pack/src/write.rs @@ -0,0 +1,219 @@ +use std::collections::HashSet; +use std::io::{Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; + +use crate::read::{Entry, Index}; +use crate::{ + ALIGN, FORMAT_VERSION, HEADER_LEN, MAGIC, MANIFEST_FILE, Manifest, PackError, align_up, check_entry_path, + directory_hash, +}; + +/// How an entry should be stored. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Compression { + /// As is. Right for data that is already compressed (video, PNG, KTX2) + /// and for anything the player wants to read in place. + None, + /// LZ4, kept only when it saves at least an eighth of the size. + Lz4, +} + +impl Compression { + /// A sensible default for a file with this name: leave formats that are + /// already compressed alone, compress the rest. + pub fn for_path(path: &str) -> Self { + const PACKED: [&str; 12] = [ + "mp4", "webm", "mkv", "png", "jpg", "jpeg", "gif", "webp", "ktx2", "woff2", "ogg", "mp3", + ]; + let ext = path.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()); + if ext.is_some_and(|e| PACKED.contains(&e.as_str())) { + Compression::None + } else { + Compression::Lz4 + } + } +} + +/// Assembles a package. Entries are written in the order they were added. +pub struct Builder { + manifest: Manifest, + entries: Vec<(String, Vec, Compression)>, + seen: HashSet, +} + +/// What `Builder::write` produced. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Summary { + pub entries: usize, + pub bytes: u64, +} + +impl Builder { + pub fn new(manifest: Manifest) -> Self { + Builder { + manifest, + entries: Vec::new(), + seen: HashSet::new(), + } + } + + pub fn add( + &mut self, + path: impl Into, + bytes: Vec, + compression: Compression, + ) -> Result<(), PackError> { + let path = path.into(); + check_entry_path(&path)?; + if !self.seen.insert(path.clone()) { + return Err(PackError::DuplicatePath(path)); + } + self.entries.push((path, bytes, compression)); + Ok(()) + } + + /// Validate the manifest against the entries, then write the package. + /// The same inputs always give the same bytes, so a republished + /// wallpaper that did not change hashes the same. + pub fn write(self, out: &mut W) -> Result { + self.manifest + .validate(self.entries.iter().map(|(p, _, _)| p.as_str()))?; + + out.seek(SeekFrom::Start(0))?; + out.write_all(&[0u8; ALIGN as usize])?; + let mut pos = ALIGN; + let mut index = Index { + entries: Vec::with_capacity(self.entries.len()), + }; + + for (path, bytes, compression) in &self.entries { + let (stored, how) = match compression { + Compression::Lz4 => { + let packed = lz4_flex::compress(bytes); + if (packed.len() as u64) * 8 <= (bytes.len() as u64) * 7 { + (std::borrow::Cow::Owned(packed), "lz4") + } else { + (std::borrow::Cow::Borrowed(bytes.as_slice()), "none") + } + } + Compression::None => (std::borrow::Cow::Borrowed(bytes.as_slice()), "none"), + }; + out.write_all(&stored)?; + let stored_len = stored.len() as u64; + index.entries.push(Entry { + path: path.clone(), + offset: pos, + stored_len, + len: bytes.len() as u64, + compression: how.to_owned(), + cipher: "none".to_owned(), + blake3: blake3::hash(&stored).to_hex().to_string(), + }); + let end = pos + stored_len; + let next = align_up(end); + write_zeros(out, next - end)?; + pos = next; + } + + let manifest = self.manifest.to_json(); + let index = serde_json::to_vec(&index).expect("an index always serializes"); + out.write_all(&manifest)?; + out.write_all(&index)?; + let manifest_offset = pos; + let index_offset = manifest_offset + manifest.len() as u64; + let total = index_offset + index.len() as u64; + + let mut header = [0u8; HEADER_LEN]; + header[0..8].copy_from_slice(&MAGIC); + header[8..10].copy_from_slice(&FORMAT_VERSION.to_le_bytes()); + header[16..24].copy_from_slice(&manifest_offset.to_le_bytes()); + header[24..32].copy_from_slice(&(manifest.len() as u64).to_le_bytes()); + header[32..40].copy_from_slice(&index_offset.to_le_bytes()); + header[40..48].copy_from_slice(&(index.len() as u64).to_le_bytes()); + header[48..64].copy_from_slice(&directory_hash(&manifest, &index)); + out.seek(SeekFrom::Start(0))?; + out.write_all(&header)?; + out.seek(SeekFrom::Start(total))?; + out.flush()?; + + Ok(Summary { + entries: self.entries.len(), + bytes: total, + }) + } +} + +fn write_zeros(out: &mut W, mut n: u64) -> std::io::Result<()> { + const ZEROS: [u8; 4096] = [0; 4096]; + while n > 0 { + let step = n.min(ZEROS.len() as u64) as usize; + out.write_all(&ZEROS[..step])?; + n -= step as u64; + } + Ok(()) +} + +/// Turn a folder into a package: `kirie.json` in it is the manifest, every +/// other file becomes an entry under its path relative to the folder. +/// Files and folders whose names start with `.`, and `.kpk` files, are left +/// out. +pub fn pack_dir(dir: &Path, out: &mut W) -> Result { + let manifest_path = dir.join(MANIFEST_FILE); + let text = std::fs::read(&manifest_path).map_err(|source| PackError::File { + path: manifest_path, + source, + })?; + let manifest = Manifest::from_json_strict(&text)?; + + let mut files = Vec::new(); + collect(dir, dir, &mut files)?; + files.sort(); + + let mut builder = Builder::new(manifest); + for (name, path) in files { + if name == MANIFEST_FILE { + continue; + } + let bytes = std::fs::read(&path).map_err(|source| PackError::File { + path: path.clone(), + source, + })?; + let compression = Compression::for_path(&name); + builder.add(name, bytes, compression)?; + } + builder.write(out) +} + +fn collect(root: &Path, dir: &Path, out: &mut Vec<(String, PathBuf)>) -> Result<(), PackError> { + let read = std::fs::read_dir(dir).map_err(|source| PackError::File { + path: dir.to_owned(), + source, + })?; + for item in read { + let item = item?; + let path = item.path(); + let file_name = item.file_name().to_string_lossy().into_owned(); + // Hidden files are version control and editor litter. Packages are + // skipped too: the one being written may be inside the folder. + if file_name.starts_with('.') || file_name.ends_with(".kpk") || file_name.ends_with(".kpk.partial") { + continue; + } + let kind = item.file_type()?; + if kind.is_dir() { + collect(root, &path, out)?; + } else if kind.is_file() { + let rel = path.strip_prefix(root).expect("walked from root"); + let name = rel + .components() + .map(|c| c.as_os_str().to_str()) + .collect::>>() + .ok_or_else(|| PackError::BadPath { + path: rel.display().to_string(), + why: "it is not UTF-8", + })? + .join("/"); + out.push((name, path)); + } + } + Ok(()) +} diff --git a/crates/kirie-pack/tests/roundtrip.rs b/crates/kirie-pack/tests/roundtrip.rs new file mode 100644 index 0000000..1df58ec --- /dev/null +++ b/crates/kirie-pack/tests/roundtrip.rs @@ -0,0 +1,194 @@ +use std::io::Cursor; + +use kirie_pack::{Builder, Compression, Kind, Manifest, PackError, Package, Provenance, pack_dir}; + +fn scene_manifest() -> Manifest { + Manifest { + id: "koi-pond".into(), + title: "Koi pond".into(), + description: "Fish, water, lanterns".into(), + author: "someone".into(), + kind: Kind::Scene, + entry: "scene.kscene".into(), + preview: Some("preview.png".into()), + tags: vec!["nature".into()], + mature: false, + properties: vec![], + min_kirie: Some("0.9.0".into()), + provenance: Provenance::Original, + } +} + +fn build(manifest: Manifest, entries: &[(&str, Vec, Compression)]) -> Vec { + let mut b = Builder::new(manifest); + for (path, bytes, c) in entries { + b.add(*path, bytes.clone(), *c).unwrap(); + } + let mut out = Cursor::new(Vec::new()); + b.write(&mut out).unwrap(); + out.into_inner() +} + +fn sample() -> Vec { + let text = b"layer water\n".repeat(2000); + let mut x = 0x9e37_79b9_7f4a_7c15u64; + let noise: Vec = (0..10_000) + .map(|_| { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + x as u8 + }) + .collect(); + build( + scene_manifest(), + &[ + ("scene.kscene", text, Compression::Lz4), + ("preview.png", noise.clone(), Compression::None), + ("textures/koi.ktx2", noise, Compression::Lz4), + ], + ) +} + +#[test] +fn what_is_written_reads_back() { + let bytes = sample(); + let mut p = Package::from_reader(Cursor::new(bytes.clone())).unwrap(); + assert_eq!(p.manifest(), &scene_manifest()); + assert_eq!(p.read("scene.kscene").unwrap(), b"layer water\n".repeat(2000)); + assert_eq!(p.entry("scene.kscene").unwrap().compression, "lz4"); + // Noise does not compress, so it is stored as is even when LZ4 was asked for. + assert_eq!(p.entry("textures/koi.ktx2").unwrap().compression, "none"); + p.verify().unwrap(); + + let span = p.span("preview.png").unwrap(); + assert_eq!(span.offset % 4096, 0); + let raw = &bytes[span.offset as usize..(span.offset + span.len) as usize]; + assert_eq!(raw, p.read("preview.png").unwrap().as_slice()); + assert!(p.span("scene.kscene").is_none()); +} + +#[test] +fn the_same_input_gives_the_same_bytes() { + assert_eq!(sample(), sample()); +} + +#[test] +fn a_changed_byte_in_an_entry_is_caught() { + let mut bytes = sample(); + let p = Package::from_reader(Cursor::new(bytes.clone())).unwrap(); + let at = p.span("preview.png").unwrap().offset as usize + 10; + bytes[at] ^= 1; + let mut p = Package::from_reader(Cursor::new(bytes)).unwrap(); + assert!(matches!( + p.read("preview.png"), + Err(PackError::HashMismatch { .. }) + )); + assert!(p.verify().is_err()); +} + +#[test] +fn a_changed_manifest_is_caught() { + let mut bytes = sample(); + let at = bytes.windows(8).position(|w| w == b"Koi pond").unwrap(); + bytes[at] = b'X'; + assert!(matches!( + Package::from_reader(Cursor::new(bytes)), + Err(PackError::Corrupt(_)) + )); +} + +#[test] +fn truncated_and_foreign_files_are_refused() { + let bytes = sample(); + for cut in [0, 10, 64, 4096, bytes.len() - 1] { + assert!( + Package::from_reader(Cursor::new(bytes[..cut].to_vec())).is_err(), + "cut at {cut}" + ); + } + assert!(matches!( + Package::from_reader(Cursor::new(b"PK\x03\x04 a zip file, not ours".repeat(10))), + Err(PackError::NotAPackage) + )); +} + +#[test] +fn encrypted_or_newer_packages_say_so() { + let mut bytes = sample(); + bytes[10] = 1; + assert!(matches!( + Package::from_reader(Cursor::new(bytes.clone())), + Err(PackError::UnsupportedFlags(1)) + )); + bytes[10] = 0; + bytes[8] = 2; + assert!(matches!( + Package::from_reader(Cursor::new(bytes)), + Err(PackError::UnsupportedVersion(2)) + )); +} + +#[test] +fn the_writer_refuses_bad_packages() { + let mut b = Builder::new(scene_manifest()); + assert!(b.add("../escape", vec![], Compression::None).is_err()); + b.add("scene.kscene", vec![1], Compression::None).unwrap(); + assert!(matches!( + b.add("scene.kscene", vec![2], Compression::None), + Err(PackError::DuplicatePath(_)) + )); + // The manifest names a preview that was never added. + assert!(matches!( + b.write(&mut Cursor::new(Vec::new())), + Err(PackError::BadManifest(_)) + )); +} + +#[test] +fn converted_items_are_not_publishable() { + let mut m = scene_manifest(); + m.provenance = Provenance::Converted { + source: "wallpaper-engine".into(), + source_id: "123".into(), + }; + assert!(!m.provenance.publishable()); + m.provenance = Provenance::ConvertedOwn { + source: "wallpaper-engine".into(), + source_id: "123".into(), + }; + assert!(m.provenance.publishable()); +} + +#[test] +fn a_folder_packs() { + let dir = std::env::temp_dir().join(format!("kirie-pack-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("web/js")).unwrap(); + std::fs::create_dir_all(dir.join(".git")).unwrap(); + std::fs::write( + dir.join("kirie.json"), + br#"{"id":"clock","title":"Clock","kind":"web","entry":"web/index.html"}"#, + ) + .unwrap(); + std::fs::write(dir.join("web/index.html"), b"").unwrap(); + std::fs::write(dir.join("web/js/clock.js"), b"tick()").unwrap(); + std::fs::write(dir.join(".git/HEAD"), b"ref").unwrap(); + std::fs::write(dir.join("clock.kpk.partial"), b"being written").unwrap(); + + let mut out = Cursor::new(Vec::new()); + let summary = pack_dir(&dir, &mut out).unwrap(); + assert_eq!(summary.entries, 2); + let mut p = Package::from_reader(Cursor::new(out.into_inner())).unwrap(); + let paths: Vec<&str> = p.entries().iter().map(|e| e.path.as_str()).collect(); + assert_eq!(paths, ["web/index.html", "web/js/clock.js"]); + assert_eq!(p.read("web/js/clock.js").unwrap(), b"tick()"); + + std::fs::write( + dir.join("kirie.json"), + br#"{"id":"clock","title":"Clock","kind":"web","entry":"web/index.html","titel":"typo"}"#, + ) + .unwrap(); + assert!(pack_dir(&dir, &mut Cursor::new(Vec::new())).is_err()); + std::fs::remove_dir_all(&dir).unwrap(); +} diff --git a/crates/kirie/Cargo.toml b/crates/kirie/Cargo.toml index 58464b9..b5a3f79 100644 --- a/crates/kirie/Cargo.toml +++ b/crates/kirie/Cargo.toml @@ -35,6 +35,7 @@ objc2-app-kit = { version = "0.3", default-features = false, features = ["std", [dependencies] zbus = { workspace = true } kirie-formats = { workspace = true } +kirie-pack = { workspace = true } kirie-platform = { path = "../kirie-platform" } kirie-render = { path = "../kirie-render" } kirie-bake = { path = "../kirie-bake" } diff --git a/crates/kirie/src/lib.rs b/crates/kirie/src/lib.rs index 6722b4d..7195834 100644 --- a/crates/kirie/src/lib.rs +++ b/crates/kirie/src/lib.rs @@ -10,6 +10,7 @@ pub mod gpus; pub mod info; pub mod list; mod os; +pub mod pack; pub mod preview; mod preview_render; pub mod soak; @@ -82,6 +83,15 @@ enum Command { #[arg(long)] size: Option, }, + /// Package a folder holding kirie.json into a .kpk, or with --inspect, + /// show and check an existing package. + Pack { + path: PathBuf, + #[arg(short = 'o', long = "output", conflicts_with = "inspect")] + output: Option, + #[arg(long)] + inspect: bool, + }, Extract { path: PathBuf, #[arg(short = 'o', long = "output", default_value = ".")] @@ -168,6 +178,7 @@ pub fn run(args: Vec) -> ExitCode { Some(sub) if sub == "info" || sub == "extract" + || sub == "pack" || sub == "check" || sub == "list" || sub == "gpus" @@ -241,6 +252,17 @@ fn run_subcommand(args: Vec) -> ExitCode { tex_to_png, } => extract::run(&path, &output, tex_to_png), Command::List { dir, json } => list::run(dir.as_deref(), json), + Command::Pack { + path, + output, + inspect, + } => { + if inspect { + pack::inspect(&path) + } else { + pack::run(&path, output) + } + } Command::Gpus { json } => gpus::run(json), Command::Update { check, force } => update::run(check, force), Command::Workshop { command } => match command { diff --git a/crates/kirie/src/pack.rs b/crates/kirie/src/pack.rs new file mode 100644 index 0000000..4a077ce --- /dev/null +++ b/crates/kirie/src/pack.rs @@ -0,0 +1,80 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use kirie_pack::{Package, Provenance}; + +/// `kirie pack `: turn a folder holding `kirie.json` into a package. +pub fn run(dir: &Path, output: Option) -> Result<()> { + let output = output.unwrap_or_else(|| { + let name = dir + .file_name() + .map_or_else(|| "wallpaper".into(), |n| n.to_string_lossy().into_owned()); + PathBuf::from(format!("{name}.{}", kirie_pack::EXTENSION)) + }); + // Write beside the destination and rename, so a failed pack never + // leaves a half-written package where a good one was. + let partial = output.with_extension(format!("{}.partial", kirie_pack::EXTENSION)); + let mut file = + std::fs::File::create(&partial).with_context(|| format!("cannot create {}", partial.display()))?; + let summary = match kirie_pack::pack_dir(dir, &mut file) { + Ok(summary) => summary, + Err(err) => { + drop(file); + let _ = std::fs::remove_file(&partial); + return Err(err).with_context(|| format!("cannot pack {}", dir.display())); + } + }; + file.sync_all()?; + drop(file); + std::fs::rename(&partial, &output).with_context(|| format!("cannot write {}", output.display()))?; + println!( + "{}: {} entries, {} bytes", + output.display(), + summary.entries, + summary.bytes + ); + Ok(()) +} + +/// `kirie pack --inspect `: print a package's manifest and entries, +/// and check every entry's hash. +pub fn inspect(path: &Path) -> Result<()> { + let mut package = Package::open(path).with_context(|| format!("cannot open {}", path.display()))?; + let m = package.manifest(); + println!("{} ({}), {} package", m.title, m.id, m.kind.as_str()); + if !m.author.is_empty() { + println!("by {}", m.author); + } + println!("entry: {}", m.entry); + if let Some(preview) = &m.preview { + println!("preview: {preview}"); + } + if !m.tags.is_empty() { + println!("tags: {}", m.tags.join(", ")); + } + if m.mature { + println!("mature: yes"); + } + for p in &m.properties { + println!("property: {} ({})", p.key, p.label); + } + if let Some(v) = &m.min_kirie { + println!("needs kirie {v} or newer"); + } + match &m.provenance { + Provenance::Original => {} + Provenance::Converted { source, source_id } => { + println!("converted from {source} item {source_id}; stays on this machine, cannot be published"); + } + Provenance::ConvertedOwn { source, source_id } => { + println!("converted from the publisher's own {source} item {source_id}"); + } + } + println!(); + for e in package.entries() { + println!("{:>12} {:<4} {}", e.len, e.compression, e.path); + } + package.verify().context("the package failed its hash check")?; + println!("\nall {} entries match their hashes", package.entries().len()); + Ok(()) +} diff --git a/docs/PACKAGE.md b/docs/PACKAGE.md new file mode 100644 index 0000000..4c9ce89 --- /dev/null +++ b/docs/PACKAGE.md @@ -0,0 +1,63 @@ +# kirie packages (`.kpk`) + +A `.kpk` file is one wallpaper: its files, and a manifest saying what it is. +The byte layout is described at the top of `crates/kirie-pack/src/lib.rs`. + +## Making one + +Put the wallpaper's files in a folder with a `kirie.json` beside them, then: + +```sh +kirie pack my-wallpaper/ # writes my-wallpaper.kpk +kirie pack my-wallpaper/ -o out.kpk +kirie pack --inspect out.kpk # show it and check every hash +``` + +Every file in the folder goes in under its path relative to the folder, +except `kirie.json` itself, hidden files (names starting with `.`) and other +`.kpk` files. Already-compressed formats (video, PNG, JPEG, KTX2 and so on) +are stored as they are; everything else is LZ4-compressed when that saves at +least an eighth. The same folder always gives the same bytes. + +## `kirie.json` + +```json +{ + "id": "rain-city", + "title": "Rain city", + "description": "Neon streets in the rain", + "author": "you", + "kind": "video", + "entry": "loop.mp4", + "preview": "preview.jpg", + "tags": ["city", "rain"], + "mature": false, + "min_kirie": "0.9.0", + "properties": [ + { "key": "speed", "label": "Speed", "type": "slider", "min": 0, "max": 2, "step": 0.1, "default": 1 }, + { "key": "rain", "label": "Rain", "type": "toggle", "default": true }, + { "key": "tint", "label": "Tint", "type": "color", "default": [1, 0.8, 0.6] }, + { "key": "mode", "label": "Mode", "type": "choice", "default": "night", + "options": [{ "value": "day", "label": "Day" }, { "value": "night", "label": "Night" }] }, + { "key": "motto", "label": "Motto", "type": "text", "default": "" } + ] +} +``` + +| Field | Required | Meaning | +| --- | --- | --- | +| `id` | yes | Stays the same across updates. `a-z`, `0-9`, `-`, `_`, `.`; up to 128 characters | +| `title` | yes | Shown in the library and on the Workshop | +| `kind` | yes | `video`, `image`, `web` or `scene` | +| `entry` | yes | The file the player starts from. Video: `.mp4`, `.webm`, `.mkv`. Image: `.png`, `.jpg`, `.webp`, `.ktx2`. Web: `.html`. Scene: `.kscene` | +| `preview` | no | Image shown in the library and on the Workshop | +| `properties` | no | Settings the user can change, in display order. Colours are linear RGB from 0 to 1 | +| `min_kirie` | no | Oldest kirie that can play it | +| `provenance` | no | Omit for your own work. Converters set `{"origin": "converted", ...}`, and such packages cannot be published | + +`kirie pack` refuses a misspelt field name. Players ignore fields they do not +know, so a package made for a newer kirie still lists in an older haru. + +The `scene` kind is reserved: the `.kscene` scene format is not defined yet, +and kirie cannot play `.kpk` files of any kind yet. This is the container +only. From 09855b386f68c06fb772fd9049764ff09f947fd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 22:34:32 +0000 Subject: [PATCH 02/33] Refuse Windows-unsafe entry names, check nested manifest fields, escape inspect output Entry paths now also refuse components that Windows cannot hold: the characters <>"|?*, a trailing dot or space, and device names such as CON, nul.txt or COM1, so a package unpacks the same everywhere. `kirie pack` now reports misspelt fields inside properties, choice options and provenance, not only at the top level. Reading packages stays permissive. `kirie pack --inspect` escapes control characters in anything it prints from a package, so a crafted title cannot drive the terminal before the hashes are checked. The partial output file is removed and created exclusively, so a link planted at that path is not followed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Kg1KotRgE7Ws6DQwhsgTpj --- crates/kirie-pack/src/lib.rs | 47 +++++++++++++++++++++++- crates/kirie-pack/src/manifest.rs | 60 +++++++++++++++++++++++++++++-- crates/kirie/src/pack.rs | 52 ++++++++++++++++++++------- 3 files changed, 143 insertions(+), 16 deletions(-) diff --git a/crates/kirie-pack/src/lib.rs b/crates/kirie-pack/src/lib.rs index 544fbbc..a9d0d7f 100644 --- a/crates/kirie-pack/src/lib.rs +++ b/crates/kirie-pack/src/lib.rs @@ -102,10 +102,39 @@ pub(crate) fn check_entry_path(path: &str) -> Result<(), PackError> { "." | ".." => return Err(bad("it has a . or .. component")), _ => {} } + if part.contains(['<', '>', '"', '|', '?', '*']) { + return Err(bad( + "it contains a character Windows does not allow in file names", + )); + } + if part.ends_with(['.', ' ']) { + return Err(bad("a component ends in a dot or space, which Windows drops")); + } + if is_windows_device_name(part) { + return Err(bad("a component is a reserved Windows device name")); + } } Ok(()) } +/// `CON`, `nul.txt`, `COM1` and the like name devices on Windows whatever +/// their extension, so a file by that name cannot be unpacked there. +fn is_windows_device_name(part: &str) -> bool { + let stem = part + .split('.') + .next() + .unwrap_or(part) + .trim_end() + .to_ascii_uppercase(); + match stem.as_str() { + "CON" | "PRN" | "AUX" | "NUL" => true, + _ => { + let digit = stem.strip_prefix("COM").or_else(|| stem.strip_prefix("LPT")); + digit.is_some_and(|d| d.len() == 1 && d.as_bytes()[0].is_ascii_digit() && d != "0") + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -123,10 +152,26 @@ mod tests { "a\\b", "a\nb", "a/", + "a?b", + "dir./x", + "x ", + "CON", + "web/nul.txt", + "com1.json", + "Lpt9", + "aux .png", ] { assert!(check_entry_path(bad).is_err(), "{bad:?} should be refused"); } - for good in ["scene.bin", "shaders/water.spv", "web/index.html", "a b/c-d_e.f"] { + for good in [ + "scene.bin", + "shaders/water.spv", + "web/index.html", + "a b/c-d_e.f", + "console.js", + "com10.png", + "com0", + ] { check_entry_path(good).unwrap(); } } diff --git a/crates/kirie-pack/src/manifest.rs b/crates/kirie-pack/src/manifest.rs index fe09d15..42af4b9 100644 --- a/crates/kirie-pack/src/manifest.rs +++ b/crates/kirie-pack/src/manifest.rs @@ -154,10 +154,34 @@ impl Manifest { ]; let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| PackError::BadManifest(e.to_string()))?; - if let Some(fields) = value.as_object() - && let Some(unknown) = fields.keys().find(|k| !KNOWN.contains(&k.as_str())) + check_fields(&value, "", &KNOWN)?; + if let Some(provenance) = value.get("provenance") { + check_fields(provenance, "provenance.", &["origin", "source", "source_id"])?; + } + for (i, property) in value + .get("properties") + .and_then(|p| p.as_array()) + .into_iter() + .flatten() + .enumerate() { - return Err(PackError::BadManifest(format!("unknown field {unknown:?}"))); + let extra: &[&str] = match property.get("type").and_then(|t| t.as_str()) { + Some("slider") => &["min", "max", "step", "default"], + Some("choice") => &["options", "default"], + _ => &["default"], + }; + let allowed: Vec<&str> = ["key", "label", "type"].iter().chain(extra).copied().collect(); + let at = format!("properties[{i}]."); + check_fields(property, &at, &allowed)?; + for (j, option) in property + .get("options") + .and_then(|o| o.as_array()) + .into_iter() + .flatten() + .enumerate() + { + check_fields(option, &format!("{at}options[{j}]."), &["value", "label"])?; + } } serde_json::from_value(value).map_err(|e| PackError::BadManifest(e.to_string())) } @@ -272,6 +296,17 @@ impl PropertyValue { } } +/// Refuse fields of a JSON object that are not in `allowed`, naming the +/// first one with its place in the manifest. +fn check_fields(value: &serde_json::Value, at: &str, allowed: &[&str]) -> Result<(), PackError> { + if let Some(fields) = value.as_object() + && let Some(unknown) = fields.keys().find(|k| !allowed.contains(&k.as_str())) + { + return Err(PackError::BadManifest(format!("unknown field {at}{unknown}"))); + } + Ok(()) +} + fn is_version(v: &str) -> bool { let parts: Vec<&str> = v.split('.').collect(); parts.len() == 3 @@ -373,5 +408,24 @@ mod tests { let text = br#"{"id":"a","title":"A","kind":"video","entry":"a.mp4","colour":"red"}"#; assert!(Manifest::from_json_strict(text).is_err()); Manifest::from_json(text).unwrap(); + + let nested = [ + br#"{"id":"a","title":"A","kind":"video","entry":"a.mp4","properties":[ + {"key":"s","label":"S","type":"slider","min":0,"max":2,"step":1,"default":1,"defualt":0}]}"# + .as_slice(), + br#"{"id":"a","title":"A","kind":"video","entry":"a.mp4","properties":[ + {"key":"m","label":"M","type":"choice","default":"d", + "options":[{"value":"d","label":"D","lable":"x"}]}]}"#, + br#"{"id":"a","title":"A","kind":"video","entry":"a.mp4", + "provenance":{"origin":"original","sorce":"x"}}"#, + ]; + for text in nested { + assert!( + Manifest::from_json_strict(text).is_err(), + "{}", + String::from_utf8_lossy(text) + ); + Manifest::from_json(text).unwrap(); + } } } diff --git a/crates/kirie/src/pack.rs b/crates/kirie/src/pack.rs index 4a077ce..b007929 100644 --- a/crates/kirie/src/pack.rs +++ b/crates/kirie/src/pack.rs @@ -14,8 +14,17 @@ pub fn run(dir: &Path, output: Option) -> Result<()> { // Write beside the destination and rename, so a failed pack never // leaves a half-written package where a good one was. let partial = output.with_extension(format!("{}.partial", kirie_pack::EXTENSION)); - let mut file = - std::fs::File::create(&partial).with_context(|| format!("cannot create {}", partial.display()))?; + // A partial file left by an earlier failed run is removed, then the new + // one is created exclusively, so a link planted at that path is never + // followed. + match std::fs::remove_file(&partial) { + Err(err) if err.kind() != std::io::ErrorKind::NotFound => { + return Err(err).with_context(|| format!("cannot remove {}", partial.display())); + } + _ => {} + } + let mut file = std::fs::File::create_new(&partial) + .with_context(|| format!("cannot create {}", partial.display()))?; let summary = match kirie_pack::pack_dir(dir, &mut file) { Ok(summary) => summary, Err(err) => { @@ -36,43 +45,62 @@ pub fn run(dir: &Path, output: Option) -> Result<()> { Ok(()) } +/// A string from a package, safe to print: control characters (terminal +/// escape sequences among them) come out escaped rather than acted on. +fn shown(text: &str) -> String { + text.chars().flat_map(char::escape_debug).collect() +} + /// `kirie pack --inspect `: print a package's manifest and entries, /// and check every entry's hash. pub fn inspect(path: &Path) -> Result<()> { let mut package = Package::open(path).with_context(|| format!("cannot open {}", path.display()))?; let m = package.manifest(); - println!("{} ({}), {} package", m.title, m.id, m.kind.as_str()); + println!( + "{} ({}), {} package", + shown(&m.title), + shown(&m.id), + m.kind.as_str() + ); if !m.author.is_empty() { - println!("by {}", m.author); + println!("by {}", shown(&m.author)); } - println!("entry: {}", m.entry); + println!("entry: {}", shown(&m.entry)); if let Some(preview) = &m.preview { - println!("preview: {preview}"); + println!("preview: {}", shown(preview)); } if !m.tags.is_empty() { - println!("tags: {}", m.tags.join(", ")); + println!("tags: {}", shown(&m.tags.join(", "))); } if m.mature { println!("mature: yes"); } for p in &m.properties { - println!("property: {} ({})", p.key, p.label); + println!("property: {} ({})", shown(&p.key), shown(&p.label)); } if let Some(v) = &m.min_kirie { - println!("needs kirie {v} or newer"); + println!("needs kirie {} or newer", shown(v)); } match &m.provenance { Provenance::Original => {} Provenance::Converted { source, source_id } => { - println!("converted from {source} item {source_id}; stays on this machine, cannot be published"); + println!( + "converted from {} item {}; stays on this machine, cannot be published", + shown(source), + shown(source_id) + ); } Provenance::ConvertedOwn { source, source_id } => { - println!("converted from the publisher's own {source} item {source_id}"); + println!( + "converted from the publisher's own {} item {}", + shown(source), + shown(source_id) + ); } } println!(); for e in package.entries() { - println!("{:>12} {:<4} {}", e.len, e.compression, e.path); + println!("{:>12} {:<4} {}", e.len, shown(&e.compression), shown(&e.path)); } package.verify().context("the package failed its hash check")?; println!("\nall {} entries match their hashes", package.entries().len()); From b69559c306f7f19e77ba857b642f2f9e7679251e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 01:49:26 +0000 Subject: [PATCH 03/33] Start a compose layer on a transparent canvas when copy background is off A compose layer's "copybackground" was never read, so every compose layer started from a copy of the scene behind it. Wallpapers put audio bars on such layers with the option off and then scroll or warp them; kirie scrolled the copied background instead, which showed as a square of background moving out of step with the rest (the LonelyCAT clock scene's "Bar 3" with its scroll effect). With the option off, slot 0 of the layer's first pass is bound to a transparent texture. The bake format version is bumped so cached scene bundles, which predate the field, are rebuilt. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01ASKszRoN1cM2ks8vnFvkiy --- crates/kirie-bake/src/key.rs | 2 +- crates/kirie-render/src/scene/plan.rs | 1 + crates/kirie-render/src/scene/renderer.rs | 27 ++- crates/kirie-render/src/scene/texture.rs | 17 ++ crates/kirie-render/tests/compose_layer.rs | 236 +++++++++++++++++++++ crates/kirie-scene/src/object.rs | 9 + 6 files changed, 289 insertions(+), 3 deletions(-) create mode 100644 crates/kirie-render/tests/compose_layer.rs diff --git a/crates/kirie-bake/src/key.rs b/crates/kirie-bake/src/key.rs index ac9dd46..6229435 100644 --- a/crates/kirie-bake/src/key.rs +++ b/crates/kirie-bake/src/key.rs @@ -1,6 +1,6 @@ use std::fmt; -pub const BAKE_FORMAT_VERSION: u32 = 2; +pub const BAKE_FORMAT_VERSION: u32 = 3; #[derive(Clone, Copy, PartialEq, Eq, Hash)] pub struct BundleKey([u8; 32]); diff --git a/crates/kirie-render/src/scene/plan.rs b/crates/kirie-render/src/scene/plan.rs index ef3d1b0..86702b2 100644 --- a/crates/kirie-render/src/scene/plan.rs +++ b/crates/kirie-render/src/scene/plan.rs @@ -320,6 +320,7 @@ mod tests { effects: vec![], animationlayers: vec![], instance: None, + copybackground: true, } } diff --git a/crates/kirie-render/src/scene/renderer.rs b/crates/kirie-render/src/scene/renderer.rs index 084184b..dcf4b0a 100644 --- a/crates/kirie-render/src/scene/renderer.rs +++ b/crates/kirie-render/src/scene/renderer.rs @@ -1575,8 +1575,18 @@ fn build_object( } let layer_tex = base_layer_texture(image, source, registry); + let transparent = registry.transparent(); let layer_reads_scene = base_layer_name(image).as_deref().is_some_and(is_scene_rt); - let mut reads_scene = layer_reads_scene; + // A compose layer with "copy background" off draws its effects on a + // transparent canvas, not on a copy of the scene behind it. + let empty_canvas = layer_reads_scene + && !image.copybackground + && image + .material + .as_ref() + .and_then(|m| m.passes.first()) + .is_some_and(|p| is_compose_layer(&p.shader)); + let mut reads_scene = layer_reads_scene && !empty_canvas; let layer_atlas = base_layer_name(image) .filter(|n| !n.starts_with("_rt_") && !n.starts_with("_alias_")) .and_then(|n| registry.atlas_for(&n)); @@ -1758,6 +1768,12 @@ fn build_object( let composite = is_composite(&target); let mut raw_pass = raw_pass; + if i == 0 + && empty_canvas + && let Some(first) = raw_pass.textures.first_mut() + { + *first = Some(EMPTY_CANVAS.to_owned()); + } for (slot, name) in &binds { let idx = *slot as usize; if idx >= raw_pass.textures.len() { @@ -1837,7 +1853,9 @@ fn build_object( let fs_params = resolve_params(¶ms_fs, &raw_pass); let (input_view, input_sampler): (&wgpu::TextureView, &wgpu::Sampler) = if reads_layer { - if layer_reads_scene { + if empty_canvas { + (&transparent.view, &transparent.sampler) + } else if layer_reads_scene { (&scene_snapshot.view, fbo_sampler) } else { (&layer_tex.view, &layer_tex.sampler) @@ -1859,6 +1877,7 @@ fn build_object( named.insert(name.as_str(), (view, fbo_sampler)); } named.insert("previous", (comp_view, fbo_sampler)); + named.insert(EMPTY_CANVAS, (&transparent.view, &transparent.sampler)); named.insert(comp_a.as_str(), (comp_view, fbo_sampler)); named.insert(comp_b.as_str(), (comp_view, fbo_sampler)); for (name, fbo) in &named_fbos { @@ -2098,6 +2117,7 @@ fn build_text_layer( effects: tobj.effects.clone(), animationlayers: Vec::new(), instance: None, + copybackground: true, }; let mut built = build_object( device, @@ -3790,6 +3810,9 @@ fn samples_scene_by_default(pass: &kirie_scene::material::Pass, samplers: &[Samp }) } +/// The texture name slot 0 of an empty compose layer is rebound to. +const EMPTY_CANVAS: &str = "\u{0}kirie-empty-canvas"; + pub(super) fn is_compose_layer(shader: &str) -> bool { shader .rsplit('/') diff --git a/crates/kirie-render/src/scene/texture.rs b/crates/kirie-render/src/scene/texture.rs index 9f7c444..99a8cc3 100644 --- a/crates/kirie-render/src/scene/texture.rs +++ b/crates/kirie-render/src/scene/texture.rs @@ -310,6 +310,7 @@ pub struct TextureRegistry { queue: wgpu::Queue, cache: std::sync::Mutex>, white: std::sync::Arc, + transparent: std::sync::Arc, videos: std::sync::Mutex>, atlases: std::sync::Mutex>>, } @@ -338,6 +339,16 @@ impl TextureRegistry { true, true, )); + let transparent = std::sync::Arc::new(upload_rgba8( + device, + queue, + "kirie-transparent", + 1, + 1, + &[0, 0, 0, 0], + true, + true, + )); TextureRegistry { device: device.clone(), queue: queue.clone(), @@ -345,9 +356,15 @@ impl TextureRegistry { videos: std::sync::Mutex::new(Vec::new()), atlases: std::sync::Mutex::new(HashMap::new()), white, + transparent, } } + #[must_use] + pub fn transparent(&self) -> std::sync::Arc { + self.transparent.clone() + } + #[must_use] pub fn white(&self) -> std::sync::Arc { self.white.clone() diff --git a/crates/kirie-render/tests/compose_layer.rs b/crates/kirie-render/tests/compose_layer.rs new file mode 100644 index 0000000..056bdab --- /dev/null +++ b/crates/kirie-render/tests/compose_layer.rs @@ -0,0 +1,236 @@ +//! A compose layer with "copy background" off draws its effects on a +//! transparent canvas. Wallpapers put audio bars on such layers and scroll or +//! warp them; drawing on a copy of the scene instead showed a moving square of +//! background. + +use std::collections::HashMap; + +use kirie_platform::{RenderTarget, Renderer, SurfaceSize}; +use kirie_render::{ClampMode, ScalingMode, SceneOptions, SceneRenderer}; +use kirie_scene::resolve::AssetSource; +use kirie_scene::{PropertyBag, Scene, SceneModel}; + +const W: u32 = 64; +const H: u32 = 64; +const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +const VERT: &str = r#" +attribute vec3 a_Position; +attribute vec2 a_TexCoord; +uniform mat4 g_ModelViewProjectionMatrix; +varying vec2 v_TexCoord; +void main() { + gl_Position = g_ModelViewProjectionMatrix * vec4(a_Position, 1.0); + v_TexCoord = a_TexCoord; +} +"#; + +const PASS_FRAG: &str = r#" +uniform sampler2D g_Texture0; +varying vec2 v_TexCoord; +void main() { + gl_FragColor = texture(g_Texture0, v_TexCoord); +} +"#; + +/// Drops red: a copy of the red scene turns black, an empty canvas stays empty. +const TINT_FRAG: &str = r#" +uniform sampler2D g_Texture0; +varying vec2 v_TexCoord; +void main() { + vec4 c = texture(g_Texture0, v_TexCoord); + gl_FragColor = vec4(c.rgb * vec3(0.0, 1.0, 1.0), c.a); +} +"#; + +struct Memory(HashMap>); + +impl AssetSource for Memory { + fn load(&self, path: &str) -> Option> { + self.0.get(path).cloned() + } +} + +fn scene(copybackground: bool) -> (Scene, Memory) { + let mut files: HashMap> = HashMap::new(); + files.insert("shaders/util/composelayer.vert".into(), VERT.into()); + files.insert("shaders/util/composelayer.frag".into(), PASS_FRAG.into()); + files.insert("shaders/tint.vert".into(), VERT.into()); + files.insert("shaders/tint.frag".into(), TINT_FRAG.into()); + files.insert( + "materials/util/composelayer.json".into(), + br#"{"passes":[{"shader":"util/composelayer","blending":"translucent","textures":["_rt_FullFrameBuffer"]}]}"# + .to_vec(), + ); + files.insert( + "models/util/composelayer.json".into(), + br#"{"material":"materials/util/composelayer.json"}"#.to_vec(), + ); + files.insert( + "materials/tint.json".into(), + br#"{"passes":[{"shader":"tint","blending":"normal"}]}"#.to_vec(), + ); + files.insert( + "effects/tint/effect.json".into(), + br#"{"passes":[{"material":"materials/tint.json"}]}"#.to_vec(), + ); + let json = format!( + r#"{{"camera":{{"eye":"0 0 100","center":"0 0 0","up":"0 1 0"}}, + "general":{{"orthogonalprojection":{{"width":{W},"height":{H}}},"clearcolor":"1.0 0.0 0.0"}}, + "objects":[{{"id":1,"name":"bars","image":"models/util/composelayer.json","visible":true, + "origin":"{cx} {cy} 0","scale":"1 1 1","angles":"0 0 0","size":"{W} {H}", + "copybackground":{copybackground}, + "effects":[{{"file":"effects/tint/effect.json","id":2,"name":"","visible":true, + "passes":[{{"id":3}}]}}]}}]}}"#, + cx = W / 2, + cy = H / 2, + ); + ( + Scene::from_slice(json.as_bytes()).expect("the test scene parses"), + Memory(files), + ) +} + +fn gpu() -> Option<(wgpu::Device, wgpu::Queue)> { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::all(), + ..wgpu::InstanceDescriptor::new_without_display_handle() + }); + let adapter = match pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) + { + Ok(adapter) => adapter, + Err(err) => { + eprintln!("skipping: no adapter ({err})"); + return None; + } + }; + match pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("compose-layer"), + ..wgpu::DeviceDescriptor::default() + })) { + Ok(pair) => Some(pair), + Err(err) => { + eprintln!("skipping: no device ({err})"); + None + } + } +} + +fn centre_pixel(device: &wgpu::Device, queue: &wgpu::Queue, copybackground: bool) -> [u8; 4] { + let (scene, source) = scene(copybackground); + let bag = PropertyBag::default(); + let mut model = SceneModel::resolve(scene, &bag); + let problems = model.load_assets(&source, &bag); + assert!(problems.is_empty(), "test assets all resolve: {problems:?}"); + + let target = RenderTarget { + device, + queue, + format: FORMAT, + output_name: "compose-layer", + size: (W, H), + position: (0, 0), + }; + let mut renderer = SceneRenderer::new( + &target, + &model, + &source, + SceneOptions { + render_scale: 1.0, + scaling: ScalingMode::Fill, + clamp: ClampMode::Clamp, + disable_parallax: true, + fit_render_to_output: false, + only_objects: Vec::new(), + skip_objects: Vec::new(), + skip_effects: Vec::new(), + base_only: false, + no_solid_final: false, + pass_log: false, + }, + None, + &[], + ) + .expect("the test scene builds"); + + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("compose-layer-target"), + size: wgpu::Extent3d { + width: W, + height: H, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + renderer.render(&view, SurfaceSize { width: W, height: H }, 1.0 / 30.0); + let pixels = read_back(device, queue, &texture); + let at = ((H / 2 * W + W / 2) * 4) as usize; + [pixels[at], pixels[at + 1], pixels[at + 2], pixels[at + 3]] +} + +fn read_back(device: &wgpu::Device, queue: &wgpu::Queue, texture: &wgpu::Texture) -> Vec { + let padded = (W * 4).div_ceil(256) * 256; + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("compose-layer-readback"), + size: u64::from(padded * H), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded), + rows_per_image: None, + }, + }, + wgpu::Extent3d { + width: W, + height: H, + depth_or_array_layers: 1, + }, + ); + queue.submit(Some(encoder.finish())); + buffer.map_async(wgpu::MapMode::Read, .., |r| r.expect("map")); + device.poll(wgpu::PollType::wait_indefinitely()).expect("poll"); + let mapped = buffer.get_mapped_range(..).expect("mapped range"); + let mut pixels = Vec::with_capacity((W * H * 4) as usize); + for row in 0..H { + let start = (row * padded) as usize; + pixels.extend_from_slice(&mapped[start..start + (W * 4) as usize]); + } + drop(mapped); + buffer.unmap(); + pixels +} + +#[test] +fn a_compose_layer_without_copy_background_starts_empty() { + let Some((device, queue)) = gpu() else { return }; + + let copied = centre_pixel(&device, &queue, true); + assert!( + copied[0] < 16, + "the tint ran on a copy of the red scene: {copied:?}" + ); + + let empty = centre_pixel(&device, &queue, false); + assert!( + empty[0] > 240 && empty[1] < 16 && empty[2] < 16, + "the scene behind an empty compose layer shows through untouched: {empty:?}" + ); +} diff --git a/crates/kirie-scene/src/object.rs b/crates/kirie-scene/src/object.rs index 3977c8b..bdde1d4 100644 --- a/crates/kirie-scene/src/object.rs +++ b/crates/kirie-scene/src/object.rs @@ -286,6 +286,14 @@ pub struct ImageObject { pub effects: Vec, pub animationlayers: Vec, pub instance: Option, + /// A compose layer starts from a copy of the scene behind it, or, when + /// this is off, from a transparent canvas. + #[serde(default = "copies_background")] + pub copybackground: bool, +} + +fn copies_background() -> bool { + true } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -434,6 +442,7 @@ fn parse_image(obj: &Map) -> ImageObject { effects, animationlayers, instance, + copybackground: user_bool(obj, "copybackground", true).value, } } From 3a0d9a8dff30c14ac8f004b86371af741f5740a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 01:56:04 +0000 Subject: [PATCH 04/33] Empty a compose layer's canvas when the shader defaults slot 0 to the scene A compose material that leaves slot 0 out and lets the shader's sampler default to _rt_FullFrameBuffer still copied the scene with copy background off. The empty canvas now depends only on the compose shader and the option, and always rebinds slot 0. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01ASKszRoN1cM2ks8vnFvkiy --- crates/kirie-render/src/scene/renderer.rs | 15 +++--- crates/kirie-render/tests/compose_layer.rs | 58 +++++++++++++++------- 2 files changed, 47 insertions(+), 26 deletions(-) diff --git a/crates/kirie-render/src/scene/renderer.rs b/crates/kirie-render/src/scene/renderer.rs index dcf4b0a..e98cbc8 100644 --- a/crates/kirie-render/src/scene/renderer.rs +++ b/crates/kirie-render/src/scene/renderer.rs @@ -1579,8 +1579,9 @@ fn build_object( let layer_reads_scene = base_layer_name(image).as_deref().is_some_and(is_scene_rt); // A compose layer with "copy background" off draws its effects on a // transparent canvas, not on a copy of the scene behind it. - let empty_canvas = layer_reads_scene - && !image.copybackground + // The compose material may name the scene in slot 0 or leave it to the + // shader's default; either way the canvas replaces it. + let empty_canvas = !image.copybackground && image .material .as_ref() @@ -1768,11 +1769,11 @@ fn build_object( let composite = is_composite(&target); let mut raw_pass = raw_pass; - if i == 0 - && empty_canvas - && let Some(first) = raw_pass.textures.first_mut() - { - *first = Some(EMPTY_CANVAS.to_owned()); + if i == 0 && empty_canvas { + if raw_pass.textures.is_empty() { + raw_pass.textures.push(None); + } + raw_pass.textures[0] = Some(EMPTY_CANVAS.to_owned()); } for (slot, name) in &binds { let idx = *slot as usize; diff --git a/crates/kirie-render/tests/compose_layer.rs b/crates/kirie-render/tests/compose_layer.rs index 056bdab..175a436 100644 --- a/crates/kirie-render/tests/compose_layer.rs +++ b/crates/kirie-render/tests/compose_layer.rs @@ -51,17 +51,30 @@ impl AssetSource for Memory { } } -fn scene(copybackground: bool) -> (Scene, Memory) { +fn scene(copybackground: bool, names_the_scene: bool) -> (Scene, Memory) { let mut files: HashMap> = HashMap::new(); files.insert("shaders/util/composelayer.vert".into(), VERT.into()); - files.insert("shaders/util/composelayer.frag".into(), PASS_FRAG.into()); + files.insert("shaders/tint.vert".into(), VERT.into()); files.insert("shaders/tint.frag".into(), TINT_FRAG.into()); - files.insert( - "materials/util/composelayer.json".into(), - br#"{"passes":[{"shader":"util/composelayer","blending":"translucent","textures":["_rt_FullFrameBuffer"]}]}"# - .to_vec(), - ); + // Either the material names the scene in slot 0, or the shader's + // sampler defaults to it. + let (material, frag) = if names_the_scene { + ( + br#"{"passes":[{"shader":"util/composelayer","blending":"translucent","textures":["_rt_FullFrameBuffer"]}]}"#.to_vec(), + PASS_FRAG.to_owned(), + ) + } else { + ( + br#"{"passes":[{"shader":"util/composelayer","blending":"translucent"}]}"#.to_vec(), + PASS_FRAG.replace( + "uniform sampler2D g_Texture0;", + r#"uniform sampler2D g_Texture0; // {"default":"_rt_FullFrameBuffer"}"#, + ), + ) + }; + files.insert("materials/util/composelayer.json".into(), material); + files.insert("shaders/util/composelayer.frag".into(), frag.into_bytes()); files.insert( "models/util/composelayer.json".into(), br#"{"material":"materials/util/composelayer.json"}"#.to_vec(), @@ -116,8 +129,13 @@ fn gpu() -> Option<(wgpu::Device, wgpu::Queue)> { } } -fn centre_pixel(device: &wgpu::Device, queue: &wgpu::Queue, copybackground: bool) -> [u8; 4] { - let (scene, source) = scene(copybackground); +fn centre_pixel( + device: &wgpu::Device, + queue: &wgpu::Queue, + copybackground: bool, + names_the_scene: bool, +) -> [u8; 4] { + let (scene, source) = scene(copybackground, names_the_scene); let bag = PropertyBag::default(); let mut model = SceneModel::resolve(scene, &bag); let problems = model.load_assets(&source, &bag); @@ -222,15 +240,17 @@ fn read_back(device: &wgpu::Device, queue: &wgpu::Queue, texture: &wgpu::Texture fn a_compose_layer_without_copy_background_starts_empty() { let Some((device, queue)) = gpu() else { return }; - let copied = centre_pixel(&device, &queue, true); - assert!( - copied[0] < 16, - "the tint ran on a copy of the red scene: {copied:?}" - ); + for names_the_scene in [true, false] { + let copied = centre_pixel(&device, &queue, true, names_the_scene); + assert!( + copied[0] < 16, + "the tint ran on a copy of the red scene ({names_the_scene}): {copied:?}" + ); - let empty = centre_pixel(&device, &queue, false); - assert!( - empty[0] > 240 && empty[1] < 16 && empty[2] < 16, - "the scene behind an empty compose layer shows through untouched: {empty:?}" - ); + let empty = centre_pixel(&device, &queue, false, names_the_scene); + assert!( + empty[0] > 240 && empty[1] < 16 && empty[2] < 16, + "the scene behind an empty compose layer shows through untouched ({names_the_scene}): {empty:?}" + ); + } } From 2a0022227ba182be30a9cf06e661fbc35d16db89 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 01:59:48 +0000 Subject: [PATCH 05/33] Give each `kirie pack` run its own partial file Two runs packing to the same output used to share one partial path, so the second could delete the first's open file and the first could then rename an incomplete package into place. The partial file is now hidden, named after the process that writes it, and created exclusively; a run only ever renames or removes its own. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Kg1KotRgE7Ws6DQwhsgTpj --- crates/kirie/src/pack.rs | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/crates/kirie/src/pack.rs b/crates/kirie/src/pack.rs index b007929..d598005 100644 --- a/crates/kirie/src/pack.rs +++ b/crates/kirie/src/pack.rs @@ -12,17 +12,14 @@ pub fn run(dir: &Path, output: Option) -> Result<()> { PathBuf::from(format!("{name}.{}", kirie_pack::EXTENSION)) }); // Write beside the destination and rename, so a failed pack never - // leaves a half-written package where a good one was. - let partial = output.with_extension(format!("{}.partial", kirie_pack::EXTENSION)); - // A partial file left by an earlier failed run is removed, then the new - // one is created exclusively, so a link planted at that path is never - // followed. - match std::fs::remove_file(&partial) { - Err(err) if err.kind() != std::io::ErrorKind::NotFound => { - return Err(err).with_context(|| format!("cannot remove {}", partial.display())); - } - _ => {} - } + // leaves a half-written package where a good one was. The partial file + // is hidden (so packing into the source folder skips it), named after + // this process (so two runs to the same output do not share it), and + // created exclusively (so a link planted at that path is not followed). + let file_name = output + .file_name() + .map_or_else(|| "wallpaper".into(), |n| n.to_string_lossy().into_owned()); + let partial = output.with_file_name(format!(".{file_name}.{}.partial", std::process::id())); let mut file = std::fs::File::create_new(&partial) .with_context(|| format!("cannot create {}", partial.display()))?; let summary = match kirie_pack::pack_dir(dir, &mut file) { From 5563cc284a94a2b411fd9376c4ae6aa7fb2f36ea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:50:20 +0000 Subject: [PATCH 06/33] Make control and preview sockets owner-only and cap request size The sockets were bound with whatever the umask allowed, so with a group- writable umask another account could send `screenshot ` or `bg`. Stale-socket cleanup now removes only an actual socket, never a regular file a mistyped --control-socket points at. Request lines are capped at 1 MiB. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LDKTCgMEZkLw7hFLpgLXNJ --- crates/kirie-ipc/src/lib.rs | 2 +- crates/kirie-ipc/src/os.rs | 96 +++++++++++++++++++++++++- crates/kirie-ipc/src/server.rs | 25 +++++-- crates/kirie-ipc/tests/socket.rs | 43 +++++++++++- crates/kirie/src/compat/desktop_ipc.rs | 20 +++--- crates/kirie/src/compat/signals.rs | 3 +- crates/kirie/src/preview.rs | 10 +-- 7 files changed, 171 insertions(+), 28 deletions(-) diff --git a/crates/kirie-ipc/src/lib.rs b/crates/kirie-ipc/src/lib.rs index 687cad7..0f94e3f 100644 --- a/crates/kirie-ipc/src/lib.rs +++ b/crates/kirie-ipc/src/lib.rs @@ -13,6 +13,6 @@ pub use event::{CommandOutcome, IpcEvent}; // Windows has AF_UNIX too; only the standard library's door to it is // Unix-only. Everything in kirie that talks to the control socket goes through // these rather than `std::os::unix::net`, so there is one place to change. -pub use os::{UnixListener, UnixStream, path_bytes, path_from_bytes}; +pub use os::{UnixListener, UnixStream, bind_private, path_bytes, path_from_bytes, remove_stale_socket}; pub use server::ControlSocket; pub use status::{ScreenStatus, StatusSnapshot}; diff --git a/crates/kirie-ipc/src/os.rs b/crates/kirie-ipc/src/os.rs index 7415e1a..d2b341a 100644 --- a/crates/kirie-ipc/src/os.rs +++ b/crates/kirie-ipc/src/os.rs @@ -1,11 +1,12 @@ //! The places where the control socket has to know which operating system it //! is on. //! -//! There are three of them, and they are all here rather than spread through -//! the protocol code: the socket type itself, how a path crosses the wire, and -//! whether a socket someone else left behind belongs to us. +//! They are all here rather than spread through the protocol code: the socket +//! type itself, who may connect to it, how a path crosses the wire, and what a +//! socket someone else left behind is to us. use std::borrow::Cow; +use std::io; use std::path::{Path, PathBuf}; #[cfg(unix)] @@ -19,6 +20,62 @@ pub use std::os::unix::net::{UnixListener, UnixStream}; #[cfg(windows)] pub use uds_windows::{UnixListener, UnixStream}; +/// Bind a listener at `path` that only this account can connect to. +/// +/// Whoever can connect can load any wallpaper and write a screenshot anywhere +/// this account can write, so the socket must not be left at whatever the +/// umask allows. There is a moment between the bind and the chmod, which is +/// why the socket belongs in a directory only its owner can enter. +pub fn bind_private(path: &Path) -> io::Result { + let listener = UnixListener::bind(path)?; + if let Err(err) = restrict_to_owner(path) { + let _ = std::fs::remove_file(path); + return Err(err); + } + Ok(listener) +} + +#[cfg(unix)] +fn restrict_to_owner(path: &Path) -> io::Result<()> { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) +} + +/// A Windows socket file takes its ACL from the directory it is made in, and +/// the default one, `%LOCALAPPDATA%\kirie`, is already private to the account. +#[cfg(windows)] +fn restrict_to_owner(_path: &Path) -> io::Result<()> { + Ok(()) +} + +/// Remove the socket a previous run left at `path`, and nothing else. +/// +/// The path is typed by a person (`--control-socket`, `--socket`), and a typo +/// that names a real file must not delete it; the bind that follows fails +/// instead. A missing path is not an error. +#[cfg(unix)] +pub fn remove_stale_socket(path: &Path) -> io::Result<()> { + use std::os::unix::fs::FileTypeExt; + + match std::fs::symlink_metadata(path) { + Ok(meta) if meta.file_type().is_socket() => std::fs::remove_file(path), + Ok(_) => Ok(()), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err), + } +} + +/// std cannot tell an AF_UNIX socket file from a plain one on Windows, so +/// this removes whatever is there, as it always has. +#[cfg(windows)] +pub fn remove_stale_socket(path: &Path) -> io::Result<()> { + match std::fs::remove_file(path) { + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), + other => other, + } +} + /// A path as it goes onto the wire. /// /// The protocol is bytes, because on Linux that is what a path is: whatever the @@ -103,6 +160,39 @@ pub(crate) fn socket_is_foreign(_path: &Path) -> bool { mod tests { use super::*; + #[cfg(unix)] + #[test] + fn a_private_socket_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let dir = std::env::temp_dir().join(format!("kirie-ipc-private-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("private.sock"); + let _listener = bind_private(&path).unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + let _ = std::fs::remove_dir_all(&dir); + assert_eq!(mode & 0o777, 0o600); + } + + #[cfg(unix)] + #[test] + fn only_a_socket_is_removed_as_stale() { + let dir = std::env::temp_dir().join(format!("kirie-ipc-stale-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("notes.txt"); + std::fs::write(&file, b"keep me").unwrap(); + let socket = dir.join("old.sock"); + drop(UnixListener::bind(&socket).unwrap()); + + remove_stale_socket(&file).unwrap(); + remove_stale_socket(&socket).unwrap(); + remove_stale_socket(&dir.join("missing.sock")).unwrap(); + let (file_kept, socket_gone) = (file.exists(), !socket.exists()); + let _ = std::fs::remove_dir_all(&dir); + assert!(file_kept); + assert!(socket_gone); + } + #[test] fn a_path_survives_the_wire() { let path = PathBuf::from(if cfg!(windows) { diff --git a/crates/kirie-ipc/src/server.rs b/crates/kirie-ipc/src/server.rs index 2e48e37..a3f8f8e 100644 --- a/crates/kirie-ipc/src/server.rs +++ b/crates/kirie-ipc/src/server.rs @@ -2,22 +2,28 @@ use std::io::{ErrorKind, Read, Write}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread; use std::thread::JoinHandle; use std::time::{Duration, Instant}; -use std::{fs, thread}; use crossbeam_channel::{Sender, bounded}; use crate::command::{Request, parse_request}; use crate::error::IpcError; use crate::event::{CommandOutcome, IpcEvent}; -use crate::os::{UnixListener, UnixStream, socket_is_foreign}; +use crate::os::{UnixListener, UnixStream, bind_private, remove_stale_socket, socket_is_foreign}; use crate::status::format_status; const READ_TIMEOUT: Duration = Duration::from_millis(50); const READ_CHUNK: usize = 1024; +/// The longest request line read. Every command is a word or two and at most +/// one path, so this is room for any path Windows or Linux can name; without +/// a cap a client that never sends a newline grows the buffer for the whole +/// connection deadline. +const MAX_REQUEST: usize = 1 << 20; + const CONNECTION_DEADLINE: Duration = Duration::from_secs(2); const WORKSHOP_DEADLINE: Duration = Duration::from_secs(30); @@ -37,9 +43,7 @@ pub struct ControlSocket { impl ControlSocket { pub fn bind(path: impl Into, events: Sender) -> Result { let path = path.into(); - if let Err(e) = fs::remove_file(&path) - && e.kind() != ErrorKind::NotFound - { + if let Err(e) = remove_stale_socket(&path) { // A shared temp dir plus a fixed name means the socket can belong to // a different account; /tmp's sticky bit then refuses the unlink and // the bind below fails with a bare EADDRINUSE. Say what is wrong. @@ -48,7 +52,7 @@ impl ControlSocket { } tracing::debug!(path = %path.display(), error = %e, "stale socket unlink failed"); } - let listener = UnixListener::bind(&path).map_err(|source| IpcError::Bind { + let listener = bind_private(&path).map_err(|source| IpcError::Bind { path: path.clone(), source, })?; @@ -87,7 +91,7 @@ impl ControlSocket { "could not wake control-socket thread; detaching it"); } } - let _ = fs::remove_file(&self.path); + let _ = remove_stale_socket(&self.path); } } @@ -136,6 +140,13 @@ fn handle_connection(mut stream: UnixStream, events: &Sender) { if has_newline { break; } + if buf.len() > MAX_REQUEST { + tracing::debug!(bytes = buf.len(), "control-socket request too long; refused"); + if let Err(e) = stream.write_all(RESP_ERROR) { + tracing::debug!(error = %e, "control-socket response write failed"); + } + return; + } } Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => break, Err(e) if e.kind() == ErrorKind::Interrupted => continue, diff --git a/crates/kirie-ipc/tests/socket.rs b/crates/kirie-ipc/tests/socket.rs index de84956..8c85571 100644 --- a/crates/kirie-ipc/tests/socket.rs +++ b/crates/kirie-ipc/tests/socket.rs @@ -218,7 +218,7 @@ fn unknown_command() { #[test] fn oversized_request_line_is_served() { let s = Server::start("oversized", MockApp::doc_semantics()); - let long = "a".repeat(1024 * 1024); + let long = "a".repeat(256 * 1024); let mut line = format!("bg HDMI-A-1 /{long}").into_bytes(); line.push(b'\n'); assert_eq!(s.request(&line), b"ok\n"); @@ -231,6 +231,23 @@ fn oversized_request_line_is_served() { } } +#[test] +fn a_request_past_the_size_cap_is_refused() { + let s = Server::start("too-long", MockApp::doc_semantics()); + let mut stream = connect(&s.sock); + let chunk = vec![b'a'; 64 * 1024]; + let mut response = Vec::new(); + for _ in 0..32 { + if stream.write_all(&chunk).is_err() { + break; + } + } + let _ = stream.shutdown(Shutdown::Write); + let _ = stream.read_to_end(&mut response); + assert_eq!(response, b"error\n"); + assert_eq!(s.request(b"ping\n"), b"pong\n"); +} + #[test] fn half_open_client_times_out_without_blocking_others() { let s = Server::start("half-open", MockApp::doc_semantics()); @@ -546,7 +563,8 @@ fn screenshot_ok_and_empty_path_error() { fn stale_socket_file_is_unlinked_on_bind() { let dir = TempDir::new("stale"); let sock = dir.sock(); - fs::write(&sock, b"stale").unwrap(); + drop(kirie_ipc::UnixListener::bind(&sock).unwrap()); + assert!(sock.exists(), "a dropped listener leaves its socket file"); let (tx, rx) = unbounded(); let server = ControlSocket::bind(&sock, tx).expect("bind over stale file"); let (app, _cap) = MockApp::doc_semantics().spawn(rx); @@ -556,6 +574,27 @@ fn stale_socket_file_is_unlinked_on_bind() { assert!(!sock.exists(), "socket file left behind after shutdown"); } +#[cfg(unix)] +#[test] +fn a_file_that_is_not_a_socket_is_never_replaced() { + let dir = TempDir::new("not-a-socket"); + let sock = dir.sock(); + fs::write(&sock, b"somebody's notes").unwrap(); + let (tx, _rx) = unbounded(); + assert!(ControlSocket::bind(&sock, tx).is_err()); + assert_eq!(fs::read(&sock).unwrap(), b"somebody's notes"); +} + +#[cfg(unix)] +#[test] +fn the_socket_is_private_to_its_owner() { + use std::os::unix::fs::PermissionsExt; + + let s = Server::start("private", MockApp::doc_semantics()); + let mode = fs::metadata(&s.sock).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); +} + #[test] fn app_gone_yields_dead_engine_signal() { let dir = TempDir::new("app-gone"); diff --git a/crates/kirie/src/compat/desktop_ipc.rs b/crates/kirie/src/compat/desktop_ipc.rs index 2b70356..4881caf 100644 --- a/crates/kirie/src/compat/desktop_ipc.rs +++ b/crates/kirie/src/compat/desktop_ipc.rs @@ -1,11 +1,11 @@ use std::collections::BTreeMap; -use std::io::{BufRead as _, BufReader, Write as _}; +use std::io::{BufRead as _, BufReader, Read as _, Write as _}; use std::path::{Path, PathBuf}; use std::sync::atomic::AtomicBool; use std::sync::mpsc::Sender; use std::sync::{Arc, Mutex}; -use kirie_ipc::{UnixListener, UnixStream}; +use kirie_ipc::UnixStream; use kirie_platform::{RenderCommand, RenderTarget, Renderer, SurfaceSize}; use crate::compat::args::{ClampMode, CompatArgs, ScalingMode}; @@ -130,6 +130,10 @@ impl Showing { } } +/// The longest request line read, as on Linux: room for any path, and a +/// bound on what a client that never sends a newline can make us hold. +const MAX_REQUEST: u64 = 1 << 20; + #[must_use] pub fn already_running(socket: &Path) -> bool { let Ok(stream) = UnixStream::connect(socket) else { @@ -146,8 +150,8 @@ pub fn already_running(socket: &Path) -> bool { } pub fn serve(socket: PathBuf, orders: Sender, showing: Arc, args: CompatArgs) { - let _ = std::fs::remove_file(&socket); - let listener = match UnixListener::bind(&socket) { + let _ = kirie_ipc::remove_stale_socket(&socket); + let listener = match kirie_ipc::bind_private(&socket) { Ok(listener) => listener, Err(err) => { tracing::error!(path = %socket.display(), %err, "cannot open the control socket"); @@ -169,8 +173,8 @@ pub fn serve(socket: PathBuf, orders: Sender, showing: Arc) { - let _ = std::fs::remove_file(&socket); - let listener = match UnixListener::bind(&socket) { + let _ = kirie_ipc::remove_stale_socket(&socket); + let listener = match kirie_ipc::bind_private(&socket) { Ok(listener) => listener, Err(err) => { tracing::error!(path = %socket.display(), %err, "cannot open the control socket"); @@ -182,7 +186,7 @@ pub fn serve_relaunching(socket: PathBuf, showing: Arc) { for stream in listener.incoming() { let Ok(stream) = stream else { continue }; let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(5))); - let mut reader = BufReader::new(&stream); + let mut reader = BufReader::new((&stream).take(MAX_REQUEST)); let mut line = String::new(); if reader.read_line(&mut line).is_err() || line.is_empty() { continue; @@ -268,7 +272,7 @@ where fn answer(stream: &UnixStream, orders: &Sender, showing: &Arc, args: &CompatArgs) { let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(5))); - let mut reader = BufReader::new(stream); + let mut reader = BufReader::new(stream.take(MAX_REQUEST)); let mut line = String::new(); if reader.read_line(&mut line).is_err() || line.is_empty() { return; diff --git a/crates/kirie/src/compat/signals.rs b/crates/kirie/src/compat/signals.rs index 53dfb59..5ff4b31 100644 --- a/crates/kirie/src/compat/signals.rs +++ b/crates/kirie/src/compat/signals.rs @@ -64,11 +64,10 @@ fn unlink(socket_path: Option<&std::path::Path>, signal: i32) { tracing::info!(signal, "signal received; shutting down"); return; }; - match std::fs::remove_file(path) { + match kirie_ipc::remove_stale_socket(path) { Ok(()) => { tracing::info!(path = %path.display(), signal, "signal received; control socket unlinked"); } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => { tracing::warn!(path = %path.display(), error = %e, "failed to unlink control socket on signal"); } diff --git a/crates/kirie/src/preview.rs b/crates/kirie/src/preview.rs index d0e2f44..0741013 100644 --- a/crates/kirie/src/preview.rs +++ b/crates/kirie/src/preview.rs @@ -4,7 +4,7 @@ use std::sync::mpsc::{Receiver, TryRecvError, channel}; use std::time::{Duration, Instant}; use anyhow::{Context, Result, anyhow, bail}; -use kirie_ipc::{UnixListener, UnixStream}; +use kirie_ipc::UnixStream; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct FrameHeader { @@ -102,9 +102,9 @@ const IDLE_EXIT: Duration = Duration::from_secs(30); const DEFAULT_EDGE: u32 = 960; pub fn run(socket: &Path, background: &Path, fps: Option, edge: Option) -> Result<()> { - let _ = std::fs::remove_file(socket); - let listener = - UnixListener::bind(socket).with_context(|| format!("bind preview socket {}", socket.display()))?; + let _ = kirie_ipc::remove_stale_socket(socket); + let listener = kirie_ipc::bind_private(socket) + .with_context(|| format!("bind preview socket {}", socket.display()))?; tracing::info!(socket = %socket.display(), "preview listening"); let requested_fps = fps.unwrap_or(DEFAULT_FPS).clamp(1, 120); @@ -123,7 +123,7 @@ pub fn run(socket: &Path, background: &Path, fps: Option, edge: Option Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { if waiting_since.elapsed() >= IDLE_EXIT { tracing::info!("no preview client; exiting"); - let _ = std::fs::remove_file(socket); + let _ = kirie_ipc::remove_stale_socket(socket); return Ok(()); } std::thread::sleep(Duration::from_millis(100)); From 0ed244af519782226b7d63b8cf24dfc82ebe0a99 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:50:20 +0000 Subject: [PATCH 07/33] Keep the fallback runtime dir private and save properties on Windows /tmp/kirie- is created 0700 and a pre-existing one owned by someone else is flagged. Saved properties read only XDG_CONFIG_HOME/HOME and so did nothing on Windows; they now use %APPDATA% there. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LDKTCgMEZkLw7hFLpgLXNJ --- crates/kirie/src/compat/saved_props.rs | 5 +- crates/kirie/src/os.rs | 63 ++++++++++++++++++++++---- 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/crates/kirie/src/compat/saved_props.rs b/crates/kirie/src/compat/saved_props.rs index 5c7e540..a9adbab 100644 --- a/crates/kirie/src/compat/saved_props.rs +++ b/crates/kirie/src/compat/saved_props.rs @@ -8,10 +8,7 @@ pub fn store() -> Option { if let Some(set) = std::env::var_os("KIRIE_PROPERTY_STORE") { return Some(PathBuf::from(set)); } - let base = std::env::var_os("XDG_CONFIG_HOME") - .map(PathBuf::from) - .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?; - Some(base.join(DIRECTORY)) + Some(crate::os::config_dir()?.join(DIRECTORY)) } #[must_use] diff --git a/crates/kirie/src/os.rs b/crates/kirie/src/os.rs index 60e5eaa..af15690 100644 --- a/crates/kirie/src/os.rs +++ b/crates/kirie/src/os.rs @@ -21,14 +21,32 @@ pub(crate) fn runtime_dir() -> PathBuf { return PathBuf::from(runtime); } let dir = std::env::temp_dir().join(format!("kirie-{}", current_user_tag())); - if std::fs::create_dir_all(&dir).is_ok() { - use std::os::unix::fs::PermissionsExt; - - let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)); + if let Err(err) = make_private_dir(&dir) { + tracing::warn!(path = %dir.display(), %err, "the control-socket directory is not private"); } dir } +/// Make `dir` 0700, or check that the one already there is ours and make it +/// 0700. Anyone can create a name in the shared temp dir first, and a +/// directory someone else owns lets them replace the socket inside it. +#[cfg(unix)] +fn make_private_dir(dir: &Path) -> std::io::Result<()> { + use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt}; + + match std::fs::DirBuilder::new().mode(0o700).create(dir) { + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {} + other => return other, + } + let meta = std::fs::symlink_metadata(dir)?; + if !meta.is_dir() || own_uid().is_some_and(|uid| uid != meta.uid()) { + return Err(std::io::Error::other( + "it belongs to another account, or is not a directory", + )); + } + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) +} + /// Windows has no `XDG_RUNTIME_DIR` and no mode bits to set, but it does have a /// per-user directory the OS already keeps other accounts out of. /// `%LOCALAPPDATA%\kirie` is that directory; the temp fallback matches the Unix @@ -47,14 +65,17 @@ pub(crate) fn runtime_dir() -> PathBuf { /// directory only one of them should use. #[cfg(unix)] pub(crate) fn current_user_tag() -> String { + own_uid().map_or_else(named_user, |uid| uid.to_string()) +} + +/// This account's uid, read off its home directory: the crate forbids the +/// `unsafe` a `getuid` call would need. +#[cfg(unix)] +fn own_uid() -> Option { use std::os::unix::fs::MetadataExt; - if let Some(home) = std::env::var_os("HOME") - && let Ok(meta) = std::fs::metadata(&home) - { - return meta.uid().to_string(); - } - named_user() + let home = std::env::var_os("HOME")?; + std::fs::metadata(home).ok().map(|meta| meta.uid()) } #[cfg(windows)] @@ -69,6 +90,28 @@ fn named_user() -> String { .unwrap_or_else(|_| "shared".to_owned()) } +/// The per-user configuration directory: `XDG_CONFIG_HOME` or `~/.config` +/// on Unix, `%APPDATA%` on Windows, which sets neither `XDG_CONFIG_HOME` nor +/// `HOME`. +#[cfg(unix)] +pub(crate) fn config_dir() -> Option { + std::env::var_os("XDG_CONFIG_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("HOME") + .filter(|home| !home.is_empty()) + .map(|home| PathBuf::from(home).join(".config")) + }) +} + +#[cfg(windows)] +pub(crate) fn config_dir() -> Option { + std::env::var_os("APPDATA") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + /// Mark a file we just downloaded as something that can be run. /// /// Windows decides that from the extension, so there is nothing to set; the From 4fa84f9e07dec50ecba6e8eb5117bb55df2437ca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:50:20 +0000 Subject: [PATCH 08/33] Honour --disable-mouse, --fps and --playback-speed on the Linux backends run.rs never set PresentOptions.pointer, so --disable-mouse did nothing and Hyprland was still polled for the cursor 60 times a second. The X11 backend ignored every option and rendered at 60 fps when 30 was the default. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LDKTCgMEZkLw7hFLpgLXNJ --- crates/kirie-platform/examples/layer_clear.rs | 2 +- crates/kirie-platform/src/backend.rs | 10 +++++--- crates/kirie-platform/src/platform.rs | 22 +++++++++++++----- crates/kirie-platform/src/x11.rs | 23 ++++++++++++++++--- crates/kirie/src/compat/run.rs | 3 ++- 5 files changed, 46 insertions(+), 14 deletions(-) diff --git a/crates/kirie-platform/examples/layer_clear.rs b/crates/kirie-platform/examples/layer_clear.rs index 4087df0..cea3b0c 100644 --- a/crates/kirie-platform/examples/layer_clear.rs +++ b/crates/kirie-platform/examples/layer_clear.rs @@ -89,7 +89,7 @@ mod linux { return Err("--window is only supported on the X11 backend".into()); } (_, Some((w, h))) => { - Platform::connect_x11(X11Mode::Window { width: w, height: h }, make_factory())? + Platform::connect_x11(X11Mode::Window { width: w, height: h }, &options, make_factory())? } (Some(Backend::Wayland), None) => { Platform::connect_with(Backend::Wayland, options, make_factory())? diff --git a/crates/kirie-platform/src/backend.rs b/crates/kirie-platform/src/backend.rs index 50889fd..fd03f9c 100644 --- a/crates/kirie-platform/src/backend.rs +++ b/crates/kirie-platform/src/backend.rs @@ -116,7 +116,7 @@ impl Platform { options, )?)), #[cfg(target_os = "linux")] - Backend::X11 => Self::connect_x11(X11Mode::Desktop, make_renderer), + Backend::X11 => Self::connect_x11(X11Mode::Desktop, &options, make_renderer), #[cfg(target_os = "macos")] Backend::Mac => Ok(Self::Mac(crate::macos::MacPlatform::connect_with( make_renderer, @@ -131,8 +131,12 @@ impl Platform { } #[cfg(target_os = "linux")] - pub fn connect_x11(mode: X11Mode, make_renderer: RendererFactory) -> Result { - Ok(Self::X11(X11Platform::connect(mode, make_renderer)?)) + pub fn connect_x11( + mode: X11Mode, + options: &PresentOptions, + make_renderer: RendererFactory, + ) -> Result { + Ok(Self::X11(X11Platform::connect(mode, options, make_renderer)?)) } #[must_use] diff --git a/crates/kirie-platform/src/platform.rs b/crates/kirie-platform/src/platform.rs index de7ecb2..d48d328 100644 --- a/crates/kirie-platform/src/platform.rs +++ b/crates/kirie-platform/src/platform.rs @@ -71,6 +71,9 @@ struct PlatformState { cmd_tx: CmdSender, preloaded: HashMap<(String, String), (wgpu::TextureFormat, Box)>, pointer: crate::pointer::PointerPoll, + /// False under `--disable-mouse`: the wallpaper is never told where the + /// pointer is, and Hyprland is not polled for it. + follow_pointer: bool, seat_state: SeatState, cursor_shape: Option, pointers: Vec<(wl_pointer::WlPointer, Option)>, @@ -187,7 +190,12 @@ impl WaylandPlatform { }, cmd_tx, preloaded: HashMap::new(), - pointer: crate::pointer::PointerPoll::start(), + pointer: if options.pointer { + crate::pointer::PointerPoll::start() + } else { + crate::pointer::PointerPoll::default() + }, + follow_pointer: options.pointer, seat_state, cursor_shape, pointers: Vec::new(), @@ -805,12 +813,14 @@ impl PlatformState { * self.playback_speed; ctx.last_frame = Some(now); - if let Some((gx, gy)) = self.pointer.get() { - let (nx, ny, on_output) = pointer_on_output((gx, gy), ctx.position, ctx.logical_size); - renderer.set_pointer(nx, ny); - renderer.set_pointer_on_output(on_output); + if self.follow_pointer { + if let Some((gx, gy)) = self.pointer.get() { + let (nx, ny, on_output) = pointer_on_output((gx, gy), ctx.position, ctx.logical_size); + renderer.set_pointer(nx, ny); + renderer.set_pointer_on_output(on_output); + } + renderer.set_pointer_buttons(self.buttons.left()); } - renderer.set_pointer_buttons(self.buttons.left()); renderer.render(&view, ctx.physical_size, dt); let hint = renderer.redraw_hint(); diff --git a/crates/kirie-platform/src/x11.rs b/crates/kirie-platform/src/x11.rs index de04757..bab9648 100644 --- a/crates/kirie-platform/src/x11.rs +++ b/crates/kirie-platform/src/x11.rs @@ -73,10 +73,15 @@ pub struct X11Platform { conn: XCBConnection, make_renderer: RendererFactory, frame_interval: Duration, + playback_speed: f32, } impl X11Platform { - pub(crate) fn connect(mode: X11Mode, make_renderer: RendererFactory) -> Result { + pub(crate) fn connect( + mode: X11Mode, + options: &crate::PresentOptions, + make_renderer: RendererFactory, + ) -> Result { let (conn, screen_num) = XCBConnection::connect(None).map_err(|e| PlatformError::X11Connect(e.to_string()))?; @@ -147,7 +152,17 @@ impl X11Platform { gpu, conn, make_renderer, - frame_interval: Duration::from_micros(16_666), + frame_interval: options + .fps + .filter(|fps| *fps > 0) + .map_or(Duration::from_micros(16_666), |fps| { + Duration::from_secs_f64(1.0 / f64::from(fps)) + }), + playback_speed: if options.playback_speed > 0.0 { + options.playback_speed as f32 + } else { + 1.0 + }, }; for index in 0..platform.outputs.len() { @@ -193,6 +208,7 @@ impl X11Platform { fn draw(&mut self, index: usize) { let (device, queue) = (self.gpu.device.clone(), self.gpu.queue.clone()); + let speed = self.playback_speed; let Some(ctx) = self.outputs.get_mut(index) else { return; @@ -250,7 +266,8 @@ impl X11Platform { let dt = ctx .last_frame .map(|prev| now.duration_since(prev).as_secs_f32()) - .unwrap_or(0.0); + .unwrap_or(0.0) + * speed; ctx.last_frame = Some(now); renderer.render(&view, ctx.physical_size, dt); diff --git a/crates/kirie/src/compat/run.rs b/crates/kirie/src/compat/run.rs index dc452c5..7deff4f 100644 --- a/crates/kirie/src/compat/run.rs +++ b/crates/kirie/src/compat/run.rs @@ -377,6 +377,7 @@ fn run_wallpapers(args: CompatArgs) -> ExitCode { fullscreen_pause_only_active: args.fullscreen_pause_only_active, fullscreen_pause_ignore_appids: args.fullscreen_pause_ignore_appid.clone(), release_hidden_after: args.release_hidden_after.map(Duration::from_secs), + pointer: !args.disable_mouse, ..Default::default() }; @@ -410,7 +411,7 @@ fn run_wallpapers(args: CompatArgs) -> ExitCode { height: u32::try_from(w.h.max(1)).unwrap_or(1), }); let connected = match asked_window { - Some(mode) => Platform::connect_x11(mode, factory), + Some(mode) => Platform::connect_x11(mode, &present, factory), None => Platform::connect_with(kirie_platform::Backend::from_env(), present, factory), }; let exit = match connected { From a0468769aafc07599825ff6ec6277989d52e60d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:50:20 +0000 Subject: [PATCH 09/33] Don't rewrite an unchanged pipeline cache It was rewritten from the render thread on every wallpaper swap. The temp file now carries the pid so two kirie processes can't interleave. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LDKTCgMEZkLw7hFLpgLXNJ --- crates/kirie-platform/src/gpu.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/kirie-platform/src/gpu.rs b/crates/kirie-platform/src/gpu.rs index a44b118..acf4a1c 100644 --- a/crates/kirie-platform/src/gpu.rs +++ b/crates/kirie-platform/src/gpu.rs @@ -217,19 +217,32 @@ pub fn attach_pipeline_cache(device: &wgpu::Device, adapter: &wgpu::Adapter) { } } +/// Size of the blob last written, so a wallpaper swap that compiled nothing +/// new does not rewrite megabytes from the render thread. +static PERSISTED_LEN: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + pub fn persist_pipeline_cache(adapter: &wgpu::Adapter) { + use std::sync::atomic::Ordering; + let Some(cache) = SHARED_PIPELINE_CACHE.get() else { return; }; let Some(data) = cache.get_data() else { return }; + if data.len() == PERSISTED_LEN.load(Ordering::Relaxed) { + return; + } let Some(path) = pipeline_cache_file(adapter) else { return; }; let Some(dir) = path.parent() else { return }; let _ = std::fs::create_dir_all(dir); - let tmp = path.with_extension("tmp"); - if std::fs::write(&tmp, &data).is_ok() { - let _ = std::fs::rename(&tmp, &path); + // Several kirie processes can share this file; each writes its own temp + // file so two saving at once cannot interleave into one. + let tmp = path.with_extension(format!("{}.tmp", std::process::id())); + if std::fs::write(&tmp, &data).is_ok() && std::fs::rename(&tmp, &path).is_ok() { + PERSISTED_LEN.store(data.len(), Ordering::Relaxed); + } else { + let _ = std::fs::remove_file(&tmp); } } From c28d7e032a2994ea38be33825f86b03f0cb21b38 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:50:20 +0000 Subject: [PATCH 10/33] Remove the unused macOS desktop-surface API and call pmset by path Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LDKTCgMEZkLw7hFLpgLXNJ --- crates/kirie-platform/src/lib.rs | 2 +- crates/kirie-platform/src/macos.rs | 57 +----------------------------- 2 files changed, 2 insertions(+), 57 deletions(-) diff --git a/crates/kirie-platform/src/lib.rs b/crates/kirie-platform/src/lib.rs index e5d440e..b37acb8 100644 --- a/crates/kirie-platform/src/lib.rs +++ b/crates/kirie-platform/src/lib.rs @@ -11,7 +11,7 @@ mod gpu; #[cfg(target_os = "macos")] mod macos; #[cfg(target_os = "macos")] -pub use macos::{DesktopSurface, open_desktop, pump_desktop_events, set_battery_fps}; +pub use macos::set_battery_fps; #[cfg(target_os = "linux")] mod output; #[cfg(target_os = "linux")] diff --git a/crates/kirie-platform/src/macos.rs b/crates/kirie-platform/src/macos.rs index 4c7aeac..1d914e1 100644 --- a/crates/kirie-platform/src/macos.rs +++ b/crates/kirie-platform/src/macos.rs @@ -563,61 +563,6 @@ impl MacPlatform { } } -pub struct DesktopSurface { - window: Retained, - name: String, - size: SurfaceSize, -} - -impl DesktopSurface { - #[must_use] - pub fn name(&self) -> &str { - &self.name - } - - #[must_use] - pub const fn size(&self) -> SurfaceSize { - self.size - } - - pub fn show(&self, view: &objc2_app_kit::NSView) { - view.setFrame(self.window.contentLayoutRect()); - self.window.setContentView(Some(view)); - self.window.orderFrontRegardless(); - } -} - -pub fn open_desktop(screen_roots: &[String]) -> Result, PlatformError> { - let mtm = MainThreadMarker::new().ok_or(PlatformError::NotMainThread)?; - let app = NSApplication::sharedApplication(mtm); - app.setActivationPolicy(NSApplicationActivationPolicy::Accessory); - finish_launching(&app); - - let screens = chosen_screens(mtm, screen_roots); - if screens.is_empty() { - return Err(PlatformError::NoCrtcs); - } - - Ok(screens - .into_iter() - .map(|(screen, name)| DesktopSurface { - size: pixel_size(&screen), - window: desktop_window(mtm, &screen), - name, - }) - .collect()) -} - -pub fn pump_desktop_events() { - let Some(mtm) = MainThreadMarker::new() else { - return; - }; - let app = NSApplication::sharedApplication(mtm); - while let Some(event) = next_event(&app) { - app.sendEvent(&event); - } -} - fn chosen_screens(mtm: MainThreadMarker, roots: &[String]) -> Vec<(Retained, String)> { let all: Vec<(Retained, String)> = NSScreen::screens(mtm) .iter() @@ -661,7 +606,7 @@ fn battery_fps() -> Option { } fn on_battery() -> bool { - let asked = std::process::Command::new("pmset") + let asked = std::process::Command::new("/usr/bin/pmset") .args(["-g", "ps"]) .stdin(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) From 2cdb83ec90eda04afb45f293f327a5b5e90a1752 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:50:20 +0000 Subject: [PATCH 11/33] Deduplicate swap and property plumbing in the IPC app Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LDKTCgMEZkLw7hFLpgLXNJ --- crates/kirie/src/compat/ipc_app.rs | 82 +++++++++--------------------- 1 file changed, 23 insertions(+), 59 deletions(-) diff --git a/crates/kirie/src/compat/ipc_app.rs b/crates/kirie/src/compat/ipc_app.rs index 87319d4..7fa2eb1 100644 --- a/crates/kirie/src/compat/ipc_app.rs +++ b/crates/kirie/src/compat/ipc_app.rs @@ -261,27 +261,13 @@ fn apply_command(state: &mut AppState, command: Command) -> CommandOutcome { state.properties = super::saved_props::with_saved(&path, &staged) .into_iter() .collect(); + let props = property_list(state); if !staged.is_empty() { - let all: Vec<(String, String)> = state - .properties - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - super::saved_props::write(&path, &all); + super::saved_props::write(&path, &props); } - let sc = state - .swap - .lock() - .ok() - .and_then(|g| g.as_ref().map(|s| (s.cmd_tx.clone(), s.build.clone()))); - let Some((cmd_tx, build_ctx)) = sc else { + let Some((cmd_tx, build_ctx)) = swap_parts(state) else { return CommandOutcome::Refused("the renderer is not ready yet".to_owned()); }; - let props: Vec<(String, String)> = state - .properties - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); #[cfg(any(feature = "web-cef", feature = "web-webview"))] let props_web = props.clone(); if let Some(build) = build_ctx.build_fn(screen.clone(), &path, props) { @@ -305,24 +291,14 @@ fn apply_command(state: &mut AppState, command: Command) -> CommandOutcome { CommandOutcome::Refused(why_not(&path)) } Command::Preload { path } => { - if let Some((cmd_tx, build_ctx)) = state - .swap - .lock() - .ok() - .and_then(|g| g.as_ref().map(|s| (s.cmd_tx.clone(), s.build.clone()))) + if let Some((cmd_tx, build_ctx)) = swap_parts(state) + && let Some(build) = build_ctx.build_fn("*".to_owned(), &path, property_list(state)) { - let props: Vec<(String, String)> = state - .properties - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - if let Some(build) = build_ctx.build_fn("*".to_owned(), &path, props) { - let _ = cmd_tx.send(RenderCommand::Build { - screen: "*".to_owned(), - stash: Some(path.to_string_lossy().into_owned()), - build, - }); - } + let _ = cmd_tx.send(RenderCommand::Build { + screen: "*".to_owned(), + stash: Some(path.to_string_lossy().into_owned()), + build, + }); } CommandOutcome::Ok } @@ -335,12 +311,7 @@ fn apply_command(state: &mut AppState, command: Command) -> CommandOutcome { if let Some(showing) = state.screens.get(&screen).and_then(|e| e.bg.clone()) { super::saved_props::remember(&showing, &key, &value); } - let sc = state - .swap - .lock() - .ok() - .and_then(|g| g.as_ref().map(|s| (s.cmd_tx.clone(), s.build.clone()))); - if let Some((cmd_tx, build_ctx)) = sc { + if let Some((cmd_tx, build_ctx)) = swap_parts(state) { let structural = Arc::new(std::sync::atomic::AtomicBool::new(true)); let _ = cmd_tx.send(RenderCommand::SetProperty { screen: screen.clone(), @@ -348,15 +319,9 @@ fn apply_command(state: &mut AppState, command: Command) -> CommandOutcome { value, structural: structural.clone(), }); - if !screen.is_empty() - && let Some(path) = state.screens.get(&screen).and_then(|e| e.bg.clone()) - { + if let Some(path) = state.screens.get(&screen).and_then(|e| e.bg.clone()) { use std::sync::atomic::Ordering; - let props: Vec<(String, String)> = state - .properties - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); + let props = property_list(state); let generation = state.prop_gen.fetch_add(1, Ordering::SeqCst) + 1; let gen_slot = state.prop_gen.clone(); let screen_c = screen.clone(); @@ -425,12 +390,7 @@ fn apply_command(state: &mut AppState, command: Command) -> CommandOutcome { return CommandOutcome::Error; } } - let cmd_tx = state - .swap - .lock() - .ok() - .and_then(|g| g.as_ref().map(|s| s.cmd_tx.clone())); - let Some(cmd_tx) = cmd_tx else { + let Some(cmd_tx) = cmd_sender(state) else { return CommandOutcome::Error; }; let capture: CaptureFn = Box::new(move |device, queue, renderer, size, format| { @@ -472,6 +432,14 @@ fn swap_parts(state: &AppState) -> Option<(kirie_platform::CommandSender, Arc Vec<(String, String)> { + state + .properties + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect() +} + fn cmd_sender(state: &AppState) -> Option { swap_parts(state).map(|(tx, _)| tx) } @@ -481,11 +449,7 @@ fn rebuild_current(state: &mut AppState) { return; }; state.prop_gen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - let props: Vec<(String, String)> = state - .properties - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); + let props = property_list(state); let screens: Vec<(String, std::path::PathBuf)> = state .screens .iter() From 79b4d4aec464970ebd6cc48d1288d80158782427 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:50:20 +0000 Subject: [PATCH 12/33] Pass release URLs to curl with --url and ignore an empty HOME Also drops path_of, which only forwarded to path_from_bytes. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LDKTCgMEZkLw7hFLpgLXNJ --- crates/kirie-ipc/src/command.rs | 8 ++------ crates/kirie-launcher/src/main.rs | 6 +++++- crates/kirie/src/update.rs | 2 ++ 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/kirie-ipc/src/command.rs b/crates/kirie-ipc/src/command.rs index 201ec57..42e9df5 100644 --- a/crates/kirie-ipc/src/command.rs +++ b/crates/kirie-ipc/src/command.rs @@ -310,18 +310,14 @@ fn parse_bg(cur: &mut Cursor<'_>) -> (String, PathBuf) { return (EVERY_SCREEN.to_owned(), PathBuf::new()); } if looks_like_a_path(whole) { - return (EVERY_SCREEN.to_owned(), path_of(cur.rest())); + return (EVERY_SCREEN.to_owned(), path_from_bytes(cur.rest())); } let screen = token_string(cur); let path = cur.rest(); if path.is_empty() { return (EVERY_SCREEN.to_owned(), PathBuf::from(screen)); } - (screen, path_of(path)) -} - -fn path_of(bytes: &[u8]) -> PathBuf { - path_from_bytes(bytes) + (screen, path_from_bytes(path)) } fn parse_workshop(cur: &mut Cursor<'_>) -> Request { diff --git a/crates/kirie-launcher/src/main.rs b/crates/kirie-launcher/src/main.rs index bfba360..f208c53 100644 --- a/crates/kirie-launcher/src/main.rs +++ b/crates/kirie-launcher/src/main.rs @@ -166,7 +166,11 @@ fn cache_root() -> io::Result { let base = std::env::var_os("XDG_CACHE_HOME") .map(PathBuf::from) .filter(|p| p.is_absolute()) - .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache"))) + .or_else(|| { + std::env::var_os("HOME") + .filter(|h| !h.is_empty()) + .map(|h| PathBuf::from(h).join(".cache")) + }) .ok_or_else(|| io::Error::other("neither XDG_CACHE_HOME nor HOME is set"))?; Ok(base.join("kirie").join("rt")) } diff --git a/crates/kirie/src/update.rs b/crates/kirie/src/update.rs index 9463714..885c13a 100644 --- a/crates/kirie/src/update.rs +++ b/crates/kirie/src/update.rs @@ -91,6 +91,7 @@ fn replace(path: &std::path::Path, url: &str) -> Result<()> { "--output", ]) .arg(&staged) + .arg("--url") .arg(url) .status() .context("could not run curl")?; @@ -146,6 +147,7 @@ fn fetch_text(url: &str) -> Result { "30", "--header", "Accept: application/vnd.github+json", + "--url", url, ]) .output() From ad175482ff94a1666272ff6fc67ddf15d0341e4c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:52:53 +0000 Subject: [PATCH 13/33] Sample the scene directly when a layer reads it offscreen, and drop the bloom copy A layer reading _rt_FullFrameBuffer copied the whole Rgba16Float scene (~16 MiB each way at 1080p) even when the pass reading it drew into an offscreen buffer. It now copies only when a pass drawing into the scene samples it. Bloom's combine writes into the spare buffer and the blit reads that, so it no longer copies at all. On the frame_cost example the digest is unchanged and copies drop from 3-5 a frame to 0-2. Script-created layers cache their bind groups instead of building them every frame. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LDKTCgMEZkLw7hFLpgLXNJ --- crates/kirie-render/src/scene/bloom.rs | 20 +-- crates/kirie-render/src/scene/renderer.rs | 181 +++++++++++++--------- 2 files changed, 114 insertions(+), 87 deletions(-) diff --git a/crates/kirie-render/src/scene/bloom.rs b/crates/kirie-render/src/scene/bloom.rs index 8e76533..79723e3 100644 --- a/crates/kirie-render/src/scene/bloom.rs +++ b/crates/kirie-render/src/scene/bloom.rs @@ -40,7 +40,6 @@ impl Bloom { proj_w: u32, proj_h: u32, scene_view: &wgpu::TextureView, - snapshot_view: &wgpu::TextureView, strength: f32, threshold: f32, ) -> Self { @@ -158,7 +157,7 @@ impl Bloom { layout: &two_tex_layout, entries: &[ bind_sampler(0, &sampler), - bind_texture(1, snapshot_view), + bind_texture(1, scene_view), bind_texture(2, &bloom.view), ], }); @@ -193,7 +192,10 @@ impl Bloom { ); } - pub(crate) fn run(&self, encoder: &mut wgpu::CommandEncoder, scene_fbo: &Fbo, scene_snapshot: &Fbo) { + /// Writes the scene with its glow added into `output`, which must not be + /// the scene buffer: the combine reads the scene, and writing the result + /// to a second buffer saves copying the scene aside first. + pub(crate) fn run(&self, encoder: &mut wgpu::CommandEncoder, output: &wgpu::TextureView) { self.pass( encoder, "bright", @@ -215,22 +217,12 @@ impl Bloom { &self.blur_y_bind, &self.bloom.view, ); - crate::frame_cost::texture_copy(u64::from(scene_fbo.width) * u64::from(scene_fbo.height) * 8); - encoder.copy_texture_to_texture( - scene_fbo.texture.as_image_copy(), - scene_snapshot.texture.as_image_copy(), - wgpu::Extent3d { - width: scene_fbo.width, - height: scene_fbo.height, - depth_or_array_layers: 1, - }, - ); self.pass( encoder, "combine", &self.combine_pipeline, &self.combine_bind, - &scene_fbo.view, + output, ); } diff --git a/crates/kirie-render/src/scene/renderer.rs b/crates/kirie-render/src/scene/renderer.rs index e98cbc8..68d7b34 100644 --- a/crates/kirie-render/src/scene/renderer.rs +++ b/crates/kirie-render/src/scene/renderer.rs @@ -4,7 +4,6 @@ use std::sync::Arc; use kirie_audio::{AudioCapture, AudioSpectrum}; use kirie_platform::{RenderTarget, Renderer, SurfaceSize}; use kirie_scene::SceneModel; -use kirie_scene::material::Blending; use kirie_scene::object::{ImageObject, Object, ObjectKind}; use kirie_scene::resolve::AssetSource; use kirie_scene::scene::Projection; @@ -150,7 +149,6 @@ struct PassGpu { output: PassOutput, geometry: Geometry, model_matrix: Mat4, - blending: Blending, tex_resolution: [[f32; 4]; 8], /// The bytes last uploaded to `vs_ubo` / `fs_ubo`. A layer that is not /// driven by time, audio or the pointer packs the same uniforms every @@ -173,7 +171,13 @@ struct ObjectGpu { color: [f32; 4], visible: bool, skip_final: bool, + /// Draws at its place in the scene's order because a pass samples the + /// scene as it stands there. reads_scene: bool, + /// A pass that samples the scene also draws into it, or runs after the + /// pass that does, so it reads a snapshot taken before the layer draws. + /// Otherwise the passes sample the scene buffer itself and no copy is made. + needs_snapshot: bool, offscreen_donor: bool, parallax_depth: [f32; 2], scene_center: [f32; 2], @@ -288,6 +292,9 @@ pub struct SceneRenderer { runtime_white: std::sync::Arc, runtime_seq: i64, runtime_pipeline: Option<(wgpu::RenderPipeline, wgpu::Buffer, usize)>, + /// The bind group for each texture the runtime layers drew with last + /// frame, so a frame that draws the same layers creates none. + runtime_binds: Vec<(Arc, wgpu::BindGroup)>, parallax_disp: [f32; 2], text_pipeline: Option, text_fonts: Option, @@ -494,7 +501,6 @@ impl SceneRenderer { fbo_w, fbo_h, &scene_fbo.view, - &scene_snapshot.view, env_f("KIRIE_BLOOM_STRENGTH").unwrap_or(scene.general.bloomstrength.value), env_f("KIRIE_BLOOM_THRESHOLD").unwrap_or(scene.general.bloomthreshold.value), ) @@ -538,7 +544,7 @@ impl SceneRenderer { object, image, (proj_w, proj_h), - &screen_mvp, + &scene_fbo.view, source, &resolver, ®istry, @@ -592,7 +598,7 @@ impl SceneRenderer { object, image, (proj_w, proj_h), - &screen_mvp, + &scene_fbo.view, source, &resolver, ®istry, @@ -647,7 +653,7 @@ impl SceneRenderer { object, tobj, (proj_w, proj_h), - &screen_mvp, + &scene_fbo.view, source, &resolver, ®istry, @@ -707,7 +713,7 @@ impl SceneRenderer { let scene_snapshot = (bloom.is_some() || items.iter().any(|it| match it { - SceneItem::Image(o) => o.reads_scene, + SceneItem::Image(o) => o.needs_snapshot, SceneItem::Model(m) => m.reads_scene, _ => false, })) @@ -734,8 +740,13 @@ impl SceneRenderer { } } - let (blit_pipeline, blit_bind, blit_window) = - build_blit(device, target.format, &scene_fbo, &fbo_sampler); + // With bloom on, the finished frame is the combine's output in the + // snapshot buffer rather than the scene buffer. + let shown = match (&bloom, &scene_snapshot) { + (Some(_), Some(snap)) => &snap.view, + _ => &scene_fbo.view, + }; + let (blit_pipeline, blit_bind, blit_window) = build_blit(device, target.format, shown, &fbo_sampler); let script = ScriptHost::build(model, (proj_w, proj_h), user_props); let animator = PropertyAnimator::build(model); @@ -836,6 +847,7 @@ impl SceneRenderer { runtime_white, runtime_seq: 0, runtime_pipeline: None, + runtime_binds: Vec::new(), text_pipeline, text_fonts, scene_fbo, @@ -1246,7 +1258,7 @@ impl SceneRenderer { self.scene_snapshot.as_ref(), &self.locals, (self.proj_w, self.proj_h), - &self.screen_mvp, + &self.scene_fbo.view, o, ); } @@ -1480,7 +1492,7 @@ fn build_object( object: &Object, image: &ImageObject, scene_size: (u32, u32), - screen_mvp: &Mat4, + scene_view: &wgpu::TextureView, source: &dyn AssetSource, resolver: &dyn IncludeResolver, registry: &TextureRegistry, @@ -1587,7 +1599,7 @@ fn build_object( .as_ref() .and_then(|m| m.passes.first()) .is_some_and(|p| is_compose_layer(&p.shader)); - let mut reads_scene = layer_reads_scene && !empty_canvas; + let reads_scene_as_layer = layer_reads_scene && !empty_canvas; let layer_atlas = base_layer_name(image) .filter(|n| !n.starts_with("_rt_") && !n.starts_with("_alias_")) .and_then(|n| registry.atlas_for(&n)); @@ -1751,6 +1763,48 @@ fn build_object( } } + for (i, sv) in built.iter_mut().enumerate() { + if i == 0 && empty_canvas { + if sv.raw.textures.is_empty() { + sv.raw.textures.push(None); + } + sv.raw.textures[0] = Some(EMPTY_CANVAS.to_owned()); + } + for (slot, name) in &sv.binds { + let idx = *slot as usize; + if idx >= sv.raw.textures.len() { + sv.raw.textures.resize(idx + 1, None); + } + if sv.raw.textures[idx].is_none() { + sv.raw.textures[idx] = Some(name.clone()); + } + } + } + let samples_scene: Vec = built + .iter() + .enumerate() + .map(|(i, sv)| { + (i == 0 && reads_scene_as_layer) + || sv.raw.textures.iter().flatten().any(|n| is_scene_rt(n)) + || samples_scene_by_default(&sv.raw, &sv.built.vs_samplers) + || samples_scene_by_default(&sv.raw, &sv.built.fs_samplers) + }) + .collect(); + let reads_scene = samples_scene.iter().any(|&s| s); + // A pass can sample the scene buffer itself only while it draws somewhere + // else and the layer has not drawn into the scene yet; the scene pass is + // `last_comp`, and everything after it runs once the scene has changed. + let scene_pass = (!offscreen_donor).then_some(last_comp); + let needs_snapshot = samples_scene + .iter() + .enumerate() + .any(|(i, &s)| s && scene_pass.is_some_and(|k| i >= k)); + let scene_read = if needs_snapshot { + &scene_snapshot.view + } else { + scene_view + }; + let mut passes = Vec::with_capacity(n); let mut comp_front: Option = None; for (i, sv) in built.into_iter().enumerate() { @@ -1760,7 +1814,6 @@ fn build_object( params_vs, params_fs, target, - binds, is_puppet_base, effect_index, .. @@ -1768,23 +1821,6 @@ fn build_object( let is_scene = i == last_comp && !offscreen_donor; let composite = is_composite(&target); - let mut raw_pass = raw_pass; - if i == 0 && empty_canvas { - if raw_pass.textures.is_empty() { - raw_pass.textures.push(None); - } - raw_pass.textures[0] = Some(EMPTY_CANVAS.to_owned()); - } - for (slot, name) in &binds { - let idx = *slot as usize; - if idx >= raw_pass.textures.len() { - raw_pass.textures.resize(idx + 1, None); - } - if raw_pass.textures[idx].is_none() { - raw_pass.textures[idx] = Some(name.clone()); - } - } - let geometry = if is_puppet_base { if is_scene { Geometry::Puppet @@ -1857,7 +1893,7 @@ fn build_object( if empty_canvas { (&transparent.view, &transparent.sampler) } else if layer_reads_scene { - (&scene_snapshot.view, fbo_sampler) + (scene_read, fbo_sampler) } else { (&layer_tex.view, &layer_tex.sampler) } @@ -1885,13 +1921,6 @@ fn build_object( named.insert(name.as_str(), (&fbo.view, fbo_sampler)); } - if raw_pass.textures.iter().flatten().any(|n| is_scene_rt(n)) - || samples_scene_by_default(&raw_pass, &built_pass.vs_samplers) - || samples_scene_by_default(&raw_pass, &built_pass.fs_samplers) - { - reads_scene = true; - } - let vs_ubo = (!built_pass.vs_globals.is_empty()).then(|| create_ubo(device, built_pass.vs_globals.size)); let fs_ubo = @@ -1908,7 +1937,7 @@ fn build_object( registry, source, &raw_pass, - (&scene_snapshot.view, fbo_sampler), + (scene_read, fbo_sampler), &named, true, ); @@ -1923,7 +1952,7 @@ fn build_object( registry, source, &raw_pass, - (&scene_snapshot.view, fbo_sampler), + (scene_read, fbo_sampler), &named, true, ); @@ -1985,7 +2014,6 @@ fn build_object( uv_crop, effect_index, model_matrix, - blending: effective_blending(is_puppet_base, raw_pass.blending), tex_resolution, params_vs, params_fs, @@ -1994,12 +2022,6 @@ fn build_object( material_pass: raw_pass, }); } - let _ = screen_mvp; - tracing::trace!(target: "kirie_render::ptrdbg", - id = object.base.id, - n_passes = passes.len(), - geoms = ?passes.iter().map(|p| format!("{:?}", p.geometry)).collect::>(), - "object built"); Some(ObjectGpu { id: object.base.id, parent: object.base.parent, @@ -2013,6 +2035,7 @@ fn build_object( skip_final: NO_SOLID_FINAL.load(std::sync::atomic::Ordering::Relaxed) && image.model.as_ref().is_some_and(|m| m.solidlayer), reads_scene, + needs_snapshot, offscreen_donor, final_front: comp_front, parallax_depth: image.parallax_depth.value, @@ -2073,7 +2096,7 @@ fn build_text_layer( object: &Object, tobj: &kirie_scene::object::TextObject, scene_size: (u32, u32), - screen_mvp: &Mat4, + scene_view: &wgpu::TextureView, source: &dyn AssetSource, resolver: &dyn IncludeResolver, registry: &TextureRegistry, @@ -2125,7 +2148,7 @@ fn build_text_layer( object, &image, scene_size, - screen_mvp, + scene_view, source, resolver, registry, @@ -2163,7 +2186,7 @@ fn refresh_text_layer( scene_snapshot: Option<&Fbo>, locals: &HashMap, scene_size: (u32, u32), - screen_mvp: &Mat4, + scene_view: &wgpu::TextureView, o: &mut ObjectGpu, ) { let Some(mut text) = o.text.take() else { @@ -2191,7 +2214,7 @@ fn refresh_text_layer( &text.object, &text.image, scene_size, - screen_mvp, + scene_view, &ctx.source, &SourceIncludes(&ctx.source), &ctx.registry, @@ -2547,7 +2570,7 @@ impl Renderer for SceneRenderer { self.scene_snapshot.as_ref(), &self.locals, (self.proj_w, self.proj_h), - &self.screen_mvp, + &self.scene_fbo.view, o, ); } @@ -2772,10 +2795,15 @@ impl Renderer for SceneRenderer { && (!object.visible || !ancestors_visible(parent_by_id, visible_by_id, object.parent)) => {} SceneItem::Image(object) => { - if object.reads_scene - && let Some(snap_tex) = snap_tex - { - encoder.refresh_snapshot(scene_view, scene_tex, snap_tex, copy_extent); + if object.reads_scene { + match snap_tex { + Some(snap_tex) if object.needs_snapshot => { + encoder.refresh_snapshot(scene_view, scene_tex, snap_tex, copy_extent); + } + // The passes sample the scene buffer itself, which + // still holds the previous frame until it is cleared. + _ => encoder.ensure_cleared(scene_view), + } } draw_image_object( &mut encoder, @@ -2952,13 +2980,18 @@ impl Renderer for SceneRenderer { } if let Some((pipeline, buf, _)) = &self.runtime_pipeline { self.queue.write_buffer(buf, 0, bytemuck::cast_slice(&verts)); - let layout = pipeline.get_bind_group_layout(0); - let binds: Vec = batches - .iter() - .map(|(t, _, _)| { + let mut binds: Vec<(Arc, wgpu::BindGroup)> = + Vec::with_capacity(batches.len()); + for (t, _, _) in &batches { + let cached = binds + .iter() + .chain(&self.runtime_binds) + .find(|(c, _)| Arc::ptr_eq(c, t)) + .map(|(_, b)| b.clone()); + let bind = cached.unwrap_or_else(|| { self.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("kirie-runtime-layer-tex"), - layout: &layout, + layout: &pipeline.get_bind_group_layout(0), entries: &[ wgpu::BindGroupEntry { binding: 0, @@ -2970,16 +3003,18 @@ impl Renderer for SceneRenderer { }, ], }) - }) - .collect(); + }); + binds.push((t.clone(), bind)); + } let rp = encoder.scene(scene_view); rp.set_pipeline(pipeline); rp.set_vertex_buffer(0, buf.slice(..)); - for ((_, first, count), bind) in batches.iter().zip(&binds) { + for ((_, first, count), (_, bind)) in batches.iter().zip(&binds) { rp.set_bind_group(0, bind, &[]); crate::frame_cost::draw(1); rp.draw(*first..*first + *count, 0..1); } + self.runtime_binds = binds; } } } @@ -2989,7 +3024,7 @@ impl Renderer for SceneRenderer { encoder.ensure_cleared(scene_view); if let (Some(bloom), Some(snap)) = (&self.bloom, &self.scene_snapshot) { - bloom.run(encoder.raw(), &self.scene_fbo, snap); + bloom.run(encoder.raw(), &snap.view); } { @@ -3174,7 +3209,6 @@ fn collect_runtime_templates( } _ => {} } - let _ = &object.base; } let lookup = |name: &str| -> Option<&kirie_scene::PropertyValue> { @@ -3703,13 +3737,15 @@ fn draw_image_object( }; let rp = match offscreen { None => encoder.scene(scene_view), - // An output buffer that failed to build fell back to the scene - // view with a clear, and still does. - Some(view) => encoder.offscreen( - view.unwrap_or(scene_view), + Some(Some(view)) => encoder.offscreen( + view, wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), "kirie-scene-offscreen", ), + // Every offscreen output is allocated when the layer is built. + // Drawing into the scene instead would be wrong, and invalid for + // a pass that samples the scene buffer. + Some(None) => continue, }; rp.set_pipeline(&pass.pipeline); rp.set_bind_group(0, &pass.g0_bind, &[]); @@ -3722,7 +3758,6 @@ fn draw_image_object( } else { rp.draw(0..4, 0..1); } - let _ = pass.blending; } } @@ -4326,7 +4361,7 @@ pub(super) fn build_bind_group( fn build_blit( device: &wgpu::Device, surface_format: wgpu::TextureFormat, - scene_fbo: &Fbo, + shown: &wgpu::TextureView, sampler: &wgpu::Sampler, ) -> (wgpu::RenderPipeline, wgpu::BindGroup, wgpu::Buffer) { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { @@ -4380,7 +4415,7 @@ fn build_blit( }, wgpu::BindGroupEntry { binding: 1, - resource: wgpu::BindingResource::TextureView(&scene_fbo.view), + resource: wgpu::BindingResource::TextureView(shown), }, wgpu::BindGroupEntry { binding: 2, From 2c1fbfaf9bdbcc9e709cd081db0d9bd31200dc58 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:52:53 +0000 Subject: [PATCH 14/33] Harden texture and asset loading against hostile wallpaper files Composite asset names could use .. or an absolute path to read any file. A .tex frame rectangle near i64::MAX overflowed past the bounds check, and a texture larger than the device limit panicked in wgpu validation. Video textures were written to a predictable /tmp name another user could plant a symlink at. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LDKTCgMEZkLw7hFLpgLXNJ --- crates/kirie-render/src/scene/load.rs | 35 ++++++++++- crates/kirie-render/src/scene/texture.rs | 76 ++++++++++++++++++++---- 2 files changed, 99 insertions(+), 12 deletions(-) diff --git a/crates/kirie-render/src/scene/load.rs b/crates/kirie-render/src/scene/load.rs index cc5c2a1..6220a51 100644 --- a/crates/kirie-render/src/scene/load.rs +++ b/crates/kirie-render/src/scene/load.rs @@ -71,10 +71,25 @@ impl AssetSource for CompositeSource<'_> { if let Ok(bytes) = self.pkg.read_name(path.as_bytes()) { return Some(bytes.to_vec()); } - std::fs::read(self.assets?.join(path)).ok() + let Some(relative) = inside_assets(path) else { + tracing::debug!(%path, "asset path leaves the assets directory; refused"); + return None; + }; + std::fs::read(self.assets?.join(relative)).ok() } } +/// Asset names come from the wallpaper's own files; one that climbs out with +/// `..` or is absolute (which `join` would take as-is) must not reach outside +/// the shared assets directory. +fn inside_assets(path: &str) -> Option<&Path> { + let relative = Path::new(path); + relative + .components() + .all(|c| matches!(c, std::path::Component::Normal(_) | std::path::Component::CurDir)) + .then_some(relative) +} + #[must_use] pub fn scene_entry(pkg: &OwnedPkg, scene_dir: &Path) -> Vec { let named = std::fs::read(scene_dir.join("project.json")) @@ -373,3 +388,21 @@ impl Renderer for ClearColorRenderer { self.queue.submit(Some(encoder.finish())); } } + +#[cfg(test)] +mod asset_path_tests { + use super::inside_assets; + + #[test] + fn plain_asset_names_stay_inside() { + assert!(inside_assets("materials/foo.tex").is_some()); + assert!(inside_assets("shaders/common.h").is_some()); + } + + #[test] + fn climbing_or_absolute_names_are_refused() { + assert!(inside_assets("../../etc/passwd").is_none()); + assert!(inside_assets("materials/../../secret").is_none()); + assert!(inside_assets("/etc/passwd").is_none()); + } +} diff --git a/crates/kirie-render/src/scene/texture.rs b/crates/kirie-render/src/scene/texture.rs index 99a8cc3..91fc4cf 100644 --- a/crates/kirie-render/src/scene/texture.rs +++ b/crates/kirie-render/src/scene/texture.rs @@ -450,6 +450,9 @@ impl TextureRegistry { return None; } let page = content.pages.first()?; + if !fits_device(&self.device, name, page.width, page.height) { + return None; + } let fr = content.frames.first()?; if fr.axes[1].abs() > 1e-4 || fr.axes[2].abs() > 1e-4 { return None; @@ -459,7 +462,9 @@ impl TextureRegistry { let fy = (fr.translation[1] * th as f32).round() as i64; let fw = ((fr.axes[0] * tw as f32).round() as i64).max(1); let fh = ((fr.axes[3] * th as f32).round() as i64).max(1); - if fx < 0 || fy < 0 || fx + fw > tw || fy + fh > th { + // The frame rectangle comes from the file; a huge width saturates to + // i64::MAX, so the bound is checked without adding to it. + if fx < 0 || fy < 0 || fw > tw - fx || fh > th - fy { return None; } let (fx, fy, fw, fh) = (fx as usize, fy as usize, fw as usize, fh as usize); @@ -467,7 +472,7 @@ impl TextureRegistry { let mut cropped = Vec::with_capacity(fw * fh * 4); for row in 0..fh { let start = (fy + row) * stride + fx * 4; - cropped.extend_from_slice(&page.pixels[start..start + fw * 4]); + cropped.extend_from_slice(page.pixels.get(start..start + fw * 4)?); } let gpu = upload_rgba8( &self.device, @@ -495,6 +500,9 @@ impl TextureRegistry { }; content.pad_pages_to_max(); let page = content.pages.first()?; + if !fits_device(&self.device, name, page.width, page.height) { + return None; + } let uv_crop = match content.frames.as_slice() { [only] => [only.axes[0], only.axes[3]], _ => [1.0, 1.0], @@ -552,12 +560,7 @@ impl TextureRegistry { let tex = kirie_formats::tex::Tex::parse(tex_bytes).ok()?; let payload = tex.video_payload().ok()?; - let mut key: u64 = 0xcbf2_9ce4_8422_2325; - for b in &*payload { - key = (key ^ u64::from(*b)).wrapping_mul(0x0000_0100_0000_01b3); - } - let file = std::env::temp_dir().join(format!("kirie-vtex-{key:016x}.mp4")); - std::fs::write(&file, &payload).ok()?; + let file = write_private_temp(&payload)?; let opened = kirie_video::VideoPlayer::open( &file, @@ -580,13 +583,16 @@ impl TextureRegistry { }; let _ = std::fs::remove_file(&file); let frame = frame?; - if frame.width == 0 || frame.height == 0 { + if frame.width == 0 + || frame.height == 0 + || !fits_device(&self.device, name, frame.width, frame.height) + { return None; } let is_nv12 = frame.pixels == kirie_video::FramePixels::Nv12; let rgba_seed: std::borrow::Cow<[u8]> = if is_nv12 { - std::borrow::Cow::Owned(vec![0u8; (frame.width * frame.height * 4) as usize]) + std::borrow::Cow::Owned(vec![0u8; frame.width as usize * frame.height as usize * 4]) } else { std::borrow::Cow::Borrowed(&frame.data) }; @@ -666,6 +672,54 @@ fn atlas_animates(content: &ImageContent) -> Option { Some(content.frames.iter().any(|f| f.page != 0)) } +/// Writes a video payload where the decoder can open it by path. The name is +/// unique to this process and the file is created fresh (`create_new` refuses +/// an existing file or a planted symlink in a shared temp directory), readable +/// only by this user. +fn write_private_temp(payload: &[u8]) -> Option { + use std::io::Write; + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("kirie-vtex-{}-{seq}.mp4", std::process::id())); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = match options.open(&path) { + Ok(f) => f, + Err(e) => { + tracing::debug!(path = %path.display(), error = %e, "video texture temp file not created"); + return None; + } + }; + if file.write_all(payload).is_err() { + drop(file); + let _ = std::fs::remove_file(&path); + return None; + } + Some(path) +} + +/// A texture larger than the GPU allows would fail validation, which wgpu +/// treats as fatal; the file's own size is untrusted, so it is checked first. +fn fits_device(device: &wgpu::Device, name: &str, width: u32, height: u32) -> bool { + let max = device.limits().max_texture_dimension_2d; + if width <= max && height <= max { + return true; + } + tracing::warn!( + texture = name, + width, + height, + max, + "texture larger than the gpu allows; drawing white" + ); + false +} + #[allow(clippy::too_many_arguments)] fn upload_rgba8( device: &wgpu::Device, @@ -693,7 +747,7 @@ fn upload_rgba8( usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, view_formats: &[], }); - let need = (width * height * 4) as usize; + let need = width as usize * height as usize * 4; if pixels.len() >= need { queue.write_texture( wgpu::TexelCopyTextureInfo { From 14a755b95dac891291b0dd55e38937e9f4094dbb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:52:53 +0000 Subject: [PATCH 15/33] Keep frame-cost counters for callers that reset them, and vary the benchmark picture end_frame cleared the counters every 5 s even when the example had started them, and the example drew a solid white frame whose digest proved nothing. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LDKTCgMEZkLw7hFLpgLXNJ --- crates/kirie-render/examples/frame_cost.rs | 19 ++++++++++++------- crates/kirie-render/src/frame_cost.rs | 14 +++++++++++--- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/crates/kirie-render/examples/frame_cost.rs b/crates/kirie-render/examples/frame_cost.rs index b813775..84ad064 100644 --- a/crates/kirie-render/examples/frame_cost.rs +++ b/crates/kirie-render/examples/frame_cost.rs @@ -6,7 +6,7 @@ //! way a real wallpaper does. //! //! ```text -//! cargo run --release --example frame_cost -- [layers] [effect_every] [composite_every] [frames] +//! cargo run --release --example frame_cost -- [layers] [effect_every] [composite_every] [frames] [bloom] //! ``` use std::collections::HashMap; @@ -60,7 +60,7 @@ impl AssetSource for Memory { } } -fn build(layers: usize, effect_every: usize, composite_every: usize) -> (String, Memory) { +fn build(layers: usize, effect_every: usize, composite_every: usize, bloom: bool) -> (String, Memory) { let mut files: HashMap> = HashMap::new(); files.insert("shaders/bench.vert".into(), VERT.into()); files.insert("shaders/bench.frag".into(), FRAG.into()); @@ -103,10 +103,14 @@ fn build(layers: usize, effect_every: usize, composite_every: usize) -> (String, } else { "models/bench.json" }; + // Each layer tints differently and lets some of the scene through, so + // the frame digest changes if any layer composites differently. + let tint = |k: usize| ((i * k + 3) % 10) as f32 / 10.0; + let (r, g, b) = (tint(3), tint(7), tint(9)); objects.push(format!( r#"{{"id":{i},"name":"layer{i}","image":"{model}","visible":true, - "origin":"0 0 0","scale":"1 1 1","angles":"0 0 0","alpha":1.0, - "color":"1 1 1","size":"1920 1080",{effects} + "origin":"0 0 0","scale":"1 1 1","angles":"0 0 0","alpha":0.7, + "color":"{r} {g} {b}","size":"1920 1080",{effects} "alignment":"center"}}"# )); } @@ -114,7 +118,7 @@ fn build(layers: usize, effect_every: usize, composite_every: usize) -> (String, let scene = format!( r#"{{"camera":{{"eye":"0 0 100","center":"0 0 0","up":"0 1 0"}}, "general":{{"orthogonalprojection":{{"width":1920,"height":1080}}, - "clearcolor":"0 0 0","bloom":false}}, + "clearcolor":"0 0 0","bloom":{bloom}}}, "objects":[{}]}}"#, objects.join(",") ); @@ -144,6 +148,7 @@ fn main() { let effect_every: usize = args.next().and_then(|a| a.parse().ok()).unwrap_or(4); let composite_every: usize = args.next().and_then(|a| a.parse().ok()).unwrap_or(6); let frames: usize = args.next().and_then(|a| a.parse().ok()).unwrap_or(120); + let bloom = args.next().is_some_and(|a| a == "1" || a == "bloom"); let (width, height) = (1920u32, 1080u32); if std::env::var_os("RUST_LOG").is_some() { @@ -157,7 +162,7 @@ fn main() { std::process::exit(1); }; - let (scene_json, source) = build(layers, effect_every, composite_every); + let (scene_json, source) = build(layers, effect_every, composite_every, bloom); let scene = Scene::from_slice(scene_json.as_bytes()).expect("synthetic scene.json parses"); let bag = PropertyBag::default(); let mut model = SceneModel::resolve(scene, &bag); @@ -235,7 +240,7 @@ fn main() { println!("adapter {adapter}"); println!( - "scene {layers} layers, an effect on every {effect_every}, a scene-reading layer on every {composite_every}, {width}x{height}" + "scene {layers} layers, an effect on every {effect_every}, a scene-reading layer on every {composite_every}, bloom {bloom}, {width}x{height}" ); println!("frames {frames}"); println!("record median {median:.3} ms/frame (CPU time inside render())"); diff --git a/crates/kirie-render/src/frame_cost.rs b/crates/kirie-render/src/frame_cost.rs index 609c8ac..b5006a4 100644 --- a/crates/kirie-render/src/frame_cost.rs +++ b/crates/kirie-render/src/frame_cost.rs @@ -11,6 +11,10 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; static ON: AtomicBool = AtomicBool::new(false); +/// Whether `end_frame` logs and clears the totals every few seconds. Off for a +/// caller that reads them itself with `per_frame`, which a clear mid-run would +/// otherwise skew. +static REPORTING: AtomicBool = AtomicBool::new(false); static RENDER_PASSES: AtomicU64 = AtomicU64::new(0); static DRAWS: AtomicU64 = AtomicU64::new(0); @@ -33,9 +37,10 @@ pub fn counting() -> bool { ON.load(Ordering::Relaxed) } -/// Start counting. Called for `--render-debug=frame-cost`. +/// Start counting and logging the totals. Called for `--render-debug=frame-cost`. pub fn enable() { ON.store(true, Ordering::Relaxed); + REPORTING.store(true, Ordering::Relaxed); } /// A render pass opened on the scene encoder. @@ -94,6 +99,9 @@ pub fn end_frame() { return; } FRAMES.fetch_add(1, Ordering::Relaxed); + if !REPORTING.load(Ordering::Relaxed) { + return; + } let Ok(mut since) = SINCE.lock() else { return }; let started = *since.get_or_insert_with(std::time::Instant::now); let elapsed = started.elapsed(); @@ -132,9 +140,9 @@ pub struct FrameCost { pub snapshots_skipped: f64, } -/// Zero the counters and start counting. +/// Zero the counters and start counting, without the periodic log. pub fn reset() { - enable(); + ON.store(true, Ordering::Relaxed); for counter in [ &RENDER_PASSES, &DRAWS, From 0b25a41a040859c59e3ffcb080ccfdc5ebba7972 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:54:12 +0000 Subject: [PATCH 16/33] Keep the shader cache out of Workshop item folders Writing .kirie-cache inside a Steam-managed item re-queued that item with the background baker, which watches the same tree. The per-user cache is keyed by content hash, so items can share it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LDKTCgMEZkLw7hFLpgLXNJ --- crates/kirie-render/src/scene/load.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/kirie-render/src/scene/load.rs b/crates/kirie-render/src/scene/load.rs index 6220a51..0f1dadf 100644 --- a/crates/kirie-render/src/scene/load.rs +++ b/crates/kirie-render/src/scene/load.rs @@ -134,7 +134,6 @@ pub fn load_workshop_scene( audio: Option>, properties: &[(String, String)], ) -> Result, SceneLoadError> { - kirie_shader::translate::set_cache_dir(Some(scene_dir.join(".kirie-cache"))); let pkg_path = scene_package(scene_dir); let pkg = match kirie_bake::map_readonly(&pkg_path) { Ok(map) => OwnedPkg::from_external(map), @@ -291,7 +290,6 @@ pub fn start_background_prebake( })?; let pkg = OwnedPkg::from_external(map).map_err(|e| kirie_bake::BakeError::Serialize(e.to_string()))?; - kirie_shader::translate::set_cache_dir(Some(item.join(".kirie-cache"))); let scene = { let bytes = pkg .read_name(&scene_entry(&pkg, item)) From 497803a7b452ab3faea5c21d58f9441f839fc704 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:05:20 +0000 Subject: [PATCH 17/33] Refuse a non-ASCII color word instead of panicking on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit float_prefix sliced a &str at byte offsets, so a project.json color like "i€€€" panicked on a char boundary. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LDKTCgMEZkLw7hFLpgLXNJ --- crates/kirie-formats/src/project.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/kirie-formats/src/project.rs b/crates/kirie-formats/src/project.rs index 7036e08..3306c91 100644 --- a/crates/kirie-formats/src/project.rs +++ b/crates/kirie-formats/src/project.rs @@ -382,7 +382,10 @@ fn float_prefix(s: &str) -> &str { let bytes = s.as_bytes(); let mut i = usize::from(matches!(bytes.first(), Some(b'+' | b'-'))); for token in ["infinity", "inf", "nan"] { - if s.len() >= i + token.len() && s[i..i + token.len()].eq_ignore_ascii_case(token) { + if bytes + .get(i..i + token.len()) + .is_some_and(|word| word.eq_ignore_ascii_case(token.as_bytes())) + { return &s[..i + token.len()]; } } @@ -1499,6 +1502,7 @@ mod tests { assert_eq!(parse_property_color("1 1 1 ").unwrap(), [1.0, 1.0, 1.0]); assert_eq!(parse_property_color("a b c").unwrap(), [0.0, 0.0, 0.0]); assert_eq!(parse_property_color("1.5x 2 3").unwrap(), [1.5, 2.0, 3.0]); + assert_eq!(parse_property_color("i€€€ n€€ -i€€").unwrap(), [0.0, 0.0, 0.0]); assert_eq!(parse_property_color("#fff").unwrap(), [1.0, 1.0, 1.0]); assert_eq!(parse_property_color("#f00f").unwrap(), [1.0, 0.0, 0.0]); From f65ffc39db99ac635753f34b9ab5c0434edb883f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:05:20 +0000 Subject: [PATCH 18/33] Reject a .kpk header whose manifest offset overflows Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LDKTCgMEZkLw7hFLpgLXNJ --- crates/kirie-pack/src/read.rs | 2 +- crates/kirie-pack/tests/roundtrip.rs | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/kirie-pack/src/read.rs b/crates/kirie-pack/src/read.rs index 9be63ef..8633e48 100644 --- a/crates/kirie-pack/src/read.rs +++ b/crates/kirie-pack/src/read.rs @@ -92,7 +92,7 @@ impl Package { return Err(PackError::corrupt("the manifest or index is implausibly large")); } if manifest_offset < ALIGN - || index_offset != manifest_offset + manifest_len + || manifest_offset.checked_add(manifest_len) != Some(index_offset) || index_offset.checked_add(index_len) != Some(file_len) { return Err(PackError::corrupt("the header's offsets do not fit the file")); diff --git a/crates/kirie-pack/tests/roundtrip.rs b/crates/kirie-pack/tests/roundtrip.rs index 1df58ec..d7b4285 100644 --- a/crates/kirie-pack/tests/roundtrip.rs +++ b/crates/kirie-pack/tests/roundtrip.rs @@ -111,6 +111,13 @@ fn truncated_and_foreign_files_are_refused() { Package::from_reader(Cursor::new(b"PK\x03\x04 a zip file, not ours".repeat(10))), Err(PackError::NotAPackage) )); + + let mut wrapping = bytes.clone(); + wrapping[16..24].copy_from_slice(&u64::MAX.to_le_bytes()); + assert!(matches!( + Package::from_reader(Cursor::new(wrapping)), + Err(PackError::Corrupt(_)) + )); } #[test] From c6e903d0651968d993f9e471d91b01fc4e984203 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:05:20 +0000 Subject: [PATCH 19/33] Coalesce background bake jobs and find WE assets on Windows Every watcher event queued a job that hashed the whole scene.pkg, so one download meant hundreds of re-hashes; there is now one pending job per item. The WE assets dir was only looked for under HOME, which Windows doesn't set. collect_meta no longer follows symlinked directories into a loop. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LDKTCgMEZkLw7hFLpgLXNJ --- crates/kirie-bake/src/baker.rs | 34 ++++++++++++++++++++---- crates/kirie-bake/src/key.rs | 2 +- crates/kirie-bake/src/lib.rs | 48 +++++++++++++++++++++++----------- 3 files changed, 63 insertions(+), 21 deletions(-) diff --git a/crates/kirie-bake/src/baker.rs b/crates/kirie-bake/src/baker.rs index acb3473..e54cc94 100644 --- a/crates/kirie-bake/src/baker.rs +++ b/crates/kirie-bake/src/baker.rs @@ -1,6 +1,7 @@ +use std::collections::HashSet; use std::path::{Path, PathBuf}; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; use std::thread::JoinHandle; use crossbeam_channel::{Receiver, Sender}; @@ -61,9 +62,27 @@ struct Inner { paused: AtomicBool, shutdown: AtomicBool, cap_bytes: u64, + /// Items with a job queued or waiting out a pause. A download or an + /// unpack fires a watcher event per write, and each job hashes the whole + /// package, so a second job for an item that already has one is dropped. + pending: Mutex>, } impl Inner { + fn claim(&self, item: &Path) -> bool { + self.pending + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert(item.to_path_buf()) + } + + fn release(&self, item: &Path) { + self.pending + .lock() + .unwrap_or_else(PoisonError::into_inner) + .remove(item); + } + fn paused_now(&self) -> bool { self.paused.load(Ordering::Relaxed) || (self.should_pause)() } @@ -111,6 +130,7 @@ impl BackgroundBaker { paused: AtomicBool::new(false), shutdown: AtomicBool::new(false), cap_bytes: config.cap_bytes, + pending: Mutex::new(HashSet::new()), }); let pool = rayon::ThreadPoolBuilder::new() .num_threads(config.num_threads.max(1)) @@ -180,9 +200,7 @@ impl BackgroundBaker { pub fn shutdown(&mut self) { self.watchers.clear(); - self.inner - .shutdown - .store(true, std::sync::atomic::Ordering::Relaxed); + self.inner.shutdown.store(true, Ordering::Relaxed); let _ = self.tx.send(Msg::Stop); if let Some(h) = self.coordinator.take() { let _ = h.join(); @@ -202,12 +220,18 @@ fn coordinator_loop(inner: &Arc, pool: &rayon::ThreadPool, rx: &Receiver< Msg::Item(p) => p, Msg::Stop => break, }; + if !inner.claim(&item) { + continue; + } let job = Arc::clone(inner); pool.spawn(move || { loop { + // Released before baking, so a change that lands while this + // bake runs queues another pass instead of being dropped. + job.release(&item); match job.bake_item(&item) { Ok(BakeOutcome::Paused) => { - if job.shutdown.load(std::sync::atomic::Ordering::Relaxed) { + if job.shutdown.load(Ordering::Relaxed) || !job.claim(&item) { return; } std::thread::sleep(std::time::Duration::from_secs(5)); diff --git a/crates/kirie-bake/src/key.rs b/crates/kirie-bake/src/key.rs index 6229435..08b973c 100644 --- a/crates/kirie-bake/src/key.rs +++ b/crates/kirie-bake/src/key.rs @@ -73,7 +73,7 @@ fn collect_meta(root: &std::path::Path, dir: &std::path::Path, out: &mut Vec Option { - const ROOTS: [&str; 4] = [ - ".local/share/Steam/steamapps/common/wallpaper_engine/assets", - ".steam/steam/steamapps/common/wallpaper_engine/assets", - ".var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common/wallpaper_engine/assets", - "snap/steam/common/.local/share/Steam/steamapps/common/wallpaper_engine/assets", - ]; if let Some(over) = std::env::var_os("KIRIE_WE_ASSETS") { let p = std::path::PathBuf::from(over).join("shaders"); return p.is_dir().then_some(p); } - let home = std::env::var_os("HOME")?; - let home = std::path::PathBuf::from(home); - ROOTS - .iter() - .map(|r| home.join(r).join("shaders")) + steam_roots() + .into_iter() + .map(|root| root.join("steamapps/common/wallpaper_engine/assets/shaders")) .find(|p| p.is_dir()) } +/// Windows sets no `HOME` and keeps Steam under Program Files, so a +/// home-relative search alone found nothing there. +#[cfg(windows)] +fn steam_roots() -> Vec { + ["ProgramFiles(x86)", "ProgramFiles", "ProgramW6432"] + .iter() + .filter_map(std::env::var_os) + .filter(|base| !base.is_empty()) + .map(|base| std::path::PathBuf::from(base).join("Steam")) + .collect() +} + +#[cfg(not(windows))] +fn steam_roots() -> Vec { + #[cfg(target_os = "macos")] + const ROOTS: &[&str] = &["Library/Application Support/Steam"]; + #[cfg(not(target_os = "macos"))] + const ROOTS: &[&str] = &[ + ".local/share/Steam", + ".steam/steam", + ".var/app/com.valvesoftware.Steam/.local/share/Steam", + "snap/steam/common/.local/share/Steam", + ]; + let Some(home) = std::env::var_os("HOME").filter(|home| !home.is_empty()) else { + return Vec::new(); + }; + let home = std::path::PathBuf::from(home); + ROOTS.iter().map(|root| home.join(root)).collect() +} + #[cfg(target_os = "linux")] pub fn limit_malloc_arenas(n: i32) { // SAFETY: mallopt sets an allocator tuning knob; no pointers involved. It @@ -50,10 +72,6 @@ pub fn resolve_vulkan_icd(selector: &str) -> Option { let manifest = if explicit.is_file() { explicit.to_path_buf() } else { - // The fallback used to be `to_owned().leak()`, which leaked a Vec on - // every call that named a driver this list does not know. Binding the - // one-element array here gives it the same lifetime as the borrowed - // arms without leaking anything. let unknown = [sel.as_str()]; let tokens: &[&str] = match sel.as_str() { "amd" | "radeon" | "radv" => &["radeon", "amd"], From 113f46a6fdd8652c3303d54f71ca7a0fb0f2c756 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:05:20 +0000 Subject: [PATCH 20/33] Keep the shader include resolver inside its roots #include "/dev/zero" read forever and ../ left the roots. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LDKTCgMEZkLw7hFLpgLXNJ --- crates/kirie-shader/src/lib.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/kirie-shader/src/lib.rs b/crates/kirie-shader/src/lib.rs index a4ade01..dd58692 100644 --- a/crates/kirie-shader/src/lib.rs +++ b/crates/kirie-shader/src/lib.rs @@ -68,6 +68,15 @@ impl FsIncludeResolver { impl IncludeResolver for FsIncludeResolver { fn resolve(&self, include_name: &str) -> Option { + // The name comes from the shader, which comes from a download: only + // a plain relative path may reach the disk, never `/dev/zero` or + // `../../` out of the roots. + let relative = std::path::Path::new(include_name) + .components() + .all(|part| matches!(part, std::path::Component::Normal(_))); + if !relative { + return None; + } for root in &self.roots { let path = root.join(include_name); if let Ok(text) = std::fs::read_to_string(&path) { @@ -177,6 +186,14 @@ mod tests { } } + #[test] + fn the_file_resolver_stays_inside_its_roots() { + let resolver = FsIncludeResolver::new(vec![std::env::temp_dir()]); + assert!(resolver.resolve("/etc/hostname").is_none()); + assert!(resolver.resolve("../etc/hostname").is_none()); + assert!(resolver.resolve("").is_none()); + } + #[test] fn translate_minimal_fragment_with_sampler_and_uniform() { let src = "\ From 0a584171df43023504ee2f16c77a61c69e37d057 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:05:20 +0000 Subject: [PATCH 21/33] Put a module's top level under the script time budget A property script with while(true){} at top level, or an endless promise chain, hung the script thread and the renderer waiting on it. Console output per call is capped too. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LDKTCgMEZkLw7hFLpgLXNJ --- crates/kirie-script/src/world.rs | 20 +++++++++++- crates/kirie-script/tests/runaway.rs | 49 ++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/crates/kirie-script/src/world.rs b/crates/kirie-script/src/world.rs index 9eb1475..1899d82 100644 --- a/crates/kirie-script/src/world.rs +++ b/crates/kirie-script/src/world.rs @@ -167,6 +167,11 @@ impl World { if self.modules.contains_key(key) { return Ok(()); } + // A module's top level runs here, so it gets the same budget as a + // tick: `while (true) {}` outside any export would otherwise hang the + // script thread, and the renderer waiting on this load with it. + self.deadline.arm(SCRIPT_BUDGET); + let _guard = DeadlineGuard(self.deadline.clone()); let key_owned = key.to_owned(); let loaded = self .context @@ -435,6 +440,8 @@ impl World { } pub fn eval_to_string(&self, source: &str) -> Result { + self.deadline.arm(SCRIPT_BUDGET); + let _guard = DeadlineGuard(self.deadline.clone()); self.context.with(|ctx| { ctx.eval::(source) .catch(&ctx) @@ -839,6 +846,10 @@ fn build_all<'js>(ctx: &Ctx<'js>, props: &BTreeMap) -> Resu Ok(obj.into_value()) } +/// A script logging in a loop would otherwise hand the host millions of +/// lines per frame to format and write out. +const MAX_LOG_LINES_PER_CALL: usize = 256; + fn drain_side_effects(ctx: &Ctx<'_>, out: &mut TickOutput) { let host: Object = match global(ctx, "__host") { Ok(h) => h, @@ -857,7 +868,8 @@ fn drain_side_effects(ctx: &Ctx<'_>, out: &mut TickOutput) { } } if let Ok(console) = host.get::<_, Array>("console") { - for i in 0..console.len() { + let kept = console.len().min(MAX_LOG_LINES_PER_CALL); + for i in 0..kept { if let Ok(s) = console.get::(i) { let error = s.starts_with('E'); out.logs.push(LogLine { @@ -866,6 +878,12 @@ fn drain_side_effects(ctx: &Ctx<'_>, out: &mut TickOutput) { }); } } + if console.len() > kept { + out.logs.push(LogLine { + error: true, + message: format!("{} more console lines dropped", console.len() - kept), + }); + } } if let Ok(empty) = Array::new(ctx.clone()) { let _ = host.set("ops", empty); diff --git a/crates/kirie-script/tests/runaway.rs b/crates/kirie-script/tests/runaway.rs index 3f8db61..abcc270 100644 --- a/crates/kirie-script/tests/runaway.rs +++ b/crates/kirie-script/tests/runaway.rs @@ -41,3 +41,52 @@ fn a_runaway_script_is_interrupted_and_the_engine_survives() { out.property_results ); } + +#[test] +fn a_runaway_top_level_is_interrupted_at_load() { + let e = ScriptEngine::new().unwrap(); + let start = std::time::Instant::now(); + let _ = e.load_property_script( + "beta_1", + "while (true) {} export function update(v){ return v; }", + None, + ScriptValue::Int(0), + serde_json::json!({}), + ); + assert!( + start.elapsed() < std::time::Duration::from_secs(5), + "took {:?}", + start.elapsed() + ); + + let start = std::time::Instant::now(); + let _ = e.load_property_script( + "beta_2", + "function spin(){ Promise.resolve().then(spin); } spin(); export function update(v){ return v; }", + None, + ScriptValue::Int(0), + serde_json::json!({}), + ); + assert!( + start.elapsed() < std::time::Duration::from_secs(5), + "an endless job chain must be cut off, took {:?}", + start.elapsed() + ); + + e.load_property_script( + "beta_3", + "export function update(v){ return 5; }", + None, + ScriptValue::Int(0), + serde_json::json!({}), + ) + .unwrap(); + let out = e.tick(HostFrame::default(), vec![]).expect("engine still ticks"); + assert!( + out.property_results + .iter() + .any(|(key, value)| key == "beta_3" && *value == ScriptValue::Int(5)), + "{:?}", + out.property_results + ); +} From 10b5920d476acbc3403a757689bae9af61358c15 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:05:20 +0000 Subject: [PATCH 22/33] Refuse oversized or frameless videos and stop ffmpeg following remote URLs Inputs open with protocol_whitelist=file so an HLS or concat playlist named .mp4 can't make ffmpeg fetch URLs. A loop pass that yields no frame stops instead of spinning, and textures are checked against the device limit before wgpu validation can panic. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LDKTCgMEZkLw7hFLpgLXNJ --- crates/kirie-video/src/audio.rs | 2 +- crates/kirie-video/src/decode.rs | 80 +++++++++++++++++++----------- crates/kirie-video/src/renderer.rs | 8 +++ 3 files changed, 59 insertions(+), 31 deletions(-) diff --git a/crates/kirie-video/src/audio.rs b/crates/kirie-video/src/audio.rs index 98f168d..084c43d 100644 --- a/crates/kirie-video/src/audio.rs +++ b/crates/kirie-video/src/audio.rs @@ -84,7 +84,7 @@ fn run_thread( ) { let setup = || -> Result, VideoError> { ffmpeg::init()?; - let input = ffmpeg::format::input(path)?; + let input = crate::decode::open_input(path)?; let Some(stream) = input.streams().best(ffmpeg::media::Type::Audio) else { return Ok(None); }; diff --git a/crates/kirie-video/src/decode.rs b/crates/kirie-video/src/decode.rs index 00eefd4..147b92a 100644 --- a/crates/kirie-video/src/decode.rs +++ b/crates/kirie-video/src/decode.rs @@ -56,6 +56,14 @@ pub const FRAME_QUEUE_CAP: usize = 4; const FALLBACK_FRAME_DUR: f64 = 1.0 / 30.0; +/// Twice 8K. Every frame is converted into a buffer of this size, so a file +/// claiming more is refused rather than trusted. +const MAX_DIMENSION: u32 = 16_384; + +fn plausible_size(width: u32, height: u32) -> bool { + (1..=MAX_DIMENSION).contains(&width) && (1..=MAX_DIMENSION).contains(&height) +} + #[derive(Debug)] pub struct DecodedFrame { pub play_pts: f64, @@ -100,6 +108,7 @@ pub(crate) struct Decoder { unconvertible: u64, frame_dur: f64, synth_pts: f64, + sent: u64, } struct Converter { @@ -125,7 +134,7 @@ impl Converter { impl Decoder { pub fn open(path: &Path) -> Result { ffmpeg::init()?; - let input = ffmpeg::format::input(path)?; + let input = open_input(path)?; let stream = input .streams() .best(ffmpeg::media::Type::Video) @@ -140,7 +149,7 @@ impl Decoder { let frame_rate = f64::from(stream.avg_frame_rate()).max(0.0); let decoder = open_video_decoder(&stream)?; let (width, height) = (decoder.width(), decoder.height()); - if width == 0 || height == 0 { + if !plausible_size(width, height) { return Err(VideoError::InvalidDimensions { width, height }); } let duration = if input.duration() > 0 { @@ -174,6 +183,7 @@ impl Decoder { unconvertible: 0, frame_dur, synth_pts: 0.0, + sent: 0, }) } @@ -185,6 +195,7 @@ impl Decoder { let mut converter = Converter::new(self.want_nv12); let mut consecutive_read_errors = 0u32; loop { + let sent_before = self.sent; loop { let mut packet = ffmpeg::Packet::empty(); match packet.read(&mut self.input) { @@ -219,6 +230,12 @@ impl Decoder { if !self.drain(&mut converter, frames, recycle) { return; } + // Looping a file that gave no picture would read it again and + // again as fast as the disk allows, forever. + if self.sent == sent_before { + tracing::error!("a whole pass over the video produced no frame; stopping video decode"); + return; + } if let Err(err) = self.input.seek(0, ..) { tracing::error!(%err, "loop seek to 0 failed; stopping video decode"); return; @@ -244,6 +261,7 @@ impl Decoder { if frames.send(frame).is_err() { return false; } + self.sent += 1; } Err(err) => { self.unconvertible += 1; @@ -257,6 +275,24 @@ impl Decoder { } } + /// Where the frame just decoded plays, keeping the running frame + /// duration and the synthetic clock for frames without timestamps. + fn next_play_pts(&mut self) -> f64 { + let raw = match self.decoded.timestamp().or_else(|| self.decoded.pts()) { + Some(ts) => ts as f64 * self.time_base - self.start, + None => self.synth_pts, + }; + if let Some(last) = self.last_raw { + let delta = raw - last; + if delta > 0.0 && delta < 1.0 { + self.frame_dur = delta; + } + } + self.last_raw = Some(raw); + self.synth_pts = raw + self.frame_dur; + self.timeline.map(raw, self.frame_dur) + } + fn convert( &mut self, converter: &mut Converter, @@ -271,27 +307,14 @@ impl Decoder { let decoded = &self.decoded; let (width, height) = (decoded.width(), decoded.height()); - if width == 0 || height == 0 { + if !plausible_size(width, height) { return Err(VideoError::InvalidDimensions { width, height }); } if converter.nv12 && decoded.format() == Pixel::NV12 && width % 2 == 0 && height % 2 == 0 { - let raw = match self.decoded.timestamp().or_else(|| self.decoded.pts()) { - Some(ts) => ts as f64 * self.time_base - self.start, - None => self.synth_pts, - }; - if let Some(last) = self.last_raw { - let delta = raw - last; - if delta > 0.0 && delta < 1.0 { - self.frame_dur = delta; - } - } - self.last_raw = Some(raw); - self.synth_pts = raw + self.frame_dur; - let play_pts = self.timeline.map(raw, self.frame_dur); - let mut data = recycle.try_recv().unwrap_or_default(); copy_nv12(decoded, &mut data); + let play_pts = self.next_play_pts(); return Ok(DecodedFrame { play_pts, width, @@ -335,19 +358,7 @@ impl Decoder { }; scaler.run(decoded, &mut converter.rgb)?; - let raw = match self.decoded.timestamp().or_else(|| self.decoded.pts()) { - Some(ts) => ts as f64 * self.time_base - self.start, - None => self.synth_pts, - }; - if let Some(last) = self.last_raw { - let delta = raw - last; - if delta > 0.0 && delta < 1.0 { - self.frame_dur = delta; - } - } - self.last_raw = Some(raw); - self.synth_pts = raw + self.frame_dur; - let play_pts = self.timeline.map(raw, self.frame_dur); + let play_pts = self.next_play_pts(); let mut data = recycle.try_recv().unwrap_or_default(); copy_rgba(&converter.rgb, &mut data); @@ -362,6 +373,15 @@ impl Decoder { } } +/// Open a media file for reading, allowing only local files for anything the +/// container itself points at: an HLS or concat playlist dressed up as a +/// video would otherwise have ffmpeg fetch whatever URLs it lists. +pub(crate) fn open_input(path: &Path) -> Result { + let mut options = ffmpeg::Dictionary::new(); + options.set("protocol_whitelist", "file"); + ffmpeg::format::input_with_dictionary(path, options) +} + fn copy_nv12(frame: &ffmpeg::frame::Video, buf: &mut Vec) { let (w, h) = (frame.width() as usize, frame.height() as usize); buf.clear(); diff --git a/crates/kirie-video/src/renderer.rs b/crates/kirie-video/src/renderer.rs index 5fd9b4b..af8cab5 100644 --- a/crates/kirie-video/src/renderer.rs +++ b/crates/kirie-video/src/renderer.rs @@ -290,6 +290,14 @@ impl VideoRenderer { } fn upload(&mut self, frame: DecodedFrame) { + // A texture the device cannot hold is a wgpu validation error, which + // panics, and the size comes from the file. + let max = self.device.limits().max_texture_dimension_2d; + let expected = frame.width as usize * frame.height as usize * 4; + if frame.width > max || frame.height > max || frame.data.len() < expected { + let _ = self.recycle_tx.try_send(frame.data); + return; + } self.ensure_texture(frame.width, frame.height); let Some(tex) = &self.frame_tex else { return }; self.queue.write_texture( From c84bf634c5def18dcc0ac930bac15abffa683db2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:05:20 +0000 Subject: [PATCH 23/33] Harden web wallpaper hosting A file:// URL from project.json is no longer used as an address, frame sizes from the host child use checked arithmetic, the GTK snapshot can't be written through a planted symlink, and the no-op --remote-allow-origins=* CEF switch is gone. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LDKTCgMEZkLw7hFLpgLXNJ --- crates/kirie-web/src/backend.rs | 5 +- crates/kirie-web/src/cef/app.rs | 1 - crates/kirie-web/src/cef/backend.rs | 7 ++- crates/kirie-web/src/feed.rs | 6 +-- crates/kirie-web/src/hosted.rs | 11 +++-- crates/kirie-web/src/page.rs | 12 ++++- crates/kirie-web/src/renderer.rs | 12 +++-- crates/kirie-web/src/webview/backend.rs | 11 ++++- crates/kirie-web/src/webview/mod.rs | 64 ------------------------- 9 files changed, 50 insertions(+), 79 deletions(-) diff --git a/crates/kirie-web/src/backend.rs b/crates/kirie-web/src/backend.rs index 5dd467a..fb24750 100644 --- a/crates/kirie-web/src/backend.rs +++ b/crates/kirie-web/src/backend.rs @@ -29,7 +29,10 @@ pub struct FrameBuffer { impl FrameBuffer { #[must_use] pub fn is_consistent(&self) -> bool { - self.data.len() == (self.width as usize) * (self.height as usize) * 4 + (self.width as usize) + .checked_mul(self.height as usize) + .and_then(|pixels| pixels.checked_mul(4)) + == Some(self.data.len()) } } diff --git a/crates/kirie-web/src/cef/app.rs b/crates/kirie-web/src/cef/app.rs index c46b441..76b09cc 100644 --- a/crates/kirie-web/src/cef/app.rs +++ b/crates/kirie-web/src/cef/app.rs @@ -59,7 +59,6 @@ wrap_app! { switch(cmd, "disable-gpu-shader-disk-cache"); switch(cmd, "disable-site-isolation-trials"); switch(cmd, "disable-web-security"); - switch_val(cmd, "remote-allow-origins", "*"); switch_val(cmd, "autoplay-policy", "no-user-gesture-required"); switch(cmd, "disable-background-timer-throttling"); switch(cmd, "disable-backgrounding-occluded-windows"); diff --git a/crates/kirie-web/src/cef/backend.rs b/crates/kirie-web/src/cef/backend.rs index 034d2d1..3e28096 100644 --- a/crates/kirie-web/src/cef/backend.rs +++ b/crates/kirie-web/src/cef/backend.rs @@ -523,5 +523,10 @@ fn throwaway_cache_dir() -> PathBuf { .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); - std::env::temp_dir().join(format!("kirie-cef-{}-{nanos}", std::process::id())) + // The per-user runtime directory first: the shared temporary directory + // would leave the page's profile where other accounts can look. + let base = std::env::var_os("XDG_RUNTIME_DIR") + .filter(|dir| !dir.is_empty()) + .map_or_else(std::env::temp_dir, PathBuf::from); + base.join(format!("kirie-cef-{}-{nanos}", std::process::id())) } diff --git a/crates/kirie-web/src/feed.rs b/crates/kirie-web/src/feed.rs index 3fa438e..badbb0e 100644 --- a/crates/kirie-web/src/feed.rs +++ b/crates/kirie-web/src/feed.rs @@ -1,3 +1,4 @@ +use std::fmt::Write as _; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -308,7 +309,7 @@ fn push_json_string(value: &str, out: &mut String) { '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"), c if (c as u32) < 0x20 || c as u32 == 0x7f => { - out.push_str(&format!("\\u{:04x}", c as u32)); + let _ = write!(out, "\\u{:04x}", c as u32); } c => out.push(c), } @@ -331,8 +332,7 @@ pub fn audio_line(bands: &[f32]) -> String { out.push_str("audio"); for b in bands { let b = if b.is_finite() { *b } else { 0.0 }; - out.push(' '); - out.push_str(&format!("{b:.4}")); + let _ = write!(out, " {b:.4}"); } out } diff --git a/crates/kirie-web/src/hosted.rs b/crates/kirie-web/src/hosted.rs index f8060f7..cadd77c 100644 --- a/crates/kirie-web/src/hosted.rs +++ b/crates/kirie-web/src/hosted.rs @@ -54,11 +54,14 @@ impl HostedBackend { } let w = u32::from_le_bytes(shm[8..12].try_into().unwrap_or_default()); let h = u32::from_le_bytes(shm[12..16].try_into().unwrap_or_default()); - let len = (w as usize) * (h as usize) * 4; - if w == 0 || h == 0 || SHM_HEADER + len > shm.len() { + let Some(pixels) = (w as usize) + .checked_mul(h as usize) + .and_then(|px| px.checked_mul(4)) + .filter(|&len| len > 0) + .and_then(|len| shm.get(SHM_HEADER..SHM_HEADER.checked_add(len)?)) + else { return; - } - let pixels = &shm[SHM_HEADER..SHM_HEADER + len]; + }; let buf = match self.cached.as_mut() { Some(b) => { b.data.clear(); diff --git a/crates/kirie-web/src/page.rs b/crates/kirie-web/src/page.rs index e9408b3..2800718 100644 --- a/crates/kirie-web/src/page.rs +++ b/crates/kirie-web/src/page.rs @@ -21,10 +21,13 @@ pub enum PageSource { impl PageSource { /// Classify what a `project.json` names as its `file`: a web address stays /// one, anything else is a page inside the wallpaper's folder. + /// + /// A `file://` address is not a web address: taking it as one would let a + /// downloaded wallpaper open any file on the machine in its view. #[must_use] pub fn of(dir: &Path, file: &str) -> Self { let lower = file.to_ascii_lowercase(); - if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("file://") { + if lower.starts_with("http://") || lower.starts_with("https://") { return Self::Url(file.to_owned()); } Self::Folder { @@ -121,6 +124,13 @@ mod tests { assert_eq!(source.address(), "https://example.com/wall"); } + #[test] + fn a_file_address_stays_inside_the_folder() { + let source = PageSource::of(Path::new("/walls/123"), "file:///etc/passwd"); + assert!(matches!(source, PageSource::Folder { .. })); + assert!(source.address().starts_with("https://wallpaper.kirie.invalid/")); + } + #[test] fn the_init_script_carries_properties_and_volume() { let script = init_script(r#"{"a":{"value":1}}"#, 0.5); diff --git a/crates/kirie-web/src/renderer.rs b/crates/kirie-web/src/renderer.rs index 6927020..d860ace 100644 --- a/crates/kirie-web/src/renderer.rs +++ b/crates/kirie-web/src/renderer.rs @@ -204,14 +204,20 @@ impl WebRenderer { } fn upload(&mut self, frame: WebFrameRef<'_>) { - self.ensure_texture(frame); - let Some(uploaded) = &self.uploaded else { + // Checked before a texture is made for it: a frame the device cannot + // hold would otherwise be a wgpu validation error, which panics. + let max = self.device.limits().max_texture_dimension_2d; + if frame.width == 0 || frame.height == 0 || frame.width > max || frame.height > max { return; - }; + } let expected = (frame.width as usize) * (frame.height as usize) * 4; if frame.data.len() < expected { return; } + self.ensure_texture(frame); + let Some(uploaded) = &self.uploaded else { + return; + }; self.queue.write_texture( wgpu::TexelCopyTextureInfo { texture: &uploaded.texture, diff --git a/crates/kirie-web/src/webview/backend.rs b/crates/kirie-web/src/webview/backend.rs index 0882297..c752fe2 100644 --- a/crates/kirie-web/src/webview/backend.rs +++ b/crates/kirie-web/src/webview/backend.rs @@ -1,4 +1,5 @@ use std::cell::RefCell; +use std::os::unix::fs::OpenOptionsExt as _; use std::rc::Rc; use gtk::glib; @@ -203,7 +204,15 @@ impl WebviewBackend { let start = (row * stride) as usize; out.extend_from_slice(&data[start..start + (w * 4) as usize]); } - std::fs::write(path, &out).ok()?; + // The path can be in a shared temporary directory, so an existing + // file or symlink there is refused rather than written through. + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + .ok()?; + std::io::Write::write_all(&mut file, &out).ok()?; Some((w, h)) } diff --git a/crates/kirie-web/src/webview/mod.rs b/crates/kirie-web/src/webview/mod.rs index 3e6599e..a677d41 100644 --- a/crates/kirie-web/src/webview/mod.rs +++ b/crates/kirie-web/src/webview/mod.rs @@ -3,67 +3,3 @@ pub mod host; mod webkit_sys; pub use backend::WebviewBackend; - -#[must_use] -pub fn file_url(path: &std::path::Path) -> String { - use std::path::Component; - - let mut url = String::from("file://"); - for comp in path.components() { - match comp { - Component::RootDir => { /* leading '/' emitted below per-segment */ } - Component::Prefix(_) => { /* Windows prefixes: not a target platform */ } - Component::CurDir => {} - Component::ParentDir => { - url.push('/'); - url.push_str(".."); - } - Component::Normal(seg) => { - url.push('/'); - encode_segment(&seg.to_string_lossy(), &mut url); - } - } - } - if url == "file://" { - url.push('/'); - } - url -} - -fn encode_segment(seg: &str, out: &mut String) { - for &b in seg.as_bytes() { - let safe = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~'); - if safe { - out.push(b as char); - } else { - out.push('%'); - out.push(hex_digit(b >> 4)); - out.push(hex_digit(b & 0x0f)); - } - } -} - -fn hex_digit(n: u8) -> char { - match n { - 0..=9 => (b'0' + n) as char, - _ => (b'A' + (n - 10)) as char, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::path::Path; - - #[test] - fn encodes_spaces_and_keeps_slashes() { - let url = file_url(Path::new("/home/a b/My Wallpaper/index.html")); - assert_eq!(url, "file:///home/a%20b/My%20Wallpaper/index.html"); - } - - #[test] - fn keeps_unreserved() { - let url = file_url(Path::new("/a-b_c.d~e/index.html")); - assert_eq!(url, "file:///a-b_c.d~e/index.html"); - } -} From 2f01a755dd272dd81f828b7346753fe8ef6ce082 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:05:21 +0000 Subject: [PATCH 24/33] Read Steam's C strings within their buffers Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LDKTCgMEZkLw7hFLpgLXNJ --- crates/kirie-steam-helper/src/lib.rs | 38 ++++++++++---------------- crates/kirie-steam-helper/src/steam.rs | 27 +++++++----------- 2 files changed, 24 insertions(+), 41 deletions(-) diff --git a/crates/kirie-steam-helper/src/lib.rs b/crates/kirie-steam-helper/src/lib.rs index b496ae5..b74583c 100644 --- a/crates/kirie-steam-helper/src/lib.rs +++ b/crates/kirie-steam-helper/src/lib.rs @@ -108,12 +108,8 @@ fn search(args: &[String]) -> std::process::ExitCode { } } - let session = match Session::open(&roots) { - Ok(session) => session, - Err(err) => { - emit_error(&err.to_string()); - return std::process::ExitCode::FAILURE; - } + let Some(session) = open_session(&roots) else { + return std::process::ExitCode::FAILURE; }; match session.search(&query, CALL_TIMEOUT) { @@ -158,12 +154,8 @@ fn state(args: &[String]) -> std::process::ExitCode { return std::process::ExitCode::FAILURE; }; - let session = match Session::open(&roots) { - Ok(session) => session, - Err(err) => { - emit_error(&err.to_string()); - return std::process::ExitCode::FAILURE; - } + let Some(session) = open_session(&roots) else { + return std::process::ExitCode::FAILURE; }; println!("{}", item_json(&session, id)); @@ -176,12 +168,8 @@ fn subscribe(args: &[String]) -> std::process::ExitCode { return std::process::ExitCode::FAILURE; }; - let session = match Session::open(&roots) { - Ok(session) => session, - Err(err) => { - emit_error(&err.to_string()); - return std::process::ExitCode::FAILURE; - } + let Some(session) = open_session(&roots) else { + return std::process::ExitCode::FAILURE; }; if let Err(err) = session.subscribe(id, CALL_TIMEOUT) { @@ -199,12 +187,8 @@ fn unsubscribe(args: &[String]) -> std::process::ExitCode { return std::process::ExitCode::FAILURE; }; - let session = match Session::open(&roots) { - Ok(session) => session, - Err(err) => { - emit_error(&err.to_string()); - return std::process::ExitCode::FAILURE; - } + let Some(session) = open_session(&roots) else { + return std::process::ExitCode::FAILURE; }; if let Err(err) = session.unsubscribe(id, CALL_TIMEOUT) { @@ -216,6 +200,12 @@ fn unsubscribe(args: &[String]) -> std::process::ExitCode { std::process::ExitCode::SUCCESS } +fn open_session(roots: &[PathBuf]) -> Option { + Session::open(roots) + .map_err(|err| emit_error(&err.to_string())) + .ok() +} + fn item_json(session: &Session, id: u64) -> serde_json::Value { let state = session.item_state(id); let install = session.item_install_info(id); diff --git a/crates/kirie-steam-helper/src/steam.rs b/crates/kirie-steam-helper/src/steam.rs index c89d348..e1a4bb3 100644 --- a/crates/kirie-steam-helper/src/steam.rs +++ b/crates/kirie-steam-helper/src/steam.rs @@ -1,4 +1,4 @@ -use std::ffi::{CStr, CString, c_char, c_int, c_void}; +use std::ffi::{CString, c_char, c_int, c_void}; use std::path::{Path, PathBuf}; pub const APP_ID: u32 = 431_960; @@ -146,13 +146,10 @@ impl Session { let init_flat: unsafe extern "C" fn(*mut c_char) -> c_int = std::mem::transmute(sym(b"SteamAPI_InitFlat\0")?); - let mut err_msg = [0i8; 1024]; + let mut err_msg: [c_char; 1024] = [0; 1024]; let rc = init_flat(err_msg.as_mut_ptr()); if rc != 0 { - let why = CStr::from_ptr(err_msg.as_ptr()) - .to_string_lossy() - .trim() - .to_owned(); + let why = c_field(&err_msg).trim().to_owned(); return Err(SteamError::InitFailed(if why.is_empty() { format!("code {rc}") } else { @@ -296,7 +293,7 @@ impl Session { #[must_use] pub fn app_install_dir(&self) -> Option { - let mut buf = [0i8; 4096]; + let mut buf: [c_char; 4096] = [0; 4096]; // SAFETY: the buffer and its length are handed over together, and Steam // writes at most that many bytes into it and NUL-terminates. The // buffer is a live local for the whole call, and `c_field` below reads @@ -313,10 +310,8 @@ impl Session { if written == 0 { return None; } - // SAFETY: Steam nul-terminates within the buffer it was given. - let path = unsafe { CStr::from_ptr(buf.as_ptr()) }; - let path = path.to_string_lossy(); - (!path.is_empty()).then(|| PathBuf::from(path.as_ref())) + let path = c_field(&buf); + (!path.is_empty()).then(|| PathBuf::from(path)) } } @@ -512,7 +507,7 @@ impl Session { #[must_use] pub fn item_install_info(&self, id: u64) -> Option { let mut size = 0u64; - let mut folder = [0i8; 4096]; + let mut folder: [c_char; 4096] = [0; 4096]; let mut updated = 0u32; // SAFETY: every out-pointer is a live local that outlives the call, // and the buffer's length is passed alongside it, so Steam writes at @@ -739,7 +734,7 @@ impl Session { } let d = details.assume_init(); - let mut url = [0i8; 1024]; + let mut url: [c_char; 1024] = [0; 1024]; let has_preview = (self.get_preview_url)( ugc, handle, @@ -747,9 +742,7 @@ impl Session { url.as_mut_ptr(), u32::try_from(url.len()).unwrap_or(u32::MAX), ); - let preview_url = has_preview - .then(|| CStr::from_ptr(url.as_ptr()).to_string_lossy().into_owned()) - .filter(|u| !u.is_empty()); + let preview_url = has_preview.then(|| c_field(&url)).filter(|u| !u.is_empty()); if !layout_plausible(&d) { release(handle); @@ -763,7 +756,7 @@ impl Session { let num_tags = (self.get_num_tags)(ugc, handle, index).min(MAX_TAGS); let mut tags: Vec = Vec::with_capacity(num_tags as usize); for tag_index in 0..num_tags { - let mut buf = [0i8; 256]; + let mut buf: [c_char; 256] = [0; 256]; let got = (self.get_tag)( ugc, handle, From fd8dd780b132d7124ed80acff6bb3dac02b3c9c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:05:21 +0000 Subject: [PATCH 25/33] Simplify particle vec3 number parsing Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LDKTCgMEZkLw7hFLpgLXNJ --- crates/kirie-scene/src/particle.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/kirie-scene/src/particle.rs b/crates/kirie-scene/src/particle.rs index 196d64c..8d7442f 100644 --- a/crates/kirie-scene/src/particle.rs +++ b/crates/kirie-scene/src/particle.rs @@ -16,8 +16,8 @@ pub fn parse_bvec3(value: Option<&Value>, default: Vec3) -> Vec3 { } out } - Some(Value::Number(_)) => { - let n = coerce_f64(value.unwrap()).unwrap_or(0.0) as f32; + Some(number @ Value::Number(_)) => { + let n = coerce_f64(number).unwrap_or(0.0) as f32; [n, n, n] } _ => default, From f2661a806b9065de8323ddd6efebc5375362322c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:08:59 +0000 Subject: [PATCH 26/33] Drop a test import the module now brings in on Windows Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LDKTCgMEZkLw7hFLpgLXNJ --- crates/kirie/src/compat/desktop_ipc.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/kirie/src/compat/desktop_ipc.rs b/crates/kirie/src/compat/desktop_ipc.rs index 4881caf..b418c13 100644 --- a/crates/kirie/src/compat/desktop_ipc.rs +++ b/crates/kirie/src/compat/desktop_ipc.rs @@ -724,7 +724,6 @@ impl Renderer for Blank { mod tests { use super::*; use std::ffi::OsString; - use std::io::Read as _; use std::sync::mpsc::Receiver; fn argv(parts: &[&str]) -> Vec { From e7334e011b46f0b90c502315f10c8cbdd258917d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:21:47 +0000 Subject: [PATCH 27/33] Serve CEF web wallpapers from their folder instead of file:// As a file:// page a web wallpaper needed --allow-file-access-from-files and --disable-web-security to fetch its own JSON and media, and with those any wallpaper could read every file the user can and post it anywhere. The CEF host now gets the wallpaper's folder and serves it under https://wallpaper.kirie.invalid from a request context of its own, as the WebView2 path already does, so those two switches are gone. A request only opens a file inside the folder, after symlinks are followed. The system web views still open file://, but no longer an entry page that lands outside the folder. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LDKTCgMEZkLw7hFLpgLXNJ --- crates/kirie-web/src/cef/app.rs | 3 - crates/kirie-web/src/cef/backend.rs | 10 +- crates/kirie-web/src/cef/folder.rs | 176 ++++++++++++++++++++++++++ crates/kirie-web/src/cef/mod.rs | 1 + crates/kirie-web/src/page.rs | 184 ++++++++++++++++++++++++++++ crates/kirie/src/compat/resolve.rs | 67 +++++++++- 6 files changed, 432 insertions(+), 9 deletions(-) create mode 100644 crates/kirie-web/src/cef/folder.rs diff --git a/crates/kirie-web/src/cef/app.rs b/crates/kirie-web/src/cef/app.rs index 76b09cc..dad5be3 100644 --- a/crates/kirie-web/src/cef/app.rs +++ b/crates/kirie-web/src/cef/app.rs @@ -58,7 +58,6 @@ wrap_app! { RendererCodeIntegrityEnabled,site-per-process"); switch(cmd, "disable-gpu-shader-disk-cache"); switch(cmd, "disable-site-isolation-trials"); - switch(cmd, "disable-web-security"); switch_val(cmd, "autoplay-policy", "no-user-gesture-required"); switch(cmd, "disable-background-timer-throttling"); switch(cmd, "disable-backgrounding-occluded-windows"); @@ -68,8 +67,6 @@ wrap_app! { switch(cmd, "disable-field-trial-config"); switch(cmd, "no-experiments"); - switch(cmd, "allow-file-access-from-files"); - if std::env::var_os("WPE_CEF_NO_IPG").is_none() { switch(cmd, "in-process-gpu"); } diff --git a/crates/kirie-web/src/cef/backend.rs b/crates/kirie-web/src/cef/backend.rs index 3e28096..d432743 100644 --- a/crates/kirie-web/src/cef/backend.rs +++ b/crates/kirie-web/src/cef/backend.rs @@ -12,6 +12,7 @@ use cef::{ }; use crate::backend::{FrameBuffer, FrameSlot, PointerState, WebBackend, WebError, WebFrameRef, WebSize}; +use crate::page::PageSource; use super::client::{SharedSize, make_client}; use super::registry::{BrowserEntry, BrowserId, BrowserRegistry}; @@ -405,7 +406,12 @@ fn create_browser(req: &CreateRequest) -> Option { windowless_frame_rate: FRAME_RATE, ..Default::default() }; - let url_str = CefString::from(req.url.as_str()); + let source = Arc::new(PageSource::from_arg(&req.url)); + let mut context = match source.as_ref() { + PageSource::Folder { .. } => Some(super::folder::folder_context(&source)?), + PageSource::Url(_) => None, + }; + let url_str = CefString::from(source.address().as_str()); let browser = browser_host_create_browser_sync( Some(&window_info), @@ -413,7 +419,7 @@ fn create_browser(req: &CreateRequest) -> Option { Some(&url_str), Some(&browser_settings), None, - None, + context.as_mut(), )?; if req.muted diff --git a/crates/kirie-web/src/cef/folder.rs b/crates/kirie-web/src/cef/folder.rs new file mode 100644 index 0000000..19153d2 --- /dev/null +++ b/crates/kirie-web/src/cef/folder.rs @@ -0,0 +1,176 @@ +//! Serves a wallpaper's own folder to its browser under +//! `https://wallpaper.kirie.invalid`, so the page never runs as `file://`. +//! +//! As a `file://` page it needed `--allow-file-access-from-files` and +//! `--disable-web-security` to fetch its own JSON and media, and with those any +//! wallpaper could read every file the user can and post it anywhere. Served +//! this way its own files are same-origin and nothing else on disk is +//! reachable: [`PageSource::resolve`] decides what a request may open. + +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; +use std::os::raw::c_int; +use std::sync::{Arc, Mutex, PoisonError}; + +use cef::{ + Browser, Callback, CefString, Frame, ImplRequest, ImplRequestContext, ImplResourceHandler, ImplResponse, + ImplSchemeHandlerFactory, Request, RequestContext, RequestContextSettings, ResourceHandler, + ResourceReadCallback, ResourceSkipCallback, Response, SchemeHandlerFactory, WrapResourceHandler, + WrapSchemeHandlerFactory, rc::Rc, request_context_create_context, wrap_resource_handler, + wrap_scheme_handler_factory, +}; + +use crate::page::{FOLDER_HOST, PageSource, mime_type}; + +/// Chromium's `net::ERR_FAILED`, what a read or skip reports when the file +/// cannot be read. +const ERR_FAILED: c_int = -2; + +/// A request context of its own for a folder page, with the folder mapped +/// under [`FOLDER_HOST`]. Each browser gets one, so two wallpapers in one +/// process never see each other's files. +pub fn folder_context(source: &Arc) -> Option { + let context = request_context_create_context(Some(&RequestContextSettings::default()), None)?; + let mut factory = FolderSchemeFactory::new(Arc::clone(source)); + let registered = context.register_scheme_handler_factory( + Some(&CefString::from("https")), + Some(&CefString::from(FOLDER_HOST)), + Some(&mut factory), + ); + (registered == 1).then_some(context) +} + +struct Opened { + file: File, + len: u64, + mime: &'static str, +} + +fn open(source: &PageSource, url: &str) -> Option { + let path = source.resolve(url)?; + let file = File::open(&path).ok()?; + let len = file.metadata().ok()?.len(); + Some(Opened { + file, + len, + mime: mime_type(&path), + }) +} + +wrap_scheme_handler_factory! { + struct FolderSchemeFactory { + source: Arc, + } + + impl SchemeHandlerFactory { + fn create( + &self, + _browser: Option<&mut Browser>, + _frame: Option<&mut Frame>, + _scheme_name: Option<&CefString>, + request: Option<&mut Request>, + ) -> Option { + let url = request + .map(|request| CefString::from(&request.url()).to_string()) + .unwrap_or_default(); + let opened = open(&self.source, &url); + if opened.is_none() { + tracing::debug!(url, "not in the wallpaper's folder"); + } + Some(FolderResource::new(Arc::new(Mutex::new(opened)))) + } + } +} + +wrap_resource_handler! { + struct FolderResource { + opened: Arc>>, + } + + impl ResourceHandler { + fn open( + &self, + _request: Option<&mut Request>, + handle_request: Option<&mut c_int>, + _callback: Option<&mut Callback>, + ) -> c_int { + if let Some(handle_request) = handle_request { + *handle_request = 1; + } + 1 + } + + fn response_headers( + &self, + response: Option<&mut Response>, + response_length: Option<&mut i64>, + _redirect_url: Option<&mut CefString>, + ) { + let Some(response) = response else { return }; + let opened = self.opened.lock().unwrap_or_else(PoisonError::into_inner); + let (status, text, mime, len) = match opened.as_ref() { + Some(opened) => (200, "OK", opened.mime, i64::try_from(opened.len).unwrap_or(-1)), + None => (404, "Not Found", "text/plain", 0), + }; + response.set_status(status); + response.set_status_text(Some(&CefString::from(text))); + response.set_mime_type(Some(&CefString::from(mime))); + if let Some(response_length) = response_length { + *response_length = len; + } + } + + // CEF answers a Range request by skipping to its start, so media can + // seek without this handler parsing the header itself. + fn skip( + &self, + bytes_to_skip: i64, + bytes_skipped: Option<&mut i64>, + _callback: Option<&mut ResourceSkipCallback>, + ) -> c_int { + let Some(bytes_skipped) = bytes_skipped else { return 0 }; + let mut opened = self.opened.lock().unwrap_or_else(PoisonError::into_inner); + match opened.as_mut().map(|opened| opened.file.seek(SeekFrom::Current(bytes_to_skip))) { + Some(Ok(_)) => { + *bytes_skipped = bytes_to_skip; + 1 + } + _ => { + *bytes_skipped = i64::from(ERR_FAILED); + 0 + } + } + } + + fn read( + &self, + data_out: *mut u8, + bytes_to_read: c_int, + bytes_read: Option<&mut c_int>, + _callback: Option<&mut ResourceReadCallback>, + ) -> c_int { + let Some(bytes_read) = bytes_read else { return 0 }; + *bytes_read = 0; + let len = usize::try_from(bytes_to_read).unwrap_or(0); + let mut opened = self.opened.lock().unwrap_or_else(PoisonError::into_inner); + let Some(opened) = opened.as_mut() else { return 0 }; + if data_out.is_null() || len == 0 { + return 0; + } + // SAFETY: CEF passes a buffer it owns of `bytes_to_read` bytes, + // valid and unaliased for the duration of this call. + let out = unsafe { std::slice::from_raw_parts_mut(data_out, len) }; + match opened.file.read(out) { + Ok(0) => 0, + Ok(n) => { + *bytes_read = c_int::try_from(n).unwrap_or(0); + 1 + } + Err(_) => { + *bytes_read = ERR_FAILED; + 0 + } + } + } + } +} diff --git a/crates/kirie-web/src/cef/mod.rs b/crates/kirie-web/src/cef/mod.rs index f6c1094..e46a24b 100644 --- a/crates/kirie-web/src/cef/mod.rs +++ b/crates/kirie-web/src/cef/mod.rs @@ -1,6 +1,7 @@ pub mod app; pub mod backend; pub mod client; +mod folder; pub mod registry; pub use backend::CefBackend; diff --git a/crates/kirie-web/src/page.rs b/crates/kirie-web/src/page.rs index 2800718..1ac9722 100644 --- a/crates/kirie-web/src/page.rs +++ b/crates/kirie-web/src/page.rs @@ -36,6 +36,72 @@ impl PageSource { } } + /// The one string a web backend is handed for this page. + /// + /// An address passes through unchanged. A folder page is written as + /// `kirie-folder:?`, percent-escaped, so a backend that serves + /// the folder under [`FOLDER_HOST`] knows the whole folder and not just the + /// entry page's directory. [`Self::from_arg`] reads it back. + #[must_use] + pub fn to_arg(&self) -> String { + match self { + Self::Url(url) => url.clone(), + Self::Folder { dir, file } => { + format!( + "{FOLDER_ARG}{}?{}", + escape_segment(&dir.to_string_lossy()), + escape_segment(file) + ) + } + } + } + + /// Reads back what [`Self::to_arg`] wrote. Anything else is an address. + #[must_use] + pub fn from_arg(arg: &str) -> Self { + let folder = arg.strip_prefix(FOLDER_ARG).and_then(|rest| { + let (dir, file) = rest.split_once('?')?; + Some(Self::Folder { + dir: PathBuf::from(unescape(dir)?), + file: unescape(file)?, + }) + }); + folder.unwrap_or_else(|| Self::Url(arg.to_owned())) + } + + /// The file inside the wallpaper's folder that a request for `url` names, + /// or `None` when it names nothing there. + /// + /// Only paths under [`FOLDER_HOST`] resolve. `..`, drive prefixes and + /// anything that lands outside the folder once symlinks are followed are + /// refused, so a page can read its own folder and nothing else. + #[must_use] + pub fn resolve(&self, url: &str) -> Option { + let Self::Folder { dir, .. } = self else { + return None; + }; + let rest = url.strip_prefix("https://")?.strip_prefix(FOLDER_HOST)?; + let path = rest.split(['?', '#']).next().unwrap_or_default(); + if !path.is_empty() && !path.starts_with('/') { + return None; + } + let mut target = dir.clone(); + for raw in path.split('/').filter(|part| !part.is_empty()) { + let part = unescape(raw)?; + if part == "." + || part == ".." + || part.contains(['/', '\\', '\0']) + || (cfg!(windows) && part.contains(':')) + { + return None; + } + target.push(part); + } + let root = dir.canonicalize().ok()?; + let target = target.canonicalize().ok()?; + (target.starts_with(&root) && target.is_file()).then_some(target) + } + /// The address the view is sent to. /// /// A wallpaper's folder is served over a mapped `https://` host rather @@ -61,6 +127,65 @@ impl PageSource { } } +const FOLDER_ARG: &str = "kirie-folder:"; + +fn unescape(text: &str) -> Option { + let bytes = text.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + let hex = std::str::from_utf8(bytes.get(i + 1..i + 3)?).ok()?; + out.push(u8::from_str_radix(hex, 16).ok()?); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + String::from_utf8(out).ok() +} + +/// The `Content-Type` to serve a wallpaper's file with, from its extension. +#[must_use] +pub fn mime_type(path: &Path) -> &'static str { + let ext = path + .extension() + .map(|ext| ext.to_string_lossy().to_ascii_lowercase()) + .unwrap_or_default(); + match ext.as_str() { + "html" | "htm" => "text/html", + "js" | "mjs" => "text/javascript", + "css" => "text/css", + "json" | "map" => "application/json", + "wasm" => "application/wasm", + "svg" => "image/svg+xml", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "avif" => "image/avif", + "bmp" => "image/bmp", + "ico" => "image/x-icon", + "mp4" | "m4v" => "video/mp4", + "webm" => "video/webm", + "ogv" => "video/ogg", + "mp3" => "audio/mpeg", + "ogg" | "oga" => "audio/ogg", + "wav" => "audio/wav", + "m4a" | "aac" => "audio/mp4", + "flac" => "audio/flac", + "woff" => "font/woff", + "woff2" => "font/woff2", + "ttf" => "font/ttf", + "otf" => "font/otf", + "txt" => "text/plain", + "xml" => "application/xml", + "glsl" | "frag" | "vert" => "text/plain", + _ => "application/octet-stream", + } +} + fn escape_segment(segment: &str) -> String { let mut out = String::with_capacity(segment.len()); for &b in segment.as_bytes() { @@ -131,6 +256,65 @@ mod tests { assert!(source.address().starts_with("https://wallpaper.kirie.invalid/")); } + #[test] + fn a_folder_page_round_trips_through_its_arg() { + let source = PageSource::of(Path::new("/walls/my wall?#%"), "web/index 1.html"); + let arg = source.to_arg(); + assert!(arg.starts_with("kirie-folder:")); + let back = PageSource::from_arg(&arg); + let PageSource::Folder { dir, file } = back else { + panic!("not a folder: {arg}"); + }; + assert_eq!(dir, Path::new("/walls/my wall?#%")); + assert_eq!(file, "web/index 1.html"); + assert!(matches!( + PageSource::from_arg("https://example.com"), + PageSource::Url(_) + )); + assert!(matches!( + PageSource::from_arg("kirie-folder:%zz?a"), + PageSource::Url(_) + )); + } + + #[test] + fn requests_resolve_inside_the_folder_only() { + let base = std::env::temp_dir().join(format!("kirie-page-resolve-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let dir = base.join("item"); + std::fs::create_dir_all(dir.join("sub")).expect("scratch"); + std::fs::write(dir.join("index.html"), "x").expect("scratch"); + std::fs::write(dir.join("sub/a b.json"), "x").expect("scratch"); + std::fs::write(base.join("secret"), "x").expect("scratch"); + let source = PageSource::of(&dir, "index.html"); + let host = format!("https://{FOLDER_HOST}"); + + let found = source.resolve(&format!("{host}/sub/a%20b.json?v=1#top")); + assert_eq!(found, dir.join("sub/a b.json").canonicalize().ok()); + assert!(source.resolve(&format!("{host}/index.html")).is_some()); + + for escape in [ + "/../secret", + "/sub/../../secret", + "/%2e%2e/secret", + "/sub%2f..%2f..%2fsecret", + "/..%5csecret", + "/sub", + "/missing.html", + ] { + assert_eq!(source.resolve(&format!("{host}{escape}")), None, "{escape}"); + } + assert_eq!(source.resolve("https://example.com/index.html"), None); + assert_eq!(source.resolve(&format!("{host}.evil.com/index.html")), None); + + #[cfg(unix)] + { + std::os::unix::fs::symlink(base.join("secret"), dir.join("link")).expect("symlink"); + assert_eq!(source.resolve(&format!("{host}/link")), None); + } + let _ = std::fs::remove_dir_all(&base); + } + #[test] fn the_init_script_carries_properties_and_volume() { let script = init_script(r#"{"a":{"value":1}}"#, 0.5); diff --git a/crates/kirie/src/compat/resolve.rs b/crates/kirie/src/compat/resolve.rs index 2d2d537..5559f81 100644 --- a/crates/kirie/src/compat/resolve.rs +++ b/crates/kirie/src/compat/resolve.rs @@ -245,17 +245,36 @@ fn classify_file(file: &Path) -> Wallpaper { } } +/// What a web backend is handed for a wallpaper's entry page. +/// +/// The CEF host serves the wallpaper's folder itself, so it gets the folder +/// and the page (see `kirie_web::page::PageSource`). The system web views +/// still open the page as `file://`; the page must stay inside the folder, +/// and a `file://` address in project.json counts as a name inside it. #[must_use] pub fn web_entry_url(dir: &Path, file: &str) -> String { let lower = file.to_ascii_lowercase(); - if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("file://") { + if lower.starts_with("http://") || lower.starts_with("https://") { return file.to_owned(); } - let path = dir.join(file); - let abs = std::fs::canonicalize(&path).unwrap_or(path); - file_url(&abs) + #[cfg(feature = "web-cef")] + { + kirie_web::page::PageSource::of(dir, file).to_arg() + } + #[cfg(not(feature = "web-cef"))] + { + let path = dir.join(file); + let abs = std::fs::canonicalize(&path).unwrap_or(path); + let root = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()); + if !abs.starts_with(&root) { + tracing::warn!(file, "the entry page is outside the wallpaper's folder"); + return "about:blank".to_owned(); + } + file_url(&abs) + } } +#[cfg(not(feature = "web-cef"))] fn file_url(path: &Path) -> String { use std::path::Component; @@ -284,6 +303,7 @@ fn file_url(path: &Path) -> String { url } +#[cfg(not(feature = "web-cef"))] fn hex_digit(n: u8) -> char { match n { 0..=9 => (b'0' + n) as char, @@ -327,3 +347,42 @@ mod path_shape_tests { assert!(!looks_like_a_path(r"wallpapers\1388331347")); } } + +#[cfg(test)] +mod web_entry_tests { + use super::web_entry_url; + use std::path::Path; + + #[test] + fn an_address_passes_through() { + assert_eq!( + web_entry_url(Path::new("/w"), "https://example.com/"), + "https://example.com/" + ); + } + + #[cfg(feature = "web-cef")] + #[test] + fn the_cef_host_is_given_the_whole_folder() { + let arg = web_entry_url(Path::new("/walls/123"), "file:///etc/passwd"); + let source = kirie_web::page::PageSource::from_arg(&arg); + let kirie_web::page::PageSource::Folder { dir, .. } = &source else { + panic!("not a folder page: {arg}"); + }; + assert_eq!(dir, Path::new("/walls/123")); + assert!(source.address().starts_with("https://wallpaper.kirie.invalid/")); + } + + #[cfg(not(feature = "web-cef"))] + #[test] + fn an_entry_page_outside_the_folder_is_not_opened() { + let base = std::env::temp_dir().join(format!("kirie-web-entry-{}", std::process::id())); + let dir = base.join("item"); + std::fs::create_dir_all(&dir).expect("scratch"); + std::fs::write(base.join("outside.html"), "x").expect("scratch"); + std::fs::write(dir.join("index.html"), "x").expect("scratch"); + assert_eq!(web_entry_url(&dir, "../outside.html"), "about:blank"); + assert!(web_entry_url(&dir, "index.html").starts_with("file:///")); + let _ = std::fs::remove_dir_all(&base); + } +} From 6115b25e22b5502d13c693dab4240cc523b2e7e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:26:52 +0000 Subject: [PATCH 28/33] Address review findings on the audit commits - Keep a baking item claimed for the whole bake and run at most one more pass when a change lands meanwhile, instead of releasing it first. - Follow symlinked shader directories again when fingerprinting WE assets, with a depth bound instead of skipping links. - Detect a changed pipeline cache by content, not length. - frame_cost::reset() now always turns the periodic log off. - Don't register a SceneScript module whose top level ran out of budget. - Keep the shader include resolver inside its roots after symlinks too. - Refuse videos over 8192x8192 pixels in total, not only per side. - Refuse a desktop-socket request cut off by the size cap. - Use a fresh private socket directory when the shared fallback can't be proven ours. - Remove kirie pack's partial file when sync or rename fails. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LDKTCgMEZkLw7hFLpgLXNJ --- crates/kirie-bake/src/baker.rs | 65 +++++++++++++++++++------- crates/kirie-bake/src/key.rs | 15 +++++- crates/kirie-platform/src/gpu.rs | 17 +++++-- crates/kirie-render/src/frame_cost.rs | 7 ++- crates/kirie-script/src/world.rs | 12 ++++- crates/kirie-shader/src/lib.rs | 24 +++++++++- crates/kirie-video/src/decode.rs | 12 +++-- crates/kirie/src/compat/desktop_ipc.rs | 13 +++++- crates/kirie/src/os.rs | 31 ++++++++++-- crates/kirie/src/pack.rs | 10 +++- 10 files changed, 170 insertions(+), 36 deletions(-) diff --git a/crates/kirie-bake/src/baker.rs b/crates/kirie-bake/src/baker.rs index e54cc94..28c080f 100644 --- a/crates/kirie-bake/src/baker.rs +++ b/crates/kirie-bake/src/baker.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; @@ -65,22 +65,42 @@ struct Inner { /// Items with a job queued or waiting out a pause. A download or an /// unpack fires a watcher event per write, and each job hashes the whole /// package, so a second job for an item that already has one is dropped. - pending: Mutex>, + /// Items with a job queued or running, and whether a change arrived for + /// one while its bake was running and so needs one more pass. + pending: Mutex>, } impl Inner { + /// Start a job for `item`, or mark the one already queued or running for + /// another pass. True when the caller should start a job. fn claim(&self, item: &Path) -> bool { - self.pending - .lock() - .unwrap_or_else(PoisonError::into_inner) - .insert(item.to_path_buf()) + let mut pending = self.pending.lock().unwrap_or_else(PoisonError::into_inner); + match pending.get_mut(item) { + Some(dirty) => { + *dirty = true; + false + } + None => { + pending.insert(item.to_path_buf(), false); + true + } + } } - fn release(&self, item: &Path) { - self.pending - .lock() - .unwrap_or_else(PoisonError::into_inner) - .remove(item); + /// End a pass over `item`. True when a change arrived during it and the + /// job should bake once more; otherwise the item is released. + fn finish(&self, item: &Path) -> bool { + let mut pending = self.pending.lock().unwrap_or_else(PoisonError::into_inner); + match pending.get_mut(item) { + Some(dirty) if *dirty => { + *dirty = false; + true + } + _ => { + pending.remove(item); + false + } + } } fn paused_now(&self) -> bool { @@ -130,7 +150,7 @@ impl BackgroundBaker { paused: AtomicBool::new(false), shutdown: AtomicBool::new(false), cap_bytes: config.cap_bytes, - pending: Mutex::new(HashSet::new()), + pending: Mutex::new(HashMap::new()), }); let pool = rayon::ThreadPoolBuilder::new() .num_threads(config.num_threads.max(1)) @@ -226,20 +246,29 @@ fn coordinator_loop(inner: &Arc, pool: &rayon::ThreadPool, rx: &Receiver< let job = Arc::clone(inner); pool.spawn(move || { loop { - // Released before baking, so a change that lands while this - // bake runs queues another pass instead of being dropped. - job.release(&item); + // The item stays claimed while it bakes; a change that lands + // meanwhile marks it for one more pass rather than a second job. match job.bake_item(&item) { Ok(BakeOutcome::Paused) => { - if job.shutdown.load(Ordering::Relaxed) || !job.claim(&item) { + if job.shutdown.load(Ordering::Relaxed) { + job.pending + .lock() + .unwrap_or_else(PoisonError::into_inner) + .remove(&item); return; } std::thread::sleep(std::time::Duration::from_secs(5)); } - Ok(_) => return, + Ok(_) => { + if !job.finish(&item) { + return; + } + } Err(e) => { tracing::warn!(item = %item.display(), error = %e, "background bake failed"); - return; + if !job.finish(&item) { + return; + } } } } diff --git a/crates/kirie-bake/src/key.rs b/crates/kirie-bake/src/key.rs index 08b973c..9850019 100644 --- a/crates/kirie-bake/src/key.rs +++ b/crates/kirie-bake/src/key.rs @@ -69,12 +69,23 @@ fn binary_fingerprint() -> [u8; 32] { }) } +/// How deep [`collect_meta`] walks. Symlinked directories are followed, so a +/// shader edited behind one still changes the key, and this bound is what +/// keeps a link that points back up the tree from recursing forever. +const MAX_META_DEPTH: usize = 16; + fn collect_meta(root: &std::path::Path, dir: &std::path::Path, out: &mut Vec) { + collect_meta_at(root, dir, out, 0); +} + +fn collect_meta_at(root: &std::path::Path, dir: &std::path::Path, out: &mut Vec, depth: usize) { let Ok(rd) = std::fs::read_dir(dir) else { return }; for entry in rd.flatten() { let path = entry.path(); - if entry.file_type().is_ok_and(|kind| kind.is_dir()) { - collect_meta(root, &path, out); + if path.is_dir() { + if depth < MAX_META_DEPTH { + collect_meta_at(root, &path, out, depth + 1); + } continue; } let Ok(meta) = entry.metadata() else { continue }; diff --git a/crates/kirie-platform/src/gpu.rs b/crates/kirie-platform/src/gpu.rs index acf4a1c..593869e 100644 --- a/crates/kirie-platform/src/gpu.rs +++ b/crates/kirie-platform/src/gpu.rs @@ -217,9 +217,17 @@ pub fn attach_pipeline_cache(device: &wgpu::Device, adapter: &wgpu::Adapter) { } } -/// Size of the blob last written, so a wallpaper swap that compiled nothing +/// Hash of the blob last written, so a wallpaper swap that compiled nothing /// new does not rewrite megabytes from the render thread. -static PERSISTED_LEN: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); +static PERSISTED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +fn blob_hash(data: &[u8]) -> u64 { + use std::hash::{Hash, Hasher}; + + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + data.hash(&mut hasher); + hasher.finish() +} pub fn persist_pipeline_cache(adapter: &wgpu::Adapter) { use std::sync::atomic::Ordering; @@ -228,7 +236,8 @@ pub fn persist_pipeline_cache(adapter: &wgpu::Adapter) { return; }; let Some(data) = cache.get_data() else { return }; - if data.len() == PERSISTED_LEN.load(Ordering::Relaxed) { + let hash = blob_hash(&data); + if hash == PERSISTED.load(Ordering::Relaxed) { return; } let Some(path) = pipeline_cache_file(adapter) else { @@ -240,7 +249,7 @@ pub fn persist_pipeline_cache(adapter: &wgpu::Adapter) { // file so two saving at once cannot interleave into one. let tmp = path.with_extension(format!("{}.tmp", std::process::id())); if std::fs::write(&tmp, &data).is_ok() && std::fs::rename(&tmp, &path).is_ok() { - PERSISTED_LEN.store(data.len(), Ordering::Relaxed); + PERSISTED.store(hash, Ordering::Relaxed); } else { let _ = std::fs::remove_file(&tmp); } diff --git a/crates/kirie-render/src/frame_cost.rs b/crates/kirie-render/src/frame_cost.rs index b5006a4..b931c64 100644 --- a/crates/kirie-render/src/frame_cost.rs +++ b/crates/kirie-render/src/frame_cost.rs @@ -123,7 +123,7 @@ pub fn end_frame() { copies_skipped = cost.snapshots_skipped, "frame cost" ); - reset(); + clear(); *since = Some(std::time::Instant::now()); } @@ -143,6 +143,11 @@ pub struct FrameCost { /// Zero the counters and start counting, without the periodic log. pub fn reset() { ON.store(true, Ordering::Relaxed); + REPORTING.store(false, Ordering::Relaxed); + clear(); +} + +fn clear() { for counter in [ &RENDER_PASSES, &DRAWS, diff --git a/crates/kirie-script/src/world.rs b/crates/kirie-script/src/world.rs index 1899d82..c6ada8d 100644 --- a/crates/kirie-script/src/world.rs +++ b/crates/kirie-script/src/world.rs @@ -186,11 +186,21 @@ impl World { key: key_owned.clone(), message: e.to_string(), })?; - let (module, _promise) = module.eval().catch(&ctx).map_err(|e| ScriptError::Load { + let (module, promise) = module.eval().catch(&ctx).map_err(|e| ScriptError::Load { key: key_owned.clone(), message: e.to_string(), })?; drain_jobs(&ctx); + // A top level that ran out of budget, or never finished, left + // a module half set up: don't register it. One that threw is + // still registered, as before, since its hoisted exports work. + let unfinished = matches!(promise.state(), rquickjs::promise::PromiseState::Pending); + if unfinished || self.deadline.expired() { + return Err(ScriptError::Load { + key: key_owned.clone(), + message: "the module's top level did not finish within its time budget".to_owned(), + }); + } let namespace = module.namespace().internal()?; let register: Function = global(&ctx, "__registerModule")?; register diff --git a/crates/kirie-shader/src/lib.rs b/crates/kirie-shader/src/lib.rs index dd58692..31ca5fb 100644 --- a/crates/kirie-shader/src/lib.rs +++ b/crates/kirie-shader/src/lib.rs @@ -78,7 +78,14 @@ impl IncludeResolver for FsIncludeResolver { return None; } for root in &self.roots { - let path = root.join(include_name); + // A symlink inside a root must not carry the read back out of it. + let Ok(root) = root.canonicalize() else { continue }; + let Ok(path) = root.join(include_name).canonicalize() else { + continue; + }; + if !path.starts_with(&root) { + continue; + } if let Ok(text) = std::fs::read_to_string(&path) { return Some(text); } @@ -194,6 +201,21 @@ mod tests { assert!(resolver.resolve("").is_none()); } + #[cfg(unix)] + #[test] + fn a_symlink_in_a_root_does_not_lead_out_of_it() { + let base = std::env::temp_dir().join(format!("kirie-shader-include-{}", std::process::id())); + let root = base.join("root"); + std::fs::create_dir_all(&root).expect("scratch"); + std::fs::write(base.join("secret.h"), "secret").expect("scratch"); + std::fs::write(root.join("common.h"), "ok").expect("scratch"); + std::os::unix::fs::symlink(base.join("secret.h"), root.join("link.h")).expect("symlink"); + let resolver = FsIncludeResolver::new(vec![root]); + assert_eq!(resolver.resolve("common.h").as_deref(), Some("ok")); + assert!(resolver.resolve("link.h").is_none()); + let _ = std::fs::remove_dir_all(&base); + } + #[test] fn translate_minimal_fragment_with_sampler_and_uniform() { let src = "\ diff --git a/crates/kirie-video/src/decode.rs b/crates/kirie-video/src/decode.rs index 147b92a..8a00a05 100644 --- a/crates/kirie-video/src/decode.rs +++ b/crates/kirie-video/src/decode.rs @@ -56,12 +56,18 @@ pub const FRAME_QUEUE_CAP: usize = 4; const FALLBACK_FRAME_DUR: f64 = 1.0 / 30.0; -/// Twice 8K. Every frame is converted into a buffer of this size, so a file -/// claiming more is refused rather than trusted. +/// Twice 8K on either side. Every frame is converted into a buffer of its +/// size, so a file claiming more is refused rather than trusted. const MAX_DIMENSION: u32 = 16_384; +/// 8192 x 8192: a 256 MiB RGBA frame, with a decoder surface and a texture on +/// top. Wide panoramas fit under this; a 16K square does not. +const MAX_PIXELS: u64 = 8192 * 8192; + fn plausible_size(width: u32, height: u32) -> bool { - (1..=MAX_DIMENSION).contains(&width) && (1..=MAX_DIMENSION).contains(&height) + (1..=MAX_DIMENSION).contains(&width) + && (1..=MAX_DIMENSION).contains(&height) + && u64::from(width) * u64::from(height) <= MAX_PIXELS } #[derive(Debug)] diff --git a/crates/kirie/src/compat/desktop_ipc.rs b/crates/kirie/src/compat/desktop_ipc.rs index b418c13..5087890 100644 --- a/crates/kirie/src/compat/desktop_ipc.rs +++ b/crates/kirie/src/compat/desktop_ipc.rs @@ -188,7 +188,7 @@ pub fn serve_relaunching(socket: PathBuf, showing: Arc) { let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(5))); let mut reader = BufReader::new((&stream).take(MAX_REQUEST)); let mut line = String::new(); - if reader.read_line(&mut line).is_err() || line.is_empty() { + if reader.read_line(&mut line).is_err() || line.is_empty() || cut_short(&line) { continue; } @@ -269,6 +269,12 @@ where kept } +/// A line that filled [`MAX_REQUEST`] without its newline was cut off by the +/// cap, and acting on the part that arrived would act on a different request. +fn cut_short(line: &str) -> bool { + line.len() as u64 >= MAX_REQUEST && !line.ends_with('\n') +} + fn answer(stream: &UnixStream, orders: &Sender, showing: &Arc, args: &CompatArgs) { let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(5))); @@ -277,6 +283,11 @@ fn answer(stream: &UnixStream, orders: &Sender, showing: &Arc PathBuf { return PathBuf::from(runtime); } let dir = std::env::temp_dir().join(format!("kirie-{}", current_user_tag())); - if let Err(err) = make_private_dir(&dir) { - tracing::warn!(path = %dir.display(), %err, "the control-socket directory is not private"); + match make_private_dir(&dir) { + Ok(()) => dir, + Err(err) => { + // Someone else's directory could swap the socket out from under + // us. A private one of our own is safer, even though haru, which + // computes the shared name, will not find the socket there. + tracing::error!(path = %dir.display(), %err, "the control-socket directory is not private"); + fresh_private_dir().unwrap_or(dir) + } } - dir +} + +#[cfg(unix)] +fn fresh_private_dir() -> Option { + use std::os::unix::fs::DirBuilderExt; + + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |since| since.as_nanos()); + let dir = std::env::temp_dir().join(format!( + "kirie-{}-{}-{stamp}", + current_user_tag(), + std::process::id() + )); + std::fs::DirBuilder::new().mode(0o700).create(&dir).ok()?; + tracing::warn!(path = %dir.display(), "using a private control-socket directory of this process's own"); + Some(dir) } /// Make `dir` 0700, or check that the one already there is ours and make it @@ -44,6 +67,8 @@ fn make_private_dir(dir: &Path) -> std::io::Result<()> { "it belongs to another account, or is not a directory", )); } + // Only the owner may chmod, so this also proves ownership when no uid + // could be read. std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) } diff --git a/crates/kirie/src/pack.rs b/crates/kirie/src/pack.rs index d598005..0449347 100644 --- a/crates/kirie/src/pack.rs +++ b/crates/kirie/src/pack.rs @@ -30,9 +30,15 @@ pub fn run(dir: &Path, output: Option) -> Result<()> { return Err(err).with_context(|| format!("cannot pack {}", dir.display())); } }; - file.sync_all()?; + let synced = file.sync_all(); drop(file); - std::fs::rename(&partial, &output).with_context(|| format!("cannot write {}", output.display()))?; + let finished = synced + .and_then(|()| std::fs::rename(&partial, &output)) + .with_context(|| format!("cannot write {}", output.display())); + if finished.is_err() { + let _ = std::fs::remove_file(&partial); + } + finished?; println!( "{}: {} entries, {} bytes", output.display(), From f58faa7d1403ad01c80af256b0413e191153fde3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:31:42 +0000 Subject: [PATCH 29/33] Refuse an unverified control-socket directory instead of falling back to it When the shared per-user temp directory can't be made private and a fresh one can't be created either, runtime_dir now returns None; ask and workshop subscribe say so and suggest --socket, and the desktop hosts run without a control socket rather than bind one in a directory someone else controls. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LDKTCgMEZkLw7hFLpgLXNJ --- crates/kirie/src/compat/desktop_present.rs | 8 +++--- crates/kirie/src/lib.rs | 29 +++++++++++++--------- crates/kirie/src/os.rs | 25 +++++++++++-------- 3 files changed, 36 insertions(+), 26 deletions(-) diff --git a/crates/kirie/src/compat/desktop_present.rs b/crates/kirie/src/compat/desktop_present.rs index e2e68ec..b263c88 100644 --- a/crates/kirie/src/compat/desktop_present.rs +++ b/crates/kirie/src/compat/desktop_present.rs @@ -178,7 +178,9 @@ pub(crate) fn web_page( let source = kirie_web::page::PageSource::of(dir, file); let level = crate::compat::desktop_ipc::level_of(sound); let init = kirie_web::page::init_script(&crate::compat::common::web_props_json(dir, properties), level); - let data = crate::os::runtime_dir().join("webview2"); + let data = crate::os::runtime_dir() + .unwrap_or_else(std::env::temp_dir) + .join("webview2"); Box::new(move |window: isize, size: SurfaceSize| { let opened = kirie_web::webview2::DesktopPage::open( window, @@ -202,9 +204,7 @@ pub(crate) fn web_page( } fn control_socket(args: &CompatArgs) -> Option { - args.control_socket - .clone() - .or_else(|| Some(crate::default_control_socket())) + args.control_socket.clone().or_else(crate::default_control_socket) } fn background_of(args: &CompatArgs) -> Option { diff --git a/crates/kirie/src/lib.rs b/crates/kirie/src/lib.rs index 7195834..74648bc 100644 --- a/crates/kirie/src/lib.rs +++ b/crates/kirie/src/lib.rs @@ -194,16 +194,26 @@ pub fn run(args: Vec) -> ExitCode { } } -pub(crate) fn default_control_socket() -> PathBuf { - os::runtime_dir().join("lwe.sock") +/// `None` when there is no private directory to put it in; see +/// [`os::runtime_dir`]. +pub(crate) fn default_control_socket() -> Option { + os::runtime_dir().map(|dir| dir.join("lwe.sock")) +} + +fn control_socket_or_default(socket: Option) -> anyhow::Result { + socket + .or_else(default_control_socket) + .ok_or_else(|| anyhow::anyhow!("no private directory for the control socket; pass --socket")) } fn run_subcommand(args: Vec) -> ExitCode { kirie_bake::limit_malloc_arenas(2); let cli = Cli::parse_from(args); if let Command::Ask { socket, words } = &cli.command { - let path = socket.clone().unwrap_or_else(default_control_socket); - return match ask::run(&path, &words.join(" ")) { + let said = control_socket_or_default(socket.clone()) + .map_err(|err| err.to_string()) + .and_then(|path| ask::run(&path, &words.join(" "))); + return match said { Ok(said) => { print!("{said}"); ExitCode::SUCCESS @@ -295,13 +305,8 @@ fn run_subcommand(args: Vec) -> ExitCode { apply, socket, json, - } => workshop::run_subscribe( - &id, - wait, - apply.as_deref(), - &socket.unwrap_or_else(default_control_socket), - json, - ), + } => control_socket_or_default(socket) + .and_then(|socket| workshop::run_subscribe(&id, wait, apply.as_deref(), &socket, json)), WorkshopCommand::Unsubscribe { id, json } => workshop::run_unsubscribe(&id, json), WorkshopCommand::State { id, json } => workshop::run_state(&id, json), #[cfg(feature = "tui")] @@ -350,6 +355,6 @@ mod socket_tests { // two agreeing on the file name is what makes them able to talk at all. #[test] fn the_socket_is_named_the_same_on_every_platform() { - assert!(super::default_control_socket().ends_with("lwe.sock")); + assert!(super::default_control_socket().is_some_and(|path| path.ends_with("lwe.sock"))); } } diff --git a/crates/kirie/src/os.rs b/crates/kirie/src/os.rs index a590dcf..0af5b55 100644 --- a/crates/kirie/src/os.rs +++ b/crates/kirie/src/os.rs @@ -15,20 +15,22 @@ use std::path::{Path, PathBuf}; /// directory is shared between accounts, so a fixed name collides and the /// second user cannot even unlink the first user's socket under /tmp's sticky /// bit. Give every uid its own 0700 directory instead. +/// +/// `None` when no directory could be made private: one someone else controls +/// would let them swap the socket out from under us. #[cfg(unix)] -pub(crate) fn runtime_dir() -> PathBuf { +pub(crate) fn runtime_dir() -> Option { if let Some(runtime) = std::env::var_os("XDG_RUNTIME_DIR") { - return PathBuf::from(runtime); + return Some(PathBuf::from(runtime)); } let dir = std::env::temp_dir().join(format!("kirie-{}", current_user_tag())); match make_private_dir(&dir) { - Ok(()) => dir, + Ok(()) => Some(dir), Err(err) => { - // Someone else's directory could swap the socket out from under - // us. A private one of our own is safer, even though haru, which + // A private one of our own is safer, even though haru, which // computes the shared name, will not find the socket there. tracing::error!(path = %dir.display(), %err, "the control-socket directory is not private"); - fresh_private_dir().unwrap_or(dir) + fresh_private_dir() } } } @@ -45,7 +47,10 @@ fn fresh_private_dir() -> Option { current_user_tag(), std::process::id() )); - std::fs::DirBuilder::new().mode(0o700).create(&dir).ok()?; + if let Err(err) = std::fs::DirBuilder::new().mode(0o700).create(&dir) { + tracing::error!(path = %dir.display(), %err, "cannot make a private control-socket directory"); + return None; + } tracing::warn!(path = %dir.display(), "using a private control-socket directory of this process's own"); Some(dir) } @@ -77,13 +82,13 @@ fn make_private_dir(dir: &Path) -> std::io::Result<()> { /// `%LOCALAPPDATA%\kirie` is that directory; the temp fallback matches the Unix /// one for the case where the variable is missing. #[cfg(windows)] -pub(crate) fn runtime_dir() -> PathBuf { +pub(crate) fn runtime_dir() -> Option { let dir = match std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()) { Some(local) => PathBuf::from(local).join("kirie"), None => std::env::temp_dir().join(format!("kirie-{}", current_user_tag())), }; let _ = std::fs::create_dir_all(&dir); - dir + Some(dir) } /// Something that differs between accounts on this machine, for naming a @@ -161,7 +166,7 @@ mod tests { fn the_runtime_directory_is_per_user() { // Either it is the session's own runtime directory, or it is a name // nobody else's account would land on. - let dir = runtime_dir(); + let dir = runtime_dir().expect("a private runtime directory"); let tagged = dir.to_string_lossy().contains(¤t_user_tag()); let session_owned = std::env::var_os("XDG_RUNTIME_DIR").is_some() || std::env::var_os("LOCALAPPDATA").is_some(); From fec1882290e9f7ffa0c7b078b08b8e49cff3fb88 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 23:39:22 +0000 Subject: [PATCH 30/33] Take a plain picture or video file without Steam `kirie --bg sunset.jpg` from the picture's own folder was read as a Workshop id, because only a value with a slash counted as a path, and it ended with "Cannot find workshop directory". Only an all-digit value is a Workshop id now; anything else is a path. Pictures and videos never needed Wallpaper Engine's assets, so a user's own file now runs with no Steam installed at all. WebP pictures are accepted too (pure-Rust decoder in the image crate). README and docs/COMMANDS.md say how to use your own files. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QK5SC6GNiVZheXhbWzZtrq --- Cargo.lock | 17 ++++++++++ Cargo.toml | 2 +- README.md | 16 +++++++++ crates/kirie/src/compat/resolve.rs | 54 ++++++++++++++++++++++++++++-- docs/COMMANDS.md | 15 ++++++--- 5 files changed, 96 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ef80323..4c6ecaa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2124,6 +2124,7 @@ dependencies = [ "byteorder-lite", "color_quant", "gif", + "image-webp", "moxcms", "num-traits", "png", @@ -2131,6 +2132,16 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -3692,6 +3703,12 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.39.4" diff --git a/Cargo.toml b/Cargo.toml index 67fa711..7f6add4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ clap = { version = "4", features = ["derive"] } blake3 = "1" memmap2 = "0.9" libc = "0.2" -image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "bmp"] } +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "bmp", "webp"] } texture2ddecoder = "0.1" insta = "1" wgpu = "30" diff --git a/README.md b/README.md index 362f036..4b59957 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,22 @@ kirie check # can this machine render at all? kirie workshop browse # find more, and subscribe ``` +### Your own pictures and videos + +A picture or video of your own needs no Steam and no Wallpaper Engine: hand +kirie the file. + +```sh +kirie --screen-root HDMI-A-1 --bg ~/Pictures/sunset.jpg +kirie --screen-root HDMI-A-1 --bg ~/Videos/rain.mp4 --silent +``` + +Pictures: PNG, JPEG, WebP, BMP and GIF (animated GIFs play). Videos: MP4, +WebM, MKV, MOV, M4V and AVI, looped. [haru](https://github.com/UnhingedSoftware/haru) +does the same from its Library with **Add a picture or video**. + +### Workshop wallpapers + Anywhere a wallpaper is named you can give a Workshop ID instead of a path, and kirie looks it up across your Steam libraries: diff --git a/crates/kirie/src/compat/resolve.rs b/crates/kirie/src/compat/resolve.rs index 5559f81..f0042c8 100644 --- a/crates/kirie/src/compat/resolve.rs +++ b/crates/kirie/src/compat/resolve.rs @@ -31,8 +31,16 @@ fn looks_like_a_path(value: &str) -> bool { drive || value.starts_with('\\') } +/// Whether `--bg` names a Workshop item: every Workshop id is a number. Only +/// these are looked up in Steam's library; anything else, such as +/// `--bg sunset.jpg` from the folder the picture is in, is the user's own +/// file and needs neither Steam nor Wallpaper Engine. +fn is_workshop_id(value: &str) -> bool { + !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) +} + pub fn translate_background(value: &str) -> Result { - if looks_like_a_path(value) { + if looks_like_a_path(value) || !is_workshop_id(value) || Path::new(value).exists() { return Ok(value.to_owned()); } if steam::home_dir().is_none() && std::env::var_os("KIRIE_STEAM_LIBRARY").is_none() { @@ -177,7 +185,7 @@ pub fn we_assets_dir_or_warn() -> Option { } const VIDEO_EXTS: [&str; 6] = ["mp4", "webm", "mkv", "avi", "mov", "m4v"]; -const IMAGE_EXTS: [&str; 6] = ["png", "jpg", "jpeg", "bmp", "gif", "tex"]; +const IMAGE_EXTS: [&str; 7] = ["png", "jpg", "jpeg", "bmp", "gif", "webp", "tex"]; pub fn classify(background: &str) -> Result { let path = Path::new(background); @@ -321,7 +329,7 @@ pub enum ClassifyError { #[cfg(test)] mod path_shape_tests { - use super::looks_like_a_path; + use super::{Wallpaper, classify, is_workshop_id, looks_like_a_path, translate_background}; #[test] fn a_slash_means_a_path_on_every_platform() { @@ -335,6 +343,46 @@ mod path_shape_tests { assert!(!looks_like_a_path("1388331347")); } + #[test] + fn a_bare_file_name_is_the_users_own_file() { + // Only a number is ever looked up in Steam's library, so a picture + // named from its own folder runs without Steam installed at all. + assert!(!is_workshop_id("sunset.jpg")); + assert!(!is_workshop_id("")); + assert!(is_workshop_id("1388331347")); + assert_eq!( + translate_background("sunset.jpg").ok().as_deref(), + Some("sunset.jpg") + ); + assert_eq!( + translate_background("rain.webm").ok().as_deref(), + Some("rain.webm") + ); + } + + #[test] + fn pictures_and_videos_classify_by_extension() { + let dir = std::env::temp_dir().join(format!("kirie-own-files-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + for (name, video) in [ + ("a.WEBP", false), + ("b.jpeg", false), + ("c.MKV", true), + ("d.mov", true), + ] { + let file = dir.join(name); + let _ = std::fs::write(&file, b"x"); + let found = classify(&file.to_string_lossy()); + let expected = if video { + Wallpaper::Video { media: file.clone() } + } else { + Wallpaper::Image { file: file.clone() } + }; + assert_eq!(found.ok(), Some(expected), "{name}"); + } + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn windows_spellings_read_as_paths_on_windows() { // This is what haru puts in `--bg=` on Windows: `Path::display()` uses diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 1c4d720..223f414 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -53,16 +53,23 @@ anything else goes to the renderer. Anywhere kirie takes a wallpaper — `--bg`, the trailing positional argument, `kirie info`, `kirie check` — you can give either: -- a **Workshop ID**, looked up under every Steam library on the machine, or -- a **path** to the item directory (anything containing a `/` is taken as a path). +- a **Workshop ID**, looked up under every Steam library on the machine, +- a **path** to the item directory, or +- a **picture or video file** of your own. Pictures: `png`, `jpg`, `jpeg`, + `webp`, `bmp`, `gif` (and Wallpaper Engine's `tex`); videos: `mp4`, `webm`, + `mkv`, `mov`, `m4v`, `avi`. These need neither Steam nor Wallpaper Engine. + +Only a value made entirely of digits is treated as a Workshop ID; anything +else, including a bare file name such as `sunset.jpg`, is a path. ```sh kirie --screen-root HDMI-A-1 --bg 1388331347 kirie --screen-root HDMI-A-1 --bg ~/.steam/steam/steamapps/workshop/content/431960/1388331347 +kirie --screen-root HDMI-A-1 --bg ~/Pictures/sunset.jpg ``` -A bare ID with no `/` that matches no installed item fails with `Cannot find -workshop directory for steam app 431960 and content `. +An ID that matches no installed item fails with `Cannot find workshop +directory for steam app 431960 and content `. ## Subcommands From d7e69427875aca8ebd6f5d2b6275f9b5ba93fea4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 23:45:51 +0000 Subject: [PATCH 31/33] feat(video): decode on the GPU on Windows and keep NV12 off the CPU Video wallpapers on Windows always decoded on the CPU: the only hardware path kirie had was VAAPI, behind a Linux-only cargo feature the release never turned on, so Windows never asked ffmpeg for a hardware device. kirie-video now tries D3D11VA and then DXVA2 on Windows. Both are already in the ffmpeg the release builds from vcpkg (its portfile passes --enable-d3d11va --enable-dxva2), and both reach NVDEC, Quick Sync or VCN through the driver with no vendor SDK. Linux keeps VAAPI behind the `vaapi` feature. A stream the GPU will not take falls back to the CPU, and the log says which happened. KIRIE_NO_HWDEC turns it off. Decoded frames still come back to system memory as NV12, but video wallpapers now hand that NV12 to the GPU and convert it in the shader (BT.601 or BT.709, limited or full range, from the stream's tags) rather than running swscale to RGBA on the CPU and uploading 2.7x the bytes. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014cUJnXKBJAR7yG4MTtz7jZ --- crates/kirie-video/src/decode.rs | 75 +++++++---- crates/kirie-video/src/hw.rs | 134 ++++++++++++++++---- crates/kirie-video/src/lib.rs | 1 - crates/kirie-video/src/renderer.rs | 171 +++++++++++++++++++++----- crates/kirie/src/compat/run.rs | 2 +- crates/kirie/src/compat/screenshot.rs | 3 +- docs/COMMANDS.md | 1 + 7 files changed, 304 insertions(+), 83 deletions(-) diff --git a/crates/kirie-video/src/decode.rs b/crates/kirie-video/src/decode.rs index 00eefd4..5b0d446 100644 --- a/crates/kirie-video/src/decode.rs +++ b/crates/kirie-video/src/decode.rs @@ -18,11 +18,9 @@ fn tell_scaler_the_colours( }; let table = match space { - Space::BT709 => SWS_CS_ITU709, - Space::BT470BG | Space::SMPTE170M => SWS_CS_ITU601, Space::SMPTE240M => SWS_CS_SMPTE240M, - _ if height >= 720 => SWS_CS_ITU709, - _ => SWS_CS_ITU601, + _ if is_bt601(space, height) => SWS_CS_ITU601, + _ => SWS_CS_ITU709, }; let full = i32::from(range == Range::JPEG); @@ -49,6 +47,17 @@ fn tell_scaler_the_colours( } } +/// Whether a frame's YUV uses the BT.601 matrix rather than BT.709. A stream +/// that does not say is taken to be 709 from 720 lines up, as players do. +fn is_bt601(space: ffmpeg::color::Space, height: u32) -> bool { + use ffmpeg::color::Space; + match space { + Space::BT470BG | Space::SMPTE170M => true, + Space::BT709 | Space::SMPTE240M => false, + _ => height < 720, + } +} + use crate::error::VideoError; use crate::pacing::{LoopTimeline, Timed}; @@ -62,6 +71,10 @@ pub struct DecodedFrame { pub width: u32, pub height: u32, pub pixels: FramePixels, + /// For `Nv12`: the frame's YUV uses the BT.601 matrix, not BT.709. + pub bt601: bool, + /// For `Nv12`: luma spans 0-255 rather than the usual 16-235. + pub full_range: bool, pub data: Vec, } @@ -100,24 +113,23 @@ pub(crate) struct Decoder { unconvertible: u64, frame_dur: f64, synth_pts: f64, + hw: Option, } struct Converter { nv12: bool, scaler: Option, rgb: ffmpeg::frame::Video, - #[cfg(feature = "vaapi")] hw: crate::hw::HwDownload, } impl Converter { - fn new(nv12: bool) -> Self { + fn new(nv12: bool, hw: Option) -> Self { Self { nv12, scaler: None, rgb: ffmpeg::frame::Video::empty(), - #[cfg(feature = "vaapi")] - hw: crate::hw::HwDownload::new(), + hw: crate::hw::HwDownload::new(hw), } } } @@ -138,7 +150,7 @@ impl Decoder { stream.start_time() as f64 * time_base }; let frame_rate = f64::from(stream.avg_frame_rate()).max(0.0); - let decoder = open_video_decoder(&stream)?; + let (decoder, hw) = open_video_decoder(&stream)?; let (width, height) = (decoder.width(), decoder.height()); if width == 0 || height == 0 { return Err(VideoError::InvalidDimensions { width, height }); @@ -174,6 +186,7 @@ impl Decoder { unconvertible: 0, frame_dur, synth_pts: 0.0, + hw, }) } @@ -182,7 +195,7 @@ impl Decoder { } pub fn run(mut self, frames: &Sender, recycle: &Receiver>) { - let mut converter = Converter::new(self.want_nv12); + let mut converter = Converter::new(self.want_nv12, self.hw); let mut consecutive_read_errors = 0u32; loop { loop { @@ -262,13 +275,10 @@ impl Decoder { converter: &mut Converter, recycle: &Receiver>, ) -> Result { - #[cfg(feature = "vaapi")] let decoded = match converter.hw.download(&self.decoded)? { Some(sw) => sw, None => &self.decoded, }; - #[cfg(not(feature = "vaapi"))] - let decoded = &self.decoded; let (width, height) = (decoded.width(), decoded.height()); if width == 0 || height == 0 { @@ -297,6 +307,8 @@ impl Decoder { width, height, pixels: FramePixels::Nv12, + bt601: is_bt601(decoded.color_space(), height), + full_range: decoded.color_range() == ffmpeg::color::Range::JPEG, data, }); } @@ -357,6 +369,8 @@ impl Decoder { width, height, pixels: FramePixels::Rgba, + bt601: false, + full_range: false, data, }) } @@ -378,30 +392,37 @@ fn copy_nv12(frame: &ffmpeg::frame::Video, buf: &mut Vec) { } } +/// Opens the stream's decoder on the first hardware backend that will take +/// it, or on the CPU when none will. fn open_video_decoder( stream: &ffmpeg::format::stream::Stream<'_>, -) -> Result { - #[cfg(feature = "vaapi")] - { +) -> Result<(ffmpeg::decoder::Video, Option), VideoError> { + for &backend in crate::hw::backends() { let mut context = ffmpeg::codec::context::Context::from_parameters(stream.parameters())?; - match crate::hw::attach_vaapi(&mut context) { - Ok(()) => match context.decoder().video() { + match crate::hw::attach(&mut context, backend) { + Ok(format) => match context.decoder().video() { Ok(decoder) => { - tracing::info!("VAAPI device attached; hardware decode enabled for supported profiles"); - return Ok(decoder); + tracing::info!(backend = backend.name, "hardware video decoder opened"); + return Ok(( + decoder, + Some(crate::hw::Attached { + name: backend.name, + format, + }), + )); } Err(err) => { - tracing::info!(%err, "VAAPI decoder open failed; falling back to CPU decode"); + tracing::info!(backend = backend.name, %err, "hardware video decoder failed to open") } }, - Err(err) => tracing::info!(%err, "VAAPI unavailable; using CPU decode"), + Err(err) => tracing::info!(%err, "hardware video decode unavailable"), } } - Ok( - ffmpeg::codec::context::Context::from_parameters(stream.parameters())? - .decoder() - .video()?, - ) + let decoder = ffmpeg::codec::context::Context::from_parameters(stream.parameters())? + .decoder() + .video()?; + tracing::info!("decoding video on the CPU"); + Ok((decoder, None)) } fn copy_rgba(rgb: &ffmpeg::frame::Video, buf: &mut Vec) { diff --git a/crates/kirie-video/src/hw.rs b/crates/kirie-video/src/hw.rs index 716117b..3b5b3df 100644 --- a/crates/kirie-video/src/hw.rs +++ b/crates/kirie-video/src/hw.rs @@ -1,3 +1,12 @@ +//! Hardware video decode: which ffmpeg device each platform asks for, and the +//! copy back to system memory that the rest of the pipeline expects. +//! +//! Windows always tries D3D11VA and then DXVA2. Both are in the ffmpeg the +//! release build gets from vcpkg, and both reach whichever GPU drives the +//! screen through its own video engine (NVDEC, Quick Sync, VCN) without any +//! vendor SDK. Linux asks for VAAPI when built with the `vaapi` feature. +//! Anywhere else, or when nothing here works for a stream, frames are decoded +//! on the CPU as before. `KIRIE_NO_HWDEC` turns this off entirely. #![allow(unsafe_code)] use ffmpeg_next as ffmpeg; @@ -8,17 +17,62 @@ use ffmpeg_next::format::Pixel; pub(crate) enum HwAttachError { #[error("no decoder for {0:?}")] DecoderNotFound(ffmpeg::codec::Id), - #[error("codec {0} has no VAAPI hw-device support")] - Unsupported(String), - #[error("VAAPI device creation failed: {0}")] - Device(ffmpeg::Error), + #[error("codec {codec} has no {backend} support")] + Unsupported { codec: String, backend: &'static str }, + #[error("{backend} device creation failed: {err}")] + Device { + backend: &'static str, + err: ffmpeg::Error, + }, } -pub(crate) fn attach_vaapi(ctx: &mut ffmpeg::codec::context::Context) -> Result<(), HwAttachError> { +#[derive(Clone, Copy, Debug)] +pub(crate) struct Backend { + pub(crate) name: &'static str, + device: ffi::AVHWDeviceType, +} + +#[cfg(windows)] +const BACKENDS: &[Backend] = &[ + Backend { + name: "D3D11VA", + device: ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA, + }, + Backend { + name: "DXVA2", + device: ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_DXVA2, + }, +]; + +#[cfg(all(not(windows), feature = "vaapi"))] +const BACKENDS: &[Backend] = &[Backend { + name: "VAAPI", + device: ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI, +}]; + +#[cfg(all(not(windows), not(feature = "vaapi")))] +const BACKENDS: &[Backend] = &[]; + +/// The hardware decoders worth trying on this platform, best first. +pub(crate) fn backends() -> &'static [Backend] { + if std::env::var_os("KIRIE_NO_HWDEC").is_some() { + return &[]; + } + BACKENDS +} + +/// Gives `ctx` a hardware device of `backend`'s type, and answers the pixel +/// format its decoded frames will carry. libavcodec's default `get_format` +/// then picks that format whenever the stream's profile is one the hardware +/// takes, and falls back to a software format by itself when it is not. +pub(crate) fn attach( + ctx: &mut ffmpeg::codec::context::Context, + backend: Backend, +) -> Result { let id = ctx.id(); let codec = ffmpeg::codec::decoder::find(id).ok_or(HwAttachError::DecoderNotFound(id))?; - let mut supported = false; + let mut format = None; for index in 0.. { // SAFETY: `codec.as_ptr()` is the valid, program-lifetime AVCodec // `find` returned -- ffmpeg's codec descriptors are static and are @@ -29,34 +83,41 @@ pub(crate) fn attach_vaapi(ctx: &mut ffmpeg::codec::context::Context) -> Result< // which is read and dropped inside this iteration. let config = unsafe { ffi::avcodec_get_hw_config(codec.as_ptr(), index).as_ref() }; let Some(config) = config else { break }; - if config.device_type == ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI + if config.device_type == backend.device && config.methods & (ffi::AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX as i32) != 0 { - supported = true; + format = Some(Pixel::from(config.pix_fmt)); break; } } - if !supported { - return Err(HwAttachError::Unsupported(codec.name().to_owned())); - } + let Some(format) = format else { + return Err(HwAttachError::Unsupported { + codec: codec.name().to_owned(), + backend: backend.name, + }); + }; let mut device: *mut ffi::AVBufferRef = std::ptr::null_mut(); // SAFETY: `&mut device` is a valid out-pointer; NULL device path + NULL - // options ask ffmpeg to open the default VAAPI device, which is what - // this function is for. On success `device` owns one reference to a + // options ask ffmpeg to open the default device of this type (for D3D11VA + // and DXVA2 that is adapter 0, the GPU driving the main screen), which is + // what this function is for. On success `device` owns one reference to a // fresh AVBufferRef; on failure ffmpeg leaves it null and the error is // returned below before anything reads it. let ret = unsafe { ffi::av_hwdevice_ctx_create( &mut device, - ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI, + backend.device, std::ptr::null(), std::ptr::null_mut(), 0, ) }; if ret < 0 { - return Err(HwAttachError::Device(ffmpeg::Error::from(ret))); + return Err(HwAttachError::Device { + backend: backend.name, + err: ffmpeg::Error::from(ret), + }); } // SAFETY: `ctx` wraps a live, not-yet-opened AVCodecContext (owned by @@ -64,37 +125,62 @@ pub(crate) fn attach_vaapi(ctx: &mut ffmpeg::codec::context::Context) -> Result< // aliases nothing. `hw_device_ctx` is documented as being set by the // caller and owned and freed by libavcodec afterwards, so handing the // reference over rather than taking another one is the contract, and - // the field was null until now -- `attach_vaapi` runs once per context, - // before it is opened -- so nothing is overwritten and leaked. + // the field was null until now -- the caller makes a fresh context for + // every backend it tries and attaches to it once, before it is opened -- + // so nothing is overwritten and leaked. unsafe { (*ctx.as_mut_ptr()).hw_device_ctx = device; } - Ok(()) + Ok(format) +} + +/// The hardware a decoder was opened with, for `HwDownload`. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Attached { + pub(crate) name: &'static str, + pub(crate) format: Pixel, } pub(crate) struct HwDownload { + attached: Option, frame: ffmpeg::frame::Video, announced: bool, } impl HwDownload { - pub(crate) fn new() -> Self { + pub(crate) fn new(attached: Option) -> Self { Self { + attached, frame: ffmpeg::frame::Video::empty(), announced: false, } } + /// Copies a frame that lives on the GPU into system memory (NV12 for + /// 8-bit video, P010 for 10-bit) and answers it; answers `None` for a + /// frame the CPU decoded, which needs no copy. pub(crate) fn download( &mut self, src: &ffmpeg::frame::Video, ) -> Result, ffmpeg::Error> { - if src.format() != Pixel::VAAPI { + let Some(attached) = self.attached else { return Ok(None); - } + }; + let on_gpu = src.format() == attached.format; if !self.announced { - tracing::info!("VAAPI hardware decode active"); self.announced = true; + if on_gpu { + tracing::info!(backend = attached.name, "hardware video decode active"); + } else { + tracing::info!( + backend = attached.name, + format = ?src.format(), + "the GPU's decoder does not take this video's profile; decoding on the CPU" + ); + } + } + if !on_gpu { + return Ok(None); } if self.frame.width() != src.width() || self.frame.height() != src.height() { @@ -120,6 +206,10 @@ impl HwDownload { if ret < 0 { return Err(ffmpeg::Error::from(ret)); } + // The transfer copies pixels only; the scaler and the NV12 path read + // colour details off the frame they are handed. + self.frame.set_color_space(src.color_space()); + self.frame.set_color_range(src.color_range()); Ok(Some(&self.frame)) } } diff --git a/crates/kirie-video/src/lib.rs b/crates/kirie-video/src/lib.rs index 8f07349..adabaf5 100644 --- a/crates/kirie-video/src/lib.rs +++ b/crates/kirie-video/src/lib.rs @@ -4,7 +4,6 @@ mod audio; mod clock; mod decode; mod error; -#[cfg(feature = "vaapi")] mod hw; mod pacing; mod player; diff --git a/crates/kirie-video/src/renderer.rs b/crates/kirie-video/src/renderer.rs index 5fd9b4b..6f66ded 100644 --- a/crates/kirie-video/src/renderer.rs +++ b/crates/kirie-video/src/renderer.rs @@ -5,7 +5,7 @@ use kirie_platform::{RenderTarget, Renderer, SurfaceSize}; use crate::audio::AudioLink; use crate::clock::{WallClock, audio_position}; -use crate::decode::DecodedFrame; +use crate::decode::{DecodedFrame, FramePixels}; use crate::pacing::Pacer; use crate::player::{RendererCmd, VideoPlayer}; use crate::scaling::{ScalingMode, UvRect, compute_uvs}; @@ -15,11 +15,16 @@ const STATS_INTERVAL: Duration = Duration::from_secs(2); const SHADER: &str = r#" struct Uniforms { rect: vec4, + // x: 1 when the frame is NV12 (frame_tex is luma, chroma_tex is CbCr), + // y: 1 for BT.601 rather than BT.709, z: 1 for full-range YUV, + // w: 1 when the target is sRGB and the result has to be linearised. + yuv: vec4, } @group(0) @binding(0) var u: Uniforms; @group(0) @binding(1) var frame_tex: texture_2d; @group(0) @binding(2) var frame_samp: sampler; +@group(0) @binding(3) var chroma_tex: texture_2d; struct VsOut { @builtin(position) pos: vec4, @@ -46,8 +51,31 @@ fn vs_main(@builtin(vertex_index) index: u32) -> VsOut { fn fs_main(in: VsOut) -> @location(0) vec4 { let uv = mix(u.rect.xy, u.rect.zw, in.uv); let inside = step(0.0, uv.x) * step(uv.x, 1.0) * step(0.0, uv.y) * step(uv.y, 1.0); - let color = textureSample(frame_tex, frame_samp, clamp(uv, vec2(0.0), vec2(1.0))); - return vec4(color.rgb * inside, 1.0); + let at = clamp(uv, vec2(0.0), vec2(1.0)); + var color = textureSample(frame_tex, frame_samp, at).rgb; + let chroma = textureSample(chroma_tex, frame_samp, at).rg - vec2(0.5); + if (u.yuv.x > 0.5) { + var y = color.r; + var c = chroma; + if (u.yuv.z < 0.5) { + y = (y - 16.0 / 255.0) * (255.0 / 219.0); + c = c * (255.0 / 224.0); + } + if (u.yuv.y > 0.5) { + color = vec3(y + 1.402 * c.y, y - 0.344136 * c.x - 0.714136 * c.y, y + 1.772 * c.x); + } else { + color = vec3(y + 1.5748 * c.y, y - 0.187324 * c.x - 0.468124 * c.y, y + 1.8556 * c.x); + } + color = clamp(color, vec3(0.0), vec3(1.0)); + if (u.yuv.w > 0.5) { + color = select( + pow((color + 0.055) / 1.055, vec3(2.4)), + color / 12.92, + color <= vec3(0.04045), + ); + } + } + return vec4(color * inside, 1.0); } "#; @@ -55,13 +83,19 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] struct Uniforms { rect: [f32; 4], + yuv: [f32; 4], } +/// What the frame textures hold: RGBA in `main`, or NV12's luma in `main` +/// and its half-size CbCr plane in `chroma`. RGBA binds a 1x1 stand-in as +/// `chroma`, which the shader never reads from. struct FrameTexture { - texture: wgpu::Texture, + main: wgpu::Texture, + chroma: wgpu::Texture, bind_group: wgpu::BindGroup, width: u32, height: u32, + pixels: FramePixels, } pub struct VideoRenderer { @@ -73,7 +107,8 @@ pub struct VideoRenderer { uniforms: wgpu::Buffer, texture_format: wgpu::TextureFormat, frame_tex: Option, - uv_key: Option<(u32, u32, u32, u32, ScalingMode)>, + uv_key: Option<(u32, u32, u32, u32, ScalingMode, [u32; 4])>, + yuv: [f32; 4], frames_rx: Receiver, recycle_tx: Sender>, @@ -136,6 +171,16 @@ impl VideoRenderer { ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, ], }); @@ -201,6 +246,7 @@ impl VideoRenderer { texture_format, frame_tex: None, uv_key: None, + yuv: [0.0; 4], frames_rx: parts.frames_rx, recycle_tx: parts.recycle_tx, commands_rx: parts.commands_rx, @@ -239,16 +285,9 @@ impl VideoRenderer { } } - fn ensure_texture(&mut self, width: u32, height: u32) { - if self - .frame_tex - .as_ref() - .is_some_and(|t| t.width == width && t.height == height) - { - return; - } - let texture = self.device.create_texture(&wgpu::TextureDescriptor { - label: Some("kirie-video-frame"), + fn plane(&self, label: &str, width: u32, height: u32, format: wgpu::TextureFormat) -> wgpu::Texture { + self.device.create_texture(&wgpu::TextureDescriptor { + label: Some(label), size: wgpu::Extent3d { width, height, @@ -257,11 +296,37 @@ impl VideoRenderer { mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: self.texture_format, + format, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, view_formats: &[], - }); - let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + }) + } + + fn ensure_texture(&mut self, width: u32, height: u32, pixels: FramePixels) { + if self + .frame_tex + .as_ref() + .is_some_and(|t| t.width == width && t.height == height && t.pixels == pixels) + { + return; + } + let (main, chroma) = match pixels { + FramePixels::Rgba => ( + self.plane("kirie-video-frame", width, height, self.texture_format), + self.plane("kirie-video-no-chroma", 1, 1, wgpu::TextureFormat::Rg8Unorm), + ), + FramePixels::Nv12 => ( + self.plane("kirie-video-luma", width, height, wgpu::TextureFormat::R8Unorm), + self.plane( + "kirie-video-chroma", + width / 2, + height / 2, + wgpu::TextureFormat::Rg8Unorm, + ), + ), + }; + let main_view = main.create_view(&wgpu::TextureViewDescriptor::default()); + let chroma_view = chroma.create_view(&wgpu::TextureViewDescriptor::default()); let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("kirie-video-bg"), layout: &self.bind_group_layout, @@ -272,51 +337,94 @@ impl VideoRenderer { }, wgpu::BindGroupEntry { binding: 1, - resource: wgpu::BindingResource::TextureView(&view), + resource: wgpu::BindingResource::TextureView(&main_view), }, wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.sampler), }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(&chroma_view), + }, ], }); - tracing::info!(width, height, "video frame texture (re)created"); + tracing::info!(width, height, ?pixels, "video frame texture (re)created"); self.frame_tex = Some(FrameTexture { - texture, + main, + chroma, bind_group, width, height, + pixels, }); } - fn upload(&mut self, frame: DecodedFrame) { - self.ensure_texture(frame.width, frame.height); - let Some(tex) = &self.frame_tex else { return }; + fn write_plane( + &self, + texture: &wgpu::Texture, + data: &[u8], + width: u32, + height: u32, + bytes_per_texel: u32, + ) { self.queue.write_texture( wgpu::TexelCopyTextureInfo { - texture: &tex.texture, + texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, - &frame.data, + data, wgpu::TexelCopyBufferLayout { offset: 0, - bytes_per_row: Some(frame.width * 4), - rows_per_image: Some(frame.height), + bytes_per_row: Some(width * bytes_per_texel), + rows_per_image: Some(height), }, wgpu::Extent3d { - width: frame.width, - height: frame.height, + width, + height, depth_or_array_layers: 1, }, ); + } + + fn upload(&mut self, frame: DecodedFrame) { + let (width, height) = (frame.width, frame.height); + self.ensure_texture(width, height, frame.pixels); + let Some(tex) = &self.frame_tex else { return }; + match frame.pixels { + FramePixels::Rgba => { + self.write_plane(&tex.main, &frame.data, width, height, 4); + self.yuv = [0.0; 4]; + } + FramePixels::Nv12 => { + let luma = (width * height) as usize; + let (y, cbcr) = frame.data.split_at(luma.min(frame.data.len())); + self.write_plane(&tex.main, y, width, height, 1); + self.write_plane(&tex.chroma, cbcr, width / 2, height / 2, 2); + let flag = |on: bool| if on { 1.0 } else { 0.0 }; + self.yuv = [ + 1.0, + flag(frame.bt601), + flag(frame.full_range), + flag(self.texture_format.is_srgb()), + ]; + } + } let _ = self.recycle_tx.try_send(frame.data); } fn update_uvs(&mut self, size: SurfaceSize) { let Some(tex) = &self.frame_tex else { return }; - let key = (size.width, size.height, tex.width, tex.height, self.scaling); + let key = ( + size.width, + size.height, + tex.width, + tex.height, + self.scaling, + self.yuv.map(f32::to_bits), + ); if self.uv_key == Some(key) { return; } @@ -332,6 +440,7 @@ impl VideoRenderer { 0, bytemuck::bytes_of(&Uniforms { rect: [ustart, vstart, uend, vend], + yuv: self.yuv, }), ); } diff --git a/crates/kirie/src/compat/run.rs b/crates/kirie/src/compat/run.rs index dc452c5..205d6fd 100644 --- a/crates/kirie/src/compat/run.rs +++ b/crates/kirie/src/compat/run.rs @@ -940,7 +940,7 @@ fn build_for_spec( silent, paused: false, scaling: to_video_scaling(*scaling), - nv12: false, + nv12: true, enable_audio: true, }; match VideoPlayer::open(media, options) { diff --git a/crates/kirie/src/compat/screenshot.rs b/crates/kirie/src/compat/screenshot.rs index 86d2ff3..8bf55ad 100644 --- a/crates/kirie/src/compat/screenshot.rs +++ b/crates/kirie/src/compat/screenshot.rs @@ -312,7 +312,7 @@ pub(crate) fn build_presented_renderer( silent: sound.silent, paused: false, scaling: super::common::to_video_scaling(scaling), - nv12: false, + nv12: true, enable_audio: true, }; let (player, _control) = VideoPlayer::open(media, options) @@ -348,6 +348,7 @@ pub(crate) fn build_offscreen_renderer( let options = VideoOptions { scaling: super::common::to_video_scaling(scaling), enable_audio: false, + nv12: true, ..VideoOptions::default() }; let (player, _control) = VideoPlayer::open(media, options) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 1c4d720..e870b1a 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -672,6 +672,7 @@ kirie ask workshop subscribe 1388331347 | `KIRIE_SHADER_DUMP`, `KIRIE_SHADER_DUMP_ALL` | Write translated shaders to disk | | `KIRIE_WEB_CONSOLE` | Forward web-wallpaper console output to the log | | `KIRIE_NO_LAYERED_HOST` | Windows 11 24H2 and later: draw straight into the desktop instead of inside a layered window under the icons | +| `KIRIE_NO_HWDEC` | Decode video wallpapers on the CPU instead of the GPU's video decoder (D3D11VA or DXVA2 on Windows, VAAPI on Linux builds with the `vaapi` feature) | | `WGPU_BACKEND` | Windows: limit the GPU backends kirie tries, e.g. `dx12` or `vulkan` | | `KIRIE_CORPUS` | Wallpaper corpus directory used by the tests | | `RUST_LOG` | Tracing filter, e.g. `RUST_LOG=debug` | From ec9a6a59b131e0aff00adbb94617a2f5a2e4c77d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 23:49:26 +0000 Subject: [PATCH 32/33] Prebake pictures to each screen's size The image renderer uploads a picture whole, with no mipmaps, and the GPU shrinks it with a linear filter: a 6000-pixel photo on a 1280-pixel screen cost the full texture's memory and drew fine detail as moire. A picture bigger than the screen is now drawn from a copy resized once with Lanczos3 to just cover that screen at the picture's own aspect ratio, so every scaling mode frames it as before. Copies are JPEG q92 (PNG when the picture is see-through), EXIF-upright, kept in /kirie/pictures under a key of path, size, mtime and screen size, one per screen size, pruned to the 512 MB most recently used. kirie bakes on first use and records each screen's size in /kirie/screens.json. `kirie prebake [--size WxH]...` bakes ahead of time for those screens or the sizes given. GIFs, .tex and pictures no bigger than the screen are drawn as they are. KIRIE_NO_PICTURE_BAKE turns it off. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QK5SC6GNiVZheXhbWzZtrq --- Cargo.lock | 1 + README.md | 6 + crates/kirie-platform/src/gpu.rs | 6 +- crates/kirie-platform/src/lib.rs | 3 +- crates/kirie/Cargo.toml | 1 + crates/kirie/src/compat/run.rs | 41 +- crates/kirie/src/compat/screenshot.rs | 5 +- crates/kirie/src/lib.rs | 18 + crates/kirie/src/prebake.rs | 518 ++++++++++++++++++++++++++ docs/COMMANDS.md | 40 ++ 10 files changed, 615 insertions(+), 24 deletions(-) create mode 100644 crates/kirie/src/prebake.rs diff --git a/Cargo.lock b/Cargo.lock index 4c6ecaa..764d333 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2337,6 +2337,7 @@ name = "kirie" version = "0.1.0" dependencies = [ "anyhow", + "blake3", "clap", "crossbeam-channel", "ctrlc", diff --git a/README.md b/README.md index 4b59957..3f90d9d 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,12 @@ Pictures: PNG, JPEG, WebP, BMP and GIF (animated GIFs play). Videos: MP4, WebM, MKV, MOV, M4V and AVI, looped. [haru](https://github.com/UnhingedSoftware/haru) does the same from its Library with **Add a picture or video**. +A picture bigger than the screen is drawn from a copy resized once, with a +good filter, to that screen's size (one per screen size, kept in kirie's +cache), which looks sharper and uses less video memory than letting the GPU +shrink it. `kirie prebake ~/Pictures/Wallpapers` makes those copies in +advance. + ### Workshop wallpapers Anywhere a wallpaper is named you can give a Workshop ID instead of a path, and diff --git a/crates/kirie-platform/src/gpu.rs b/crates/kirie-platform/src/gpu.rs index 593869e..273a6c8 100644 --- a/crates/kirie-platform/src/gpu.rs +++ b/crates/kirie-platform/src/gpu.rs @@ -161,14 +161,16 @@ fn pipeline_cache_file(adapter: &wgpu::Adapter) -> Option { /// Windows sets neither `XDG_CACHE_HOME` nor `HOME`, so asking only for those /// found nothing and the pipeline cache was never written or read there. #[cfg(windows)] -fn cache_home() -> Option { +#[must_use] +pub fn cache_home() -> Option { std::env::var_os("LOCALAPPDATA") .filter(|value| !value.is_empty()) .map(std::path::PathBuf::from) } #[cfg(unix)] -fn cache_home() -> Option { +#[must_use] +pub fn cache_home() -> Option { std::env::var_os("XDG_CACHE_HOME") .filter(|value| !value.is_empty()) .map(std::path::PathBuf::from) diff --git a/crates/kirie-platform/src/lib.rs b/crates/kirie-platform/src/lib.rs index b37acb8..206578e 100644 --- a/crates/kirie-platform/src/lib.rs +++ b/crates/kirie-platform/src/lib.rs @@ -37,7 +37,8 @@ mod x11; pub use backend::{Backend, Platform, PresentOptions}; pub use error::PlatformError; pub use gpu::{ - attach_pipeline_cache, persist_pipeline_cache, pipeline_cache, pipeline_cache_feature, power_preference, + attach_pipeline_cache, cache_home, persist_pipeline_cache, pipeline_cache, pipeline_cache_feature, + power_preference, }; #[cfg(target_os = "linux")] pub use renderer::CommandSender; diff --git a/crates/kirie/Cargo.toml b/crates/kirie/Cargo.toml index b5a3f79..8035b49 100644 --- a/crates/kirie/Cargo.toml +++ b/crates/kirie/Cargo.toml @@ -49,6 +49,7 @@ anyhow = { workspace = true } thiserror = { workspace = true } serde_json = { workspace = true } image = { workspace = true } +blake3 = { workspace = true } texture2ddecoder = { workspace = true } wgpu = { workspace = true } pollster = { workspace = true } diff --git a/crates/kirie/src/compat/run.rs b/crates/kirie/src/compat/run.rs index 0e6d7ca..d0a648d 100644 --- a/crates/kirie/src/compat/run.rs +++ b/crates/kirie/src/compat/run.rs @@ -931,6 +931,7 @@ fn build_for_spec( } } let _trim = TrimOnExit; + crate::prebake::remember_screen(target.output_name, target.size); let saved = spec_background(spec).map(|bg| super::saved_props::with_saved(bg, properties)); let properties: &[(String, String)] = saved.as_deref().unwrap_or(properties); match spec { @@ -971,28 +972,30 @@ fn build_for_spec( } } } - RunSpec::Image { file, scaling, clamp } => match ImageContent::from_path(file) { - Ok(content) => { - let options = ImageOptions { - scaling: to_render_scaling(*scaling), - clamp: to_render_clamp(*clamp), - }; - match ImageRenderer::new(target, &content, options) { - Ok(renderer) => { - tracing::info!(output = %target.output_name, "image wallpaper ready"); - Box::new(renderer) - } - Err(err) => { - eprintln!("{}: failed to build image renderer: {err}", target.output_name); - black(target) + RunSpec::Image { file, scaling, clamp } => { + match ImageContent::from_path(&crate::prebake::for_screen(file, target.size)) { + Ok(content) => { + let options = ImageOptions { + scaling: to_render_scaling(*scaling), + clamp: to_render_clamp(*clamp), + }; + match ImageRenderer::new(target, &content, options) { + Ok(renderer) => { + tracing::info!(output = %target.output_name, "image wallpaper ready"); + Box::new(renderer) + } + Err(err) => { + eprintln!("{}: failed to build image renderer: {err}", target.output_name); + black(target) + } } } + Err(err) => { + eprintln!("{}: failed to load image: {err}", target.output_name); + black(target) + } } - Err(err) => { - eprintln!("{}: failed to load image: {err}", target.output_name); - black(target) - } - }, + } RunSpec::Scene { dir, scaling, clamp } => { let options = kirie_render::SceneOptions { render_scale: render_scale(), diff --git a/crates/kirie/src/compat/screenshot.rs b/crates/kirie/src/compat/screenshot.rs index 8bf55ad..b01c9ed 100644 --- a/crates/kirie/src/compat/screenshot.rs +++ b/crates/kirie/src/compat/screenshot.rs @@ -356,8 +356,9 @@ pub(crate) fn build_offscreen_renderer( Box::new(kirie_video::VideoRenderer::new(render_target, player)) } Wallpaper::Image { file } => { - let content = - ImageContent::from_path(file).with_context(|| format!("loading image {}", file.display()))?; + let drawn = crate::prebake::for_screen(file, render_target.size); + let content = ImageContent::from_path(&drawn) + .with_context(|| format!("loading image {}", drawn.display()))?; let options = ImageOptions { scaling: super::common::to_render_scaling(scaling), clamp: super::common::to_render_clamp(clamp), diff --git a/crates/kirie/src/lib.rs b/crates/kirie/src/lib.rs index 74648bc..894be82 100644 --- a/crates/kirie/src/lib.rs +++ b/crates/kirie/src/lib.rs @@ -11,6 +11,7 @@ pub mod info; pub mod list; mod os; pub mod pack; +pub mod prebake; pub mod preview; mod preview_render; pub mod soak; @@ -92,6 +93,17 @@ enum Command { #[arg(long)] inspect: bool, }, + /// Resize pictures ahead of time to the screens they will be shown on, + /// so the first time one goes up is as quick as the rest. + Prebake { + /// Pictures, wallpaper folders, or folders of pictures. + #[arg(required = true)] + paths: Vec, + /// A screen size such as 2560x1440; repeat for several. Without it, + /// every screen kirie has drawn on. + #[arg(long = "size", value_parser = prebake::parse_size)] + sizes: Vec<(u32, u32)>, + }, Extract { path: PathBuf, #[arg(short = 'o', long = "output", default_value = ".")] @@ -179,6 +191,7 @@ pub fn run(args: Vec) -> ExitCode { if sub == "info" || sub == "extract" || sub == "pack" + || sub == "prebake" || sub == "check" || sub == "list" || sub == "gpus" @@ -273,6 +286,11 @@ fn run_subcommand(args: Vec) -> ExitCode { pack::run(&path, output) } } + Command::Prebake { paths, sizes } => match prebake::run(&paths, &sizes) { + Ok(true) => Ok(()), + Ok(false) => return ExitCode::FAILURE, + Err(err) => Err(err), + }, Command::Gpus { json } => gpus::run(json), Command::Update { check, force } => update::run(check, force), Command::Workshop { command } => match command { diff --git a/crates/kirie/src/prebake.rs b/crates/kirie/src/prebake.rs new file mode 100644 index 0000000..aa65aeb --- /dev/null +++ b/crates/kirie/src/prebake.rs @@ -0,0 +1,518 @@ +//! Pictures resized ahead of time to the screens they are shown on. +//! +//! The image renderer uploads a picture whole, with no mipmaps, and the GPU +//! shrinks it with a linear filter every frame. A 6000-pixel photo on a +//! 1920-pixel screen then costs 96 MB of video memory and shows the jagged +//! edges of sampling one texel in nine. A prebaked copy is the picture +//! resized once, with a Lanczos filter, to just cover the screen, and kept as +//! a JPEG (or a PNG when it is see-through) in kirie's cache, one per screen +//! size. +//! +//! The copy keeps the picture's aspect ratio, so every scaling mode frames it +//! exactly as it framed the original. Pictures already no larger than the +//! screen, animated GIFs and Wallpaper Engine textures are drawn as they are. +//! +//! `kirie prebake` bakes in advance for the screens kirie last ran on (or the +//! sizes given), so the first time a picture goes up is as quick as the rest. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +/// Bumped when a change makes earlier bakes wrong, so they are made again. +const FORMAT: u32 = 1; +/// JPEG quality of a bake: indistinguishable from the original at screen size. +const QUALITY: u8 = 92; +/// The most the bake cache keeps before the least recently used go. +const CACHE_LIMIT: u64 = 512 * 1024 * 1024; + +/// Pictures a bake can be made from. GIFs may move, and `.tex` is drawn by +/// its own decoder, so both are left alone. +const BAKEABLE: [&str; 5] = ["png", "jpg", "jpeg", "bmp", "webp"]; + +/// The picture to draw on a screen of `screen` pixels: a bake when one helps, +/// otherwise `file` itself. Never fails; a bake that cannot be made is +/// reported and the original is drawn. +#[must_use] +pub fn for_screen(file: &Path, screen: (u32, u32)) -> PathBuf { + if std::env::var_os("KIRIE_NO_PICTURE_BAKE").is_some() { + return file.to_owned(); + } + match bake(file, screen) { + Ok(Some(baked)) => baked.path, + Ok(None) => file.to_owned(), + Err(err) => { + tracing::warn!(file = %file.display(), "could not prebake the picture, drawing the original: {err:#}"); + file.to_owned() + } + } +} + +/// What `bake` produced. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Baked { + pub path: PathBuf, + pub size: (u32, u32), + /// Already in the cache from an earlier run. + pub reused: bool, +} + +/// The cached bake of `file` for a screen of `screen` pixels, made now if +/// there is none. `None` when the picture is drawn as it is. +/// +/// # Errors +/// +/// When the picture cannot be read or decoded, or the bake cannot be written. +pub fn bake(file: &Path, screen: (u32, u32)) -> Result> { + let Some(dir) = cache_dir() else { + return Ok(None); + }; + bake_into(file, screen, &dir) +} + +fn bake_into(file: &Path, screen: (u32, u32), dir: &Path) -> Result> { + let ext = file + .extension() + .map(|ext| ext.to_string_lossy().to_ascii_lowercase()) + .unwrap_or_default(); + if !BAKEABLE.contains(&ext.as_str()) || screen.0 == 0 || screen.1 == 0 { + return Ok(None); + } + + // The header alone says whether there is anything to gain, so a picture + // that is already small is never decoded twice. + let reader = image::ImageReader::open(file) + .with_context(|| format!("opening {}", file.display()))? + .with_guessed_format()?; + let (width, height) = reader.into_dimensions()?; + let target = cover_size((width, height), screen); + if target == (width, height) { + return Ok(None); + } + + let key = key(file, screen)?; + for ext in ["jpg", "png"] { + let path = dir.join(format!("{key}.{ext}")); + if path.is_file() { + touch(&path); + return Ok(Some(Baked { + path, + size: target, + reused: true, + })); + } + } + + let mut decoder = image::ImageReader::open(file)? + .with_guessed_format()? + .into_decoder()?; + // Photos from a phone are stored sideways and say so in EXIF. + let orientation = image::ImageDecoder::orientation(&mut decoder).ok(); + let mut picture = + image::DynamicImage::from_decoder(decoder).with_context(|| format!("decoding {}", file.display()))?; + if let Some(orientation) = orientation { + picture.apply_orientation(orientation); + } + let target = cover_size((picture.width(), picture.height()), screen); + let resized = picture.resize_exact(target.0, target.1, image::imageops::FilterType::Lanczos3); + + std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; + let see_through = + resized.color().has_alpha() && resized.to_rgba8().pixels().any(|pixel| pixel.0[3] < 255); + let ext = if see_through { "png" } else { "jpg" }; + let path = dir.join(format!("{key}.{ext}")); + let staged = dir.join(format!(".{key}.{}.{ext}", std::process::id())); + let written = write(&resized, see_through, &staged) + .and_then(|()| std::fs::rename(&staged, &path).context("moving the bake into place")); + if written.is_err() { + let _ = std::fs::remove_file(&staged); + } + written?; + prune(dir, CACHE_LIMIT); + tracing::info!( + file = %file.display(), + from = ?(width, height), + to = ?target, + "prebaked the picture for this screen" + ); + Ok(Some(Baked { + path, + size: target, + reused: false, + })) +} + +fn write(picture: &image::DynamicImage, see_through: bool, to: &Path) -> Result<()> { + let file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(to) + .with_context(|| format!("creating {}", to.display()))?; + let mut out = std::io::BufWriter::new(file); + if see_through { + picture.to_rgba8().write_to(&mut out, image::ImageFormat::Png)?; + } else { + let rgb = picture.to_rgb8(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, QUALITY).encode_image(&rgb)?; + } + std::io::Write::flush(&mut out)?; + Ok(()) +} + +/// The smallest size, at the picture's own aspect ratio, that covers +/// `screen`; the picture's own size when that is no bigger. +#[must_use] +pub fn cover_size(picture: (u32, u32), screen: (u32, u32)) -> (u32, u32) { + let (pw, ph) = (f64::from(picture.0.max(1)), f64::from(picture.1.max(1))); + let scale = (f64::from(screen.0) / pw).max(f64::from(screen.1) / ph); + if scale >= 1.0 { + return picture; + } + // Less a hair, so 6000 x 0.32 that comes out as 1920.0000000002 is 1920. + let size = |side: f64| ((side * scale - 1e-6).ceil() as u32).max(1); + (size(pw).min(picture.0), size(ph).min(picture.1)) +} + +/// Names a bake by the picture (where it is, its size and when it last +/// changed), the screen size and the bake format, so an edited picture is +/// baked again and two screens of one size share a bake. +fn key(file: &Path, screen: (u32, u32)) -> Result { + let meta = std::fs::metadata(file).with_context(|| format!("reading {}", file.display()))?; + let changed = meta + .modified() + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map_or(0, |since| since.as_nanos()); + let source = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_owned()); + let mut hasher = blake3::Hasher::new(); + hasher.update(&FORMAT.to_le_bytes()); + hasher.update(source.to_string_lossy().as_bytes()); + hasher.update(&[0]); + hasher.update(&meta.len().to_le_bytes()); + hasher.update(&changed.to_le_bytes()); + hasher.update(&screen.0.to_le_bytes()); + hasher.update(&screen.1.to_le_bytes()); + let hex = hasher.finalize().to_hex(); + Ok(format!("{}-{}x{}", &hex[..24], screen.0, screen.1)) +} + +fn cache_dir() -> Option { + kirie_platform::cache_home().map(|home| home.join("kirie").join("pictures")) +} + +/// Marks a bake as just used, so pruning keeps it. +fn touch(path: &Path) { + if let Ok(file) = std::fs::File::options().append(true).open(path) { + let _ = file.set_modified(std::time::SystemTime::now()); + } +} + +/// Removes the least recently used bakes until the folder holds at most +/// `limit` bytes. +fn prune(dir: &Path, limit: u64) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + let mut bakes: Vec<(std::time::SystemTime, u64, PathBuf)> = entries + .flatten() + .filter(|entry| !entry.file_name().to_string_lossy().starts_with('.')) + .filter_map(|entry| { + let meta = entry.metadata().ok()?; + meta.is_file().then(|| { + ( + meta.modified().unwrap_or(std::time::UNIX_EPOCH), + meta.len(), + entry.path(), + ) + }) + }) + .collect(); + let mut total: u64 = bakes.iter().map(|(_, len, _)| len).sum(); + bakes.sort(); + for (_, len, path) in bakes { + if total <= limit { + break; + } + if std::fs::remove_file(&path).is_ok() { + total = total.saturating_sub(len); + } + } +} + +/// Screens kirie has drawn on, by name, with their size in pixels, so +/// `kirie prebake` knows what to bake for without a window of its own. +fn screens_file() -> Option { + kirie_platform::cache_home().map(|home| home.join("kirie").join("screens.json")) +} + +/// Records the size of a screen kirie is drawing on. +pub fn remember_screen(name: &str, size: (u32, u32)) { + let Some(path) = screens_file() else { + return; + }; + let mut known = read_screens(&path); + if known + .get(name) + .and_then(serde_json::Value::as_array) + .is_some_and(|seen| { + seen.first().and_then(serde_json::Value::as_u64) == Some(u64::from(size.0)) + && seen.get(1).and_then(serde_json::Value::as_u64) == Some(u64::from(size.1)) + }) + { + return; + } + known.insert(name.to_owned(), serde_json::json!([size.0, size.1])); + let Some(parent) = path.parent() else { + return; + }; + let staged = parent.join(format!(".screens.{}.json", std::process::id())); + let written = std::fs::create_dir_all(parent) + .and_then(|()| std::fs::write(&staged, serde_json::Value::Object(known).to_string())) + .and_then(|()| std::fs::rename(&staged, &path)); + if let Err(err) = written { + let _ = std::fs::remove_file(&staged); + tracing::debug!("could not record the screen size: {err}"); + } +} + +fn read_screens(path: &Path) -> serde_json::Map { + std::fs::read_to_string(path) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + .and_then(|value| value.as_object().cloned()) + .unwrap_or_default() +} + +/// The distinct screen sizes kirie has drawn on. +#[must_use] +pub fn remembered_sizes() -> Vec<(u32, u32)> { + let Some(path) = screens_file() else { + return Vec::new(); + }; + let mut sizes: Vec<(u32, u32)> = read_screens(&path) + .values() + .filter_map(|size| { + let size = size.as_array()?; + let width = u32::try_from(size.first()?.as_u64()?).ok()?; + let height = u32::try_from(size.get(1)?.as_u64()?).ok()?; + (width > 0 && height > 0).then_some((width, height)) + }) + .collect(); + sizes.sort_unstable(); + sizes.dedup(); + sizes +} + +/// Parses `2560x1440`. +/// +/// # Errors +/// +/// When it is not two positive whole numbers joined by `x`. +pub fn parse_size(text: &str) -> Result<(u32, u32), String> { + let (width, height) = text + .split_once(['x', 'X']) + .ok_or_else(|| format!("{text}: expected WIDTHxHEIGHT, such as 2560x1440"))?; + let side = |side: &str| side.trim().parse::().ok().filter(|side| *side > 0); + match (side(width), side(height)) { + (Some(width), Some(height)) => Ok((width, height)), + _ => Err(format!("{text}: expected WIDTHxHEIGHT, such as 2560x1440")), + } +} + +/// `kirie prebake`: bakes each picture named, or found in a folder named, +/// for each size given, or else for every screen kirie has drawn on. +/// +/// # Errors +/// +/// When no size is given and kirie has not drawn on any screen yet. +pub fn run(paths: &[PathBuf], sizes: &[(u32, u32)]) -> Result { + let sizes = if sizes.is_empty() { + remembered_sizes() + } else { + sizes.to_vec() + }; + if sizes.is_empty() { + anyhow::bail!( + "no screen sizes known yet: run kirie on your screens once, or pass --size WIDTHxHEIGHT" + ); + } + let mut all_ok = true; + for picture in paths.iter().flat_map(|path| pictures_in(path)) { + for &size in &sizes { + match bake(&picture, size) { + Ok(Some(baked)) => println!( + "{} {}x{} {}{}", + picture.display(), + baked.size.0, + baked.size.1, + baked.path.display(), + if baked.reused { " (already baked)" } else { "" } + ), + Ok(None) => println!("{} {}x{} drawn as it is", picture.display(), size.0, size.1), + Err(err) => { + all_ok = false; + eprintln!("{}: {err:#}", picture.display()); + } + } + } + } + Ok(all_ok) +} + +/// The pictures `path` names: itself, the picture of a wallpaper folder, or +/// the pictures directly inside a plain folder. +fn pictures_in(path: &Path) -> Vec { + match crate::compat::resolve::classify(&path.to_string_lossy()) { + Ok(crate::compat::resolve::Wallpaper::Image { file }) => vec![file], + Ok(_) => Vec::new(), + Err(_) if path.is_dir() => { + let mut found: Vec = std::fs::read_dir(path) + .into_iter() + .flatten() + .flatten() + .map(|entry| entry.path()) + .filter(|file| { + file.is_file() + && file.extension().is_some_and(|ext| { + BAKEABLE.contains(&ext.to_string_lossy().to_ascii_lowercase().as_str()) + }) + }) + .collect(); + found.sort(); + found + } + Err(_) => { + eprintln!("{}: not found", path.display()); + Vec::new() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Scratch(PathBuf); + + impl Scratch { + fn new(name: &str) -> Self { + let dir = std::env::temp_dir().join(format!("kirie-prebake-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::create_dir_all(&dir); + Self(dir) + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn picture(at: &Path, size: (u32, u32), alpha: u8) { + let mut canvas = image::RgbaImage::new(size.0, size.1); + for (x, y, pixel) in canvas.enumerate_pixels_mut() { + *pixel = image::Rgba([(x % 251) as u8, (y % 241) as u8, 90, alpha]); + } + canvas.save(at).expect("scratch picture"); + } + + #[test] + fn the_bake_just_covers_the_screen_at_the_pictures_shape() { + assert_eq!(cover_size((6000, 4000), (1920, 1080)), (1920, 1280)); + assert_eq!(cover_size((4000, 6000), (1920, 1080)), (1920, 2880)); + assert_eq!(cover_size((1000, 500), (1920, 1080)), (1000, 500)); + assert_eq!(cover_size((3840, 2160), (3840, 2160)), (3840, 2160)); + assert_eq!(cover_size((7680, 4320), (2560, 1440)), (2560, 1440)); + } + + #[test] + fn a_big_picture_is_baked_once_per_screen_size() { + let scratch = Scratch::new("big"); + let source = scratch.0.join("photo.png"); + picture(&source, (1600, 1000), 255); + let cache = scratch.0.join("cache"); + + let first = bake_into(&source, (800, 450), &cache) + .expect("bake") + .expect("a bake"); + assert!(!first.reused); + assert_eq!(first.size, (800, 500)); + assert_eq!(first.path.extension().and_then(|e| e.to_str()), Some("jpg")); + assert_eq!(image::image_dimensions(&first.path).ok(), Some((800, 500))); + + let again = bake_into(&source, (800, 450), &cache) + .expect("bake") + .expect("a bake"); + assert!(again.reused); + assert_eq!(again.path, first.path); + + let other = bake_into(&source, (640, 400), &cache) + .expect("bake") + .expect("a bake"); + assert_ne!(other.path, first.path); + assert_eq!(other.size, (640, 400)); + } + + #[test] + fn a_see_through_picture_stays_see_through() { + let scratch = Scratch::new("alpha"); + let source = scratch.0.join("logo.png"); + picture(&source, (1200, 1200), 128); + let baked = bake_into(&source, (600, 600), &scratch.0.join("cache")) + .expect("bake") + .expect("a bake"); + assert_eq!(baked.path.extension().and_then(|e| e.to_str()), Some("png")); + } + + #[test] + fn what_gains_nothing_is_drawn_as_it_is() { + let scratch = Scratch::new("small"); + let cache = scratch.0.join("cache"); + let small = scratch.0.join("small.png"); + picture(&small, (640, 360), 255); + assert_eq!(bake_into(&small, (1920, 1080), &cache).ok(), Some(None)); + + let gif = scratch.0.join("moves.gif"); + let _ = std::fs::write(&gif, b"GIF89a"); + assert_eq!(bake_into(&gif, (100, 100), &cache).ok(), Some(None)); + assert!(!cache.exists()); + } + + #[test] + fn an_edited_picture_is_baked_again() { + let scratch = Scratch::new("edited"); + let source = scratch.0.join("photo.png"); + picture(&source, (1600, 1000), 255); + let before = key(&source, (800, 450)).expect("key"); + picture(&source, (1600, 1001), 255); + assert_ne!(key(&source, (800, 450)).expect("key"), before); + } + + #[test] + fn pruning_drops_the_least_recently_used_first() { + let scratch = Scratch::new("prune"); + let old = scratch.0.join("old.jpg"); + let new = scratch.0.join("new.jpg"); + std::fs::write(&old, [0_u8; 600]).expect("write"); + std::fs::write(&new, [0_u8; 600]).expect("write"); + let long_ago = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000); + std::fs::File::options() + .append(true) + .open(&old) + .and_then(|file| file.set_modified(long_ago)) + .expect("age"); + prune(&scratch.0, 1000); + assert!(!old.exists()); + assert!(new.exists()); + } + + #[test] + fn sizes_read_the_way_people_write_them() { + assert_eq!(parse_size("2560x1440"), Ok((2560, 1440))); + assert_eq!(parse_size("1920X1080"), Ok((1920, 1080))); + assert!(parse_size("1920").is_err()); + assert!(parse_size("0x1080").is_err()); + assert!(parse_size("wide").is_err()); + } +} diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 13c78b3..03c265c 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -29,6 +29,7 @@ anything else goes to the renderer. - [`kirie assets`](#kirie-assets) - [`kirie gpus`](#kirie-gpus) - [`kirie extract`](#kirie-extract) + - [`kirie prebake`](#kirie-prebake) - [`kirie workshop`](#kirie-workshop) - [`kirie ask`](#kirie-ask) - [`kirie preview`](#kirie-preview) @@ -194,6 +195,44 @@ kirie extract materials/water.tex -o /tmp/tex Extracting a `project.json` is refused — that is a manifest, not an archive. +### `kirie prebake` + +Resize pictures ahead of time to the screens they will be shown on. + +``` +kirie prebake ... [--size ]... +``` + +| Flag | Default | Meaning | +|------|---------|---------| +| `--size ` | every screen kirie has drawn on | A screen size to bake for; repeat for several | + +A `PATH` is a picture, a wallpaper folder whose `project.json` names a +picture, or a plain folder (the pictures directly inside it). + +kirie draws a picture bigger than the screen from a copy resized once, with a +Lanczos filter, to just cover that screen at the picture's own shape. It is a +JPEG (PNG when the picture is see-through) in `/kirie/pictures`, one per +screen size, keyed by the picture's path, size and modification time, and the +cache keeps the most recently used 512 MB. The GPU then never shrinks a +6000-pixel photo itself, which used the full-size texture's memory and showed +jagged edges. Phone photos are turned upright from their EXIF orientation. +Every scaling mode frames the copy exactly as it framed the original. + +kirie makes these on its own the first time a picture goes up on a screen; +`kirie prebake` makes them in advance, so that first time is quick too. With +no `--size` it bakes for every screen size kirie has drawn on, which it +records in `/kirie/screens.json`. Pictures no larger than the screen, +GIFs and `.tex` files are drawn as they are. + +```sh +kirie prebake ~/Pictures/sunset.jpg +kirie prebake ~/Pictures/Wallpapers --size 2560x1440 --size 1920x1080 +``` + +Exits non-zero when a picture could not be baked, or when no `--size` is +given and kirie has never drawn on a screen. + ### `kirie workshop` Search, subscribe to and inspect Steam Workshop items. These talk to *your* @@ -676,6 +715,7 @@ kirie ask workshop subscribe 1388331347 | `KIRIE_BLOOM_THRESHOLD`, `KIRIE_BLOOM_STRENGTH` | Override the bloom pass | | `KIRIE_NO_PREBAKE` | Skip the prebaked scene-bundle cache | | `KIRIE_NO_PIPELINE_CACHE` | Skip the on-disk pipeline cache | +| `KIRIE_NO_PICTURE_BAKE` | Draw pictures from the original file instead of a copy resized to the screen (see [`kirie prebake`](#kirie-prebake)) | | `KIRIE_SHADER_DUMP`, `KIRIE_SHADER_DUMP_ALL` | Write translated shaders to disk | | `KIRIE_WEB_CONSOLE` | Forward web-wallpaper console output to the log | | `KIRIE_NO_LAYERED_HOST` | Windows 11 24H2 and later: draw straight into the desktop instead of inside a layered window under the icons | From 7d49550daf2fd7f2d410b23df56a55b37c87febb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 23:53:43 +0000 Subject: [PATCH 33/33] Address review findings on frame_cost, the CEF skip and the socket test - frame_cost::reset() also clears the reporting interval, so the next one starts fresh instead of reporting at once. - The CEF folder handler's skip() never seeks past the end of the file and reports the bytes it actually skipped. - The socket-name test passes on a machine with no private runtime directory, where there is no default socket to name. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QK5SC6GNiVZheXhbWzZtrq --- crates/kirie-render/src/frame_cost.rs | 3 +++ crates/kirie-web/src/cef/folder.rs | 14 +++++++++++--- crates/kirie/src/lib.rs | 6 +++++- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/kirie-render/src/frame_cost.rs b/crates/kirie-render/src/frame_cost.rs index b931c64..0abbe54 100644 --- a/crates/kirie-render/src/frame_cost.rs +++ b/crates/kirie-render/src/frame_cost.rs @@ -144,6 +144,9 @@ pub struct FrameCost { pub fn reset() { ON.store(true, Ordering::Relaxed); REPORTING.store(false, Ordering::Relaxed); + if let Ok(mut since) = SINCE.lock() { + *since = None; + } clear(); } diff --git a/crates/kirie-web/src/cef/folder.rs b/crates/kirie-web/src/cef/folder.rs index 19153d2..3d412db 100644 --- a/crates/kirie-web/src/cef/folder.rs +++ b/crates/kirie-web/src/cef/folder.rs @@ -130,9 +130,17 @@ wrap_resource_handler! { ) -> c_int { let Some(bytes_skipped) = bytes_skipped else { return 0 }; let mut opened = self.opened.lock().unwrap_or_else(PoisonError::into_inner); - match opened.as_mut().map(|opened| opened.file.seek(SeekFrom::Current(bytes_to_skip))) { - Some(Ok(_)) => { - *bytes_skipped = bytes_to_skip; + // Never past the end: a Range starting beyond the file would + // otherwise be told it skipped bytes the file does not have. + let skipped = opened.as_mut().and_then(|opened| { + let at = opened.file.stream_position().ok()?; + let step = u64::try_from(bytes_to_skip).ok()?.min(opened.len.saturating_sub(at)); + opened.file.seek(SeekFrom::Start(at + step)).ok()?; + i64::try_from(step).ok() + }); + match skipped { + Some(step) if step > 0 || bytes_to_skip == 0 => { + *bytes_skipped = step; 1 } _ => { diff --git a/crates/kirie/src/lib.rs b/crates/kirie/src/lib.rs index 894be82..4741664 100644 --- a/crates/kirie/src/lib.rs +++ b/crates/kirie/src/lib.rs @@ -373,6 +373,10 @@ mod socket_tests { // two agreeing on the file name is what makes them able to talk at all. #[test] fn the_socket_is_named_the_same_on_every_platform() { - assert!(super::default_control_socket().is_some_and(|path| path.ends_with("lwe.sock"))); + // None is a machine with no private directory to put it in, which + // says nothing about the name. + if let Some(path) = super::default_control_socket() { + assert!(path.ends_with("lwe.sock")); + } } }