diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 48b39110c7..2b8f5ae8f0 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -5147,6 +5147,7 @@ dependencies = [ "codex-windows-sandbox", "codex-worktree", "color-eyre", + "core-foundation 0.10.1", "core_test_support", "crossterm", "derive_more 2.1.1", diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 47968b8694..b58450ef86 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -141,6 +141,7 @@ tokio-util = { workspace = true, features = ["time"] } libc = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] +core-foundation = "0.10" objc2-app-kit = { version = "0.3.2", default-features = false, features = ["std", "NSAccessibility", "NSWorkspace"] } objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSDateFormatter", "NSFormatter", "NSLocale", "NSString"] } diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index a4c856e122..ffa3a3fc02 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -2367,6 +2367,11 @@ impl App { self.control_voice(control); } } + AppEvent::RealtimeSessionLockStopped { thread_id } => { + if let Some(owner) = self.voice_widget_for_thread(thread_id) { + owner.stop_if_session_locked(); + } + } AppEvent::RealtimeWebrtcOfferCreated { thread_id, attempt_id, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 952dcc84d5..5c0cf72fb3 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1252,6 +1252,9 @@ pub(crate) enum AppEvent { control: VoiceControl, }, RealtimeConversationStateChanged, + RealtimeSessionLockStopped { + thread_id: ThreadId, + }, BackgroundVoiceError { thread_id: ThreadId, message: String, diff --git a/codex-rs/tui/src/app_event_sender.rs b/codex-rs/tui/src/app_event_sender.rs index 0057b90ad0..cf3882a4b1 100644 --- a/codex-rs/tui/src/app_event_sender.rs +++ b/codex-rs/tui/src/app_event_sender.rs @@ -51,6 +51,7 @@ impl AppEventSender { ) | AppEvent::RealtimeWebrtcOfferCreated { .. } | AppEvent::RealtimeWebrtcConnected { .. } | AppEvent::StopRealtimeConversation { .. } + | AppEvent::RealtimeSessionLockStopped { .. } | AppEvent::RealtimeConversationStateChanged | AppEvent::BackgroundVoiceError { .. } | AppEvent::SyncThreadGitBranch { .. } diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs index fc794186a3..bf07714f7b 100644 --- a/codex-rs/tui/src/chatwidget/realtime.rs +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -5,6 +5,7 @@ mod failure_metrics; mod recording_controls; +mod session_lock; mod transcript_replay; pub(crate) use failure_metrics::RealtimeFailureCause; @@ -138,6 +139,7 @@ enum StartupRetry { #[derive(Default)] pub(super) struct RealtimeConversationUiState { startup_retry: StartupRetry, + session_lock: Option, pub(super) phase: RealtimeConversationPhase, recover_late_transcripts: bool, attempt_id: u64, @@ -319,11 +321,18 @@ impl ChatWidget { NEXT_REALTIME_ATTEMPT_ID.fetch_add(1, Ordering::Relaxed); self.realtime_conversation.thread_id = Some(thread_id); let attempt_id = self.realtime_conversation.attempt_id; - let (startup_abort, abort_registration) = AbortHandle::new_pair(); - self.realtime_conversation.startup_abort = Some(startup_abort); self.realtime_conversation.phase = RealtimeConversationPhase::Starting; self.update_realtime_footer(); - let app_event_tx = self.app_event_tx.clone(); + let session_lock = self + .realtime_conversation + .session_lock + .get_or_insert_with(|| { + session_lock::SessionLock::observe( + thread_id, + self.app_event_tx.clone(), + crate::session_lock::observe(), + ) + }); let selection = codex_realtime_webrtc::AudioDeviceSelection { microphone: audio.microphone, speaker: audio.speaker, @@ -331,7 +340,8 @@ impl ChatWidget { .microphone_channel .map(|channels| channels.as_slice().to_vec()), }; - std::thread::spawn(move || { + let app_event_tx = self.app_event_tx.clone(); + let startup_abort = session_lock.start_media(move |abort_registration| { let result = RealtimeWebrtcSession::start(abort_registration, selection).map_err(|error| { RealtimeWebrtcStartupFailure { @@ -348,10 +358,12 @@ impl ChatWidget { result, }); }); + self.realtime_conversation.startup_abort = Some(startup_abort); self.request_redraw(); } pub(crate) fn stop_realtime_conversation(&mut self) { + self.realtime_conversation.session_lock.take(); self.realtime_conversation.startup_retry = StartupRetry::Used; if matches!( self.realtime_conversation.phase, @@ -392,6 +404,7 @@ impl ChatWidget { attempt_id: u64, result: Result, ) { + self.stop_if_session_locked(); if self.realtime_conversation.phase != RealtimeConversationPhase::Starting || self.realtime_conversation.attempt_id != attempt_id { @@ -478,6 +491,7 @@ impl ChatWidget { attempt_id: u64, result: Result<(), codex_realtime_webrtc::ConnectionError>, ) { + self.stop_if_session_locked(); if self.realtime_conversation.phase != RealtimeConversationPhase::Starting || self.realtime_conversation.attempt_id != attempt_id { @@ -1716,6 +1730,7 @@ impl ChatWidget { } pub(super) fn on_realtime_conversation_closed(&mut self, reason: Option) { + self.stop_if_session_locked(); if self.realtime_conversation.phase == RealtimeConversationPhase::Inactive { if self.realtime_conversation.recover_late_transcripts { self.finish_realtime_partial_transcripts(); @@ -1757,8 +1772,11 @@ impl ChatWidget { self.record_realtime_failure(cause); } let failed = self.realtime_conversation.failure_recorded; + let session_lock = + retry_thread_id.and_then(|_| self.realtime_conversation.session_lock.take()); self.reset_realtime_conversation(); if let Some(thread_id) = retry_thread_id { + self.realtime_conversation.session_lock = session_lock; // The old backend is closed. A late peer result belongs to its attempt ID. self.realtime_conversation.startup_retry = StartupRetry::Used; self.realtime_conversation.microphone_muted = muted; diff --git a/codex-rs/tui/src/chatwidget/realtime/session_lock.rs b/codex-rs/tui/src/chatwidget/realtime/session_lock.rs new file mode 100644 index 0000000000..0ca35149a6 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/realtime/session_lock.rs @@ -0,0 +1,98 @@ +//! Owns one desktop observer across a call's startup retry and widget parking. +//! Activation aborts local media before the TUI handles the wakeup; only current state may stop a call. + +use super::ChatWidget; +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::session_lock::Activation; +use codex_protocol::ThreadId; +use futures::FutureExt; +use futures::future::AbortHandle; +use futures::future::AbortRegistration; +use tokio::task::JoinHandle; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum State { + Initializing, + Ready, + Stopped(&'static str), +} + +pub(super) struct SessionLock { + state: tokio::sync::watch::Receiver, + task: JoinHandle<()>, +} + +impl SessionLock { + pub(super) fn observe( + thread_id: ThreadId, + tx: AppEventSender, + setup: impl Future> + Send + 'static, + ) -> Self { + let (state_tx, state) = tokio::sync::watch::channel(State::Initializing); + let task = tokio::spawn(async move { + let result = match setup.await { + Ok(mut activation) => match activation.as_mut().now_or_never() { + Some(result) => result, + None => { + state_tx.send_replace(State::Ready); + activation.await + } + }, + Err(error) => Err(error), + }; + let message = match result { + Ok(()) => "Voice stopped because your session locked.", + Err(error) => { + tracing::debug!(%error, "session lock observer failed"); + "Voice conversation stopped because session lock monitoring failed." + } + }; + state_tx.send_replace(State::Stopped(message)); + tx.send(AppEvent::RealtimeSessionLockStopped { thread_id }); + }); + Self { state, task } + } + + /// Gate the attempt on the initial snapshot and abort its media actor on activation, + /// even if the TUI is still waiting for an app-server operation. + pub(super) fn start_media( + &self, + start: impl FnOnce(AbortRegistration) + Send + 'static, + ) -> AbortHandle { + let mut state = self.state.clone(); + let (abort, registration) = AbortHandle::new_pair(); + let media = abort.clone(); + tokio::spawn(async move { + let ready = state + .wait_for(|state| *state != State::Initializing) + .await + .is_ok_and(|state| *state == State::Ready); + if ready { + std::thread::spawn(move || start(registration)); + let _ = state.changed().await; + } + media.abort(); + }); + abort + } +} + +impl Drop for SessionLock { + fn drop(&mut self) { + self.task.abort(); + } +} + +impl ChatWidget { + pub(crate) fn stop_if_session_locked(&mut self) { + let Some(observer) = &self.realtime_conversation.session_lock else { + return; + }; + let State::Stopped(message) = *observer.state.borrow() else { + return; + }; + self.stop_realtime_conversation(); + self.add_realtime_error(message.into()); + } +} diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/lifecycle.rs b/codex-rs/tui/src/chatwidget/realtime_tests/lifecycle.rs index a0bf879b70..8ff6abc221 100644 --- a/codex-rs/tui/src/chatwidget/realtime_tests/lifecycle.rs +++ b/codex-rs/tui/src/chatwidget/realtime_tests/lifecycle.rs @@ -1,5 +1,6 @@ //! Voice startup, shutdown, and retry maintain one active owned session. +use super::super::session_lock::SessionLock; use super::*; use crate::app_event::RealtimeWebrtcStartupFailure; use pretty_assertions::assert_eq; @@ -408,6 +409,120 @@ async fn startup_retry_is_cancelled_while_waiting_for_backend_close() { ); } +fn watch_session_lock( + chat: &ChatWidget, + thread_id: ThreadId, +) -> (SessionLock, tokio::sync::oneshot::Sender<()>) { + let (activate, activated) = tokio::sync::oneshot::channel(); + let observer = SessionLock::observe(thread_id, chat.app_event_tx.clone(), async { + Ok(Box::pin(async { Ok(activated.await?) }) as crate::session_lock::Activation) + }); + (observer, activate) +} + +#[tokio::test] +async fn already_locked_session_prevents_microphone_startup() { + let (chat, _, _events, _ops) = make_chatwidget_manual_with_sender().await; + let observer = SessionLock::observe(ThreadId::new(), chat.app_event_tx.clone(), async { + Ok(Box::pin(async { Ok(()) }) as crate::session_lock::Activation) + }); + let (opened, microphone) = tokio::sync::oneshot::channel(); + let abort = observer.start_media(move |_| { + let _ = opened.send(()); + }); + assert!(microphone.await.is_err()); + assert!(abort.is_aborted()); +} + +#[tokio::test] +async fn session_lock_stops_a_parked_call_and_ignores_its_late_notice_after_restart() { + let (mut chat, _, mut events, mut ops) = make_chatwidget_manual_with_sender().await; + let thread_id = activate_voice(&mut chat); + let (observer, activate) = watch_session_lock(&chat, thread_id); + let (opened, microphone) = tokio::sync::oneshot::channel(); + observer.start_media(move |registration| { + let _ = opened.send(registration); + }); + chat.realtime_conversation.session_lock = Some(observer); + let microphone = + futures::future::Abortable::new(std::future::pending::<()>(), microphone.await.unwrap()); + chat.park_voice(); + activate.send(()).unwrap(); + // Media shuts down even before the UI dispatches the notice to the parked widget. + assert!(microphone.await.is_err()); + let event = events.recv().await.unwrap(); + assert!( + matches!(event, AppEvent::RealtimeSessionLockStopped { thread_id: owner } if owner == thread_id) + ); + chat.stop_if_session_locked(); + assert_eq!( + (chat.realtime_conversation.phase, ops.try_recv().ok()), + ( + RealtimeConversationPhase::Inactive, + Some(AppCommand::RealtimeConversationStop { thread_id }) + ) + ); + assert!( + std::iter::from_fn(|| events.try_recv().ok()).any(|event| matches!( + event, AppEvent::BackgroundVoiceError { thread_id: owner, message: reason } + if owner == thread_id && reason.contains("session locked") + )) + ); + + chat.start_realtime_conversation(thread_id); + chat.stop_if_session_locked(); + assert_eq!( + chat.realtime_conversation.phase, + RealtimeConversationPhase::Starting + ); + chat.reset_realtime_conversation(); +} + +#[tokio::test] +async fn session_lock_during_retry_cleanup_keeps_the_microphone_off() { + let (mut chat, _, mut events, mut ops) = make_chatwidget_manual_with_sender().await; + let thread_id = activate_voice(&mut chat); + let first_attempt = chat.realtime_conversation.attempt_id; + let (observer, activate) = watch_session_lock(&chat, thread_id); + chat.realtime_conversation.session_lock = Some(observer); + chat.realtime_conversation.phase = RealtimeConversationPhase::Starting; + chat.on_realtime_webrtc_connected( + first_attempt, + Err(codex_realtime_webrtc::ConnectionError::NegotiationTimedOut), + ); + assert_eq!( + ops.try_recv().unwrap(), + AppCommand::RealtimeConversationStop { thread_id } + ); + activate.send(()).unwrap(); + while !matches!( + events.recv().await.unwrap(), + AppEvent::RealtimeSessionLockStopped { .. } + ) {} + // The close response can overtake dispatch of the queued session-lock event. + chat.on_realtime_conversation_closed(Some("requested".into())); + chat.stop_if_session_locked(); + assert_eq!( + ( + chat.realtime_conversation.phase, + chat.realtime_conversation.attempt_id + ), + (RealtimeConversationPhase::Inactive, first_attempt) + ); + assert!(ops.try_recv().is_err()); + commit_realtime_history_events(&mut chat, &mut events); + let rendered = std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some(cell.display_lines(/*width*/ 80)), + _ => None, + }) + .flatten() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + insta::assert_snapshot!("voice_session_lock_stop", rendered); +} + #[tokio::test] async fn startup_transport_close_before_peer_timeout_retries_once_and_ignores_old_result() { let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await; diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__lifecycle__voice_session_lock_stop.snap b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__lifecycle__voice_session_lock_stop.snap new file mode 100644 index 0000000000..25b1ba81b4 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__lifecycle__voice_session_lock_stop.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/realtime_tests/lifecycle.rs +expression: rendered +--- +■ Voice stopped because your session locked. +• Voice conversation ended: requested diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 362bbd7228..04048c7d16 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -204,6 +204,7 @@ mod resume_picker; mod screen_reader; mod service_tier_resolution; mod session_archive_commands; +mod session_lock; mod session_log; mod session_queue_commands; mod session_resume; diff --git a/codex-rs/tui/src/session_lock.rs b/codex-rs/tui/src/session_lock.rs new file mode 100644 index 0000000000..04ad802f35 --- /dev/null +++ b/codex-rs/tui/src/session_lock.rs @@ -0,0 +1,45 @@ +//! Observe the local session lock for one explicit realtime call, including its transport retries. +//! +//! Setup takes an initial snapshot before audio starts. Dropping the returned future unregisters +//! observation; there are no detached tasks or threads. Polling requires the TUI runtime to run. + +use std::future::Future; +use std::pin::Pin; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(windows)] +mod windows; + +#[cfg(target_os = "macos")] +use macos::is_locked; +#[cfg(windows)] +use windows::is_locked; + +pub(crate) type Activation = Pin> + Send>>; + +pub(crate) async fn observe() -> anyhow::Result { + #[cfg(target_os = "linux")] + return linux::observe().await; + + #[cfg(any(target_os = "macos", windows))] + { + let initially_locked = is_locked()?; + Ok(Box::pin(async move { + if initially_locked { + return Ok(()); + } + loop { + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + if is_locked()? { + return Ok(()); + } + } + })) + } + + #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] + Ok(Box::pin(std::future::pending())) +} diff --git a/codex-rs/tui/src/session_lock/linux.rs b/codex-rs/tui/src/session_lock/linux.rs new file mode 100644 index 0000000000..79bdeed875 --- /dev/null +++ b/codex-rs/tui/src/session_lock/linux.rs @@ -0,0 +1,203 @@ +//! Observe the local graphical session's confirmed lock state through system logind. +//! +//! Resolve a real session object, subscribe before reading its lock state, and retain individual +//! property-change payloads so a quick lock/unlock cannot erase the lock. Unsupported environments +//! leave voice available; idle hints and requests to lock are not confirmed locks. + +use std::collections::HashMap; + +use futures::StreamExt; +use zbus::zvariant::OwnedObjectPath; +use zbus::zvariant::OwnedValue; + +use super::Activation; + +const SERVICE: &str = "org.freedesktop.login1"; +const SESSION: &str = "org.freedesktop.login1.Session"; + +pub(super) async fn observe() -> anyhow::Result { + let setup = async { + // SAFETY: geteuid has no preconditions and does not mutate process state. + let current_uid = unsafe { libc::geteuid() }; + let connection = zbus::Connection::system().await?; + let manager = zbus::Proxy::new( + &connection, + SERVICE, + "/org/freedesktop/login1", + "org.freedesktop.login1.Manager", + ) + .await?; + let path: Option = match manager + .call::<_, _, OwnedObjectPath>("GetSessionByPID", &std::process::id()) + .await + { + Ok(path) => { + let candidate = session_proxy(&connection, path.clone()).await?; + let class: String = candidate.get_property("Class").await?; + if class == "manager" || class == "manager-early" { + None + } else { + Some(path) + } + } + Err(zbus::Error::MethodError(name, _, _)) + if name.as_str() == "org.freedesktop.login1.NoSessionForPID" => + { + None + } + Err(error) => return Err(error.into()), + }; + let path = if let Some(path) = path { + path + } else { + // Graphical terminals may be launched by the per-user service manager, outside + // the login scope. Prefer the client environment's explicit session association. + if std::env::var_os("SSH_CONNECTION").is_some() || std::env::var_os("SSH_TTY").is_some() + { + anyhow::bail!("remote terminal has no graphical session"); + } + match std::env::var("XDG_SESSION_ID") { + Ok(session_id) => { + anyhow::ensure!( + !session_id.is_empty() && !matches!(session_id.as_str(), "self" | "auto"), + "no explicit graphical session" + ); + manager.call("GetSession", &session_id).await? + } + Err(std::env::VarError::NotPresent) => { + unique_graphical_session(&connection, &manager, current_uid).await? + } + Err(error) => return Err(error.into()), + } + }; + let session = session_proxy(&connection, path.clone()).await?; + let remote: bool = session.get_property("Remote").await?; + let kind: String = session.get_property("Type").await?; + let class: String = session.get_property("Class").await?; + let (uid, _): (u32, OwnedObjectPath) = session.get_property("User").await?; + let (seat, _): (String, OwnedObjectPath) = session.get_property("Seat").await?; + anyhow::ensure!( + !remote + && is_graphical_user_session(&kind, &class) + && uid == current_uid + && !seat.is_empty(), + "process is not in a local graphical user session" + ); + let properties = zbus::Proxy::new( + &connection, + SERVICE, + path, + "org.freedesktop.DBus.Properties", + ) + .await?; + // Do not use receive_property_changed: zbus coalesces its cached updates, potentially + // replacing a queued true with false before the TUI can observe it. + let mut changes = properties + .receive_signal_with_args("PropertiesChanged", &[(0, SESSION)]) + .await?; + let initially_locked: bool = session.get_property("LockedHint").await?; + Ok::(Box::pin(async move { + // Keep the connection and session alive until this explicit voice call ends. + let _connection = connection; + if initially_locked { + return Ok(()); + } + while let Some(signal) = changes.next().await { + let (_, changed, invalidated): (String, HashMap, Vec) = + signal.body().deserialize()?; + if changed + .get("LockedHint") + .and_then(|value| bool::try_from(value).ok()) + == Some(true) + || (invalidated.iter().any(|name| name == "LockedHint") + && session.get_property::("LockedHint").await?) + { + return Ok(()); + } + } + anyhow::bail!("desktop lock observation ended") + })) + }; + // Missing logind, unsupported sessions, and unavailable properties must not disable voice. + match tokio::time::timeout(std::time::Duration::from_secs(3), setup).await { + Ok(Ok(activation)) => Ok(activation), + Ok(Err(_)) | Err(_) => Ok(Box::pin(std::future::pending())), + } +} + +async fn unique_graphical_session( + connection: &zbus::Connection, + manager: &zbus::Proxy<'_>, + current_uid: u32, +) -> anyhow::Result { + // GNOME terminal servers may forward DISPLAY/WAYLAND_DISPLAY without a + // session ID. Resolve only an unambiguous graphical login for this user. + let display = std::env::var("DISPLAY").ok(); + let local_display = display.as_deref().and_then(local_x_display); + let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some_and(|value| !value.is_empty()); + anyhow::ensure!( + local_display.is_some() || wayland, + "no local graphical client environment" + ); + let sessions: Vec<(String, u32, String, String, OwnedObjectPath)> = + manager.call("ListSessions", &()).await?; + let mut selected = None; + for (_, uid, _, _, path) in sessions { + if uid != current_uid { + continue; + } + let candidate = session_proxy(connection, path.clone()).await?; + let kind: String = candidate.get_property("Type").await?; + let class: String = candidate.get_property("Class").await?; + if is_graphical_user_session(&kind, &class) { + // Remote/seatless graphical logins also make association ambiguous. + anyhow::ensure!(selected.is_none(), "ambiguous graphical session"); + if kind == "x11" + && let Some(client_display) = local_display + { + let display: String = candidate.get_property("Display").await?; + anyhow::ensure!( + display.is_empty() || local_x_display(&display) == Some(client_display), + "graphical session display does not match client" + ); + } + selected = Some(path); + } + } + selected.ok_or_else(|| anyhow::anyhow!("no graphical user session")) +} + +fn is_graphical_user_session(kind: &str, class: &str) -> bool { + matches!(kind, "x11" | "wayland" | "mir") + && matches!( + class, + "user" | "user-early" | "user-light" | "user-early-light" + ) +} + +async fn session_proxy( + connection: &zbus::Connection, + path: OwnedObjectPath, +) -> zbus::Result> { + zbus::proxy::Builder::new(connection) + .destination(SERVICE)? + .path(path)? + .interface(SESSION)? + .cache_properties(zbus::proxy::CacheProperties::No) + .build() + .await +} + +fn local_x_display(display: &str) -> Option { + let number = display + .strip_prefix(':') + .or_else(|| display.strip_prefix("unix:")) + .or_else(|| display.strip_prefix("unix/:"))?; + let number = if let Some((number, screen)) = number.split_once('.') { + screen.parse::().ok()?; + number + } else { + number + }; + number.parse().ok() +} diff --git a/codex-rs/tui/src/session_lock/macos.rs b/codex-rs/tui/src/session_lock/macos.rs new file mode 100644 index 0000000000..fcc1fbbf6a --- /dev/null +++ b/codex-rs/tui/src/session_lock/macos.rs @@ -0,0 +1,43 @@ +//! Read the lock state of the calling process's macOS login session. + +use chrono::Utc; +use core_foundation::base::CFType; +use core_foundation::base::TCFType; +use core_foundation::boolean::CFBoolean; +use core_foundation::dictionary::CFDictionary; +use core_foundation::dictionary::CFDictionaryRef; +use core_foundation::number::CFNumber; +use core_foundation::string::CFString; + +#[link(name = "CoreGraphics", kind = "framework")] +unsafe extern "C" { + fn CGSessionCopyCurrentDictionary() -> CFDictionaryRef; +} + +pub(super) fn is_locked() -> anyhow::Result { + // SAFETY: The function takes no arguments and returns an owned dictionary or null. + let session = unsafe { CGSessionCopyCurrentDictionary() }; + anyhow::ensure!(!session.is_null(), "macOS login session unavailable"); + // SAFETY: CGSession follows the CF create rule. The dictionary owns its CF strings and values. + let session: CFDictionary = + unsafe { TCFType::wrap_under_create_rule(session) }; + Ok(requires_authentication(&session, Utc::now().timestamp())) +} + +fn requires_authentication(session: &CFDictionary, now: i64) -> bool { + let lock_screen_active = session + .find(CFString::new("CGSSessionScreenIsLocked")) + .and_then(|locked| locked.downcast::()) + .is_some_and(bool::from); + // loginwindow sets the flag as soon as the saver starts. XNU enforces the password + // after this per-session Unix timestamp, including when the grace period expires. + let password_required_at = session + .find(CFString::new("CGSSessionScreenLockedTime")) + .and_then(|time| time.downcast::()) + .and_then(|time| time.to_i64()); + lock_screen_active && password_required_at.is_some_and(|time| (1..=now).contains(&time)) +} + +#[cfg(test)] +#[path = "macos_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/session_lock/macos_tests.rs b/codex-rs/tui/src/session_lock/macos_tests.rs new file mode 100644 index 0000000000..7e3c4b8fb4 --- /dev/null +++ b/codex-rs/tui/src/session_lock/macos_tests.rs @@ -0,0 +1,22 @@ +#![cfg(test)] + +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn screen_saver_stops_voice_when_its_password_grace_period_expires() { + let password_required_at = 1_793_735_319_i64; + let session = CFDictionary::from_CFType_pairs(&[ + ( + CFString::new("CGSSessionScreenIsLocked"), + CFBoolean::true_value().as_CFType(), + ), + ( + CFString::new("CGSSessionScreenLockedTime"), + CFNumber::from(password_required_at).as_CFType(), + ), + ]); + let observed = [password_required_at - 1, password_required_at] + .map(|now| requires_authentication(&session, now)); + assert_eq!(observed, [false, true]); +} diff --git a/codex-rs/tui/src/session_lock/windows.rs b/codex-rs/tui/src/session_lock/windows.rs new file mode 100644 index 0000000000..86d122bf27 --- /dev/null +++ b/codex-rs/tui/src/session_lock/windows.rs @@ -0,0 +1,59 @@ +//! Snapshot the lock state of the current Windows session. + +use windows_sys::Win32::System::RemoteDesktop::WTS_CURRENT_SERVER_HANDLE; +use windows_sys::Win32::System::RemoteDesktop::WTS_CURRENT_SESSION; +use windows_sys::Win32::System::RemoteDesktop::WTS_SESSIONSTATE_LOCK; +use windows_sys::Win32::System::RemoteDesktop::WTS_SESSIONSTATE_UNLOCK; +use windows_sys::Win32::System::RemoteDesktop::WTSFreeMemory; +use windows_sys::Win32::System::RemoteDesktop::WTSINFOEXW; +use windows_sys::Win32::System::RemoteDesktop::WTSQuerySessionInformationW; +use windows_sys::Win32::System::RemoteDesktop::WTSSessionInfoEx; + +pub(super) fn is_locked() -> anyhow::Result { + let mut buffer = std::ptr::null_mut(); + let mut bytes = 0; + // SAFETY: The output pointers are valid, and the returned buffer is released below. + let succeeded = unsafe { + WTSQuerySessionInformationW( + WTS_CURRENT_SERVER_HANDLE, + WTS_CURRENT_SESSION, + WTSSessionInfoEx, + &mut buffer, + &mut bytes, + ) + }; + if succeeded == 0 { + return Err(std::io::Error::last_os_error().into()); + } + + let result = if buffer.is_null() || (bytes as usize) < std::mem::size_of::() { + Err(anyhow::anyhow!( + "invalid Windows session information buffer" + )) + } else { + // SAFETY: The API returned a buffer large enough for WTSINFOEXW. Reading + // unaligned avoids relying on the allocator's alignment guarantees. + let info = unsafe { buffer.cast::().read_unaligned() }; + if info.Level != 1 { + Err(anyhow::anyhow!( + "unsupported Windows session information level: {}", + info.Level + )) + } else { + // SAFETY: Level 1 identifies this union member. + let flags = unsafe { info.Data.WTSInfoExLevel1.SessionFlags } as u32; + match flags { + WTS_SESSIONSTATE_LOCK => Ok(true), + WTS_SESSIONSTATE_UNLOCK => Ok(false), + _ => Err(anyhow::anyhow!( + "unknown Windows session lock state: {flags}" + )), + } + } + }; + if !buffer.is_null() { + // SAFETY: This buffer was allocated by WTSQuerySessionInformationW. + unsafe { WTSFreeMemory(buffer.cast()) }; + } + result +} diff --git a/codex-rs/tui/voice.md b/codex-rs/tui/voice.md new file mode 100644 index 0000000000..cb546116d2 --- /dev/null +++ b/codex-rs/tui/voice.md @@ -0,0 +1,12 @@ +# Voice when the desktop locks + +The TUI ends a voice call when it detects that your OS session has locked, +including while the call is connecting or retrying. Voice stays on through a +screen saver until the OS reports a lock. After unlocking, run `/voice` to +start a new call. + +On Linux, the TUI follows the desktop's lock report to logind. KDE Plasma +can report a lock while its screen saver can still be dismissed without a +password, so voice may stop at the start of that grace period. In a +headless terminal or with a locker that never reports a lock, use `/voice` +to end the call before leaving.