Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
89 changes: 80 additions & 9 deletions crates/openlogi-assets/src/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self> {
Expand Down Expand Up @@ -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);
}
}
4 changes: 3 additions & 1 deletion crates/openlogi-assets/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
6 changes: 4 additions & 2 deletions crates/openlogi-assets/src/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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-
Expand Down
49 changes: 30 additions & 19 deletions crates/openlogi-cli/src/cmd/assets/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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)]
Expand Down Expand Up @@ -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 {
Expand All @@ -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::<u64>()
})
.sum();
#[allow(
clippy::cast_precision_loss,
Expand Down
103 changes: 86 additions & 17 deletions crates/openlogi-gui/src/asset/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -109,10 +111,12 @@ impl AssetResolver {
) -> Option<ResolvedAsset> {
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
Expand All @@ -130,26 +134,21 @@ 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;
};

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;
}
};
Expand Down Expand Up @@ -268,6 +267,7 @@ fn suffix_candidates(model: &DeviceModelInfo) -> Vec<String> {
}

#[cfg(test)]
#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
mod tests {
use super::*;
use openlogi_assets::DeviceEntry;
Expand Down Expand Up @@ -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<u8> {
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);
}
}
Loading
Loading