@@ -11,13 +11,7 @@ use tokio_stream::StreamExt;
1111
1212use 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
2216enum UiAction {
2317 Cancel ,
@@ -27,123 +21,153 @@ enum UiAction {
2721const SAMPLE_RATE : u32 = 16_000 ;
2822const 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-
130154async 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- }
0 commit comments