diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 043dea8b..cd30ebcb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,8 +47,8 @@ OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ## Development environment The project is built with Nix. `nix develop` (or direnv, whose `.envrc` is `use flake`) provides the -Rust toolchain with nightly `rustfmt`, `cargo-deny`, `cargo-nextest`, `ffmpeg`, `jq`, `just` and -`ripgrep`, and sets `KOMOREBI_MAC_SRC` and `KOMOREBI_WINDOWS_SRC` (with `_COMMIT`) to the komorebi +Rust toolchain with nightly `rustfmt`, `cargo-deny`, `cargo-nextest`, `ffmpeg`, `jq`, `just`, +`resvg` and `ripgrep`, and sets `KOMOREBI_MAC_SRC` and `KOMOREBI_WINDOWS_SRC` (with `_COMMIT`) to the komorebi sources pinned in `flake.lock`. The tests that read komorebi's source need them, so run `cargo` inside the shell. @@ -58,6 +58,9 @@ The Windows code is type-checked from a Mac with `nix develop .#windows`, whose On a Windows machine, the steps in [docs/dev/windows-checkpoints.md](docs/dev/windows-checkpoints.md) say what to run and what to look for. +The icon is `komorebi-recorder/assets/watermelon.svg`. After changing it, run `just icon` to render the PNG that +the windows set and the ICO that Windows embeds, and commit the three together. + ## Checks Before pushing, run diff --git a/Cargo.lock b/Cargo.lock index d9dd36bf..d2e7c5fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -215,7 +215,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -226,7 +226,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -928,7 +928,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1133,6 +1133,20 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "embed-resource" +version = "3.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml", + "vswhom", + "winreg", +] + [[package]] name = "endi" version = "1.1.1" @@ -1197,7 +1211,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1918,6 +1932,7 @@ dependencies = [ "dispatch2", "dunce", "eframe", + "embed-resource", "epaint_default_fonts", "libc", "objc2 0.6.4", @@ -3030,7 +3045,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3187,6 +3202,15 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -3406,7 +3430,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3425,7 +3449,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3527,10 +3551,25 @@ dependencies = [ ] [[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +name = "toml" +version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] @@ -3549,13 +3588,19 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + [[package]] name = "tracing" version = "0.1.44" @@ -3633,7 +3678,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3707,6 +3752,26 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -4180,7 +4245,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4678,6 +4743,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/docs/PLATFORM.md b/docs/PLATFORM.md index 78efe185..47af36be 100644 --- a/docs/PLATFORM.md +++ b/docs/PLATFORM.md @@ -117,6 +117,7 @@ The recorder window and the viewer must not be in the video they are recording, |---|---|---| | left out of the video | by name: every process called `komorebi-recorder` is excluded from the capture | each window asks to be left out of captures with `WDA_EXCLUDEFROMCAPTURE` (Windows 10 version 2004 and later) | | left alone by komorebi | the windows have no title, which komorebi does not manage | the same, and `KOMOREBI_RECORDER_PROBE_WINDOW=1` makes a window ask komorebi whether it would manage it and write the answer to `komorebi-recorder-window-probe.txt` in the temp directory | +| icon | a slice of watermelon, which the Dock shows while a window is open (eframe sets it as the application's icon image). The program is a bare executable and not an app bundle, so Finder gives the file the generic executable icon | the same picture as the window and taskbar icon while a window is open, and compiled into both executables as a resource, which is what Explorer shows for the files; the installer shows it beside the program in Apps & features | | executables | `komorebi-recorder` | `komorebi-recorder.exe` (console: run commands with this one) and `komorebi-recorder-gui.exe` (no console window) | ## Degraded modes diff --git a/docs/dev/windows-checkpoints.md b/docs/dev/windows-checkpoints.md index be29b7dd..f80cead7 100644 --- a/docs/dev/windows-checkpoints.md +++ b/docs/dev/windows-checkpoints.md @@ -179,6 +179,7 @@ Status: can be run once W1 shows capture works. 1. Start komorebi, then open the viewer on a Windows session: `komorebi-recorder-gui.exe view C:\temp\w2`. It should open floating (not tiled into your layout) and be usable: moving, resizing, minimizing and closing it. If komorebi tiles it, say so. + Its taskbar button and Alt+Tab entry should show the watermelon icon. 2. Ask komorebi what it thinks of it. Set `KOMOREBI_RECORDER_PROBE_WINDOW=1` before starting the viewer (or the recorder window), wait a few seconds, and send back `%TEMP%\komorebi-recorder-window-probe.txt`. @@ -204,6 +205,7 @@ warn on the first run. That it runs proves the `PATH` entry. `Program Files\komorebi-recorder\bin` should hold `komorebi-recorder.exe` and `komorebi-recorder-gui.exe`. + In Explorer both executables should show the watermelon icon, and so should the entry in Apps & features. 2. Install a second build over it, such as the next nightly. It should upgrade in place: one entry in Apps & features, not two. diff --git a/flake.nix b/flake.nix index 131667b6..61c68960 100644 --- a/flake.nix +++ b/flake.nix @@ -58,7 +58,9 @@ || (pkgs.lib.hasSuffix "/komorebi-recorder/src/guide.md" path) || (pkgs.lib.hasInfix "/komorebi-recorder/tests/fixtures/" path) # the installer, which `tests/packaging.rs` checks - || (pkgs.lib.hasInfix "/wix/" path); + || (pkgs.lib.hasInfix "/wix/" path) + # the icon, which `src/icon.rs` embeds and `build.rs` compiles into the Windows executables + || (pkgs.lib.hasInfix "/komorebi-recorder/assets/" path); }; mkPackages = @@ -299,6 +301,8 @@ pkgs.jq pkgs.just pkgs.ripgrep + # `just icon` renders the watermelon + pkgs.resvg ]; }; diff --git a/justfile b/justfile index d1e7dbb4..ecf7a67f 100644 --- a/justfile +++ b/justfile @@ -56,6 +56,10 @@ record *args: build-windows: nix build .#komorebi-recorder-windows --out-link result-windows +# render the icon: komorebi-recorder/assets/watermelon.svg into the PNG the windows set and the ICO Windows embeds +icon: + python3 komorebi-recorder/assets/generate.py + deps: cargo update just depgen diff --git a/komorebi-recorder/Cargo.toml b/komorebi-recorder/Cargo.toml index 6d1b4e56..c11db88f 100644 --- a/komorebi-recorder/Cargo.toml +++ b/komorebi-recorder/Cargo.toml @@ -144,5 +144,6 @@ windows = { version = "0.62", features = [ ] } [build-dependencies] +embed-resource = "3" proc-macro2 = { version = "1", features = ["span-locations"] } syn = { version = "2", features = ["full", "visit"] } diff --git a/komorebi-recorder/assets/generate.py b/komorebi-recorder/assets/generate.py new file mode 100644 index 00000000..efb499c2 --- /dev/null +++ b/komorebi-recorder/assets/generate.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Renders watermelon.svg into the two files the program uses. + +watermelon.png (256 by 256) is the icon every window sets while the program runs, and +watermelon.ico is the icon Windows shows for the executables and for the installer. Needs +`resvg` on PATH, which `nix develop` provides; run it with `just icon`. +""" + +import struct +import subprocess +import tempfile +from pathlib import Path + +HERE = Path(__file__).resolve().parent +SIZES = [16, 24, 32, 48, 64, 128, 256] + + +def render(size, directory): + out = Path(directory) / f"{size}.png" + subprocess.run( + ["resvg", "-w", str(size), "-h", str(size), str(HERE / "watermelon.svg"), str(out)], + check=True, + ) + return out.read_bytes() + + +def ico(images): + """An ICO file whose entries are PNGs, which Windows has read since Vista. A size of 256 is + written as 0.""" + offset = 6 + 16 * len(images) + entries, data = b"", b"" + for size, png in images: + entries += struct.pack( + " + A slice of watermelon + + + + + + + + + + + + + + + + + + + + + + + diff --git a/komorebi-recorder/build.rs b/komorebi-recorder/build.rs index 12e32855..34e25141 100644 --- a/komorebi-recorder/build.rs +++ b/komorebi-recorder/build.rs @@ -20,6 +20,9 @@ //! `KOMOREBI_WINDOWS_COMMIT` for a build outside a checkout (nix). //! //! If a source is not there the catalog is empty and says so. +//! +//! The script also gives the Windows executables their icon, which Explorer reads from a resource in the file: see +//! [`embed_icon`]. use proc_macro2::TokenStream; use proc_macro2::TokenTree; @@ -482,11 +485,33 @@ fn index(flavor: &Flavor) -> String { out } +/// Compiles the icon into the executables when the target is Windows. The windows set their own icon while they run +/// (`src/icon.rs`), which is not what Explorer shows for the file, and the command-line build has no window at all. +/// +/// A Windows host compiles `assets/watermelon.rc` with `rc.exe`, and any other host with `llvm-rc`, as the Windows +/// build in `flake.nix` does. `llvm-rc` reads a copy of the file from the build directory, so the icon is found by +/// naming `assets` as an include directory and not by its place next to the `.rc`. +fn embed_icon() { + println!("cargo:rerun-if-changed=assets/watermelon.rc"); + println!("cargo:rerun-if-changed=assets/watermelon.ico"); + + if env::var("CARGO_CFG_TARGET_OS").is_ok_and(|target| target == "windows") { + embed_resource::compile( + "assets/watermelon.rc", + embed_resource::ParamsIncludeDirs(&["assets"]), + ) + .manifest_required() + .expect("compile the icon resource"); + } +} + fn main() { let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")); println!("cargo:rerun-if-changed=build.rs"); + embed_icon(); + let out: String = FLAVORS.iter().map(index).collect(); fs::write(out_dir.join("log_catalog.rs"), out).expect("write log_catalog.rs"); diff --git a/komorebi-recorder/src/app.rs b/komorebi-recorder/src/app.rs index 6714de8f..8c587f42 100644 --- a/komorebi-recorder/src/app.rs +++ b/komorebi-recorder/src/app.rs @@ -170,6 +170,7 @@ pub fn run(args: RecordArgs) -> eyre::Result<()> { viewport: egui::ViewportBuilder::default() // no title: komorebi leaves untitled windows alone .with_title("") + .with_icon(crate::icon::window_icon()) .with_decorations(false) .with_inner_size([420.0, 380.0]) .with_min_inner_size([360.0, 260.0]) diff --git a/komorebi-recorder/src/icon.rs b/komorebi-recorder/src/icon.rs new file mode 100644 index 00000000..09cd58c5 --- /dev/null +++ b/komorebi-recorder/src/icon.rs @@ -0,0 +1,99 @@ +//! The icon of every window the program opens: a slice of watermelon. +//! +//! The picture is `assets/watermelon.svg`. `assets/generate.py` (`just icon`) renders it into `assets/watermelon.png`, +//! which this module embeds, and `assets/watermelon.ico`, which Windows shows for the executables (`build.rs` compiles +//! it into them) and for the installer. What each host does with the icon is in `docs/PLATFORM.md`. + +use eframe::egui; +use std::io::Cursor; + +/// The watermelon, 256 by 256 pixels of RGBA. +const PNG: &[u8] = include_bytes!("../assets/watermelon.png"); + +/// Returns the icon of the program's windows: a slice of watermelon on a transparent background, 256 by 256 pixels. +/// +/// The recorder window and the viewer pass it to [`egui::ViewportBuilder::with_icon`]. eframe sets it as the window and +/// taskbar icon on Windows and as the Dock icon on macOS. +/// +/// The picture is part of the binary, and a test holds that it decodes. If it ever did not, this returns an empty +/// [`egui::IconData`], which the window system takes to mean no icon: the window opens with the system's default. +/// +/// # Examples +/// +/// ```rust +/// let icon = komorebi_recorder::icon::window_icon(); +/// +/// assert_eq!((icon.width, icon.height), (256, 256)); +/// assert_eq!(icon.rgba.len(), 256 * 256 * 4); +/// +/// // the corner of the picture is transparent and its middle is not +/// assert_eq!(icon.rgba[3], 0); +/// assert_eq!(icon.rgba[(128 * 256 + 128) * 4 + 3], 255); +/// ``` +pub fn window_icon() -> egui::IconData { + decode().unwrap_or_default() +} + +/// The picture as RGBA, or nothing if it is not an RGBA PNG with eight bits a channel, which is what `generate.py` +/// writes. +fn decode() -> Option { + let mut reader = png::Decoder::new(Cursor::new(PNG)).read_info().ok()?; + let mut rgba = vec![0; reader.output_buffer_size()?]; + let info = reader.next_frame(&mut rgba).ok()?; + + (info.color_type == png::ColorType::Rgba && info.bit_depth == png::BitDepth::Eight).then(|| { + rgba.truncate(info.buffer_size()); + egui::IconData { + rgba, + width: info.width, + height: info.height, + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ICO: &[u8] = include_bytes!("../assets/watermelon.ico"); + + #[test] + fn the_embedded_picture_decodes() { + let icon = decode().expect("watermelon.png is an RGBA PNG with eight bits a channel"); + + assert_eq!(icon.rgba.len(), (icon.width * icon.height * 4) as usize); + assert_eq!(window_icon(), icon); + } + + #[test] + fn the_windows_icon_holds_the_same_picture_at_256_pixels() { + assert_eq!(ICO[..4], [0, 0, 1, 0], "an icon file starts with its type"); + let count = usize::from(u16::from_le_bytes([ICO[4], ICO[5]])); + assert!( + count > 1, + "one size is not enough for a taskbar and a window" + ); + + let mut widths = Vec::new(); + for entry in ICO[6..].as_chunks::<16>().0.iter().take(count) { + // a width of 0 means 256 + let width = entry[0]; + let pixels = if width == 0 { 256 } else { u32::from(width) }; + let length = u32::from_le_bytes([entry[8], entry[9], entry[10], entry[11]]) as usize; + let offset = u32::from_le_bytes([entry[12], entry[13], entry[14], entry[15]]) as usize; + let image = &ICO[offset..offset + length]; + + assert!( + image.starts_with(b"\x89PNG"), + "the {pixels} pixel entry is not a PNG" + ); + if width == 0 { + assert_eq!(image, PNG, "the 256 pixel entry is not watermelon.png"); + } + widths.push(width); + } + + assert!(widths.contains(&0), "no 256 pixel entry in {widths:?}"); + assert!(widths.contains(&16), "no 16 pixel entry in {widths:?}"); + } +} diff --git a/komorebi-recorder/src/lib.rs b/komorebi-recorder/src/lib.rs index c24f89b9..7ce6b28d 100644 --- a/komorebi-recorder/src/lib.rs +++ b/komorebi-recorder/src/lib.rs @@ -55,6 +55,7 @@ //! | [`annotations`] | the bug regions, notes and findings written into a session | //! | [`recovery`] | rebuilding a session whose recorder died | //! | [`cli`] | the command line | +//! | [`icon`] | the icon of the windows | //! | [`app`] | the recorder window | //! | [`av`] and [`selftest`] | decoding the recorded video and checking it against the clocks | //! | [`doctor`] | what a recording leans on, and probes that measure it | @@ -82,6 +83,7 @@ pub mod cli; pub mod clock; pub mod doctor; pub mod frames; +pub mod icon; pub mod incidents; pub mod komorebi_link; pub mod lock; diff --git a/komorebi-recorder/src/viewer/mod.rs b/komorebi-recorder/src/viewer/mod.rs index 1655eeab..fc332832 100644 --- a/komorebi-recorder/src/viewer/mod.rs +++ b/komorebi-recorder/src/viewer/mod.rs @@ -113,6 +113,7 @@ pub fn run(session: Option) -> eyre::Result<()> { // no title: komorebi leaves untitled windows alone, so opening the viewer does not // move the layout of the machine whose behavior is being investigated .with_title("") + .with_icon(crate::icon::window_icon()) .with_title_shown(false) .with_titlebar_shown(false) .with_fullsize_content_view(true) diff --git a/komorebi-recorder/tests/packaging.rs b/komorebi-recorder/tests/packaging.rs index 92c62c0c..d7bee2cf 100644 --- a/komorebi-recorder/tests/packaging.rs +++ b/komorebi-recorder/tests/packaging.rs @@ -138,3 +138,29 @@ fn the_license_the_installer_shows_is_there_and_is_the_projects() { "main.wxs does not refer to wix\\License.rtf" ); } + +#[test] +fn the_installer_shows_the_icon_the_executables_carry() { + let text = installer(); + + let id = attribute(&text, "Icon", "Id"); + let source = attribute(&text, "Icon", "SourceFile"); + assert!( + text.contains(&format!(" + + + +