Skip to main content

openlogi_core/
diagnostics.rs

1//! Privacy-filtered diagnostics report for support tickets — model-level only, no unique identifiers by construction.
2
3use std::fmt::Write as _;
4
5use serde::{Deserialize, Serialize};
6
7use crate::device::{BatteryInfo, BatteryStatus, Capabilities, DeviceKind, DeviceTransports};
8
9/// Where the resolver found the bundled device renders.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum AssetSource {
13    /// Read-only assets shipped inside the macOS `.app` bundle (release builds).
14    Bundle,
15    /// The per-user cache populated by the background asset sync.
16    UserCache,
17    /// Neither tier was found — devices fall back to the synthetic silhouette.
18    None,
19}
20
21/// How a device reaches the host, refined past the raw HID++ route via announced transports.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum ConnectionKind {
25    BoltReceiver,
26    UnifyingReceiver,
27    BluetoothDirect,
28    Wired,
29    Unknown,
30}
31
32/// Whether a curated render resolved, or the device fell back to the silhouette.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case", tag = "state", content = "depot")]
35pub enum RenderState {
36    Resolved(String),
37    Silhouette,
38}
39
40/// A receiver, by model only — never its `unique_id`.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct ReceiverDiag {
43    pub name: String,
44    pub vendor_id: u16,
45    pub product_id: u16,
46}
47
48/// One paired device, model-level only.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct DeviceDiag {
51    pub display_name: String,
52    pub kind: DeviceKind,
53    /// Firmware codename (e.g. `"MX Master 3S"`), when known.
54    pub codename: Option<String>,
55    pub connection: ConnectionKind,
56    pub online: bool,
57    pub battery: Option<BatteryInfo>,
58    /// Measured HID++ capabilities, or `None` if never probed since the agent started.
59    pub capabilities: Option<Capabilities>,
60    /// Human DPI summary (current + supported range), or `None` when not queried.
61    pub dpi: Option<String>,
62    /// Model identifier (e.g. `"2b35a"`) — a per-model key, not user-identifying.
63    pub config_key: String,
64    pub wpid: Option<u16>,
65    /// Per-transport PID array from HID++ DeviceInformation (0x0003).
66    pub model_ids: Option<[u16; 3]>,
67    pub extended_model_id: Option<u8>,
68    pub transports: Option<DeviceTransports>,
69    pub render: RenderState,
70    pub slot: u8,
71}
72
73/// App, agent, and host environment.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct AppInfo {
76    pub gui_version: String,
77    /// `"debug"` or `"release"`.
78    pub build_profile: String,
79    /// `None` when the agent is unreachable (not yet connected / restarting).
80    pub agent_version: Option<String>,
81    pub protocol_gui: u32,
82    pub protocol_agent: Option<u32>,
83    /// Raw `std::env::consts::OS` (`"macos"` / `"linux"` / `"windows"`).
84    pub os: String,
85    pub os_version: Option<String>,
86    pub arch: String,
87    pub system_locale: Option<String>,
88    /// Explicit UI-language override, or `None` for "follow system".
89    pub ui_language: Option<String>,
90    pub accessibility_granted: bool,
91    /// `None` when the agent status is unavailable.
92    pub hook_installed: Option<bool>,
93    pub launch_at_login: Option<bool>,
94    pub show_in_menu_bar: Option<bool>,
95    pub check_for_updates: Option<bool>,
96    pub thumbwheel_sensitivity: Option<i32>,
97    pub config_schema_version: Option<u32>,
98    pub configured_device_count: Option<usize>,
99    pub running_from_bundle: bool,
100}
101
102/// Asset-cache state behind device renders.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub struct AssetInfo {
105    pub source: AssetSource,
106    pub index_loaded: bool,
107    /// Number of device models in the loaded index, when known.
108    pub index_entries: Option<usize>,
109    pub user_cache_present: bool,
110    /// Cache directory with the home prefix redacted to `~`.
111    pub cache_path: String,
112    pub bundle_present: bool,
113}
114
115/// The whole report. Render with [`Self::to_markdown`] for the clipboard.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct DiagnosticsReport {
118    pub app: AppInfo,
119    pub assets: AssetInfo,
120    pub receivers: Vec<ReceiverDiag>,
121    pub devices: Vec<DeviceDiag>,
122}
123
124impl DiagnosticsReport {
125    /// Render the report as the Markdown blob copied to the clipboard.
126    #[must_use]
127    pub fn to_markdown(&self) -> String {
128        let mut out = String::new();
129        let _ = writeln!(out, "### OpenLogi Diagnostics\n");
130        self.write_app(&mut out);
131        self.write_assets(&mut out);
132        self.write_devices(&mut out);
133        out.truncate(out.trim_end().len());
134        out
135    }
136
137    fn write_app(&self, out: &mut String) {
138        let a = &self.app;
139        let _ = writeln!(out, "**App**");
140        let _ = writeln!(
141            out,
142            "- OpenLogi (GUI): v{} ({})",
143            a.gui_version, a.build_profile
144        );
145        let agent = match &a.agent_version {
146            Some(v) if *v == a.gui_version => format!("v{v} (connected)"),
147            Some(v) => format!("v{v} (connected) ⚠️ version mismatch with GUI"),
148            None => "not connected".to_string(),
149        };
150        let _ = writeln!(out, "- Agent: {agent}");
151        let proto = match a.protocol_agent {
152            Some(p) if p == a.protocol_gui => format!("GUI {} / agent {p}", a.protocol_gui),
153            Some(p) => format!("GUI {} / agent {p} ⚠️ mismatch", a.protocol_gui),
154            None => format!("GUI {} / agent —", a.protocol_gui),
155        };
156        let _ = writeln!(out, "- IPC protocol: {proto}");
157        let os = match &a.os_version {
158            Some(v) => format!("{} {} ({})", os_label(&a.os), v, a.arch),
159            None => format!("{} ({})", os_label(&a.os), a.arch),
160        };
161        let _ = writeln!(out, "- OS: {os}");
162        let locale = a.system_locale.as_deref().unwrap_or("unknown");
163        let ui = a.ui_language.as_deref().unwrap_or("follow system");
164        let _ = writeln!(out, "- Locale: {locale} (UI: {ui})");
165        let _ = writeln!(
166            out,
167            "- Accessibility: {} · Input hook: {}",
168            granted(a.accessibility_granted),
169            opt_state(a.hook_installed, "installed", "not installed"),
170        );
171        let _ = writeln!(
172            out,
173            "- Launch at login: {} · Menu bar: {} · Update check: {}",
174            opt_state(a.launch_at_login, "yes", "no"),
175            opt_state(a.show_in_menu_bar, "yes", "no"),
176            opt_state(a.check_for_updates, "on", "off"),
177        );
178        let source = if a.running_from_bundle {
179            "app bundle (release)"
180        } else {
181            "source build (dev)"
182        };
183        let _ = writeln!(out, "- Running from: {source}");
184        let _ = writeln!(
185            out,
186            "- Config: schema {} · {} configured device(s) · thumbwheel {}\n",
187            opt_num(a.config_schema_version),
188            opt_num(a.configured_device_count),
189            opt_num(a.thumbwheel_sensitivity),
190        );
191    }
192
193    fn write_assets(&self, out: &mut String) {
194        let s = &self.assets;
195        let _ = writeln!(out, "**Assets**");
196        let index = match (s.index_loaded, s.index_entries) {
197            (true, Some(n)) => format!("loaded ({n} models)"),
198            (true, None) => "loaded".to_string(),
199            (false, _) => "not loaded".to_string(),
200        };
201        let _ = writeln!(
202            out,
203            "- Source: {} · Index: {index} · User cache: {}",
204            asset_source_label(s.source),
205            if s.user_cache_present {
206                "present"
207            } else {
208                "absent"
209            },
210        );
211        let _ = writeln!(
212            out,
213            "- Cache path: {} · Bundle assets: {}\n",
214            s.cache_path,
215            if s.bundle_present {
216                "present"
217            } else {
218                "absent"
219            },
220        );
221    }
222
223    fn write_devices(&self, out: &mut String) {
224        let _ = writeln!(out, "**Devices ({})**", self.devices.len());
225        if self.devices.is_empty() {
226            let _ = writeln!(out, "- No devices detected.");
227        }
228        for d in &self.devices {
229            let codename = d
230                .codename
231                .as_deref()
232                .map(|c| format!(" (codename: {c})"))
233                .unwrap_or_default();
234            let _ = writeln!(
235                out,
236                "- {} — {}{codename}",
237                d.display_name,
238                kind_label(d.kind)
239            );
240            let _ = writeln!(
241                out,
242                "  - Connection: {} · Online: {} · Battery: {}",
243                connection_label(d.connection),
244                yes_no(d.online),
245                battery_label(d.battery.as_ref()),
246            );
247            let caps = match d.capabilities {
248                Some(c) => format!(
249                    "buttons={}, pointer={}, lighting={}",
250                    yes_no(c.buttons),
251                    yes_no(c.pointer),
252                    yes_no(c.lighting),
253                ),
254                None => "not probed".to_string(),
255            };
256            let _ = writeln!(out, "  - Capabilities: {caps}");
257            if let Some(dpi) = &d.dpi {
258                let _ = writeln!(out, "  - DPI: {dpi}");
259            }
260            let _ = writeln!(out, "  - Model: {}{}", d.config_key, model_detail(d));
261            if let Some(t) = d.transports {
262                let _ = writeln!(out, "  - Transports: {}", transports_label(t));
263            }
264            let render = match &d.render {
265                RenderState::Resolved(depot) => depot.clone(),
266                RenderState::Silhouette => "⚠️ none (silhouette)".to_string(),
267            };
268            let _ = writeln!(out, "  - Render: {render} · {}", slot_label(d.slot));
269        }
270        if !self.receivers.is_empty() {
271            let _ = writeln!(out, "\n**Receivers ({})**", self.receivers.len());
272            for r in &self.receivers {
273                let _ = writeln!(
274                    out,
275                    "- {} (VID {:04x} / PID {:04x})",
276                    r.name, r.vendor_id, r.product_id
277                );
278            }
279        }
280    }
281}
282
283fn model_detail(d: &DeviceDiag) -> String {
284    let mut parts = Vec::new();
285    if let Some(wpid) = d.wpid {
286        parts.push(format!("wpid: {wpid:04x}"));
287    }
288    if let Some([a, b, c]) = d.model_ids {
289        parts.push(format!("model-ids: {a:04x}/{b:04x}/{c:04x}"));
290    }
291    if let Some(ext) = d.extended_model_id {
292        parts.push(format!("ext-model: {ext:02x}"));
293    }
294    if parts.is_empty() {
295        String::new()
296    } else {
297        format!(" ({})", parts.join(", "))
298    }
299}
300
301fn slot_label(slot: u8) -> String {
302    // 0xFF is the HID++ direct-device index (USB cable / Bluetooth, no receiver).
303    if slot == 0xFF {
304        "direct".to_string()
305    } else {
306        format!("Slot {slot}")
307    }
308}
309
310fn os_label(os: &str) -> &str {
311    match os {
312        "macos" => "macOS",
313        "linux" => "Linux",
314        "windows" => "Windows",
315        other => other,
316    }
317}
318
319fn asset_source_label(source: AssetSource) -> &'static str {
320    match source {
321        AssetSource::Bundle => "app bundle",
322        AssetSource::UserCache => "user cache",
323        AssetSource::None => "none",
324    }
325}
326
327fn kind_label(kind: DeviceKind) -> &'static str {
328    match kind {
329        DeviceKind::Mouse => "mouse",
330        DeviceKind::Keyboard => "keyboard",
331        DeviceKind::Numpad => "numpad",
332        DeviceKind::Presenter => "presenter",
333        DeviceKind::Remote => "remote",
334        DeviceKind::Trackball => "trackball",
335        DeviceKind::Touchpad => "touchpad",
336        DeviceKind::Tablet => "tablet",
337        DeviceKind::Gamepad => "gamepad",
338        DeviceKind::Joystick => "joystick",
339        DeviceKind::Headset => "headset",
340        DeviceKind::Unknown => "unknown",
341    }
342}
343
344fn connection_label(connection: ConnectionKind) -> &'static str {
345    match connection {
346        ConnectionKind::BoltReceiver => "Logi Bolt receiver",
347        ConnectionKind::UnifyingReceiver => "Logi Unifying receiver",
348        ConnectionKind::BluetoothDirect => "Bluetooth (direct)",
349        ConnectionKind::Wired => "Wired (USB)",
350        ConnectionKind::Unknown => "unknown",
351    }
352}
353
354fn battery_label(battery: Option<&BatteryInfo>) -> String {
355    match battery {
356        Some(b) => format!(
357            "{}% ({}, {})",
358            b.percentage,
359            battery_status_label(b.status),
360            battery_level_label(b.level),
361        ),
362        None => "n/a".to_string(),
363    }
364}
365
366fn battery_status_label(status: BatteryStatus) -> &'static str {
367    match status {
368        BatteryStatus::Discharging => "discharging",
369        BatteryStatus::Charging => "charging",
370        BatteryStatus::ChargingSlow => "charging (slow)",
371        BatteryStatus::Full => "full",
372        BatteryStatus::Error => "error",
373        BatteryStatus::Unknown => "unknown",
374    }
375}
376
377fn battery_level_label(level: crate::device::BatteryLevel) -> &'static str {
378    use crate::device::BatteryLevel;
379    match level {
380        BatteryLevel::Critical => "critical",
381        BatteryLevel::Low => "low",
382        BatteryLevel::Good => "good",
383        BatteryLevel::Full => "full",
384        BatteryLevel::Unknown => "unknown",
385    }
386}
387
388fn transports_label(t: DeviceTransports) -> String {
389    let mut parts = Vec::new();
390    if t.usb {
391        parts.push("USB");
392    }
393    if t.equad {
394        parts.push("eQuad");
395    }
396    if t.btle {
397        parts.push("BTLE");
398    }
399    if t.bluetooth {
400        parts.push("Bluetooth");
401    }
402    if parts.is_empty() {
403        "none".to_string()
404    } else {
405        parts.join(", ")
406    }
407}
408
409fn yes_no(value: bool) -> &'static str {
410    if value { "yes" } else { "no" }
411}
412
413fn granted(value: bool) -> &'static str {
414    if value { "granted" } else { "denied" }
415}
416
417fn opt_state(value: Option<bool>, yes: &'static str, no: &'static str) -> &'static str {
418    match value {
419        Some(true) => yes,
420        Some(false) => no,
421        None => "unknown",
422    }
423}
424
425fn opt_num<T: std::fmt::Display>(value: Option<T>) -> String {
426    value.map_or_else(|| "—".to_string(), |v| v.to_string())
427}
428
429#[cfg(test)]
430#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
431mod tests {
432    use super::{
433        AppInfo, AssetInfo, AssetSource, ConnectionKind, DeviceDiag, DiagnosticsReport,
434        ReceiverDiag, RenderState,
435    };
436    use crate::device::{
437        BatteryInfo, BatteryLevel, BatteryStatus, Capabilities, DeviceKind, DeviceTransports,
438    };
439
440    fn app() -> AppInfo {
441        AppInfo {
442            gui_version: "0.6.6".to_string(),
443            build_profile: "release".to_string(),
444            agent_version: Some("0.6.6".to_string()),
445            protocol_gui: 1,
446            protocol_agent: Some(1),
447            os: "macos".to_string(),
448            os_version: Some("15.5".to_string()),
449            arch: "arm64".to_string(),
450            system_locale: Some("en-US".to_string()),
451            ui_language: None,
452            accessibility_granted: true,
453            hook_installed: Some(true),
454            launch_at_login: Some(true),
455            show_in_menu_bar: Some(true),
456            check_for_updates: Some(false),
457            thumbwheel_sensitivity: Some(0),
458            config_schema_version: Some(2),
459            configured_device_count: Some(3),
460            running_from_bundle: true,
461        }
462    }
463
464    fn assets() -> AssetInfo {
465        AssetInfo {
466            source: AssetSource::Bundle,
467            index_loaded: true,
468            index_entries: Some(142),
469            user_cache_present: true,
470            cache_path: "~/.local/share/openlogi/assets".to_string(),
471            bundle_present: true,
472        }
473    }
474
475    fn sample() -> DiagnosticsReport {
476        DiagnosticsReport {
477            app: app(),
478            assets: assets(),
479            receivers: vec![ReceiverDiag {
480                name: "Logi Bolt".to_string(),
481                vendor_id: 0x046d,
482                product_id: 0xc548,
483            }],
484            devices: vec![
485                DeviceDiag {
486                    display_name: "MX Keys".to_string(),
487                    kind: DeviceKind::Keyboard,
488                    codename: Some("MX Keys".to_string()),
489                    connection: ConnectionKind::BoltReceiver,
490                    online: true,
491                    battery: Some(BatteryInfo {
492                        percentage: 80,
493                        level: BatteryLevel::Good,
494                        status: BatteryStatus::Discharging,
495                    }),
496                    capabilities: Some(Capabilities::default()),
497                    dpi: None,
498                    config_key: "2b35a".to_string(),
499                    wpid: Some(0x4093),
500                    model_ids: Some([0xb35a, 0, 0]),
501                    extended_model_id: Some(0x02),
502                    transports: Some(DeviceTransports {
503                        equad: true,
504                        ..DeviceTransports::default()
505                    }),
506                    render: RenderState::Silhouette,
507                    slot: 2,
508                },
509                DeviceDiag {
510                    display_name: "MX Master 3S".to_string(),
511                    kind: DeviceKind::Mouse,
512                    codename: Some("MX Master 3S".to_string()),
513                    connection: ConnectionKind::Wired,
514                    online: false,
515                    battery: None,
516                    capabilities: Some(Capabilities {
517                        buttons: true,
518                        pointer: true,
519                        lighting: false,
520                    }),
521                    dpi: Some("1600 dpi (range 200–8000, 5 steps)".to_string()),
522                    config_key: "4082d".to_string(),
523                    wpid: Some(0x4082),
524                    model_ids: Some([0x082d, 0, 0]),
525                    extended_model_id: Some(0x04),
526                    transports: Some(DeviceTransports {
527                        usb: true,
528                        ..DeviceTransports::default()
529                    }),
530                    render: RenderState::Resolved("mx_master_3s".to_string()),
531                    slot: 1,
532                },
533            ],
534        }
535    }
536
537    #[test]
538    fn renders_header_and_sections() {
539        let md = sample().to_markdown();
540        assert!(md.starts_with("### OpenLogi Diagnostics"));
541        assert!(md.contains("**App**"));
542        assert!(md.contains("**Assets**"));
543        assert!(md.contains("**Devices (2)**"));
544        assert!(md.contains("**Receivers (1)**"));
545        assert!(md.contains("- Logi Bolt (VID 046d / PID c548)"));
546        assert!(md.contains("- OpenLogi (GUI): v0.6.6 (release)"));
547        assert!(md.contains("- Agent: v0.6.6 (connected)"));
548        assert!(md.contains("- IPC protocol: GUI 1 / agent 1"));
549        assert!(md.contains("- OS: macOS 15.5 (arm64)"));
550        assert!(
551            md.contains("- Source: app bundle · Index: loaded (142 models) · User cache: present")
552        );
553        assert!(md.contains("- Config: schema 2 · 3 configured device(s) · thumbwheel 0"));
554    }
555
556    #[test]
557    fn renders_device_detail() {
558        let md = sample().to_markdown();
559        assert!(md.contains("- MX Keys — keyboard (codename: MX Keys)"));
560        assert!(md.contains(
561            "Connection: Logi Bolt receiver · Online: yes · Battery: 80% (discharging, good)"
562        ));
563        assert!(md.contains("Capabilities: buttons=no, pointer=no, lighting=no"));
564        assert!(md.contains("Model: 2b35a (wpid: 4093, model-ids: b35a/0000/0000, ext-model: 02)"));
565        assert!(md.contains("Transports: eQuad"));
566        assert!(md.contains("Render: ⚠️ none (silhouette) · Slot 2"));
567        assert!(md.contains("- MX Master 3S — mouse"));
568        assert!(md.contains("DPI: 1600 dpi (range 200–8000, 5 steps)"));
569        assert!(md.contains("Transports: USB"));
570        assert!(md.contains("Render: mx_master_3s · Slot 1"));
571        assert!(md.contains("Battery: n/a"));
572    }
573
574    #[test]
575    fn flags_version_and_protocol_mismatch() {
576        let mut report = sample();
577        report.app.agent_version = Some("0.6.5".to_string());
578        report.app.protocol_agent = Some(2);
579        let md = report.to_markdown();
580        assert!(md.contains("v0.6.5 (connected) ⚠️ version mismatch with GUI"));
581        assert!(md.contains("GUI 1 / agent 2 ⚠️ mismatch"));
582    }
583
584    #[test]
585    fn omits_unique_identifiers_and_footer() {
586        let md = sample().to_markdown();
587        assert!(!md.contains("Serial"));
588        assert!(!md.to_lowercase().contains("unit id"));
589        assert!(!md.contains("omitted by design"));
590    }
591
592    #[test]
593    fn direct_slot_renders_as_direct() {
594        let mut report = sample();
595        report.devices[0].slot = 0xFF;
596        let md = report.to_markdown();
597        assert!(md.contains("· direct"));
598        assert!(!md.contains("Slot 255"));
599    }
600
601    #[test]
602    fn unprobed_capabilities_render_not_probed() {
603        let mut report = sample();
604        report.devices[0].capabilities = None;
605        let md = report.to_markdown();
606        assert!(md.contains("  - Capabilities: not probed"));
607    }
608
609    #[test]
610    fn empty_inventory_still_renders() {
611        let report = DiagnosticsReport {
612            app: app(),
613            assets: assets(),
614            receivers: Vec::new(),
615            devices: Vec::new(),
616        };
617        let md = report.to_markdown();
618        assert!(md.contains("**Devices (0)**"));
619        assert!(md.contains("- No devices detected."));
620    }
621
622    #[test]
623    fn unreachable_agent_renders_unknowns() {
624        let mut report = sample();
625        report.app.agent_version = None;
626        report.app.protocol_agent = None;
627        report.app.hook_installed = None;
628        report.app.launch_at_login = None;
629        let md = report.to_markdown();
630        assert!(md.contains("- Agent: not connected"));
631        assert!(md.contains("GUI 1 / agent —"));
632        assert!(md.contains("Input hook: unknown"));
633    }
634}