| 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 |