From cb037aae64a37b842396ad8892bc95239bcf8b56 Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Mon, 9 Feb 2026 14:35:49 +0100 Subject: [PATCH 01/34] chore(release): bump Cargo versions to 0.7.47 in release flow --- .github/workflows/release.yml | 36 ++++++++++++++++++++++++++++++++++- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1bdcf41d96..3c47b4af29 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -634,6 +634,40 @@ jobs: path.write_text(json.dumps(data, indent=2) + "\n") PY + python3 - <$NEXT_VERSION\2', + content, + count=1, + ) + if count != 1: + raise SystemExit("Failed to update codex-monitor version in src-tauri/Cargo.lock") + path.write_text(content) + PY + python3 - < Date: Mon, 9 Feb 2026 15:06:56 +0100 Subject: [PATCH 02/34] feat(daemon): add identity/version checks and exit persistence toggle --- .../gen/apple/codex-monitor_iOS/Info.plist | 4 +- src-tauri/src/bin/codex_monitor_daemon.rs | 53 +++++ src-tauri/src/bin/codex_monitor_daemon/rpc.rs | 1 + src-tauri/src/lib.rs | 114 +++++++++- src-tauri/src/orbit/mod.rs | 167 +++++++++++++- src-tauri/src/settings/mod.rs | 23 +- src-tauri/src/state.rs | 2 + src-tauri/src/tailscale/daemon_commands.rs | 209 ++++++++++++++++-- src-tauri/src/tailscale/rpc_client.rs | 70 +++++- src-tauri/src/types.rs | 4 + .../settings/components/SettingsView.test.tsx | 1 + .../sections/SettingsServerSection.tsx | 24 ++ src/features/settings/hooks/useAppSettings.ts | 1 + src/types.ts | 1 + 14 files changed, 654 insertions(+), 20 deletions(-) diff --git a/src-tauri/gen/apple/codex-monitor_iOS/Info.plist b/src-tauri/gen/apple/codex-monitor_iOS/Info.plist index f16c2721b9..c7b7b575ea 100644 --- a/src-tauri/gen/apple/codex-monitor_iOS/Info.plist +++ b/src-tauri/gen/apple/codex-monitor_iOS/Info.plist @@ -17,7 +17,7 @@ CFBundleShortVersionString 0.7.47 CFBundleVersion - 0.7.47 + 0.7.47.1 LSRequiresIPhoneOS UILaunchStoryboardName @@ -49,4 +49,4 @@ NSMicrophoneUsageDescription Allow access to the microphone for dictation. - + \ No newline at end of file diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 96334fc5a3..c5286d0840 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -99,6 +99,7 @@ use workspace_settings::apply_workspace_settings_update; const DEFAULT_LISTEN_ADDR: &str = "127.0.0.1:4732"; const MAX_IN_FLIGHT_RPC_PER_CONNECTION: usize = 32; +const DAEMON_NAME: &str = "codex-monitor-daemon"; fn spawn_with_client( event_sink: DaemonEventSink, @@ -165,6 +166,8 @@ struct DaemonState { app_settings: Mutex, event_sink: DaemonEventSink, codex_login_cancels: Mutex>, + daemon_mode: String, + daemon_binary_path: Option, } #[derive(Serialize, Deserialize)] @@ -179,6 +182,14 @@ impl DaemonState { let settings_path = config.data_dir.join("settings.json"); let workspaces = read_workspaces(&storage_path).unwrap_or_default(); let app_settings = read_settings(&settings_path).unwrap_or_default(); + let daemon_mode = if config.orbit_url.is_some() { + "orbit".to_string() + } else { + "tcp".to_string() + }; + let daemon_binary_path = std::env::current_exe() + .ok() + .and_then(|path| path.to_str().map(str::to_string)); Self { data_dir: config.data_dir.clone(), workspaces: Mutex::new(workspaces), @@ -188,9 +199,21 @@ impl DaemonState { app_settings: Mutex::new(app_settings), event_sink, codex_login_cancels: Mutex::new(HashMap::new()), + daemon_mode, + daemon_binary_path, } } + fn daemon_info(&self) -> Value { + json!({ + "name": DAEMON_NAME, + "version": env!("CARGO_PKG_VERSION"), + "pid": std::process::id(), + "mode": self.daemon_mode, + "binaryPath": self.daemon_binary_path, + }) + } + async fn list_workspaces(&self) -> Vec { workspaces_core::list_workspaces_core(&self.workspaces, &self.sessions).await } @@ -1503,6 +1526,8 @@ mod tests { app_settings: Mutex::new(AppSettings::default()), event_sink: DaemonEventSink { tx }, codex_login_cancels: Mutex::new(HashMap::new()), + daemon_mode: "tcp".to_string(), + daemon_binary_path: Some("/tmp/codex-monitor-daemon".to_string()), } } @@ -1609,6 +1634,34 @@ mod tests { let _ = std::fs::remove_dir_all(&tmp); }); } + + #[test] + fn rpc_daemon_info_reports_identity() { + run_async_test(async { + let tmp = make_temp_dir("rpc-daemon-info"); + let state = test_state(&tmp); + + let result = rpc::handle_rpc_request( + &state, + "daemon_info", + json!({}), + "daemon-test".to_string(), + ) + .await + .expect("daemon_info should succeed"); + + assert_eq!( + result.get("name").and_then(Value::as_str), + Some(DAEMON_NAME) + ); + assert_eq!(result.get("mode").and_then(Value::as_str), Some("tcp")); + assert_eq!( + result.get("version").and_then(Value::as_str), + Some(env!("CARGO_PKG_VERSION")) + ); + let _ = std::fs::remove_dir_all(&tmp); + }); + } } fn main() { diff --git a/src-tauri/src/bin/codex_monitor_daemon/rpc.rs b/src-tauri/src/bin/codex_monitor_daemon/rpc.rs index 7415fcea69..e908d63eff 100644 --- a/src-tauri/src/bin/codex_monitor_daemon/rpc.rs +++ b/src-tauri/src/bin/codex_monitor_daemon/rpc.rs @@ -151,6 +151,7 @@ pub(super) async fn handle_rpc_request( ) -> Result { match method { "ping" => Ok(json!({ "ok": true })), + "daemon_info" => Ok(state.daemon_info()), "daemon_shutdown" => { tokio::spawn(async { sleep(Duration::from_millis(100)).await; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5bbb4c868d..3e9f43976e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,10 @@ +#[cfg(desktop)] +use std::sync::atomic::{AtomicBool, Ordering}; use tauri::Manager; +#[cfg(desktop)] +use tauri::RunEvent; #[cfg(target_os = "macos")] -use tauri::{RunEvent, WindowEvent}; +use tauri::WindowEvent; mod backend; mod codex; @@ -36,6 +40,29 @@ mod utils; mod window; mod workspaces; +#[cfg(desktop)] +static EXIT_CLEANUP_IN_PROGRESS: AtomicBool = AtomicBool::new(false); + +#[cfg(desktop)] +fn keep_daemon_running_after_close(app_handle: &tauri::AppHandle) -> bool { + let state = app_handle.state::(); + tauri::async_runtime::block_on(async { + state + .app_settings + .lock() + .await + .keep_daemon_running_after_app_close + }) +} + +#[cfg(desktop)] +async fn stop_managed_daemons_for_exit(app_handle: tauri::AppHandle) { + let state = app_handle.state::(); + let _ = orbit::orbit_runner_stop(state).await; + let state = app_handle.state::(); + let _ = tailscale::tailscale_daemon_stop(state).await; +} + #[tauri::command] fn is_mobile_runtime() -> bool { cfg!(any(target_os = "ios", target_os = "android")) @@ -79,6 +106,75 @@ pub fn run() { .setup(|app| { let state = state::AppState::load(&app.handle()); app.manage(state); + #[cfg(desktop)] + { + let app_handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + let state = app_handle.state::(); + let settings = state.app_settings.lock().await.clone(); + if matches!( + settings.remote_backend_provider, + crate::types::RemoteBackendProvider::Tcp + ) { + if matches!(settings.backend_mode, crate::types::BackendMode::Remote) { + // Remote mode: ensure daemon is up and version-current. + let state = app_handle.state::(); + let _ = tailscale::tailscale_daemon_start(state).await; + } else { + // Local mode: only enforce version if daemon is already running. + let state = app_handle.state::(); + if let Ok(status) = tailscale::tailscale_daemon_status(state).await { + if matches!(status.state, crate::types::TcpDaemonState::Running) { + let state = app_handle.state::(); + let _ = tailscale::tailscale_daemon_start(state).await; + } + } + } + } + + if matches!(settings.backend_mode, crate::types::BackendMode::Remote) + && matches!( + settings.remote_backend_provider, + crate::types::RemoteBackendProvider::Orbit + ) + { + if settings.orbit_auto_start_runner { + if settings.keep_daemon_running_after_app_close { + // Avoid duplicate detached Orbit runners across relaunches. + // orbit_runner_start can still be called manually from Settings. + let state = app_handle.state::(); + let _ = orbit::orbit_runner_status(state).await; + } else { + let state = app_handle.state::(); + let _ = orbit::orbit_runner_start(state).await; + } + } else { + let state = app_handle.state::(); + if let Ok(status) = orbit::orbit_runner_status(state).await { + if matches!(status.state, crate::types::OrbitRunnerState::Running) { + // Enforce version for a currently running managed runner. + let state = app_handle.state::(); + let _ = orbit::orbit_runner_start(state).await; + } + } + } + } else if matches!( + settings.remote_backend_provider, + crate::types::RemoteBackendProvider::Orbit + ) { + // Local mode with Orbit selected: only enforce version if runner is already running. + let state = app_handle.state::(); + if let Ok(status) = orbit::orbit_runner_status(state).await { + if matches!(status.state, crate::types::OrbitRunnerState::Running) + && !settings.keep_daemon_running_after_app_close + { + let state = app_handle.state::(); + let _ = orbit::orbit_runner_start(state).await; + } + } + } + }); + } #[cfg(target_os = "ios")] { if let Some(main_webview) = app.get_webview_window("main") { @@ -219,6 +315,22 @@ pub fn run() { .expect("error while running tauri application"); app.run(|app_handle, event| { + #[cfg(desktop)] + if let RunEvent::ExitRequested { api, .. } = event { + if !EXIT_CLEANUP_IN_PROGRESS.load(Ordering::SeqCst) + && !keep_daemon_running_after_close(app_handle) + { + api.prevent_exit(); + EXIT_CLEANUP_IN_PROGRESS.store(true, Ordering::SeqCst); + let app_handle = app_handle.clone(); + tauri::async_runtime::spawn(async move { + stop_managed_daemons_for_exit(app_handle.clone()).await; + app_handle.exit(0); + }); + } + return; + } + #[cfg(target_os = "macos")] if let RunEvent::Reopen { .. } = event { if let Some(window) = app_handle.get_webview_window("main") { diff --git a/src-tauri/src/orbit/mod.rs b/src-tauri/src/orbit/mod.rs index b6a976b607..5e29be26b5 100644 --- a/src-tauri/src/orbit/mod.rs +++ b/src-tauri/src/orbit/mod.rs @@ -1,7 +1,9 @@ use std::process::Stdio; use std::time::{SystemTime, UNIX_EPOCH}; +use serde::{Deserialize, Serialize}; use tauri::State; +use tokio::fs; use crate::daemon_binary::resolve_daemon_binary_path; use crate::shared::orbit_core; @@ -13,6 +15,82 @@ use crate::types::{ OrbitSignInStatus, OrbitSignOutResult, }; +const CURRENT_APP_VERSION: &str = env!("CARGO_PKG_VERSION"); +const ORBIT_RUNNER_RECORD_FILE: &str = "orbit_runner.json"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct OrbitRunnerRecord { + pid: u32, + version: String, + orbit_url: Option, + started_at_ms: Option, +} + +fn orbit_runner_record_path(state: &AppState) -> Option { + state + .settings_path + .parent() + .map(|parent| parent.join(ORBIT_RUNNER_RECORD_FILE)) +} + +async fn load_orbit_runner_record(state: &AppState) -> Option { + let path = orbit_runner_record_path(state)?; + let payload = fs::read(path).await.ok()?; + serde_json::from_slice(&payload).ok() +} + +async fn save_orbit_runner_record(state: &AppState, record: &OrbitRunnerRecord) { + let Some(path) = orbit_runner_record_path(state) else { + return; + }; + let Ok(payload) = serde_json::to_vec(record) else { + return; + }; + let _ = fs::write(path, payload).await; +} + +async fn clear_orbit_runner_record(state: &AppState) { + let Some(path) = orbit_runner_record_path(state) else { + return; + }; + let _ = fs::remove_file(path).await; +} + +#[cfg(unix)] +async fn is_pid_running(pid: u32) -> bool { + let result = unsafe { libc::kill(pid as i32, 0) }; + if result == 0 { + return true; + } + match std::io::Error::last_os_error().raw_os_error() { + Some(code) => code != libc::ESRCH, + None => false, + } +} + +#[cfg(windows)] +async fn is_pid_running(pid: u32) -> bool { + let output = match tokio_command("tasklist") + .args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"]) + .output() + .await + { + Ok(output) => output, + Err(_) => return false, + }; + if !output.status.success() { + return false; + } + let stdout = String::from_utf8_lossy(&output.stdout); + stdout.lines().any(|line| line.contains(&format!("\"{pid}\""))) +} + +#[cfg(not(any(unix, windows)))] +async fn is_pid_running(_pid: u32) -> bool { + false +} + fn now_unix_ms() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -24,6 +102,7 @@ async fn refresh_runner_runtime(runtime: &mut OrbitRunnerRuntime) { let Some(child) = runtime.child.as_mut() else { runtime.status.state = OrbitRunnerState::Stopped; runtime.status.pid = None; + runtime.managed_version = None; return; }; @@ -48,6 +127,7 @@ async fn refresh_runner_runtime(runtime: &mut OrbitRunnerRuntime) { orbit_url: runtime.status.orbit_url.clone(), }; } + runtime.managed_version = None; } Ok(None) => { runtime.status.state = OrbitRunnerState::Running; @@ -62,6 +142,7 @@ async fn refresh_runner_runtime(runtime: &mut OrbitRunnerRuntime) { last_error: Some(format!("Failed to inspect runner process: {err}")), orbit_url: runtime.status.orbit_url.clone(), }; + runtime.managed_version = None; } } } @@ -155,10 +236,63 @@ pub(crate) async fn orbit_runner_start( .map(|path| path.to_path_buf()) .ok_or_else(|| "Unable to resolve app data directory".to_string())?; + let persisted_runner = load_orbit_runner_record(&state).await; + let mut runtime = state.orbit_runner.lock().await; refresh_runner_runtime(&mut runtime).await; if matches!(runtime.status.state, OrbitRunnerState::Running) { - return Ok(runtime.status.clone()); + if runtime.managed_version.as_deref() == Some(CURRENT_APP_VERSION) { + return Ok(runtime.status.clone()); + } + + if runtime.child.is_none() { + let pid_display = runtime + .status + .pid + .map(|pid| pid.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let message = format!( + "Orbit runner (pid {pid_display}) is already running outside this app process. Stop it first to avoid duplicate runners." + ); + runtime.status.last_error = Some(message.clone()); + return Err(message); + } + + if let Some(mut child) = runtime.child.take() { + kill_child_process_tree(&mut child).await; + let _ = child.wait().await; + } + runtime.status = OrbitRunnerStatus { + state: OrbitRunnerState::Stopped, + pid: None, + started_at_ms: None, + last_error: None, + orbit_url: runtime.status.orbit_url.clone(), + }; + runtime.managed_version = None; + } + + if let Some(record) = persisted_runner { + if is_pid_running(record.pid).await { + runtime.status = OrbitRunnerStatus { + state: OrbitRunnerState::Running, + pid: Some(record.pid), + started_at_ms: record.started_at_ms, + last_error: None, + orbit_url: record.orbit_url.or_else(|| Some(ws_url.clone())), + }; + runtime.managed_version = Some(record.version.clone()); + if record.version == CURRENT_APP_VERSION { + return Ok(runtime.status.clone()); + } + let message = format!( + "Orbit runner version {} does not match app version {}. Stop the existing runner before starting a new one.", + record.version, CURRENT_APP_VERSION + ); + runtime.status.last_error = Some(message.clone()); + return Err(message); + } + clear_orbit_runner_record(&state).await; } let mut command = tokio_command(&daemon_binary); @@ -210,6 +344,19 @@ pub(crate) async fn orbit_runner_start( orbit_url: Some(ws_url), }; runtime.child = Some(child); + runtime.managed_version = Some(CURRENT_APP_VERSION.to_string()); + if let Some(pid) = runtime.status.pid { + save_orbit_runner_record( + &state, + &OrbitRunnerRecord { + pid, + version: CURRENT_APP_VERSION.to_string(), + orbit_url: runtime.status.orbit_url.clone(), + started_at_ms: runtime.status.started_at_ms, + }, + ) + .await; + } Ok(runtime.status.clone()) } @@ -222,6 +369,7 @@ pub(crate) async fn orbit_runner_stop( if let Some(mut child) = runtime.child.take() { kill_child_process_tree(&mut child).await; let _ = child.wait().await; + clear_orbit_runner_record(&state).await; } runtime.status = OrbitRunnerStatus { @@ -231,6 +379,7 @@ pub(crate) async fn orbit_runner_stop( last_error: None, orbit_url: runtime.status.orbit_url.clone(), }; + runtime.managed_version = None; Ok(runtime.status.clone()) } @@ -248,6 +397,22 @@ pub(crate) async fn orbit_runner_status( let mut runtime = state.orbit_runner.lock().await; refresh_runner_runtime(&mut runtime).await; + if !matches!(runtime.status.state, OrbitRunnerState::Running) { + if let Some(record) = load_orbit_runner_record(&state).await { + if is_pid_running(record.pid).await { + runtime.status = OrbitRunnerStatus { + state: OrbitRunnerState::Running, + pid: Some(record.pid), + started_at_ms: record.started_at_ms, + last_error: None, + orbit_url: record.orbit_url.clone(), + }; + runtime.managed_version = Some(record.version); + } else { + clear_orbit_runner_record(&state).await; + } + } + } if runtime.status.orbit_url.is_none() { runtime.status.orbit_url = configured_orbit_url; } diff --git a/src-tauri/src/settings/mod.rs b/src-tauri/src/settings/mod.rs index 00fd1c7676..6b1da28a7f 100644 --- a/src-tauri/src/settings/mod.rs +++ b/src-tauri/src/settings/mod.rs @@ -4,7 +4,7 @@ use crate::shared::settings_core::{ get_app_settings_core, get_codex_config_path_core, update_app_settings_core, }; use crate::state::AppState; -use crate::types::AppSettings; +use crate::types::{AppSettings, BackendMode, RemoteBackendProvider}; use crate::window; #[tauri::command] @@ -29,6 +29,7 @@ pub(crate) async fn update_app_settings( if should_reset_remote_backend(&previous, &updated) { *state.remote_backend.lock().await = None; } + ensure_remote_runtime_for_settings(&updated, state).await; let _ = window::apply_window_appearance(&window, updated.theme.as_str()); Ok(updated) } @@ -56,6 +57,26 @@ fn should_reset_remote_backend(previous: &AppSettings, updated: &AppSettings) -> || previous.orbit_ws_url != updated.orbit_ws_url } +async fn ensure_remote_runtime_for_settings(settings: &AppSettings, state: State<'_, AppState>) { + if cfg!(any(target_os = "android", target_os = "ios")) { + return; + } + if !matches!(settings.backend_mode, BackendMode::Remote) { + return; + } + + match settings.remote_backend_provider { + RemoteBackendProvider::Tcp => { + let _ = crate::tailscale::tailscale_daemon_start(state).await; + } + RemoteBackendProvider::Orbit => { + if settings.orbit_auto_start_runner { + let _ = crate::orbit::orbit_runner_start(state).await; + } + } + } +} + #[cfg(test)] mod tests { use super::should_reset_remote_backend; diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 8d00abaae0..beee510503 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -16,6 +16,7 @@ use crate::types::{ pub(crate) struct OrbitRunnerRuntime { pub(crate) child: Option, pub(crate) status: OrbitRunnerStatus, + pub(crate) managed_version: Option, } impl Default for OrbitRunnerRuntime { @@ -29,6 +30,7 @@ impl Default for OrbitRunnerRuntime { last_error: None, orbit_url: None, }, + managed_version: None, } } } diff --git a/src-tauri/src/tailscale/daemon_commands.rs b/src-tauri/src/tailscale/daemon_commands.rs index b81cb8c70a..33f208954d 100644 --- a/src-tauri/src/tailscale/daemon_commands.rs +++ b/src-tauri/src/tailscale/daemon_commands.rs @@ -1,8 +1,58 @@ use super::rpc_client::{ - probe_daemon, request_daemon_shutdown, wait_for_daemon_shutdown, DaemonProbe, + probe_daemon, request_daemon_shutdown, wait_for_daemon_shutdown, DaemonInfo, DaemonProbe, }; use super::*; +const EXPECTED_DAEMON_NAME: &str = "codex-monitor-daemon"; +const EXPECTED_DAEMON_MODE: &str = "tcp"; +const CURRENT_APP_VERSION: &str = env!("CARGO_PKG_VERSION"); + +fn is_managed_daemon(info: &DaemonInfo) -> bool { + info.name == EXPECTED_DAEMON_NAME +} + +fn can_force_stop_daemon(auth_ok: bool, info: Option<&DaemonInfo>) -> bool { + auth_ok && info.is_some_and(is_managed_daemon) +} + +fn should_restart_daemon(info: Option<&DaemonInfo>) -> bool { + let Some(info) = info else { + return true; + }; + !is_managed_daemon(info) + || info.version != CURRENT_APP_VERSION + || info.mode != EXPECTED_DAEMON_MODE +} + +fn daemon_restart_reason(info: Option<&DaemonInfo>) -> String { + let Some(info) = info else { + return "Daemon is running but did not report identity/version metadata".to_string(); + }; + if !is_managed_daemon(info) { + return format!("Daemon identity mismatch (`{}`)", info.name); + } + if info.version != CURRENT_APP_VERSION { + return format!( + "Daemon version {} is different from app version {}", + info.version, CURRENT_APP_VERSION + ); + } + if info.mode != EXPECTED_DAEMON_MODE { + return format!( + "Daemon mode `{}` does not match expected `{}`", + info.mode, EXPECTED_DAEMON_MODE + ); + } + "Daemon restart required".to_string() +} + +async fn resolve_daemon_pid(listen_port: u16, info: Option<&DaemonInfo>) -> Option { + match info.and_then(|entry| entry.pid) { + Some(pid) => Some(pid), + None => find_listener_pid(listen_port).await, + } +} + pub(super) async fn tailscale_daemon_command_preview( state: State<'_, AppState>, ) -> Result { @@ -61,16 +111,21 @@ pub(super) async fn tailscale_daemon_start( let mut runtime = state.tcp_daemon.lock().await; refresh_tcp_daemon_runtime(&mut runtime).await; - if matches!(runtime.status.state, TcpDaemonState::Running) { - return Ok(runtime.status.clone()); - } match probe_daemon(&listen_addr, Some(token)).await { DaemonProbe::Running { auth_ok, auth_error, + info, } => { - let pid = find_listener_pid(listen_port).await; + let pid = resolve_daemon_pid(listen_port, info.as_ref()).await; + let restart_required = should_restart_daemon(info.as_ref()); + let restart_reason = if restart_required { + Some(daemon_restart_reason(info.as_ref())) + } else { + None + }; + runtime.child = None; runtime.status = TcpDaemonStatus { state: TcpDaemonState::Running, @@ -84,7 +139,68 @@ pub(super) async fn tailscale_daemon_start( "Daemon is already running but authentication failed.".to_string() })); } - return Ok(runtime.status.clone()); + if !restart_required { + return Ok(runtime.status.clone()); + } + + let force_kill_allowed = can_force_stop_daemon(auth_ok, info.as_ref()); + let pid_for_control = pid; + if let Err(shutdown_error) = request_daemon_shutdown(&listen_addr, Some(token)).await { + if !force_kill_allowed { + return Err(format!( + "{}; automatic restart aborted because daemon ownership could not be verified: {}", + restart_reason.unwrap_or_else(|| "Daemon restart required".to_string()), + shutdown_error + )); + } + if let Some(pid) = pid_for_control { + kill_pid_gracefully(pid).await.map_err(|err| { + format!( + "{}; graceful shutdown failed ({shutdown_error}) and forced stop failed: {err}", + restart_reason + .clone() + .unwrap_or_else(|| "Daemon restart required".to_string()) + ) + })?; + } else { + return Err(format!( + "{}; daemon did not stop and no PID could be resolved for safe forced stop ({shutdown_error})", + restart_reason.unwrap_or_else(|| "Daemon restart required".to_string()) + )); + } + } + + if !wait_for_daemon_shutdown(&listen_addr, Some(token)).await { + if !force_kill_allowed { + return Err(format!( + "{}; daemon acknowledged shutdown but is still reachable", + restart_reason.unwrap_or_else(|| "Daemon restart required".to_string()) + )); + } + if let Some(pid) = resolve_daemon_pid(listen_port, info.as_ref()).await { + kill_pid_gracefully(pid).await.map_err(|err| { + format!( + "{}; daemon remained reachable and forced stop failed: {err}", + restart_reason + .clone() + .unwrap_or_else(|| "Daemon restart required".to_string()) + ) + })?; + } else { + return Err(format!( + "{}; daemon remained reachable and no PID could be resolved for safe forced stop", + restart_reason.unwrap_or_else(|| "Daemon restart required".to_string()) + )); + } + } + + runtime.status = TcpDaemonStatus { + state: TcpDaemonState::Stopped, + pid: None, + started_at_ms: None, + last_error: None, + listen_addr: Some(listen_addr.clone()), + }; } DaemonProbe::NotDaemon => { return Err(format!( @@ -140,19 +256,26 @@ pub(super) async fn tailscale_daemon_stop( ) .await { - DaemonProbe::Running { .. } => { + DaemonProbe::Running { auth_ok, info, .. } => { + let force_kill_allowed = can_force_stop_daemon(auth_ok, info.as_ref()); if let Err(shutdown_error) = request_daemon_shutdown( &configured_listen_addr, settings.remote_backend_token.as_deref(), ) .await { - let pid = find_listener_pid(port).await; + let pid = resolve_daemon_pid(port, info.as_ref()).await; if let Some(pid) = pid { - if let Err(err) = kill_pid_gracefully(pid).await { - stop_error = Some(format!("{shutdown_error}; {err}")); + if force_kill_allowed { + if let Err(err) = kill_pid_gracefully(pid).await { + stop_error = Some(format!("{shutdown_error}; {err}")); + } else { + stop_error = None; + } } else { - stop_error = None; + stop_error = Some(format!( + "{shutdown_error}; refusing forced stop because daemon ownership could not be verified" + )); } } else { stop_error = Some(shutdown_error); @@ -163,8 +286,28 @@ pub(super) async fn tailscale_daemon_stop( ) .await { - stop_error = - Some("Daemon acknowledged shutdown but is still reachable.".to_string()); + if force_kill_allowed { + let pid = resolve_daemon_pid(port, info.as_ref()).await; + if let Some(pid) = pid { + if let Err(err) = kill_pid_gracefully(pid).await { + stop_error = Some(format!( + "Daemon acknowledged shutdown but remained reachable; {err}" + )); + } else { + stop_error = None; + } + } else { + stop_error = Some( + "Daemon acknowledged shutdown but remained reachable and PID could not be resolved." + .to_string(), + ); + } + } else { + stop_error = Some( + "Daemon acknowledged shutdown but is still reachable; refusing forced stop because daemon ownership could not be verified." + .to_string(), + ); + } } } DaemonProbe::NotDaemon => { @@ -243,6 +386,7 @@ pub(super) async fn tailscale_daemon_status( DaemonProbe::Running { auth_ok: _, auth_error, + info: _, } => TcpDaemonStatus { state: TcpDaemonState::Running, pid, @@ -273,3 +417,42 @@ pub(super) async fn tailscale_daemon_status( Ok(runtime.status.clone()) } + +#[cfg(test)] +mod tests { + use super::{ + can_force_stop_daemon, should_restart_daemon, DaemonInfo, CURRENT_APP_VERSION, + EXPECTED_DAEMON_MODE, EXPECTED_DAEMON_NAME, + }; + + fn daemon_info(version: &str) -> DaemonInfo { + DaemonInfo { + name: EXPECTED_DAEMON_NAME.to_string(), + version: version.to_string(), + pid: Some(42), + mode: EXPECTED_DAEMON_MODE.to_string(), + binary_path: Some("/tmp/codex-monitor-daemon".to_string()), + } + } + + #[test] + fn restart_required_for_old_version() { + let info = daemon_info("0.1.0"); + assert!(should_restart_daemon(Some(&info))); + } + + #[test] + fn no_restart_for_same_version_and_mode() { + let info = daemon_info(CURRENT_APP_VERSION); + assert!(!should_restart_daemon(Some(&info))); + } + + #[test] + fn force_stop_requires_verified_daemon_identity() { + let mut info = daemon_info(CURRENT_APP_VERSION); + info.name = "unknown-daemon".to_string(); + assert!(!can_force_stop_daemon(true, Some(&info))); + assert!(!can_force_stop_daemon(false, Some(&info))); + assert!(!can_force_stop_daemon(true, None)); + } +} diff --git a/src-tauri/src/tailscale/rpc_client.rs b/src-tauri/src/tailscale/rpc_client.rs index 0f48b024d8..88965d7d4e 100644 --- a/src-tauri/src/tailscale/rpc_client.rs +++ b/src-tauri/src/tailscale/rpc_client.rs @@ -2,12 +2,22 @@ use super::*; const DAEMON_RPC_TIMEOUT: Duration = Duration::from_millis(700); +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct DaemonInfo { + pub(super) name: String, + pub(super) version: String, + pub(super) pid: Option, + pub(super) mode: String, + pub(super) binary_path: Option, +} + #[derive(Debug, Clone)] pub(super) enum DaemonProbe { NotReachable, Running { auth_ok: bool, auth_error: Option, + info: Option, }, NotDaemon, } @@ -27,6 +37,48 @@ fn is_auth_error_message(message: &str) -> bool { lower.contains("unauthorized") || lower.contains("invalid token") } +fn parse_daemon_info(value: &Value) -> Result { + let name = value + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "daemon_info missing `name`".to_string())? + .to_string(); + let version = value + .get("version") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "daemon_info missing `version`".to_string())? + .to_string(); + let mode = value + .get("mode") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "daemon_info missing `mode`".to_string())? + .to_string(); + let pid = value + .get("pid") + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()); + let binary_path = value + .get("binaryPath") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + + Ok(DaemonInfo { + name, + version, + pid, + mode, + binary_path, + }) +} + async fn send_rpc_request( writer: &mut OwnedWriteHalf, id: u64, @@ -90,6 +142,15 @@ async fn send_and_expect_result( .ok_or_else(|| "daemon response missing result".to_string()) } +async fn request_daemon_info( + writer: &mut OwnedWriteHalf, + lines: &mut DaemonLines, + id: u64, +) -> Result { + let result = send_and_expect_result(writer, lines, id, "daemon_info", json!({})).await?; + parse_daemon_info(&result) +} + pub(super) async fn probe_daemon(listen_addr: &str, token: Option<&str>) -> DaemonProbe { let Some(connect_addr) = daemon_connect_addr(listen_addr) else { return DaemonProbe::NotReachable; @@ -107,6 +168,7 @@ pub(super) async fn probe_daemon(listen_addr: &str, token: Option<&str>) -> Daem Ok(_) => DaemonProbe::Running { auth_ok: true, auth_error: None, + info: request_daemon_info(&mut writer, &mut lines, 2).await.ok(), }, Err(message) => { if !is_auth_error_message(&message) { @@ -120,31 +182,34 @@ pub(super) async fn probe_daemon(listen_addr: &str, token: Option<&str>) -> Daem auth_error: Some( "Daemon is running but requires a remote backend token.".to_string(), ), + info: None, }; }; match send_and_expect_result( &mut writer, &mut lines, - 2, + 10, "auth", json!({ "token": auth_token }), ) .await { Ok(_) => { - match send_and_expect_result(&mut writer, &mut lines, 3, "ping", json!({})) + match send_and_expect_result(&mut writer, &mut lines, 11, "ping", json!({})) .await { Ok(_) => DaemonProbe::Running { auth_ok: true, auth_error: None, + info: request_daemon_info(&mut writer, &mut lines, 12).await.ok(), }, Err(ping_error) => DaemonProbe::Running { auth_ok: false, auth_error: Some(format!( "Daemon is running but ping failed after auth: {ping_error}" )), + info: None, }, } } @@ -155,6 +220,7 @@ pub(super) async fn probe_daemon(listen_addr: &str, token: Option<&str>) -> Daem auth_error: Some(format!( "Daemon is running but token authentication failed: {auth_error}" )), + info: None, } } else { DaemonProbe::NotDaemon diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 5c40336598..f0d0092bc0 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -461,6 +461,8 @@ pub(crate) struct AppSettings { pub(crate) orbit_runner_name: Option, #[serde(default, rename = "orbitAutoStartRunner")] pub(crate) orbit_auto_start_runner: bool, + #[serde(default, rename = "keepDaemonRunningAfterAppClose")] + pub(crate) keep_daemon_running_after_app_close: bool, #[serde(default, rename = "orbitUseAccess")] pub(crate) orbit_use_access: bool, #[serde(default, rename = "orbitAccessClientId")] @@ -1131,6 +1133,7 @@ impl Default for AppSettings { orbit_auth_url: None, orbit_runner_name: None, orbit_auto_start_runner: false, + keep_daemon_running_after_app_close: false, orbit_use_access: false, orbit_access_client_id: None, orbit_access_client_secret_ref: None, @@ -1224,6 +1227,7 @@ mod tests { assert!(settings.orbit_auth_url.is_none()); assert!(settings.orbit_runner_name.is_none()); assert!(!settings.orbit_auto_start_runner); + assert!(!settings.keep_daemon_running_after_app_close); assert!(!settings.orbit_use_access); assert!(settings.orbit_access_client_id.is_none()); assert!(settings.orbit_access_client_secret_ref.is_none()); diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index 653733fe61..e8ee54ac6c 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -29,6 +29,7 @@ const baseSettings: AppSettings = { orbitAuthUrl: null, orbitRunnerName: null, orbitAutoStartRunner: false, + keepDaemonRunningAfterAppClose: false, orbitUseAccess: false, orbitAccessClientId: null, orbitAccessClientSecretRef: null, diff --git a/src/features/settings/components/sections/SettingsServerSection.tsx b/src/features/settings/components/sections/SettingsServerSection.tsx index 112bd59a80..7c8c93d508 100644 --- a/src/features/settings/components/sections/SettingsServerSection.tsx +++ b/src/features/settings/components/sections/SettingsServerSection.tsx @@ -194,6 +194,30 @@ export function SettingsServerSection({ + {!isMobileSimplified && ( +
+
+
Keep daemon running after app closes
+
+ If disabled, CodexMonitor stops managed TCP and Orbit daemon processes before exit. +
+
+ +
+ )} + {appSettings.remoteBackendProvider === "tcp" && ( <>
diff --git a/src/features/settings/hooks/useAppSettings.ts b/src/features/settings/hooks/useAppSettings.ts index d6e1f0138e..d82f37d10e 100644 --- a/src/features/settings/hooks/useAppSettings.ts +++ b/src/features/settings/hooks/useAppSettings.ts @@ -35,6 +35,7 @@ function buildDefaultSettings(): AppSettings { orbitAuthUrl: null, orbitRunnerName: null, orbitAutoStartRunner: false, + keepDaemonRunningAfterAppClose: false, orbitUseAccess: false, orbitAccessClientId: null, orbitAccessClientSecretRef: null, diff --git a/src/types.ts b/src/types.ts index 0bfcab2da5..ca6f7a4054 100644 --- a/src/types.ts +++ b/src/types.ts @@ -153,6 +153,7 @@ export type AppSettings = { orbitAuthUrl: string | null; orbitRunnerName: string | null; orbitAutoStartRunner: boolean; + keepDaemonRunningAfterAppClose: boolean; orbitUseAccess: boolean; orbitAccessClientId: string | null; orbitAccessClientSecretRef: string | null; From e6a8fa2ab42eba57a3cbefe8f63d1ffe75086b36 Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Mon, 9 Feb 2026 15:29:42 +0100 Subject: [PATCH 03/34] build(ios): use dedicated bundle id/config for iOS builds --- README.md | 2 +- scripts/build_run_ios.sh | 4 ++-- scripts/build_run_ios_device.sh | 14 +++++++++----- .../apple/codex-monitor.xcodeproj/project.pbxproj | 4 ++-- src-tauri/gen/apple/codex-monitor_iOS/Info.plist | 2 +- src-tauri/tauri.ios.conf.json | 9 +++++++++ 6 files changed, 24 insertions(+), 11 deletions(-) create mode 100644 src-tauri/tauri.ios.conf.json diff --git a/README.md b/README.md index 29b158bf0e..11518d3a23 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ rustup target add x86_64-apple-ios ``` - Apple signing configured (development team). - - Set `bundle.iOS.developmentTeam` in `src-tauri/tauri.conf.json`, or + - Set `bundle.iOS.developmentTeam` in `src-tauri/tauri.ios.conf.json` (preferred), or - pass `--team ` to the device script. ### Run on iOS Simulator diff --git a/scripts/build_run_ios.sh b/scripts/build_run_ios.sh index 203e8547fb..4c69aa5677 100755 --- a/scripts/build_run_ios.sh +++ b/scripts/build_run_ios.sh @@ -6,7 +6,7 @@ cd "$ROOT_DIR" SIMULATOR_NAME="${SIMULATOR_NAME:-iPhone Air}" TARGET="${TARGET:-aarch64-sim}" -BUNDLE_ID="${BUNDLE_ID:-com.dimillian.codexmonitor}" +BUNDLE_ID="${BUNDLE_ID:-com.dimillian.codexmonitor.ios}" SKIP_BUILD=0 CLEAN_BUILD=1 IOS_APP_ICONSET_DIR="src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset" @@ -20,7 +20,7 @@ Builds the iOS simulator app, installs it on a booted simulator, and launches it Options: --simulator Simulator name (default: "iPhone Air") --target Tauri iOS target (default: "aarch64-sim") - --bundle-id Bundle id to launch (default: com.dimillian.codexmonitor) + --bundle-id Bundle id to launch (default: com.dimillian.codexmonitor.ios) --skip-build Skip the build and only install + launch the existing app --no-clean Do not remove stale src-tauri/gen/apple/build before build -h, --help Show this help diff --git a/scripts/build_run_ios_device.sh b/scripts/build_run_ios_device.sh index b3c3a98f0d..958695f8ba 100755 --- a/scripts/build_run_ios_device.sh +++ b/scripts/build_run_ios_device.sh @@ -6,7 +6,7 @@ cd "$ROOT_DIR" DEVICE="" TARGET="${TARGET:-aarch64}" -BUNDLE_ID="${BUNDLE_ID:-com.dimillian.codexmonitor}" +BUNDLE_ID="${BUNDLE_ID:-com.dimillian.codexmonitor.ios}" DEVELOPMENT_TEAM="${APPLE_DEVELOPMENT_TEAM:-}" SKIP_BUILD=0 OPEN_XCODE=0 @@ -24,7 +24,7 @@ Options: --device Required unless --list-devices is used. Accepts UDID, serial, UUID, or device name. --target Tauri iOS target (default: aarch64) - --bundle-id Bundle id to launch (default: com.dimillian.codexmonitor) + --bundle-id Bundle id to launch (default: com.dimillian.codexmonitor.ios) --team Apple development team ID (sets APPLE_DEVELOPMENT_TEAM) --skip-build Skip build and only install + launch existing app --open-xcode Open Xcode after build instead of install/launch via devicectl @@ -112,8 +112,12 @@ sync_ios_icons() { has_configured_ios_team() { node -e ' const fs = require("fs"); - const cfg = JSON.parse(fs.readFileSync("src-tauri/tauri.conf.json", "utf8")); - const team = cfg?.bundle?.iOS?.developmentTeam; + const baseCfg = JSON.parse(fs.readFileSync("src-tauri/tauri.conf.json", "utf8")); + let iosCfg = {}; + try { + iosCfg = JSON.parse(fs.readFileSync("src-tauri/tauri.ios.conf.json", "utf8")); + } catch (_) {} + const team = iosCfg?.bundle?.iOS?.developmentTeam ?? baseCfg?.bundle?.iOS?.developmentTeam; process.exit(team && String(team).trim() ? 0 : 1); ' >/dev/null 2>&1 } @@ -147,7 +151,7 @@ fi if [[ "$SKIP_BUILD" -eq 0 && -z "${APPLE_DEVELOPMENT_TEAM:-}" ]]; then if ! has_configured_ios_team; then echo "Missing iOS signing team." >&2 - echo "Set one via --team or APPLE_DEVELOPMENT_TEAM, or set bundle.iOS.developmentTeam in src-tauri/tauri.conf.json." >&2 + echo "Set one via --team or APPLE_DEVELOPMENT_TEAM, or set bundle.iOS.developmentTeam in src-tauri/tauri.ios.conf.json (or src-tauri/tauri.conf.json)." >&2 echo "Tip: First-time setup can be done with --open-xcode." >&2 exit 1 fi diff --git a/src-tauri/gen/apple/codex-monitor.xcodeproj/project.pbxproj b/src-tauri/gen/apple/codex-monitor.xcodeproj/project.pbxproj index 265150e99b..73729ec451 100644 --- a/src-tauri/gen/apple/codex-monitor.xcodeproj/project.pbxproj +++ b/src-tauri/gen/apple/codex-monitor.xcodeproj/project.pbxproj @@ -494,7 +494,7 @@ ); "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"; "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"; - PRODUCT_BUNDLE_IDENTIFIER = com.dimillian.codexmonitor; + PRODUCT_BUNDLE_IDENTIFIER = com.dimillian.codexmonitor.ios; PRODUCT_NAME = "Codex Monitor"; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; @@ -531,7 +531,7 @@ ); "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"; "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"; - PRODUCT_BUNDLE_IDENTIFIER = com.dimillian.codexmonitor; + PRODUCT_BUNDLE_IDENTIFIER = com.dimillian.codexmonitor.ios; PRODUCT_NAME = "Codex Monitor"; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/src-tauri/gen/apple/codex-monitor_iOS/Info.plist b/src-tauri/gen/apple/codex-monitor_iOS/Info.plist index c7b7b575ea..f5724fccf0 100644 --- a/src-tauri/gen/apple/codex-monitor_iOS/Info.plist +++ b/src-tauri/gen/apple/codex-monitor_iOS/Info.plist @@ -17,7 +17,7 @@ CFBundleShortVersionString 0.7.47 CFBundleVersion - 0.7.47.1 + 0.7.47 LSRequiresIPhoneOS UILaunchStoryboardName diff --git a/src-tauri/tauri.ios.conf.json b/src-tauri/tauri.ios.conf.json new file mode 100644 index 0000000000..8306c9b23e --- /dev/null +++ b/src-tauri/tauri.ios.conf.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "identifier": "com.dimillian.codexmonitor.ios", + "bundle": { + "iOS": { + "developmentTeam": "Z6P74P6T99" + } + } +} From 6761de5b3e4b3f06a0a7888dc485cd5a9a857ecf Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Mon, 9 Feb 2026 16:00:12 +0100 Subject: [PATCH 04/34] feat(ios): add scripted TestFlight release flow and env template --- .gitignore | 1 + .testflight.local.env.example | 16 ++ README.md | 11 + scripts/release_testflight_ios.sh | 393 ++++++++++++++++++++++++++++++ 4 files changed, 421 insertions(+) create mode 100644 .testflight.local.env.example create mode 100755 scripts/release_testflight_ios.sh diff --git a/.gitignore b/.gitignore index e44067141c..246c31caca 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +.testflight.local.env # Editor directories and files .vscode/* diff --git a/.testflight.local.env.example b/.testflight.local.env.example new file mode 100644 index 0000000000..33b2311d77 --- /dev/null +++ b/.testflight.local.env.example @@ -0,0 +1,16 @@ +# Copy this file to .testflight.local.env and fill in your values. +# Quote values that contain spaces. + +APP_ID= +BUNDLE_ID=com.example.app.ios +BETA_GROUP_NAME="Beta Testers" +LOCALE=en-US + +REVIEW_FIRST_NAME= +REVIEW_LAST_NAME= +REVIEW_CONTACT_EMAIL= +REVIEW_CONTACT_PHONE= +FEEDBACK_EMAIL= + +BETA_DESCRIPTION="Your app beta description." +REVIEW_NOTES="Your beta review notes." diff --git a/README.md b/README.md index 11518d3a23..192c2a65d4 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,17 @@ If signing is not ready yet, open Xcode from the script flow: ./scripts/build_run_ios_device.sh --open-xcode ``` +### iOS TestFlight Release (Scripted) + +Use the end-to-end script to archive, upload, configure compliance, assign beta group, and submit for beta review. + +```bash +./scripts/release_testflight_ios.sh +``` + +The script auto-loads release metadata from `.testflight.local.env` (gitignored). +For new setups, copy `.testflight.local.env.example` to `.testflight.local.env` and fill values. + ## Release Build Build the production Tauri bundle: diff --git a/scripts/release_testflight_ios.sh b/scripts/release_testflight_ios.sh new file mode 100755 index 0000000000..94c59132ec --- /dev/null +++ b/scripts/release_testflight_ios.sh @@ -0,0 +1,393 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +DEFAULT_ENV_FILE=".testflight.local.env" +ENV_FILE="${TESTFLIGHT_ENV_FILE:-$DEFAULT_ENV_FILE}" +if [[ -f "$ENV_FILE" ]]; then + set -a + # shellcheck source=/dev/null + . "$ENV_FILE" + set +a +fi + +TARGET="${TARGET:-aarch64}" +BUNDLE_ID="${BUNDLE_ID:-com.dimillian.codexmonitor.ios}" +APP_ID="${APP_ID:-}" +IPA_PATH="${IPA_PATH:-}" +BUILD_NUMBER="${BUILD_NUMBER:-}" +LOCALE="${LOCALE:-en-US}" +BETA_GROUP_NAME="${BETA_GROUP_NAME:-Beta Testers}" +BETA_DESCRIPTION="${BETA_DESCRIPTION:-Codex Monitor iOS beta build for external testing.}" +FEEDBACK_EMAIL="${FEEDBACK_EMAIL:-}" +REVIEW_FIRST_NAME="${REVIEW_FIRST_NAME:-}" +REVIEW_LAST_NAME="${REVIEW_LAST_NAME:-}" +REVIEW_CONTACT_EMAIL="${REVIEW_CONTACT_EMAIL:-}" +REVIEW_CONTACT_PHONE="${REVIEW_CONTACT_PHONE:-}" +REVIEW_NOTES="${REVIEW_NOTES:-Codex Monitor iOS beta build for external testing.}" +SKIP_BUILD=0 +SKIP_SUBMIT=0 + +usage() { + cat <<'USAGE' +Usage: scripts/release_testflight_ios.sh [options] + +Builds iOS release IPA, uploads to App Store Connect, applies export compliance, +adds build to a TestFlight group, and submits for external beta review. + +Defaults are auto-loaded from .testflight.local.env (gitignored) when present. +Override the path with TESTFLIGHT_ENV_FILE=/path/to/file. + +Options: + --app-id App Store Connect app ID (auto-resolved by bundle id if omitted) + --bundle-id Bundle identifier (default: com.dimillian.codexmonitor.ios) + --ipa IPA path (default: src-tauri/gen/apple/build/arm64/Codex Monitor.ipa) + --target Tauri iOS target (default: aarch64) + --build-number Build number used during archive (default: current unix timestamp) + --skip-build Skip Tauri archive/export step and reuse existing IPA + --skip-submit Do not submit for external beta review + --group-name TestFlight beta group name (default: Beta Testers) + --locale Beta localization locale (default: en-US) + --beta-description Beta app description (used for localization) + --feedback-email Beta feedback email (defaults to review contact email) + +Review metadata (required for external submission if not already set in ASC): + --review-first-name + --review-last-name + --review-email + --review-phone + --review-notes + +Examples: + ./scripts/release_testflight_ios.sh + ./scripts/release_testflight_ios.sh --skip-build --ipa "src-tauri/gen/apple/build/arm64/Codex Monitor.ipa" +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --app-id) + APP_ID="${2:-}" + shift 2 + ;; + --bundle-id) + BUNDLE_ID="${2:-}" + shift 2 + ;; + --ipa) + IPA_PATH="${2:-}" + shift 2 + ;; + --target) + TARGET="${2:-}" + shift 2 + ;; + --build-number) + BUILD_NUMBER="${2:-}" + shift 2 + ;; + --skip-build) + SKIP_BUILD=1 + shift + ;; + --skip-submit) + SKIP_SUBMIT=1 + shift + ;; + --group-name) + BETA_GROUP_NAME="${2:-}" + shift 2 + ;; + --locale) + LOCALE="${2:-}" + shift 2 + ;; + --beta-description) + BETA_DESCRIPTION="${2:-}" + shift 2 + ;; + --feedback-email) + FEEDBACK_EMAIL="${2:-}" + shift 2 + ;; + --review-first-name) + REVIEW_FIRST_NAME="${2:-}" + shift 2 + ;; + --review-last-name) + REVIEW_LAST_NAME="${2:-}" + shift 2 + ;; + --review-email) + REVIEW_CONTACT_EMAIL="${2:-}" + shift 2 + ;; + --review-phone) + REVIEW_CONTACT_PHONE="${2:-}" + shift 2 + ;; + --review-notes) + REVIEW_NOTES="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +log() { + echo "[testflight] $*" +} + +fail() { + echo "[testflight] ERROR: $*" >&2 + exit 1 +} + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + fail "Missing required command: $1" + fi +} + +resolve_npm() { + if command -v npm >/dev/null 2>&1; then + command -v npm + return + fi + + for candidate in /opt/homebrew/bin/npm /usr/local/bin/npm; do + if [[ -x "$candidate" ]]; then + echo "$candidate" + return + fi + done + + if [[ -n "${NVM_DIR:-}" && -s "${NVM_DIR}/nvm.sh" ]]; then + # shellcheck source=/dev/null + . "${NVM_DIR}/nvm.sh" + if command -v npm >/dev/null 2>&1; then + command -v npm + return + fi + fi + + return 1 +} + +sync_ios_icons() { + local iconset_dir="src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset" + if [[ ! -d "$iconset_dir" ]]; then + return + fi + if compgen -G "src-tauri/icons/ios/*.png" >/dev/null; then + cp -f src-tauri/icons/ios/*.png "$iconset_dir"/ + fi +} + +json_get() { + local json="$1" + local expr="$2" + jq -r "$expr" <<<"$json" +} + +require_cmd asc +require_cmd jq + +log "Checking App Store Connect authentication" +asc auth status --validate >/dev/null + +if [[ -z "$APP_ID" ]]; then + log "Resolving app id for bundle id: $BUNDLE_ID" + apps_json="$(asc apps list --bundle-id "$BUNDLE_ID" --output json)" + APP_ID="$(json_get "$apps_json" '.data[0].id // empty')" + [[ -n "$APP_ID" ]] || fail "No ASC app found for bundle id '$BUNDLE_ID'" +fi + +log "Using app id: $APP_ID" + +if [[ "$SKIP_BUILD" -eq 0 ]]; then + NPM_BIN="$(resolve_npm || true)" + [[ -n "$NPM_BIN" ]] || fail "Unable to find npm in PATH or common install locations" + + if [[ -z "$BUILD_NUMBER" ]]; then + BUILD_NUMBER="$(date +%s)" + fi + + log "Building iOS archive and exporting IPA (build number: $BUILD_NUMBER)" + sync_ios_icons + "$NPM_BIN" run tauri -- ios build --target "$TARGET" --export-method app-store-connect --build-number "$BUILD_NUMBER" --ci +fi + +if [[ -z "$IPA_PATH" ]]; then + IPA_PATH="src-tauri/gen/apple/build/arm64/Codex Monitor.ipa" +fi + +[[ -f "$IPA_PATH" ]] || fail "IPA not found at: $IPA_PATH" + +log "Uploading IPA to ASC" +asc builds upload --app "$APP_ID" --ipa "$IPA_PATH" --wait --output json >/dev/null + +latest_json="$(asc builds latest --app "$APP_ID" --platform IOS --output json)" +BUILD_ID="$(json_get "$latest_json" '.data.id // empty')" +BUILD_VERSION="$(json_get "$latest_json" '.data.attributes.version // empty')" +BUILD_UPLOADED_AT="$(json_get "$latest_json" '.data.attributes.uploadedDate // empty')" +[[ -n "$BUILD_ID" ]] || fail "Unable to resolve uploaded build id" + +log "Latest uploaded build: id=$BUILD_ID version=$BUILD_VERSION uploaded=$BUILD_UPLOADED_AT" + +beta_detail_json="$(asc builds build-beta-detail get --build "$BUILD_ID" --output json)" +internal_state="$(json_get "$beta_detail_json" '.data.attributes.internalBuildState // empty')" +external_state="$(json_get "$beta_detail_json" '.data.attributes.externalBuildState // empty')" + +if [[ "$internal_state" == "MISSING_EXPORT_COMPLIANCE" || "$external_state" == "MISSING_EXPORT_COMPLIANCE" ]]; then + log "Export compliance missing; resolving encryption declaration" + declarations_json="$(asc encryption declarations list --app "$APP_ID" --output json)" + declaration_id="$(json_get "$declarations_json" '.data[0].id // empty')" + + if [[ -z "$declaration_id" ]]; then + create_decl_json="$(asc encryption declarations create \ + --app "$APP_ID" \ + --app-description "Uses standard third-party cryptography for encrypted network transport (e.g. HTTPS/TLS)." \ + --contains-proprietary-cryptography=false \ + --contains-third-party-cryptography=true \ + --available-on-french-store=true \ + --output json)" + declaration_id="$(json_get "$create_decl_json" '.data.id // empty')" + [[ -n "$declaration_id" ]] || fail "Failed to create encryption declaration" + log "Created encryption declaration: $declaration_id" + else + log "Reusing encryption declaration: $declaration_id" + fi + + asc encryption declarations assign-builds --id "$declaration_id" --build "$BUILD_ID" --output json >/dev/null || true + + for _ in {1..12}; do + beta_detail_json="$(asc builds build-beta-detail get --build "$BUILD_ID" --output json)" + internal_state="$(json_get "$beta_detail_json" '.data.attributes.internalBuildState // empty')" + external_state="$(json_get "$beta_detail_json" '.data.attributes.externalBuildState // empty')" + if [[ "$internal_state" != "MISSING_EXPORT_COMPLIANCE" && "$external_state" != "MISSING_EXPORT_COMPLIANCE" ]]; then + break + fi + sleep 5 + done +fi + +log "Build beta state: internal=$internal_state external=$external_state" + +groups_json="$(asc testflight beta-groups list --app "$APP_ID" --output json)" +BETA_GROUP_ID="$(jq -r --arg name "$BETA_GROUP_NAME" '.data[] | select(.attributes.name == $name) | .id' <<<"$groups_json" | head -n 1)" + +if [[ -z "$BETA_GROUP_ID" ]]; then + log "Creating beta group: $BETA_GROUP_NAME" + group_create_json="$(asc testflight beta-groups create --app "$APP_ID" --name "$BETA_GROUP_NAME" --output json)" + BETA_GROUP_ID="$(json_get "$group_create_json" '.data.id // empty')" + [[ -n "$BETA_GROUP_ID" ]] || fail "Failed to create beta group" +fi + +log "Using beta group: $BETA_GROUP_NAME ($BETA_GROUP_ID)" +asc builds add-groups --build "$BUILD_ID" --group "$BETA_GROUP_ID" --output json >/dev/null || true + +if [[ -z "$FEEDBACK_EMAIL" ]]; then + FEEDBACK_EMAIL="$REVIEW_CONTACT_EMAIL" +fi + +localizations_json="$(asc beta-app-localizations list --app "$APP_ID" --output json)" +localization_id="$(jq -r --arg locale "$LOCALE" '.data[] | select(.attributes.locale == $locale) | .id' <<<"$localizations_json" | head -n 1)" + +if [[ -z "$localization_id" ]]; then + log "Creating beta localization for locale: $LOCALE" + create_loc_cmd=(asc beta-app-localizations create --app "$APP_ID" --locale "$LOCALE" --description "$BETA_DESCRIPTION" --output json) + if [[ -n "$FEEDBACK_EMAIL" ]]; then + create_loc_cmd+=(--feedback-email "$FEEDBACK_EMAIL") + fi + create_loc_json="$("${create_loc_cmd[@]}")" + localization_id="$(json_get "$create_loc_json" '.data.id // empty')" + [[ -n "$localization_id" ]] || fail "Failed to create beta app localization" +else + log "Updating beta localization for locale: $LOCALE" + update_loc_cmd=(asc beta-app-localizations update --id "$localization_id" --description "$BETA_DESCRIPTION" --output json) + if [[ -n "$FEEDBACK_EMAIL" ]]; then + update_loc_cmd+=(--feedback-email "$FEEDBACK_EMAIL") + fi + "${update_loc_cmd[@]}" >/dev/null +fi + +review_json="$(asc testflight review get --app "$APP_ID" --output json)" + +current_first_name="$(json_get "$review_json" '.data[0].attributes.contactFirstName // empty')" +current_last_name="$(json_get "$review_json" '.data[0].attributes.contactLastName // empty')" +current_contact_email="$(json_get "$review_json" '.data[0].attributes.contactEmail // empty')" +current_contact_phone="$(json_get "$review_json" '.data[0].attributes.contactPhone // empty')" + +[[ -n "$REVIEW_FIRST_NAME" ]] || REVIEW_FIRST_NAME="$current_first_name" +[[ -n "$REVIEW_LAST_NAME" ]] || REVIEW_LAST_NAME="$current_last_name" +[[ -n "$REVIEW_CONTACT_EMAIL" ]] || REVIEW_CONTACT_EMAIL="$current_contact_email" +[[ -n "$REVIEW_CONTACT_PHONE" ]] || REVIEW_CONTACT_PHONE="$current_contact_phone" + +[[ -n "$REVIEW_FIRST_NAME" ]] || fail "Missing review first name. Pass --review-first-name or set REVIEW_FIRST_NAME in $ENV_FILE" +[[ -n "$REVIEW_LAST_NAME" ]] || fail "Missing review last name. Pass --review-last-name or set REVIEW_LAST_NAME in $ENV_FILE" +[[ -n "$REVIEW_CONTACT_EMAIL" ]] || fail "Missing review email. Pass --review-email or set REVIEW_CONTACT_EMAIL in $ENV_FILE" +[[ -n "$REVIEW_CONTACT_PHONE" ]] || fail "Missing review phone. Pass --review-phone or set REVIEW_CONTACT_PHONE in $ENV_FILE" + +log "Updating beta review contact metadata" +asc testflight review update \ + --id "$APP_ID" \ + --contact-first-name "$REVIEW_FIRST_NAME" \ + --contact-last-name "$REVIEW_LAST_NAME" \ + --contact-email "$REVIEW_CONTACT_EMAIL" \ + --contact-phone "$REVIEW_CONTACT_PHONE" \ + --notes "$REVIEW_NOTES" \ + --output json >/dev/null + +beta_detail_json="$(asc builds build-beta-detail get --build "$BUILD_ID" --output json)" +external_state="$(json_get "$beta_detail_json" '.data.attributes.externalBuildState // empty')" + +if [[ "$SKIP_SUBMIT" -eq 1 ]]; then + log "Skipping external review submit by request (--skip-submit)" + echo + echo "Build ID: $BUILD_ID" + echo "App ID: $APP_ID" + echo "Group ID: $BETA_GROUP_ID" + echo "External state:$external_state" + exit 0 +fi + +if [[ "$external_state" == "READY_FOR_BETA_SUBMISSION" ]]; then + log "Submitting build for external beta review" + set +e + submit_output="$(asc testflight review submit --build "$BUILD_ID" --confirm --output json 2>&1)" + submit_status=$? + set -e + if [[ "$submit_status" -ne 0 ]]; then + if grep -q "Another build is in review" <<<"$submit_output"; then + log "Submission blocked because another build is already in beta review" + else + echo "$submit_output" >&2 + fail "Failed to submit build for external beta review" + fi + fi + beta_detail_json="$(asc builds build-beta-detail get --build "$BUILD_ID" --output json)" + external_state="$(json_get "$beta_detail_json" '.data.attributes.externalBuildState // empty')" +elif [[ "$external_state" == "WAITING_FOR_BETA_REVIEW" || "$external_state" == "IN_BETA_TESTING" ]]; then + log "Build already submitted/available for external testing" +else + fail "Build is not ready for external submit (state: $external_state)" +fi + +echo +log "Completed TestFlight release flow" +echo "App ID: $APP_ID" +echo "Build ID: $BUILD_ID" +echo "Build version: $BUILD_VERSION" +echo "Beta group: $BETA_GROUP_NAME ($BETA_GROUP_ID)" +echo "External state: $external_state" From c54558bb20e8c20ff3ef203ae8dbf02e52c4429a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:03:28 +0100 Subject: [PATCH 05/34] chore: bump version to 0.7.48 (#376) Co-authored-by: github-actions[bot] --- package-lock.json | 4 ++-- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/gen/apple/codex-monitor_iOS/Info.plist | 6 +++--- src-tauri/tauri.conf.json | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 46b98bda06..834a1cfde9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-monitor", - "version": "0.7.47", + "version": "0.7.48", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-monitor", - "version": "0.7.47", + "version": "0.7.48", "hasInstallScript": true, "dependencies": { "@pierre/diffs": "^1.0.6", diff --git a/package.json b/package.json index ba92765675..c45a6fa20c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codex-monitor", "private": true, - "version": "0.7.47", + "version": "0.7.48", "type": "module", "scripts": { "sync:material-icons": "node scripts/sync-material-icons.mjs", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1deb996940..7364529cb5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -599,7 +599,7 @@ dependencies = [ [[package]] name = "codex-monitor" -version = "0.7.47" +version = "0.7.48" dependencies = [ "base64 0.22.1", "block2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f05b984b61..8c1aa7b202 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codex-monitor" -version = "0.7.47" +version = "0.7.48" description = "A Tauri App" authors = ["you"] edition = "2021" diff --git a/src-tauri/gen/apple/codex-monitor_iOS/Info.plist b/src-tauri/gen/apple/codex-monitor_iOS/Info.plist index f5724fccf0..1e3387b414 100644 --- a/src-tauri/gen/apple/codex-monitor_iOS/Info.plist +++ b/src-tauri/gen/apple/codex-monitor_iOS/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.7.47 + 0.7.48 CFBundleVersion - 0.7.47 + 0.7.48 LSRequiresIPhoneOS UILaunchStoryboardName @@ -49,4 +49,4 @@ NSMicrophoneUsageDescription Allow access to the microphone for dictation. - \ No newline at end of file + diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1a95d257a1..e1a9b094a0 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Codex Monitor", - "version": "0.7.47", + "version": "0.7.48", "identifier": "com.dimillian.codexmonitor", "build": { "beforeDevCommand": "npm run dev", From b91c039d71ed02f5a4eeb1d1de00a0e2a41142ee Mon Sep 17 00:00:00 2001 From: Gurpartap Singh Date: Mon, 9 Feb 2026 22:17:28 +0530 Subject: [PATCH 06/34] fix: macOS Tailscale detection (#374) --- src-tauri/src/tailscale/mod.rs | 74 ++++++++++++++++++- .../settings/components/SettingsView.tsx | 22 +++++- 2 files changed, 88 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/tailscale/mod.rs b/src-tauri/src/tailscale/mod.rs index a3848f83c4..7e82d80ce3 100644 --- a/src-tauri/src/tailscale/mod.rs +++ b/src-tauri/src/tailscale/mod.rs @@ -26,6 +26,19 @@ use self::core as tailscale_core; #[cfg(any(target_os = "android", target_os = "ios"))] const UNSUPPORTED_MESSAGE: &str = "Tailscale integration is only available on desktop."; +#[cfg(target_os = "macos")] +fn tailscale_command(binary: &OsStr) -> tokio::process::Command { + let mut command = tokio_command("/bin/launchctl"); + let uid = unsafe { libc::geteuid() }; + command.arg("asuser").arg(uid.to_string()).arg(binary); + command +} + +#[cfg(not(target_os = "macos"))] +fn tailscale_command(binary: &OsStr) -> tokio::process::Command { + tokio_command(binary) +} + fn trim_to_non_empty(value: Option<&str>) -> Option { value .map(str::trim) @@ -79,9 +92,28 @@ fn missing_tailscale_message() -> String { async fn resolve_tailscale_binary() -> Result, String> { let mut failures: Vec = Vec::new(); for binary in tailscale_binary_candidates() { - let output = tokio_command(&binary).arg("version").output().await; + let output = tailscale_command(binary.as_os_str()) + .arg("version") + .output() + .await; match output { - Ok(version_output) => return Ok(Some((binary, version_output))), + Ok(version_output) => { + if version_output.status.success() { + return Ok(Some((binary, version_output))); + } + let stdout = trim_to_non_empty(std::str::from_utf8(&version_output.stdout).ok()); + let stderr = trim_to_non_empty(std::str::from_utf8(&version_output.stderr).ok()); + let detail = match (stdout, stderr) { + (Some(out), Some(err)) => format!("stdout: {out}; stderr: {err}"), + (Some(out), None) => format!("stdout: {out}"), + (None, Some(err)) => format!("stderr: {err}"), + (None, None) => "no output".to_string(), + }; + failures.push(format!( + "{}: tailscale version failed ({detail})", + OsStr::new(&binary).to_string_lossy() + )); + } Err(err) if err.kind() == ErrorKind::NotFound => continue, Err(err) => failures.push(format!("{}: {err}", OsStr::new(&binary).to_string_lossy())), } @@ -311,7 +343,7 @@ pub(crate) async fn tailscale_status() -> Result { let version = trim_to_non_empty(std::str::from_utf8(&version_output.stdout).ok()) .and_then(|raw| raw.lines().next().map(str::trim).map(str::to_string)); - let status_output = tokio_command(&tailscale_binary) + let status_output = tailscale_command(tailscale_binary.as_os_str()) .arg("status") .arg("--json") .output() @@ -337,7 +369,41 @@ pub(crate) async fn tailscale_status() -> Result { let payload = std::str::from_utf8(&status_output.stdout) .map_err(|err| format!("Invalid UTF-8 from tailscale status: {err}"))?; - tailscale_core::status_from_json(version, payload) + let stderr_text = trim_to_non_empty(std::str::from_utf8(&status_output.stderr).ok()); + if payload.trim().is_empty() { + let suffix = stderr_text + .as_deref() + .map(|value| format!(" stderr: {value}")) + .unwrap_or_default(); + return Err(format!( + "tailscale status --json returned empty output.{suffix}" + )); + } + match tailscale_core::status_from_json(version, payload) { + Ok(status) => Ok(status), + Err(err) => { + let trimmed_payload = payload.trim(); + let payload_preview = if trimmed_payload.is_empty() { + None + } else if trimmed_payload.len() > 200 { + Some(format!("{}…", &trimmed_payload[..200])) + } else { + Some(trimmed_payload.to_string()) + }; + let mut details = Vec::new(); + if let Some(stderr) = stderr_text { + details.push(format!("stderr: {stderr}")); + } + if let Some(preview) = payload_preview { + details.push(format!("stdout: {preview}")); + } + if details.is_empty() { + Err(err) + } else { + Err(format!("{err} ({})", details.join("; "))) + } + } + } } #[cfg(test)] diff --git a/src/features/settings/components/SettingsView.tsx b/src/features/settings/components/SettingsView.tsx index 57ad12c979..c808e62e55 100644 --- a/src/features/settings/components/SettingsView.tsx +++ b/src/features/settings/components/SettingsView.tsx @@ -76,6 +76,22 @@ import { type OrbitActionResult, } from "./settingsViewHelpers"; +const formatErrorMessage = (error: unknown, fallback: string) => { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "string") { + return error; + } + if (error && typeof error === "object" && "message" in error) { + const message = (error as { message?: unknown }).message; + if (typeof message === "string") { + return message; + } + } + return fallback; +}; + export type SettingsViewProps = { workspaceGroups: WorkspaceGroup[]; groupedWorkspaces: Array<{ @@ -673,7 +689,7 @@ export function SettingsView({ setTailscaleStatus(status); } catch (error) { setTailscaleStatusError( - error instanceof Error ? error.message : "Unable to load Tailscale status.", + formatErrorMessage(error, "Unable to load Tailscale status."), ); } finally { setTailscaleStatusBusy(false); @@ -690,9 +706,7 @@ export function SettingsView({ setTailscaleCommandPreview(preview); } catch (error) { setTailscaleCommandError( - error instanceof Error - ? error.message - : "Unable to build Tailscale daemon command.", + formatErrorMessage(error, "Unable to build Tailscale daemon command."), ); } finally { setTailscaleCommandBusy(false); From 3f82df10ad79925acc265698145200db15ca2f29 Mon Sep 17 00:00:00 2001 From: Gurpartap Singh Date: Mon, 9 Feb 2026 22:17:52 +0530 Subject: [PATCH 07/34] fix: iOS Swift library search paths (#377) Co-authored-by: Thomas Ricouard --- .../gen/apple/codex-monitor.xcodeproj/project.pbxproj | 8 ++++---- src-tauri/gen/apple/project.yml | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src-tauri/gen/apple/codex-monitor.xcodeproj/project.pbxproj b/src-tauri/gen/apple/codex-monitor.xcodeproj/project.pbxproj index 73729ec451..aa1500bfca 100644 --- a/src-tauri/gen/apple/codex-monitor.xcodeproj/project.pbxproj +++ b/src-tauri/gen/apple/codex-monitor.xcodeproj/project.pbxproj @@ -492,8 +492,8 @@ "-lz", "-liconv", ); - "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"; - "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"; + "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DT_TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(DT_TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"; + "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DT_TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(DT_TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"; PRODUCT_BUNDLE_IDENTIFIER = com.dimillian.codexmonitor.ios; PRODUCT_NAME = "Codex Monitor"; SDKROOT = iphoneos; @@ -529,8 +529,8 @@ "-lz", "-liconv", ); - "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"; - "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"; + "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DT_TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(DT_TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"; + "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DT_TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(DT_TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"; PRODUCT_BUNDLE_IDENTIFIER = com.dimillian.codexmonitor.ios; PRODUCT_NAME = "Codex Monitor"; SDKROOT = iphoneos; diff --git a/src-tauri/gen/apple/project.yml b/src-tauri/gen/apple/project.yml index 8e4c75a782..fa830dc7f7 100644 --- a/src-tauri/gen/apple/project.yml +++ b/src-tauri/gen/apple/project.yml @@ -67,8 +67,8 @@ targets: ARCHS: [arm64] VALID_ARCHS: arm64 OTHER_LDFLAGS: $(inherited) -lz -liconv - LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME) - LIBRARY_SEARCH_PATHS[arch=arm64]: $(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME) + LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DT_TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(DT_TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME) + LIBRARY_SEARCH_PATHS[arch=arm64]: $(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DT_TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(DT_TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME) ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: true EXCLUDED_ARCHS[sdk=iphoneos*]: x86_64 groups: [app] From 5c3508ea2ee29e05d89bd67cc5c90a1cceaf0279 Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Mon, 9 Feb 2026 20:57:04 +0100 Subject: [PATCH 08/34] fix: token usage reset handling in UI (#378) --- src/features/app/hooks/useAppServerEvents.ts | 8 ++++---- src/features/threads/hooks/useThreadTurnEvents.ts | 6 +++++- src/features/threads/utils/threadNormalize.ts | 11 +++++++---- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/features/app/hooks/useAppServerEvents.ts b/src/features/app/hooks/useAppServerEvents.ts index 45dd3dffbc..137f5f49b2 100644 --- a/src/features/app/hooks/useAppServerEvents.ts +++ b/src/features/app/hooks/useAppServerEvents.ts @@ -77,7 +77,7 @@ type AppServerEventHandlers = { onThreadTokenUsageUpdated?: ( workspaceId: string, threadId: string, - tokenUsage: Record, + tokenUsage: Record | null, ) => void; onAccountRateLimitsUpdated?: ( workspaceId: string, @@ -299,9 +299,9 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { if (method === "thread/tokenUsage/updated") { const threadId = String(params.threadId ?? params.thread_id ?? ""); const tokenUsage = - (params.tokenUsage as Record | undefined) ?? - (params.token_usage as Record | undefined); - if (threadId && tokenUsage) { + (params.tokenUsage as Record | null | undefined) ?? + (params.token_usage as Record | null | undefined); + if (threadId && tokenUsage !== undefined) { handlers.onThreadTokenUsageUpdated?.(workspace_id, threadId, tokenUsage); } return; diff --git a/src/features/threads/hooks/useThreadTurnEvents.ts b/src/features/threads/hooks/useThreadTurnEvents.ts index 4f1913bc9a..dac3dbda5e 100644 --- a/src/features/threads/hooks/useThreadTurnEvents.ts +++ b/src/features/threads/hooks/useThreadTurnEvents.ts @@ -171,7 +171,11 @@ export function useThreadTurnEvents({ ); const onThreadTokenUsageUpdated = useCallback( - (workspaceId: string, threadId: string, tokenUsage: Record) => { + ( + workspaceId: string, + threadId: string, + tokenUsage: Record | null, + ) => { dispatch({ type: "ensureThread", workspaceId, threadId }); dispatch({ type: "setThreadTokenUsage", diff --git a/src/features/threads/utils/threadNormalize.ts b/src/features/threads/utils/threadNormalize.ts index dbcc0625c3..4a62be2961 100644 --- a/src/features/threads/utils/threadNormalize.ts +++ b/src/features/threads/utils/threadNormalize.ts @@ -73,9 +73,12 @@ export function extractReviewThreadId(response: unknown): string | null { return threadId || null; } -export function normalizeTokenUsage(raw: Record): ThreadTokenUsage { - const total = (raw.total as Record) ?? {}; - const last = (raw.last as Record) ?? {}; +export function normalizeTokenUsage( + raw: Record | null | undefined, +): ThreadTokenUsage { + const source = raw ?? {}; + const total = (source.total as Record) ?? {}; + const last = (source.last as Record) ?? {}; return { total: { totalTokens: asNumber(total.totalTokens ?? total.total_tokens), @@ -98,7 +101,7 @@ export function normalizeTokenUsage(raw: Record): ThreadTokenUs ), }, modelContextWindow: (() => { - const value = raw.modelContextWindow ?? raw.model_context_window; + const value = source.modelContextWindow ?? source.model_context_window; if (typeof value === "number") { return value; } From e2f97b47bd3f2cc247e819736a081347ea3e4371 Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Mon, 9 Feb 2026 21:03:55 +0100 Subject: [PATCH 09/34] feat(settings/git): expose editable commit-message prompt in Git settings (#381) --- src-tauri/src/bin/codex_monitor_daemon.rs | 18 +++++- src-tauri/src/codex/mod.rs | 15 ++++- src-tauri/src/shared/codex_aux_core.rs | 19 +++++-- src-tauri/src/types.rs | 16 ++++++ .../settings/components/SettingsView.test.tsx | 2 + .../settings/components/SettingsView.tsx | 55 +++++++++++++++++++ .../sections/SettingsGitSection.tsx | 48 ++++++++++++++++ src/features/settings/hooks/useAppSettings.ts | 7 +++ src/types.ts | 1 + src/utils/commitMessagePrompt.ts | 4 ++ 10 files changed, 177 insertions(+), 8 deletions(-) create mode 100644 src/utils/commitMessagePrompt.ts diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index c5286d0840..3f1e7b18ed 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -1109,7 +1109,14 @@ impl DaemonState { if diff.trim().is_empty() { return Err("No changes to generate commit message for".to_string()); } - Ok(codex_aux_core::build_commit_message_prompt(&diff)) + let commit_message_prompt = { + let settings = self.app_settings.lock().await; + settings.commit_message_prompt.clone() + }; + Ok(codex_aux_core::build_commit_message_prompt( + &diff, + &commit_message_prompt, + )) } async fn generate_commit_message(&self, workspace_id: String) -> Result { @@ -1122,7 +1129,14 @@ impl DaemonState { if diff.trim().is_empty() { return Err("No changes to generate commit message for".to_string()); } - let prompt = codex_aux_core::build_commit_message_prompt(&diff); + let commit_message_prompt = { + let settings = self.app_settings.lock().await; + settings.commit_message_prompt.clone() + }; + let prompt = codex_aux_core::build_commit_message_prompt( + &diff, + &commit_message_prompt, + ); let response = codex_aux_core::run_background_prompt_core( &self.sessions, workspace_id, diff --git a/src-tauri/src/codex/mod.rs b/src-tauri/src/codex/mod.rs index 1a97864cdb..9f6ed20bab 100644 --- a/src-tauri/src/codex/mod.rs +++ b/src-tauri/src/codex/mod.rs @@ -575,8 +575,14 @@ pub(crate) async fn get_commit_message_prompt( return Err("No changes to generate commit message for".to_string()); } + let commit_message_prompt = { + let settings = state.app_settings.lock().await; + settings.commit_message_prompt.clone() + }; + Ok(crate::shared::codex_aux_core::build_commit_message_prompt( &diff, + &commit_message_prompt, )) } @@ -632,7 +638,14 @@ pub(crate) async fn generate_commit_message( return Err("No changes to generate commit message for".to_string()); } - let prompt = crate::shared::codex_aux_core::build_commit_message_prompt(&diff); + let commit_message_prompt = { + let settings = state.app_settings.lock().await; + settings.commit_message_prompt.clone() + }; + let prompt = crate::shared::codex_aux_core::build_commit_message_prompt( + &diff, + &commit_message_prompt, + ); let response = crate::shared::codex_aux_core::run_background_prompt_core( &state.sessions, workspace_id, diff --git a/src-tauri/src/shared/codex_aux_core.rs b/src-tauri/src/shared/codex_aux_core.rs index 69807472ff..3af5a57c42 100644 --- a/src-tauri/src/shared/codex_aux_core.rs +++ b/src-tauri/src/shared/codex_aux_core.rs @@ -12,14 +12,23 @@ use crate::backend::app_server::{ use crate::shared::process_core::tokio_command; use crate::types::AppSettings; -pub(crate) fn build_commit_message_prompt(diff: &str) -> String { - format!( - "Generate a concise git commit message for the following changes. \ +const DEFAULT_COMMIT_MESSAGE_PROMPT: &str = "Generate a concise git commit message for the following changes. \ Follow conventional commit format (e.g., feat:, fix:, refactor:, docs:, etc.). \ Keep the summary line under 72 characters. \ Only output the commit message, nothing else.\n\n\ -Changes:\n{diff}" - ) +Changes:\n{diff}"; + +pub(crate) fn build_commit_message_prompt(diff: &str, template: &str) -> String { + let base = if template.trim().is_empty() { + DEFAULT_COMMIT_MESSAGE_PROMPT + } else { + template + }; + if base.contains("{diff}") { + base.replace("{diff}", diff) + } else { + format!("{base}\n\nChanges:\n{diff}") + } } pub(crate) fn build_run_metadata_prompt(cleaned_prompt: &str) -> String { diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index f0d0092bc0..dfb8c0bf34 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -591,6 +591,11 @@ pub(crate) struct AppSettings { rename = "gitDiffIgnoreWhitespaceChanges" )] pub(crate) git_diff_ignore_whitespace_changes: bool, + #[serde( + default = "default_commit_message_prompt", + rename = "commitMessagePrompt" + )] + pub(crate) commit_message_prompt: String, #[serde( default = "default_system_notifications_enabled", rename = "systemNotificationsEnabled" @@ -924,6 +929,15 @@ fn default_git_diff_ignore_whitespace_changes() -> bool { false } +fn default_commit_message_prompt() -> String { + "Generate a concise git commit message for the following changes. \ +Follow conventional commit format (e.g., feat:, fix:, refactor:, docs:, etc.). \ +Keep the summary line under 72 characters. \ +Only output the commit message, nothing else.\n\n\ +Changes:\n{diff}" + .to_string() +} + fn default_experimental_collab_enabled() -> bool { false } @@ -1169,6 +1183,7 @@ impl Default for AppSettings { system_notifications_enabled: true, preload_git_diffs: default_preload_git_diffs(), git_diff_ignore_whitespace_changes: default_git_diff_ignore_whitespace_changes(), + commit_message_prompt: default_commit_message_prompt(), experimental_collab_enabled: false, collaboration_modes_enabled: true, steer_enabled: true, @@ -1329,6 +1344,7 @@ mod tests { assert!(settings.system_notifications_enabled); assert!(settings.preload_git_diffs); assert!(!settings.git_diff_ignore_whitespace_changes); + assert!(settings.commit_message_prompt.contains("{diff}")); assert!(settings.collaboration_modes_enabled); assert!(settings.steer_enabled); assert!(settings.unified_exec_enabled); diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index e8ee54ac6c..95afa2f816 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -11,6 +11,7 @@ import { import type { ComponentProps } from "react"; import { describe, expect, it, vi } from "vitest"; import type { AppSettings, WorkspaceInfo } from "../../../types"; +import { DEFAULT_COMMIT_MESSAGE_PROMPT } from "../../../utils/commitMessagePrompt"; import { SettingsView } from "./SettingsView"; vi.mock("@tauri-apps/plugin-dialog", () => ({ @@ -68,6 +69,7 @@ const baseSettings: AppSettings = { systemNotificationsEnabled: true, preloadGitDiffs: true, gitDiffIgnoreWhitespaceChanges: false, + commitMessagePrompt: DEFAULT_COMMIT_MESSAGE_PROMPT, experimentalCollabEnabled: false, collaborationModesEnabled: true, steerEnabled: true, diff --git a/src/features/settings/components/SettingsView.tsx b/src/features/settings/components/SettingsView.tsx index c808e62e55..104376758b 100644 --- a/src/features/settings/components/SettingsView.tsx +++ b/src/features/settings/components/SettingsView.tsx @@ -36,6 +36,7 @@ import { clampCodeFontSize, normalizeFontFamily, } from "../../../utils/fonts"; +import { DEFAULT_COMMIT_MESSAGE_PROMPT } from "../../../utils/commitMessagePrompt"; import { useGlobalAgentsMd } from "../hooks/useGlobalAgentsMd"; import { useGlobalCodexConfigToml } from "../hooks/useGlobalCodexConfigToml"; import { useSettingsOpenAppDrafts } from "../hooks/useSettingsOpenAppDrafts"; @@ -208,6 +209,10 @@ export function SettingsView({ ); const [orbitAccessClientSecretRefDraft, setOrbitAccessClientSecretRefDraft] = useState(appSettings.orbitAccessClientSecretRef ?? ""); + const [commitMessagePromptDraft, setCommitMessagePromptDraft] = useState( + appSettings.commitMessagePrompt, + ); + const [commitMessagePromptSaving, setCommitMessagePromptSaving] = useState(false); const [orbitStatusText, setOrbitStatusText] = useState(null); const [orbitAuthCode, setOrbitAuthCode] = useState(null); const [orbitVerificationUrl, setOrbitVerificationUrl] = useState( @@ -419,6 +424,10 @@ export function SettingsView({ setOrbitAccessClientSecretRefDraft(appSettings.orbitAccessClientSecretRef ?? ""); }, [appSettings.orbitAccessClientSecretRef]); + useEffect(() => { + setCommitMessagePromptDraft(appSettings.commitMessagePrompt); + }, [appSettings.commitMessagePrompt]); + useEffect(() => { setScaleDraft(`${Math.round(clampUiScale(appSettings.uiScale) * 100)}%`); }, [appSettings.uiScale]); @@ -447,6 +456,46 @@ export function SettingsView({ } }, []); + const commitMessagePromptDirty = + commitMessagePromptDraft !== appSettings.commitMessagePrompt; + + const handleSaveCommitMessagePrompt = useCallback(async () => { + if (commitMessagePromptSaving || !commitMessagePromptDirty) { + return; + } + setCommitMessagePromptSaving(true); + try { + await onUpdateAppSettings({ + ...appSettings, + commitMessagePrompt: commitMessagePromptDraft, + }); + } finally { + setCommitMessagePromptSaving(false); + } + }, [ + appSettings, + commitMessagePromptDirty, + commitMessagePromptDraft, + commitMessagePromptSaving, + onUpdateAppSettings, + ]); + + const handleResetCommitMessagePrompt = useCallback(async () => { + if (commitMessagePromptSaving) { + return; + } + setCommitMessagePromptDraft(DEFAULT_COMMIT_MESSAGE_PROMPT); + setCommitMessagePromptSaving(true); + try { + await onUpdateAppSettings({ + ...appSettings, + commitMessagePrompt: DEFAULT_COMMIT_MESSAGE_PROMPT, + }); + } finally { + setCommitMessagePromptSaving(false); + } + }, [appSettings, commitMessagePromptSaving, onUpdateAppSettings]); + useEffect(() => { setCodexBinOverrideDrafts((prev) => buildWorkspaceOverrideDrafts( @@ -1431,6 +1480,12 @@ export function SettingsView({ )} {activeSection === "server" && ( diff --git a/src/features/settings/components/sections/SettingsGitSection.tsx b/src/features/settings/components/sections/SettingsGitSection.tsx index 4f054bcb01..2e138f3d7e 100644 --- a/src/features/settings/components/sections/SettingsGitSection.tsx +++ b/src/features/settings/components/sections/SettingsGitSection.tsx @@ -3,11 +3,23 @@ import type { AppSettings } from "../../../../types"; type SettingsGitSectionProps = { appSettings: AppSettings; onUpdateAppSettings: (next: AppSettings) => Promise; + commitMessagePromptDraft: string; + commitMessagePromptDirty: boolean; + commitMessagePromptSaving: boolean; + onSetCommitMessagePromptDraft: (value: string) => void; + onSaveCommitMessagePrompt: () => Promise; + onResetCommitMessagePrompt: () => Promise; }; export function SettingsGitSection({ appSettings, onUpdateAppSettings, + commitMessagePromptDraft, + commitMessagePromptDirty, + commitMessagePromptSaving, + onSetCommitMessagePromptDraft, + onSaveCommitMessagePrompt, + onResetCommitMessagePrompt, }: SettingsGitSectionProps) { return (
@@ -55,6 +67,42 @@ export function SettingsGitSection({
+
+
Commit message prompt
+
+ Used when generating commit messages. Include {"{diff}"} to insert the + git diff. +
+