diff --git a/Cargo.lock b/Cargo.lock index 2af1fbe..589aca7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6957,6 +6957,8 @@ dependencies = [ "keyring", "solitaire_data", "solitaire_engine", + "tiny-skia 0.12.0", + "winit", ] [[package]] diff --git a/assets/icon/icon_1024.png b/assets/icon/icon_1024.png new file mode 100644 index 0000000..592699f Binary files /dev/null and b/assets/icon/icon_1024.png differ diff --git a/assets/icon/icon_128.png b/assets/icon/icon_128.png new file mode 100644 index 0000000..ebb7168 Binary files /dev/null and b/assets/icon/icon_128.png differ diff --git a/assets/icon/icon_16.png b/assets/icon/icon_16.png new file mode 100644 index 0000000..f881f42 Binary files /dev/null and b/assets/icon/icon_16.png differ diff --git a/assets/icon/icon_24.png b/assets/icon/icon_24.png new file mode 100644 index 0000000..ac9b95b Binary files /dev/null and b/assets/icon/icon_24.png differ diff --git a/assets/icon/icon_256.png b/assets/icon/icon_256.png new file mode 100644 index 0000000..9bd78c7 Binary files /dev/null and b/assets/icon/icon_256.png differ diff --git a/assets/icon/icon_32.png b/assets/icon/icon_32.png new file mode 100644 index 0000000..9f27009 Binary files /dev/null and b/assets/icon/icon_32.png differ diff --git a/assets/icon/icon_48.png b/assets/icon/icon_48.png new file mode 100644 index 0000000..a52db6a Binary files /dev/null and b/assets/icon/icon_48.png differ diff --git a/assets/icon/icon_512.png b/assets/icon/icon_512.png new file mode 100644 index 0000000..dc1d577 Binary files /dev/null and b/assets/icon/icon_512.png differ diff --git a/assets/icon/icon_64.png b/assets/icon/icon_64.png new file mode 100644 index 0000000..054097c Binary files /dev/null and b/assets/icon/icon_64.png differ diff --git a/solitaire_app/Cargo.toml b/solitaire_app/Cargo.toml index 3577a9e..0d25fd6 100644 --- a/solitaire_app/Cargo.toml +++ b/solitaire_app/Cargo.toml @@ -22,16 +22,25 @@ bevy = { workspace = true } solitaire_engine = { workspace = true } solitaire_data = { workspace = true } -# `keyring`'s default-store init only matters on platforms with a -# real keychain backend (Linux Secret Service, macOS Keychain, -# Windows Credential Store). The crate also pulls `rpassword` -# transitively, which uses `libc::__errno_location` — a symbol -# Android's bionic doesn't expose. Target-gating keeps -# `cargo apk build` viable; the call site in `lib.rs` has its own -# `cfg(not(target_os = "android"))` guard so the desktop init path -# is unchanged. +# Desktop-only deps. `keyring`'s default-store init only matters on +# platforms with a real keychain backend (Linux Secret Service, +# macOS Keychain, Windows Credential Store), and its transitive +# `rpassword` uses `libc::__errno_location` — a symbol Android's +# bionic doesn't expose. `winit` is promoted from a transitive +# Bevy 0.18 → bevy_winit 0.18 → winit 0.30 dep to a direct dep so +# the `Window::icon` wiring in `set_window_icon` can construct +# `winit::window::Icon` values (bevy_winit 0.18 doesn't re-export +# `Icon`). Android draws its launcher icon from the APK manifest, +# so neither dep matters there. Target-gating keeps `cargo apk +# build` viable; the desktop call sites have their own +# `cfg(not(target_os = "android"))` guards. [target.'cfg(not(target_os = "android"))'.dependencies] -keyring = { workspace = true } +keyring = { workspace = true } +winit = { version = "0.30", default-features = false } +# `tiny-skia` is already in the workspace deps for `solitaire_engine`; +# `solitaire_app` consumes it directly only on the desktop icon path +# (PNG → raw RGBA decode for `set_window_icon`). +tiny-skia = { workspace = true } # --- Android packaging metadata (read by `cargo-apk`) ------------------- # diff --git a/solitaire_app/src/lib.rs b/solitaire_app/src/lib.rs index 52e9568..06650f5 100644 --- a/solitaire_app/src/lib.rs +++ b/solitaire_app/src/lib.rs @@ -21,6 +21,8 @@ use bevy::prelude::*; use bevy::window::{ Monitor, MonitorSelection, PresentMode, PrimaryMonitor, PrimaryWindow, WindowPosition, }; +#[cfg(not(target_os = "android"))] +use bevy::winit::WinitWindows; use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings}; use solitaire_engine::{ register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, @@ -174,6 +176,14 @@ pub fn run() { .add_plugins(SplashPlugin) .add_plugins(DiagnosticsHudPlugin); + // Wire the runtime window icon. Bevy 0.18 has no first-class + // `Window::icon` field; the icon is set through the underlying + // `winit::window::Window` via `WinitWindows`. Android draws its + // launcher icon from the APK manifest, so the system is desktop- + // only — same target-gate as the `winit` dep itself. + #[cfg(not(target_os = "android"))] + app.add_systems(Update, set_window_icon); + // Smart default window sizing: when no saved geometry was loaded, // resize the freshly-opened 1280×800 window to ~70 % of the primary // monitor's logical size on the first frame. Without this, a 4K @@ -251,6 +261,71 @@ fn apply_smart_default_window_size( *applied = true; } +/// One-shot Update system that sets the primary window's taskbar / +/// title-bar icon to the embedded 256 px Terminal-aesthetic mark +/// generated by `solitaire_engine/examples/icon_generator.rs`. +/// +/// Bevy 0.18 has no `Window::icon` field — the icon is set through +/// the underlying `winit::window::Window` via the `WinitWindows` +/// resource. The system is desktop-only (Android draws its launcher +/// icon from the APK manifest, not from any runtime call). Returns +/// silently and tries again next frame until both the primary +/// window and `WinitWindows` are populated, then sets the icon +/// once and self-disables via `Local`. +/// +/// Icon bytes are `include_bytes!()`-embedded at compile time, same +/// shape as the audio assets and default-theme SVGs — no runtime +/// asset-path resolution, no `cargo run` working-directory +/// assumptions. PNG → RGBA decode runs through `tiny_skia` (already +/// in the build for SVG rasterisation), so this system adds zero +/// new dependencies on top of the direct `winit` dep that's +/// already required for `Icon` construction. +#[cfg(not(target_os = "android"))] +fn set_window_icon( + mut applied: Local, + primary_window: Query>, + winit_windows: NonSend, +) { + if *applied { + return; + } + let Ok(primary_entity) = primary_window.single() else { + return; + }; + let Some(window_wrapper) = winit_windows.get_window(primary_entity) else { + // Primary window's underlying winit handle not yet + // populated — `WinitWindows` fills in after the first + // `Resumed` event. Try again next frame. + return; + }; + + // The 256 × 256 PNG is sufficient for `set_window_icon`; winit + // scales it for the actual rendered size. Smaller PNGs in + // `assets/icon/` exist for downstream Linux hicolor / Windows + // `.ico` / macOS `.icns` packaging — they're not used here. + const ICON_BYTES: &[u8] = include_bytes!("../../assets/icon/icon_256.png"); + + let pixmap = match tiny_skia::Pixmap::decode_png(ICON_BYTES) { + Ok(p) => p, + Err(e) => { + eprintln!("warn: could not decode embedded window icon PNG: {e}"); + *applied = true; // don't retry every frame + return; + } + }; + let rgba = pixmap.data().to_vec(); + let icon = match winit::window::Icon::from_rgba(rgba, pixmap.width(), pixmap.height()) { + Ok(i) => i, + Err(e) => { + eprintln!("warn: could not construct window icon: {e}"); + *applied = true; + return; + } + }; + window_wrapper.set_window_icon(Some(icon)); + *applied = true; +} + /// Wraps the default panic hook with one that also appends a crash log /// to `/crash.log` (next to `settings.json`). The default hook /// still runs afterwards, so stderr output and debugger integration are diff --git a/solitaire_engine/examples/icon_generator.rs b/solitaire_engine/examples/icon_generator.rs new file mode 100644 index 0000000..b7a0184 --- /dev/null +++ b/solitaire_engine/examples/icon_generator.rs @@ -0,0 +1,67 @@ +//! Application-icon generator — rasterises the project's icon SVG +//! into `assets/icon/icon_.png` at every size in +//! `card_face_svg::ICON_SIZES` (16, 24, 32, 48, 64, 128, 256, 512, +//! 1024). Sufficient to cover Linux hicolor, Windows `.ico`, and +//! macOS `.icns` packaging targets — and the runtime `Window::icon` +//! wiring picks the 256 px slot. +//! +//! Run with: +//! +//! ```sh +//! cargo run --example icon_generator --release +//! ``` +//! +//! Same shape as `card_face_generator`: SVG builder lives in +//! `solitaire_engine::assets::icon_svg` so the `icon_svg_pin` +//! integration test can call it. Rasterisation runs through +//! `assets::rasterize_svg` (the `usvg` + `resvg` + `tiny_skia` +//! pipeline already used by every other generated asset). + +use bevy::math::UVec2; +use solitaire_engine::assets::icon_svg::{icon_svg, ICON_SIZES}; +use solitaire_engine::assets::rasterize_svg; +use std::path::PathBuf; +use tiny_skia::{IntSize, Pixmap}; + +fn main() { + let icon_dir = workspace_assets_dir().join("icon"); + std::fs::create_dir_all(&icon_dir).expect("create icon dir"); + + let svg = icon_svg(); + + for &size in ICON_SIZES { + let target = UVec2::new(size, size); + let pixmap = rasterize_to_pixmap(&svg, target); + let path = icon_dir.join(format!("icon_{size}.png")); + pixmap + .save_png(&path) + .unwrap_or_else(|e| panic!("write {}: {e}", path.display())); + } + + println!( + "Wrote {} PNGs ({}–{} px) to {}", + ICON_SIZES.len(), + ICON_SIZES.iter().min().copied().unwrap_or(0), + ICON_SIZES.iter().max().copied().unwrap_or(0), + icon_dir.display(), + ); +} + +fn rasterize_to_pixmap(svg: &str, target: UVec2) -> Pixmap { + let image = rasterize_svg(svg.as_bytes(), target).expect("rasterise icon SVG"); + let bytes = image.data.expect("rasterised image carries pixel data"); + debug_assert_eq!( + bytes.len(), + (target.x * target.y * 4) as usize, + "rasterised buffer must match width × height × 4 RGBA bytes", + ); + let size = IntSize::from_wh(target.x, target.y).expect("non-zero target size"); + Pixmap::from_vec(bytes, size).expect("RGBA buffer forms a valid Pixmap") +} + +fn workspace_assets_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("solitaire_engine crate has a workspace-root parent") + .join("assets") +} diff --git a/solitaire_engine/src/assets/icon_svg.rs b/solitaire_engine/src/assets/icon_svg.rs new file mode 100644 index 0000000..8cc1f94 --- /dev/null +++ b/solitaire_engine/src/assets/icon_svg.rs @@ -0,0 +1,70 @@ +//! SVG builder for the Solitaire Quest application icon. +//! +//! Renders the project's signature `▌RS` Terminal mark (the same +//! cursor-block + monogram pair used on the splash boot-screen and +//! card backs) on a dark `#151515` background with a 1 px brick-red +//! border. Square aspect, authored in a 64-unit logical box and +//! scaled at the rasterisation site. +//! +//! Reads at every size from 16 px taskbar tile to 1024 px macOS +//! Retina icon — the high-contrast cursor block carries the +//! recognition load and the smaller `RS` letters sit beneath as +//! a secondary recognition cue. +//! +//! Same SVG-to-PNG pipeline as `card_face_svg` — `icon_generator` +//! example rasterises this at multiple target sizes and writes +//! into `assets/icon/`. The `icon_svg_pin` integration test hashes +//! rasterised RGBA bytes to guard against `usvg`/`resvg` drift. + +use bevy::math::UVec2; + +/// Default rasterisation target — single canonical size used by the +/// runtime `Window::icon` wiring. The generator example emits +/// additional sizes (16, 32, 48, 64, 128, 256, 512, 1024) for the +/// Linux hicolor hierarchy and for downstream `.ico` / `.icns` +/// packaging. +pub const TARGET: UVec2 = UVec2::new(256, 256); + +/// Every size the `icon_generator` example emits. Covers Linux +/// hicolor (16, 24, 32, 48, 64, 128, 256, 512), Windows `.ico` +/// targets (16, 32, 48, 256), and macOS `.icns` targets (16, 32, +/// 64, 128, 256, 512, 1024). +pub const ICON_SIZES: &[u32] = &[16, 24, 32, 48, 64, 128, 256, 512, 1024]; + +const BG: &str = "#151515"; // BG_BASE +const ACCENT: &str = "#a54242"; // ACCENT_PRIMARY brick red +const FG: &str = "#d0d0d0"; // TEXT_PRIMARY + +/// Build the icon SVG. Square aspect, 64 logical units per side. +pub fn icon_svg() -> String { + // Layout in a 64×64 logical box: + // border: 1 logical unit, brick-red, inset 0.5 to + // centre the stroke inside the pixmap. + // corner radius: 6 units (~9 % of side, scales smoothly down + // to 16 px where it disappears into pixel grid). + // `▌` cursor: 18 px tall, 6 px wide, brick-red, centred + // horizontally, sitting on a baseline at y=40 + // so there's room for `RS` beneath it. + // `RS` mark: 14 px FiraMono Bold at y=58, foreground gray, + // letter-spaced for readability at small sizes. + // + // The `▌` glyph is U+258C (LEFT HALF BLOCK) — same character the + // splash and card-back monogram use, rendered upright at icon + // scale. FiraMono carries this at usable size (verified by the + // splash + card-back rendering), so `` is safe here unlike + // the suit glyphs. + format!( + r##" + + + + + + + RS +"## + ) +} diff --git a/solitaire_engine/src/assets/mod.rs b/solitaire_engine/src/assets/mod.rs index 078647b..e016757 100644 --- a/solitaire_engine/src/assets/mod.rs +++ b/solitaire_engine/src/assets/mod.rs @@ -7,6 +7,7 @@ //! `AssetSource` implementations for `embedded://` and `themes://`. pub mod card_face_svg; +pub mod icon_svg; pub mod sources; pub mod svg_loader; pub mod user_dir; diff --git a/solitaire_engine/tests/icon_svg_pin.rs b/solitaire_engine/tests/icon_svg_pin.rs new file mode 100644 index 0000000..8e0bdad --- /dev/null +++ b/solitaire_engine/tests/icon_svg_pin.rs @@ -0,0 +1,114 @@ +//! Pinning test for the application-icon SVG builder. +//! +//! Hashes the raw RGBA8 pixel bytes produced by rasterising +//! `icon_svg()` at every size in `ICON_SIZES`, compares each hash +//! to an embedded constant, and fails on any drift. Catches +//! `usvg`/`resvg`/`tiny_skia` rendering changes and any +//! intentional builder edit that wasn't paired with a hash +//! refresh. +//! +//! When the icon SVG changes intentionally (or a dependency +//! upgrade legitimately changes rendering), update `EXPECTED` by +//! emptying it (`&[]`) and re-running this test once — the test +//! will panic with the new hashes formatted as Rust source ready +//! to paste back in. Same bootstrap pattern as +//! `card_face_svg_pin.rs`. + +use bevy::math::UVec2; +use solitaire_engine::assets::icon_svg::{icon_svg, ICON_SIZES}; +use solitaire_engine::assets::rasterize_svg; + +const EXPECTED: &[(u32, u64)] = &[ + (16, 0x07e641beea430d66), + (24, 0x24e66767f4756a60), + (32, 0xf22a3104623a3873), + (48, 0x2d7f978cf7b12763), + (64, 0x1b377e3e30202eba), + (128, 0xafdc80f901b45518), + (256, 0x82b5b46f73c5921d), + (512, 0xe14c018e1e285209), + (1024, 0xfcd0a6a3beb68bdb), +]; + +#[test] +fn rasterised_icon_bytes_match_pinned_hashes() { + let actual = compute_actual_hashes(); + + if EXPECTED.is_empty() { + panic_with_hashes_to_paste(&actual); + } + + assert_eq!( + actual.len(), + EXPECTED.len(), + "icon-size count drifted (actual {} vs expected {})", + actual.len(), + EXPECTED.len(), + ); + + let mut mismatches: Vec = Vec::new(); + for ((actual_size, actual_hash), (expected_size, expected_hash)) in + actual.iter().zip(EXPECTED.iter()) + { + assert_eq!( + actual_size, expected_size, + "icon-size order drifted", + ); + if actual_hash != expected_hash { + mismatches.push(format!( + " icon_{actual_size}: actual 0x{actual_hash:016x} expected 0x{expected_hash:016x}", + )); + } + } + + if !mismatches.is_empty() { + let mut msg = String::from( + "rasterised icon bytes drifted from EXPECTED — usvg/resvg/tiny_skia/font upgrade?\n", + ); + for m in &mismatches { + msg.push_str(m); + msg.push('\n'); + } + msg.push_str( + "\nIf this drift is intentional, replace EXPECTED with `&[]` and re-run\nthis test to print fresh hashes.\n", + ); + panic!("{msg}"); + } +} + +fn compute_actual_hashes() -> Vec<(u32, u64)> { + let svg = icon_svg(); + ICON_SIZES + .iter() + .map(|&size| (size, hash_rasterised(&svg, size))) + .collect() +} + +fn hash_rasterised(svg: &str, size: u32) -> u64 { + let target = UVec2::new(size, size); + let image = rasterize_svg(svg.as_bytes(), target).expect("rasterise icon SVG"); + let bytes = image.data.expect("rasterised image carries RGBA pixel data"); + fnv1a(&bytes) +} + +/// FNV-1a 64-bit, inline. Same shape as `card_face_svg_pin.rs` — +/// no cryptographic strength needed, just stable byte fingerprints. +fn fnv1a(bytes: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for &b in bytes { + h ^= b as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h +} + +fn panic_with_hashes_to_paste(actual: &[(u32, u64)]) -> ! { + let mut out = String::from( + "\nEXPECTED is empty — paste the following into the const literal:\n\nconst EXPECTED: &[(u32, u64)] = &[\n", + ); + for (size, hash) in actual { + out.push_str(&format!(" ({size}, 0x{hash:016x}),\n")); + } + out.push_str("];\n"); + panic!("{out}"); +}