Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
d0b79c7
Add the .kpk wallpaper package and `kirie pack`
claude Sep 26, 2026
a4c3b1d
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Sep 26, 2026
09855b3
Refuse Windows-unsafe entry names, check nested manifest fields, esca…
claude Sep 26, 2026
b69559c
Start a compose layer on a transparent canvas when copy background is…
claude Sep 27, 2026
3a0d9a8
Empty a compose layer's canvas when the shader defaults slot 0 to the…
claude Sep 27, 2026
2a00222
Give each `kirie pack` run its own partial file
claude Sep 27, 2026
5563cc2
Make control and preview sockets owner-only and cap request size
claude Sep 28, 2026
0ed244a
Keep the fallback runtime dir private and save properties on Windows
claude Sep 28, 2026
4fa84f9
Honour --disable-mouse, --fps and --playback-speed on the Linux backends
claude Sep 28, 2026
a046876
Don't rewrite an unchanged pipeline cache
claude Sep 28, 2026
c28d7e0
Remove the unused macOS desktop-surface API and call pmset by path
claude Sep 28, 2026
2cdb83e
Deduplicate swap and property plumbing in the IPC app
claude Sep 28, 2026
79b4d4a
Pass release URLs to curl with --url and ignore an empty HOME
claude Sep 28, 2026
ad17548
Sample the scene directly when a layer reads it offscreen, and drop t…
claude Sep 28, 2026
2c1fbfa
Harden texture and asset loading against hostile wallpaper files
claude Sep 28, 2026
14a755b
Keep frame-cost counters for callers that reset them, and vary the be…
claude Sep 28, 2026
0b25a41
Keep the shader cache out of Workshop item folders
claude Sep 28, 2026
497803a
Refuse a non-ASCII color word instead of panicking on it
claude Sep 28, 2026
f65ffc3
Reject a .kpk header whose manifest offset overflows
claude Sep 28, 2026
c6e903d
Coalesce background bake jobs and find WE assets on Windows
claude Sep 28, 2026
113f46a
Keep the shader include resolver inside its roots
claude Sep 28, 2026
0a58417
Put a module's top level under the script time budget
claude Sep 28, 2026
10b5920
Refuse oversized or frameless videos and stop ffmpeg following remote…
claude Sep 28, 2026
c84bf63
Harden web wallpaper hosting
claude Sep 28, 2026
2f01a75
Read Steam's C strings within their buffers
claude Sep 28, 2026
fd8dd78
Simplify particle vec3 number parsing
claude Sep 28, 2026
f2661a8
Drop a test import the module now brings in on Windows
claude Sep 28, 2026
e7334e0
Serve CEF web wallpapers from their folder instead of file://
claude Sep 28, 2026
6115b25
Address review findings on the audit commits
claude Sep 28, 2026
f58faa7
Refuse an unverified control-socket directory instead of falling back…
claude Sep 28, 2026
fec1882
Take a plain picture or video file without Steam
claude Oct 8, 2026
d7e6942
feat(video): decode on the GPU on Windows and keep NV12 off the CPU
claude Oct 8, 2026
e4df2e9
Merge hardware video decoding on Windows into the open kirie PR
claude Oct 8, 2026
ec9a6a5
Prebake pictures to each screen's size
claude Oct 8, 2026
7d49550
Address review findings on frame_cost, the CEF skip and the socket test
claude Oct 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand All @@ -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"
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
67 changes: 60 additions & 7 deletions crates/kirie-bake/src/baker.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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<HashMap<PathBuf, bool>>,
}

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)()
}
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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();
Expand All @@ -202,20 +240,35 @@ fn coordinator_loop(inner: &Arc<Inner>, 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;
}
}
}
}
Expand Down
15 changes: 13 additions & 2 deletions crates/kirie-bake/src/key.rs
Original file line number Diff line number Diff line change
@@ -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]);
Expand Down Expand Up @@ -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<String>) {
collect_meta_at(root, dir, out, 0);
}

fn collect_meta_at(root: &std::path::Path, dir: &std::path::Path, out: &mut Vec<String>, 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 };
Expand Down
48 changes: 33 additions & 15 deletions crates/kirie-bake/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::path::PathBuf> {
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<std::path::PathBuf> {
["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<std::path::PathBuf> {
#[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
Expand All @@ -50,10 +72,6 @@ pub fn resolve_vulkan_icd(selector: &str) -> Option<std::path::PathBuf> {
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"],
Expand Down
6 changes: 5 additions & 1 deletion crates/kirie-formats/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()];
}
}
Expand Down Expand Up @@ -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]);
Expand Down
8 changes: 2 additions & 6 deletions crates/kirie-ipc/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion crates/kirie-ipc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Loading
Loading