Skip to content
Closed
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
13 changes: 13 additions & 0 deletions crates/openlogi-cli/src/cmd/assets/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,25 @@ use openlogi_assets::{AssetRegistry, FRONT_RENDER_FILES, FetchOutcome, METADATA_
/// carousel and the buttons-config view. Newer depots name them
/// `front_ext_N`, older ones `front_extN`; the `front_ext` prefix covers
/// both.
/// - `core_metadata_*.json` / `metadata_*.json` — per-variant hotspot
/// metadata. Depots whose variants are *handed* rather than coloured ship
/// no bare `core_metadata.json` at all (Lift has only
/// `core_metadata_left.json` / `core_metadata_right.json`), and the depot
/// manifest's `image_metadata` is what names the right one. Bundling them
/// is what keeps such a device off the generic silhouette offline.
///
/// (`back_*` renders stay remote until an easyswitch view needs them.)
fn is_optional_asset(name: &str) -> bool {
if name == "side_core.png" || name == "side.png" {
return true;
}
if (name.starts_with("core_metadata_") || name.starts_with("metadata_"))
&& std::path::Path::new(name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
{
return true;
}
let path = std::path::Path::new(name);
let ext_is_png = path
.extension()
Expand Down
8 changes: 7 additions & 1 deletion crates/openlogi-desktop/src/app/menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,13 @@ pub fn rebuild(cx: &mut App) {
/// Run a manual update check and open Settings → Updates where its status is
/// rendered. Shared by the app menu and agent tray IPC commands.
pub fn check_for_updates(cx: &mut App) {
if let Some(updater) = crate::platform::updater::shared(cx) {
// Where releases ship no in-place-updatable artifact (Linux: distro
// packages only), running the check just resolves to "no release asset
// matched the current platform". Open the Updates page anyway — it says
// where updates come from on this install.
if crate::platform::updater::IN_APP_UPDATES
&& let Some(updater) = crate::platform::updater::shared(cx)
{
updater.update(cx, gpui_updater::Updater::check);
}
crate::windows::settings::open_at(crate::windows::settings::SettingsPage::Updates, cx);
Expand Down
20 changes: 16 additions & 4 deletions crates/openlogi-desktop/src/platform/updater.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ const MANIFEST_URL: &str = match option_env!("OPENLOGI_UPDATE_MANIFEST_URL") {
/// Absent in local/dev builds, which then fail closed (see [`new_entity`]).
const MINISIGN_PUBLIC_KEY: Option<&str> = option_env!("OPENLOGI_UPDATE_MINISIGN_PUBLIC_KEY");

/// Whether this build can update itself in place.
///
/// A release publishes a DMG for macOS and an MSI for Windows, and for Linux
/// only distro packages (`.deb` / `.rpm` / `.pkg.tar.zst`) — those installs
/// update through the package manager, so the manifest deliberately carries no
/// Linux asset (see `xtask release latest-json`). Asking the updater anyway
/// resolves to "no release asset matched the current platform", which reads as
/// a failure rather than as the design it is, so Linux skips the check and the
/// Updates page says where updates actually come from.
pub const IN_APP_UPDATES: bool = !cfg!(target_os = "linux");

/// App-global handle to the shared updater entity.
#[derive(Clone)]
pub struct SharedUpdater(pub Entity<Updater>);
Expand Down Expand Up @@ -105,16 +116,17 @@ pub fn install(cx: &mut App, settings: &AppSettings) {
// later manual check — is honoured. Installed unconditionally; it's inert
// until both the flag is on and a check resolves to `Available`.
let auto_install = cx.observe(&updater, |updater, cx| {
let opted_in = cx
.try_global::<AppState>()
.is_some_and(|s| s.app_settings().auto_install_updates);
let opted_in = IN_APP_UPDATES
&& cx
.try_global::<AppState>()
.is_some_and(|s| s.app_settings().auto_install_updates);
if opted_in && matches!(updater.read(cx).status(), UpdateStatus::Available(_)) {
updater.update(cx, Updater::download_and_install);
}
});
cx.set_global(AutoInstaller(auto_install));

if settings.check_for_updates {
if IN_APP_UPDATES && settings.check_for_updates {
updater.update(cx, Updater::check);
}
cx.set_global(SharedUpdater(updater));
Expand Down
113 changes: 103 additions & 10 deletions crates/openlogi-desktop/src/services/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,16 @@ use std::sync::Arc;

use openlogi_assets::http::safe_component_path;
use openlogi_assets::{
BUTTONS_RENDER_FILES, DeviceEntry, FRONT_RENDER_FILES, Index, METADATA_FILES, Metadata,
BUTTONS_RENDER_FILES, DepotManifest, DeviceEntry, FRONT_RENDER_FILES, Index, METADATA_FILES,
Metadata,
};
use openlogi_core::device::{DeviceKind, DeviceModelInfo};
use tracing::{debug, warn};
use walkdir::WalkDir;

use self::images::{buttons_image_for, load_manifest, read_png_dimensions, variant_image_for};
use self::images::{
buttons_image_for, load_manifest, metadata_for, read_png_dimensions, variant_image_for,
};
use self::paths::{bundle_assets_root, load_index, user_cache_root};

/// Total bytes of the per-user asset cache — the tier [`sync`] writes and
Expand Down Expand Up @@ -225,13 +228,6 @@ impl AssetResolver {
);
continue;
};
// 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
// markers against the *buttons* image (typically
Expand All @@ -246,6 +242,13 @@ impl AssetResolver {
// colour render resolves regardless of which pid Logi keyed on.
// Parse the manifest once and consult it for every candidate.
let manifest = load_manifest(&dir);

let Some((meta_name, meta_path)) =
resolve_metadata(&dir, entry, manifest.as_ref(), model.extended_model_id)
else {
continue;
};

let buttons_name = manifest.as_ref().and_then(|m| {
entry
.model_id_candidates()
Expand Down Expand Up @@ -289,7 +292,7 @@ impl AssetResolver {
let metadata = match Metadata::load_from(&meta_path) {
Ok(m) => m,
Err(e) => {
warn!(depot, root = %root.display(), file = meta_name, error = ?e, "device metadata unparseable — rendering image without hotspots");
warn!(depot, root = %root.display(), file = meta_name.as_str(), error = ?e, "device metadata unparseable — rendering image without hotspots");
Metadata::default()
}
};
Expand Down Expand Up @@ -399,6 +402,36 @@ impl Default for AssetResolver {
}
}

/// Resolve a depot's hotspot-metadata file inside `dir`, as `(filename, path)`.
///
/// The manifest's `image_metadata` for this colour variant comes first, then
/// the well-known schema names ([`METADATA_FILES`]). Depots whose variants are
/// *handed* rather than coloured ship none of the well-known names — the Lift
/// keys its metadata `core_metadata_left.json` / `core_metadata_right.json` —
/// so a name-only lookup skips the depot outright and the GUI falls back to the
/// generic silhouette. Manifest-sourced names are attacker-influenced, so they
/// pass the same component check as every other asset file.
fn resolve_metadata(
dir: &Path,
entry: &DeviceEntry,
manifest: Option<&DepotManifest>,
ext: u8,
) -> Option<(String, PathBuf)> {
let mut candidates: Vec<String> = manifest
.and_then(|m| {
entry
.model_id_candidates()
.find_map(|base| metadata_for(m, base, ext))
})
.into_iter()
.collect();
candidates.extend(METADATA_FILES.map(str::to_string));
candidates.into_iter().find_map(|name| {
let path = safe_component_path(dir, &name, "asset file").ok()?;
path.exists().then_some((name, path))
})
}

/// Match a connected device's HID++ model info against a loaded index,
/// returning the depot name + entry without touching the filesystem.
///
Expand Down Expand Up @@ -624,6 +657,66 @@ mod tests {
assert_eq!(asset.metadata.assignments().count(), 1);
}

/// A depot whose variants are handed rather than coloured ships none of
/// [`METADATA_FILES`] — the Lift keys its metadata `core_metadata_left`
/// / `core_metadata_right` and names the right one in the manifest's
/// `image_metadata`. Resolving by well-known name alone skipped the
/// depot outright and rendered the generic silhouette.
#[test]
fn resolves_depot_whose_metadata_is_only_named_by_the_manifest() {
let root = tempfile::tempdir().expect("create temp dir");
let depot = "mx_vertical_mini";
let dir = root.path().join(depot);
std::fs::create_dir_all(&dir).expect("create depot dir");
std::fs::write(
dir.join("manifest.json"),
r#"{"devices":[{"modelId":"b031_ext4","resources":[
{"key":"image_metadata","src":"core_metadata_right.json"},
{"key":"device_image","src":"front_ext_2.png"}
]}]}"#,
)
.expect("write manifest");
std::fs::write(
dir.join("core_metadata_right.json"),
r#"{"images":[
{"key":"device_buttons_image","origin":{"width":860,"height":1256},
"assignments":[{"slotName":"SLOT_NAME_MIDDLE_BUTTON",
"marker":{"x":50,"y":50},"label":{"x":0,"y":0}}]}
]}"#,
)
.expect("write variant metadata");
std::fs::write(dir.join("front_ext_2.png"), png_header(860, 1256))
.expect("write variant render");

let resolver = AssetResolver {
read_roots: vec![root.path().to_path_buf()],
write_root: root.path().to_path_buf(),
has_bundle: false,
index: None,
};
let entry = DeviceEntry {
model_id: "b031".to_string(),
model_ids: Vec::new(),
display_name: "Lift".to_string(),
kind: "MOUSE".to_string(),
asset_path: format!("v1/devices/{depot}/"),
files: Vec::new(),
};
let model = DeviceModelInfo {
extended_model_id: 4,
..bare_model()
};

let asset = resolver
.load_files(depot, &entry, &model)
.expect("manifest-named metadata should resolve the depot");
assert_eq!(
asset.image_path.file_name().expect("image has a file name"),
"front_ext_2.png"
);
assert_eq!(asset.metadata.assignments().count(), 1);
}

#[test]
fn resolves_standalone_registry_model_without_synthetic_hidpp_info() {
let root = tempfile::tempdir().expect("create temp dir");
Expand Down
16 changes: 16 additions & 0 deletions crates/openlogi-desktop/src/services/assets/images.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,22 @@ pub(super) fn buttons_image_for(
.map(str::to_string)
}

/// Like [`variant_image_for`] but returns the `image_metadata` resource —
/// the hotspot-metadata JSON calibrated against this colour variant's
/// renders. Depots whose variants are *handed* rather than coloured ship no
/// well-known metadata name at all (Lift keys its metadata as
/// `core_metadata_left.json` / `core_metadata_right.json`), so the manifest
/// is the only place their filename appears.
pub(super) fn metadata_for(
manifest: &DepotManifest,
base_model_id: &str,
ext: u8,
) -> Option<String> {
manifest
.resource_for_variant(base_model_id, ext, "image_metadata")
.map(str::to_string)
}

/// Load and parse a depot's `manifest.json`, or `None` when it's missing /
/// malformed. Read once per [`load_files`](super::AssetResolver::load_files)
/// so the variant lookups above don't re-parse it for each candidate base.
Expand Down
Loading