Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
d44d1c3
feat(linux): launch_at_login via systemd user unit
cserby Jun 8, 2026
8b060ba
feat(linux): input device access permission check
cserby Jun 8, 2026
39b6e6c
fix(gui): hide macOS-only UI on Linux
cserby Jun 8, 2026
5a439f3
fix(linux): address PR #172 review comments
cserby Jun 8, 2026
fabb150
fix(linux): address PR #172 round-2 review comments
cserby Jun 8, 2026
d65c013
i18n(gui): add translations for 'Input device access'
cserby Jun 8, 2026
f4a714e
fix(gui,i18n): add missing locale keys; fix InteractiveElement import…
cserby Jun 10, 2026
d7cb0eb
fix(gui): gate dead code; add StatefulInteractiveElement import
cserby Jun 11, 2026
df3a482
fix(gui): rebase onto upstream; correct cfg gates for macOS-only imports
cserby Jun 11, 2026
370b5e8
fix(gui): gate platform-specific imports to fix Windows clippy
cserby Jun 11, 2026
cc33be4
feat(linux): input device access permission check
cserby Jun 8, 2026
6e0ccad
feat(linux): add install artifacts
cserby Jun 8, 2026
eb1c94f
docs(linux): add Linux install guide and README section
cserby Jun 8, 2026
bb9386e
fix(linux): address PR #173 review comments
cserby Jun 8, 2026
97d50dc
fix(linux): address PR #173 round-2 review comments
cserby Jun 8, 2026
4eb9d96
fix(linux): also grant uaccess for Bluetooth-connected Logitech devices
cserby Jun 8, 2026
f4ea8c6
fix(linux): set XDG_RUNTIME_DIR for systemctl --user calls under sudo
cserby Jun 8, 2026
40f650a
refactor(linux): use @BINDIR@ placeholder in service template
cserby Jun 9, 2026
cda7b6a
docs(linux): note replug requirement when device connected at install…
cserby Jun 11, 2026
ae84ff8
feat(linux): add nfpm package descriptor and install scripts
cserby Jun 8, 2026
ecc6040
feat(linux): xtask package-linux command and CI release job
cserby Jun 8, 2026
ce5e899
refactor(linux): simplify package-linux xtask and CI
cserby Jun 8, 2026
bba48bf
fix(linux): address PR #179 review comments
cserby Jun 8, 2026
abde629
fix(ci): pin nfpm to a specific release with SHA256 verification
cserby Jun 8, 2026
dcc026a
fix(linux): udevadm settle in package scripts; sign .deb/.rpm
cserby Jun 8, 2026
e6b58b9
fix(linux): expand @BINDIR@ in service unit during package postinstall
cserby Jun 9, 2026
c6d9f82
style(xtask): rustfmt linux.rs args array
cserby Jun 10, 2026
7404514
fix(ci,pkg): artifact version mismatch, publish race, postremove orde…
cserby Jun 11, 2026
3b9ea8f
feat(hid): Unifying receiver — protocol, discovery, enumeration, routing
cserby Jun 8, 2026
0bd7a49
fix(gui,agent,cli): wire Unifying receiver into the app stack
cserby Jun 8, 2026
f57c3af
fix(hid): scope Unifying cache key to receiver; clarify Bolt fallback
cserby Jun 8, 2026
7008efd
fix(hid): safe Clone for UnifyingReceiver; proper Unifying cache key
cserby Jun 8, 2026
6e023be
i18n(gui): add translations for 'Unifying receiver'
cserby Jun 8, 2026
af1e971
fix(hidpp): safe Clone for BoltReceiver; share ListenerDropGuard
cserby Jun 8, 2026
2eea309
fix(hid): preserve cached data on Unifying slot probe timeout
cserby Jun 9, 2026
e60a66e
fix(hid): gate is_receiver_child_sysfs_path for non-Linux builds
cserby Jun 10, 2026
d32e2de
fix(hid): fall back to PID when Unifying receiver UID is unavailable
cserby Jun 10, 2026
b110228
fix(agent): re-enable systemd unit even when content is already current
cserby Jun 11, 2026
abf4c4b
fix(gui,xtask): gate platform permissions to macos/linux; drop dead x…
cserby Jun 11, 2026
59fe8f2
Merge remote-tracking branch 'origin/master' into b181
AprilNEA Jun 12, 2026
3fe0999
Merge remote-tracking branch 'origin/master' into b181
AprilNEA Jun 12, 2026
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
44 changes: 43 additions & 1 deletion crates/openlogi-agent-core/src/device_order.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ impl DeviceStableId {
unit_id: [u8; 4],
) -> Self {
match route {
Some(DeviceRoute::Bolt { receiver_uid, slot }) => Self::Bolt {
Some(
DeviceRoute::Bolt { receiver_uid, slot }
| DeviceRoute::Unifying { receiver_uid, slot },
) => Self::Bolt {
receiver_uid: receiver_uid.to_ascii_lowercase(),
slot: *slot,
},
Expand All @@ -78,3 +81,42 @@ impl DeviceStableId {
}
}
}

#[cfg(test)]
mod tests {
use openlogi_hid::DeviceRoute;

use super::DeviceStableId;

#[test]
fn unifying_route_maps_to_bolt_stable_id() {
let route = DeviceRoute::Unifying {
receiver_uid: "DA2699E1".into(),
slot: 2,
};
let id = DeviceStableId::from_parts(Some(&route), 2, None, [0; 4]);
// Unifying and Bolt share the same stable-id variant so the GUI and
// agent agree on carousel order regardless of receiver family.
assert!(
matches!(id, DeviceStableId::Bolt { ref receiver_uid, slot: 2 }
if receiver_uid == "da2699e1"),
"Unifying route should map to DeviceStableId::Bolt with case-folded uid"
);
}

#[test]
fn bolt_and_unifying_same_uid_slot_produce_identical_stable_id() {
let bolt = DeviceRoute::Bolt {
receiver_uid: "AABB".into(),
slot: 1,
};
let unifying = DeviceRoute::Unifying {
receiver_uid: "AABB".into(),
slot: 1,
};
assert_eq!(
DeviceStableId::from_parts(Some(&bolt), 1, None, [0; 4]),
DeviceStableId::from_parts(Some(&unifying), 1, None, [0; 4]),
);
}
}
23 changes: 2 additions & 21 deletions crates/openlogi-agent-core/src/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use std::sync::{Arc, RwLock};

use openlogi_core::config::Config;
use openlogi_core::device::DeviceInventory;
use openlogi_hid::{CaptureChannel, DIRECT_DEVICE_INDEX, DeviceRoute};
use openlogi_hid::{CaptureChannel, DeviceRoute};
use tracing::warn;

use crate::DpiCycleState;
Expand Down Expand Up @@ -296,7 +296,7 @@ fn build_devices(inventories: &[DeviceInventory]) -> Vec<AgentDevice> {
};
devices.push(AgentDevice {
config_key: model.config_key(),
route: device_route(inv, paired.slot),
route: DeviceRoute::device_route_for(inv, paired.slot),
slot: paired.slot,
serial: model.serial_number.clone(),
unit_id: model.unit_id,
Expand Down Expand Up @@ -330,25 +330,6 @@ fn pick_current(devices: &[AgentDevice], saved: Option<&str>) -> usize {
.unwrap_or(0)
}

/// Build the [`DeviceRoute`] HID++ writes use to reach a device. A Bolt-paired
/// device routes through its receiver UID + slot; a directly attached one
/// (USB / Bluetooth) carries no receiver UID and sits at [`DIRECT_DEVICE_INDEX`],
/// routing by vendor/product id. A Bolt device whose receiver UID is unknown
/// gets no route, so writes are skipped rather than mis-routed.
fn device_route(inv: &DeviceInventory, slot: u8) -> Option<DeviceRoute> {
match &inv.receiver.unique_id {
Some(receiver_uid) => Some(DeviceRoute::Bolt {
receiver_uid: receiver_uid.clone(),
slot,
}),
None if slot == DIRECT_DEVICE_INDEX => Some(DeviceRoute::Direct {
vendor_id: inv.receiver.vendor_id,
product_id: inv.receiver.product_id,
}),
None => None,
}
}

/// Replace the value behind an `RwLock`, logging (not panicking) on poison so a
/// background thread that paniced while holding the lock can't take the agent
/// down — it just keeps the stale value until the next successful rebuild.
Expand Down
3 changes: 3 additions & 0 deletions crates/openlogi-agent/src/launch_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,9 @@ fn reconcile_linux(enabled: bool) -> io::Result<()> {
match (desired.as_deref(), current.as_deref()) {
(Some(want), Some(have)) if want == have => {
debug!(path = %path.display(), "systemd user unit already current");
// Re-enable unconditionally: the unit file is current but the user
// may have manually disabled the service since the last reconcile.
run_systemctl(&["enable", UNIT_NAME]);
}
(Some(want), _) => {
if let Some(parent) = path.parent() {
Expand Down
11 changes: 3 additions & 8 deletions crates/openlogi-cli/src/cmd/diag/features.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,11 @@ pub async fn run(_args: FeaturesArgs) -> Result<()> {
for inv in &inventories {
for paired in inv.paired.iter().filter(|p| p.online) {
any = true;
let route = match &inv.receiver.unique_id {
Some(uid) => DeviceRoute::Bolt {
receiver_uid: uid.clone(),
slot: paired.slot,
},
None => DeviceRoute::Direct {
let route =
DeviceRoute::device_route_for(inv, paired.slot).unwrap_or(DeviceRoute::Direct {
vendor_id: inv.receiver.vendor_id,
product_id: inv.receiver.product_id,
},
};
});
let name = paired
.codename
.clone()
Expand Down
14 changes: 5 additions & 9 deletions crates/openlogi-cli/src/cmd/diag/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,19 +51,15 @@ async fn online_devices() -> Result<Vec<Candidate>> {
let inventories = openlogi_hid::enumerate().await?;
let mut out = Vec::new();
for inv in inventories {
for paired in inv.paired.into_iter().filter(|p| p.online) {
let route = match &inv.receiver.unique_id {
Some(uid) => DeviceRoute::Bolt {
receiver_uid: uid.clone(),
slot: paired.slot,
},
None => DeviceRoute::Direct {
for paired in inv.paired.iter().filter(|p| p.online) {
let route =
DeviceRoute::device_route_for(&inv, paired.slot).unwrap_or(DeviceRoute::Direct {
vendor_id: inv.receiver.vendor_id,
product_id: inv.receiver.product_id,
},
};
});
let name = paired
.codename
.clone()
.unwrap_or_else(|| format!("Slot {}", paired.slot));
out.push(Candidate { route, name });
}
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-gui/locales/da.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ _version: 1
"Full": "Fuldt"
"Battery error": "Batterifejl"
"Bolt receiver": "Bolt-modtager"
"Unifying receiver": "Unifying receiver"
"Direct connection": "Direkte forbindelse"
"Unavailable": "Ikke tilgængelig"
"Mouse": "Mus"
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-gui/locales/de.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ _version: 1
"Full": "Voll"
"Battery error": "Akkufehler"
"Bolt receiver": "Bolt-Empfänger"
"Unifying receiver": "Unifying receiver"
"Direct connection": "Direktverbindung"
"Unavailable": "Nicht verfügbar"
"Mouse": "Maus"
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-gui/locales/el.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ _version: 1
"Full": "Πλήρης"
"Battery error": "Σφάλμα μπαταρίας"
"Bolt receiver": "Δέκτης Bolt"
"Unifying receiver": "Unifying receiver"
"Direct connection": "Απευθείας σύνδεση"
"Unavailable": "Μη διαθέσιμο"
"Mouse": "Ποντίκι"
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-gui/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ _version: 1
"Full": "Full"
"Battery error": "Battery error"
"Bolt receiver": "Bolt receiver"
"Unifying receiver": "Unifying receiver"
"Direct connection": "Direct connection"
"Unavailable": "Unavailable"
"Mouse": "Mouse"
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-gui/locales/es.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ _version: 1
"Full": "Completa"
"Battery error": "Error de batería"
"Bolt receiver": "Receptor Bolt"
"Unifying receiver": "Unifying receiver"
"Direct connection": "Conexión directa"
"Unavailable": "No disponible"
"Mouse": "Ratón"
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-gui/locales/fi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ _version: 1
"Full": "Täynnä"
"Battery error": "Akkuvirhe"
"Bolt receiver": "Bolt-vastaanotin"
"Unifying receiver": "Unifying receiver"
"Direct connection": "Suora yhteys"
"Unavailable": "Ei käytettävissä"
"Mouse": "Hiiri"
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-gui/locales/fr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ _version: 1
"Full": "Pleine"
"Battery error": "Erreur de batterie"
"Bolt receiver": "Récepteur Bolt"
"Unifying receiver": "Unifying receiver"
"Direct connection": "Connexion directe"
"Unavailable": "Indisponible"
"Mouse": "Souris"
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-gui/locales/it.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ _version: 1
"Full": "Carica completa"
"Battery error": "Errore batteria"
"Bolt receiver": "Ricevitore Bolt"
"Unifying receiver": "Ricevitore Unifying"
"Direct connection": "Connessione diretta"
"Unavailable": "Non disponibile"
"Mouse": "Mouse"
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-gui/locales/ja.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ _version: 1
"Full": "フル充電"
"Battery error": "バッテリーエラー"
"Bolt receiver": "Bolt レシーバー"
"Unifying receiver": "Unifying レシーバー"
"Direct connection": "直接接続"
"Unavailable": "利用不可"
"Mouse": "マウス"
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-gui/locales/ko.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ _version: 1
"Full": "완충"
"Battery error": "배터리 오류"
"Bolt receiver": "Bolt 수신기"
"Unifying receiver": "Unifying receiver"
"Direct connection": "직접 연결"
"Unavailable": "사용 불가"
"Mouse": "마우스"
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-gui/locales/nb.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ _version: 1
"Full": "Fullt"
"Battery error": "Batterifeil"
"Bolt receiver": "Bolt-mottaker"
"Unifying receiver": "Unifying receiver"
"Direct connection": "Direkte tilkobling"
"Unavailable": "Utilgjengelig"
"Mouse": "Mus"
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-gui/locales/nl.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ _version: 1
"Full": "Vol"
"Battery error": "Batterijfout"
"Bolt receiver": "Bolt-ontvanger"
"Unifying receiver": "Unifying receiver"
"Direct connection": "Directe verbinding"
"Unavailable": "Niet beschikbaar"
"Mouse": "Muis"
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-gui/locales/pl.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ _version: 1
"Full": "Pełna"
"Battery error": "Błąd baterii"
"Bolt receiver": "Odbiornik Bolt"
"Unifying receiver": "Unifying receiver"
"Direct connection": "Połączenie bezpośrednie"
"Unavailable": "Niedostępne"
"Mouse": "Mysz"
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-gui/locales/pt-BR.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ _version: 1
"Full": "Cheia"
"Battery error": "Erro de bateria"
"Bolt receiver": "Receptor Bolt"
"Unifying receiver": "Unifying receiver"
"Direct connection": "Conexão direta"
"Unavailable": "Indisponível"
"Mouse": "Mouse"
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-gui/locales/pt-PT.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ _version: 1
"Full": "Completa"
"Battery error": "Erro de bateria"
"Bolt receiver": "Recetor Bolt"
"Unifying receiver": "Unifying receiver"
"Direct connection": "Ligação direta"
"Unavailable": "Indisponível"
"Mouse": "Rato"
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-gui/locales/ru.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ _version: 1
"Full": "Полный заряд"
"Battery error": "Ошибка батареи"
"Bolt receiver": "Приёмник Bolt"
"Unifying receiver": "Приёмник Unifying"
"Direct connection": "Прямое подключение"
"Unavailable": "Недоступно"
"Mouse": "Мышь"
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-gui/locales/sv.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ _version: 1
"Full": "Fulladdat"
"Battery error": "Batterifel"
"Bolt receiver": "Bolt-mottagare"
"Unifying receiver": "Unifying receiver"
"Direct connection": "Direktanslutning"
"Unavailable": "Otillgänglig"
"Mouse": "Mus"
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-gui/locales/zh-CN.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ _version: 1
"Full": "已充满"
"Battery error": "电池错误"
"Bolt receiver": "Bolt 接收器"
"Unifying receiver": "Unifying 接收器"
"Direct connection": "直接连接"
"Unavailable": "不可用"
"Mouse": "鼠标"
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-gui/locales/zh-HK.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ _version: 1
"Full": "已充滿"
"Battery error": "電池錯誤"
"Bolt receiver": "Bolt 接收器"
"Unifying receiver": "Unifying 接收器"
"Direct connection": "直接連線"
"Unavailable": "不可用"
"Mouse": "滑鼠"
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-gui/locales/zh-TW.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ _version: 1
"Full": "已充滿"
"Battery error": "電池錯誤"
"Bolt receiver": "Bolt 接收器"
"Unifying receiver": "Unifying 接收器"
"Direct connection": "直接連線"
"Unavailable": "無法使用"
"Mouse": "滑鼠"
Expand Down
46 changes: 34 additions & 12 deletions crates/openlogi-gui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,22 +80,24 @@ impl DetailTab {
/// Each panel is gated on the device's actual [`Capabilities`] — the HID++
/// features it announced — not on its [`DeviceKind`]. A panel shows iff the
/// device can do that thing, so a misclassified device can't lose its
/// panels (issue #127) and a keyboard's future button config won't be hidden
/// by a kind check. Devices we never probed (offline at startup) have no
/// panels (issue #127). Devices we never probed (offline at startup) have no
/// measured capabilities; we presume a set from their kind so a sleeping
/// mouse still shows its (host-side) button bindings.
///
/// The Buttons panel renders a *mouse-model* silhouette with hotspots. It is
/// only useful for pointer-type devices (Mouse / Trackball) or when the device
/// has a resolved asset that provides its own correct layout. A keyboard that
/// exposes ReprogControls via HID++ but has no asset would get the generic
/// mouse fallback hotspots — confusing and wrong. Suppress the Buttons tab for
/// such devices until a proper keyboard-layout UI is available.
fn tabs_for(record: &DeviceRecord) -> Vec<Self> {
let caps = record
.capabilities
.unwrap_or_else(|| Capabilities::presumed_from_kind(record.kind));
// A real keyboard exposes reprogrammable controls (media / G-keys) that
// the HID++ probe reports as `buttons`, but those aren't the mouse remap
// section — so a keyboard only earns the Buttons tab when it's also a
// pointing device (i.e. actually a mouse the registry mislabelled, the
// #127 case). Non-keyboards keep the capability-driven behaviour.
let pointer_device = caps.pointer || record.kind != DeviceKind::Keyboard;
let can_show_mouse_model = record.asset.is_some()
|| matches!(record.kind, DeviceKind::Mouse | DeviceKind::Trackball);
let mut tabs = Vec::new();
if caps.buttons && pointer_device {
if caps.buttons && can_show_mouse_model {
tabs.push(Self::Buttons);
}
if caps.pointer {
Expand Down Expand Up @@ -1259,6 +1261,7 @@ fn sidebar_action(
fn route_label(route: Option<&DeviceRoute>) -> String {
match route {
Some(DeviceRoute::Bolt { .. }) => tr!("Bolt receiver").to_string(),
Some(DeviceRoute::Unifying { .. }) => tr!("Unifying receiver").to_string(),
Some(DeviceRoute::Direct { .. }) => tr!("Direct connection").to_string(),
None => tr!("Unavailable").to_string(),
}
Expand Down Expand Up @@ -1586,21 +1589,40 @@ mod tests {
}

/// Tabs follow measured capabilities, not kind — the core of the #127 fix.
/// A device the registry mislabels (kind=Keyboard) but that exposes button +
/// pointer features still gets its Buttons/Pointer tabs and no lighting.
/// A device the Bolt register mislabels as Keyboard but whose 0x0005 probe
/// returns Mouse ends up with kind=Mouse; measured caps drive the tabs.
#[test]
fn tabs_follow_capabilities_not_kind() {
let caps = Some(Capabilities {
buttons: true,
pointer: true,
lighting: false,
});
let tabs = DetailTab::tabs_for(&record(DeviceKind::Keyboard, caps));
// After 0x0005 kind-correction the record has kind=Mouse, not Keyboard.
let tabs = DetailTab::tabs_for(&record(DeviceKind::Mouse, caps));
assert!(tabs.contains(&DetailTab::Buttons));
assert!(tabs.contains(&DetailTab::Pointer));
assert!(!tabs.contains(&DetailTab::Lighting));
}

/// A keyboard that exposes ReprogControls (buttons=true) but has no resolved
/// asset should not get the mouse-model Buttons panel — the generic mouse
/// hotspot layout (Middle Click, DPI Toggle, …) is wrong for a keyboard.
#[test]
fn keyboard_without_asset_hides_buttons_tab() {
let caps = Some(Capabilities {
buttons: true,
pointer: false,
lighting: true,
});
let tabs = DetailTab::tabs_for(&record(DeviceKind::Keyboard, caps));
assert!(
!tabs.contains(&DetailTab::Buttons),
"mouse model shown for keyboard"
);
assert!(tabs.contains(&DetailTab::Lighting));
}

/// Each panel is independent: a lighting-only device (e.g. a keyboard with
/// RGB but no remappable keys yet) shows only Lighting + Device.
#[test]
Expand Down
Loading
Loading