diff --git a/crates/openlogi-cli/src/cmd/assets/sync.rs b/crates/openlogi-cli/src/cmd/assets/sync.rs index a7db2cd98..4050e54ff 100644 --- a/crates/openlogi-cli/src/cmd/assets/sync.rs +++ b/crates/openlogi-cli/src/cmd/assets/sync.rs @@ -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() diff --git a/crates/openlogi-desktop/src/app/menu.rs b/crates/openlogi-desktop/src/app/menu.rs index a62c2311b..fe56954db 100644 --- a/crates/openlogi-desktop/src/app/menu.rs +++ b/crates/openlogi-desktop/src/app/menu.rs @@ -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); diff --git a/crates/openlogi-desktop/src/platform/updater.rs b/crates/openlogi-desktop/src/platform/updater.rs index 107e16d78..5c80e4bd4 100644 --- a/crates/openlogi-desktop/src/platform/updater.rs +++ b/crates/openlogi-desktop/src/platform/updater.rs @@ -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); @@ -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::() - .is_some_and(|s| s.app_settings().auto_install_updates); + let opted_in = IN_APP_UPDATES + && cx + .try_global::() + .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)); diff --git a/crates/openlogi-desktop/src/services/assets.rs b/crates/openlogi-desktop/src/services/assets.rs index 07c021095..af125e1b0 100644 --- a/crates/openlogi-desktop/src/services/assets.rs +++ b/crates/openlogi-desktop/src/services/assets.rs @@ -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 @@ -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 @@ -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() @@ -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() } }; @@ -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 = 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. /// @@ -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"); diff --git a/crates/openlogi-desktop/src/services/assets/images.rs b/crates/openlogi-desktop/src/services/assets/images.rs index c87b88e80..2960dadd8 100644 --- a/crates/openlogi-desktop/src/services/assets/images.rs +++ b/crates/openlogi-desktop/src/services/assets/images.rs @@ -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 { + 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. diff --git a/crates/openlogi-desktop/src/services/assets/sync.rs b/crates/openlogi-desktop/src/services/assets/sync.rs index ed36c32f9..4a9714b42 100644 --- a/crates/openlogi-desktop/src/services/assets/sync.rs +++ b/crates/openlogi-desktop/src/services/assets/sync.rs @@ -17,6 +17,7 @@ use backon::{BackoffBuilder, ExponentialBuilder}; use openlogi_assets::http; use openlogi_assets::{ AssetRegistry, AssetSource, BUTTONS_RENDER_FILES, DepotManifest, DeviceEntry, FetchOutcome, + Index, }; use openlogi_core::config::AssetSourcePreference; use openlogi_core::device::DeviceModelInfo; @@ -99,15 +100,45 @@ pub fn sync(source: Option, targets: &[AssetTarget]) -> Result<()> // retries the whole sync on a later device snapshot, rather than latching // success off a run that downloaded nothing. Per-depot failures below stay // best-effort: an optional colour variant 404 shouldn't block everything. - // Each target carries the HID++ `extended_model_id` byte so the - // depot sync can fetch the right colour variant. `OPENLOGI_FORCE_DEPOT` - // doesn't correspond to a physical device, so we pass `ext = 0` - // and end up with the base PNG. + let forced = std::env::var("OPENLOGI_FORCE_DEPOT").ok(); + let depot_targets = depot_targets(index, targets, forced.as_deref()); + + if depot_targets.is_empty() { + debug!("sync: no matching depots for known devices"); + return Ok(()); + } + + for (depot, entry, ext) in &depot_targets { + if let Err(e) = sync_depot(client, &cache_root, depot, entry, *ext) { + warn!(depot, error = %e, "depot sync failed"); + } + } + info!(devices = depot_targets.len(), "asset sync complete"); + Ok(()) +} + +/// The depots to sync for `targets`, each paired with its registry entry and +/// the HID++ `extended_model_id` byte its manifest-mapped resources are keyed +/// on. `forced` is `OPENLOGI_FORCE_DEPOT`, which names no physical device and +/// so takes `ext = 0` — the base render. +/// +/// Deduplicated on `(depot, ext)`, not on the depot alone. Two connected +/// devices can share a depot while differing in `extended_model_id` — a colour +/// pair, or the Lift's left- and right-handed variants — and each needs its own +/// `device_image` / `device_buttons_image` / `image_metadata` resource. Keying +/// on the depot dropped one of them before [`sync_depot`] ever read the +/// manifest, leaving that device on fallback artwork with no hotspot metadata. +/// The baseline files the second pass then re-requests are cache hits. +fn depot_targets( + index: &Index, + targets: &[AssetTarget], + forced: Option<&str>, +) -> Vec<(String, DeviceEntry, u8)> { let mut depot_targets: Vec<(String, DeviceEntry, u8)> = Vec::new(); - if let Ok(forced) = std::env::var("OPENLOGI_FORCE_DEPOT") - && let Some(entry) = index.devices.get(&forced) + if let Some(depot) = forced + && let Some(entry) = index.devices.get(depot) { - depot_targets.push((forced, entry.clone(), 0)); + depot_targets.push((depot.to_owned(), entry.clone(), 0)); } for target in targets { let match_result = match target { @@ -120,7 +151,7 @@ pub fn sync(source: Option, targets: &[AssetTarget]) -> Result<()> .map(|(depot, entry)| (depot, entry, 0)), }; if let Some((depot, entry, ext)) = match_result { - depot_targets.push((depot.to_string(), entry.clone(), ext)); + depot_targets.push((depot.to_owned(), entry.clone(), ext)); } else if let AssetTarget::Standalone { registry_model_id } = target { info!( registry_model_id, @@ -128,21 +159,9 @@ pub fn sync(source: Option, targets: &[AssetTarget]) -> Result<()> ); } } - depot_targets.sort_by(|a, b| a.0.cmp(&b.0)); - depot_targets.dedup_by(|a, b| a.0 == b.0); - - if depot_targets.is_empty() { - debug!("sync: no matching depots for known devices"); - return Ok(()); - } - - for (depot, entry, ext) in &depot_targets { - if let Err(e) = sync_depot(client, &cache_root, depot, entry, *ext) { - warn!(depot, error = %e, "depot sync failed"); - } - } - info!(devices = depot_targets.len(), "asset sync complete"); - Ok(()) + depot_targets.sort_by(|a, b| (&a.0, a.2).cmp(&(&b.0, b.2))); + depot_targets.dedup_by(|a, b| a.0 == b.0 && a.2 == b.2); + depot_targets } fn sync_depot( @@ -172,26 +191,33 @@ fn sync_depot( warn!(depot, error = %e, "buttons render fetch failed"); } - // Optional second pass: download the manifest-mapped render PNGs — the + // Optional second pass: download the manifest-mapped resources — the // colour variant matching the device's `extended_model_id` for the front - // (carousel) and side / buttons (mouse-model) views, plus the camera hero + // (carousel) and side / buttons (mouse-model) views, the camera hero // (`device_camera_image` — camera depots ship no bare `front*.png`, so the - // baseline fetch above brings no render for them at all). Failure is - // non-fatal — `AssetResolver.load_files` falls back to whatever landed. + // baseline fetch above brings no render for them at all), and this + // variant's hotspot metadata (`image_metadata`, the only place a depot + // with handed variants names its metadata file). Failure is non-fatal — + // `AssetResolver.load_files` falls back to whatever landed. let manifest_path = dir.join("manifest.json"); for resource_key in [ "device_image", "device_buttons_image", "device_camera_image", + "image_metadata", ] { - let Some(variant) = - pick_variant_filename(&manifest_path, &entry.model_id, ext, resource_key) - else { + let Some(variant) = pick_variant_filename(&manifest_path, entry, ext, resource_key) else { continue; }; if matches!( variant.as_str(), - "front_core.png" | "front.png" | "side_core.png" | "side.png" + "front_core.png" + | "front.png" + | "side_core.png" + | "side.png" + | "core_metadata.json" + | "metadata_full.json" + | "metadata.json" ) { continue; } @@ -233,9 +259,13 @@ fn fetch_to_cache( /// needed for depots whose base render isn't a baseline `front*.png` (the /// caller's skip list keeps already-fetched baseline names from re-fetching). /// `None` when the manifest is missing, malformed, or lacks the variant. +/// +/// Every model id the depot answers to is tried as the variant base, the +/// same way the resolver does: a manifest is keyed on whichever pid Logi +/// authored it against, which isn't always the index primary. fn pick_variant_filename( manifest_path: &Path, - base_model_id: &str, + entry: &DeviceEntry, ext: u8, resource_key: &str, ) -> Option { @@ -245,8 +275,9 @@ fn pick_variant_filename( let manifest = DepotManifest::load_from(manifest_path) .map_err(|e| warn!(error = %e, path = %manifest_path.display(), "manifest unreadable")) .ok()?; - manifest - .resource_for_variant(base_model_id, ext, resource_key) + entry + .model_id_candidates() + .find_map(|base| manifest.resource_for_variant(base, ext, resource_key)) .map(str::to_string) } @@ -317,11 +348,86 @@ fn source_for_sync( #[cfg(test)] mod tests { - use super::{AssetTarget, model_key, source_for_sync, sync_retry_delay}; - use openlogi_assets::AssetSource; + use super::{AssetTarget, depot_targets, model_key, source_for_sync, sync_retry_delay}; + use openlogi_assets::{AssetSource, DeviceEntry, Index}; use openlogi_core::config::AssetSourcePreference; + use openlogi_core::device::{DeviceModelInfo, DeviceTransports}; + use std::collections::HashMap; use std::time::Duration; + /// A one-depot index whose entry answers to `model_id`. + fn index_with(depot: &str, model_id: &str) -> Index { + let mut devices = HashMap::new(); + devices.insert( + depot.to_owned(), + DeviceEntry { + model_id: model_id.to_owned(), + model_ids: Vec::new(), + display_name: "Lift".to_owned(), + kind: "MOUSE".to_owned(), + asset_path: format!("v1/devices/{depot}/"), + files: Vec::new(), + }, + ); + Index { + schema_version: 1, + devices, + } + } + + fn hidpp_target(pid: u16, ext: u8) -> AssetTarget { + AssetTarget::Hidpp { + model: DeviceModelInfo { + entity_count: 0, + serial_number: None, + unit_id: [0; 4], + transports: DeviceTransports::default(), + model_ids: [pid, 0, 0], + extended_model_id: ext, + }, + codename: None, + } + } + + /// Two devices of one model differing only in `extended_model_id` — a + /// colour pair, or the Lift's left- and right-handed variants — share a + /// depot but need different `device_image` and `image_metadata` + /// resources. Deduplicating on the depot alone dropped one before + /// `sync_depot` ever read the manifest, leaving that device on fallback + /// artwork with no hotspot metadata. + #[test] + fn two_variants_of_one_depot_are_both_synced() { + let index = index_with("mx_vertical_mini", "b031"); + + let targets = depot_targets( + &index, + &[hidpp_target(0xb031, 4), hidpp_target(0xb031, 0)], + None, + ); + + let keyed: Vec<(&str, u8)> = targets + .iter() + .map(|(depot, _, ext)| (depot.as_str(), *ext)) + .collect(); + assert_eq!(keyed, [("mx_vertical_mini", 0), ("mx_vertical_mini", 4)]); + } + + /// The same variant seen twice — two snapshots of one device — still + /// collapses to a single sync. + #[test] + fn the_same_variant_twice_is_synced_once() { + let index = index_with("mx_vertical_mini", "b031"); + + let targets = depot_targets( + &index, + &[hidpp_target(0xb031, 4), hidpp_target(0xb031, 4)], + None, + ); + + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].2, 4); + } + #[test] fn retry_delay_doubles_then_caps() { assert_eq!(sync_retry_delay(1), Duration::from_secs(1)); diff --git a/crates/openlogi-desktop/src/windows/settings/updates.rs b/crates/openlogi-desktop/src/windows/settings/updates.rs index de16dc4bd..64bf8f57a 100644 --- a/crates/openlogi-desktop/src/windows/settings/updates.rs +++ b/crates/openlogi-desktop/src/windows/settings/updates.rs @@ -12,6 +12,10 @@ use crate::ui::theme::Typography as _; /// the contextual check / install / restart action; the opt-in auto-check and /// auto-install switches; and where updates come from. pub(super) fn updates_page(updater: Entity, pal: Palette) -> SettingPage { + if !crate::platform::updater::IN_APP_UPDATES { + return package_managed_page(pal); + } + let hero = SettingGroup::new().item(SettingItem::render(move |_, _, cx| { update_hero(&updater, pal, cx) })); @@ -71,6 +75,43 @@ pub(super) fn updates_page(updater: Entity, pal: Palette) -> SettingPag .group(source) } +/// The Updates page for a build with no in-place-updatable artifact. +/// +/// Linux releases ship distro packages only, so the check can never resolve to +/// anything but "no release asset matched the current platform". Showing the +/// check button and the auto-install switches there offers the user a control +/// whose only possible outcome is a red "Update failed" pill; this page states +/// the running version and where updates actually come from instead. +fn package_managed_page(pal: Palette) -> SettingPage { + let hero = SettingGroup::new().item(SettingItem::render(move |_, _, _| { + h_flex() + .w_full() + .items_center() + .gap_3() + .child(img(crate::app_assets::LOGO).w(px(52.)).h(px(52.))) + .child( + v_flex() + .gap_1() + .min_w_0() + .child( + div() + .font_weight(FontWeight::SEMIBOLD) + .child(concat!("OpenLogi ", env!("CARGO_PKG_VERSION"))), + ) + .child(div().text_caption().text_color(pal.text_muted).child(tr!( + "Installed from a distribution package — updates come from your package manager." + ))), + ) + .into_any_element() + })); + + SettingPage::new(tr!("Updates")) + .icon(IconName::ArrowDown) + .resettable(false) + .group(hero) + .group(SettingGroup::new().item(SettingItem::render(move |_, _, _| update_source(pal)))) +} + /// The Updates hero row: logo, name + version, a status pill, the live status /// message (or channel), and the one contextual action button. fn update_hero(updater: &Entity, pal: Palette, cx: &mut App) -> AnyElement { diff --git a/crates/openlogi-inject/src/inject.rs b/crates/openlogi-inject/src/inject.rs index 4f03575a6..1da40057e 100644 --- a/crates/openlogi-inject/src/inject.rs +++ b/crates/openlogi-inject/src/inject.rs @@ -53,6 +53,13 @@ mod windows; pub fn execute(action: &Action) { if let Action::OpenApplication(target) = action { let expanded = shellexpand::tilde(target.path()); + // On Linux an executable path or a bare command name has to be + // spawned; `xdg-open` would only try to *open* it. Everything the + // desktop can genuinely open falls through to the opener below. + #[cfg(target_os = "linux")] + if linux::launch_program(expanded.as_ref()) { + return; + } if let Err(error) = opener::open(expanded.as_ref()) { tracing::warn!( %error, diff --git a/crates/openlogi-inject/src/inject/linux.rs b/crates/openlogi-inject/src/inject/linux.rs index f31b2a220..b9a9bc60f 100644 --- a/crates/openlogi-inject/src/inject/linux.rs +++ b/crates/openlogi-inject/src/inject/linux.rs @@ -6,6 +6,8 @@ //! without panicking. use std::io; +use std::path::{Path, PathBuf}; +use std::process::Stdio; use std::sync::{LazyLock, Mutex}; use evdev::uinput::VirtualDevice; @@ -606,12 +608,179 @@ fn try_mpris_command(command: &str) -> Option<()> { } } +/// What an "Open application" target should do on Linux. +#[derive(Debug, PartialEq, Eq)] +pub(super) enum Launch { + /// Hand it to the desktop opener (`xdg-open`): URLs, folders, documents, + /// and `.desktop` entries — which xdg-open activates properly. + Opener, + /// Run it as a program: an executable file path, or a bare command name + /// resolved on `PATH`. + Program(PathBuf), +} + +/// Decide how `target` should be launched. +/// +/// `is_executable` and `on_path` are injected so the decision table is +/// testable without touching the real filesystem or `PATH`. +fn classify( + target: &str, + is_executable: &dyn Fn(&Path) -> bool, + on_path: &dyn Fn(&str) -> Option, +) -> Launch { + if target.contains("://") { + return Launch::Opener; + } + // A desktop entry is the one "application" xdg-open launches correctly — + // it reads Exec= and applies the entry's Terminal / StartupNotify keys, + // which spawning the file directly would not. + if Path::new(target) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("desktop")) + { + return Launch::Opener; + } + if target.contains('/') { + let path = Path::new(target); + return if is_executable(path) { + Launch::Program(path.to_path_buf()) + } else { + Launch::Opener + }; + } + on_path(target).map_or(Launch::Opener, Launch::Program) +} + +/// Whether `path` is a regular file with any execute bit set. +fn is_executable(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt as _; + + std::fs::metadata(path) + .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0) +} + +/// First executable named `name` on `PATH`. +fn on_path(name: &str) -> Option { + std::env::var_os("PATH").and_then(|paths| { + std::env::split_paths(&paths) + .map(|dir| dir.join(name)) + .find(|candidate| is_executable(candidate)) + }) +} + +/// Run an "Open application" target as a program when that is what it is, +/// reporting whether it was handled here. +/// +/// `xdg-open` — what `opener` calls on Linux — *opens* a file: handed +/// `/usr/bin/nautilus` it looks for something claiming +/// `application/x-executable` and, finding nothing, does nothing at all +/// (#775). A path to an executable, or a bare command name on `PATH`, has to +/// be spawned instead. Everything the desktop genuinely knows how to open — +/// URLs, folders, documents, `.desktop` entries — returns `false` and stays +/// with the opener. +/// +/// The child is waited on from a detached thread: a long-lived GUI app would +/// otherwise linger as a zombie for the agent's whole lifetime. +pub(super) fn launch_program(target: &str) -> bool { + let Launch::Program(program) = classify(target, &is_executable, &on_path) else { + return false; + }; + std::thread::spawn(move || { + match std::process::Command::new(&program) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + Ok(mut child) => { + let _ = child.wait(); + } + Err(error) => tracing::warn!( + %error, + program = %program.display(), + "could not launch the configured application" + ), + } + }); + true +} + #[cfg(test)] mod tests { use evdev::KeyCode; use openlogi_core::binding::{KeyCombo, Shortcut}; - use super::{combo, hid_usage_to_linux, modifiers_to_keycodes}; + use std::path::{Path, PathBuf}; + + use super::{ + Launch, classify, combo, hid_usage_to_linux, is_executable, modifiers_to_keycodes, on_path, + }; + + /// #775: configuring `/usr/bin/nautilus` (or bare `nautilus`) did + /// nothing, because `xdg-open` opens files rather than running them. + #[test] + fn executables_are_run_and_everything_else_goes_to_the_opener() { + let executable = |p: &Path| p == Path::new("/usr/bin/nautilus"); + let on_path = |name: &str| (name == "nautilus").then(|| PathBuf::from("/usr/bin/nautilus")); + + assert_eq!( + classify("/usr/bin/nautilus", &executable, &on_path), + Launch::Program(PathBuf::from("/usr/bin/nautilus")), + "an executable path is a program to run" + ); + assert_eq!( + classify("nautilus", &executable, &on_path), + Launch::Program(PathBuf::from("/usr/bin/nautilus")), + "a bare command name resolves through PATH" + ); + + for opener in [ + "https://example.com", + "/home/u/Documents", + "/home/u/notes.txt", + "/usr/share/applications/org.gnome.Nautilus.desktop", + "definitely-not-on-path", + ] { + assert_eq!( + classify(opener, &executable, &on_path), + Launch::Opener, + "{opener} belongs to the desktop opener" + ); + } + } + + /// The fakes above pin the decision table; this pins the two real probes + /// they stand in for against the filesystem. `sh` is on `PATH` and + /// executable on every Linux host. + #[test] + fn the_real_probes_resolve_a_shell_on_path() { + let resolved = on_path("sh").expect("sh is on PATH"); + assert!( + is_executable(&resolved), + "{} is executable", + resolved.display() + ); + assert_eq!( + classify("sh", &is_executable, &on_path), + Launch::Program(resolved) + ); + assert_eq!(classify("/", &is_executable, &on_path), Launch::Opener); + } + + /// A `.desktop` entry is executable often enough that the extension check + /// has to come first: xdg-open reads its `Exec=` and honours `Terminal=` + /// and `StartupNotify=`, which spawning the file itself would not. + #[test] + fn an_executable_desktop_entry_still_goes_to_the_opener() { + assert_eq!( + classify( + "/usr/share/applications/foo.desktop", + &|_: &Path| true, + &|_: &str| None + ), + Launch::Opener + ); + } #[test] fn modifiers_map_to_linux_without_duplicate_control() { diff --git a/crates/openlogi-ui/locales/da.yml b/crates/openlogi-ui/locales/da.yml index fd3fabda5..eb3866b4f 100644 --- a/crates/openlogi-ui/locales/da.yml +++ b/crates/openlogi-ui/locales/da.yml @@ -308,6 +308,7 @@ _version: 1 "Update source": "Update source" "View changelog": "View changelog" "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Installed from a distribution package — updates come from your package manager.": "Installeret fra en distributionspakke — opdateringer kommer fra din pakkehåndtering." "Stable channel": "Stable channel" "A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." "Changelog": "Changelog" diff --git a/crates/openlogi-ui/locales/de.yml b/crates/openlogi-ui/locales/de.yml index 270b48bd3..894418df0 100644 --- a/crates/openlogi-ui/locales/de.yml +++ b/crates/openlogi-ui/locales/de.yml @@ -308,6 +308,7 @@ _version: 1 "Update source": "Update source" "View changelog": "View changelog" "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Installed from a distribution package — updates come from your package manager.": "Über ein Distributionspaket installiert – Updates kommen von deiner Paketverwaltung." "Stable channel": "Stable channel" "A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." "Changelog": "Changelog" diff --git a/crates/openlogi-ui/locales/el.yml b/crates/openlogi-ui/locales/el.yml index ce4253f1d..284220294 100644 --- a/crates/openlogi-ui/locales/el.yml +++ b/crates/openlogi-ui/locales/el.yml @@ -308,6 +308,7 @@ _version: 1 "Update source": "Update source" "View changelog": "View changelog" "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Installed from a distribution package — updates come from your package manager.": "Εγκαταστάθηκε από πακέτο διανομής — οι ενημερώσεις προέρχονται από τον διαχειριστή πακέτων σας." "Stable channel": "Stable channel" "A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." "Changelog": "Changelog" diff --git a/crates/openlogi-ui/locales/en.yml b/crates/openlogi-ui/locales/en.yml index b64f78582..af749b01a 100644 --- a/crates/openlogi-ui/locales/en.yml +++ b/crates/openlogi-ui/locales/en.yml @@ -308,6 +308,7 @@ _version: 1 "Update source": "Update source" "View changelog": "View changelog" "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Installed from a distribution package — updates come from your package manager.": "Installed from a distribution package — updates come from your package manager." "Stable channel": "Stable channel" "A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." "Changelog": "Changelog" diff --git a/crates/openlogi-ui/locales/es.yml b/crates/openlogi-ui/locales/es.yml index bfd1741e3..7953977de 100644 --- a/crates/openlogi-ui/locales/es.yml +++ b/crates/openlogi-ui/locales/es.yml @@ -308,6 +308,7 @@ _version: 1 "Update source": "Update source" "View changelog": "View changelog" "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Installed from a distribution package — updates come from your package manager.": "Instalado desde un paquete de la distribución: las actualizaciones vienen de tu gestor de paquetes." "Stable channel": "Stable channel" "A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." "Changelog": "Changelog" diff --git a/crates/openlogi-ui/locales/fi.yml b/crates/openlogi-ui/locales/fi.yml index 749f8f9e8..848eda33a 100644 --- a/crates/openlogi-ui/locales/fi.yml +++ b/crates/openlogi-ui/locales/fi.yml @@ -308,6 +308,7 @@ _version: 1 "Update source": "Update source" "View changelog": "View changelog" "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Installed from a distribution package — updates come from your package manager.": "Asennettu jakelupaketista — päivitykset tulevat paketinhallinnastasi." "Stable channel": "Stable channel" "A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." "Changelog": "Changelog" diff --git a/crates/openlogi-ui/locales/fr.yml b/crates/openlogi-ui/locales/fr.yml index b62a5cb4f..8d4bafb77 100644 --- a/crates/openlogi-ui/locales/fr.yml +++ b/crates/openlogi-ui/locales/fr.yml @@ -308,6 +308,7 @@ _version: 1 "Update source": "Update source" "View changelog": "View changelog" "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Installed from a distribution package — updates come from your package manager.": "Installé depuis un paquet de la distribution — les mises à jour viennent de votre gestionnaire de paquets." "Stable channel": "Stable channel" "A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." "Changelog": "Changelog" diff --git a/crates/openlogi-ui/locales/it.yml b/crates/openlogi-ui/locales/it.yml index 1a698545b..659beb620 100644 --- a/crates/openlogi-ui/locales/it.yml +++ b/crates/openlogi-ui/locales/it.yml @@ -308,6 +308,7 @@ _version: 1 "Update source": "Update source" "View changelog": "View changelog" "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Installed from a distribution package — updates come from your package manager.": "Installato da un pacchetto della distribuzione: gli aggiornamenti arrivano dal gestore pacchetti." "Stable channel": "Stable channel" "A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." "Changelog": "Changelog" diff --git a/crates/openlogi-ui/locales/ja.yml b/crates/openlogi-ui/locales/ja.yml index 045bde46f..2c687bae1 100644 --- a/crates/openlogi-ui/locales/ja.yml +++ b/crates/openlogi-ui/locales/ja.yml @@ -308,6 +308,7 @@ _version: 1 "Update source": "Update source" "View changelog": "View changelog" "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Installed from a distribution package — updates come from your package manager.": "ディストリビューションのパッケージからインストールされています — 更新はパッケージマネージャーから提供されます。" "Stable channel": "Stable channel" "A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." "Changelog": "Changelog" diff --git a/crates/openlogi-ui/locales/ko.yml b/crates/openlogi-ui/locales/ko.yml index a9bbfd894..dc5c59220 100644 --- a/crates/openlogi-ui/locales/ko.yml +++ b/crates/openlogi-ui/locales/ko.yml @@ -308,6 +308,7 @@ _version: 1 "Update source": "Update source" "View changelog": "View changelog" "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Installed from a distribution package — updates come from your package manager.": "배포판 패키지로 설치되었습니다 — 업데이트는 패키지 관리자에서 제공됩니다." "Stable channel": "Stable channel" "A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." "Changelog": "Changelog" diff --git a/crates/openlogi-ui/locales/nb.yml b/crates/openlogi-ui/locales/nb.yml index 7cb8881b2..3b31db440 100644 --- a/crates/openlogi-ui/locales/nb.yml +++ b/crates/openlogi-ui/locales/nb.yml @@ -308,6 +308,7 @@ _version: 1 "Update source": "Update source" "View changelog": "View changelog" "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Installed from a distribution package — updates come from your package manager.": "Installert fra en distribusjonspakke – oppdateringer kommer fra pakkebehandleren din." "Stable channel": "Stable channel" "A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." "Changelog": "Changelog" diff --git a/crates/openlogi-ui/locales/nl.yml b/crates/openlogi-ui/locales/nl.yml index c5d4bebbe..07d90290e 100644 --- a/crates/openlogi-ui/locales/nl.yml +++ b/crates/openlogi-ui/locales/nl.yml @@ -308,6 +308,7 @@ _version: 1 "Update source": "Update source" "View changelog": "View changelog" "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Installed from a distribution package — updates come from your package manager.": "Geïnstalleerd via een distributiepakket — updates komen van je pakketbeheerder." "Stable channel": "Stable channel" "A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." "Changelog": "Changelog" diff --git a/crates/openlogi-ui/locales/pl.yml b/crates/openlogi-ui/locales/pl.yml index bc75d25b4..3fbc60aa2 100644 --- a/crates/openlogi-ui/locales/pl.yml +++ b/crates/openlogi-ui/locales/pl.yml @@ -308,6 +308,7 @@ _version: 1 "Update source": "Źródło aktualizacji" "View changelog": "Zobacz dziennik zmian" "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "Brak aktualizatora działającego w tle — OpenLogi łączy się tylko po włączeniu automatycznych sprawdzeń lub kliknięciu Sprawdź dostępność aktualizacji." +"Installed from a distribution package — updates come from your package manager.": "Zainstalowano z pakietu dystrybucji — aktualizacje pochodzą z menedżera pakietów." "Stable channel": "Kanał stabilny" "A native, local-first alternative to Logitech Options+.": "Natywna, lokalna alternatywa dla Logitech Options+." "Changelog": "Dziennik zmian" diff --git a/crates/openlogi-ui/locales/pt-BR.yml b/crates/openlogi-ui/locales/pt-BR.yml index e15287593..3ecdfb9eb 100644 --- a/crates/openlogi-ui/locales/pt-BR.yml +++ b/crates/openlogi-ui/locales/pt-BR.yml @@ -308,6 +308,7 @@ _version: 1 "Update source": "Update source" "View changelog": "View changelog" "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Installed from a distribution package — updates come from your package manager.": "Instalado a partir de um pacote da distribuição — as atualizações vêm do seu gerenciador de pacotes." "Stable channel": "Stable channel" "A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." "Changelog": "Changelog" diff --git a/crates/openlogi-ui/locales/pt-PT.yml b/crates/openlogi-ui/locales/pt-PT.yml index a26aa6585..7d997b4fa 100644 --- a/crates/openlogi-ui/locales/pt-PT.yml +++ b/crates/openlogi-ui/locales/pt-PT.yml @@ -308,6 +308,7 @@ _version: 1 "Update source": "Update source" "View changelog": "View changelog" "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Installed from a distribution package — updates come from your package manager.": "Instalado a partir de um pacote da distribuição — as atualizações vêm do seu gestor de pacotes." "Stable channel": "Stable channel" "A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." "Changelog": "Changelog" diff --git a/crates/openlogi-ui/locales/ru.yml b/crates/openlogi-ui/locales/ru.yml index 43e0e2bda..a5e28252a 100644 --- a/crates/openlogi-ui/locales/ru.yml +++ b/crates/openlogi-ui/locales/ru.yml @@ -308,6 +308,7 @@ _version: 1 "Update source": "Update source" "View changelog": "View changelog" "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Installed from a distribution package — updates come from your package manager.": "Установлено из пакета дистрибутива — обновления приходят из вашего менеджера пакетов." "Stable channel": "Stable channel" "A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." "Changelog": "Changelog" diff --git a/crates/openlogi-ui/locales/sv.yml b/crates/openlogi-ui/locales/sv.yml index d96f86dce..86a224d08 100644 --- a/crates/openlogi-ui/locales/sv.yml +++ b/crates/openlogi-ui/locales/sv.yml @@ -308,6 +308,7 @@ _version: 1 "Update source": "Update source" "View changelog": "View changelog" "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Installed from a distribution package — updates come from your package manager.": "Installerad från ett distributionspaket – uppdateringar kommer från din pakethanterare." "Stable channel": "Stable channel" "A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." "Changelog": "Changelog" diff --git a/crates/openlogi-ui/locales/tr.yml b/crates/openlogi-ui/locales/tr.yml new file mode 100644 index 000000000..4689a70ce --- /dev/null +++ b/crates/openlogi-ui/locales/tr.yml @@ -0,0 +1,402 @@ +# OpenLogi GUI translations. Managed by Crowdin; edit source text there when possible. +_version: 1 +"No devices connected": "Bağlı cihaz yok" +"Devices": "Cihazlar" +"Buttons": "Düğmeler" +"Keys": "Tuşlar" +"Pointer": "İmleç" +"Lighting": "Aydınlatma" +"Colour temperature": "Renk sıcaklığı" +"On": "Açık" +"Off": "Kapalı" +"Open OpenLogi": "OpenLogi'yi aç" +"Quit OpenLogi": "OpenLogi'den çık" +"Plug in or pair a supported Logitech device — it'll show up here automatically. For direct Bluetooth connections, pair in your computer's bluetooth settings.": "Desteklenen bir Logitech cihazını takın veya eşleştirin — burada otomatik olarak görünecektir. Doğrudan Bluetooth bağlantıları için bilgisayarınızın Bluetooth ayarlarından eşleştirin." +"No active device": "Etkin cihaz yok" +"Pointer tuning": "İmleç ayarı" +"Device details": "Cihaz ayrıntıları" +"Configuration": "Yapılandırma" +"Connection": "Bağlantı" +"Slot": "Yuva" +"Device key": "Cihaz anahtarı" +"Serial": "Seri numarası" +"Active profile": "Etkin profil" +"Default profile": "Varsayılan profil" +"Button bindings": "Düğme atamaları" +"Gesture bindings": "Hareket atamaları" +"DPI presets": "DPI ön ayarları" +"Config folder": "Yapılandırma klasörü" +"Connected": "Bağlı" +"Offline": "Çevrimdışı" +"Device offline — changes will apply when it reconnects.": "Cihaz çevrimdışı — değişiklikler yeniden bağlandığında uygulanacak." +"Battery": "Pil" +"Charging": "Şarj oluyor" +"Full": "Dolu" +"Battery error": "Pil hatası" +"Bolt receiver": "Bolt alıcı" +"Unifying receiver": "Unifying alıcı" +"Direct connection": "Doğrudan bağlantı" +"Unavailable": "Kullanılamıyor" +"Mouse": "Fare" +"Keyboard": "Klavye" +"Numpad": "Sayısal tuş takımı" +"Presenter": "Sunum kumandası" +"Remote": "Uzaktan kumanda" +"Trackball": "İz topu" +"Touchpad": "Dokunmatik yüzey" +"Tablet": "Tablet" +"Gamepad": "Oyun kumandası" +"Joystick": "Joystick" +"Headset": "Kulaklık" +"Using Logi Options+? Quit it first — both apps compete for HID++ access.": "Logi Options+ kullanıyor musunuz? Önce ondan çıkın — iki uygulama da HID++ erişimi için yarışır." +"Accessibility permission required": "Erişilebilirlik izni gerekli" +"OpenLogi captures mouse buttons (Back / Forward / gesture button) through the system Accessibility permission and runs the actions you bind. Features that talk to the device directly — DPI, SmartShift — are unaffected.": "OpenLogi, fare düğmelerini (Geri / İleri / hareket düğmesi) sistemin Erişilebilirlik izniyle yakalar ve atadığınız eylemleri çalıştırır. Cihazla doğrudan konuşan özellikler — DPI, SmartShift — bundan etkilenmez." +"Enable “OpenLogi Agent” in the Accessibility list — the background agent owns the mouse hook, not the OpenLogi app. If it already shows as enabled, remove the stale entry with the − button and add it back.": "Erişilebilirlik listesinde “OpenLogi Agent” girişini etkinleştirin — fare kancasının sahibi OpenLogi uygulaması değil, arka plan ajanıdır. Zaten etkin görünüyorsa, − düğmesiyle eski kaydı kaldırıp yeniden ekleyin." +"Open System Settings to grant access": "Erişim vermek için Sistem Ayarları'nı aç" +"Takes effect automatically once granted — no restart needed.": "İzin verildiğinde otomatik olarak etkinleşir — yeniden başlatma gerekmez." +"Not now (use DPI and other features only)": "Şimdi değil (yalnızca DPI ve diğer özellikleri kullan)" +"Settings": "Ayarlar" +"Accessibility granted": "Erişilebilirlik izni verildi" +"Accessibility not granted · click to grant": "Erişilebilirlik izni verilmedi · vermek için tıklayın" +"About OpenLogi": "OpenLogi Hakkında" +"Settings…": "Ayarlar…" +"Check for Updates…": "Güncellemeleri Denetle…" +"Copy Diagnostics": "Tanılamayı Kopyala" +"Copied!": "Kopyalandı!" +"Edit": "Düzen" +"Undo": "Geri Al" +"Redo": "Yinele" +"Cut": "Kes" +"Copy": "Kopyala" +"Paste": "Yapıştır" +"Select All": "Tümünü Seç" +"View": "Görünüm" +"Open Configuration Folder": "Yapılandırma Klasörünü Aç" +"Device": "Cihaz" +"Hide OpenLogi": "OpenLogi'yi Gizle" +"Hide Others": "Diğerlerini Gizle" +"Show All": "Tümünü Göster" +"Window": "Pencere" +"Minimize": "Simge Durumuna Küçült" +"Zoom": "Yakınlaştır" +"Close Window": "Pencereyi Kapat" +"Bring All to Front": "Tümünü Öne Getir" +"Help": "Yardım" +"OpenLogi Help": "OpenLogi Yardımı" +"Open GitHub Repository": "GitHub Deposunu Aç" +"Latest Release": "En Son Sürüm" +"Open-source Logitech mouse configuration — DPI, SmartShift, button bindings, and gestures.": "Açık kaynaklı Logitech fare yapılandırması — DPI, SmartShift, düğme atamaları ve hareketler." +"Download & Install": "İndir ve Kur" +"Restart to Update": "Güncellemek için Yeniden Başlat" +"Checking for updates…": "Güncellemeler denetleniyor…" +"You're on the latest version.": "En son sürümü kullanıyorsunuz." +"Version %{version} is available.": "%{version} sürümü mevcut." +"Downloading… %{percent}%": "İndiriliyor… %{percent}%" +"Downloading… %{size} MB": "İndiriliyor… %{size} MB" +"Installing…": "Kuruluyor…" +"Version %{version} is ready.": "%{version} sürümü hazır." +"Update failed: %{error}": "Güncelleme başarısız: %{error}" +"Check for Updates": "Güncellemeleri Denetle" +"Licensed under MIT OR Apache-2.0": "MIT OR Apache-2.0 lisansı altındadır" +"GitHub": "GitHub" +"Releases": "Sürümler" +"General": "Genel" +"Launch at login": "Oturum açılışında başlat" +"Automatically start OpenLogi when you log in to macOS.": "macOS'ta oturum açtığınızda OpenLogi'yi otomatik olarak başlat." +"Automatically start OpenLogi when you log in.": "Oturum açtığınızda OpenLogi'yi otomatik olarak başlat." +"Check for updates": "Güncellemeleri denetle" +"Check once per launch for a new version (query only — no automatic download).": "Her açılışta bir kez yeni sürüm denetle (yalnızca sorgu — otomatik indirme yok)." +"Show in menu bar": "Menü çubuğunda göster" +"Keep OpenLogi's icon in the menu bar. When off, it stays in the Dock instead.": "OpenLogi simgesini menü çubuğunda tut. Kapalıyken bunun yerine Dock'ta kalır." +"Show in the notification area": "Bildirim alanında göster" +"Keep OpenLogi's icon in the taskbar notification area. Takes effect the next time the background agent starts.": "OpenLogi simgesini görev çubuğu bildirim alanında tut. Arka plan ajanı bir sonraki başlatıldığında etkinleşir." +"Assets": "Görseller" +"Asset source": "Görsel kaynağı" +"Automatic (recommended)": "Otomatik (önerilen)" +"Automatic uses the first healthy mirror. Choose a source to pin downloads; OPENLOGI_ASSETS still takes precedence.": "Otomatik, çalışan ilk aynayı kullanır. İndirmeleri sabitlemek için bir kaynak seçin; OPENLOGI_ASSETS yine de önceliklidir." +"Automatically download device images": "Cihaz görsellerini otomatik indir" +"Fetch device renders from the selected source when a device connects. When off, OpenLogi makes no asset network requests; bundled art and the silhouette still show.": "Bir cihaz bağlandığında seçili kaynaktan cihaz görsellerini indir. Kapalıyken OpenLogi hiçbir görsel ağ isteği yapmaz; paketle gelen görseller ve siluet yine de gösterilir." +"Refresh assets": "Görselleri yenile" +"Refresh": "Yenile" +"Re-download images for the connected devices now.": "Bağlı cihazların görsellerini şimdi yeniden indir." +"Clear cache": "Önbelleği temizle" +"Clear": "Temizle" +"Downloaded images currently use %{size}.": "İndirilen görseller şu anda %{size} yer kaplıyor." +"Cache location": "Önbellek konumu" +"Show the downloaded-images folder in your file manager.": "İndirilen görseller klasörünü dosya yöneticinizde göster." +"Language": "Dil" +"Choose the interface language.": "Arayüz dilini seçin." +"Follow system": "Sistemi izle" +"Presets": "Ön ayarlar" +"Add": "Ekle" +"Wheel mode": "Tekerlek modu" +"Free spin": "Serbest dönüş" +"Ratchet": "Kademeli" +"Sensitivity": "Hassasiyet" +"Higher keeps the ratchet engaged longer before free-spin.": "Yüksek değer, serbest dönüşe geçmeden önce kademeli modu daha uzun süre korur." +"Permanent ratchet": "Kalıcı kademeli mod" +"Never auto-switch to free-spin.": "Serbest dönüşe asla otomatik olarak geçme." +"Device offline — SmartShift unavailable.": "Cihaz çevrimdışı — SmartShift kullanılamıyor." +"Reading SmartShift settings…": "SmartShift ayarları okunuyor…" +"Couldn't read SmartShift — click to retry.": "SmartShift okunamadı — yeniden denemek için tıklayın." +"This device does not support SmartShift.": "Bu cihaz SmartShift'i desteklemiyor." +"Bind %{name}": "%{name} ata" +"Unbound": "Atanmamış" +"Default": "Varsayılan" +"5 directions": "5 yön" +"Middle": "Orta" +"DPI Preset %{index}": "DPI Ön Ayarı %{index}" +"Gesture %{name}": "%{name} hareketi" +"Left Click": "Sol Tık" +"Right Click": "Sağ Tık" +"Middle Click": "Orta Tık" +"Back (Button 4)": "Geri (Düğme 4)" +"Forward (Button 5)": "İleri (Düğme 5)" +"Back": "Geri" +"Forward": "İleri" +"DPI Toggle": "DPI Değiştir" +"Thumb Wheel": "Başparmak Tekerleği" +"Back / Forward": "Geri / İleri" +"Undo / Redo": "Geri Al / Yinele" +"Browser Back / Forward": "Tarayıcıda Geri / İleri" +"Previous / Next Tab": "Önceki / Sonraki Sekme" +"Previous / Next Desktop": "Önceki / Sonraki Masaüstü" +"Previous / Next Track": "Önceki / Sonraki Parça" +"Volume Down / Up": "Sesi Azalt / Artır" +"Volume Up / Down": "Sesi Artır / Azalt" +"Vertical Scroll": "Dikey Kaydırma" +"Horizontal Scroll": "Yatay Kaydırma" +"Custom": "Özel" +"Gesture Button": "Hareket Düğmesi" +"Gestures": "Hareketler" +"Turn off gestures": "Hareketleri kapat" +"Haptic Panel": "Dokunsal Panel" +"Up": "Yukarı" +"Down": "Aşağı" +"Left": "Sol" +"Right": "Sağ" +"Click": "Tık" +"Editing": "Düzenleme" +"Browser": "Tarayıcı" +"Media": "Medya" +"Scroll": "Kaydırma" +"Navigation": "Gezinme" +"System": "Sistem" +"DPI": "DPI" +"Find": "Bul" +"Save": "Kaydet" +"Browser Back": "Tarayıcıda Geri" +"Browser Forward": "Tarayıcıda İleri" +"New Tab": "Yeni Sekme" +"Close Tab": "Sekmeyi Kapat" +"Reopen Tab": "Sekmeyi Yeniden Aç" +"Next Tab": "Sonraki Sekme" +"Previous Tab": "Önceki Sekme" +"Reload Page": "Sayfayı Yenile" +"Mission Control": "Mission Control" +"App Exposé": "App Exposé" +"Previous Desktop": "Önceki Masaüstü" +"Next Desktop": "Sonraki Masaüstü" +"Show Desktop": "Masaüstünü Göster" +"Launchpad": "Launchpad" +"Lock Screen": "Ekranı Kilitle" +"Screenshot": "Ekran Görüntüsü" +"Sleep": "Uyku" +"Capture Region": "Bölge Yakala" +"Play / Pause": "Oynat / Duraklat" +"Next Track": "Sonraki Parça" +"Previous Track": "Önceki Parça" +"Volume Up": "Sesi Artır" +"Volume Down": "Sesi Azalt" +"Mute": "Sessize Al" +"Cycle DPI Presets": "DPI Ön Ayarlarında Gez" +"Toggle SmartShift": "SmartShift'i Aç/Kapat" +"Scroll Up": "Yukarı Kaydır" +"Scroll Down": "Aşağı Kaydır" +"Scroll Left": "Sola Kaydır" +"Scroll Right": "Sağa Kaydır" +"Add Device": "Cihaz Ekle" +"Add Device…": "Cihaz Ekle…" +"Put the device in pairing mode, then start searching.": "Cihazı eşleştirme moduna alın, sonra aramayı başlatın." +"Search for devices": "Cihaz ara" +"Searching for devices…": "Cihazlar aranıyor…" +"Make sure the device is on and in pairing mode.": "Cihazın açık ve eşleştirme modunda olduğundan emin olun." +"No devices found yet…": "Henüz cihaz bulunamadı…" +"Select a device to pair:": "Eşleştirilecek bir cihaz seçin:" +"Pairing…": "Eşleştiriliyor…" +"Follow the instructions on your device.": "Cihazınızdaki yönergeleri izleyin." +"Type this passkey on the new keyboard, then press Enter:": "Bu erişim kodunu yeni klavyede yazın, sonra Enter'a basın:" +"On the new mouse, click in this order, then press both buttons together:": "Yeni farede şu sırayla tıklayın, sonra iki düğmeye birlikte basın:" +"Device paired": "Cihaz eşleştirildi" +"Paired to slot %{slot}.": "%{slot} numaralı yuvaya eşleştirildi." +"Done": "Bitti" +"Pairing failed": "Eşleştirme başarısız" +"HID transport error: %{message}": "HID aktarım hatası: %{message}" +"No supported pairing-capable receiver was found.": "Eşleştirme yapabilen desteklenen bir alıcı bulunamadı." +"Receiver register access failed: %{message}": "Alıcı yazmaç erişimi başarısız: %{message}" +"Pairing timed out.": "Eşleştirme zaman aşımına uğradı." +"The receiver reported pairing error %{code}.": "Alıcı %{code} eşleştirme hatasını bildirdi." +"Pairing was cancelled.": "Eşleştirme iptal edildi." +"The receiver is busy. Try pairing again.": "Alıcı meşgul. Eşleştirmeyi yeniden deneyin." +"Pairing is unavailable because the background service is not ready.": "Arka plan hizmeti hazır olmadığından eşleştirme kullanılamıyor." +"Pairing is unavailable because receiver access could not be recorded.": "Alıcı erişimi kaydedilemediğinden eşleştirme kullanılamıyor." +"A pairing session is already active.": "Zaten etkin bir eşleştirme oturumu var." +"That device is no longer available. Search again and retry pairing.": "O cihaz artık kullanılamıyor. Yeniden arayıp eşleştirmeyi tekrar deneyin." +"No pairing session is active.": "Etkin bir eşleştirme oturumu yok." +"Try again": "Yeniden dene" +"Cancel": "İptal" +"Permissions": "İzinler" +"Accessibility": "Erişilebilirlik" +"Input Monitoring": "Girdi İzleme" +"Bluetooth": "Bluetooth" +"Input device access": "Girdi cihazı erişimi" +"Granted": "Verildi" +"Not granted": "Verilmedi" +"Unknown": "Bilinmiyor" +"Not requested": "İstenmedi" +"Open": "Aç" +"Grant": "İzin ver" +"Needed for gesture and button remapping (event tap).": "Hareket ve düğme yeniden atama için gerekli (olay kancası)." +"Needed to read HID++ data, including Bluetooth-direct mice.": "Doğrudan Bluetooth fareleri dahil HID++ verilerini okumak için gerekli." +"Allows OpenLogi to use CoreBluetooth (not required for HID access).": "OpenLogi'nin CoreBluetooth kullanmasına izin verir (HID erişimi için gerekli değildir)." +"Scanning for devices…": "Cihazlar taranıyor…" +"Connecting to the background service…": "Arka plan hizmetine bağlanılıyor…" +"Can't reach the background service": "Arka plan hizmetine ulaşılamıyor" +"OpenLogi keeps retrying — if this persists, try reinstalling the app.": "OpenLogi denemeye devam ediyor — sorun sürerse uygulamayı yeniden kurmayı deneyin." +"OpenLogi was updated": "OpenLogi güncellendi" +"This window is from the previous version — relaunch to finish the update.": "Bu pencere önceki sürüme ait — güncellemeyi tamamlamak için yeniden başlatın." +"Relaunch OpenLogi": "OpenLogi'yi yeniden başlat" +"Device scanning is unavailable": "Cihaz taraması kullanılamıyor" +"The background service couldn't scan for devices — check its log for details.": "Arka plan hizmeti cihazları tarayamadı — ayrıntılar için günlüğüne bakın." +"The background service restarted — try pairing again.": "Arka plan hizmeti yeniden başladı — eşleştirmeyi tekrar deneyin." +"Thumb Wheel Up": "Başparmak Tekerleği Yukarı" +"Thumb Wheel Down": "Başparmak Tekerleği Aşağı" +"Do Nothing": "Hiçbir Şey Yapma" +"Thumb Wheel Sensitivity": "Başparmak Tekerleği Hassasiyeti" +"Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger.": "Başparmak tekerleğinin yatay kaydırma hızını ve özel tekerlek eylemlerinin ne kadar kolay tetikleneceğini ölçekler." +"Device offline — reconnect to read DPI range": "Cihaz çevrimdışı — DPI aralığını okumak için yeniden bağlanın" +"Loading device DPI range…": "Cihazın DPI aralığı yükleniyor…" +"DPI read failed: %{message}": "DPI okuması başarısız: %{message}" +"DPI range unavailable: %{message}": "DPI aralığı kullanılamıyor: %{message}" +"Fixed DPI: %{dpi}": "Sabit DPI: %{dpi}" +"Preparing DPI slider…": "DPI kaydırıcısı hazırlanıyor…" +"Device offline — DPI unavailable.": "Cihaz çevrimdışı — DPI kullanılamıyor." +"Reading supported DPI values…": "Desteklenen DPI değerleri okunuyor…" +"Couldn't read DPI — click to retry.": "DPI okunamadı — yeniden denemek için tıklayın." +"This device did not report Adjustable DPI support.": "Bu cihaz Ayarlanabilir DPI desteği bildirmedi." +"Scrolling": "Kaydırma" +"Invert scroll direction": "Kaydırma yönünü ters çevir" +"Reverse this mouse's scroll wheel. Your trackpad keeps the system scroll direction.": "Bu farenin kaydırma tekerleğini ters çevir. Dokunmatik yüzeyiniz sistem kaydırma yönünü korur." +"This device does not report native HID++ scroll inversion support.": "Bu cihaz yerel HID++ kaydırma ters çevirme desteği bildirmiyor." +"Wheel resolution": "Tekerlek çözünürlüğü" +"Device default": "Cihaz varsayılanı" +"Standard": "Standart" +"High resolution": "Yüksek çözünürlük" +"OpenLogi does not change the wheel resolution.": "OpenLogi tekerlek çözünürlüğünü değiştirmez." +"Scrolls once per physical ratchet step.": "Her fiziksel kademe adımında bir kez kaydırır." +"Detects finer movement between ratchet steps.": "Kademeler arasındaki daha ince hareketi algılar." +"This device does not support wheel resolution control.": "Bu cihaz tekerlek çözünürlüğü denetimini desteklemiyor." +"About": "Hakkında" +"Updates": "Güncellemeler" +"Off by default — checking for updates is OpenLogi's only optional outbound network request.": "Varsayılan olarak kapalı — güncelleme denetimi OpenLogi'nin tek isteğe bağlı dış ağ isteğidir." +"Up to date": "Güncel" +"Update available": "Güncelleme mevcut" +"Update ready": "Güncelleme hazır" +"Update failed": "Güncelleme başarısız" +"Automatically download and install": "Otomatik indir ve kur" +"Download updates in the background and apply them the next time OpenLogi restarts.": "Güncellemeleri arka planda indir ve OpenLogi bir sonraki başlatıldığında uygula." +"Update source": "Güncelleme kaynağı" +"View changelog": "Değişiklik günlüğünü görüntüle" +"No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "Arka planda çalışan bir güncelleyici yok — OpenLogi yalnızca otomatik denetimi açtığınızda veya Güncellemeleri Denetle'ye tıkladığınızda bağlanır." +"Installed from a distribution package — updates come from your package manager.": "Bir dağıtım paketinden kuruldu — güncellemeler paket yöneticinizden gelir." +"Stable channel": "Kararlı kanal" +"A native, local-first alternative to Logitech Options+.": "Logitech Options+ için yerel ve öncelikle çevrimdışı çalışan bir alternatif." +"Changelog": "Değişiklik günlüğü" +"Documentation": "Belgeler" +"Report an issue": "Sorun bildir" +"Show in file manager": "Dosya yöneticisinde göster" +"Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A.": "Logitech ile bağlantılı değildir. \"Logitech\", \"MX Master\" ve \"Options+\", Logitech International S.A. şirketinin ticari markalarıdır." +"Appearance": "Görünüm" +"Appearance mode": "Görünüm modu" +"Light and dark use the matching theme; Follow system tracks the OS setting.": "Açık ve koyu eşleşen temayı kullanır; Sistemi izle, işletim sistemi ayarını takip eder." +"Light": "Açık" +"Dark": "Koyu" +"Theme": "Tema" +"Corner radius": "Köşe yuvarlaklığı" +"Roundness of buttons, cards, and controls.": "Düğmelerin, kartların ve denetimlerin yuvarlaklığı." +"Sharp": "Keskin" +"Round": "Yuvarlak" +"All": "Tümü" +"Filter themes…": "Temaları filtrele…" +"No themes match “%{query}”.": "“%{query}” ile eşleşen tema yok." +"Color theme": "Renk teması" +"Interface language": "Arayüz dili" +"Diagnostics": "Tanılama" +"Input interception": "Girdiye araya girme" +"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Fare olay akışını dinleyen diğer uygulamaları algılar — imleç gecikmesinin yaygın bir nedeni." +"No other app is intercepting mouse input.": "Başka hiçbir uygulama fare girdisine araya girmiyor." +"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Başka bir uygulama fare girdisine araya giriyor; bu, imleç gecikmesine veya düğme eylemlerinin yinelenmesine yol açabilir: %{apps}" +"Camera": "Kamera" +"Starting preview…": "Önizleme başlatılıyor…" +"Enable Camera access in Settings to preview.": "Önizleme için Ayarlar'dan Kamera erişimini etkinleştirin." +"Click to enable camera access.": "Kamera erişimini etkinleştirmek için tıklayın." +"Your Logitech webcam shows up on the main page. Grant access to see its live preview — video never leaves your Mac.": "Logitech web kameranız ana sayfada görünür. Canlı önizlemesini görmek için erişim verin — video Mac'inizden asla çıkmaz." +"Brightness": "Parlaklık" +"Contrast": "Karşıtlık" +"Saturation": "Doygunluk" +"Sharpness": "Keskinlik" +"Camera controls": "Kamera denetimleri" +"Reset to defaults": "Varsayılanlara sıfırla" +"This camera exposes no adjustable image controls.": "Bu kamera ayarlanabilir görüntü denetimi sunmuyor." +"Focus": "Odak" +"Exposure": "Pozlama" +"Anti-flicker": "Titreşim önleme" +"Low light compensation": "Düşük ışık telafisi" +"White balance": "Beyaz dengesi" +"Tint": "Renk tonu" +"Auto": "Otomatik" +"Lens": "Lens" +"Image": "Görüntü" +"Streaming": "Yayın" +"Video call": "Görüntülü görüşme" +"New": "Yeni" +"Live preview isn't available on this platform yet.": "Canlı önizleme bu platformda henüz kullanılamıyor." +"Applying light setting…": "Işık ayarı uygulanıyor…" +"Auto-on with camera": "Kamerayla otomatik aç" +"Turn this light on while any camera is in use and off when cameras stop.": "Herhangi bir kamera kullanılırken bu ışığı aç, kameralar durunca kapat." +"Power User": "İleri Düzey Kullanıcı" +"Type Text…": "Metin Yaz…" +"Run AppleScript…": "AppleScript Çalıştır…" +"Run Shell Command…": "Kabuk Komutu Çalıştır…" +"Workflow…": "İş Akışı…" +"Save Workflow": "İş Akışını Kaydet" +"+ Add Step": "+ Adım Ekle" + +"Actions Ring": "Eylem Halkası" +"Configure the eight actions shown around the cursor.": "İmlecin çevresinde gösterilen sekiz eylemi yapılandırın." +"Open at the current cursor position.": "Geçerli imleç konumunda aç." +"Haptic feedback": "Dokunsal geri bildirim" +"Play feedback when hovering and activating.": "Üzerine gelindiğinde ve etkinleştirildiğinde geri bildirim ver." +"Clear slot": "Yuvayı temizle" +"Empty slot": "Boş yuva" +"Haptic Sense Panel": "Dokunsal Algı Paneli" +"Show Actions Ring": "Eylem Halkasını Göster" +"Application path or URL": "Uygulama yolu veya URL" +"Open application": "Uygulama aç" +"Icon": "Simge" +"Use action icon": "Eylem simgesini kullan" +"Folder": "Klasör" +"File": "Dosya" +"Globe": "Küre" +"Terminal": "Terminal" +"Star": "Yıldız" +"Heart": "Kalp" +"Calendar": "Takvim" +"Bell": "Zil" +"User": "Kullanıcı" +"Palette": "Palet" +"Book": "Kitap" +"Custom shortcut": "Özel kısayol" +"Shortcut, e.g. Cmd+Shift+P": "Kısayol, örn. Cmd+Shift+P" +"Application, folder path, or URL": "Uygulama, klasör yolu veya URL" +"Open application or folder": "Uygulama veya klasör aç" diff --git a/crates/openlogi-ui/locales/uk.yml b/crates/openlogi-ui/locales/uk.yml index 16e6b5ca2..18314e90c 100644 --- a/crates/openlogi-ui/locales/uk.yml +++ b/crates/openlogi-ui/locales/uk.yml @@ -308,6 +308,7 @@ _version: 1 "Update source": "Джерело оновлень" "View changelog": "Переглянути список змін" "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "Жодного фонового оновлювача — OpenLogi підключається лише тоді, коли ви вмикаєте автоматичну перевірку або натискаєте «Перевірити наявність оновлень»." +"Installed from a distribution package — updates come from your package manager.": "Встановлено з пакунка дистрибутива — оновлення надходять із вашого менеджера пакунків." "Stable channel": "Стабільний канал" "A native, local-first alternative to Logitech Options+.": "Нативна локальна альтернатива Logitech Options+." "Changelog": "Список змін" diff --git a/crates/openlogi-ui/locales/zh-CN.yml b/crates/openlogi-ui/locales/zh-CN.yml index 95317a4cc..50408dd7e 100644 --- a/crates/openlogi-ui/locales/zh-CN.yml +++ b/crates/openlogi-ui/locales/zh-CN.yml @@ -308,6 +308,7 @@ _version: 1 "Update source": "更新来源" "View changelog": "查看更新日志" "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "无常驻更新器 —— 仅在你开启自动检查或点击「检查更新」时联网。" +"Installed from a distribution package — updates come from your package manager.": "通过发行版软件包安装 — 更新由你的包管理器提供。" "Stable channel": "稳定版通道" "A native, local-first alternative to Logitech Options+.": "原生、本地优先的 Logitech Options+ 替代品。" "Changelog": "更新日志" diff --git a/crates/openlogi-ui/locales/zh-HK.yml b/crates/openlogi-ui/locales/zh-HK.yml index 84feb1825..79ce1ac32 100644 --- a/crates/openlogi-ui/locales/zh-HK.yml +++ b/crates/openlogi-ui/locales/zh-HK.yml @@ -308,6 +308,7 @@ _version: 1 "Update source": "更新來源" "View changelog": "檢視更新日誌" "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "無常駐更新器 —— 僅在你開啟自動檢查或點擊「檢查更新」時連線。" +"Installed from a distribution package — updates come from your package manager.": "透過發行版套件安裝 — 更新由你的套件管理員提供。" "Stable channel": "穩定版通道" "A native, local-first alternative to Logitech Options+.": "原生、本機優先的 Logitech Options+ 替代品。" "Changelog": "更新日誌" diff --git a/crates/openlogi-ui/locales/zh-TW.yml b/crates/openlogi-ui/locales/zh-TW.yml index 4b10574f0..cf47aa009 100644 --- a/crates/openlogi-ui/locales/zh-TW.yml +++ b/crates/openlogi-ui/locales/zh-TW.yml @@ -308,6 +308,7 @@ _version: 1 "Update source": "更新來源" "View changelog": "檢視更新日誌" "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "無常駐更新器 —— 僅在你開啟自動檢查或點擊「檢查更新」時連線。" +"Installed from a distribution package — updates come from your package manager.": "透過發行版套件安裝 — 更新由你的套件管理員提供。" "Stable channel": "穩定版通道" "A native, local-first alternative to Logitech Options+.": "原生、本機優先的 Logitech Options+ 替代品。" "Changelog": "更新日誌" diff --git a/crates/openlogi-ui/src/locale.rs b/crates/openlogi-ui/src/locale.rs index 9f46ac168..a4c7f5241 100644 --- a/crates/openlogi-ui/src/locale.rs +++ b/crates/openlogi-ui/src/locale.rs @@ -34,6 +34,7 @@ pub const SUPPORTED: &[(&str, &str)] = &[ ("pt-BR", "Português - Brasil"), ("fi", "Suomi"), ("sv", "Svenska"), + ("tr", "Türkçe"), ("el", "Ελληνικά"), ("ru", "Русский"), ("uk", "Українська"), @@ -165,6 +166,8 @@ mod tests { assert_eq!(match_supported("pt"), Some("pt-PT")); assert_eq!(match_supported("pt-PT"), Some("pt-PT")); assert_eq!(match_supported("pt-BR"), Some("pt-BR")); + assert_eq!(match_supported("tr"), Some("tr")); + assert_eq!(match_supported("tr-TR"), Some("tr")); assert_eq!(match_supported("nb-NO"), Some("nb")); assert_eq!(match_supported("no"), Some("nb")); assert_eq!(match_supported("nn"), Some("nb")); @@ -218,6 +221,7 @@ mod tests { ("pt-BR", include_str!("../locales/pt-BR.yml")), ("pt-PT", include_str!("../locales/pt-PT.yml")), ("sv", include_str!("../locales/sv.yml")), + ("tr", include_str!("../locales/tr.yml")), ]; // `include_str!` needs literal paths, so this list is written out by diff --git a/crowdin.yml b/crowdin.yml index 69a6f7142..00b505130 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -17,6 +17,7 @@ export_languages: - pt-BR - fi - sv-SE + - tr - el - ru - uk @@ -45,6 +46,7 @@ files: pt-BR: "pt-BR" fi: "fi" sv-SE: "sv" + tr: "tr" el: "el" ja: "ja" ru: "ru" diff --git a/design/icon/openlogi-128.png b/design/icon/openlogi-128.png new file mode 100644 index 000000000..1bfc400b2 Binary files /dev/null and b/design/icon/openlogi-128.png differ diff --git a/design/icon/openlogi-16.png b/design/icon/openlogi-16.png new file mode 100644 index 000000000..807d76cc6 Binary files /dev/null and b/design/icon/openlogi-16.png differ diff --git a/design/icon/openlogi-256.png b/design/icon/openlogi-256.png new file mode 100644 index 000000000..aa69768fb Binary files /dev/null and b/design/icon/openlogi-256.png differ diff --git a/design/icon/openlogi-32.png b/design/icon/openlogi-32.png new file mode 100644 index 000000000..4134da95e Binary files /dev/null and b/design/icon/openlogi-32.png differ diff --git a/design/icon/openlogi-48.png b/design/icon/openlogi-48.png new file mode 100644 index 000000000..7eb36fdde Binary files /dev/null and b/design/icon/openlogi-48.png differ diff --git a/design/icon/openlogi-512.png b/design/icon/openlogi-512.png new file mode 100644 index 000000000..564dfe792 Binary files /dev/null and b/design/icon/openlogi-512.png differ diff --git a/design/icon/openlogi-64.png b/design/icon/openlogi-64.png new file mode 100644 index 000000000..2e00f7408 Binary files /dev/null and b/design/icon/openlogi-64.png differ diff --git a/packaging/linux/install.sh b/packaging/linux/install.sh index fb71f8c19..dff72ce96 100755 --- a/packaging/linux/install.sh +++ b/packaging/linux/install.sh @@ -51,7 +51,7 @@ The script installs: /etc/udev/rules.d/70-openlogi.rules /usr/lib/systemd/user/openlogi-agent.service (if systemd is present) /usr/share/applications/openlogi.desktop - /usr/share/icons/hicolor/1024x1024/apps/openlogi.png + /usr/share/icons/hicolor//apps/openlogi.png (16 … 1024) EOF exit 0 fi @@ -124,11 +124,20 @@ sudo install -Dm644 "${SCRIPT_DIR}/desktop/openlogi.desktop" \ # ── icon ────────────────────────────────────────────────────────────────────── +# Every standard indexed hicolor size, not only the 1024 master: a stock +# `hicolor/index.theme` stops at 512x512, so a launcher that resolves icons +# through the theme index shows nothing when only `1024x1024/apps` exists. ICON_SRC="${REPO_ROOT}/design/icon/openlogi.png" if [ -f "$ICON_SRC" ]; then echo "Installing icon …" sudo install -Dm644 "$ICON_SRC" \ /usr/share/icons/hicolor/1024x1024/apps/openlogi.png + for size in 512 256 128 64 48 32 16; do + sized="${REPO_ROOT}/design/icon/openlogi-${size}.png" + [ -f "$sized" ] || continue + sudo install -Dm644 "$sized" \ + "/usr/share/icons/hicolor/${size}x${size}/apps/openlogi.png" + done if command -v gtk-update-icon-cache >/dev/null 2>&1; then sudo gtk-update-icon-cache -qtf /usr/share/icons/hicolor || true fi diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml index bb4ddb510..bbe49fedc 100644 --- a/packaging/linux/nfpm.yaml +++ b/packaging/linux/nfpm.yaml @@ -60,10 +60,42 @@ contents: mode: 0644 # ── icon ──────────────────────────────────────────────────────────────────── + # Every standard indexed hicolor size, not just the 1024 master: a stock + # `hicolor/index.theme` stops at 512x512, so a launcher that resolves icons + # by theme index (KDE's kbuildsycoca, most GTK launchers) finds nothing at + # all when only `1024x1024/apps` is installed. - src: design/icon/openlogi.png dst: /usr/share/icons/hicolor/1024x1024/apps/openlogi.png file_info: mode: 0644 + - src: design/icon/openlogi-512.png + dst: /usr/share/icons/hicolor/512x512/apps/openlogi.png + file_info: + mode: 0644 + - src: design/icon/openlogi-256.png + dst: /usr/share/icons/hicolor/256x256/apps/openlogi.png + file_info: + mode: 0644 + - src: design/icon/openlogi-128.png + dst: /usr/share/icons/hicolor/128x128/apps/openlogi.png + file_info: + mode: 0644 + - src: design/icon/openlogi-64.png + dst: /usr/share/icons/hicolor/64x64/apps/openlogi.png + file_info: + mode: 0644 + - src: design/icon/openlogi-48.png + dst: /usr/share/icons/hicolor/48x48/apps/openlogi.png + file_info: + mode: 0644 + - src: design/icon/openlogi-32.png + dst: /usr/share/icons/hicolor/32x32/apps/openlogi.png + file_info: + mode: 0644 + - src: design/icon/openlogi-16.png + dst: /usr/share/icons/hicolor/16x16/apps/openlogi.png + file_info: + mode: 0644 - src: LICENSE-APACHE dst: /usr/share/licenses/openlogi/LICENSE-APACHE diff --git a/packaging/linux/package.nix b/packaging/linux/package.nix index d564b911d..4245917dd 100644 --- a/packaging/linux/package.nix +++ b/packaging/linux/package.nix @@ -51,7 +51,7 @@ let (src + "/LICENSE-APACHE") (src + "/LICENSE-MIT") (src + "/crates") - (src + "/design/icon/openlogi.png") + (src + "/design/icon") (src + "/docs/config.example.toml") (src + "/packaging/linux/desktop") (src + "/packaging/linux/systemd") @@ -183,8 +183,15 @@ rustPlatform.buildRustPackage { install -Dm644 packaging/linux/desktop/openlogi.desktop \ "$out/share/applications/openlogi.desktop" + # Every standard indexed hicolor size: a stock `hicolor/index.theme` + # stops at 512x512, so an icon installed only under `1024x1024/apps` is + # invisible to launchers that resolve by theme index. install -Dm644 design/icon/openlogi.png \ "$out/share/icons/hicolor/1024x1024/apps/openlogi.png" + for size in 512 256 128 64 48 32 16; do + install -Dm644 "design/icon/openlogi-$size.png" \ + "$out/share/icons/hicolor/''${size}x''${size}/apps/openlogi.png" + done install -Dm644 packaging/linux/udev/70-openlogi.rules \ "$out/lib/udev/rules.d/70-openlogi.rules" install -Dm644 packaging/linux/systemd/openlogi-agent.service \ @@ -213,7 +220,9 @@ rustPlatform.buildRustPackage { test ! -e "$out/bin/openlogi-agent-mock" test -f "$out/lib/udev/rules.d/70-openlogi.rules" test -f "$out/share/applications/openlogi.desktop" - test -f "$out/share/icons/hicolor/1024x1024/apps/openlogi.png" + for size in 1024 512 256 128 64 48 32 16; do + test -f "$out/share/icons/hicolor/''${size}x''${size}/apps/openlogi.png" + done grep -Fqx \ "ExecStart=$out/bin/openlogi-agent" \ "$out/share/systemd/user/openlogi-agent.service" diff --git a/packaging/linux/uninstall.sh b/packaging/linux/uninstall.sh index 6e6749821..21bb951c3 100755 --- a/packaging/linux/uninstall.sh +++ b/packaging/linux/uninstall.sh @@ -67,7 +67,9 @@ sudo rm -f /usr/lib/systemd/user/openlogi-agent.service echo "Removing desktop entry and icon …" sudo rm -f /usr/share/applications/openlogi.desktop -sudo rm -f /usr/share/icons/hicolor/1024x1024/apps/openlogi.png +for size in 1024 512 256 128 64 48 32 16; do + sudo rm -f "/usr/share/icons/hicolor/${size}x${size}/apps/openlogi.png" +done if command -v gtk-update-icon-cache >/dev/null 2>&1; then sudo gtk-update-icon-cache -qtf /usr/share/icons/hicolor || true