mirror of
https://github.com/openai/codex.git
synced 2026-11-07 20:30:33 +00:00
Stop TUI voice calls when the OS session locks (#59330)
## What changed - Monitor session lock state on Linux, macOS, and Windows, checking the initial state before starting audio. - Abort local media on lock, including while connecting, retrying, or running a parked call, and display why voice stopped. - Keep lock monitoring across startup retries and ignore stale lock notifications after a call restarts. - Document lock behavior and platform limitations. After unlocking, users must run `/voice` to start a new call. ## Testing Add lifecycle tests for already-locked startup, parked-call media shutdown, stale notifications, and locks during retry cleanup, plus a macOS test for password grace-period expiry. GitOrigin-RevId: fea4b4a7903a48727c546ee3b7bd06e957a9b885
This commit is contained in:
committed by
copyberry
parent
19438adcb6
commit
93b4e5b4c6
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1252,6 +1252,9 @@ pub(crate) enum AppEvent {
|
||||
control: VoiceControl,
|
||||
},
|
||||
RealtimeConversationStateChanged,
|
||||
RealtimeSessionLockStopped {
|
||||
thread_id: ThreadId,
|
||||
},
|
||||
BackgroundVoiceError {
|
||||
thread_id: ThreadId,
|
||||
message: String,
|
||||
|
||||
@@ -51,6 +51,7 @@ impl AppEventSender {
|
||||
) | AppEvent::RealtimeWebrtcOfferCreated { .. }
|
||||
| AppEvent::RealtimeWebrtcConnected { .. }
|
||||
| AppEvent::StopRealtimeConversation { .. }
|
||||
| AppEvent::RealtimeSessionLockStopped { .. }
|
||||
| AppEvent::RealtimeConversationStateChanged
|
||||
| AppEvent::BackgroundVoiceError { .. }
|
||||
| AppEvent::SyncThreadGitBranch { .. }
|
||||
|
||||
@@ -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<session_lock::SessionLock>,
|
||||
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<StartedRealtimeWebrtcSession, RealtimeWebrtcStartupFailure>,
|
||||
) {
|
||||
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<String>) {
|
||||
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;
|
||||
|
||||
@@ -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<State>,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl SessionLock {
|
||||
pub(super) fn observe(
|
||||
thread_id: ThreadId,
|
||||
tx: AppEventSender,
|
||||
setup: impl Future<Output = anyhow::Result<Activation>> + 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());
|
||||
}
|
||||
}
|
||||
@@ -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::<Vec<_>>()
|
||||
.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;
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/realtime_tests/lifecycle.rs
|
||||
expression: rendered
|
||||
---
|
||||
■ Voice stopped because your session locked.
|
||||
• Voice conversation ended: requested
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Box<dyn Future<Output = anyhow::Result<()>> + Send>>;
|
||||
|
||||
pub(crate) async fn observe() -> anyhow::Result<Activation> {
|
||||
#[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()))
|
||||
}
|
||||
@@ -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<Activation> {
|
||||
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<OwnedObjectPath> = 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::<Activation, anyhow::Error>(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<String, OwnedValue>, Vec<String>) =
|
||||
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::<bool>("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<OwnedObjectPath> {
|
||||
// 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<'static>> {
|
||||
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<u32> {
|
||||
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::<u32>().ok()?;
|
||||
number
|
||||
} else {
|
||||
number
|
||||
};
|
||||
number.parse().ok()
|
||||
}
|
||||
@@ -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<bool> {
|
||||
// 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<CFString, CFType> =
|
||||
unsafe { TCFType::wrap_under_create_rule(session) };
|
||||
Ok(requires_authentication(&session, Utc::now().timestamp()))
|
||||
}
|
||||
|
||||
fn requires_authentication(session: &CFDictionary<CFString, CFType>, now: i64) -> bool {
|
||||
let lock_screen_active = session
|
||||
.find(CFString::new("CGSSessionScreenIsLocked"))
|
||||
.and_then(|locked| locked.downcast::<CFBoolean>())
|
||||
.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::<CFNumber>())
|
||||
.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;
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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<bool> {
|
||||
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::<WTSINFOEXW>() {
|
||||
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::<WTSINFOEXW>().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
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user