diff --git a/docs/content/en/api-reference/keys/page.mdx b/docs/content/en/api-reference/keys/page.mdx index 8c17b6d0..651a5257 100644 --- a/docs/content/en/api-reference/keys/page.mdx +++ b/docs/content/en/api-reference/keys/page.mdx @@ -28,6 +28,9 @@ interface KeyStateEvent { // Elapsed time (ms) between input capture and event emit. // Recover the real input time with `performance.now() - eventAgeMs`. eventAgeMs?: number; + // UP events only. Physical hold duration (ms) measured by the input + // daemon at capture time - immune to delivery latency and jitter. + holdDurationMs?: number; } const unsub = dmn.keys.onKeyState(({ key, state, mode }) => { @@ -45,6 +48,23 @@ const unsub = dmn.keys.onKeyState(handler); await unsub.ready; // subscription is live from this point ``` +### `dmn.keys.onKeysReset(callback): ReadyUnsubscribe` + +Fires when the pressed-key state is invalidated as a whole, e.g. when the +keyboard hook (re)starts after a global shortcut change. Any state derived +from previous `onKeyState` events (held keys, active visualizations) should +be cleared and rebuilt from a fresh snapshot. + +```typescript +interface KeysResetEvent { + reason: string; // e.g., 'hook_restart' +} + +dmn.keys.onKeysReset(({ reason }) => { + console.log(`key state reset: ${reason}`); +}); +``` + ### `dmn.keys.onRawInput(callback): Unsubscribe` Overlay window only. diff --git a/docs/content/ko/api-reference/keys/page.mdx b/docs/content/ko/api-reference/keys/page.mdx index 9bfdb2ac..62048d44 100644 --- a/docs/content/ko/api-reference/keys/page.mdx +++ b/docs/content/ko/api-reference/keys/page.mdx @@ -223,6 +223,9 @@ interface KeyStatePayload { // 입력 수신~emit 경과 시간(ms). // `performance.now() - eventAgeMs`로 실제 입력 시각 복원 eventAgeMs?: number; + // UP 이벤트 한정. 입력 데몬이 캡처 시점에 측정한 물리 눌림 + // 지속 시간(ms) - 전달 지연·지터의 영향을 받지 않음 + holdDurationMs?: number; } ``` @@ -241,6 +244,24 @@ const unsub = dmn.keys.onKeyState(handler); await unsub.ready; // 이 시점부터 구독이 활성 ``` +### onKeysReset(listener) + +눌림 상태 전체가 무효화될 때 발화합니다. 예를 들어 글로벌 단축키 변경으로 +키보드 훅이 재시작되면 이전 `onKeyState` 이벤트에서 파생된 상태(눌림 유지, +활성 시각화)는 더 이상 유효하지 않으므로, 정리 후 새 스냅샷으로 재구성하세요. + +```typescript +interface KeysResetPayload { + reason: string; // 예: "hook_restart" +} +``` + +```javascript +dmn.keys.onKeysReset(({ reason }) => { + console.log(`키 상태 리셋: ${reason}`); +}); +``` + ### onRawInput(listener) 로우 레벨 입력 이벤트를 구독합니다. 키보드, 마우스, HID 기기의 원시 diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 4734ac37..0e27f33b 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -43,6 +43,12 @@ pub struct HookMessage { #[serde(skip_serializing_if = "Option::is_none")] #[serde(default)] pub flags: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub hold_duration_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub input_ts_ms: Option, } #[repr(u8)] @@ -144,3 +150,53 @@ pub fn pipe_client_connect(name: &str) -> anyhow::Result { Ok(file) } } + +#[cfg(test)] +mod tests { + use super::{HookKeyState, HookMessage, InputDeviceKind}; + + #[test] + fn hook_message_accepts_legacy_payload_without_timing_fields() { + let message: HookMessage = + serde_json::from_str(r#"{"device":"keyboard","labels":["A"],"state":"DOWN"}"#).unwrap(); + + assert_eq!(message.hold_duration_ms, None); + assert_eq!(message.input_ts_ms, None); + } + + #[test] + fn hook_message_omits_absent_timing_fields() { + let message = HookMessage { + device: InputDeviceKind::Keyboard, + labels: vec!["A".to_string()], + state: HookKeyState::Down, + vk_code: None, + scan_code: None, + flags: None, + hold_duration_ms: None, + input_ts_ms: None, + }; + + let value = serde_json::to_value(message).unwrap(); + assert!(value.get("hold_duration_ms").is_none()); + assert!(value.get("input_ts_ms").is_none()); + } + + #[test] + fn hook_message_serializes_present_timing_fields() { + let message = HookMessage { + device: InputDeviceKind::Keyboard, + labels: vec!["A".to_string()], + state: HookKeyState::Up, + vk_code: None, + scan_code: None, + flags: None, + hold_duration_ms: Some(12.5), + input_ts_ms: Some(1_500.25), + }; + + let value = serde_json::to_value(message).unwrap(); + assert_eq!(value["hold_duration_ms"], 12.5); + assert_eq!(value["input_ts_ms"], 1_500.25); + } +} diff --git a/src-tauri/src/keyboard/daemon/macos.rs b/src-tauri/src/keyboard/daemon/macos.rs index 606927fd..dbf78012 100644 --- a/src-tauri/src/keyboard/daemon/macos.rs +++ b/src-tauri/src/keyboard/daemon/macos.rs @@ -1,10 +1,22 @@ -use std::io::Write; - use anyhow::{anyhow, Result}; use crate::ipc::{DaemonCommand, HookKeyState, HookMessage, InputDeviceKind}; use crate::models::ShortcutBinding; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum MacPhysicalInputId { + Keyboard(rdev::Key), + MouseButton(rdev::Button), +} + +fn mac_keyboard_physical_id(key: rdev::Key) -> MacPhysicalInputId { + MacPhysicalInputId::Keyboard(key) +} + +fn mac_mouse_physical_id(button: rdev::Button) -> MacPhysicalInputId { + MacPhysicalInputId::MouseButton(button) +} + struct MacHotkeyState { ctrl_left: bool, ctrl_right: bool, @@ -316,6 +328,8 @@ pub(super) fn run_macos() -> Result<()> { } } + let output = super::start_output_writer(Box::new(std::io::stdout()))?; + // rdev::listen — 접근성 + 입력 모니터링 권한 필수 // 권한 부여 직후 CGEventTap 생성 실패 가능 — 재시도 처리 let max_retries = 5; @@ -329,7 +343,7 @@ pub(super) fn run_macos() -> Result<()> { std::thread::sleep(std::time::Duration::from_secs(2)); } - let result = run_macos_listen(); + let result = run_macos_listen(output.clone()); match result { Ok(_) => return Ok(()), Err(err) => { @@ -344,89 +358,110 @@ pub(super) fn run_macos() -> Result<()> { } /// rdev::listen 실행 내부 함수. 매 재시도마다 새로운 콜백/상태 생성 -fn run_macos_listen() -> Result<()> { +fn run_macos_listen(output: super::OutputSender) -> Result<()> { use rdev::{listen, EventType}; - let mut sink: Box = Box::new(std::io::stdout()); let hotkeys = super::load_hotkeys_from_env(); let mut hotkey_state = MacHotkeyState::new( hotkeys.toggle_overlay, hotkeys.toggle_overlay_lock, hotkeys.toggle_always_on_top, ); + let mut hold_tracker = super::HoldTracker::::default(); + + let callback = move |event: rdev::Event| { + let captured = super::InputCapture::now(); + match event.event_type { + EventType::KeyPress(key) => { + let key_name = format!("{:?}", key).to_ascii_lowercase(); + if let Some(command) = hotkey_state.update(&key_name, true) { + output.send(super::DaemonOutput::Command(command)); + } - let callback = move |event: rdev::Event| match event.event_type { - EventType::KeyPress(key) => { - let key_name = format!("{:?}", key).to_ascii_lowercase(); - if let Some(command) = hotkey_state.update(&key_name, true) { - let _ = super::write_command(&mut sink, &command); - } - - let labels = mac_key_labels(key, event.name.as_deref()); - if labels.is_empty() { - return; + let labels = mac_key_labels(key, event.name.as_deref()); + if labels.is_empty() { + return; + } + let labels = + hold_tracker.press(mac_keyboard_physical_id(key), captured.instant, labels); + + let message = HookMessage { + device: InputDeviceKind::Keyboard, + labels, + state: HookKeyState::Down, + vk_code: None, + scan_code: None, + flags: None, + hold_duration_ms: None, + input_ts_ms: captured.input_ts_ms, + }; + output.send(super::DaemonOutput::Hook(message)); } + EventType::KeyRelease(key) => { + let key_name = format!("{:?}", key).to_ascii_lowercase(); + let _ = hotkey_state.update(&key_name, false); + + let release = hold_tracker.release( + mac_keyboard_physical_id(key), + captured.instant, + mac_key_labels(key, event.name.as_deref()), + ); + if release.labels.is_empty() { + return; + } - let message = HookMessage { - device: InputDeviceKind::Keyboard, - labels, - state: HookKeyState::Down, - vk_code: None, - scan_code: None, - flags: None, - }; - let _ = super::write_message(&mut sink, &message); - } - EventType::KeyRelease(key) => { - let key_name = format!("{:?}", key).to_ascii_lowercase(); - let _ = hotkey_state.update(&key_name, false); - - let labels = mac_key_labels(key, event.name.as_deref()); - if labels.is_empty() { - return; + let message = HookMessage { + device: InputDeviceKind::Keyboard, + labels: release.labels, + state: HookKeyState::Up, + vk_code: None, + scan_code: None, + flags: None, + hold_duration_ms: release.hold_duration_ms, + input_ts_ms: captured.input_ts_ms, + }; + output.send(super::DaemonOutput::Hook(message)); } - - let message = HookMessage { - device: InputDeviceKind::Keyboard, - labels, - state: HookKeyState::Up, - vk_code: None, - scan_code: None, - flags: None, - }; - let _ = super::write_message(&mut sink, &message); - } - EventType::ButtonPress(button) => { - if let Some(label) = mac_mouse_label(button) { - let _ = super::write_message( - &mut sink, - &HookMessage { + EventType::ButtonPress(button) => { + if let Some(label) = mac_mouse_label(button) { + let labels = hold_tracker.press( + mac_mouse_physical_id(button), + captured.instant, + vec![label], + ); + output.send(super::DaemonOutput::Hook(HookMessage { device: InputDeviceKind::Mouse, - labels: vec![label], + labels, state: HookKeyState::Down, vk_code: None, scan_code: None, flags: None, - }, - ); + hold_duration_ms: None, + input_ts_ms: captured.input_ts_ms, + })); + } } - } - EventType::ButtonRelease(button) => { - if let Some(label) = mac_mouse_label(button) { - let _ = super::write_message( - &mut sink, - &HookMessage { + EventType::ButtonRelease(button) => { + if let Some(label) = mac_mouse_label(button) { + let release = hold_tracker.release( + mac_mouse_physical_id(button), + captured.instant, + vec![label], + ); + output.send(super::DaemonOutput::Hook(HookMessage { device: InputDeviceKind::Mouse, - labels: vec![label], + labels: release.labels, state: HookKeyState::Up, vk_code: None, scan_code: None, flags: None, - }, - ); + hold_duration_ms: release.hold_duration_ms, + input_ts_ms: captured.input_ts_ms, + })); + } } + _ => {} } - _ => {} }; listen(callback).map_err(|err| anyhow!("macOS input listener failed: {err:?}"))?; @@ -442,3 +477,32 @@ fn check_accessibility_permission() -> bool { } unsafe { AXIsProcessTrusted() } } + +#[cfg(test)] +mod tests { + use super::{mac_keyboard_physical_id, mac_mouse_physical_id, MacPhysicalInputId}; + + #[test] + fn mac_keyboard_id_uses_rdev_key_variant() { + assert_eq!( + mac_keyboard_physical_id(rdev::Key::Unknown(42)), + MacPhysicalInputId::Keyboard(rdev::Key::Unknown(42)) + ); + assert_ne!( + mac_keyboard_physical_id(rdev::Key::KeyA), + mac_keyboard_physical_id(rdev::Key::KeyB) + ); + } + + #[test] + fn mac_mouse_id_includes_button_kind() { + assert_eq!( + mac_mouse_physical_id(rdev::Button::Left), + MacPhysicalInputId::MouseButton(rdev::Button::Left) + ); + assert_ne!( + mac_mouse_physical_id(rdev::Button::Left), + mac_mouse_physical_id(rdev::Button::Right) + ); + } +} diff --git a/src-tauri/src/keyboard/daemon/mod.rs b/src-tauri/src/keyboard/daemon/mod.rs index 33305846..1fde0f79 100644 --- a/src-tauri/src/keyboard/daemon/mod.rs +++ b/src-tauri/src/keyboard/daemon/mod.rs @@ -1,17 +1,17 @@ use std::{ + collections::HashMap, + hash::Hash, io::{self, Read, Write}, + sync::mpsc::{self, Sender}, thread, + time::{Instant, SystemTime, UNIX_EPOCH}, }; +use crate::ipc::{DaemonCommand, HidAxisMessage, HookMessage}; +use crate::models::ShortcutsState; #[cfg(not(any(target_os = "windows", target_os = "macos")))] use anyhow::anyhow; use anyhow::Result; -use serde_json::to_string; - -#[cfg(target_os = "windows")] -use crate::ipc::HidAxisMessage; -use crate::ipc::{DaemonCommand, HookMessage}; -use crate::models::ShortcutsState; #[cfg(target_os = "windows")] mod windows; @@ -56,28 +56,185 @@ pub fn start_parent_liveness_watch() -> io::Result<()> { .map(|_| ()) } -fn write_message(sink: &mut Box, message: &HookMessage) -> Result<()> { - let line = to_string(message)?; - sink.write_all(line.as_bytes())?; - sink.write_all(b"\n")?; - Ok(()) +#[derive(Debug)] +pub(super) enum DaemonOutput { + Hook(HookMessage), + Command(DaemonCommand), + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + Axis(HidAxisMessage), } -fn write_command(sink: &mut Box, command: &DaemonCommand) -> Result<()> { - let line = to_string(command)?; - sink.write_all(line.as_bytes())?; - sink.write_all(b"\n")?; - Ok(()) +#[derive(Clone)] +pub(super) struct OutputSender(Sender); + +impl OutputSender { + pub(super) fn send(&self, output: DaemonOutput) { + if self.0.send(output).is_err() { + eprintln!("keyboard daemon writer channel closed"); + std::process::exit(1); + } + } } -#[cfg(target_os = "windows")] -fn write_axis(sink: &mut Box, message: &HidAxisMessage) -> Result<()> { - let line = to_string(message)?; - sink.write_all(line.as_bytes())?; - sink.write_all(b"\n")?; +fn write_output(sink: &mut (dyn Write + Send), output: &DaemonOutput) -> Result<()> { + let mut line = match output { + DaemonOutput::Hook(message) => serde_json::to_vec(message)?, + DaemonOutput::Command(command) => serde_json::to_vec(command)?, + DaemonOutput::Axis(message) => serde_json::to_vec(message)?, + }; + line.push(b'\n'); + sink.write_all(&line)?; Ok(()) } +pub(super) fn start_output_writer(mut sink: Box) -> io::Result { + let (sender, receiver) = mpsc::channel::(); + thread::Builder::new() + .name("keyboard-daemon-writer".into()) + .spawn(move || { + for output in receiver { + if let Err(err) = write_output(&mut *sink, &output) { + eprintln!("keyboard daemon writer failed: {err}"); + std::process::exit(1); + } + } + })?; + Ok(OutputSender(sender)) +} + +#[derive(Clone, Copy)] +pub(super) struct InputCapture { + pub(super) instant: Instant, + pub(super) input_ts_ms: Option, +} + +impl InputCapture { + pub(super) fn now() -> Self { + let instant = Instant::now(); + let input_ts_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .map(|duration| duration.as_secs_f64() * 1000.0); + Self { + instant, + input_ts_ms, + } + } +} + +struct ActiveInput { + pressed_at: Instant, + labels: Vec, +} + +pub(super) struct HoldTracker { + active: HashMap, +} + +impl Default for HoldTracker { + fn default() -> Self { + Self { + active: HashMap::new(), + } + } +} + +pub(super) struct ReleaseMetadata { + pub(super) labels: Vec, + pub(super) hold_duration_ms: Option, +} + +impl HoldTracker { + pub(super) fn press( + &mut self, + physical_id: K, + captured_at: Instant, + labels: Vec, + ) -> Vec { + self.active + .entry(physical_id) + .or_insert_with(|| ActiveInput { + pressed_at: captured_at, + labels, + }) + .labels + .clone() + } + + pub(super) fn release( + &mut self, + physical_id: K, + captured_at: Instant, + fallback_labels: Vec, + ) -> ReleaseMetadata { + let Some(active) = self.active.remove(&physical_id) else { + return ReleaseMetadata { + labels: fallback_labels, + hold_duration_ms: None, + }; + }; + ReleaseMetadata { + labels: active.labels, + hold_duration_ms: captured_at + .checked_duration_since(active.pressed_at) + .map(|duration| duration.as_secs_f64() * 1000.0), + } + } +} + +#[cfg(any(target_os = "windows", test))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(super) enum WindowsKeyboardPhysicalId { + ScanCode { + device: usize, + scan_code: u32, + extension_flags: u8, + }, + VirtualKey { + device: usize, + vk_code: u32, + }, +} + +#[cfg(any(target_os = "windows", test))] +pub(super) fn windows_keyboard_physical_id( + device: usize, + scan_code: u32, + is_e0: bool, + is_e1: bool, + vk_code: u32, +) -> WindowsKeyboardPhysicalId { + if scan_code == 0 { + return WindowsKeyboardPhysicalId::VirtualKey { device, vk_code }; + } + WindowsKeyboardPhysicalId::ScanCode { + device, + scan_code, + extension_flags: u8::from(is_e0) | (u8::from(is_e1) << 1), + } +} + +#[cfg(any(target_os = "windows", test))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(super) struct WindowsHidPhysicalId { + device: usize, + usage_page: u16, + usage: u16, +} + +#[cfg(any(target_os = "windows", test))] +pub(super) fn windows_hid_physical_id( + device: usize, + usage_page: u16, + usage: u16, +) -> WindowsHidPhysicalId { + WindowsHidPhysicalId { + device, + usage_page, + usage, + } +} + pub fn run() -> Result<()> { #[cfg(target_os = "windows")] { @@ -99,9 +256,41 @@ pub fn run() -> Result<()> { #[cfg(test)] mod tests { - use std::io::Cursor; + use std::{ + io::{Cursor, Write}, + sync::mpsc, + time::{Duration, Instant}, + }; + + use super::{ + start_output_writer, wait_for_parent_disconnect, windows_hid_physical_id, + windows_keyboard_physical_id, DaemonOutput, HoldTracker, WindowsKeyboardPhysicalId, + }; + use crate::ipc::{DaemonCommand, HidAxisMessage, HookKeyState, HookMessage, InputDeviceKind}; + + struct DropSink { + bytes: Vec, + sender: Option>>, + } + + impl Write for DropSink { + fn write(&mut self, buffer: &[u8]) -> std::io::Result { + self.bytes.extend_from_slice(buffer); + Ok(buffer.len()) + } - use super::wait_for_parent_disconnect; + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl Drop for DropSink { + fn drop(&mut self) { + if let Some(sender) = self.sender.take() { + let _ = sender.send(std::mem::take(&mut self.bytes)); + } + } + } #[test] fn parent_watch_returns_after_pipe_eof() { @@ -115,4 +304,139 @@ mod tests { wait_for_parent_disconnect(&mut reader).unwrap(); assert_eq!(reader.position(), b"keepalive".len() as u64); } + + #[test] + fn writer_queue_preserves_output_order_and_wire_shapes() { + let (sender, receiver) = mpsc::channel(); + let output = start_output_writer(Box::new(DropSink { + bytes: Vec::new(), + sender: Some(sender), + })) + .unwrap(); + output.send(DaemonOutput::Hook(HookMessage { + device: InputDeviceKind::Keyboard, + labels: vec!["A".to_string()], + state: HookKeyState::Down, + vk_code: None, + scan_code: None, + flags: None, + hold_duration_ms: None, + input_ts_ms: Some(100.0), + })); + output.send(DaemonOutput::Command(DaemonCommand::ToggleOverlay)); + output.send(DaemonOutput::Axis(HidAxisMessage { + axis_id: "axis".to_string(), + value: 3, + full: 16, + })); + drop(output); + + let bytes = receiver.recv_timeout(Duration::from_secs(1)).unwrap(); + let lines: Vec = std::str::from_utf8(&bytes) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + + assert_eq!(lines.len(), 3); + assert_eq!(lines[0]["labels"], serde_json::json!(["A"])); + assert_eq!(lines[1]["type"], "toggle_overlay"); + assert_eq!(lines[2]["axis_id"], "axis"); + } + + #[test] + fn repeated_down_keeps_first_timestamp_and_labels() { + let mut tracker = HoldTracker::::default(); + let started_at = Instant::now(); + + assert_eq!( + tracker.press(1, started_at, vec!["DOWN".to_string()]), + vec!["DOWN"] + ); + assert_eq!( + tracker.press( + 1, + started_at + Duration::from_millis(20), + vec!["REPEAT".to_string()], + ), + vec!["DOWN"] + ); + + let release = tracker.release( + 1, + started_at + Duration::from_millis(35), + vec!["UP".to_string()], + ); + assert_eq!(release.labels, vec!["DOWN"]); + assert_eq!(release.hold_duration_ms, Some(35.0)); + } + + #[test] + fn up_removes_active_press() { + let mut tracker = HoldTracker::::default(); + let started_at = Instant::now(); + tracker.press(1, started_at, vec!["A".to_string()]); + + let first = tracker.release(1, started_at, vec!["A".to_string()]); + let second = tracker.release(1, started_at, vec!["A".to_string()]); + + assert_eq!(first.hold_duration_ms, Some(0.0)); + assert_eq!(second.hold_duration_ms, None); + } + + #[test] + fn unmatched_up_has_no_hold_duration() { + let mut tracker = HoldTracker::::default(); + let release = tracker.release(1, Instant::now(), vec!["A".to_string()]); + + assert_eq!(release.labels, vec!["A"]); + assert_eq!(release.hold_duration_ms, None); + } + + #[test] + fn release_reuses_down_labels() { + let mut tracker = HoldTracker::::default(); + let captured_at = Instant::now(); + tracker.press(1, captured_at, vec!["DOWN LABEL".to_string()]); + + let release = tracker.release(1, captured_at, vec!["UP LABEL".to_string()]); + + assert_eq!(release.labels, vec!["DOWN LABEL"]); + } + + #[test] + fn windows_keyboard_id_uses_scan_code_extensions_and_vk_fallback() { + let base = windows_keyboard_physical_id(7, 29, false, false, 0x11); + let extended = windows_keyboard_physical_id(7, 29, true, false, 0x11); + let fallback = windows_keyboard_physical_id(7, 0, false, false, 0xA2); + + assert_ne!(base, extended); + assert_eq!( + fallback, + WindowsKeyboardPhysicalId::VirtualKey { + device: 7, + vk_code: 0xA2, + } + ); + } + + #[test] + fn windows_physical_ids_include_device_handle() { + assert_ne!( + windows_keyboard_physical_id(1, 30, false, false, 0x41), + windows_keyboard_physical_id(2, 30, false, false, 0x41) + ); + assert_ne!( + windows_hid_physical_id(1, 9, 1), + windows_hid_physical_id(2, 9, 1) + ); + assert_ne!( + windows_hid_physical_id(1, 9, 1), + windows_hid_physical_id(1, 8, 1) + ); + assert_ne!( + windows_hid_physical_id(1, 9, 1), + windows_hid_physical_id(1, 9, 2) + ); + } } diff --git a/src-tauri/src/keyboard/daemon/windows.rs b/src-tauri/src/keyboard/daemon/windows.rs index d7e1938e..9abedd33 100644 --- a/src-tauri/src/keyboard/daemon/windows.rs +++ b/src-tauri/src/keyboard/daemon/windows.rs @@ -1,5 +1,3 @@ -use std::io::Write; - use anyhow::{anyhow, Result}; use super::super::labels::{ @@ -9,6 +7,12 @@ use super::super::labels::{ use crate::ipc::{pipe_client_connect, DaemonCommand, HookKeyState, HookMessage, InputDeviceKind}; use crate::models::ShortcutBinding; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum WindowsPhysicalInputId { + Keyboard(super::WindowsKeyboardPhysicalId), + MouseButton(u8), +} + /// 글로벌 단축키 상태 추적기 struct HotkeyState { ctrl_left: bool, @@ -208,10 +212,11 @@ pub(super) fn run_raw_input() -> Result<()> { }; // Named pipe 연결 시도; 불가 시 stdout으로 폴백 - let mut sink: Box = match pipe_client_connect("dmnote_keys_v1") { + let sink: Box = match pipe_client_connect("dmnote_keys_v1") { Ok(file) => Box::new(file), Err(_) => Box::new(std::io::stdout()), }; + let output = super::start_output_writer(sink)?; // 글로벌 단축키 상태 추적기 let hotkeys = super::load_hotkeys_from_env(); @@ -329,10 +334,12 @@ pub(super) fn run_raw_input() -> Result<()> { // HID 입력 처리기 (버튼/축 동적 디코딩) let mut hid = super::windows_hid::HidProcessor::new(); + let mut hold_tracker = super::HoldTracker::::default(); // 메시지 루프: WM_INPUT 처리 후 HookMessage로 변환 let mut msg = MSG::default(); while GetMessageW(&mut msg, None, 0, 0).into() { + let captured = super::InputCapture::now(); if msg.message == WM_INPUT { // 필요한 버퍼 크기 먼저 조회 let mut size: u32 = 0; @@ -450,7 +457,7 @@ pub(super) fn run_raw_input() -> Result<()> { // 글로벌 단축키 확인 (Ctrl+Shift+O로 오버레이 토글) if let Some(command) = hotkey_state.update(vk_norm, !is_break) { - let _ = super::write_command(&mut sink, &command); + output.send(super::DaemonOutput::Command(command)); // 키 이벤트는 계속 정상 처리 } @@ -477,17 +484,49 @@ pub(super) fn run_raw_input() -> Result<()> { continue; } - let labels = build_key_labels(&event); - if labels.is_empty() { - let _ = TranslateMessage(&msg); - DispatchMessageW(&msg); - continue; - } - let state = match event.pressed { KeyPress::Down(_) => HookKeyState::Down, KeyPress::Up(_) => HookKeyState::Up, }; + let physical_id = + WindowsPhysicalInputId::Keyboard(super::windows_keyboard_physical_id( + raw.header.hDevice.0 as usize, + scan_code, + is_e0, + is_e1, + vk_norm, + )); + let current_labels = build_key_labels(&event); + let (labels, hold_duration_ms) = match state { + HookKeyState::Down => { + if current_labels.is_empty() { + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + continue; + } + ( + hold_tracker.press( + physical_id, + captured.instant, + current_labels, + ), + None, + ) + } + HookKeyState::Up => { + let release = hold_tracker.release( + physical_id, + captured.instant, + current_labels, + ); + if release.labels.is_empty() { + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + continue; + } + (release.labels, release.hold_duration_ms) + } + }; let message = HookMessage { device: InputDeviceKind::Keyboard, @@ -496,67 +535,88 @@ pub(super) fn run_raw_input() -> Result<()> { vk_code: event.vk_code, scan_code: event.scan_code, flags: event.flags, + hold_duration_ms, + input_ts_ms: captured.input_ts_ms, }; - let _ = super::write_message(&mut sink, &message); + output.send(super::DaemonOutput::Hook(message)); } t if t == RIM_TYPEMOUSE.0 => { let mouse = raw.data.mouse; let button_flags = mouse.Anonymous.Anonymous.usButtonFlags; - let mut events: Vec<(String, HookKeyState)> = Vec::new(); - let mut push = |label: &str, state: HookKeyState| { - events.push((label.to_string(), state)); + let mut events: Vec<(u8, String, HookKeyState)> = Vec::new(); + let mut push = |button: u8, label: &str, state: HookKeyState| { + events.push((button, label.to_string(), state)); }; if (button_flags & RI_MOUSE_LEFT_BUTTON_DOWN) != 0 { - push("MOUSE1", HookKeyState::Down); + push(1, "MOUSE1", HookKeyState::Down); } if (button_flags & RI_MOUSE_LEFT_BUTTON_UP) != 0 { - push("MOUSE1", HookKeyState::Up); + push(1, "MOUSE1", HookKeyState::Up); } if (button_flags & RI_MOUSE_RIGHT_BUTTON_DOWN) != 0 { - push("MOUSE2", HookKeyState::Down); + push(2, "MOUSE2", HookKeyState::Down); } if (button_flags & RI_MOUSE_RIGHT_BUTTON_UP) != 0 { - push("MOUSE2", HookKeyState::Up); + push(2, "MOUSE2", HookKeyState::Up); } if (button_flags & RI_MOUSE_MIDDLE_BUTTON_DOWN) != 0 { - push("MOUSE3", HookKeyState::Down); + push(3, "MOUSE3", HookKeyState::Down); } if (button_flags & RI_MOUSE_MIDDLE_BUTTON_UP) != 0 { - push("MOUSE3", HookKeyState::Up); + push(3, "MOUSE3", HookKeyState::Up); } if (button_flags & RI_MOUSE_BUTTON_4_DOWN) != 0 { - push("MOUSE4", HookKeyState::Down); + push(4, "MOUSE4", HookKeyState::Down); } if (button_flags & RI_MOUSE_BUTTON_4_UP) != 0 { - push("MOUSE4", HookKeyState::Up); + push(4, "MOUSE4", HookKeyState::Up); } if (button_flags & RI_MOUSE_BUTTON_5_DOWN) != 0 { - push("MOUSE5", HookKeyState::Down); + push(5, "MOUSE5", HookKeyState::Down); } if (button_flags & RI_MOUSE_BUTTON_5_UP) != 0 { - push("MOUSE5", HookKeyState::Up); + push(5, "MOUSE5", HookKeyState::Up); } - for (label, state) in events { - let _ = super::write_message( - &mut sink, - &HookMessage { - device: InputDeviceKind::Mouse, - labels: vec![label], - state, - vk_code: None, - scan_code: None, - flags: None, - }, - ); + for (button, label, state) in events { + let physical_id = WindowsPhysicalInputId::MouseButton(button); + let current_labels = vec![label]; + let (labels, hold_duration_ms) = match state { + HookKeyState::Down => ( + hold_tracker.press( + physical_id, + captured.instant, + current_labels, + ), + None, + ), + HookKeyState::Up => { + let release = hold_tracker.release( + physical_id, + captured.instant, + current_labels, + ); + (release.labels, release.hold_duration_ms) + } + }; + output.send(super::DaemonOutput::Hook(HookMessage { + device: InputDeviceKind::Mouse, + labels, + state, + vk_code: None, + scan_code: None, + flags: None, + hold_duration_ms, + input_ts_ms: captured.input_ts_ms, + })); } } t if t == RIM_TYPEHID.0 => { // HID 버튼/축 디코딩 → 파이프 전송 (버튼=HookMessage, 축=HidAxisMessage) - hid.handle_hid(raw, raw.header.hDevice, &mut sink); + hid.handle_hid(raw, raw.header.hDevice, captured, &output); } _ => {} } diff --git a/src-tauri/src/keyboard/daemon/windows_hid.rs b/src-tauri/src/keyboard/daemon/windows_hid.rs index 53cbc8cc..5c01b761 100644 --- a/src-tauri/src/keyboard/daemon/windows_hid.rs +++ b/src-tauri/src/keyboard/daemon/windows_hid.rs @@ -8,7 +8,6 @@ use std::collections::{HashMap, HashSet}; use std::ffi::c_void; -use std::io::Write; use std::mem::size_of; use std::time::{SystemTime, UNIX_EPOCH}; @@ -28,8 +27,6 @@ use crate::ipc::{HidAxisMessage, HookKeyState, HookMessage, InputDeviceKind}; /// 축 값 전송 최소 간격(ms) — 고빈도 노브 입력이 파이프/입력 스레드를 막지 않도록. const AXIS_THROTTLE_MS: u64 = 12; -type Sink = Box; - /// 디바이스별 정적 정보 (preparsed data + caps 캐시) struct DeviceCaps { preparsed: Vec, @@ -64,6 +61,7 @@ struct DeviceState { pub struct HidProcessor { devices: HashMap, states: HashMap, + hold_tracker: super::HoldTracker, } fn now_ms() -> u64 { @@ -78,11 +76,18 @@ impl HidProcessor { Self { devices: HashMap::new(), states: HashMap::new(), + hold_tracker: super::HoldTracker::default(), } } /// WM_INPUT (RIM_TYPEHID) 진입점 - pub fn handle_hid(&mut self, raw: &RAWINPUT, hdevice: HANDLE, sink: &mut Sink) { + pub fn handle_hid( + &mut self, + raw: &RAWINPUT, + hdevice: HANDLE, + captured: super::InputCapture, + output: &super::OutputSender, + ) { let key = hdevice.0 as usize; if let std::collections::hash_map::Entry::Vacant(e) = self.devices.entry(key) { match build_device_caps(hdevice) { @@ -105,11 +110,17 @@ impl HidProcessor { for i in 0..count { let report = unsafe { std::slice::from_raw_parts(base.add(i * size), size) }; - self.decode_report(key, report, sink); + self.decode_report(key, report, captured, output); } } - fn decode_report(&mut self, key: usize, report: &[u8], sink: &mut Sink) { + fn decode_report( + &mut self, + key: usize, + report: &[u8], + captured: super::InputCapture, + output: &super::OutputSender, + ) { // 동일 리포트 반복 skip — 상수 주기 전송 디바이스의 폭주 방지 { let state = self.states.entry(key).or_default(); @@ -194,23 +205,45 @@ impl HidProcessor { // caps 차용 종료 — 이후 상태(states) 갱신 + 전송 let now = now_ms(); - let state = self.states.entry(key).or_default(); - - // 버튼 엣지 - let down: Vec<(u16, u16)> = current.difference(&state.prev_buttons).copied().collect(); - let up: Vec<(u16, u16)> = state.prev_buttons.difference(¤t).copied().collect(); - state.prev_buttons = current; + let (down, up) = { + let state = self.states.entry(key).or_default(); + let down: Vec<_> = current.difference(&state.prev_buttons).copied().collect(); + let up: Vec<_> = state.prev_buttons.difference(¤t).copied().collect(); + state.prev_buttons = current; + (down, up) + }; for (page, usage) in down { let label = button_label(vid, pid, page, usage); - let _ = super::write_message(sink, &button_msg(label, HookKeyState::Down)); + let labels = self.hold_tracker.press( + super::windows_hid_physical_id(key, page, usage), + captured.instant, + vec![label], + ); + output.send(super::DaemonOutput::Hook(button_msg( + labels, + HookKeyState::Down, + None, + captured.input_ts_ms, + ))); } for (page, usage) in up { let label = button_label(vid, pid, page, usage); - let _ = super::write_message(sink, &button_msg(label, HookKeyState::Up)); + let release = self.hold_tracker.release( + super::windows_hid_physical_id(key, page, usage), + captured.instant, + vec![label], + ); + output.send(super::DaemonOutput::Hook(button_msg( + release.labels, + HookKeyState::Up, + release.hold_duration_ms, + captured.input_ts_ms, + ))); } // 축 throttle 전송 + let state = self.states.entry(key).or_default(); for (page, usage, value, full) in axis_values { let th = state.axis.entry((page, usage)).or_default(); let changed = !th.sent || th.last_value != value; @@ -218,14 +251,11 @@ impl HidProcessor { th.last_value = value; th.last_emit_ms = now; th.sent = true; - let _ = super::write_axis( - sink, - &HidAxisMessage { - axis_id: axis_label(vid, pid, page, usage), - value, - full, - }, - ); + output.send(super::DaemonOutput::Axis(HidAxisMessage { + axis_id: axis_label(vid, pid, page, usage), + value, + full, + })); } } } @@ -239,14 +269,21 @@ fn axis_label(vid: u16, pid: u16, page: u16, usage: u16) -> String { format!("HIDA:{:04x}:{:04x}:{}:{}", vid, pid, page, usage) } -fn button_msg(label: String, state: HookKeyState) -> HookMessage { +fn button_msg( + labels: Vec, + state: HookKeyState, + hold_duration_ms: Option, + input_ts_ms: Option, +) -> HookMessage { HookMessage { device: InputDeviceKind::Gamepad, - labels: vec![label], + labels, state, vk_code: None, scan_code: None, flags: None, + hold_duration_ms, + input_ts_ms, } } diff --git a/src-tauri/src/keyboard/manager.rs b/src-tauri/src/keyboard/manager.rs index 07814d73..f6a6f28c 100644 --- a/src-tauri/src/keyboard/manager.rs +++ b/src-tauri/src/keyboard/manager.rs @@ -146,7 +146,12 @@ impl KeyboardManager { self.active_keys.write().clear(); } + #[cfg(test)] pub fn pressed_keys(&self) -> Vec { + self.current_mode_and_pressed_keys().1 + } + + pub fn current_mode_and_pressed_keys(&self) -> (String, Vec) { let current_mode = self.current_mode.read(); let prefix = format!("{current_mode}::"); let active_keys = self.active_keys.read(); @@ -155,7 +160,7 @@ impl KeyboardManager { .filter_map(|entry| entry.strip_prefix(&prefix).map(str::to_string)) .collect(); keys.sort(); - keys + (current_mode.clone(), keys) } fn rebuild_valid_keys(&self) { @@ -333,4 +338,21 @@ mod tests { assert!(manager.pressed_keys().is_empty()); assert_eq!(manager.match_and_register(["KeyD"], true), None); } + + #[test] + fn current_mode_and_pressed_keys_share_one_snapshot() { + let manager = KeyboardManager::new( + HashMap::from([ + ("source".to_string(), vec!["KeyD".to_string()]), + ("target".to_string(), vec!["KeyF".to_string()]), + ]), + "source", + ); + assert!(manager.register_key_down("source", "KeyD")); + + assert_eq!( + manager.current_mode_and_pressed_keys(), + ("source".to_string(), vec!["KeyD".to_string()]) + ); + } } diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index 6a94f2d0..0ca90d5f 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -370,6 +370,7 @@ impl ObsBridgeService { "settings:changed", "editor:committed", "keys:state", + "keys:reset", "keys:changed", "keys:counters", "keys:counter", diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index db10c250..3569a074 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -1,4 +1,3 @@ -use std::time::Instant; use std::{ collections::{HashSet, VecDeque}, io::{BufRead, BufReader}, @@ -9,7 +8,7 @@ use std::{ Arc, }, thread::{self, JoinHandle}, - time::Duration, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use anyhow::{anyhow, Context, Result}; @@ -82,6 +81,9 @@ const OVERLAY_CREATION_LOCK_TIMEOUT: Duration = Duration::from_secs(10); const EDITOR_FLUSH_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); const SHUTDOWN_WATCHDOG_TIMEOUT: Duration = Duration::from_secs(5); const SHUTDOWN_WATCHDOG_EXIT_CODE: i32 = 1; +const MAX_INPUT_EVENT_AGE_MS: f64 = 10_000.0; +const KEYBOARD_DAEMON_STABLE_RUNTIME: Duration = Duration::from_secs(30); +const KEYBOARD_RECOVERY_DELAYS_MS: [u64; 5] = [250, 500, 1_000, 2_000, 4_000]; const HISTORY_FRONTEND_FLUSH_BUSY: &str = "HISTORY_FRONTEND_FLUSH_BUSY"; const HISTORY_FRONTEND_FLUSH_CANCELED: &str = "HISTORY_FRONTEND_FLUSH_CANCELED"; const HISTORY_FRONTEND_FLUSH_EMIT_FAILED: &str = "HISTORY_FRONTEND_FLUSH_EMIT_FAILED"; @@ -572,6 +574,12 @@ struct RuntimePublicationState { counters_generation: u64, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct KeyboardRecoveryPlan { + attempt: usize, + delay: Duration, +} + #[derive(Debug)] pub(crate) struct AdmittedCounterMutation { pub(crate) counters: KeyCounters, @@ -609,8 +617,78 @@ fn should_create_overlay_on_startup(obs_mode_enabled: bool, overlay_visible: boo !obs_mode_enabled && overlay_visible } -fn bootstrap_active_keys(keyboard: &KeyboardManager) -> Vec { - keyboard.pressed_keys() +fn bootstrap_keyboard_state(keyboard: &KeyboardManager) -> (String, Vec) { + keyboard.current_mode_and_pressed_keys() +} + +fn unix_epoch_ms() -> Option { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .map(|duration| duration.as_secs_f64() * 1000.0) +} + +fn resolve_event_age_ms( + input_ts_ms: Option, + now_wall_ms: Option, + fallback_age_ms: f64, +) -> f64 { + let Some(event_age_ms) = input_ts_ms + .zip(now_wall_ms) + .map(|(input_ts_ms, now_wall_ms)| now_wall_ms - input_ts_ms) + else { + return fallback_age_ms; + }; + if event_age_ms.is_finite() && (0.0..=MAX_INPUT_EVENT_AGE_MS).contains(&event_age_ms) { + event_age_ms + } else { + fallback_age_ms + } +} + +fn next_keyboard_recovery_plan( + current_attempt: usize, + daemon_uptime: Duration, +) -> Option { + let attempt = if daemon_uptime >= KEYBOARD_DAEMON_STABLE_RUNTIME { + 1 + } else { + current_attempt.saturating_add(1) + }; + let delay_ms = *KEYBOARD_RECOVERY_DELAYS_MS.get(attempt.checked_sub(1)?)?; + Some(KeyboardRecoveryPlan { + attempt, + delay: Duration::from_millis(delay_ms), + }) +} + +fn should_recover_keyboard_daemon( + shutdown_started: bool, + current_generation: u64, + task_generation: Option, + failed_generation: u64, +) -> bool { + !shutdown_started + && current_generation == failed_generation + && task_generation == Some(failed_generation) +} + +fn key_state_payload( + key: &str, + state: &str, + mode: &str, + event_age_ms: f64, + is_down: bool, + hold_duration_ms: Option, +) -> serde_json::Value { + let mut payload = + json!({ "key": key, "state": state, "mode": mode, "eventAgeMs": event_age_ms }); + if !is_down { + if let Some(hold_duration_ms) = hold_duration_ms { + payload["holdDurationMs"] = json!(hold_duration_ms); + } + } + payload } fn collect_frontend_lifecycle_targets( @@ -788,6 +866,7 @@ pub struct AppState { panel_destroy_reason: Mutex>, panel_view_state: Mutex>, keyboard_task: RwLock>, + keyboard_task_generation: AtomicU64, key_counters: Arc>, counter_history_barrier: Mutex, counter_history_ready: Condvar, @@ -863,6 +942,7 @@ impl AppState { panel_destroy_reason: Mutex::new(None), panel_view_state: Mutex::new(None), keyboard_task: RwLock::new(None), + keyboard_task_generation: AtomicU64::new(0), key_counters, counter_history_barrier: Mutex::new(CounterHistoryBarrierState::default()), counter_history_ready: Condvar::new(), @@ -1037,6 +1117,7 @@ impl AppState { let state = self.store.snapshot(); let mut custom_js = state.custom_js.clone(); let _ = custom_js.normalize(); + let (current_mode, active_keys) = bootstrap_keyboard_state(&self.keyboard); BootstrapPayload { defaults: DefaultsPayload { settings: SettingsState::default(), @@ -1073,8 +1154,8 @@ impl AppState { knob_positions: state.knob_positions.clone(), custom_tabs: state.custom_tabs.clone(), selected_key_type: state.selected_key_type.clone(), - current_mode: self.keyboard.current_mode(), - active_keys: bootstrap_active_keys(&self.keyboard), + current_mode, + active_keys, overlay: BootstrapOverlayState { visible: *self.overlay_visible.read(), locked: state.overlay_locked, @@ -1406,7 +1487,12 @@ impl AppState { } self.overlay_bounds_generation .fetch_add(1, Ordering::SeqCst); - if let Some(task) = self.keyboard_task.write().take() { + self.keyboard_task_generation.fetch_add(1, Ordering::SeqCst); + let keyboard_task = { + let mut task_guard = self.keyboard_task.write(); + task_guard.take() + }; + if let Some(task) = keyboard_task { drop(task); } if let Some(watcher) = self.css_watcher.write().take() { @@ -2062,8 +2148,44 @@ impl AppState { if task_guard.is_some() { return Ok(()); } + self.start_keyboard_hook_locked(app, &mut task_guard, 0, None) + } - self.clear_active_keys(); + fn start_keyboard_hook_locked( + &self, + app: AppHandle, + task_slot: &mut Option, + recovery_attempt: usize, + expected_generation: Option, + ) -> Result<()> { + if self.shutdown_started.load(Ordering::SeqCst) { + return Ok(()); + } + + let generation = if let Some(expected_generation) = expected_generation { + let next_generation = expected_generation.wrapping_add(1); + if self + .keyboard_task_generation + .compare_exchange( + expected_generation, + next_generation, + Ordering::SeqCst, + Ordering::SeqCst, + ) + .is_err() + { + return Ok(()); + } + next_generation + } else { + self.keyboard_task_generation + .fetch_add(1, Ordering::SeqCst) + .wrapping_add(1) + }; + + self.reset_keyboard_hook_state(&app); + + let daemon_started_at = Instant::now(); let current_exe = std::env::current_exe().context("failed to locate dm-note executable")?; let shortcuts_json = serde_json::to_string(&self.store.settings_snapshot().shortcuts) @@ -2145,10 +2267,14 @@ impl AppState { let _ = SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_ABOVE_NORMAL); } + let mut exit_reason = None; while running_reader.load(Ordering::SeqCst) { let mut line = String::new(); match reader.read_line(&mut line) { - Ok(0) => break, + Ok(0) => { + exit_reason = Some(String::from("output EOF")); + break; + } Ok(_) => { let s = line.trim(); if s.is_empty() { @@ -2257,6 +2383,8 @@ impl AppState { vk_code: None, scan_code: None, flags: None, + hold_duration_ms: None, + input_ts_ms: None, } }; @@ -2380,11 +2508,21 @@ impl AppState { } } } - // 입력 수신~emit 사이 경과 시간(ms). 오버레이가 - // performance.now() - eventAgeMs로 실제 입력 시각을 복원해 - // 노트 시작 위치가 렌더 프레임 경계에 양자화되는 것을 방지 - let event_age_ms = recv_at.elapsed().as_secs_f64() * 1000.0; - let payload = json!({ "key": key_label, "state": state, "mode": mode, "eventAgeMs": event_age_ms }); + // 데몬 캡처 시각부터 emit까지 경과 시간 + let fallback_age_ms = recv_at.elapsed().as_secs_f64() * 1000.0; + let event_age_ms = resolve_event_age_ms( + message.input_ts_ms, + unix_epoch_ms(), + fallback_age_ms, + ); + let payload = key_state_payload( + &key_label, + state, + &mode, + event_age_ms, + is_down, + message.hold_duration_ms, + ); let mut emitted = false; if let Some(overlay) = overlay_window.as_ref() { @@ -2442,10 +2580,35 @@ impl AppState { { continue; } + exit_reason = Some(format!("output read failed: {err}")); break; } } } + if running_reader.load(Ordering::SeqCst) { + let exit_reason = + exit_reason.unwrap_or_else(|| String::from("reader loop stopped")); + let daemon_uptime = daemon_started_at.elapsed(); + let recovery_plan = + next_keyboard_recovery_plan(recovery_attempt, daemon_uptime); + if let Some(plan) = recovery_plan { + warn!( + "keyboard daemon ended unexpectedly ({exit_reason}); scheduling recovery attempt {}/{} in {} ms", + plan.attempt, + KEYBOARD_RECOVERY_DELAYS_MS.len(), + plan.delay.as_millis() + ); + } else { + error!( + "keyboard daemon ended unexpectedly ({exit_reason}); automatic recovery limit reached after {recovery_attempt} attempts" + ); + } + AppState::schedule_keyboard_hook_recovery( + app_handle, + generation, + recovery_plan, + ); + } }) .map_err(|err| anyhow!("failed to spawn keyboard daemon reader: {err}"))?; @@ -2477,7 +2640,8 @@ impl AppState { None }; - *task_guard = Some(KeyboardDaemonTask { + *task_slot = Some(KeyboardDaemonTask { + generation, running, reader_handle: Some(reader_handle), stderr_handle, @@ -2487,6 +2651,74 @@ impl AppState { Ok(()) } + fn schedule_keyboard_hook_recovery( + app: AppHandle, + failed_generation: u64, + plan: Option, + ) { + let fallback_app = app.clone(); + let spawn_result = thread::Builder::new() + .name("keyboard-daemon-supervisor".into()) + .spawn(move || { + if let Some(plan) = plan { + thread::sleep(plan.delay); + } + let app_state = app.state::(); + let mut task_guard = app_state.keyboard_task.write(); + let task_generation = task_guard.as_ref().map(|task| task.generation); + if !should_recover_keyboard_daemon( + app_state.shutdown_started.load(Ordering::SeqCst), + app_state.keyboard_task_generation.load(Ordering::SeqCst), + task_generation, + failed_generation, + ) { + log::debug!( + "keyboard daemon recovery canceled for generation {failed_generation}" + ); + return; + } + + let previous_task = task_guard.take(); + drop(previous_task); + if let Some(plan) = plan { + if let Err(err) = app_state.start_keyboard_hook_locked( + app.clone(), + &mut task_guard, + plan.attempt, + Some(failed_generation), + ) { + error!( + "failed to recover keyboard daemon on attempt {}: {err:#}", + plan.attempt + ); + } + } else { + app_state.reset_keyboard_hook_state(&app); + } + }); + if let Err(err) = spawn_result { + error!("failed to spawn keyboard daemon supervisor: {err}"); + fallback_app + .state::() + .reset_keyboard_hook_state(&fallback_app); + } + } + + fn reset_keyboard_hook_state(&self, app: &AppHandle) { + self.clear_active_keys(); + if let Err(err) = app.emit("keys:reset", &json!({ "reason": "hook_restart" })) { + warn!("failed to emit keys:reset: {err}"); + } + } + + fn restart_keyboard_hook(&self, app: AppHandle) -> Result<()> { + self.keyboard_task_generation.fetch_add(1, Ordering::SeqCst); + let mut task_guard = self.keyboard_task.write(); + let previous_task = task_guard.take(); + drop(previous_task); + self.start_keyboard_hook_locked(app, &mut task_guard, 0, None) + } + pub fn selection_session(&self) -> SelectionSessionSnapshot { self.selection_session.lock().clone() } @@ -3128,10 +3360,7 @@ impl AppState { if diff.changed.shortcuts.is_some() { // 변경된 글로벌 단축키 적용을 위해 키보드 daemon 재시작 - if let Some(task) = self.keyboard_task.write().take() { - drop(task); - } - self.start_keyboard_hook(app.clone())?; + self.restart_keyboard_hook(app.clone())?; } Ok(()) @@ -4621,6 +4850,7 @@ fn flush_deferred_overlay_bounds(store: &Arc, generation: &Arc, reader_handle: Option>, stderr_handle: Option>, @@ -4666,17 +4896,19 @@ mod tests { atomic::{AtomicBool, AtomicUsize, Ordering}, Arc, }, + time::Duration, }; use super::{ acknowledge_editor_flush_handshake, acknowledge_panel_close_request, - apply_panel_bounds_change, begin_panel_close_request, bootstrap_active_keys, + apply_panel_bounds_change, begin_panel_close_request, bootstrap_keyboard_state, changed_panel_max_height, collect_authorized_css_paths, collect_frontend_lifecycle_targets, frontend_history_mutation_blocked, frontend_lifecycle_restore_labels, global_css_watch_path, install_history_handshake, install_lifecycle_handshake, - panel_bounds_from_sample, panel_max_height, publish_panel_hidden_transition, - publish_panel_visibility_transition, publish_selection_snapshot, - resolve_panel_window_layout, run_panel_close_timeout, should_create_overlay_on_startup, + key_state_payload, next_keyboard_recovery_plan, panel_bounds_from_sample, panel_max_height, + publish_panel_hidden_transition, publish_panel_visibility_transition, + publish_selection_snapshot, resolve_event_age_ms, resolve_panel_window_layout, + run_panel_close_timeout, should_create_overlay_on_startup, should_recover_keyboard_daemon, take_cancelable_editor_flush_handshake, take_editor_flush_handshake, take_targeted_panel_view_state, validate_selection_session, EditorFlushAcknowledge, EditorFlushCompletion, EditorFlushHandshake, EditorFlushRequest, FrontendFlushAction, @@ -4687,10 +4919,11 @@ mod tests { PanelViewMode, PanelViewState, PanelViewTarget, PanelVisibilityEventEmitter, PanelVisibilityPayload, PanelVisibilityReason, PhysicalPosition, PhysicalSize, SelectionSessionElement, SelectionSessionSnapshot, TargetedPanelViewState, - HISTORY_FRONTEND_FLUSH_INTERRUPTED, MAX_SELECTION_ELEMENTS, - MAX_SELECTION_ELEMENT_TYPE_BYTES, MAX_SELECTION_FULL_ID_BYTES, - MAX_SELECTION_GROUP_ID_BYTES, MAX_SELECTION_MODE_BYTES, OVERLAY_LABEL, PANEL_ENTRYPOINT, - PANEL_INITIAL_HEIGHT, PANEL_LABEL, PANEL_MIN_HEIGHT, PANEL_WIDTH, RAW_INPUT_WINDOW_LABELS, + HISTORY_FRONTEND_FLUSH_INTERRUPTED, KEYBOARD_DAEMON_STABLE_RUNTIME, + KEYBOARD_RECOVERY_DELAYS_MS, MAX_SELECTION_ELEMENTS, MAX_SELECTION_ELEMENT_TYPE_BYTES, + MAX_SELECTION_FULL_ID_BYTES, MAX_SELECTION_GROUP_ID_BYTES, MAX_SELECTION_MODE_BYTES, + OVERLAY_LABEL, PANEL_ENTRYPOINT, PANEL_INITIAL_HEIGHT, PANEL_LABEL, PANEL_MIN_HEIGHT, + PANEL_WIDTH, RAW_INPUT_WINDOW_LABELS, }; use crate::{ keyboard::KeyboardManager, @@ -5582,14 +5815,78 @@ mod tests { } #[test] - fn bootstrap_active_keys_include_registered_event_key_names() { + fn bootstrap_keyboard_state_includes_mode_and_registered_event_key_names() { let manager = KeyboardManager::new( HashMap::from([("4key".to_string(), vec!["KeyD".to_string()])]), "4key", ); assert!(manager.register_key_down("4key", "KeyD")); - assert_eq!(bootstrap_active_keys(&manager), vec!["KeyD"]); + assert_eq!( + bootstrap_keyboard_state(&manager), + ("4key".to_string(), vec!["KeyD".to_string()]) + ); + } + + #[test] + fn event_age_uses_daemon_wall_clock_timestamp_when_sane() { + assert_eq!( + resolve_event_age_ms(Some(1_000.0), Some(1_025.5), 3.0), + 25.5 + ); + } + + #[test] + fn event_age_falls_back_for_invalid_wall_clock_delta() { + for input_ts_ms in [Some(2_000.0), Some(f64::NAN), Some(-f64::INFINITY)] { + assert_eq!(resolve_event_age_ms(input_ts_ms, Some(1_000.0), 7.0), 7.0); + } + assert_eq!( + resolve_event_age_ms(Some(1_000.0), Some(11_001.0), 7.0), + 7.0 + ); + assert_eq!(resolve_event_age_ms(None, Some(1_000.0), 7.0), 7.0); + } + + #[test] + fn key_state_payload_exposes_hold_duration_on_up_only() { + let down = key_state_payload("A", "DOWN", "4key", 2.0, true, Some(15.0)); + let up = key_state_payload("A", "UP", "4key", 3.0, false, Some(15.0)); + let unmatched_up = key_state_payload("A", "UP", "4key", 3.0, false, None); + + assert!(down.get("holdDurationMs").is_none()); + assert_eq!(up["holdDurationMs"], serde_json::json!(15.0)); + assert!(unmatched_up.get("holdDurationMs").is_none()); + } + + #[test] + fn keyboard_recovery_backoff_grows_and_stops_at_the_limit() { + let mut current_attempt = 0; + for (index, delay_ms) in KEYBOARD_RECOVERY_DELAYS_MS.into_iter().enumerate() { + let plan = next_keyboard_recovery_plan(current_attempt, Duration::ZERO).unwrap(); + assert_eq!(plan.attempt, index + 1); + assert_eq!(plan.delay, Duration::from_millis(delay_ms)); + current_attempt = plan.attempt; + } + + assert!(next_keyboard_recovery_plan(current_attempt, Duration::ZERO).is_none()); + } + + #[test] + fn stable_keyboard_daemon_resets_the_recovery_budget() { + let plan = next_keyboard_recovery_plan(5, KEYBOARD_DAEMON_STABLE_RUNTIME).unwrap(); + + assert_eq!(plan.attempt, 1); + assert_eq!(plan.delay, Duration::from_millis(250)); + } + + #[test] + fn keyboard_recovery_guard_rejects_teardown_and_stale_tasks() { + assert!(should_recover_keyboard_daemon(false, 7, Some(7), 7)); + assert!(!should_recover_keyboard_daemon(true, 7, Some(7), 7)); + assert!(!should_recover_keyboard_daemon(false, 8, Some(7), 7)); + assert!(!should_recover_keyboard_daemon(false, 7, Some(8), 7)); + assert!(!should_recover_keyboard_daemon(false, 7, None, 7)); } #[test] diff --git a/src/renderer/api/modules/keysApi.ts b/src/renderer/api/modules/keysApi.ts index ba09fd78..014b3340 100644 --- a/src/renderer/api/modules/keysApi.ts +++ b/src/renderer/api/modules/keysApi.ts @@ -14,6 +14,7 @@ import type { ModeChangePayload, CustomTabsChangePayload, KeyStatePayload, + KeysResetPayload, CustomTabResult, CustomTabDeleteResult, RawInputPayload, @@ -110,6 +111,10 @@ export const keysApi = { onKeyState: ( listener: (payload: KeyStatePayload) => void, ): ReadyUnsubscribe => subscribe('keys:state', listener), + // 키보드 훅 (재)시작 등으로 눌림 상태가 통째로 무효화될 때 발화 + onKeysReset: ( + listener: (payload: KeysResetPayload) => void, + ): ReadyUnsubscribe => subscribe('keys:reset', listener), onRawInput: (listener: (payload: RawInputPayload) => void): Unsubscribe => { let unsubscribeFn: (() => void) | null = null; let cancelled = false; diff --git a/src/renderer/components/main/Modal/content/dialogs/UpdateModal.tsx b/src/renderer/components/main/Modal/content/dialogs/UpdateModal.tsx index 47206d6f..5b46ef63 100644 --- a/src/renderer/components/main/Modal/content/dialogs/UpdateModal.tsx +++ b/src/renderer/components/main/Modal/content/dialogs/UpdateModal.tsx @@ -60,13 +60,12 @@ const UpdateModal = ({ } }; + // 파싱 실패·빈 값은 캡션 자체를 생략 const formatDate = (dateString: string) => { - try { - const date = new Date(dateString); - return date.toLocaleDateString(); - } catch { - return dateString; - } + if (!dateString) return ''; + const date = new Date(dateString); + if (Number.isNaN(date.getTime())) return ''; + return date.toLocaleDateString(); }; const handlePrimaryClick = async () => { @@ -78,122 +77,73 @@ const UpdateModal = ({ await handleGoToRelease(); }; + const publishedLabel = formatDate(updateInfo.publishedAt); + return (
e.stopPropagation()} > {isLatestVersion ? ( // 최신 버전일 때 UI <> - {/* 헤더 */} -
-
- - - -
-
-

- {t('update.latestAlready')} -

-
-
+

{t('update.latestAlready')}

- {/* 버전 정보 */} -
-
- - {t('update.currentVersion')} - - - {updateInfo.currentVersion} - -
-
- - {/* 버튼 */} -
+ {/* 버전 웰, 설정 화면 버전 행과 같은 문법 */} +
+ + Ver {updateInfo.currentVersion} + -
+ + ) : ( // 업데이트 있을 때 UI <> - {/* 헤더 */} -
-
- - - - - - - - - - -
-
-

{t('update.title')}

-

- {formatDate(updateInfo.publishedAt)} +

+

{t('update.title')}

+ {publishedLabel && ( +

+ {publishedLabel}

-
+ )}
- {/* 버전 정보 */} -
-
+ {/* 버전 비교 웰, 새 버전만 fg로 올려 대비 */} +
+
{t('update.currentVersion')} - + {updateInfo.currentVersion}
-
+
{t('update.latestVersion')} - + {updateInfo.latestVersion}
{/* 이 버전 건너뛰기 체크박스 */} -