diff --git a/Cargo.lock b/Cargo.lock index 4d605e3..764d333 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" @@ -2326,6 +2337,7 @@ name = "kirie" version = "0.1.0" dependencies = [ "anyhow", + "blake3", "clap", "crossbeam-channel", "ctrlc", @@ -2334,6 +2346,7 @@ dependencies = [ "kirie-bake", "kirie-formats", "kirie-ipc", + "kirie-pack", "kirie-platform", "kirie-render", "kirie-steam-helper", @@ -2417,6 +2430,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" @@ -3680,6 +3704,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 c9e234a..7f6add4 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" @@ -35,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..3f90d9d 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,28 @@ 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**. + +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 kirie looks it up across your Steam libraries: diff --git a/crates/kirie-bake/src/baker.rs b/crates/kirie-bake/src/baker.rs index acb3473..28c080f 100644 --- a/crates/kirie-bake/src/baker.rs +++ b/crates/kirie-bake/src/baker.rs @@ -1,6 +1,7 @@ +use std::collections::HashMap; 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,47 @@ 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. + /// 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 { + 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 + } + } + } + + /// 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 { self.paused.load(Ordering::Relaxed) || (self.should_pause)() } @@ -111,6 +150,7 @@ impl BackgroundBaker { paused: AtomicBool::new(false), shutdown: AtomicBool::new(false), cap_bytes: config.cap_bytes, + pending: Mutex::new(HashMap::new()), }); let pool = rayon::ThreadPoolBuilder::new() .num_threads(config.num_threads.max(1)) @@ -180,9 +220,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,20 +240,35 @@ 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 { + // 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(std::sync::atomic::Ordering::Relaxed) { + 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 ac9dd46..9850019 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]); @@ -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 path.is_dir() { - collect_meta(root, &path, out); + 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-bake/src/lib.rs b/crates/kirie-bake/src/lib.rs index e8c9d92..bb3c126 100644 --- a/crates/kirie-bake/src/lib.rs +++ b/crates/kirie-bake/src/lib.rs @@ -8,24 +8,46 @@ pub mod key; pub use baker::{BackgroundBaker, BakeOutcome, BakerConfig, ContentFn, PauseFn, SourceFn, never_pause}; pub(crate) fn we_assets_shaders_dir() -> 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"], 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]); 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-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-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-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..a9d0d7f --- /dev/null +++ b/crates/kirie-pack/src/lib.rs @@ -0,0 +1,186 @@ +//! 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")), + _ => {} + } + 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::*; + + #[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/", + "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", + "console.js", + "com10.png", + "com0", + ] { + 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..42af4b9 --- /dev/null +++ b/crates/kirie-pack/src/manifest.rs @@ -0,0 +1,431 @@ +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()))?; + 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() + { + 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())) + } + + 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(()) + } +} + +/// 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 + && 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(); + + 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-pack/src/read.rs b/crates/kirie-pack/src/read.rs new file mode 100644 index 0000000..8633e48 --- /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 + || 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")); + } + + 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..d7b4285 --- /dev/null +++ b/crates/kirie-pack/tests/roundtrip.rs @@ -0,0 +1,201 @@ +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) + )); + + 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] +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-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/gpu.rs b/crates/kirie-platform/src/gpu.rs index a44b118..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) @@ -217,19 +219,41 @@ pub fn attach_pipeline_cache(device: &wgpu::Device, adapter: &wgpu::Adapter) { } } +/// Hash of the blob last written, so a wallpaper swap that compiled nothing +/// new does not rewrite megabytes from the render thread. +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; + let Some(cache) = SHARED_PIPELINE_CACHE.get() else { return; }; let Some(data) = cache.get_data() else { return }; + let hash = blob_hash(&data); + if hash == PERSISTED.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.store(hash, Ordering::Relaxed); + } else { + let _ = std::fs::remove_file(&tmp); } } diff --git a/crates/kirie-platform/src/lib.rs b/crates/kirie-platform/src/lib.rs index e5d440e..206578e 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")] @@ -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-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()) 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-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..0abbe54 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(); @@ -115,7 +123,7 @@ pub fn end_frame() { copies_skipped = cost.snapshots_skipped, "frame cost" ); - reset(); + clear(); *since = Some(std::time::Instant::now()); } @@ -132,9 +140,17 @@ 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); + REPORTING.store(false, Ordering::Relaxed); + if let Ok(mut since) = SINCE.lock() { + *since = None; + } + clear(); +} + +fn clear() { for counter in [ &RENDER_PASSES, &DRAWS, 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/load.rs b/crates/kirie-render/src/scene/load.rs index cc5c2a1..0f1dadf 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")) @@ -119,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), @@ -276,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)) @@ -373,3 +386,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/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..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, @@ -1575,8 +1587,19 @@ 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. + // 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() + .and_then(|m| m.passes.first()) + .is_some_and(|p| is_compose_layer(&p.shader)); + 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)); @@ -1740,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() { @@ -1749,7 +1814,6 @@ fn build_object( params_vs, params_fs, target, - binds, is_puppet_base, effect_index, .. @@ -1757,17 +1821,6 @@ fn build_object( let is_scene = i == last_comp && !offscreen_donor; let composite = is_composite(&target); - let mut raw_pass = raw_pass; - 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 @@ -1837,8 +1890,10 @@ 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 { - (&scene_snapshot.view, fbo_sampler) + if empty_canvas { + (&transparent.view, &transparent.sampler) + } else if layer_reads_scene { + (scene_read, fbo_sampler) } else { (&layer_tex.view, &layer_tex.sampler) } @@ -1859,19 +1914,13 @@ 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 { 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 = @@ -1888,7 +1937,7 @@ fn build_object( registry, source, &raw_pass, - (&scene_snapshot.view, fbo_sampler), + (scene_read, fbo_sampler), &named, true, ); @@ -1903,7 +1952,7 @@ fn build_object( registry, source, &raw_pass, - (&scene_snapshot.view, fbo_sampler), + (scene_read, fbo_sampler), &named, true, ); @@ -1965,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, @@ -1974,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, @@ -1993,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, @@ -2053,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, @@ -2098,13 +2141,14 @@ fn build_text_layer( effects: tobj.effects.clone(), animationlayers: Vec::new(), instance: None, + copybackground: true, }; let mut built = build_object( device, object, &image, scene_size, - screen_mvp, + scene_view, source, resolver, registry, @@ -2142,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 { @@ -2170,7 +2214,7 @@ fn refresh_text_layer( &text.object, &text.image, scene_size, - screen_mvp, + scene_view, &ctx.source, &SourceIncludes(&ctx.source), &ctx.registry, @@ -2526,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, ); } @@ -2751,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, @@ -2931,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, @@ -2949,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; } } } @@ -2968,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); } { @@ -3153,7 +3209,6 @@ fn collect_runtime_templates( } _ => {} } - let _ = &object.base; } let lookup = |name: &str| -> Option<&kirie_scene::PropertyValue> { @@ -3682,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, &[]); @@ -3701,7 +3758,6 @@ fn draw_image_object( } else { rp.draw(0..4, 0..1); } - let _ = pass.blending; } } @@ -3790,6 +3846,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('/') @@ -4302,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 { @@ -4356,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, diff --git a/crates/kirie-render/src/scene/texture.rs b/crates/kirie-render/src/scene/texture.rs index 9f7c444..91fc4cf 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() @@ -433,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; @@ -442,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); @@ -450,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, @@ -478,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], @@ -535,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, @@ -563,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) }; @@ -649,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, @@ -676,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 { diff --git a/crates/kirie-render/tests/compose_layer.rs b/crates/kirie-render/tests/compose_layer.rs new file mode 100644 index 0000000..175a436 --- /dev/null +++ b/crates/kirie-render/tests/compose_layer.rs @@ -0,0 +1,256 @@ +//! 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, names_the_scene: bool) -> (Scene, Memory) { + let mut files: HashMap> = HashMap::new(); + files.insert("shaders/util/composelayer.vert".into(), VERT.into()); + + files.insert("shaders/tint.vert".into(), VERT.into()); + files.insert("shaders/tint.frag".into(), TINT_FRAG.into()); + // 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(), + ); + 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, + 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); + 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 }; + + 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, 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:?}" + ); + } +} 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, } } 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, diff --git a/crates/kirie-script/src/world.rs b/crates/kirie-script/src/world.rs index 9eb1475..c6ada8d 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 @@ -181,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 @@ -435,6 +450,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 +856,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 +878,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 +888,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 + ); +} diff --git a/crates/kirie-shader/src/lib.rs b/crates/kirie-shader/src/lib.rs index a4ade01..31ca5fb 100644 --- a/crates/kirie-shader/src/lib.rs +++ b/crates/kirie-shader/src/lib.rs @@ -68,8 +68,24 @@ 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); + // 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); } @@ -177,6 +193,29 @@ 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()); + } + + #[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-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, 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..c9c41f8 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}; @@ -56,12 +65,30 @@ pub const FRAME_QUEUE_CAP: usize = 4; const FALLBACK_FRAME_DUR: f64 = 1.0 / 30.0; +/// 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) + && u64::from(width) * u64::from(height) <= MAX_PIXELS +} + #[derive(Debug)] pub struct DecodedFrame { pub play_pts: f64, 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 +127,24 @@ pub(crate) struct Decoder { unconvertible: u64, frame_dur: f64, synth_pts: f64, + sent: u64, + 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), } } } @@ -125,7 +152,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) @@ -138,9 +165,9 @@ 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 { + if !plausible_size(width, height) { return Err(VideoError::InvalidDimensions { width, height }); } let duration = if input.duration() > 0 { @@ -174,6 +201,8 @@ impl Decoder { unconvertible: 0, frame_dur, synth_pts: 0.0, + sent: 0, + hw, }) } @@ -182,9 +211,10 @@ 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 { + let sent_before = self.sent; loop { let mut packet = ffmpeg::Packet::empty(); match packet.read(&mut self.input) { @@ -219,6 +249,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 +280,7 @@ impl Decoder { if frames.send(frame).is_err() { return false; } + self.sent += 1; } Err(err) => { self.unconvertible += 1; @@ -257,46 +294,52 @@ 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, 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 { + 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 bt601 = is_bt601(decoded.color_space(), height); + let full_range = decoded.color_range() == ffmpeg::color::Range::JPEG; + let play_pts = self.next_play_pts(); return Ok(DecodedFrame { play_pts, width, height, pixels: FramePixels::Nv12, + bt601, + full_range, data, }); } @@ -335,19 +378,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); @@ -357,11 +388,22 @@ impl Decoder { width, height, pixels: FramePixels::Rgba, + bt601: false, + full_range: false, data, }) } } +/// 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(); @@ -378,30 +420,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..d0b23ec 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,105 @@ 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); + // 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 pixels = width as usize * height as usize; + let expected = match frame.pixels { + FramePixels::Rgba => pixels * 4, + FramePixels::Nv12 => pixels + pixels / 2, + }; + if width > max || height > max || frame.data.len() < expected { + let _ = self.recycle_tx.try_send(frame.data); + return; + } + 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 (y, cbcr) = frame.data.split_at(pixels); + 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 +451,7 @@ impl VideoRenderer { 0, bytemuck::bytes_of(&Uniforms { rect: [ustart, vstart, uend, vend], + yuv: self.yuv, }), ); } 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..dad5be3 100644 --- a/crates/kirie-web/src/cef/app.rs +++ b/crates/kirie-web/src/cef/app.rs @@ -58,8 +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, "remote-allow-origins", "*"); switch_val(cmd, "autoplay-policy", "no-user-gesture-required"); switch(cmd, "disable-background-timer-throttling"); switch(cmd, "disable-backgrounding-occluded-windows"); @@ -69,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 034d2d1..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 @@ -523,5 +529,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/cef/folder.rs b/crates/kirie-web/src/cef/folder.rs new file mode 100644 index 0000000..3d412db --- /dev/null +++ b/crates/kirie-web/src/cef/folder.rs @@ -0,0 +1,184 @@ +//! 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); + // 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 + } + _ => { + *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/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..1ac9722 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 { @@ -33,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 @@ -58,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() { @@ -121,6 +249,72 @@ 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 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-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"); - } -} diff --git a/crates/kirie/Cargo.toml b/crates/kirie/Cargo.toml index 58464b9..8035b49 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" } @@ -48,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/desktop_ipc.rs b/crates/kirie/src/compat/desktop_ipc.rs index 2b70356..5087890 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,9 +186,9 @@ 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() { + if reader.read_line(&mut line).is_err() || line.is_empty() || cut_short(&line) { continue; } @@ -265,14 +269,25 @@ 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))); - 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; } + if cut_short(&line) { + let mut writer = stream; + let _ = writer.write_all(b"error: request too long\n"); + return; + } let (reply, restart) = act(line.trim(), orders, showing, args); let mut writer = stream; @@ -720,7 +735,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 { 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/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() diff --git a/crates/kirie/src/compat/resolve.rs b/crates/kirie/src/compat/resolve.rs index 2d2d537..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); @@ -245,17 +253,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 +311,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, @@ -301,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() { @@ -315,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 @@ -327,3 +395,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); + } +} diff --git a/crates/kirie/src/compat/run.rs b/crates/kirie/src/compat/run.rs index dc452c5..d0a648d 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 { @@ -930,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 { @@ -940,7 +942,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) { @@ -970,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/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/compat/screenshot.rs b/crates/kirie/src/compat/screenshot.rs index 86d2ff3..b01c9ed 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) @@ -355,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/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/lib.rs b/crates/kirie/src/lib.rs index 6722b4d..4741664 100644 --- a/crates/kirie/src/lib.rs +++ b/crates/kirie/src/lib.rs @@ -10,6 +10,8 @@ pub mod gpus; pub mod info; pub mod list; mod os; +pub mod pack; +pub mod prebake; pub mod preview; mod preview_render; pub mod soak; @@ -82,6 +84,26 @@ 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, + }, + /// 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 = ".")] @@ -168,6 +190,8 @@ pub fn run(args: Vec) -> ExitCode { Some(sub) if sub == "info" || sub == "extract" + || sub == "pack" + || sub == "prebake" || sub == "check" || sub == "list" || sub == "gpus" @@ -183,16 +207,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 @@ -241,6 +275,22 @@ 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::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 { @@ -273,13 +323,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")] @@ -328,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().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")); + } } } diff --git a/crates/kirie/src/os.rs b/crates/kirie/src/os.rs index 60e5eaa..0af5b55 100644 --- a/crates/kirie/src/os.rs +++ b/crates/kirie/src/os.rs @@ -15,18 +15,66 @@ 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())); - if std::fs::create_dir_all(&dir).is_ok() { - use std::os::unix::fs::PermissionsExt; + match make_private_dir(&dir) { + Ok(()) => Some(dir), + Err(err) => { + // 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() + } + } +} - let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)); +#[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() + )); + 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) +} + +/// 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", + )); } - dir + // 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)) } /// Windows has no `XDG_RUNTIME_DIR` and no mode bits to set, but it does have a @@ -34,27 +82,30 @@ pub(crate) fn runtime_dir() -> PathBuf { /// `%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 /// 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 +120,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 @@ -93,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(); diff --git a/crates/kirie/src/pack.rs b/crates/kirie/src/pack.rs new file mode 100644 index 0000000..0449347 --- /dev/null +++ b/crates/kirie/src/pack.rs @@ -0,0 +1,111 @@ +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. 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) { + Ok(summary) => summary, + Err(err) => { + drop(file); + let _ = std::fs::remove_file(&partial); + return Err(err).with_context(|| format!("cannot pack {}", dir.display())); + } + }; + let synced = file.sync_all(); + drop(file); + 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(), + summary.entries, + summary.bytes + ); + 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", + shown(&m.title), + shown(&m.id), + m.kind.as_str() + ); + if !m.author.is_empty() { + println!("by {}", shown(&m.author)); + } + println!("entry: {}", shown(&m.entry)); + if let Some(preview) = &m.preview { + println!("preview: {}", shown(preview)); + } + if !m.tags.is_empty() { + println!("tags: {}", shown(&m.tags.join(", "))); + } + if m.mature { + println!("mature: yes"); + } + for p in &m.properties { + println!("property: {} ({})", shown(&p.key), shown(&p.label)); + } + if let Some(v) = &m.min_kirie { + println!("needs kirie {} or newer", shown(v)); + } + match &m.provenance { + Provenance::Original => {} + Provenance::Converted { source, source_id } => { + 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 {} item {}", + shown(source), + shown(source_id) + ); + } + } + println!(); + for e in package.entries() { + 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()); + Ok(()) +} 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/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)); 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() diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 1c4d720..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) @@ -53,16 +54,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 @@ -187,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* @@ -669,9 +715,11 @@ 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 | +| `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` | 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.