From 3d825cdb0eb193d99f724fa343f82509046e724d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 22 Jul 2026 22:14:29 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EB=8B=A8=EB=85=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=9D=BC=EA=B4=80=EC=84=B1=20=ED=8C=90=EC=A0=95=EC=9D=84=20?= =?UTF-8?q?=EB=8D=B0=EB=AA=AC=20=EC=8B=A4=EC=B8=A1=20hold=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20UP=20=EC=9C=A0=EC=8B=A4=20=EB=B3=B5=EA=B5=AC=20?= =?UTF-8?q?=EC=A0=95=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/en/api-reference/keys/page.mdx | 20 + docs/content/ko/api-reference/keys/page.mdx | 21 + src-tauri/src/ipc.rs | 56 +++ src-tauri/src/keyboard/daemon/macos.rs | 186 ++++++--- src-tauri/src/keyboard/daemon/mod.rs | 370 ++++++++++++++++-- src-tauri/src/keyboard/daemon/windows.rs | 136 +++++-- src-tauri/src/keyboard/daemon/windows_hid.rs | 85 ++-- src-tauri/src/keyboard/manager.rs | 24 +- src-tauri/src/services/obs_bridge.rs | 1 + src-tauri/src/state/app_state.rs | 355 +++++++++++++++-- src/renderer/api/modules/keysApi.ts | 5 + .../components/overlay/WebGLTracksOGL.tsx | 15 +- .../hooks/overlay/useNoteSystem.test.tsx | 250 ++++++++++++ src/renderer/hooks/overlay/useNoteSystem.ts | 114 ++++-- .../stores/signals/noteBuffer.test.ts | 81 ++++ src/renderer/stores/signals/noteBuffer.ts | 51 ++- src/renderer/utils/core/keyEventBus.ts | 1 + src/renderer/windows/overlay/App.test.tsx | 268 ++++++++++++- src/renderer/windows/overlay/App.tsx | 151 +++++-- src/types/plugin/api.ts | 9 + 20 files changed, 1955 insertions(+), 244 deletions(-) create mode 100644 src/renderer/hooks/overlay/useNoteSystem.test.tsx create mode 100644 src/renderer/stores/signals/noteBuffer.test.ts 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/overlay/WebGLTracksOGL.tsx b/src/renderer/components/overlay/WebGLTracksOGL.tsx index b2b01e6b..ca27d469 100644 --- a/src/renderer/components/overlay/WebGLTracksOGL.tsx +++ b/src/renderer/components/overlay/WebGLTracksOGL.tsx @@ -441,6 +441,8 @@ interface NoteEvent { interface NoteBuffer { version: number; activeCount: number; + timeEpoch: number; + maybeRebaseEpoch(nowMs: number): boolean; noteInfo: Float32Array; noteSize: Float32Array; noteColorTop: Float32Array; @@ -679,6 +681,15 @@ export function WebGLTracksOGL({ resetFrameClock(frameClockRef.current); } + // Float32 정밀도 유지 - 장시간 실행 시 epoch 이동, noteInfo 전체 재업로드 예약 + if (noteBuffer.maybeRebaseEpoch(renderTime)) { + queueAttributeUpload( + pendingUpdateRef.current, + FINALIZE_ATTRIBUTE_KEYS, + noteBuffer.activeCount, + ); + } + // 프레임 시작 시 배치 업데이트 적용 if (pendingUpdateRef.current.dirtySinceFrame) { const geometryTarget = geometryRef.current; @@ -708,7 +719,9 @@ export function WebGLTracksOGL({ pendingUpdateRef.current.instancedCount = null; } - programRef.current.uniforms.uTime.value = renderTime; + // uTime도 epoch 상대값 - noteInfo와 같은 기준이어야 길이·이동 계산이 성립 + programRef.current.uniforms.uTime.value = + renderTime - noteBuffer.timeEpoch; rendererRef.current.render({ scene: sceneRef.current, camera: cameraRef.current, diff --git a/src/renderer/hooks/overlay/useNoteSystem.test.tsx b/src/renderer/hooks/overlay/useNoteSystem.test.tsx new file mode 100644 index 00000000..e9e3e87e --- /dev/null +++ b/src/renderer/hooks/overlay/useNoteSystem.test.tsx @@ -0,0 +1,250 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useNoteSystem } from './useNoteSystem'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +type HookResult = ReturnType; + +interface HarnessProps { + noteEffect: boolean; + noteSettings: { + speed?: number; + trackHeight?: number; + frameLimit?: number; + delayedNoteEnabled?: boolean; + shortNoteThresholdMs?: number; + shortNoteMinLengthPx?: number; + }; + onResult: (result: HookResult) => void; +} + +const Harness = ({ noteEffect, noteSettings, onResult }: HarnessProps) => { + onResult(useNoteSystem({ noteEffect, noteSettings })); + return null; +}; + +// threshold 100ms, 최소 길이 10px @ 400px/s = 25ms +const DELAY_SETTINGS = { + speed: 400, + trackHeight: 300, + delayedNoteEnabled: true, + shortNoteThresholdMs: 100, + shortNoteMinLengthPx: 10, +}; + +describe('useNoteSystem 단/롱 판정', () => { + let container: HTMLDivElement; + let root: Root; + let result: HookResult; + let nowMs: number; + + const advance = async (ms: number) => { + // performance.now와 타이머를 같은 축으로 전진 + const target = nowMs + ms; + while (nowMs < target) { + nowMs = Math.min(nowMs + 1, target); + vi.advanceTimersByTime(1); + } + }; + + const render = async ( + noteSettings: HarnessProps['noteSettings'] = DELAY_SETTINGS, + ) => { + await act(async () => { + root.render( + { + result = value; + }} + />, + ); + }); + }; + + const noteOf = (key: string, index = 0) => + result.notesRef.current[key]?.[index]; + + beforeEach(() => { + vi.useFakeTimers(); + nowMs = 0; + vi.spyOn(performance, 'now').mockImplementation(() => nowMs); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('UP이 startTimer 이후 도착해도 hold < threshold면 고정 길이 단노트다', async () => { + await render(); + + // 물리 hold 80ms < threshold 100ms인 탭. 전달 지연으로 UP 콜백은 + // 타이머 발화(100ms) 뒤인 110ms에 도착 - 1.6.1 회귀의 핵심 시나리오 + result.handleKeyDown('Z', { displayTime: 0, physTime: 0 }); + await advance(105); + result.handleKeyUp('Z', { + displayTime: 80, + physTime: 80, + holdDurationMs: 80, + }); + await advance(30); + + const note = noteOf('Z'); + expect(note).toBeDefined(); + expect(note!.isActive).toBe(false); + expect(note!.startTime).toBe(100); + // 단노트 고정 길이 25ms (80ms가 아님) + expect(note!.endTime).toBe(125); + }); + + it('UP이 startTimer 이전에 도착해도 hold >= threshold면 롱노트다', async () => { + await render(); + + // 배치 전달로 DOWN/UP이 함께 늦게 도착한 진짜 600ms 홀드. + // 기존 코드는 타이머 생존만 보고 단노트로 오분류했다 + result.handleKeyDown('Z', { displayTime: 0, physTime: 0 }); + result.handleKeyUp('Z', { + displayTime: 5, + physTime: 5, + holdDurationMs: 600, + }); + await advance(100); + await advance(650); + + const note = noteOf('Z'); + expect(note!.isActive).toBe(false); + expect(note!.startTime).toBe(100); + expect(note!.endTime).toBe(700); + }); + + it('경계 정책: hold == threshold는 롱, 미만은 단', async () => { + await render(); + + result.handleKeyDown('A', { displayTime: 0, physTime: 0 }); + await advance(105); + result.handleKeyUp('A', { + displayTime: 100, + physTime: 100, + holdDurationMs: 100, + }); + await advance(120); + expect(noteOf('A')!.endTime).toBe(200); + + result.handleKeyDown('B', { displayTime: nowMs, physTime: nowMs }); + const bDown = nowMs; + await advance(105); + result.handleKeyUp('B', { + displayTime: bDown + 99, + physTime: bDown + 99, + holdDurationMs: 99, + }); + await advance(30); + expect(noteOf('B')!.endTime).toBe(bDown + 125); + }); + + it('holdDurationMs가 NaN·음수면 비클램프 시각 차로 폴백한다', async () => { + await render(); + + result.handleKeyDown('N', { displayTime: 0, physTime: 0 }); + await advance(105); + result.handleKeyUp('N', { + displayTime: 30, + physTime: 30, + holdDurationMs: Number.NaN, + }); + await advance(30); + // 폴백 hold 30ms < 100ms → 단노트 + expect(noteOf('N')!.endTime).toBe(125); + + result.handleKeyDown('M', { displayTime: nowMs, physTime: nowMs }); + const mDown = nowMs; + await advance(105); + result.handleKeyUp('M', { + displayTime: mDown + 40, + physTime: mDown + 40, + holdDurationMs: -5, + }); + await advance(30); + expect(noteOf('M')!.endTime).toBe(mDown + 125); + }); + + it('mid-press 설정 변경에도 press 시작 시점의 threshold로 판정한다', async () => { + await render(); + + result.handleKeyDown('S', { displayTime: 0, physTime: 0 }); + // press 진행 중 threshold를 100 → 30으로 낮춰도 진행 중 press에는 미적용 + await render({ ...DELAY_SETTINGS, shortNoteThresholdMs: 30 }); + result.handleKeyUp('S', { + displayTime: 60, + physTime: 60, + holdDurationMs: 60, + }); + await advance(105); + await advance(30); + + // 스냅샷 threshold 100 기준 단노트 (라이브 값 30이었다면 롱노트 160) + expect(noteOf('S')!.endTime).toBe(125); + }); + + it('reconcile: 스냅샷에 없는 키의 활성 노트를 현재 시각으로 종료한다', async () => { + await render(); + + result.handleKeyDown('Z', { displayTime: 0, physTime: 0 }); + await advance(300); + expect(noteOf('Z')!.isActive).toBe(true); + + act(() => { + result.reconcileActiveNotes(new Set()); + }); + + const note = noteOf('Z'); + expect(note!.isActive).toBe(false); + expect(note!.endTime).toBe(300); + }); + + it('reconcile: 스냅샷에 있는 키는 유지하고, 생성 전 press는 조용히 취소한다', async () => { + await render(); + + result.handleKeyDown('H', { displayTime: 0, physTime: 0 }); + await advance(150); + act(() => { + result.reconcileActiveNotes(new Set(['H'])); + }); + expect(noteOf('H')!.isActive).toBe(true); + + result.handleKeyDown('P', { displayTime: nowMs, physTime: nowMs }); + await advance(50); + act(() => { + result.reconcileActiveNotes(new Set(['H'])); + }); + await advance(200); + // P는 표시 전 취소 - 노트가 생성되지 않아야 함 + expect(noteOf('P')).toBeUndefined(); + }); + + it('non-delay 모드는 기존 계약 유지 - 도착 시각 기반 종료', async () => { + await render({ ...DELAY_SETTINGS, delayedNoteEnabled: false }); + + result.handleKeyDown('D', { displayTime: 10, physTime: 10 }); + await advance(60); + result.handleKeyUp('D', { + displayTime: 60, + physTime: 60, + holdDurationMs: 9999, + }); + + const note = noteOf('D'); + expect(note!.startTime).toBe(10); + // holdDurationMs와 무관하게 도착(보정) 시각으로 종료 + expect(note!.endTime).toBe(60); + }); +}); diff --git a/src/renderer/hooks/overlay/useNoteSystem.ts b/src/renderer/hooks/overlay/useNoteSystem.ts index 1687b539..2dfdffb9 100644 --- a/src/renderer/hooks/overlay/useNoteSystem.ts +++ b/src/renderer/hooks/overlay/useNoteSystem.ts @@ -28,15 +28,29 @@ type NoteSubscriber = (event: NoteEvent) => void; interface NoteState { useDelay: boolean; downTime?: number; + // 판정 폴백용 비클램프 보정 시각 - 표시용 downTime과 분리 + physDownTime?: number; releaseTime: number | null; + physReleaseTime?: number; + // 데몬이 캡처 시점에 측정한 물리 hold (UP payload, 검증 통과 값만) + holdDurationMs?: number; startTime: number | null; startTimer: ReturnType | null; finalizeTimer: ReturnType | null; noteId: string | null; created: boolean; released: boolean; + // press 시작 시점 정책 스냅샷 - mid-press 설정 변경에도 판정·길이 일관 유지 delayMs?: number; - releasedBeforeStart?: boolean; + minLengthMs?: number; +} + +// 키 이벤트 시각 정보. displayTime은 클램프 보정(표시 위치 전용), +// physTime은 비클램프 보정(hold 폴백 전용) +export interface NoteKeyTiming { + displayTime?: number; + physTime?: number; + holdDurationMs?: number; } interface NoteSettings { @@ -60,9 +74,10 @@ interface UseNoteSystemOptions { interface UseNoteSystemReturn { notesRef: React.MutableRefObject>; subscribe: (callback: NoteSubscriber) => () => void; - handleKeyDown: (keyName: string, eventTime?: number) => void; - handleKeyUp: (keyName: string, eventTime?: number) => void; + handleKeyDown: (keyName: string, timing?: NoteKeyTiming) => void; + handleKeyUp: (keyName: string, timing?: NoteKeyTiming) => void; finalizeAllActive: () => void; + reconcileActiveNotes: (activeKeys: ReadonlySet) => void; noteBuffer: NoteBuffer; updateTrackLayouts: (layouts: TrackLayoutInput[]) => void; } @@ -451,28 +466,24 @@ export function useNoteSystem({ const scheduleNoteFinalization = ( keyName: string, state: NoteState, - options: { forceMinLength?: boolean } = {}, ): void => { - const { forceMinLength = false } = options; if (!state?.noteId || state.startTime == null) return; const releaseTime = state.releaseTime ?? performance.now(); - const noteRef = state.noteId - ? noteLookupRef.current.get(state.noteId) - : null; - const baselineStart = - noteRef?.startTime ?? state.startTime ?? state.downTime ?? releaseTime; - const clampedStart = Math.min(releaseTime, baselineStart); - const holdDurationFromStart = Math.max(0, releaseTime - clampedStart); - // delayed mode: 시작 표시가 threshold만큼 지연되므로 실제 입력 유지 시간 기준으로 길이 계산 (종료도 동일하게 지연) - const physicalHoldMs = - state.useDelay && state.downTime != null - ? Math.max(0, releaseTime - state.downTime) - : holdDurationFromStart; - const minLengthMs = computeMinLengthMs(); - const desiredDuration = forceMinLength + // 판정용 물리 hold: 데몬 authoritative 값 우선, 없으면 비클램프 보정 시각 차 폴백 + const physDown = state.physDownTime ?? state.downTime; + const physRelease = state.physReleaseTime ?? releaseTime; + const fallbackHold = + physDown != null ? Math.max(0, physRelease - physDown) : 0; + const holdMs = state.holdDurationMs ?? fallbackHold; + // 단/롱 판정은 실행 순서(타이머 vs UP 도착)가 아니라 물리 hold와 press 시점 + // 스냅샷 threshold의 비교로만 결정 - 전달 지연·지터에 불변 + const threshold = state.delayMs ?? 0; + const minLengthMs = state.minLengthMs ?? computeMinLengthMs(); + const isShort = state.useDelay && holdMs < threshold; + const desiredDuration = isShort ? minLengthMs - : Math.max(minLengthMs, physicalHoldMs); + : Math.max(minLengthMs, holdMs); const safeDuration = Math.max(desiredDuration, 1); const targetEndTime = state.startTime + safeDuration; @@ -500,8 +511,8 @@ export function useNoteSystem({ finalizeTimersRef.current.set(state.noteId, timer); }; - // 노트 생성/완료. eventTime: 실제 입력 시각(performance.now 기준 보정값) - const handleKeyDown = (keyName: string, eventTime?: number): void => { + // 노트 생성/완료. timing.displayTime: 표시용 보정 시각, timing.physTime: 판정 폴백용 + const handleKeyDown = (keyName: string, timing?: NoteKeyTiming): void => { if (!noteEffectEnabled.current) return; const useDelay = delayEnabledRef.current && delayMsRef.current > 0; @@ -517,10 +528,11 @@ export function useNoteSystem({ if (useDelay) { const delayMs = delayMsRef.current; - const downTime = eventTime ?? performance.now(); + const downTime = timing?.displayTime ?? performance.now(); const state: NoteState = { useDelay: true, downTime, + physDownTime: timing?.physTime ?? downTime, releaseTime: null, startTime: null, startTimer: null, @@ -529,7 +541,7 @@ export function useNoteSystem({ created: false, released: false, delayMs, - releasedBeforeStart: false, + minLengthMs: computeMinLengthMs(), }; const startTimer = setTimeout(() => { @@ -545,10 +557,9 @@ export function useNoteSystem({ state.created = true; state.startTime = overrideStart; + // 생성 전에 UP이 먼저 도착한 press - 단/롱은 finalize 계산이 hold로 판정 if (state.released) { - const forceMinLength = !!state.releasedBeforeStart; - scheduleNoteFinalization(keyName, state, { forceMinLength }); - state.releasedBeforeStart = false; + scheduleNoteFinalization(keyName, state); } // 실제 입력 시각 기준으로 노트 등장 시점을 맞춤. 입력 시각이 과거면 // 남은 대기를 0으로 clamp해 타이머가 음수가 되지 않도록 함 @@ -559,7 +570,7 @@ export function useNoteSystem({ return; } - const noteId = createNote(keyName, eventTime); + const noteId = createNote(keyName, timing?.displayTime); const createdNote = noteLookupRef.current.get(noteId); const noteStartTime = createdNote?.startTime ?? performance.now(); stateList.push({ @@ -574,7 +585,7 @@ export function useNoteSystem({ }); }; - const handleKeyUp = (keyName: string, eventTime?: number): void => { + const handleKeyUp = (keyName: string, timing?: NoteKeyTiming): void => { if (!noteEffectEnabled.current) return; const stateList = activeNotes.current.get(keyName); @@ -590,9 +601,16 @@ export function useNoteSystem({ if (!state) return; - const now = eventTime ?? performance.now(); + const now = timing?.displayTime ?? performance.now(); state.released = true; state.releaseTime = now; + state.physReleaseTime = timing?.physTime ?? now; + // NaN·음수 방어: 검증 실패 값은 폴백 계산으로 강등 + const rawHold = timing?.holdDurationMs; + state.holdDurationMs = + typeof rawHold === 'number' && Number.isFinite(rawHold) && rawHold >= 0 + ? rawHold + : undefined; if (!state.useDelay) { if (state.created && state.noteId) { @@ -603,7 +621,6 @@ export function useNoteSystem({ } if (state.startTimer) { - state.releasedBeforeStart = true; // 아직 노트가 생성되지 않았으므로 타이머가 실행되면 finalize를 스케줄링한다 return; } @@ -691,12 +708,47 @@ export function useNoteSystem({ }; const finalizeAllActive = (): void => finalizeAllActiveRef.current(); + // UP 유실 복구: 스냅샷 기준 실제로 눌려 있지 않은 키의 활성 press를 종료. + // 유실된 release의 실제 시각은 복원 불가하므로 현재 시각 finalize로 + // 성장만 정지시킨다 (실패 복구 경로이지 정상 판정이 아님) + const reconcileActiveNotes = (activeKeys: ReadonlySet): void => { + const now = performance.now(); + for (const [keyName, stateList] of activeNotes.current.entries()) { + if (activeKeys.has(keyName)) continue; + if (!Array.isArray(stateList)) continue; + // removeState가 배열을 변형하므로 사본 순회 + for (const state of [...stateList]) { + if (!state || state.released) continue; + state.released = true; + state.releaseTime = now; + state.physReleaseTime = now; + if (state.startTimer) { + // 노트 생성 전이면 표시된 것이 없으므로 조용히 취소 + clearTimeout(state.startTimer); + state.startTimer = null; + removeState(keyName, state); + continue; + } + // 성장 즉시 정지 - schedule 경유 시 delay 모드는 threshold만큼 더 자람 + if (state.created && state.noteId) { + finalizeNote( + keyName, + state.noteId, + Math.max(now, state.startTime ?? 0), + ); + } + removeState(keyName, state); + } + } + }; + return { notesRef, subscribe, handleKeyDown: effectiveHandleKeyDown, handleKeyUp: effectiveHandleKeyUp, finalizeAllActive, + reconcileActiveNotes, noteBuffer: noteBufferRef.current, updateTrackLayouts: (layouts: TrackLayoutInput[]) => noteBufferRef.current.updateTrackLayouts(layouts), diff --git a/src/renderer/stores/signals/noteBuffer.test.ts b/src/renderer/stores/signals/noteBuffer.test.ts new file mode 100644 index 00000000..1446e74d --- /dev/null +++ b/src/renderer/stores/signals/noteBuffer.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { createNoteBuffer } from './noteBuffer'; + +const layoutFor = (trackKey: string) => ({ + trackKey, + trackIndex: 0, + position: { dx: 10, dy: 20 }, + width: 60, + height: 60, + noteColor: '#FFFFFF', + noteOpacity: 80, +}); + +describe('NoteBuffer 시각 저장', () => { + it('서브 프레임 노트 시작 시각을 프레임 단위로 양자화하지 않고 보존한다', () => { + const buffer = createNoteBuffer(); + buffer.updateTrackLayouts([layoutFor('Z')]); + + buffer.allocate('Z', 'note-1', 1000.25); + buffer.allocate('Z', 'note-2', 1004.75); + + expect(buffer.noteInfo[0]).toBeCloseTo(1000.25, 3); + expect(buffer.noteInfo[3]).toBeCloseTo(1004.75, 3); + expect(buffer.noteInfo[3] - buffer.noteInfo[0]).toBeCloseTo(4.5, 3); + }); + + it('한도 초과 시 epoch를 이동하고 저장 시각을 재기준화한다', () => { + const buffer = createNoteBuffer(); + buffer.updateTrackLayouts([layoutFor('Z')]); + + buffer.allocate('Z', 'note-1', 1000.25); + buffer.allocate('Z', 'note-2', 1004.75); + + expect(buffer.maybeRebaseEpoch(3_000_000)).toBe(true); + expect(buffer.timeEpoch).toBe(3_000_000); + // 상대값으로 이동하되 서브 프레임 간격은 보존 + expect(buffer.noteInfo[0]).toBeCloseTo(1000.25 - 3_000_000, 2); + expect(buffer.noteInfo[3] - buffer.noteInfo[0]).toBeCloseTo(4.5, 3); + + // 이후 저장은 epoch 상대값 - 큰 절대값이 Float32에 들어가지 않음 + buffer.allocate('Z', 'note-3', 3_000_100.5); + expect(buffer.noteInfo[6]).toBeCloseTo(100.5, 3); + buffer.finalize('note-3', 3_000_150.25); + expect(buffer.noteInfo[7]).toBeCloseTo(150.25, 3); + }); + + it('한도 이내에서는 재기준화하지 않는다', () => { + const buffer = createNoteBuffer(); + buffer.updateTrackLayouts([layoutFor('Z')]); + buffer.allocate('Z', 'note-1', 500); + + expect(buffer.maybeRebaseEpoch(2_000_000)).toBe(false); + expect(buffer.timeEpoch).toBe(0); + expect(buffer.noteInfo[0]).toBe(500); + }); + + it('장시간 유휴 후 첫 할당은 자동 재기준화되고 sentinel 0을 피한다', () => { + const buffer = createNoteBuffer(); + buffer.updateTrackLayouts([layoutFor('Z')]); + + buffer.allocate('Z', 'note-1', 5_000_000); + + expect(buffer.timeEpoch).toBe(5_000_000); + // startTime 0.0은 빈 슬롯 sentinel - 정확히 0이 되면 안 됨 + expect(buffer.noteInfo[0]).not.toBe(0); + expect(Math.abs(buffer.noteInfo[0])).toBeLessThan(1); + }); + + it('재기준화 후 활성 노트의 finalize도 같은 기준을 쓴다', () => { + const buffer = createNoteBuffer(); + buffer.updateTrackLayouts([layoutFor('Z')]); + + buffer.allocate('Z', 'note-1', 2_000_000); + buffer.maybeRebaseEpoch(4_200_000); + buffer.finalize('note-1', 4_200_050); + + // 길이(end - start)가 물리 시간과 일치 + expect(buffer.noteInfo[1] - buffer.noteInfo[0]).toBeCloseTo(2_200_050, 0); + expect(buffer.noteInfo[1]).toBeCloseTo(50, 3); + }); +}); diff --git a/src/renderer/stores/signals/noteBuffer.ts b/src/renderer/stores/signals/noteBuffer.ts index e72aee16..abd1f322 100644 --- a/src/renderer/stores/signals/noteBuffer.ts +++ b/src/renderer/stores/signals/noteBuffer.ts @@ -3,6 +3,13 @@ import { toRgbHexColor } from '@utils/color/colorUtils'; const MAX_NOTES = 2048; +// GPU 시각의 Float32 정밀도 유지 한도. performance.now()가 수일 누적되면 +// 간격이 32~64ms로 벌어져 짧은 노트 길이가 양자화되므로, 이 한도를 넘으면 +// epoch를 현재로 옮겨 절대값을 작게 유지 (2^21ms ≈ 35분, 해당 구간 정밀도 0.125ms) +const EPOCH_REBASE_LIMIT_MS = 2_097_152; +// 셰이더 sentinel(startTime 0.0 = 빈 슬롯, endTime 0.0 = 활성)과의 우연 충돌 방지 +const EPOCH_ZERO_NUDGE = 1e-4; + const SRGB_TO_LINEAR = new Float32Array(256); for (let i = 0; i < 256; i += 1) { const c = i / 255; @@ -212,6 +219,8 @@ export class NoteBuffer { private trackLayouts: Map; // allocate() 시프트 후 Map이 오래된 첫 인덱스. Infinity = Map 최신 상태 private dirtyIndexStart: number; + // noteInfo 시각의 기준점 - GPU에는 (원시 시각 - epoch)만 전달 + private epoch: number; activeCount: number; version: number; @@ -237,6 +246,42 @@ export class NoteBuffer { this.activeCount = 0; this.version = 0; this.dirtyIndexStart = Infinity; + this.epoch = 0; + } + + get timeEpoch(): number { + return this.epoch; + } + + // 원시 시각(performance.now 기준) → GPU 저장 시각 + private toGpuTime(t: number): number { + const v = t - this.epoch; + return v === 0 ? EPOCH_ZERO_NUDGE : v; + } + + // 한도 초과 시 epoch를 nowMs로 이동하고 저장된 시각을 재기준화. + // true 반환 시 호출자가 noteInfo 전체 재업로드를 예약해야 함 + maybeRebaseEpoch(nowMs: number): boolean { + const delta = nowMs - this.epoch; + if (delta <= EPOCH_REBASE_LIMIT_MS) return false; + for (let i = 0; i < this.activeCount; i += 1) { + const infoOffset = i * 3; + if (this.noteInfo[infoOffset] !== 0) { + this.noteInfo[infoOffset] -= delta; + if (this.noteInfo[infoOffset] === 0) { + this.noteInfo[infoOffset] = -EPOCH_ZERO_NUDGE; + } + } + if (this.noteInfo[infoOffset + 1] !== 0) { + this.noteInfo[infoOffset + 1] -= delta; + if (this.noteInfo[infoOffset + 1] === 0) { + this.noteInfo[infoOffset + 1] = -EPOCH_ZERO_NUDGE; + } + } + } + this.epoch = nowMs; + this.version += 1; + return true; } // allocate() 시프트로 오염된 Map 항목을 한 번에 재구축 @@ -277,6 +322,8 @@ export class NoteBuffer { if (this.activeCount >= MAX_NOTES) { return -1; } + // 'add' 이벤트가 전 속성 업로드를 예약하므로 여기서의 재기준화는 별도 예약 불필요 + this.maybeRebaseEpoch(startTime); const { opacityTop, opacityBottom, @@ -374,7 +421,7 @@ export class NoteBuffer { this.activeCount += 1; const infoOffset = insertIndex * 3; - this.noteInfo[infoOffset] = startTime; + this.noteInfo[infoOffset] = this.toGpuTime(startTime); this.noteInfo[infoOffset + 1] = 0; this.noteInfo[infoOffset + 2] = layout.position.dx; @@ -427,7 +474,7 @@ export class NoteBuffer { if (index === undefined) { return -1; } - this.noteInfo[index * 3 + 1] = endTime; + this.noteInfo[index * 3 + 1] = this.toGpuTime(endTime); this.version += 1; return index; } diff --git a/src/renderer/utils/core/keyEventBus.ts b/src/renderer/utils/core/keyEventBus.ts index 873c29c5..9e4ecc22 100644 --- a/src/renderer/utils/core/keyEventBus.ts +++ b/src/renderer/utils/core/keyEventBus.ts @@ -8,6 +8,7 @@ type KeyStatePayload = { state: string; mode: string; eventAgeMs?: number; + holdDurationMs?: number; }; type KeyEventListener = (payload: KeyStatePayload) => void; diff --git a/src/renderer/windows/overlay/App.test.tsx b/src/renderer/windows/overlay/App.test.tsx index 0148ff80..e54796b0 100644 --- a/src/renderer/windows/overlay/App.test.tsx +++ b/src/renderer/windows/overlay/App.test.tsx @@ -16,6 +16,13 @@ const mocks = vi.hoisted(() => ({ keyEventListener: null as null | ((payload: unknown) => void), unsubscribeKeyEvents: vi.fn(), updateTrackLayouts: vi.fn(), + handleKeyDown: vi.fn(), + handleKeyUp: vi.fn(), + finalizeAllActive: vi.fn(), + reconcileActiveNotes: vi.fn(), + resyncListener: null as null | (() => void), + keysResetListener: null as null | ((payload: unknown) => void), + noteEffectEnabled: { value: false }, })); vi.mock('@tauri-apps/api/window', () => ({ @@ -52,9 +59,10 @@ vi.mock('@hooks/overlay/useNoteSystem', () => ({ useNoteSystem: () => ({ notesRef: { current: {} }, subscribe: vi.fn(() => () => {}), - handleKeyDown: vi.fn(), - handleKeyUp: vi.fn(), - finalizeAllActive: vi.fn(), + handleKeyDown: mocks.handleKeyDown, + handleKeyUp: mocks.handleKeyUp, + finalizeAllActive: mocks.finalizeAllActive, + reconcileActiveNotes: mocks.reconcileActiveNotes, noteBuffer: {}, updateTrackLayouts: mocks.updateTrackLayouts, }), @@ -97,7 +105,9 @@ vi.mock('@stores/useSettingsStore', () => { keyDisplayDelayMs: 0, }, tabNoteOverrides: {}, - noteEffect: false, + get noteEffect() { + return mocks.noteEffectEnabled.value; + }, gridSettings: { overlayPadding: 30 }, overlayResizeAnchor: 'center', keyCounterEnabled: false, @@ -136,6 +146,14 @@ vi.mock('@utils/core/keyEventBus', () => ({ initialize: vi.fn(() => Promise.resolve()), }, })); +vi.mock('@api/modules/obsApi', () => ({ + obsApi: { + onResync: vi.fn((listener: () => void) => { + mocks.resyncListener = listener; + return vi.fn(); + }), + }, +})); import App from './App'; @@ -157,6 +175,31 @@ const flushAsync = () => await new Promise((resolve) => setTimeout(resolve, 0)); }); +const makeApiMock = () => + ({ + app: { bootstrap: mocks.bootstrap }, + keys: { + onKeysReset: vi.fn((listener: (payload: unknown) => void) => { + mocks.keysResetListener = listener; + return vi.fn(); + }), + }, + } as unknown as Window['api']); + +const resetSharedMocks = () => { + mocks.bootstrap.mockReset(); + mocks.bootstrap.mockResolvedValue({ activeKeys: [] }); + mocks.keyEventListener = null; + mocks.resyncListener = null; + mocks.keysResetListener = null; + mocks.unsubscribeKeyEvents.mockClear(); + mocks.updateTrackLayouts.mockClear(); + mocks.handleKeyDown.mockClear(); + mocks.handleKeyUp.mockClear(); + mocks.finalizeAllActive.mockClear(); + mocks.reconcileActiveNotes.mockClear(); +}; + describe('overlay active key reconciliation', () => { let container: HTMLDivElement; let root: Root; @@ -164,14 +207,9 @@ describe('overlay active key reconciliation', () => { beforeEach(async () => { originalApi = window.api; - mocks.bootstrap.mockReset(); - mocks.bootstrap.mockResolvedValue({ activeKeys: [] }); - mocks.keyEventListener = null; - mocks.unsubscribeKeyEvents.mockClear(); - mocks.updateTrackLayouts.mockClear(); - window.api = { - app: { bootstrap: mocks.bootstrap }, - } as unknown as Window['api']; + resetSharedMocks(); + mocks.noteEffectEnabled.value = false; + window.api = makeApiMock(); useKeyStore.setState({ selectedKeyType: '4key', customTabs: [], @@ -299,3 +337,209 @@ describe('overlay active key reconciliation', () => { expect(getKeySignal('KeyL').value).toBe(true); }); }); + +describe('note timing payload and loss recovery', () => { + let container: HTMLDivElement; + let root: Root; + let originalApi: Window['api']; + + beforeEach(async () => { + originalApi = window.api; + resetSharedMocks(); + mocks.noteEffectEnabled.value = true; + window.api = makeApiMock(); + useKeyStore.setState({ + selectedKeyType: '4key', + customTabs: [], + keyMappings: { + '4key': ['KeyK', 'KeyJ'], + '8key': ['KeyQ'], + }, + positions: { + '4key': [ + { ...createDefaultKeyPosition(0, 0), noteEffectEnabled: false }, + createDefaultKeyPosition(70, 0), + ], + '8key': [], + }, + canonicalPositions: { '4key': [], '8key': [] }, + isBootstrapped: true, + isLocalUpdateInProgress: false, + }); + resetAllKeySignals(); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + await act(async () => { + root.render(); + }); + await flushAsync(); + mocks.bootstrap.mockClear(); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + resetAllKeySignals(); + window.api = originalApi; + vi.restoreAllMocks(); + }); + + it('noteEffectEnabled가 꺼진 키도 UP은 항상 노트 시스템에 전달된다', async () => { + await act(async () => { + mocks.keyEventListener?.({ + key: 'KeyK', + state: 'DOWN', + mode: '4key', + eventAgeMs: 0, + }); + }); + expect(mocks.handleKeyDown).not.toHaveBeenCalled(); + + await act(async () => { + mocks.keyEventListener?.({ + key: 'KeyK', + state: 'UP', + mode: '4key', + eventAgeMs: 0, + holdDurationMs: 20, + }); + }); + expect(mocks.handleKeyUp).toHaveBeenCalledWith( + 'KeyK', + expect.objectContaining({ holdDurationMs: 20 }), + ); + }); + + it('표시 시각은 클램프하고 판정 시각은 비클램프로 분리 전달한다', async () => { + await act(async () => { + mocks.keyEventListener?.({ + key: 'KeyJ', + state: 'UP', + mode: '4key', + eventAgeMs: 500, + holdDurationMs: 42, + }); + }); + + expect(mocks.handleKeyUp).toHaveBeenCalledTimes(1); + const [, timing] = mocks.handleKeyUp.mock.calls[0] as [ + string, + { displayTime: number; physTime: number; holdDurationMs?: number }, + ]; + expect(timing.holdDurationMs).toBe(42); + // displayTime은 250ms 클램프, physTime은 원 age(500ms) 그대로 + expect(timing.displayTime - timing.physTime).toBeCloseTo(250, 0); + }); + + it('keys:reset 수신 시 활성 노트를 강제 완료하고 눌림 상태를 재수화한다', async () => { + mocks.bootstrap.mockResolvedValue({ activeKeys: ['KeyJ'] }); + + await act(async () => { + mocks.keysResetListener?.({ reason: 'hook_restart' }); + }); + await flushAsync(); + + expect(mocks.finalizeAllActive).toHaveBeenCalled(); + expect(mocks.bootstrap).toHaveBeenCalled(); + expect(getKeySignal('KeyJ').value).toBe(true); + }); + + it('obs:resync 수신 시 모드 삼중 일치에서만 스냅샷 대조를 실행한다', async () => { + mocks.bootstrap.mockResolvedValue({ + activeKeys: ['KeyJ'], + selectedKeyType: '4key', + currentMode: '4key', + }); + + await act(async () => { + mocks.resyncListener?.(); + }); + await flushAsync(); + + expect(mocks.reconcileActiveNotes).toHaveBeenCalledTimes(1); + const [held] = mocks.reconcileActiveNotes.mock.calls[0] as [Set]; + expect(held.has('KeyJ')).toBe(true); + expect(held.has('KeyK')).toBe(false); + }); + + it('obs:resync 스냅샷의 모드가 어긋나면 대조를 건너뛴다', async () => { + mocks.bootstrap.mockResolvedValue({ + activeKeys: ['KeyJ'], + selectedKeyType: '4key', + currentMode: '8key', + }); + + await act(async () => { + mocks.resyncListener?.(); + }); + await flushAsync(); + + expect(mocks.reconcileActiveNotes).not.toHaveBeenCalled(); + }); + + it('중첩 대조에서 낡은 응답을 적용하지 않는다', async () => { + type Snapshot = { + activeKeys: string[]; + selectedKeyType: string; + currentMode: string; + }; + const first = deferred(); + const second = deferred(); + mocks.bootstrap + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + + await act(async () => { + mocks.resyncListener?.(); + }); + await act(async () => { + mocks.resyncListener?.(); + }); + + second.resolve({ + activeKeys: ['KeyJ'], + selectedKeyType: '4key', + currentMode: '4key', + }); + await flushAsync(); + expect(mocks.reconcileActiveNotes).toHaveBeenCalledTimes(1); + + first.resolve({ + activeKeys: [], + selectedKeyType: '4key', + currentMode: '4key', + }); + await flushAsync(); + // 첫 요청의 낡은 응답은 무시 - 최신 세대만 적용 + expect(mocks.reconcileActiveNotes).toHaveBeenCalledTimes(1); + const [held] = mocks.reconcileActiveNotes.mock.calls[0] as [Set]; + expect(held.has('KeyJ')).toBe(true); + }); + + it('keys:reset은 진행 중 대조를 무효화한다', async () => { + const pending = deferred<{ + activeKeys: string[]; + selectedKeyType: string; + currentMode: string; + }>(); + mocks.bootstrap.mockReturnValueOnce(pending.promise); + + await act(async () => { + mocks.resyncListener?.(); + }); + await act(async () => { + mocks.keysResetListener?.({ reason: 'hook_restart' }); + }); + + pending.resolve({ + activeKeys: ['KeyJ'], + selectedKeyType: '4key', + currentMode: '4key', + }); + await flushAsync(); + + // 리셋 이전 스냅샷은 적용되지 않아야 함 + expect(mocks.reconcileActiveNotes).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/windows/overlay/App.tsx b/src/renderer/windows/overlay/App.tsx index b6234104..78515149 100644 --- a/src/renderer/windows/overlay/App.tsx +++ b/src/renderer/windows/overlay/App.tsx @@ -14,6 +14,7 @@ import { useCustomJsInjection } from '@hooks/app/useCustomJsInjection'; import { useBlockBrowserShortcuts } from '@hooks/app/useBlockBrowserShortcuts'; import { useNoteSystem } from '@hooks/overlay/useNoteSystem'; import { useAppBootstrap } from '@hooks/app/useAppBootstrap'; +import { obsApi } from '@api/modules/obsApi'; import { useBuiltinStatsSubscription } from '@hooks/overlay/useBuiltinStatsSubscription'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; @@ -329,6 +330,7 @@ export default function App() { handleKeyDown, handleKeyUp, finalizeAllActive, + reconcileActiveNotes, noteBuffer, updateTrackLayouts, } = useNoteSystem({ @@ -380,6 +382,8 @@ export default function App() { selectedKeyType, handleKeyDown, handleKeyUp, + finalizeAllActive, + reconcileActiveNotes, }); useEffect(() => { keyEventContextRef.current = { @@ -389,11 +393,17 @@ export default function App() { selectedKeyType, handleKeyDown, handleKeyUp, + finalizeAllActive, + reconcileActiveNotes, }; }); // 리셋 이후 이벤트가 도착한 키 추적 - 스냅샷 재수화보다 최신 이벤트가 우선 const seenSinceResetRef = useRef>(new Set()); + // 대조(reconcile) fetch 이후 도착한 실이벤트 추적 - null이면 수집 안 함 + const reconcileSeenRef = useRef | null>(null); + // 중첩 대조의 낡은 응답 차단 - keys:reset·탭 전환·새 대조가 세대를 올림 + const reconcileGenerationRef = useRef(0); // 탭 전환 재수화가 구독 확립을 기다릴 수 있게 구독 준비 promise 보관 const keyEventsReadyRef = useRef | null>(null); @@ -434,8 +444,9 @@ export default function App() { async ({ keyEventBus }) => { if (hydrationCancelled) return undefined; const unsubscribeKeyEvents = keyEventBus.subscribe( - ({ key, state, eventAgeMs }) => { + ({ key, state, mode, eventAgeMs, holdDurationMs }) => { seenSinceResetRef.current.add(key); + reconcileSeenRef.current?.add(`${mode}::${key}`); const isDown = state === 'DOWN'; // 키 UI 업데이트 (딜레이 적용) updateKeySignalWithDelay(key, isDown); @@ -449,27 +460,31 @@ export default function App() { } = keyEventContextRef.current; // 노트 이펙트는 즉시 처리 (딜레이 없음) if (noteEffect) { - // 개별 키의 noteEffectEnabled 확인 - const currentKeys = keyMappings[selectedKeyType] ?? []; - const currentPositions = positions[selectedKeyType] ?? []; - const keyIndex = currentKeys.indexOf(key); - const keyPosition = currentPositions[keyIndex]; - const keyNoteEffectEnabled = - keyPosition?.noteEffectEnabled !== false; - - if (keyNoteEffectEnabled) { - // 실제 입력 시각을 복원해 노트 시작 위치를 보정 (프레임 양자화 방지). - // requestAnimationFrame 래핑 시 노트 생성 시각이 프레임 경계로 양자화돼 - // 주사율/OBS fps에 시간 해상도가 종속되던 문제 해결. - // age는 0~MAX_EVENT_AGE_MS로 clamp — 백엔드 stall/클럭 이상 시 노트가 - // 화면 위로 튀는 것을 방지 - const age = Math.min( - Math.max(eventAgeMs ?? 0, 0), - MAX_EVENT_AGE_MS, - ); - const inputTime = performance.now() - age; - if (isDown) handleKeyDown(key, inputTime); - else handleKeyUp(key, inputTime); + // 실제 입력 시각을 복원해 노트 시작 위치를 보정 (프레임 양자화 방지). + // displayTime은 0~MAX_EVENT_AGE_MS 클램프 - 백엔드 stall/클럭 이상 시 + // 노트가 화면 위로 튀는 것을 방지. physTime은 비클램프 - 단/롱 판정의 + // hold 폴백 계산 전용이라 클램프 절단 왜곡을 받지 않음 + const rawAge = Math.max(eventAgeMs ?? 0, 0); + const displayAge = Math.min(rawAge, MAX_EVENT_AGE_MS); + const now = performance.now(); + const timing = { + displayTime: now - displayAge, + physTime: now - rawAge, + holdDurationMs, + }; + + if (isDown) { + // 개별 키의 noteEffectEnabled는 DOWN에만 적용 + const currentKeys = keyMappings[selectedKeyType] ?? []; + const currentPositions = positions[selectedKeyType] ?? []; + const keyIndex = currentKeys.indexOf(key); + const keyPosition = currentPositions[keyIndex]; + if (keyPosition?.noteEffectEnabled !== false) { + handleKeyDown(key, timing); + } + } else { + // UP은 항상 전달 - DOWN 이후 설정이 꺼진 키의 활성 노트 고착 방지 + handleKeyUp(key, timing); } } }, @@ -515,10 +530,100 @@ export default function App() { console.error('Failed to initialize key state listener', error); }); + // UP 유실 복구 - fresh 스냅샷과 활성 노트·눌림 신호를 대조 (실패 복구 경로) + const reconcileWithBootstrap = async (): Promise => { + const generation = reconcileGenerationRef.current + 1; + reconcileGenerationRef.current = generation; + const sinceFetch = new Set(); + reconcileSeenRef.current = sinceFetch; + try { + const payload = await window.api.app.bootstrap(); + if (hydrationCancelled) return; + // 더 새로운 대조나 keys:reset·탭 전환이 끼었으면 이 응답은 낡음 + if (generation !== reconcileGenerationRef.current) return; + const { selectedKeyType, keyMappings, reconcileActiveNotes } = + keyEventContextRef.current; + // 모드 삼중 일치에서만 대조 - 비원자 스냅샷·낙관적 모드 전환 방어 + if ( + !payload.currentMode || + payload.currentMode !== payload.selectedKeyType || + payload.currentMode !== selectedKeyType + ) { + return; + } + const held = new Set(payload.activeKeys ?? []); + // fetch 이후 실이벤트가 도착한 키는 그 이벤트가 최신 - 대조에서 제외 + for (const entry of sinceFetch) { + const sep = entry.indexOf('::'); + if (sep < 0) continue; + if (entry.slice(0, sep) === payload.currentMode) { + held.add(entry.slice(sep + 2)); + } + } + reconcileActiveNotes(held); + // 고착된 눌림 하이라이트도 같은 기준으로 정정 + const validKeys = validKeySet(keyMappings[selectedKeyType] ?? []); + for (const key of validKeys) { + if (!sinceFetch.has(`${payload.currentMode}::${key}`)) { + setKeyActiveSignal(key, held.has(key)); + } + } + } catch (error) { + if (!hydrationCancelled) { + console.error('Failed to reconcile active notes', error); + } + } finally { + if (reconcileSeenRef.current === sinceFetch) { + reconcileSeenRef.current = null; + } + } + }; + + // OBS Lagged/재연결 스냅샷은 유실된 keys:state를 개별 복구하지 못하므로 대조로 정리 + const unsubscribeResync = obsApi.onResync(() => { + void reconcileWithBootstrap(); + }); + + // 키보드 훅 (재)시작 - 이전 눌림 상태가 통째로 무효화되므로 전체 리셋 후 재수화 + const unsubscribeKeysReset = window.api.keys.onKeysReset(() => { + // 진행 중인 대조의 낡은 스냅샷이 리셋 이후 상태를 덮지 못하게 무효화 + reconcileGenerationRef.current += 1; + const { finalizeAllActive } = keyEventContextRef.current; + finalizeAllActive(); + keyDelayTimersRef.current.forEach((timerEntry) => { + timerEntry.timers.forEach((timer) => clearTimeout(timer)); + timerEntry.timers.clear(); + }); + keyDelayTimersRef.current.clear(); + const seen = new Set(); + seenSinceResetRef.current = seen; + resetAllKeySignals(); + void window.api.app + .bootstrap() + .then(({ activeKeys }) => { + if (hydrationCancelled || !activeKeys?.length) return; + if (seenSinceResetRef.current !== seen) return; + const { keyMappings, selectedKeyType } = keyEventContextRef.current; + const validKeys = validKeySet(keyMappings[selectedKeyType] ?? []); + for (const key of activeKeys) { + if (validKeys.has(key) && !seen.has(key)) { + setKeyActiveSignal(key, true); + } + } + }) + .catch((error) => { + if (!hydrationCancelled) { + console.error('Failed to rehydrate after keys reset', error); + } + }); + }); + const keyDelayTimers = keyDelayTimersRef.current; return () => { hydrationCancelled = true; + unsubscribeResync(); + unsubscribeKeysReset(); void unsubscribe .then((unsub) => { try { @@ -552,6 +657,8 @@ export default function App() { keySignalResetArmedRef.current = true; return; } + // 탭 전환은 진행 중 대조의 스냅샷을 낡게 만든다 + reconcileGenerationRef.current += 1; let cancelled = false; keyDelayTimersRef.current.forEach((timerEntry) => { timerEntry.timers.forEach((timer) => clearTimeout(timer)); diff --git a/src/types/plugin/api.ts b/src/types/plugin/api.ts index f2905fcc..b14719f4 100644 --- a/src/types/plugin/api.ts +++ b/src/types/plugin/api.ts @@ -38,6 +38,12 @@ export type KeyStatePayload = { mode: string; /** 입력 수신~emit 경과 시간(ms). performance.now() - eventAgeMs로 실제 입력 시각 복원 */ eventAgeMs?: number; + /** UP 한정. 데몬이 입력 캡처 시점 기준으로 측정한 물리 눌림 지속 시간(ms) */ + holdDurationMs?: number; +}; +export type KeysResetPayload = { + /** 리셋 사유 (예: hook_restart) */ + reason: string; }; export type InputDevice = 'keyboard' | 'mouse' | 'gamepad' | 'unknown'; export type RawInputPayload = { @@ -925,6 +931,9 @@ export interface DMNoteAPI { ): Unsubscribe; onModeChanged(listener: (payload: ModeChangePayload) => void): Unsubscribe; onKeyState(listener: (payload: KeyStatePayload) => void): ReadyUnsubscribe; + onKeysReset( + listener: (payload: KeysResetPayload) => void, + ): ReadyUnsubscribe; onRawInput(listener: (payload: RawInputPayload) => void): Unsubscribe; resetCounters(): Promise; resetCountersMode(mode: string): Promise; From dadc8fbc2d37b3f6917154dd70e0ec7b8eaeb9cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sat, 25 Jul 2026 17:06:38 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EB=AA=A8=EB=8B=AC=EC=9D=84=20=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EC=96=BC=EB=A1=9C=EA=B7=B8=20=EA=B3=B5=ED=86=B5=20=EB=AC=B8?= =?UTF-8?q?=EB=B2=95=EC=9C=BC=EB=A1=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modal/content/dialogs/UpdateModal.tsx | 124 ++++++------------ src/renderer/locales/en.json | 1 + src/renderer/locales/ko.json | 1 + src/renderer/locales/ru.json | 1 + src/renderer/locales/zh-Hant.json | 1 + src/renderer/locales/zh-cn.json | 1 + 6 files changed, 42 insertions(+), 87 deletions(-) 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}
{/* 이 버전 건너뛰기 체크박스 */} -