synheart-sensor-agent 0.2.2

Privacy-first PC background sensor for behavioral research
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
//! Windows implementation of event collection using Windows Hooks.
//!
//! This module captures keyboard and mouse events at the system level using
//! the Windows Hook API (SetWindowsHookEx). It captures low-level input events
//! in a privacy-preserving manner.

use crate::collector::types::{
    KeyboardEvent, KeyboardEventType, MouseEvent, SensorEvent, ShortcutEvent, ShortcutType,
};
use crossbeam_channel::{bounded, Receiver, Sender};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread::{self, JoinHandle};
use std::time::Duration;
use windows::Win32::Foundation::{CloseHandle, HWND, LPARAM, LRESULT, WPARAM};
use windows::Win32::System::ProcessStatus::GetModuleFileNameExW;
use windows::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ};
use windows::Win32::UI::Input::KeyboardAndMouse::GetKeyState;
use windows::Win32::UI::WindowsAndMessaging::{
    CallNextHookEx, GetForegroundWindow, GetWindowThreadProcessId, PeekMessageW, SetWindowsHookExW,
    UnhookWindowsHookEx, HHOOK, KBDLLHOOKSTRUCT, MSLLHOOKSTRUCT, PM_REMOVE, WH_KEYBOARD_LL,
    WH_MOUSE_LL, WM_KEYDOWN, WM_KEYUP, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP,
    WM_MOUSEHWHEEL, WM_MOUSEMOVE, WM_MOUSEWHEEL, WM_QUIT, WM_RBUTTONDOWN, WM_RBUTTONUP,
    WM_SYSKEYDOWN, WM_SYSKEYUP,
};

/// Configuration for which event sources to capture.
#[derive(Debug, Clone)]
pub struct CollectorConfig {
    /// Whether to capture keyboard events.
    pub capture_keyboard: bool,
    /// Whether to capture mouse events.
    pub capture_mouse: bool,
}

impl Default for CollectorConfig {
    fn default() -> Self {
        Self {
            capture_keyboard: true,
            capture_mouse: true,
        }
    }
}

/// The Windows event collector using Windows Hooks.
pub struct WindowsCollector {
    config: CollectorConfig,
    sender: Sender<SensorEvent>,
    receiver: Receiver<SensorEvent>,
    running: Arc<AtomicBool>,
    thread_handle: Option<JoinHandle<()>>,
}

impl WindowsCollector {
    /// Create a new Windows collector with the given configuration.
    pub fn new(config: CollectorConfig) -> Self {
        // Use a bounded channel to prevent unbounded memory growth
        let (sender, receiver) = bounded(10_000);

        Self {
            config,
            sender,
            receiver,
            running: Arc::new(AtomicBool::new(false)),
            thread_handle: None,
        }
    }

    /// Start capturing events in a background thread.
    ///
    /// Returns an error if the collector is already running.
    pub fn start(&mut self) -> Result<(), CollectorError> {
        if self.running.load(Ordering::SeqCst) {
            return Err(CollectorError::AlreadyRunning);
        }

        self.running.store(true, Ordering::SeqCst);

        let sender = self.sender.clone();
        let running = self.running.clone();
        let config = self.config.clone();

        let handle = thread::spawn(move || {
            if let Err(e) = run_hook_loop(sender, running.clone(), config) {
                eprintln!("Hook loop error: {e:?}");
            }
            running.store(false, Ordering::SeqCst);
        });

        self.thread_handle = Some(handle);
        Ok(())
    }

    /// Stop capturing events.
    pub fn stop(&mut self) {
        self.running.store(false, Ordering::SeqCst);
        if let Some(handle) = self.thread_handle.take() {
            // The thread should exit when running becomes false
            let _ = handle.join();
        }
    }

    /// Check if the collector is currently running.
    pub fn is_running(&self) -> bool {
        self.running.load(Ordering::SeqCst)
    }

    /// Get the receiver for sensor events.
    pub fn receiver(&self) -> &Receiver<SensorEvent> {
        &self.receiver
    }

    /// Try to receive an event without blocking.
    pub fn try_recv(&self) -> Option<SensorEvent> {
        self.receiver.try_recv().ok()
    }
}

impl Drop for WindowsCollector {
    fn drop(&mut self) {
        self.stop();
    }
}

/// Errors that can occur during event collection.
#[derive(Debug)]
pub enum CollectorError {
    /// The collector is already running; call `stop()` first.
    AlreadyRunning,
    /// Failed to install the Windows low-level hook.
    HookInstallationFailed,
}

impl std::fmt::Display for CollectorError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            CollectorError::AlreadyRunning => write!(f, "Collector is already running"),
            CollectorError::HookInstallationFailed => {
                write!(f, "Failed to install Windows hook")
            }
        }
    }
}

impl std::error::Error for CollectorError {}

// Global state for the hook callbacks.
// We use thread-local storage to avoid true globals.
thread_local! {
    static EVENT_SENDER: std::cell::RefCell<Option<Sender<SensorEvent>>> = const { std::cell::RefCell::new(None) };
    static LAST_MOUSE_X: std::cell::RefCell<i32> = const { std::cell::RefCell::new(0) };
    static LAST_MOUSE_Y: std::cell::RefCell<i32> = const { std::cell::RefCell::new(0) };
}

/// Classify a Windows virtual key code into a KeyboardEventType category.
///
/// Privacy: The virtual key code is used only for classification and is immediately
/// discarded. The actual key code value is never stored or transmitted. Only the
/// category (typing, navigation, backspace, etc.) is recorded.
pub(crate) fn classify_vk_code(vk_code: u32) -> KeyboardEventType {
    // Windows virtual key codes
    // https://learn.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes
    const VK_BACK: u32 = 0x08;
    const VK_TAB: u32 = 0x09;
    const VK_RETURN: u32 = 0x0D;
    const VK_SHIFT: u32 = 0x10;
    const VK_CONTROL: u32 = 0x11;
    const VK_MENU: u32 = 0x12; // Alt
    const VK_ESCAPE: u32 = 0x1B;
    const VK_PRIOR: u32 = 0x21; // Page Up
    const VK_NEXT: u32 = 0x22; // Page Down
    const VK_END: u32 = 0x23;
    const VK_HOME: u32 = 0x24;
    const VK_LEFT: u32 = 0x25;
    const VK_UP: u32 = 0x26;
    const VK_RIGHT: u32 = 0x27;
    const VK_DOWN: u32 = 0x28;
    const VK_DELETE: u32 = 0x2E;
    const VK_LWIN: u32 = 0x5B;
    const VK_RWIN: u32 = 0x5C;
    const VK_F1: u32 = 0x70;
    const VK_F12: u32 = 0x7B;
    const VK_LSHIFT: u32 = 0xA0;
    const VK_RSHIFT: u32 = 0xA1;
    const VK_LCONTROL: u32 = 0xA2;
    const VK_RCONTROL: u32 = 0xA3;
    const VK_LMENU: u32 = 0xA4;
    const VK_RMENU: u32 = 0xA5;

    match vk_code {
        // Navigation keys
        VK_LEFT | VK_RIGHT | VK_UP | VK_DOWN | VK_PRIOR | VK_NEXT | VK_HOME | VK_END => {
            KeyboardEventType::NavigationKey
        }
        // Correction keys
        VK_BACK => KeyboardEventType::Backspace,
        VK_DELETE => KeyboardEventType::Delete,
        // Special keys
        VK_RETURN => KeyboardEventType::Enter,
        VK_TAB => KeyboardEventType::Tab,
        VK_ESCAPE => KeyboardEventType::Escape,
        // Modifier keys
        VK_SHIFT | VK_CONTROL | VK_MENU | VK_LWIN | VK_RWIN | VK_LSHIFT | VK_RSHIFT
        | VK_LCONTROL | VK_RCONTROL | VK_LMENU | VK_RMENU => KeyboardEventType::ModifierKey,
        // Function keys F1-F12
        vk if (VK_F1..=VK_F12).contains(&vk) => KeyboardEventType::FunctionKey,
        // Everything else is a typing tap
        _ => KeyboardEventType::TypingTap,
    }
}

/// Detect if a key-down event with Ctrl held represents a known shortcut.
///
/// Privacy: The virtual key code is used only for shortcut detection and is
/// immediately discarded. Only the shortcut category (copy, paste, etc.) is
/// recorded, never the specific keys.
unsafe fn detect_shortcut(vk_code: u32) -> Option<ShortcutType> {
    // Check if Ctrl is held (high bit set means key is down)
    let ctrl_down = (GetKeyState(0x11) as u16) & 0x8000 != 0; // VK_CONTROL
    if !ctrl_down {
        return None;
    }

    let shift_down = (GetKeyState(0x10) as u16) & 0x8000 != 0; // VK_SHIFT

    // Virtual key codes for shortcut letters
    const VK_C: u32 = 0x43;
    const VK_V: u32 = 0x56;
    const VK_X: u32 = 0x58;
    const VK_Z: u32 = 0x5A;
    const VK_A: u32 = 0x41;
    const VK_S: u32 = 0x53;

    match vk_code {
        VK_C => Some(ShortcutType::Copy),
        VK_V => Some(ShortcutType::Paste),
        VK_X => Some(ShortcutType::Cut),
        VK_Z if shift_down => Some(ShortcutType::Redo),
        VK_Z => Some(ShortcutType::Undo),
        VK_A => Some(ShortcutType::SelectAll),
        VK_S => Some(ShortcutType::Save),
        _ => None,
    }
}

/// Low-level keyboard hook callback.
unsafe extern "system" fn keyboard_hook_proc(
    n_code: i32,
    w_param: WPARAM,
    l_param: LPARAM,
) -> LRESULT {
    if n_code >= 0 {
        let kb_struct = &*(l_param.0 as *const KBDLLHOOKSTRUCT);
        let w_param_u32 = w_param.0 as u32;
        let vk_code = kb_struct.vkCode;

        let is_key_down = matches!(w_param_u32, WM_KEYDOWN | WM_SYSKEYDOWN);

        // Only send events for key down and key up
        if matches!(
            w_param_u32,
            WM_KEYDOWN | WM_KEYUP | WM_SYSKEYDOWN | WM_SYSKEYUP
        ) {
            // On key-down, check for shortcuts first - emit Shortcut event INSTEAD of keyboard
            let event = if is_key_down {
                if let Some(shortcut_type) = detect_shortcut(vk_code) {
                    SensorEvent::Shortcut(ShortcutEvent {
                        timestamp: chrono::Utc::now(),
                        shortcut_type,
                    })
                } else {
                    let event_type = classify_vk_code(vk_code);
                    SensorEvent::Keyboard(KeyboardEvent::with_type(true, event_type))
                }
            } else {
                let event_type = classify_vk_code(vk_code);
                SensorEvent::Keyboard(KeyboardEvent::with_type(false, event_type))
            };

            EVENT_SENDER.with(|sender| {
                if let Some(ref s) = *sender.borrow() {
                    let _ = s.try_send(event);
                }
            });
        }
    }

    // Pass the event to the next hook
    CallNextHookEx(HHOOK::default(), n_code, w_param, l_param)
}

/// Low-level mouse hook callback.
unsafe extern "system" fn mouse_hook_proc(
    n_code: i32,
    w_param: WPARAM,
    l_param: LPARAM,
) -> LRESULT {
    if n_code >= 0 {
        let mouse_struct = &*(l_param.0 as *const MSLLHOOKSTRUCT);
        let w_param_u32 = w_param.0 as u32;

        let event = match w_param_u32 {
            // Mouse movement - capture delta magnitude only, NO absolute position
            WM_MOUSEMOVE => {
                let current_x = mouse_struct.pt.x;
                let current_y = mouse_struct.pt.y;

                // Calculate delta from last position
                let (delta_x, delta_y) = LAST_MOUSE_X.with(|last_x| {
                    LAST_MOUSE_Y.with(|last_y| {
                        let lx = *last_x.borrow();
                        let ly = *last_y.borrow();

                        // Update stored position
                        *last_x.borrow_mut() = current_x;
                        *last_y.borrow_mut() = current_y;

                        // If this is the first event, delta is 0
                        if lx == 0 && ly == 0 {
                            (0.0, 0.0)
                        } else {
                            ((current_x - lx) as f64, (current_y - ly) as f64)
                        }
                    })
                });

                // Only send if there's actual movement
                if delta_x.abs() > 0.1 || delta_y.abs() > 0.1 {
                    Some(SensorEvent::Mouse(MouseEvent::movement(delta_x, delta_y)))
                } else {
                    None
                }
            }

            // Click events
            WM_LBUTTONDOWN => Some(SensorEvent::Mouse(MouseEvent::click(true))),
            WM_RBUTTONDOWN => Some(SensorEvent::Mouse(MouseEvent::click(false))),

            // Scroll events
            WM_MOUSEWHEEL => {
                // High word of mouseData contains the wheel delta
                let wheel_delta = ((mouse_struct.mouseData >> 16) & 0xFFFF) as i16 as f64;
                // Convert to scroll units (typically 120 per notch)
                let delta_y = wheel_delta / 120.0;
                Some(SensorEvent::Mouse(MouseEvent::scroll(0.0, delta_y)))
            }

            WM_MOUSEHWHEEL => {
                // Horizontal scroll
                let wheel_delta = ((mouse_struct.mouseData >> 16) & 0xFFFF) as i16 as f64;
                let delta_x = wheel_delta / 120.0;
                Some(SensorEvent::Mouse(MouseEvent::scroll(delta_x, 0.0)))
            }

            // Ignore button up events and middle button
            WM_LBUTTONUP | WM_RBUTTONUP | WM_MBUTTONDOWN | WM_MBUTTONUP => None,

            _ => None,
        };

        if let Some(event) = event {
            EVENT_SENDER.with(|sender| {
                if let Some(ref s) = *sender.borrow() {
                    let _ = s.try_send(event);
                }
            });
        }
    }

    // Pass the event to the next hook
    CallNextHookEx(HHOOK::default(), n_code, w_param, l_param)
}

/// Run the Windows hook message loop.
fn run_hook_loop(
    sender: Sender<SensorEvent>,
    running: Arc<AtomicBool>,
    config: CollectorConfig,
) -> Result<(), CollectorError> {
    // Store sender in thread-local
    EVENT_SENDER.with(|s| {
        *s.borrow_mut() = Some(sender);
    });

    // Initialize last mouse position
    LAST_MOUSE_X.with(|x| *x.borrow_mut() = 0);
    LAST_MOUSE_Y.with(|y| *y.borrow_mut() = 0);

    unsafe {
        // Install hooks based on configuration
        let mut hooks: Vec<HHOOK> = Vec::new();

        if config.capture_keyboard {
            let kb_hook = SetWindowsHookExW(WH_KEYBOARD_LL, Some(keyboard_hook_proc), None, 0);
            if kb_hook.is_err() {
                // Clean up any hooks we've installed
                for hook in hooks {
                    let _ = UnhookWindowsHookEx(hook);
                }
                return Err(CollectorError::HookInstallationFailed);
            }
            hooks.push(kb_hook.unwrap());
        }

        if config.capture_mouse {
            let mouse_hook = SetWindowsHookExW(WH_MOUSE_LL, Some(mouse_hook_proc), None, 0);
            if mouse_hook.is_err() {
                // Clean up any hooks we've installed
                for hook in hooks {
                    let _ = UnhookWindowsHookEx(hook);
                }
                return Err(CollectorError::HookInstallationFailed);
            }
            hooks.push(mouse_hook.unwrap());
        }

        // Message loop
        let mut msg = windows::Win32::UI::WindowsAndMessaging::MSG::default();
        while running.load(Ordering::SeqCst) {
            // Use PeekMessageW instead of GetMessageW to avoid blocking indefinitely
            // This allows us to check the running flag periodically
            let result = PeekMessageW(&mut msg, HWND::default(), 0, 0, PM_REMOVE);

            if result.as_bool() {
                // Message retrieved
                if msg.message == WM_QUIT {
                    break;
                }
                // The hooks run automatically, no need to dispatch
            } else {
                // No message available, sleep briefly to avoid busy-waiting
                // and allow the running flag check to happen periodically
                std::thread::sleep(Duration::from_millis(10));
            }
        }

        // Unhook before exiting
        for hook in hooks {
            let _ = UnhookWindowsHookEx(hook);
        }
    }

    Ok(())
}

/// Get the executable name of the current foreground application.
///
/// Privacy: Only the executable filename (e.g., "chrome.exe") is captured.
/// No window titles, file names, URLs, or document content is ever accessed.
pub fn get_frontmost_app_id() -> Option<String> {
    unsafe {
        let hwnd = GetForegroundWindow();
        if hwnd.0.is_null() {
            return None;
        }

        let mut process_id: u32 = 0;
        GetWindowThreadProcessId(hwnd, Some(&mut process_id));
        if process_id == 0 {
            return None;
        }

        let handle = OpenProcess(
            PROCESS_QUERY_INFORMATION | PROCESS_VM_READ,
            false,
            process_id,
        )
        .ok()?;

        let mut buffer = [0u16; 260]; // MAX_PATH
        let len = GetModuleFileNameExW(handle, None, &mut buffer);
        let _ = CloseHandle(handle);

        if len == 0 {
            return None;
        }

        let full_path = String::from_utf16_lossy(&buffer[..len as usize]);

        // Extract just the filename (e.g., "chrome.exe") from the full path
        full_path.rsplit('\\').next().map(|s| s.to_string())
    }
}

/// Check if the application has permission to capture events.
///
/// On Windows, low-level hooks generally work without explicit permission,
/// but may require the application to run with appropriate privileges.
/// This function attempts to install a temporary hook to verify.
pub fn check_permission() -> bool {
    unsafe {
        // Try to install a temporary keyboard hook
        let hook_result = SetWindowsHookExW(WH_KEYBOARD_LL, Some(keyboard_hook_proc), None, 0);

        if let Ok(hook) = hook_result {
            // Successfully installed, clean up and return true
            let _ = UnhookWindowsHookEx(hook);
            true
        } else {
            false
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_collector_config_default() {
        let config = CollectorConfig::default();
        assert!(config.capture_keyboard);
        assert!(config.capture_mouse);
    }

    #[test]
    fn test_collector_creation() {
        let collector = WindowsCollector::new(CollectorConfig::default());
        assert!(!collector.is_running());
    }

    #[test]
    fn test_collector_lifecycle() {
        let collector = WindowsCollector::new(CollectorConfig::default());
        assert!(!collector.is_running());

        // Note: Actually starting the collector requires a message loop
        // and may fail in test environment, so we just test creation
    }

    #[test]
    fn test_classify_vk_code_navigation() {
        assert_eq!(classify_vk_code(0x25), KeyboardEventType::NavigationKey); // Left
        assert_eq!(classify_vk_code(0x26), KeyboardEventType::NavigationKey); // Up
        assert_eq!(classify_vk_code(0x27), KeyboardEventType::NavigationKey); // Right
        assert_eq!(classify_vk_code(0x28), KeyboardEventType::NavigationKey); // Down
        assert_eq!(classify_vk_code(0x21), KeyboardEventType::NavigationKey); // Page Up
        assert_eq!(classify_vk_code(0x22), KeyboardEventType::NavigationKey); // Page Down
        assert_eq!(classify_vk_code(0x24), KeyboardEventType::NavigationKey); // Home
        assert_eq!(classify_vk_code(0x23), KeyboardEventType::NavigationKey); // End
    }

    #[test]
    fn test_classify_vk_code_special_keys() {
        assert_eq!(classify_vk_code(0x08), KeyboardEventType::Backspace);
        assert_eq!(classify_vk_code(0x2E), KeyboardEventType::Delete);
        assert_eq!(classify_vk_code(0x0D), KeyboardEventType::Enter);
        assert_eq!(classify_vk_code(0x09), KeyboardEventType::Tab);
        assert_eq!(classify_vk_code(0x1B), KeyboardEventType::Escape);
    }

    #[test]
    fn test_classify_vk_code_modifiers() {
        assert_eq!(classify_vk_code(0x10), KeyboardEventType::ModifierKey); // Shift
        assert_eq!(classify_vk_code(0x11), KeyboardEventType::ModifierKey); // Control
        assert_eq!(classify_vk_code(0x12), KeyboardEventType::ModifierKey); // Alt/Menu
        assert_eq!(classify_vk_code(0x5B), KeyboardEventType::ModifierKey); // LWin
        assert_eq!(classify_vk_code(0x5C), KeyboardEventType::ModifierKey); // RWin
        assert_eq!(classify_vk_code(0xA0), KeyboardEventType::ModifierKey); // LShift
        assert_eq!(classify_vk_code(0xA1), KeyboardEventType::ModifierKey); // RShift
        assert_eq!(classify_vk_code(0xA2), KeyboardEventType::ModifierKey); // LControl
        assert_eq!(classify_vk_code(0xA3), KeyboardEventType::ModifierKey); // RControl
    }

    #[test]
    fn test_classify_vk_code_function_keys() {
        assert_eq!(classify_vk_code(0x70), KeyboardEventType::FunctionKey); // F1
        assert_eq!(classify_vk_code(0x71), KeyboardEventType::FunctionKey); // F2
        assert_eq!(classify_vk_code(0x7B), KeyboardEventType::FunctionKey); // F12
    }

    #[test]
    fn test_classify_vk_code_typing() {
        assert_eq!(classify_vk_code(0x41), KeyboardEventType::TypingTap); // A
        assert_eq!(classify_vk_code(0x5A), KeyboardEventType::TypingTap); // Z
        assert_eq!(classify_vk_code(0x30), KeyboardEventType::TypingTap); // 0
        assert_eq!(classify_vk_code(0x39), KeyboardEventType::TypingTap); // 9
        assert_eq!(classify_vk_code(0x20), KeyboardEventType::TypingTap); // Space
    }

    #[test]
    fn test_get_frontmost_app_id() {
        // Should return Some(...) when running in a desktop session on Windows
        let app_id = get_frontmost_app_id();
        // Can be None in headless CI, so just check it doesn't panic
        if let Some(ref id) = app_id {
            assert!(!id.is_empty());
        }
    }
}