Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/content/en/api-reference/keys/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand All @@ -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`

<Callout type="warning">Overlay window only.</Callout>
Expand Down
21 changes: 21 additions & 0 deletions docs/content/ko/api-reference/keys/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,9 @@ interface KeyStatePayload {
// 입력 수신~emit 경과 시간(ms).
// `performance.now() - eventAgeMs`로 실제 입력 시각 복원
eventAgeMs?: number;
// UP 이벤트 한정. 입력 데몬이 캡처 시점에 측정한 물리 눌림
// 지속 시간(ms) - 전달 지연·지터의 영향을 받지 않음
holdDurationMs?: number;
}
```

Expand All @@ -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 기기의 원시
Expand Down
56 changes: 56 additions & 0 deletions src-tauri/src/ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ pub struct HookMessage {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub flags: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub hold_duration_ms: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub input_ts_ms: Option<f64>,
}

#[repr(u8)]
Expand Down Expand Up @@ -144,3 +150,53 @@ pub fn pipe_client_connect(name: &str) -> anyhow::Result<std::fs::File> {
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);
}
}
186 changes: 125 additions & 61 deletions src-tauri/src/keyboard/daemon/macos.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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) => {
Expand All @@ -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<dyn Write + Send> = 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::<MacPhysicalInputId>::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:?}"))?;
Expand All @@ -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)
);
}
}
Loading
Loading