Skip to content

Commit 0bd3e26

Browse files
committed
fix(cli): run shortcut event tap on main thread via shortcut-macos
Signed-off-by: Yujong Lee <yujonglee.dev@gmail.com>
1 parent 6e09211 commit 0bd3e26

5 files changed

Lines changed: 190 additions & 156 deletions

File tree

apps/cli/src/commands/shortcut/daemon.rs

Lines changed: 114 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,7 @@ use tokio_stream::StreamExt;
1111

1212
use crate::error::{CliError, CliResult};
1313

14-
use super::hotkey::{self, HotkeyError, HotkeyEvent};
15-
16-
enum DaemonEvent {
17-
Hotkey(HotkeyEvent),
18-
HotkeyFailure(HotkeyError),
19-
UiAction(UiAction),
20-
}
14+
use super::hotkey::{self, HotkeyEvent};
2115

2216
enum UiAction {
2317
Cancel,
@@ -27,123 +21,153 @@ enum UiAction {
2721
const SAMPLE_RATE: u32 = 16_000;
2822
const LEVEL_TICK: Duration = Duration::from_millis(100);
2923

30-
pub async fn run() -> CliResult<()> {
24+
pub fn run_blocking() -> CliResult<()> {
3125
tracing::info!("Shortcut daemon starting");
3226

3327
let ui_binary = resolve_ui_binary()?;
3428
tracing::info!(path = %ui_binary.display(), "UI binary resolved");
3529

30+
let (hotkey_tx, hotkey_rx) = tokio::sync::mpsc::unbounded_channel::<HotkeyEvent>();
31+
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
32+
let worker = std::thread::spawn(move || worker_main(ui_binary, hotkey_rx, shutdown_rx));
33+
34+
let listener_result = hotkey::run_listener_on_main_thread(hotkey_tx)
35+
.map_err(|error| CliError::operation_failed("start hotkey listener", error.message()));
36+
37+
let _ = shutdown_tx.send(true);
38+
match worker.join() {
39+
Ok(Ok(())) => {}
40+
Ok(Err(error)) => {
41+
if listener_result.is_ok() {
42+
return Err(error);
43+
}
44+
}
45+
Err(_) => {
46+
if listener_result.is_ok() {
47+
return Err(CliError::operation_failed(
48+
"shortcut daemon worker",
49+
"worker thread panicked",
50+
));
51+
}
52+
}
53+
}
54+
55+
listener_result
56+
}
57+
58+
pub async fn run() -> CliResult<()> {
59+
Err(CliError::operation_failed(
60+
"shortcut daemon",
61+
"must be started directly on the process main thread",
62+
))
63+
}
64+
65+
fn worker_main(
66+
ui_binary: PathBuf,
67+
hotkey_rx: tokio::sync::mpsc::UnboundedReceiver<HotkeyEvent>,
68+
shutdown_rx: tokio::sync::watch::Receiver<bool>,
69+
) -> CliResult<()> {
70+
let runtime = tokio::runtime::Builder::new_current_thread()
71+
.enable_all()
72+
.build()
73+
.map_err(|e| CliError::operation_failed("build shortcut runtime", e.to_string()))?;
74+
runtime.block_on(worker_loop(ui_binary, hotkey_rx, shutdown_rx))
75+
}
76+
77+
async fn worker_loop(
78+
ui_binary: PathBuf,
79+
mut hotkey_rx: tokio::sync::mpsc::UnboundedReceiver<HotkeyEvent>,
80+
shutdown_rx: tokio::sync::watch::Receiver<bool>,
81+
) -> CliResult<()> {
3682
let audio = ActualAudio;
3783
let chunk_size = chunk_size_for_stt(SAMPLE_RATE);
38-
39-
let listener = hotkey::listen()
40-
.map_err(|error| CliError::operation_failed("start hotkey listener", error.message()))?;
41-
let mut hotkey_rx = listener.events;
42-
let mut hotkey_failure_rx = listener.failures;
4384
let (ui_tx, mut ui_rx) = tokio::sync::mpsc::unbounded_channel::<UiAction>();
4485
let mut ui_process: Option<UiProcess> = None;
86+
let mut shutdown_rx = shutdown_rx;
4587

4688
loop {
47-
let event = tokio::select! {
48-
Some(hk) = hotkey_rx.recv() => DaemonEvent::Hotkey(hk),
49-
Some(error) = hotkey_failure_rx.recv() => DaemonEvent::HotkeyFailure(error),
50-
Some(action) = ui_rx.recv() => DaemonEvent::UiAction(action),
51-
else => DaemonEvent::HotkeyFailure(hotkey::HotkeyError::internal("Hotkey listener exited unexpectedly.")),
52-
};
53-
54-
match event {
55-
DaemonEvent::Hotkey(HotkeyEvent::RecordStart) => {
56-
tracing::info!("Hotkey: record start");
57-
58-
if let Some(mut proc) = ui_process.take() {
59-
proc.dismiss();
60-
}
61-
62-
match UiProcess::spawn(&ui_binary, ui_tx.clone()) {
63-
Ok(proc) => ui_process = Some(proc),
64-
Err(e) => {
65-
tracing::error!("Failed to spawn UI: {e}");
66-
continue;
89+
tokio::select! {
90+
changed = shutdown_rx.changed() => {
91+
if changed.is_err() || *shutdown_rx.borrow() {
92+
if let Some(mut proc) = ui_process.take() {
93+
proc.dismiss();
6794
}
95+
return Ok(());
6896
}
97+
}
98+
Some(hk) = hotkey_rx.recv() => {
99+
match hk {
100+
HotkeyEvent::RecordStart => {
101+
tracing::info!("Hotkey: record start");
102+
103+
if let Some(mut proc) = ui_process.take() {
104+
proc.dismiss();
105+
}
69106

70-
let stream = audio.open_mic_capture(None, SAMPLE_RATE, chunk_size);
71-
match stream {
72-
Ok(stream) => {
73-
if let Some(listener_health) = outcome_to_health(
74-
run_capture(
75-
stream,
76-
ui_process.as_mut().unwrap(),
77-
&mut hotkey_rx,
78-
&mut hotkey_failure_rx,
79-
&mut ui_rx,
80-
)
81-
.await,
82-
) {
83-
if let Some(mut proc) = ui_process.take() {
84-
proc.dismiss();
107+
match UiProcess::spawn(&ui_binary, ui_tx.clone()) {
108+
Ok(proc) => ui_process = Some(proc),
109+
Err(e) => {
110+
tracing::error!("Failed to spawn UI: {e}");
111+
continue;
85112
}
86-
return Err(CliError::operation_failed(
87-
"shortcut daemon",
88-
listener_health.message(),
89-
));
90113
}
91-
}
92-
Err(e) => {
93-
tracing::error!("Failed to open mic capture: {e}");
94-
}
95-
}
96114

97-
if let Some(mut proc) = ui_process.take() {
98-
proc.dismiss();
99-
}
115+
let stream = audio.open_mic_capture(None, SAMPLE_RATE, chunk_size);
116+
match stream {
117+
Ok(stream) => {
118+
run_capture(
119+
stream,
120+
ui_process.as_mut().unwrap(),
121+
&mut hotkey_rx,
122+
&mut shutdown_rx,
123+
&mut ui_rx,
124+
)
125+
.await;
126+
}
127+
Err(e) => {
128+
tracing::error!("Failed to open mic capture: {e}");
129+
}
130+
}
100131

101-
// TODO: transcribe, copy to clipboard (separate PR)
102-
}
103-
DaemonEvent::HotkeyFailure(listener_error) => {
104-
if let Some(mut proc) = ui_process.take() {
105-
proc.dismiss();
132+
if let Some(mut proc) = ui_process.take() {
133+
proc.dismiss();
134+
}
135+
}
136+
HotkeyEvent::RecordStop => {
137+
tracing::info!("Recording stopped (no active capture)");
138+
if let Some(mut proc) = ui_process.take() {
139+
proc.dismiss();
140+
}
141+
}
106142
}
107-
return Err(CliError::operation_failed(
108-
"shortcut daemon",
109-
listener_error.message(),
110-
));
111143
}
112-
DaemonEvent::Hotkey(HotkeyEvent::RecordStop)
113-
| DaemonEvent::UiAction(UiAction::Cancel)
114-
| DaemonEvent::UiAction(UiAction::Stop) => {
115-
tracing::info!("Recording stopped (no active capture)");
116-
144+
else => {
117145
if let Some(mut proc) = ui_process.take() {
118146
proc.dismiss();
119147
}
148+
return Ok(());
120149
}
121150
}
122151
}
123152
}
124153

125-
enum CaptureOutcome {
126-
Finished,
127-
ListenerLost(HotkeyError),
128-
}
129-
130154
async fn run_capture(
131155
stream: hypr_audio::CaptureStream,
132156
ui: &mut UiProcess,
133157
hotkey_rx: &mut tokio::sync::mpsc::UnboundedReceiver<HotkeyEvent>,
134-
hotkey_failure_rx: &mut tokio::sync::mpsc::UnboundedReceiver<HotkeyError>,
158+
shutdown_rx: &mut tokio::sync::watch::Receiver<bool>,
135159
ui_rx: &mut tokio::sync::mpsc::UnboundedReceiver<UiAction>,
136-
) -> CaptureOutcome {
160+
) {
137161
let mut stream = pin!(stream);
138162
let mut last_level = Instant::now() - LEVEL_TICK;
139163

140164
loop {
141165
tokio::select! {
142166
frame = stream.next() => {
143-
let Some(result) = frame else { return CaptureOutcome::Finished };
167+
let Some(result) = frame else { return; };
144168
let Ok(frame) = result else {
145169
tracing::error!("Audio capture error");
146-
return CaptureOutcome::Finished;
170+
return;
147171
};
148172

149173
let now = Instant::now();
@@ -157,20 +181,22 @@ async fn run_capture(
157181
Some(hk) = hotkey_rx.recv() => {
158182
if matches!(hk, HotkeyEvent::RecordStop) {
159183
tracing::info!("Hotkey: record stop");
160-
return CaptureOutcome::Finished;
184+
return;
161185
}
162186
}
163-
Some(listener_error) = hotkey_failure_rx.recv() => {
164-
return CaptureOutcome::ListenerLost(listener_error);
187+
changed = shutdown_rx.changed() => {
188+
if changed.is_err() || *shutdown_rx.borrow() {
189+
return;
190+
}
165191
}
166192
Some(action) = ui_rx.recv() => {
167193
tracing::info!("UI action: {:?}", match &action {
168194
UiAction::Cancel => "cancel",
169195
UiAction::Stop => "stop",
170196
});
171-
return CaptureOutcome::Finished;
197+
return;
172198
}
173-
else => return CaptureOutcome::ListenerLost(hotkey::HotkeyError::internal("Hotkey listener exited unexpectedly.")),
199+
else => return,
174200
}
175201
}
176202
}
@@ -287,10 +313,3 @@ fn parse_ui_action(line: &str) -> Option<UiAction> {
287313
_ => None,
288314
}
289315
}
290-
291-
fn outcome_to_health(outcome: CaptureOutcome) -> Option<HotkeyError> {
292-
match outcome {
293-
CaptureOutcome::Finished => None,
294-
CaptureOutcome::ListenerLost(health) => Some(health),
295-
}
296-
}
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
pub(crate) use hypr_shortcut_macos::{
2-
ShortcutError as HotkeyError, ShortcutErrorKind as HotkeyErrorKind,
3-
ShortcutEvent as HotkeyEvent, current_blocker, input_monitoring_granted, listen,
2+
ShortcutErrorKind as HotkeyErrorKind, ShortcutEvent as HotkeyEvent, current_blocker,
3+
input_monitoring_granted, run_listener_on_main_thread,
44
};

apps/cli/src/main.rs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,8 @@ use crate::cli::{Cli, Commands};
1212
use crate::error::CliResult;
1313
use clap::Parser;
1414

15-
#[tokio::main]
1615
#[allow(clippy::let_unit_value)]
17-
async fn main() {
16+
fn main() {
1817
let cli = Cli::parse();
1918

2019
if cli.no_color || std::env::var_os("NO_COLOR").is_some() {
@@ -23,7 +22,27 @@ async fn main() {
2322

2423
let trace_buffer = init_tracing(&cli);
2524

26-
if let Err(error) = run(cli, trace_buffer).await {
25+
#[cfg(all(feature = "standalone", target_os = "macos"))]
26+
let result = if matches!(&cli.command, Some(Commands::ShortcutDaemon)) {
27+
crate::commands::shortcut::daemon::run_blocking()
28+
} else {
29+
let runtime = tokio::runtime::Builder::new_multi_thread()
30+
.enable_all()
31+
.build()
32+
.expect("failed to build tokio runtime");
33+
runtime.block_on(run(cli, trace_buffer))
34+
};
35+
36+
#[cfg(not(all(feature = "standalone", target_os = "macos")))]
37+
let result = {
38+
let runtime = tokio::runtime::Builder::new_multi_thread()
39+
.enable_all()
40+
.build()
41+
.expect("failed to build tokio runtime");
42+
runtime.block_on(run(cli, trace_buffer))
43+
};
44+
45+
if let Err(error) = result {
2746
eprintln!("error: {error}");
2847
std::process::exit(1);
2948
}

0 commit comments

Comments
 (0)