Rust · 5999 bytes Raw Blame History
1 //! claudex — GUI thread organizer for Claude Code.
2 //!
3 //! `lib.rs` is the Tauri façade. All real work lives in [`core`], which
4 //! is pure Rust and Tauri-free so it can be unit-tested in isolation.
5 //! [`commands`] is the thin IPC edge that hands state + types between
6 //! the webview and `core`.
7
8 pub mod commands;
9 pub mod core;
10
11 use std::path::PathBuf;
12 use std::sync::OnceLock;
13
14 use tauri::Manager;
15 use tracing_appender::non_blocking::WorkerGuard;
16 use tracing_subscriber::layer::SubscriberExt;
17 use tracing_subscriber::util::SubscriberInitExt;
18 use tracing_subscriber::Layer;
19
20 /// Holds the non-blocking appender's worker thread. Dropping the
21 /// guard flushes pending log writes, so we stash it in a static
22 /// `OnceLock` keyed to process lifetime.
23 static LOG_GUARD: OnceLock<WorkerGuard> = OnceLock::new();
24
25 /// Resolve the claudex log directory. macOS convention is
26 /// `~/Library/Logs/claudex/`. We create it eagerly so the file
27 /// appender has somewhere to write on first boot.
28 fn log_dir() -> PathBuf {
29 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
30 let dir = home.join("Library").join("Logs").join("claudex");
31 let _ = std::fs::create_dir_all(&dir);
32 dir
33 }
34
35 /// Two-layer tracing setup: one `fmt::Layer` writing ANSI to stdout
36 /// for `pnpm tauri dev` users, and one writing plain text to a
37 /// daily-rotated file under `~/Library/Logs/claudex/claudex.log`.
38 /// Both layers share the same env filter (default
39 /// `claudex=debug,warn`) so silencing works from either direction.
40 fn init_logging() {
41 let filter = tracing_subscriber::EnvFilter::try_from_default_env()
42 .unwrap_or_else(|_| "claudex=debug,warn".into());
43
44 let file_appender = tracing_appender::rolling::daily(log_dir(), "claudex.log");
45 let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
46 // Keep the worker alive for the process lifetime.
47 let _ = LOG_GUARD.set(guard);
48
49 let stdout_layer = tracing_subscriber::fmt::layer()
50 .with_writer(std::io::stdout)
51 .with_target(true)
52 .with_filter(filter);
53
54 let file_filter = tracing_subscriber::EnvFilter::try_from_default_env()
55 .unwrap_or_else(|_| "claudex=debug,warn".into());
56 let file_layer = tracing_subscriber::fmt::layer()
57 .with_writer(non_blocking)
58 .with_ansi(false)
59 .with_target(true)
60 .with_filter(file_filter);
61
62 tracing_subscriber::registry()
63 .with(stdout_layer)
64 .with(file_layer)
65 .init();
66
67 tracing::info!(log_dir = %log_dir().display(), "claudex log file ready");
68 }
69
70 #[cfg_attr(mobile, tauri::mobile_entry_point)]
71 pub fn run() {
72 init_logging();
73
74 tauri::Builder::default()
75 .setup(|app| {
76 tracing::info!("claudex starting");
77 let state = commands::initialize(app.handle())
78 .map_err(|e| format!("initialize failed: {e}"))?;
79 app.manage(state);
80 Ok(())
81 })
82 .on_window_event(|window, event| {
83 match event {
84 tauri::WindowEvent::Destroyed => {
85 // Best-effort cache flush + reap every live PTY so
86 // we don't leave zombie claude subprocesses running
87 // after a hard app quit.
88 if let Some(state) =
89 window.app_handle().try_state::<commands::AppState>()
90 {
91 commands::persist_cache_on_exit(&state);
92 commands::shutdown_active_ptys(&state);
93 }
94 }
95 tauri::WindowEvent::Resized(_) => {
96 // macOS moves the window when an external monitor
97 // disconnects. If the new position is fully
98 // off-screen the user can't see it. Re-center as
99 // a safety net.
100 if let Ok(pos) = window.outer_position() {
101 if let Ok(size) = window.outer_size() {
102 // Heuristic: if the window's left+width or
103 // top+height places it entirely off-screen
104 // (negative right edge or top below a
105 // reasonable bound), re-center it. We
106 // can't easily enumerate monitors from
107 // Tauri, so check against reasonable
108 // bounds: right edge < 0 OR left edge >
109 // 6000 OR top edge > 4000 OR bottom < 0.
110 let right = pos.x + size.width as i32;
111 let bottom = pos.y + size.height as i32;
112 if right < 0
113 || pos.x > 6000
114 || bottom < 0
115 || pos.y > 4000
116 {
117 tracing::info!(
118 x = pos.x,
119 y = pos.y,
120 "window off-screen after resize, re-centering"
121 );
122 let _ = window.center();
123 }
124 }
125 }
126 }
127 _ => {}
128 }
129 })
130 .invoke_handler(tauri::generate_handler![
131 commands::list_projects,
132 commands::list_sessions,
133 commands::read_session,
134 commands::rescan,
135 commands::start_turn,
136 commands::cancel_turn,
137 commands::list_active_turns,
138 commands::spawn_pty,
139 commands::write_pty,
140 commands::resize_pty,
141 commands::close_pty,
142 commands::get_pty_buffer,
143 commands::list_ptys,
144 commands::log_frontend,
145 ])
146 .run(tauri::generate_context!())
147 .expect("error while running claudex");
148 }
149