From 38381c3b1e7b24fe6e2a4c445a194916ec5c335c Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 2 Jun 2026 16:45:45 +0800 Subject: [PATCH 1/2] feat(assets): accept both depot filename schemas in the GUI resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Older depots (the MX Vertical, most keyboards, older mice) ship metadata.json + front.png; newer ones ship core_metadata.json + front_core.png. Resolve each schema slot — hotspot metadata, hero render, buttons render — to whichever filename the depot's registry entry actually lists, instead of hardcoding the *_core names. - openlogi-assets: replace the CORE_FILES const with METADATA_FILES / FRONT_RENDER_FILES / BUTTONS_RENDER_FILES preference lists plus DeviceEntry::{preferred_file, baseline_files}. - gui resolver: probe both metadata names on disk and fall through both render schemas so an old-schema depot resolves instead of dropping to the synthetic silhouette. - gui + cli sync: fetch the per-depot baseline in either schema; warn, don't error, when a depot ships no metadata or render. Keyboards now render their hero image; key-level hotspots live under a different metadata image key (device_keys_image) and stay a separate feature. --- crates/openlogi-assets/src/index.rs | 89 ++++++++++++++++-- crates/openlogi-assets/src/lib.rs | 4 +- crates/openlogi-assets/src/metadata.rs | 6 +- crates/openlogi-cli/src/cmd/assets/sync.rs | 49 ++++++---- crates/openlogi-gui/src/asset/mod.rs | 103 +++++++++++++++++---- crates/openlogi-gui/src/asset/sync.rs | 22 +++-- 6 files changed, 216 insertions(+), 57 deletions(-) diff --git a/crates/openlogi-assets/src/index.rs b/crates/openlogi-assets/src/index.rs index cded707..59122be 100644 --- a/crates/openlogi-assets/src/index.rs +++ b/crates/openlogi-assets/src/index.rs @@ -54,15 +54,43 @@ pub struct FileEntry { pub bytes: u64, } -/// The files every depot must ship, fetched as the per-depot baseline by -/// both the CLI bundle sync and the GUI runtime sync: -/// -/// - `core_metadata.json` — hotspot percentages for the buttons overlay -/// - `manifest.json` — `extended_model_id` → colour-variant + resource-key -/// filename lookup -/// - `front_core.png` — the carousel render (and the buttons render on -/// simpler devices whose manifest points `device_buttons_image` at it) -pub const CORE_FILES: [&str; 3] = ["core_metadata.json", "manifest.json", "front_core.png"]; +/// Filename schemas Logi ships, most-preferred first. Newer depots use the +/// `*_core` names; older ones — most keyboards, the MX Vertical, older mice — +/// ship the bare names. A depot commits to one schema, never a mix, so +/// resolving each slot to the first name the registry actually lists picks +/// the right one. The manifest then maps `device_image` / +/// `device_buttons_image` to the concrete render for colour variants. +pub const METADATA_FILES: [&str; 2] = ["core_metadata.json", "metadata.json"]; +pub const FRONT_RENDER_FILES: [&str; 2] = ["front_core.png", "front.png"]; +pub const BUTTONS_RENDER_FILES: [&str; 2] = ["side_core.png", "side.png"]; + +impl DeviceEntry { + /// First of `candidates` this depot's registry file list contains — + /// the concrete filename for a schema slot (metadata / hero render / + /// buttons render). `None` when the depot ships none of them. + #[must_use] + pub fn preferred_file(&self, candidates: &[&'static str]) -> Option<&'static str> { + candidates + .iter() + .copied() + .find(|name| self.files.iter().any(|f| f.name == *name)) + } + + /// Baseline files both syncs fetch per depot: hotspot metadata (either + /// schema), the manifest, and the hero render (either schema). A slot + /// the depot doesn't ship is skipped — a camera/receiver depot with no + /// metadata or render contributes just the manifest, if even that. + #[must_use] + pub fn baseline_files(&self) -> Vec<&'static str> { + let mut files = Vec::with_capacity(3); + files.extend(self.preferred_file(&METADATA_FILES)); + if self.files.iter().any(|f| f.name == "manifest.json") { + files.push("manifest.json"); + } + files.extend(self.preferred_file(&FRONT_RENDER_FILES)); + files + } +} impl Index { pub fn load_from(path: &Path) -> anyhow::Result { @@ -148,4 +176,47 @@ mod tests { let index = index_with("mx_master_3s", "2b043", "MX Master 3S"); assert!(index.find_by_display_name("MX Master 3").is_none()); } + + fn entry_with_files(names: &[&str]) -> DeviceEntry { + let mut e = entry("2b043", "MX Master 3S"); + e.files = names + .iter() + .map(|name| FileEntry { + name: (*name).to_string(), + sha256: String::new(), + bytes: 0, + }) + .collect(); + e + } + + #[test] + fn baseline_files_resolves_core_schema() { + let e = entry_with_files(&["core_metadata.json", "manifest.json", "front_core.png"]); + assert_eq!( + e.baseline_files(), + ["core_metadata.json", "manifest.json", "front_core.png"] + ); + } + + #[test] + fn baseline_files_resolves_old_schema() { + // MX Vertical / most keyboards ship the bare names — the same slots + // resolve to `metadata.json` + `front.png`. + let e = entry_with_files(&["metadata.json", "manifest.json", "front.png", "side.png"]); + assert_eq!( + e.baseline_files(), + ["metadata.json", "manifest.json", "front.png"] + ); + assert_eq!(e.preferred_file(&BUTTONS_RENDER_FILES), Some("side.png")); + } + + #[test] + fn baseline_files_skips_missing_slots() { + // A depot with no hotspot metadata or render (camera/receiver) + // contributes only the manifest. + let e = entry_with_files(&["manifest.json"]); + assert_eq!(e.baseline_files(), ["manifest.json"]); + assert_eq!(e.preferred_file(&METADATA_FILES), None); + } } diff --git a/crates/openlogi-assets/src/lib.rs b/crates/openlogi-assets/src/lib.rs index 8304b15..4bc8a5c 100644 --- a/crates/openlogi-assets/src/lib.rs +++ b/crates/openlogi-assets/src/lib.rs @@ -16,6 +16,8 @@ pub mod manifest; pub mod metadata; pub use http::{AssetClient, FetchOutcome, cached_matches, read_bytes, sha256_hex, sha256_of_file}; -pub use index::{CORE_FILES, DeviceEntry, FileEntry, Index}; +pub use index::{ + BUTTONS_RENDER_FILES, DeviceEntry, FRONT_RENDER_FILES, FileEntry, Index, METADATA_FILES, +}; pub use manifest::{DepotManifest, ManifestDevice, ManifestResource, variant_model_id}; pub use metadata::{Assignment, Direction, ImageEntry, Metadata, Origin, Point}; diff --git a/crates/openlogi-assets/src/metadata.rs b/crates/openlogi-assets/src/metadata.rs index a35291c..39db484 100644 --- a/crates/openlogi-assets/src/metadata.rs +++ b/crates/openlogi-assets/src/metadata.rs @@ -3,8 +3,10 @@ reason = "full schema parsed; label direction codes + extra coords land in later phases" )] -//! Parses the per-depot `core_metadata.json` shipped by the Logi Options+ -//! installer (and re-hosted by assets.openlogi.org). +//! Parses the per-depot hotspot metadata shipped by the Logi Options+ +//! installer (and re-hosted by assets.openlogi.org) — `core_metadata.json` +//! on newer depots, `metadata.json` on older ones. Same schema either way; +//! the caller picks the filename and hands the path to [`Metadata::load_from`]. //! //! Only the fields OpenLogi actually consumes are deserialized — every //! other field is silently ignored. The schema below is observed-from-the- diff --git a/crates/openlogi-cli/src/cmd/assets/sync.rs b/crates/openlogi-cli/src/cmd/assets/sync.rs index f3455b9..7e71e01 100644 --- a/crates/openlogi-cli/src/cmd/assets/sync.rs +++ b/crates/openlogi-cli/src/cmd/assets/sync.rs @@ -12,22 +12,24 @@ use std::path::PathBuf; use anyhow::{Context as _, Result}; use clap::Args; -use openlogi_assets::{CORE_FILES, FetchOutcome, http}; +use openlogi_assets::{FRONT_RENDER_FILES, FetchOutcome, METADATA_FILES, http}; /// Default origin. Overridable via `--base` / `OPENLOGI_ASSETS`. const DEFAULT_BASE: &str = "https://assets.openlogi.org"; /// Returns true when `name` is an *optional* asset OpenLogi fetches when the /// registry lists it but never warns about when it's absent: -/// - `side_core.png` — the dedicated buttons render, present only on devices -/// (e.g. MX Master) whose `device_buttons_image` is a distinct side view. -/// Devices that reuse `front_core.png` simply don't ship one. -/// - `front_ext_N.png` / `side_ext_N.png` — per-colour variants for the -/// carousel and the buttons-config view. +/// - `side_core.png` / `side.png` — the dedicated buttons render, present +/// only on devices (e.g. MX Master) whose `device_buttons_image` is a +/// distinct side view. Devices that reuse the hero render don't ship one. +/// - `front_ext*.png` / `side_ext*.png` — per-colour variants for the +/// carousel and the buttons-config view. Newer depots name them +/// `front_ext_N`, older ones `front_extN`; the `front_ext` prefix covers +/// both. /// /// (`back_*` renders stay remote until an easyswitch view needs them.) fn is_optional_asset(name: &str) -> bool { - if name == "side_core.png" { + if name == "side_core.png" || name == "side.png" { return true; } let path = std::path::Path::new(name); @@ -37,7 +39,7 @@ fn is_optional_asset(name: &str) -> bool { if !ext_is_png { return false; } - name.starts_with("front_ext_") || name.starts_with("side_ext_") + name.starts_with("front_ext") || name.starts_with("side_ext") } #[derive(Debug, Args)] @@ -83,18 +85,22 @@ pub fn run(args: SyncArgs) -> Result<()> { let dir = out.join(depot); fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; - // Required core set + every optional asset (side render + colour - // variants) the registry lists. Only a *required* file's absence - // warns; optional ones are simply skipped when not present. + // Per-depot baseline (metadata + manifest + hero render, either + // schema) + every optional asset (side render + colour variants) + // the registry lists. A depot that ships no hotspot metadata or + // hero render won't render in the GUI (cameras, receivers, bare + // keyboards) — warn, but still bundle whatever it does have. + let baseline = entry.baseline_files(); let wanted: Vec<&openlogi_assets::FileEntry> = entry .files .iter() - .filter(|f| CORE_FILES.contains(&f.name.as_str()) || is_optional_asset(&f.name)) + .filter(|f| baseline.contains(&f.name.as_str()) || is_optional_asset(&f.name)) .collect(); - for required in CORE_FILES { - if !wanted.iter().any(|f| f.name == required) { - eprintln!(" WARN {depot}: registry missing {required}"); - } + if entry.preferred_file(&METADATA_FILES).is_none() { + eprintln!(" WARN {depot}: no hotspot metadata (core_metadata.json / metadata.json)"); + } + if entry.preferred_file(&FRONT_RENDER_FILES).is_none() { + eprintln!(" WARN {depot}: no hero render (front_core.png / front.png)"); } for &file_entry in &wanted { @@ -111,9 +117,14 @@ pub fn run(args: SyncArgs) -> Result<()> { let bundle_bytes: u64 = index .devices .values() - .flat_map(|d| d.files.iter()) - .filter(|f| CORE_FILES.contains(&f.name.as_str()) || is_optional_asset(&f.name)) - .map(|f| f.bytes) + .map(|d| { + let baseline = d.baseline_files(); + d.files + .iter() + .filter(|f| baseline.contains(&f.name.as_str()) || is_optional_asset(&f.name)) + .map(|f| f.bytes) + .sum::() + }) .sum(); #[allow( clippy::cast_precision_loss, diff --git a/crates/openlogi-gui/src/asset/mod.rs b/crates/openlogi-gui/src/asset/mod.rs index d2fd804..dab0209 100644 --- a/crates/openlogi-gui/src/asset/mod.rs +++ b/crates/openlogi-gui/src/asset/mod.rs @@ -19,7 +19,9 @@ pub mod sync; use std::path::{Path, PathBuf}; -use openlogi_assets::{DeviceEntry, Index, Metadata}; +use openlogi_assets::{ + BUTTONS_RENDER_FILES, DeviceEntry, FRONT_RENDER_FILES, Index, METADATA_FILES, Metadata, +}; use openlogi_core::device::DeviceModelInfo; use tracing::{debug, warn}; @@ -109,10 +111,12 @@ impl AssetResolver { ) -> Option { for root in &self.read_roots { let dir = root.join(depot); - let meta_path = dir.join("core_metadata.json"); - if !meta_path.exists() { + // Hotspot metadata in whichever schema this depot cached: + // `core_metadata.json` (newer) or `metadata.json` (older). + let Some(&meta_name) = METADATA_FILES.iter().find(|n| dir.join(n).exists()) else { continue; - } + }; + let meta_path = dir.join(meta_name); // Pick the colour variant matching this device's HID++ // extended_model_id byte. Logi calibrates the assignment @@ -130,18 +134,13 @@ impl AssetResolver { // The chosen file may not have been synced (older bundles // shipped front-only); fall back through alternatives so a // stale cache still gets *something* rather than a synthetic - // silhouette. - let candidates = [ - image_name.clone(), - "side_core.png".to_string(), - variant_front_name.unwrap_or_default(), - "front_core.png".to_string(), - ]; - let Some(image_path) = candidates - .iter() - .filter(|n| !n.is_empty()) - .map(|n| dir.join(n)) - .find(|p| p.exists()) + // silhouette. Both filename schemas (`*_core` and bare) are + // tried for each of the buttons and hero renders. + let mut candidates = vec![image_name.clone()]; + candidates.extend(BUTTONS_RENDER_FILES.map(str::to_string)); + candidates.extend(variant_front_name); + candidates.extend(FRONT_RENDER_FILES.map(str::to_string)); + let Some(image_path) = candidates.iter().map(|n| dir.join(n)).find(|p| p.exists()) else { continue; }; @@ -149,7 +148,7 @@ impl AssetResolver { let metadata = match Metadata::load_from(&meta_path) { Ok(m) => m, Err(e) => { - warn!(depot, root = %root.display(), error = ?e, "failed to parse core_metadata.json"); + warn!(depot, root = %root.display(), file = meta_name, error = ?e, "failed to parse device metadata"); continue; } }; @@ -268,6 +267,7 @@ fn suffix_candidates(model: &DeviceModelInfo) -> Vec { } #[cfg(test)] +#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")] mod tests { use super::*; use openlogi_assets::DeviceEntry; @@ -323,4 +323,73 @@ mod tests { let hit = resolve_in_index(&index, &btle_3s_model(), Some("MX Master 3S")); assert_eq!(hit.map(|(depot, _)| depot), Some("mx_master_3s")); } + + fn bare_model() -> DeviceModelInfo { + DeviceModelInfo { + entity_count: 0, + serial_number: None, + unit_id: [0; 4], + transports: DeviceTransports::default(), + model_ids: [0; 3], + extended_model_id: 0, + } + } + + /// A 24-byte PNG: signature + an `IHDR` chunk header carrying only the + /// width/height — all `read_png_dimensions` actually reads. + fn png_header(width: u32, height: u32) -> Vec { + let mut bytes = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]; + bytes.extend_from_slice(&13u32.to_be_bytes()); + bytes.extend_from_slice(b"IHDR"); + bytes.extend_from_slice(&width.to_be_bytes()); + bytes.extend_from_slice(&height.to_be_bytes()); + bytes + } + + /// An old-schema depot (`metadata.json` + `front.png`, no `*_core` + /// names, no manifest) must still resolve — this is what makes the + /// MX Vertical and the older mice render. + #[test] + fn resolves_old_schema_depot_on_disk() { + let root = std::env::temp_dir().join(format!("openlogi-asset-test-{}", std::process::id())); + let depot = "mx_vertical"; + let dir = root.join(depot); + std::fs::create_dir_all(&dir).expect("create depot dir"); + std::fs::write( + dir.join("metadata.json"), + r#"{"images":[ + {"key":"device_image","origin":{"width":100,"height":200}}, + {"key":"device_buttons_image","origin":{"width":100,"height":200}, + "assignments":[{"slotName":"SLOT_NAME_MIDDLE_BUTTON", + "marker":{"x":50,"y":50},"label":{"x":0,"y":0}}]} + ]}"#, + ) + .expect("write metadata.json"); + std::fs::write(dir.join("front.png"), png_header(100, 200)).expect("write front.png"); + + let resolver = AssetResolver { + read_roots: vec![root.clone()], + write_root: root.clone(), + has_bundle: false, + index: None, + }; + let entry = DeviceEntry { + model_id: "eb020".to_string(), + display_name: "MX Vertical".to_string(), + kind: "MOUSE".to_string(), + asset_path: format!("v1/devices/{depot}/"), + files: Vec::new(), + }; + + let result = resolver.load_files(depot, &entry, &bare_model()); + std::fs::remove_dir_all(&root).ok(); + + let asset = result.expect("old-schema depot should resolve"); + assert_eq!( + asset.image_path.file_name().expect("image has a file name"), + "front.png" + ); + assert_eq!((asset.png_width, asset.png_height), (100, 200)); + assert_eq!(asset.metadata.assignments().count(), 1); + } } diff --git a/crates/openlogi-gui/src/asset/sync.rs b/crates/openlogi-gui/src/asset/sync.rs index c8202dc..a470087 100644 --- a/crates/openlogi-gui/src/asset/sync.rs +++ b/crates/openlogi-gui/src/asset/sync.rs @@ -12,7 +12,7 @@ use std::path::Path; use anyhow::{Context as _, Result}; use openlogi_assets::http; -use openlogi_assets::{CORE_FILES, DepotManifest, DeviceEntry, FetchOutcome}; +use openlogi_assets::{BUTTONS_RENDER_FILES, DepotManifest, DeviceEntry, FetchOutcome}; use openlogi_core::device::DeviceModelInfo; use tracing::{debug, info, warn}; @@ -101,19 +101,20 @@ fn sync_depot( let dir = cache_root.join(depot); fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; - // Baseline: metadata + manifest + base PNG. Manifest is mandatory - // so the variant lookup below has something to consult. - for name in CORE_FILES { + // Baseline: hotspot metadata + manifest + hero render, in whichever + // schema this depot ships (`*_core` or the bare names). Manifest is + // fetched here so the variant lookup below has something to consult. + for name in entry.baseline_files() { fetch_to_cache(client, &entry.asset_path, &dir, entry, name)?; } // Dedicated buttons render — only present on devices whose manifest // points `device_buttons_image` at a distinct side view. Fetch it only // when the registry lists it so front-only devices don't 404; failure - // is non-fatal (the GUI falls back to `front_core.png`). - if entry.files.iter().any(|f| f.name == "side_core.png") { - if let Err(e) = fetch_to_cache(client, &entry.asset_path, &dir, entry, "side_core.png") { - warn!(depot, error = %e, "side_core.png fetch failed"); + // is non-fatal (the GUI falls back to the hero render). + if let Some(side) = entry.preferred_file(&BUTTONS_RENDER_FILES) { + if let Err(e) = fetch_to_cache(client, &entry.asset_path, &dir, entry, side) { + warn!(depot, error = %e, "buttons render fetch failed"); } } @@ -129,7 +130,10 @@ fn sync_depot( else { continue; }; - if variant == "front_core.png" || variant == "side_core.png" { + if matches!( + variant.as_str(), + "front_core.png" | "front.png" | "side_core.png" | "side.png" + ) { continue; } if let Err(e) = fetch_to_cache(client, &entry.asset_path, &dir, entry, &variant) { From ee5f28e472d7eb4ad5b31e247a81d1c25cead22e Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 2 Jun 2026 17:02:05 +0800 Subject: [PATCH 2/2] docs(assets): drop stale CORE_FILES reference in sync comment The variant second-pass comment still pointed at CORE_FILES, removed in this PR in favour of DeviceEntry::baseline_files(). Per Pullfrog review. --- crates/openlogi-gui/src/asset/sync.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/openlogi-gui/src/asset/sync.rs b/crates/openlogi-gui/src/asset/sync.rs index a470087..63ef804 100644 --- a/crates/openlogi-gui/src/asset/sync.rs +++ b/crates/openlogi-gui/src/asset/sync.rs @@ -121,8 +121,8 @@ fn sync_depot( // Optional second pass: download the colour variant PNGs matching // the connected device's `extended_model_id`, for both the front // (carousel) and the side / buttons (mouse-model) views. Failure is - // non-fatal — `AssetResolver.load_files` falls back to the bare core - // PNG that came in with `CORE_FILES`. + // non-fatal — `AssetResolver.load_files` falls back to the bare hero + // render that came in with the baseline fetch above. let manifest_path = dir.join("manifest.json"); for resource_key in ["device_image", "device_buttons_image"] { let Some(variant) =