Implement native inhibition and acknowledged session-ending confirmation

This commit is contained in:
Samuel Onoja committed 2026-09-30 02:38:54 +01:00
1 parent cc441c049e
commit 29a71e9974
27 files changed
+1915 -142

No files matched your search

+1 -2
View File
@@ -105,8 +105,7 @@ impl CompositorHandler for Ferese {
self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
crate::backends::direct::render_all(self);
if self.idle_inhibitors.remove(surface).is_some() {
self.idle_notifier_state
.set_is_inhibited(!self.idle_inhibitors.is_empty());
self.refresh_idle_inhibition();
}
if matches!(&self.cursor_status, smithay::input::pointer::CursorImageStatus::Surface(cursor) if cursor == surface)
{
+1 -2
View File
@@ -126,8 +126,7 @@ impl IdleInhibitHandler for Ferese {
self.idle_inhibitors.remove(&surface);
}
}
self.idle_notifier_state
.set_is_inhibited(!self.idle_inhibitors.is_empty());
self.refresh_idle_inhibition();
}
}
+137 -4
View File
@@ -122,10 +122,33 @@ pub(crate) fn init(
IpcEvent::Call(call) => call,
IpcEvent::Closed(owner) => {
state.portal_shortcuts.remove(owner);
state.portal_session.remove(owner);
state.refresh_idle_inhibition();
return;
}
};
if call.request.command == "screenshot-window" {
if call.request.command == "session-watch" {
if let Err(error) = validate_request(&call.request) {
let _ = call.response.send(Response::error(
call.request.id,
error.code,
error.message,
));
} else if let Some(since) = call.request.args["since"].as_u64() {
state.portal_session.watch(
call.owner,
call.request.id,
since,
call.response,
);
} else {
let _ = call.response.send(Response::error(
call.request.id,
"invalid_argument",
"Missing session revision",
));
}
} else if call.request.command == "screenshot-window" {
state.start_window_screenshot(call.request, call.response);
} else if call.request.command == "screenshot" {
// Deferred: this path answers the caller itself, exactly
@@ -322,8 +345,34 @@ fn serve_connection(
tracing::warn!("disconnecting IPC client because the request queue is full");
return;
}
let Ok(response) = receiver.recv() else {
return;
let response = loop {
match receiver.recv_timeout(Duration::from_secs(1)) {
Ok(response) => break response,
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
let mut byte = 0_u8;
// SAFETY: the live stream owns this fd and byte is writable.
let result = unsafe {
libc::recv(
std::os::fd::AsRawFd::as_raw_fd(&stream),
(&mut byte as *mut u8).cast(),
1,
libc::MSG_PEEK | libc::MSG_DONTWAIT,
)
};
if result == 0 {
return;
}
if result < 0
&& !matches!(
io::Error::last_os_error().kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted
)
{
return;
}
}
}
};
let exit_accepted = exit_requested && response.error.is_none();
if let Err(error) = write_frame(&mut stream, &response) {
@@ -342,6 +391,13 @@ fn serve_connection(
}
}
fn u32_arg(args: &Value, key: &str) -> Result<u32, CommandError> {
args[key]
.as_u64()
.and_then(|value| u32::try_from(value).ok())
.ok_or_else(|| CommandError::new("invalid_argument", format!("Missing or invalid {key}")))
}
fn discard_undelivered_screenshot(response: &Response) {
if response.error.is_none()
&& let Some(path) = response
@@ -357,7 +413,18 @@ fn discard_undelivered_screenshot(response: &Response) {
impl Ferese {
fn handle_ipc_request(&mut self, owner: u64, request: Request) -> Response {
if self.session_lock.active && request.command != "portal-shortcuts-poll" {
if self.session_lock.active
&& !matches!(
request.command.as_str(),
"portal-shortcuts-poll"
| "get-session-state"
| "portal-inhibit"
| "portal-monitor-register"
| "portal-monitor-ack"
| "logind-session-ending"
| "cancel-session-end"
)
{
return Response::error(
request.id,
"session_locked",
@@ -381,6 +448,72 @@ impl Ferese {
args: &Value,
) -> Result<Value, CommandError> {
match command {
"portal-inhibit" => {
let inhibition = serde_json::from_value(args.clone())
.map_err(|error| CommandError::new("invalid_argument", error.to_string()))?;
self.portal_session
.register(owner, inhibition)
.map_err(|error| CommandError::new("invalid_argument", error))?;
self.refresh_idle_inhibition();
return Ok(json!({}));
}
"get-session-state" => {
return Ok(self.portal_session.snapshot());
}
"portal-monitor-register" => {
self.portal_session
.register_monitor(owner)
.map_err(|error| CommandError::new("invalid_argument", error))?;
let mut result = self.portal_session.snapshot();
result["monitor-owner"] = json!(owner);
return Ok(result);
}
"portal-monitor-ack" => {
let token = u32_arg(args, "token")?;
self.portal_session
.acknowledge(owner, token)
.map_err(|error| CommandError::new("invalid_argument", error))?;
return Ok(json!({}));
}
"begin-session-end" => {
self.portal_session.begin_owned_query(owner);
return Ok(self.portal_session.snapshot());
}
"cancel-session-end" => {
self.portal_session.cancel_query(u32_arg(args, "token")?);
return Ok(json!({}));
}
"validate-session-end" => {
self.portal_session
.validate_end(
u32_arg(args, "token")?,
u32_arg(args, "inhibitor-revision")?,
args["force"].as_bool().ok_or_else(|| {
CommandError::new("invalid_argument", "Missing force confirmation")
})?,
)
.map_err(|error| CommandError::new("inhibited", error))?;
return Ok(json!({}));
}
"commit-session-end" => {
self.portal_session
.commit_end(
u32_arg(args, "token")?,
u32_arg(args, "inhibitor-revision")?,
args["force"].as_bool().ok_or_else(|| {
CommandError::new("invalid_argument", "Missing force confirmation")
})?,
)
.map_err(|error| CommandError::new("inhibited", error))?;
return Ok(json!({}));
}
"logind-session-ending" => {
let ending = args["ending"].as_bool().ok_or_else(|| {
CommandError::new("invalid_argument", "Missing shutdown state")
})?;
self.portal_session.set_phase(if ending { 3 } else { 1 });
return Ok(json!({}));
}
"portal-shortcuts-register" => {
let shortcuts = serde_json::from_value(args.clone())
.map_err(|error| CommandError::new("invalid_argument", error.to_string()))?;
+1
View File
@@ -13,6 +13,7 @@ mod input;
mod ipc;
mod metrics;
mod overview;
mod portal_session;
mod portal_shortcuts;
mod presentation;
mod private_client;
+422
View File
@@ -0,0 +1,422 @@
use ferese_ipc::Response;
use serde::Deserialize;
use serde_json::{Value, json};
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
use std::{
collections::{HashMap, HashSet, VecDeque},
sync::mpsc::SyncSender,
time::{Duration, Instant},
};
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Inhibition {
flags: u32,
app: String,
reason: String,
}
struct Query {
token: u32,
owner: Option<u64>,
deadline: Instant,
pending: HashSet<u64>,
}
pub(crate) struct PortalSession {
inhibitors: HashMap<u64, Inhibition>,
monitors: HashSet<u64>,
waiters: HashMap<u64, (u64, SyncSender<Response>)>,
phase: u32,
locked: bool,
revision: u64,
inhibitor_revision: u32,
next_query: u32,
query: Option<Query>,
transitions: VecDeque<Value>,
}
impl Default for PortalSession {
fn default() -> Self {
Self {
inhibitors: HashMap::new(),
monitors: HashSet::new(),
waiters: HashMap::new(),
phase: 1,
locked: false,
revision: 0,
inhibitor_revision: 0,
next_query: 0,
query: None,
transitions: VecDeque::new(),
}
}
}
impl PortalSession {
pub(crate) fn register(&mut self, owner: u64, inhibition: Inhibition) -> Result<(), String> {
if inhibition.flags == 0
|| inhibition.flags & !15 != 0
|| inhibition.app.len() > 512
|| inhibition.reason.len() > 1024
|| inhibition
.app
.chars()
.chain(inhibition.reason.chars())
.any(char::is_control)
{
return Err("Invalid session inhibition".into());
}
if self.inhibitors.len() >= 16 && !self.inhibitors.contains_key(&owner) {
return Err("Session inhibition limit reached".into());
}
self.inhibitors.insert(owner, inhibition);
self.inhibitor_revision = self.inhibitor_revision.wrapping_add(1);
Ok(())
}
pub(crate) fn register_monitor(&mut self, owner: u64) -> Result<(), String> {
if self.monitors.len() >= 8 && !self.monitors.contains(&owner) {
return Err("Session monitor limit reached".into());
}
self.monitors.insert(owner);
if let Some(query) = &mut self.query
&& self.phase == 2
{
query.pending.insert(owner);
}
Ok(())
}
pub(crate) fn acknowledge(&mut self, owner: u64, token: u32) -> Result<(), String> {
if !self.monitors.contains(&owner) {
return Err("Unknown session monitor".into());
}
if let Some(query) = &mut self.query
&& query.token == token
&& self.phase == 2
&& query.pending.remove(&owner)
{
self.changed();
}
Ok(())
}
pub(crate) fn remove(&mut self, owner: u64) {
self.waiters.remove(&owner);
if self.phase == 2
&& self
.query
.as_ref()
.is_some_and(|query| query.owner == Some(owner))
{
self.set_phase(1);
}
if self.inhibitors.remove(&owner).is_some() {
self.inhibitor_revision = self.inhibitor_revision.wrapping_add(1);
}
let monitor_removed = self.monitors.remove(&owner);
let acknowledged = self
.query
.as_mut()
.is_some_and(|query| query.pending.remove(&owner));
if monitor_removed || acknowledged {
self.changed();
}
}
fn idle_inhibited(&self) -> bool {
self.inhibitors
.values()
.any(|inhibitor| inhibitor.flags & 8 != 0)
}
pub(crate) fn begin_query(&mut self) -> u32 {
self.next_query = self.next_query.wrapping_add(1).max(1);
let token = self.next_query;
self.query = Some(Query {
token,
owner: None,
deadline: Instant::now() + Duration::from_secs(1),
pending: self.monitors.clone(),
});
self.phase = 2;
self.changed();
token
}
pub(crate) fn begin_owned_query(&mut self, owner: u64) -> u32 {
let token = self.begin_query();
self.query.as_mut().unwrap().owner = Some(owner);
token
}
pub(crate) fn cancel_query(&mut self, token: u32) {
if self
.query
.as_ref()
.is_some_and(|query| query.token == token)
{
self.set_phase(1);
}
}
pub(crate) fn query_ready(&self) -> bool {
self.phase == 2
&& self
.query
.as_ref()
.is_some_and(|query| query.pending.is_empty() || Instant::now() >= query.deadline)
}
pub(crate) fn validate_end(
&self,
token: u32,
inhibitor_revision: u32,
force: bool,
) -> Result<(), String> {
if !self.query_ready()
|| !self
.query
.as_ref()
.is_some_and(|query| query.token == token)
{
return Err(
"Session-ending confirmation expired or applications are still responding".into(),
);
}
if self.inhibitor_revision != inhibitor_revision
|| (!force
&& self
.inhibitors
.values()
.any(|inhibitor| inhibitor.flags & 1 != 0))
{
return Err(
"Applications changed their inhibitors; review the confirmation again".into(),
);
}
Ok(())
}
pub(crate) fn commit_end(
&mut self,
token: u32,
inhibitor_revision: u32,
force: bool,
) -> Result<(), String> {
self.validate_end(token, inhibitor_revision, force)?;
self.set_phase(3);
Ok(())
}
pub(crate) fn set_phase(&mut self, phase: u32) {
if phase == 2 {
self.begin_query();
return;
}
if phase == 1 {
self.query = None;
}
if self.phase != phase {
self.phase = phase;
self.changed();
}
}
pub(crate) fn set_locked(&mut self, locked: bool) {
if self.locked != locked {
self.locked = locked;
self.changed();
}
}
fn transition(&self) -> Value {
json!({"screensaver-active": self.locked, "session-state": self.phase, "revision": self.revision, "query-token": self.query.as_ref().map_or(0, |query| query.token)})
}
fn changed(&mut self) {
self.revision = self.revision.wrapping_add(1);
if self.transitions.len() == 64 {
self.transitions.pop_front();
}
self.transitions.push_back(self.transition());
let snapshot = self.snapshot();
for (_, (id, response)) in self.waiters.drain() {
let _ = response.try_send(Response::success(id, snapshot.clone()));
}
}
pub(crate) fn watch(
&mut self,
owner: u64,
id: u64,
since: u64,
response: SyncSender<Response>,
) {
if since != self.revision {
let _ = response.try_send(Response::success(id, self.snapshot()));
} else {
self.waiters.insert(owner, (id, response));
}
}
pub(crate) fn snapshot(&self) -> Value {
let inhibitors = self.inhibitors.values().map(|inhibitor| json!({"flags": inhibitor.flags, "app": inhibitor.app, "reason": inhibitor.reason})).collect::<Vec<_>>();
let remaining = self.query.as_ref().map_or(0, |query| {
query
.deadline
.saturating_duration_since(Instant::now())
.as_millis() as u64
});
json!({"screensaver-active": self.locked, "session-state": self.phase, "revision": self.revision,
"inhibitors": inhibitors, "inhibitor-revision": self.inhibitor_revision,
"query-token": self.query.as_ref().map_or(0, |query| query.token), "query-ready": self.query_ready(),
"query-remaining-ms": remaining, "transitions": self.transitions, "monitor-owners": self.monitors})
}
}
impl crate::Ferese {
pub(crate) fn refresh_idle_inhibition(&mut self) {
self.idle_notifier_state.set_is_inhibited(
!self.idle_inhibitors.is_empty() || self.portal_session.idle_inhibited(),
);
}
pub(crate) fn end_portal_session(&mut self) {
let token = self.portal_session.query.as_ref().map(|query| query.token);
let result = self.loop_handle.insert_source(
Timer::from_duration(Duration::from_millis(200)),
move |_, _, state| {
if state.portal_session.phase == 3
&& state.portal_session.query.as_ref().map(|query| query.token) == token
{
state.loop_signal.stop();
}
TimeoutAction::Drop
},
);
if let Err(error) = result {
tracing::error!(%error, "failed to schedule session ending");
self.loop_signal.stop();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn inhibition(flags: u32) -> Inhibition {
Inhibition {
flags,
app: "test.app".into(),
reason: "Playing media".into(),
}
}
#[test]
fn inhibition_is_connection_scoped_and_invalid_flags_do_not_replace_it() {
let mut session = PortalSession::default();
session.register(1, inhibition(8)).unwrap();
session.register(2, inhibition(8)).unwrap();
assert!(session.register(1, inhibition(16)).is_err());
session.remove(1);
assert!(session.idle_inhibited());
session.remove(2);
assert!(!session.idle_inhibited());
}
#[test]
fn acknowledgements_are_generation_scoped_and_disconnect_releases_pending_monitor() {
let mut session = PortalSession::default();
session.register_monitor(1).unwrap();
session.register_monitor(2).unwrap();
let old = session.begin_query();
assert!(!session.query_ready());
let token = session.begin_query();
session.acknowledge(1, old).unwrap();
assert!(!session.query_ready());
session.acknowledge(1, token).unwrap();
assert!(!session.query_ready());
session.remove(2);
assert!(session.query_ready());
session.cancel_query(old);
assert!(session.query_ready());
session.cancel_query(token);
assert!(!session.query_ready());
}
#[test]
fn late_inhibitor_requires_a_new_confirmation_and_force_is_explicit() {
let mut session = PortalSession::default();
let token = session.begin_query();
let approved = session.inhibitor_revision;
session.register(1, inhibition(1)).unwrap();
assert!(session.commit_end(token, approved, true).is_err());
assert!(
session
.commit_end(token, session.inhibitor_revision, false)
.is_err()
);
session
.commit_end(token, session.inhibitor_revision, true)
.unwrap();
assert_eq!(session.snapshot()["session-state"], 3);
assert!(
session
.commit_end(token, session.inhibitor_revision, true)
.is_err()
);
}
#[test]
fn validation_keeps_query_open_and_owner_disconnect_cancels_it() {
let mut session = PortalSession::default();
let token = session.begin_owned_query(9);
session.validate_end(token, 0, false).unwrap();
assert_eq!(session.snapshot()["session-state"], 2);
session.remove(9);
assert_eq!(session.snapshot()["session-state"], 1);
assert!(session.validate_end(token, 0, false).is_err());
}
#[test]
fn old_logout_token_does_not_cancel_a_new_shutdown_query() {
let mut session = PortalSession::default();
let logout = session.begin_query();
let shutdown = session.begin_owned_query(9);
session.cancel_query(logout);
session.validate_end(shutdown, 0, false).unwrap();
}
#[test]
fn unresponsive_monitors_have_a_one_second_deadline() {
let mut session = PortalSession::default();
session.register_monitor(1).unwrap();
session.begin_query();
assert!(!session.query_ready());
session.query.as_mut().unwrap().deadline = Instant::now() - Duration::from_millis(1);
assert!(session.query_ready());
}
#[test]
fn state_watch_preserves_fast_transitions_and_stops_on_disconnect() {
let mut session = PortalSession::default();
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
session.watch(1, 10, 0, sender);
assert!(receiver.try_recv().is_err());
session.set_locked(true);
let response = receiver.try_recv().unwrap();
assert_eq!(response.result.unwrap()["screensaver-active"], true);
session.set_locked(false);
assert_eq!(
session.snapshot()["transitions"].as_array().unwrap().len(),
2
);
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
session.watch(1, 11, session.revision, sender);
session.remove(1);
assert!(receiver.recv().is_err());
}
}
+2
View File
@@ -123,6 +123,7 @@ impl SessionLockHandler for Ferese {
}
self.cancel_logout_confirmation();
self.session_lock.active = true;
self.portal_session.set_locked(true);
self.session_lock.owner = Some(confirmation.ext_session_lock().clone());
self.session_lock.confirmation = Some(confirmation);
for capture in self.pending_screencopies.drain(..) {
@@ -165,6 +166,7 @@ impl SessionLockHandler for Ferese {
fn unlock(&mut self) {
self.session_lock = Lock::default();
self.portal_session.set_locked(false);
self.restore_keyboard_focus();
crate::backends::direct::render_all(self);
tracing::info!("session unlocked by lock owner");
+74 -8
View File
@@ -14,7 +14,7 @@ use smithay::{
use crate::{Ferese, private_client::ClientCapabilities, state::ClientState};
pub(crate) fn init_global(display: &DisplayHandle) {
display.create_global::<Ferese, FereseShellManagerV1, _>(3, ());
display.create_global::<Ferese, FereseShellManagerV1, _>(4, ());
}
pub(crate) fn config_chunks(source: &str) -> Vec<&str> {
@@ -137,11 +137,25 @@ impl Dispatch<FereseShellV1, ()> for Ferese {
}
}
ferese_shell_v1::Request::ConfirmLogout { serial } => {
if state.logout_owner.as_ref() == Some(&shell.id())
&& consume_logout_confirmation(&mut state.pending_logout, serial)
{
state.logout_owner = None;
state.loop_signal.stop();
let snapshot = state.portal_session.snapshot();
let token = snapshot["query-token"].as_u64().unwrap_or(0) as u32;
let revision = snapshot["inhibitor-revision"].as_u64().unwrap_or(0) as u32;
state.confirm_portal_logout(shell, serial, token, revision, false);
}
ferese_shell_v1::Request::ConfirmLogoutWithInhibitors {
serial,
inhibitor_revision,
query_token,
force,
} => {
if force <= 1 {
state.confirm_portal_logout(
shell,
serial,
query_token,
inhibitor_revision,
force == 1,
);
}
}
ferese_shell_v1::Request::CancelLogout { serial } => {
@@ -183,12 +197,20 @@ impl Dispatch<FereseShellV1, ()> for Ferese {
_data: &(),
) {
if state.logout_owner.as_ref() == Some(&shell.id()) {
state.pending_logout = None;
state.logout_owner = None;
state.cancel_logout_confirmation();
}
}
}
fn logout_confirmation_matches(
pending: Option<u32>,
query: Option<u32>,
serial: u32,
token: u32,
) -> bool {
pending == Some(serial) && query == Some(token)
}
fn consume_logout_confirmation(pending: &mut Option<u32>, serial: u32) -> bool {
if *pending == Some(serial) {
*pending = None;
@@ -199,6 +221,34 @@ fn consume_logout_confirmation(pending: &mut Option<u32>, serial: u32) -> bool {
}
impl Ferese {
fn confirm_portal_logout(
&mut self,
shell: &FereseShellV1,
serial: u32,
token: u32,
revision: u32,
force: bool,
) {
if self.logout_owner.as_ref() != Some(&shell.id())
|| !logout_confirmation_matches(self.pending_logout, self.logout_query, serial, token)
{
return;
}
match self.portal_session.commit_end(token, revision, force) {
Ok(()) => {
consume_logout_confirmation(&mut self.pending_logout, serial);
self.logout_owner = None;
self.logout_query = None;
self.end_portal_session();
}
Err(error) => {
tracing::debug!(%error, "logout requires a new confirmation");
self.cancel_logout_confirmation();
self.request_logout_confirmation();
}
}
}
pub(crate) fn request_logout_confirmation(&mut self) {
if self.session_lock.active {
return;
@@ -216,6 +266,7 @@ impl Ferese {
self.cancel_logout_confirmation();
let serial = u32::from(smithay::utils::SERIAL_COUNTER.next_serial());
let shell = &shells[0];
self.logout_query = Some(self.portal_session.begin_query());
self.pending_logout = Some(serial);
self.logout_owner = Some(shell.id());
let output_name = self
@@ -225,6 +276,9 @@ impl Ferese {
}
pub(crate) fn cancel_logout_confirmation(&mut self) {
if let Some(token) = self.logout_query.take() {
self.portal_session.cancel_query(token);
}
if let Some(serial) = self.pending_logout.take() {
for shell in self
.shell_resources
@@ -507,6 +561,18 @@ fn send_request_failed(shell: &FereseShellV1, request: ferese_shell_v1::FailedRe
#[cfg(test)]
mod tests {
#[test]
fn logout_serial_cannot_authorize_a_different_session_query() {
assert!(super::logout_confirmation_matches(Some(42), Some(9), 42, 9));
assert!(!super::logout_confirmation_matches(
Some(42),
Some(9),
42,
10
));
assert!(!super::logout_confirmation_matches(None, Some(9), 42, 9));
}
#[test]
fn logout_requires_the_current_confirmation_and_can_be_cancelled() {
let mut pending = Some(42);
+6
View File
@@ -231,6 +231,8 @@ pub struct Ferese {
pub socket_name: OsString,
pub display_handle: DisplayHandle,
pub loop_signal: LoopSignal,
pub(crate) loop_handle: smithay::reexports::calloop::LoopHandle<'static, Self>,
pub(crate) portal_session: crate::portal_session::PortalSession,
pub space: Space<Window>,
pub workspaces: WorkspaceSet,
pub output_workspaces: OutputWorkspaceMap,
@@ -259,6 +261,7 @@ pub struct Ferese {
pub(crate) material_buffers: HashMap<WlSurface, crate::winit::MaterialBuffers>,
pub(crate) portal_shortcuts: crate::portal_shortcuts::PortalShortcuts,
pub(crate) pending_logout: Option<u32>,
pub(crate) logout_query: Option<u32>,
pub(crate) logout_owner: Option<smithay::reexports::wayland_server::backend::ObjectId>,
pub(crate) blur_programs: HashMap<ErasedContextId, crate::winit::BlurProgram>,
pub(crate) backdrop_generation: u64,
@@ -448,6 +451,8 @@ impl Ferese {
socket_name,
display_handle,
loop_signal: event_loop.get_signal(),
loop_handle: event_loop.handle(),
portal_session: Default::default(),
space: Space::default(),
workspaces: WorkspaceSet::new(
config.layout_mode,
@@ -483,6 +488,7 @@ impl Ferese {
material_buffers: HashMap::new(),
portal_shortcuts: crate::portal_shortcuts::PortalShortcuts::default(),
pending_logout: None,
logout_query: None,
logout_owner: None,
blur_programs: HashMap::new(),
backdrop_generation: 0,
+19 -3
View File
@@ -41,6 +41,12 @@ standard file, print and application dialogs. Install both backends.
Closing a session or losing its compositor connection releases its shortcuts;
shortcuts do not activate on the lock screen. Saved choices are offered for
future sessions, with explicit approval before registering them.
- **Inhibit:** connection-scoped logout, suspend and idle inhibitors, backed by
logind descriptors and compositor idle inhibition. Native session monitors
report lock state and Running/QueryEnd/Ending transitions. Ending queries wait
for acknowledgements for up to one second. Power confirmations list blockers
and recheck them before acting; authentication failure cancels the query.
UserSwitch is recorded, but Ferese currently has no user-switch operation.
- **ScreenCast:** monitor sharing with a native picker and PipeWire streams.
See [screen sharing](screen-sharing.md) for lifetime and recording details.
@@ -70,11 +76,11 @@ Matching D-Bus signatures does not establish complete feature parity.
| Background | Native application state, per-instance consent and XDG autostart |
| Usb | Native consent; standard frontend handles enumeration and device descriptors |
| GlobalShortcuts | Native sessions, configurable keyboard triggers, conflict checks and activation/release signals |
| RemoteDesktop | Missing authorized input injection and EIS transport |
| Clipboard | Missing remote-session clipboard transfer |
| RemoteDesktop | Deferred: authorized input injection and EIS transport |
| Clipboard | Deferred with RemoteDesktop |
| InputCapture | Missing zones, pointer barriers and EIS transport |
| Lockdown | Native seven-property policy provider with persisted configuration and change signals |
| Inhibit | GTK's GNOME-session/ScreenSaver integration does not provide complete Ferese session inhibition |
| Inhibit | Native inhibitors and session monitors; UserSwitch recorded until a switch operation exists |
The frontend supplies additional APIs such as OpenURI, network monitoring,
document export and permission storage. Adding a similarly named backend service
@@ -117,3 +123,13 @@ XML, verifies appearance changes and invalid-save retention, and checks that
sensitive methods reject callers outside the portal frontend. It does not open
consent dialogs or capture the host desktop. Interactive consent, fractional-scale
capture, polkit and hotplug still require a live session test.
Check native inhibitor lifetimes, acknowledgement generations and session-ending
transitions against a mock logind in an isolated nested compositor:
```sh
FERESE_TEST_INHIBIT=1 python3 scripts/tests/test_inhibit_isolated.py
```
The shell now requires version 4 of Ferese's shell protocol; rebuild the shell
and compositor together when upgrading.
+1
View File
@@ -8,3 +8,4 @@ org.freedesktop.impl.portal.Background=ferese;
org.freedesktop.impl.portal.Usb=ferese;
org.freedesktop.impl.portal.Lockdown=ferese;
org.freedesktop.impl.portal.GlobalShortcuts=ferese;
org.freedesktop.impl.portal.Inhibit=ferese;
+1 -1
View File
@@ -1,4 +1,4 @@
[portal]
DBusName=org.freedesktop.impl.portal.desktop.ferese
Interfaces=org.freedesktop.impl.portal.ScreenCast;org.freedesktop.impl.portal.Settings;org.freedesktop.impl.portal.Screenshot;org.freedesktop.impl.portal.Wallpaper;org.freedesktop.impl.portal.Background;org.freedesktop.impl.portal.Usb;org.freedesktop.impl.portal.Lockdown;org.freedesktop.impl.portal.GlobalShortcuts;
Interfaces=org.freedesktop.impl.portal.ScreenCast;org.freedesktop.impl.portal.Settings;org.freedesktop.impl.portal.Screenshot;org.freedesktop.impl.portal.Wallpaper;org.freedesktop.impl.portal.Background;org.freedesktop.impl.portal.Usb;org.freedesktop.impl.portal.Lockdown;org.freedesktop.impl.portal.GlobalShortcuts;org.freedesktop.impl.portal.Inhibit;
UseIn=Ferese;
+1 -1
View File
@@ -31,7 +31,7 @@ memmap2 = "0.9"
pipewire = { version = "0.10.1", features = ["v0_3_65"] }
serde.workspace = true
serde_json.workspace = true
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync", "process", "io-util"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync", "process", "io-util", "net"] }
wayland-client.workspace = true
wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
wayland-protocols-wlr.workspace = true
+5
View File
@@ -670,6 +670,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
let background = crate::permissions::Background::new(requests.clone());
let lockdown = crate::lockdown::Lockdown::new();
let shortcuts = crate::shortcuts::GlobalShortcuts::new(requests.clone());
let inhibit = crate::inhibit::Inhibit::default();
let connection = zbus::connection::Builder::session()?
.name("org.freedesktop.impl.portal.desktop.ferese")?
.serve_at(PATH, backend.clone())?
@@ -680,6 +681,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
.serve_at(PATH, background.clone())?
.serve_at(PATH, lockdown.clone())?
.serve_at(PATH, shortcuts.clone())?
.serve_at(PATH, inhibit.clone())?
.serve_at(
"/org/ferese/ScreenRecorder",
RecorderControl(backend.clone()),
@@ -689,6 +691,8 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
tokio::spawn(settings.watch(connection.clone()));
tokio::spawn(background.watch(connection.clone()));
tokio::spawn(lockdown.watch(connection.clone()));
tokio::spawn(inhibit.clone().watch(connection.clone()));
tokio::spawn(inhibit.clone().watch_logind());
// Frontend death must revoke every stream, even if Session.Close never arrives.
loop {
tokio::time::sleep(Duration::from_secs(1)).await;
@@ -702,6 +706,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
};
requests.revoke_stale(owner.as_deref()).await;
shortcuts.revoke_stale(&connection, owner.as_deref()).await;
inhibit.revoke_stale(&connection, owner.as_deref()).await;
let stale: Vec<_> = backend
.sessions
.lock()
+103
View File
@@ -0,0 +1,103 @@
use serde_json::Value as Json;
use std::{os::unix::net::UnixStream, sync::Arc, time::Duration};
#[derive(Clone)]
pub(crate) struct Bridge(Arc<BridgeInner>);
struct BridgeInner {
stream: std::sync::Mutex<UnixStream>,
shutdown: UnixStream,
}
impl Bridge {
pub(crate) fn connect() -> Result<Self, String> {
let path = std::env::var_os("XDG_RUNTIME_DIR")
.map(std::path::PathBuf::from)
.ok_or("Missing runtime directory")?
.join("ferese/control.sock");
let stream = UnixStream::connect(path).map_err(|error| error.to_string())?;
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.map_err(|error| error.to_string())?;
stream
.set_write_timeout(Some(Duration::from_secs(2)))
.map_err(|error| error.to_string())?;
let shutdown = stream.try_clone().map_err(|error| error.to_string())?;
Ok(Self(Arc::new(BridgeInner {
stream: std::sync::Mutex::new(stream),
shutdown,
})))
}
pub(crate) async fn call(&self, command: &'static str, args: Json) -> Result<Json, String> {
self.request(command, args, false).await
}
pub(crate) async fn wait(&self, since: u64) -> Result<Json, String> {
self.request("session-watch", serde_json::json!({"since": since}), true)
.await
}
async fn request(&self, command: &'static str, args: Json, wait: bool) -> Result<Json, String> {
let bridge = self.clone();
tokio::task::spawn_blocking(move || {
let mut stream = bridge
.0
.stream
.lock()
.map_err(|_| "Compositor connection failed")?;
stream
.set_read_timeout(if wait {
None
} else {
Some(Duration::from_secs(2))
})
.map_err(|error| error.to_string())?;
let request = ferese_ipc::Request {
version: ferese_ipc::VERSION,
id: 1,
kind: "command".into(),
command: command.into(),
args,
};
ferese_ipc::write_frame(&mut *stream, &request).map_err(|error| error.to_string())?;
let response: ferese_ipc::Response =
ferese_ipc::read_frame(&mut *stream).map_err(|error| error.to_string())?;
if let Some(error) = response.error {
return Err(error.message);
}
response.result.ok_or("Missing compositor response".into())
})
.await
.map_err(|error| error.to_string())?
}
pub(crate) async fn closed(&self) {
use std::io::Read;
let Ok(reader) = self.0.shutdown.try_clone() else {
return;
};
if reader.set_nonblocking(true).is_err() {
return;
}
let Ok(reader) = tokio::io::unix::AsyncFd::new(reader) else {
return;
};
loop {
let Ok(mut ready) = reader.readable().await else {
return;
};
match ready.try_io(|reader| {
let mut socket = reader.get_ref();
socket.read(&mut [0_u8; 1])
}) {
Ok(_) => return,
Err(_) => continue,
}
}
}
pub(crate) fn close(&self) {
let _ = self.0.shutdown.shutdown(std::net::Shutdown::Both);
}
}
+13 -3
View File
@@ -22,10 +22,20 @@ const PATH: &str = "/org/freedesktop/portal/desktop";
const MAX_IMAGE_BYTES: u64 = 128 * 1024 * 1024;
type Reply = (u32, Options);
#[derive(Clone, Default)]
pub(crate) struct Requests(Arc<Mutex<HashMap<String, (String, Arc<Cancel>)>>>);
#[derive(Clone)]
pub(crate) struct Requests(Arc<Mutex<HashMap<String, (String, Arc<Cancel>)>>>, usize);
impl Default for Requests {
fn default() -> Self {
Self::with_limit(8)
}
}
impl Requests {
pub(crate) fn with_limit(limit: usize) -> Self {
Self(Default::default(), limit)
}
pub(crate) async fn revoke_stale(&self, owner: Option<&str>) {
for (request_owner, cancel) in self.0.lock().await.values() {
if Some(request_owner.as_str()) != owner {
@@ -45,7 +55,7 @@ impl Requests {
return Err(zbus::fdo::Error::InvalidArgs("Invalid request path".into()));
}
let mut pending = self.0.lock().await;
if pending.len() >= 8 || pending.contains_key(handle.as_str()) {
if pending.len() >= self.1 || pending.contains_key(handle.as_str()) {
return Err(zbus::fdo::Error::LimitsExceeded(
"Request limit reached".into(),
));
+511
View File
@@ -0,0 +1,511 @@
use std::{
collections::HashMap,
sync::{Arc, atomic::Ordering},
time::Duration,
};
use tokio::sync::{Mutex, Notify};
use zbus::{
Connection,
message::Header,
object_server::SignalEmitter,
zvariant::{OwnedFd, OwnedObjectPath, Value},
};
use crate::{
backend::{Cancel, Options, authorize},
bridge::Bridge,
desktop::Requests,
};
const PATH: &str = "/org/freedesktop/portal/desktop";
struct Monitor {
owner: String,
cancel: Arc<Cancel>,
last: Mutex<Option<(bool, u32, u64, u32)>>,
bridge: Bridge,
native_owner: u64,
}
#[derive(Clone)]
pub(crate) struct Inhibit {
requests: Requests,
monitors: Arc<Mutex<HashMap<String, Arc<Monitor>>>>,
changed: Arc<Notify>,
}
impl Default for Inhibit {
fn default() -> Self {
Self {
requests: Requests::with_limit(24),
monitors: Default::default(),
changed: Default::default(),
}
}
}
impl Inhibit {
pub(crate) async fn revoke_stale(&self, connection: &Connection, owner: Option<&str>) {
self.requests.revoke_stale(owner).await;
let stale = self
.monitors
.lock()
.await
.iter()
.filter(|(_, monitor)| Some(monitor.owner.as_str()) != owner)
.map(|(path, _)| path.clone())
.collect::<Vec<_>>();
for path in stale {
self.end(connection, &path).await;
}
}
async fn end(&self, connection: &Connection, path: &str) {
if let Some(monitor) = self.monitors.lock().await.remove(path) {
let _state = monitor.last.lock().await;
monitor.cancel.stop();
monitor.bridge.close();
if let Ok(emitter) = SignalEmitter::new(connection, path) {
let _ = MonitorObject::closed(&emitter).await;
}
let _ = connection
.object_server()
.remove::<MonitorObject, _>(path)
.await;
self.changed.notify_one();
}
}
pub(crate) async fn watch(self, connection: Connection) {
let mut bridge: Option<Bridge> = None;
let mut since = None;
loop {
if self.monitors.lock().await.is_empty() {
if let Some(bridge) = bridge.take() {
bridge.close();
}
since = None;
self.changed.notified().await;
continue;
}
if bridge.is_none() {
bridge = Bridge::connect().ok();
}
let result = if let Some(active) = &bridge {
tokio::select! {
biased;
_ = self.changed.notified() => { active.close(); bridge = None; since = None; continue; }
state = async {
match since { Some(revision) => active.wait(revision).await, None => active.call("get-session-state", serde_json::json!({})).await }
} => state,
}
} else {
Err("Session monitor is unavailable".into())
};
let monitors = self
.monitors
.lock()
.await
.iter()
.map(|(path, monitor)| (path.clone(), monitor.clone()))
.collect::<Vec<_>>();
match result {
Ok(state) => {
since = state["revision"].as_u64();
for (path, monitor) in monitors {
if !state["monitor-owners"].as_array().is_some_and(|owners| {
owners
.iter()
.any(|owner| owner.as_u64() == Some(monitor.native_owner))
}) {
self.end(&connection, &path).await;
continue;
}
let mut last = monitor.last.lock().await;
if monitor.cancel.stopped.load(Ordering::SeqCst) {
continue;
}
let transitions = match *last {
None => vec![state.clone()],
Some((_, _, revision, _)) => {
let events =
state["transitions"].as_array().cloned().unwrap_or_default();
if events
.first()
.and_then(|event| event["revision"].as_u64())
.is_some_and(|first| first > revision.saturating_add(1))
{
drop(last);
self.end(&connection, &path).await;
continue;
}
events
.into_iter()
.filter(|event| {
event["revision"]
.as_u64()
.is_some_and(|next| next > revision)
})
.collect()
}
};
for event in transitions {
let locked = event["screensaver-active"].as_bool().unwrap_or(false);
let phase = event["session-state"].as_u64().unwrap_or(1) as u32;
let revision = event["revision"].as_u64().unwrap_or(0);
let token = event["query-token"].as_u64().unwrap_or(0) as u32;
let changed = last.as_ref().is_none_or(|old| {
old.0 != locked || old.1 != phase || (phase == 2 && old.3 != token)
});
if changed {
let values = HashMap::from([
(
"screensaver-active".into(),
Value::from(locked).try_into().unwrap(),
),
(
"session-state".into(),
Value::from(phase).try_into().unwrap(),
),
]);
if let Ok(emitter) = SignalEmitter::new(&connection, PATH) {
if Self::state_changed(
&emitter,
OwnedObjectPath::try_from(path.clone()).unwrap(),
values,
)
.await
.is_err()
{
break;
}
}
}
*last = Some((locked, phase, revision, token));
}
}
}
Err(error) => {
eprintln!("ferese-portal: {error}");
for (path, _) in monitors {
self.end(&connection, &path).await;
}
if let Some(bridge) = bridge.take() {
bridge.close();
}
since = None;
}
}
}
}
pub(crate) async fn watch_logind(self) {
use futures_util::StreamExt;
loop {
let result: Result<(), String> = async {
let connection = Connection::system()
.await
.map_err(|error| error.to_string())?;
let proxy = zbus::Proxy::new(
&connection,
"org.freedesktop.login1",
"/org/freedesktop/login1",
"org.freedesktop.login1.Manager",
)
.await
.map_err(|error| error.to_string())?;
let mut signals = proxy
.receive_signal("PrepareForShutdown")
.await
.map_err(|error| error.to_string())?;
while let Some(message) = signals.next().await {
let (ending,): (bool,) = message
.body()
.deserialize()
.map_err(|error| error.to_string())?;
if let Ok(bridge) = Bridge::connect() {
let _ = bridge
.call(
"logind-session-ending",
serde_json::json!({"ending": ending}),
)
.await;
bridge.close();
}
}
Ok(())
}
.await;
if let Err(error) = result {
eprintln!("ferese-portal: logind monitor unavailable: {error}");
}
tokio::time::sleep(Duration::from_secs(10)).await;
}
}
}
fn flags_what(flags: u32) -> zbus::fdo::Result<String> {
if flags == 0 || flags & !15 != 0 {
return Err(zbus::fdo::Error::InvalidArgs(
"Invalid inhibition flags".into(),
));
}
let mut what = Vec::new();
if flags & 1 != 0 {
what.push("shutdown");
}
if flags & 4 != 0 {
what.push("sleep");
}
if flags & 8 != 0 {
what.push("idle");
}
Ok(what.join(":"))
}
async fn login_inhibitor(what: &str, app: &str, reason: &str) -> Result<Option<OwnedFd>, String> {
if what.is_empty() {
return Ok(None);
}
tokio::time::timeout(Duration::from_secs(5), async {
let connection = Connection::system()
.await
.map_err(|error| error.to_string())?;
let proxy = zbus::Proxy::new(
&connection,
"org.freedesktop.login1",
"/org/freedesktop/login1",
"org.freedesktop.login1.Manager",
)
.await
.map_err(|error| error.to_string())?;
let fd: OwnedFd = proxy
.call("Inhibit", &(what, app, reason, "block"))
.await
.map_err(|error| error.to_string())?;
Ok(Some(fd))
})
.await
.map_err(|_| "Session inhibition timed out".to_owned())?
}
#[zbus::interface(name = "org.freedesktop.impl.portal.Inhibit")]
impl Inhibit {
async fn inhibit(
&self,
handle: OwnedObjectPath,
app_id: String,
_window: String,
flags: u32,
options: Options,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<()> {
authorize(connection, &header).await?;
let what = flags_what(flags)?;
let reason = options
.get("reason")
.map(|value| <&str>::try_from(value).map(str::to_owned))
.transpose()
.map_err(|_| zbus::fdo::Error::InvalidArgs("Invalid inhibition reason".into()))?
.unwrap_or_else(|| "Application requested session inhibition".into());
if app_id.len() > 512
|| reason.len() > 1024
|| app_id.chars().chain(reason.chars()).any(char::is_control)
{
return Err(zbus::fdo::Error::InvalidArgs(
"Invalid application or reason".into(),
));
}
let cancel = self.requests.begin(connection, &header, &handle).await?;
let bridge = match Bridge::connect() {
Ok(bridge) => bridge,
Err(error) => {
self.requests.end(connection, &handle).await;
return Err(zbus::fdo::Error::Failed(error));
}
};
let acquired = tokio::select! {
biased;
_ = cancel.wait() => Err("Inhibition was cancelled".to_owned()),
result = async {
let fd = login_inhibitor(&what, &app_id, &reason).await?;
bridge.call("portal-inhibit", serde_json::json!({"flags": flags, "app": app_id, "reason": reason})).await?;
Ok(fd)
} => result,
};
match acquired {
Ok(fd) if !cancel.stopped.load(Ordering::SeqCst) => {
let requests = self.requests.clone();
let connection = connection.clone();
tokio::spawn(async move {
tokio::select! { _ = cancel.wait() => {}, _ = bridge.closed() => {} }
cancel.stop();
bridge.close();
drop(fd);
requests.end(&connection, &handle).await;
});
Ok(())
}
result => {
bridge.close();
self.requests.end(connection, &handle).await;
Err(zbus::fdo::Error::Failed(
result
.err()
.unwrap_or_else(|| "Inhibition was cancelled".into()),
))
}
}
}
async fn create_monitor(
&self,
handle: OwnedObjectPath,
session_handle: OwnedObjectPath,
app_id: String,
_window: String,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<u32> {
let owner = authorize(connection, &header).await?;
if !session_handle
.as_str()
.starts_with(&format!("{PATH}/session/"))
|| session_handle.as_str().len() > 512
|| app_id.len() > 512
{
return Err(zbus::fdo::Error::InvalidArgs(
"Invalid session monitor".into(),
));
}
let cancel = self.requests.begin(connection, &header, &handle).await?;
let mut monitors = self.monitors.lock().await;
let result = if monitors.len() >= 8
|| monitors.contains_key(session_handle.as_str())
|| cancel.stopped.load(Ordering::SeqCst)
{
Ok(2)
} else {
let attempt = async {
let bridge = Bridge::connect().map_err(zbus::fdo::Error::Failed)?;
let result = tokio::select! {
biased;
_ = cancel.wait() => Err("Monitor creation was cancelled".into()),
result = bridge.call("portal-monitor-register", serde_json::json!({})) => result,
};
let native = match result { Ok(native) => native, Err(error) => { bridge.close(); return Err(zbus::fdo::Error::Failed(error)); } };
let Some(native_owner) = native["monitor-owner"].as_u64() else {
bridge.close();
return Err(zbus::fdo::Error::Failed("Missing monitor owner".into()));
};
let exported = connection.object_server().at(&session_handle, MonitorObject {
backend: self.clone(), path: session_handle.to_string(), owner: owner.clone(), cancel: cancel.clone(),
}).await;
match exported {
Ok(true) if !cancel.stopped.load(Ordering::SeqCst) => {
monitors.insert(session_handle.to_string(), Arc::new(Monitor {
owner, cancel: cancel.clone(), last: Mutex::new(None), bridge,
native_owner,
}));
self.changed.notify_one();
Ok(0)
}
result => {
bridge.close();
if matches!(result, Ok(true)) { let _ = connection.object_server().remove::<MonitorObject, _>(&session_handle).await; }
result.map(|_| if cancel.stopped.load(Ordering::SeqCst) { 1 } else { 2 }).map_err(Into::into)
}
}
}.await;
attempt
};
drop(monitors);
self.requests.end(connection, &handle).await;
result
}
async fn query_end_response(
&self,
session_handle: OwnedObjectPath,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<()> {
let owner = authorize(connection, &header).await?;
let monitor = self
.monitors
.lock()
.await
.get(session_handle.as_str())
.filter(|monitor| monitor.owner == owner)
.cloned()
.ok_or_else(|| zbus::fdo::Error::AccessDenied("Not your session monitor".into()))?;
let last = monitor.last.lock().await;
if monitor.cancel.stopped.load(Ordering::SeqCst) {
return Err(zbus::fdo::Error::AccessDenied(
"Session monitor closed".into(),
));
}
if let Some((_, 2, _, token)) = *last {
monitor
.bridge
.call("portal-monitor-ack", serde_json::json!({"token": token}))
.await
.map_err(zbus::fdo::Error::Failed)?;
}
Ok(())
}
#[zbus(signal)]
async fn state_changed(
emitter: &SignalEmitter<'_>,
session_handle: OwnedObjectPath,
state: Options,
) -> zbus::Result<()>;
}
struct MonitorObject {
backend: Inhibit,
path: String,
owner: String,
cancel: Arc<Cancel>,
}
#[zbus::interface(name = "org.freedesktop.impl.portal.Session")]
impl MonitorObject {
#[zbus(property, name = "version")]
fn version(&self) -> u32 {
1
}
async fn close(
&self,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<()> {
if authorize(connection, &header).await? != self.owner {
return Err(zbus::fdo::Error::AccessDenied(
"Not your session monitor".into(),
));
}
self.cancel.stop();
self.backend.end(connection, &self.path).await;
Ok(())
}
#[zbus(signal)]
async fn closed(emitter: &SignalEmitter<'_>) -> zbus::Result<()>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn logind_flags_are_validated_and_mapped_independently() {
assert_eq!(flags_what(1 | 4 | 8).unwrap(), "shutdown:sleep:idle");
assert_eq!(flags_what(2).unwrap(), "");
assert!(flags_what(0).is_err());
assert!(flags_what(16).is_err());
}
}
+2
View File
@@ -1,7 +1,9 @@
mod backend;
mod bridge;
mod capture;
mod consent;
mod desktop;
mod inhibit;
mod lockdown;
mod parent;
mod permissions;
+13 -65
View File
@@ -1,9 +1,6 @@
use std::{
collections::HashMap, io::Read, os::unix::net::UnixStream, process::Stdio, sync::Arc,
time::Duration,
};
use std::{collections::HashMap, io::Read, process::Stdio, sync::Arc, time::Duration};
use serde_json::{Value as Json, json};
use serde_json::json;
use tokio::{io::AsyncWriteExt, sync::Mutex};
use zbus::{
Connection,
@@ -14,6 +11,7 @@ use zbus::{
use crate::{
backend::{Cancel, Options, authorize},
bridge::Bridge,
consent::{Prompt, ShortcutField},
desktop::Requests,
};
@@ -21,66 +19,6 @@ use crate::{
const PATH: &str = "/org/freedesktop/portal/desktop";
type Shortcuts = Vec<(String, Options)>;
#[derive(Clone)]
struct Bridge(Arc<BridgeInner>);
struct BridgeInner {
stream: std::sync::Mutex<UnixStream>,
shutdown: UnixStream,
}
impl Bridge {
fn connect() -> Result<Self, String> {
let path = std::env::var_os("XDG_RUNTIME_DIR")
.map(std::path::PathBuf::from)
.ok_or("Missing runtime directory")?
.join("ferese/control.sock");
let stream = UnixStream::connect(path).map_err(|error| error.to_string())?;
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.map_err(|error| error.to_string())?;
stream
.set_write_timeout(Some(Duration::from_secs(2)))
.map_err(|error| error.to_string())?;
let shutdown = stream.try_clone().map_err(|error| error.to_string())?;
Ok(Self(Arc::new(BridgeInner {
stream: std::sync::Mutex::new(stream),
shutdown,
})))
}
async fn call(&self, command: &'static str, args: Json) -> Result<Json, String> {
let bridge = self.clone();
tokio::task::spawn_blocking(move || {
let mut stream = bridge
.0
.stream
.lock()
.map_err(|_| "Shortcut connection failed")?;
let request = ferese_ipc::Request {
version: ferese_ipc::VERSION,
id: 1,
kind: "command".into(),
command: command.into(),
args,
};
ferese_ipc::write_frame(&mut *stream, &request).map_err(|error| error.to_string())?;
let response: ferese_ipc::Response =
ferese_ipc::read_frame(&mut *stream).map_err(|error| error.to_string())?;
if let Some(error) = response.error {
return Err(error.message);
}
response.result.ok_or("Missing shortcut response".into())
})
.await
.map_err(|error| error.to_string())?
}
fn close(&self) {
let _ = self.0.shutdown.shutdown(std::net::Shutdown::Both);
}
}
struct Session {
owner: String,
app: String,
@@ -177,6 +115,7 @@ impl GlobalShortcuts {
};
let mut fields = fields;
let mut message = "Choose shortcuts for this application. Leave a shortcut empty to disable it. System shortcuts cannot be overridden.".to_owned();
loop {
let selected = tokio::select! {
biased;
@@ -187,7 +126,9 @@ impl GlobalShortcuts {
let Some(selected) = selected else {
return Ok(None);
};
validate_selection(&fields, &selected)?;
let registration = selected
.iter()
.filter(|field| !field.trigger.trim().is_empty())
@@ -199,6 +140,7 @@ impl GlobalShortcuts {
{
Ok(_) => {
let mut state = session.state.lock().await;
if session
.cancel
.stopped
@@ -218,6 +160,7 @@ impl GlobalShortcuts {
.filter(|field| !field.trigger.trim().is_empty())
.map(|field| json!({"id":field.id,"trigger":field.trigger}))
.collect::<Vec<_>>();
if bridge
.call("portal-shortcuts-register", json!(previous))
.await
@@ -226,16 +169,20 @@ impl GlobalShortcuts {
bridge.close();
}
}
return Ok(None);
}
state.fields = selected.clone();
state.bound = true;
state.bridge = Some(bridge.clone());
state.revision = state.revision.wrapping_add(1);
drop(state);
if let Err(error) = save(&session.app, &selected) {
eprintln!("ferese shortcuts: could not save preferences: {error}");
}
if first {
let backend = self.clone();
let connection = connection.clone();
@@ -244,6 +191,7 @@ impl GlobalShortcuts {
backend.watch(connection, path, session, bridge).await;
});
}
return Ok(Some(results(&selected)));
}
Err(error) => {
+8 -2
View File
@@ -22,7 +22,7 @@
DEALINGS IN THE SOFTWARE.
</copyright>
<interface name="ferese_shell_manager_v1" version="3">
<interface name="ferese_shell_manager_v1" version="4">
<description summary="create a capability-scoped Ferese shell controller">
This private global is visible only to clients granted the shell-control
capability. It does not assign a Wayland surface role.
@@ -35,7 +35,7 @@
<request name="destroy" type="destructor"/>
</interface>
<interface name="ferese_shell_v1" version="3">
<interface name="ferese_shell_v1" version="4">
<description summary="managed-window metadata and shell control">
Window, workspace, and output identifiers are unsigned 64-bit values
encoded as high and low 32-bit words. Each snapshot atomically replaces
@@ -156,5 +156,11 @@
<arg name="serial" type="uint"/>
<arg name="output_name" type="string"/>
</event>
<request name="confirm_logout_with_inhibitors" since="4">
<arg name="serial" type="uint"/>
<arg name="inhibitor_revision" type="uint"/>
<arg name="query_token" type="uint"/>
<arg name="force" type="uint"/>
</request>
</interface>
</protocol>
+234
View File
@@ -0,0 +1,234 @@
"""Exercise real inhibitor FD lifetimes and session handshake on an isolated bus.
FERESE_TEST_INHIBIT=1 python3 scripts/tests/test_inhibit_isolated.py
Uses a nested compositor and mock logind; never suspends or ends the host session.
"""
import json
import os
from pathlib import Path
import signal
import socket
import struct
import subprocess
import sys
import tempfile
import threading
import time
import unittest
REPO = Path(__file__).resolve().parents[2]
NAME = "org.freedesktop.impl.portal.desktop.ferese"
PATH = "/org/freedesktop/portal/desktop"
INTERFACE = "org.freedesktop.impl.portal.Inhibit"
@unittest.skipUnless(os.environ.get("FERESE_TEST_INHIBIT") == "1", "requires a Wayland host")
class InhibitIntegration(unittest.TestCase):
def test_private_bus_lifetimes_and_query_end(self):
result = subprocess.run(["dbus-run-session", "--", sys.executable, __file__, "--private"], capture_output=True, text=True, timeout=40)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
class IPC:
def __init__(self, path):
self.socket = socket.socket(socket.AF_UNIX)
self.socket.settimeout(3)
self.socket.connect(str(path))
def call(self, command, args=None):
request = json.dumps({"version": 1, "id": 1, "type": "command", "command": command, "args": args or {}}).encode()
self.socket.sendall(struct.pack(">I", len(request)) + request)
def read(size):
result = b""
while len(result) < size:
chunk = self.socket.recv(size - len(result))
if not chunk:
raise EOFError("IPC connection closed")
result += chunk
return result
response = json.loads(read(struct.unpack(">I", read(4))[0]))
if response.get("error"):
raise ValueError(response["error"])
return response["result"]
def close(self):
self.socket.close()
def private_test():
from gi.repository import Gio, GLib
processes = []
pipes = []
pipe_lock = threading.Lock()
ready = threading.Event()
login_loop = None
login_connection = None
manager_xml = """<node><interface name="org.freedesktop.login1.Manager">
<method name="Inhibit"><arg type="s" direction="in"/><arg type="s" direction="in"/><arg type="s" direction="in"/><arg type="s" direction="in"/><arg type="h" direction="out"/></method>
<method name="ListInhibitors"><arg type="a(ssssuu)" direction="out"/></method>
<signal name="PrepareForShutdown"><arg type="b"/></signal>
</interface></node>"""
def logind():
nonlocal login_loop, login_connection
context = GLib.MainContext.new()
context.push_thread_default()
login_connection = Gio.DBusConnection.new_for_address_sync(os.environ["DBUS_SESSION_BUS_ADDRESS"], Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, None, None)
login_connection.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "RequestName", GLib.Variant("(su)", ("org.freedesktop.login1", 0)), None, Gio.DBusCallFlags.NONE, 2000, None)
def method(connection, sender, object_path, interface, name, params, invocation):
if name == "Inhibit":
what, app, reason, mode = params.unpack()
reader, writer = os.pipe2(os.O_CLOEXEC | os.O_NONBLOCK)
descriptors = Gio.UnixFDList.new()
index = descriptors.append(writer)
with pipe_lock:
pipes.append((reader, what, app, reason, mode))
invocation.return_value_with_unix_fd_list(GLib.Variant("(h)", (index,)), descriptors)
os.close(writer)
else:
rows = []
with pipe_lock:
for reader, what, app, reason, mode in pipes:
try:
if os.read(reader, 1) == b"":
continue
except BlockingIOError:
pass
rows.append((what, app, reason, mode, os.getuid(), os.getpid()))
invocation.return_value(GLib.Variant("(a(ssssuu))", (rows,)))
login_connection.register_object("/org/freedesktop/login1", Gio.DBusNodeInfo.new_for_xml(manager_xml).interfaces[0], method, None, None)
login_loop = GLib.MainLoop.new(context, False)
ready.set()
login_loop.run()
context.pop_thread_default()
thread = threading.Thread(target=logind, daemon=True)
thread.start()
assert ready.wait(3), "Mock logind did not start"
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
def call(interface, method, params=None, signature=None, path=PATH):
return bus.call_sync(NAME, path, interface, method, params, GLib.VariantType.new(signature) if signature else None, Gio.DBusCallFlags.NO_AUTO_START, 5000, None).unpack()
def wait_for(check, message):
deadline = time.monotonic() + 4
while time.monotonic() < deadline:
while GLib.MainContext.default().pending():
GLib.MainContext.default().iteration(False)
if check():
return
time.sleep(0.01)
raise AssertionError(message)
def active_fd(index):
with pipe_lock:
try:
return os.read(pipes[index][0], 1) != b""
except BlockingIOError:
return True
with tempfile.TemporaryDirectory(prefix="ferese-inhibit-") as directory:
root = Path(directory)
runtime = root / "runtime"
runtime.mkdir(mode=0o700)
config = root / "config/ferese/config.kdl"
config.parent.mkdir(parents=True)
config.write_text("status { keybinding-guide #false; }\n")
display = Path(os.environ["WAYLAND_DISPLAY"])
if not display.is_absolute():
display = Path(os.environ["XDG_RUNTIME_DIR"]) / display
env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"), WAYLAND_DISPLAY=str(display), DBUS_SYSTEM_BUS_ADDRESS=os.environ["DBUS_SESSION_BUS_ADDRESS"])
env.pop("WAYLAND_SOCKET", None)
log = (root / "test.log").open("w")
try:
compositor = subprocess.Popen([str(REPO / "target/debug/ferese"), "--backend=nested"], env=env, stdout=log, stderr=log, start_new_session=True)
processes.append(compositor)
deadline = time.monotonic() + 10
while not (runtime / "ferese/control.sock").exists():
assert compositor.poll() is None and time.monotonic() < deadline, (root / "test.log").read_text()
time.sleep(0.02)
backend = subprocess.Popen([str(REPO / "target/debug/xdg-desktop-portal-ferese")], env=env, stdout=log, stderr=log, start_new_session=True)
processes.append(backend)
deadline = time.monotonic() + 10
while True:
try:
call("org.freedesktop.DBus.Introspectable", "Introspect", signature="(s)")
break
except GLib.Error:
assert backend.poll() is None and time.monotonic() < deadline, (root / "test.log").read_text()
time.sleep(0.02)
request = PATH + "/request/test/inhibit"
try:
call(INTERFACE, "Inhibit", GLib.Variant("(ossua{sv})", (request, "test.media", "", 8, {})))
raise AssertionError("Untrusted inhibition was accepted")
except GLib.Error as error:
assert "AccessDenied" in str(error)
bus.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "RequestName", GLib.Variant("(su)", ("org.freedesktop.portal.Desktop", 0)), None, Gio.DBusCallFlags.NONE, 2000, None)
admin = IPC(runtime / "ferese/control.sock")
def inhibit(path, flags):
call(INTERFACE, "Inhibit", GLib.Variant("(ossua{sv})", (path, "test.media", "", flags, {"reason": GLib.Variant("s", "Playing media")})))
inhibit(request, 8)
assert active_fd(0)
assert admin.call("get-session-state")["inhibitors"][0]["flags"] == 8
call("org.freedesktop.impl.portal.Request", "Close", path=request)
wait_for(lambda: not active_fd(0) and not admin.call("get-session-state")["inhibitors"], "Close retained inhibition")
session = PATH + "/session/test/monitor"
changes = []
closed = []
bus.signal_subscribe(NAME, INTERFACE, "StateChanged", PATH, None, Gio.DBusSignalFlags.NONE, lambda *args: changes.append(args[-1].unpack()[1]))
bus.signal_subscribe(NAME, "org.freedesktop.impl.portal.Session", "Closed", session, None, Gio.DBusSignalFlags.NONE, lambda *args: closed.append(True))
assert call(INTERFACE, "CreateMonitor", GLib.Variant("(ooss)", (PATH + "/request/test/monitor", session, "test.media", "")), "(u)") == (0,)
wait_for(lambda: any(value["session-state"] == 1 for value in changes), "Missing initial Running")
assert call("org.freedesktop.DBus.Properties", "Get", GLib.Variant("(ss)", ("org.freedesktop.impl.portal.Session", "version")), "(v)", session) == (1,)
query = IPC(runtime / "ferese/control.sock")
state = query.call("begin-session-end")
assert state["query-ready"] is False
wait_for(lambda: any(value["session-state"] == 2 for value in changes), "Missing QueryEnd")
call(INTERFACE, "QueryEndResponse", GLib.Variant("(o)", (session,)))
assert admin.call("get-session-state")["query-ready"] is True
approved = state["inhibitor-revision"]
inhibit(request, 1)
try:
query.call("commit-session-end", {"token": state["query-token"], "inhibitor-revision": approved, "force": True})
raise AssertionError("Late inhibitor bypassed confirmation")
except ValueError:
pass
current = admin.call("get-session-state")
query.call("validate-session-end", {"token": state["query-token"], "inhibitor-revision": current["inhibitor-revision"], "force": True})
assert admin.call("get-session-state")["session-state"] == 2
query.call("commit-session-end", {"token": state["query-token"], "inhibitor-revision": current["inhibitor-revision"], "force": True})
wait_for(lambda: any(value["session-state"] == 3 for value in changes), "Missing Ending")
query.call("cancel-session-end", {"token": state["query-token"]})
wait_for(lambda: changes[-1]["session-state"] == 1, "Missing cancelled end state")
query.close()
call("org.freedesktop.impl.portal.Session", "Close", path=session)
wait_for(lambda: len(closed) == 1, "Missing Closed")
count = len(changes)
login_connection.emit_signal(None, "/org/freedesktop/login1", "org.freedesktop.login1.Manager", "PrepareForShutdown", GLib.Variant("(b)", (True,)))
wait_for(lambda: admin.call("get-session-state")["session-state"] == 3, "Missing external shutdown state")
time.sleep(0.1)
while GLib.MainContext.default().pending():
GLib.MainContext.default().iteration(False)
assert len(changes) == count, "StateChanged appeared after Closed"
compositor.terminate()
compositor.wait(timeout=5)
wait_for(lambda: not active_fd(1), "Compositor death retained logind inhibitor FD")
admin.close()
print("Inhibit FD, monitor, ACK, late consent and disconnect checks passed")
finally:
for process in reversed(processes):
if process.poll() is None:
os.killpg(process.pid, signal.SIGTERM)
process.wait(timeout=5)
log.close()
login_loop.quit()
thread.join(timeout=2)
for pipe, *_ in pipes:
os.close(pipe)
if __name__ == "__main__":
if "--private" in sys.argv:
private_test()
else:
unittest.main()
+1 -1
View File
@@ -54,7 +54,7 @@ def private_bus_checks():
raise AssertionError((root / "backend.log").read_text())
time.sleep(0.05)
node = ET.fromstring(xml)
for name in ("ScreenCast", "Settings", "Screenshot", "Wallpaper", "Background", "Usb", "Lockdown", "GlobalShortcuts"):
for name in ("ScreenCast", "Settings", "Screenshot", "Wallpaper", "Background", "Usb", "Lockdown", "GlobalShortcuts", "Inhibit"):
interface = node.find(f"interface[@name='org.freedesktop.impl.portal.{name}']")
assert interface is not None, name
expected = ET.parse(f"/usr/share/dbus-1/interfaces/org.freedesktop.impl.portal.{name}.xml").getroot().find("interface")
+221
View File
@@ -0,0 +1,221 @@
use serde_json::{Value, json};
use std::{
os::unix::net::UnixStream,
path::PathBuf,
time::{Duration, Instant},
};
pub(super) fn call(command: &str) -> Result<Value, String> {
request(command, json!({}))
}
#[derive(Debug)]
struct SessionConnection {
stream: std::sync::Mutex<UnixStream>,
}
impl SessionConnection {
fn connect() -> Result<Self, String> {
let path = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.ok_or("Missing runtime directory")?
.join("ferese/control.sock");
let stream = UnixStream::connect(path).map_err(|error| error.to_string())?;
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.map_err(|error| error.to_string())?;
stream
.set_write_timeout(Some(Duration::from_secs(2)))
.map_err(|error| error.to_string())?;
Ok(Self {
stream: std::sync::Mutex::new(stream),
})
}
fn request(&self, command: &str, args: Value) -> Result<Value, String> {
let mut stream = self
.stream
.lock()
.map_err(|_| "Compositor connection failed")?;
let request = ferese_ipc::Request {
version: ferese_ipc::VERSION,
id: 1,
kind: "command".into(),
command: command.into(),
args,
};
ferese_ipc::write_frame(&mut *stream, &request).map_err(|error| error.to_string())?;
let response: ferese_ipc::Response =
ferese_ipc::read_frame(&mut *stream).map_err(|error| error.to_string())?;
if let Some(error) = response.error {
return Err(error.message);
}
response.result.ok_or("Missing compositor response".into())
}
}
fn request(command: &str, args: Value) -> Result<Value, String> {
SessionConnection::connect()?.request(command, args)
}
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub(super) enum Mode {
#[default]
Guide,
Logout,
Shutdown,
Suspend,
}
#[derive(Clone, Debug, Default)]
pub(super) struct Approval {
pub reasons: Vec<String>,
pub revision: u32,
pub token: u32,
mode: Mode,
external: Vec<String>,
connection: Option<std::sync::Arc<SessionConnection>>,
}
impl Approval {
pub fn force(&self) -> bool {
!self.reasons.is_empty()
}
pub fn cancel(&self) {
if self.mode == Mode::Shutdown {
let _ = request("cancel-session-end", json!({"token": self.token}));
}
}
pub fn prepare(&self) -> Result<(), String> {
if matches!(self.mode, Mode::Shutdown | Mode::Suspend)
&& login_inhibitors(self.mode)? != self.external
{
return Err(
"Applications changed their inhibitors; review the confirmation again".into(),
);
}
if self.mode == Mode::Shutdown {
self.connection.as_ref().ok_or("Missing session-ending lease")?.request(
"validate-session-end",
json!({"token": self.token, "inhibitor-revision": self.revision, "force": self.force()}),
)?;
} else if self.mode == Mode::Suspend {
let state = call("get-session-state")?;
if state["inhibitor-revision"].as_u64() != Some(u64::from(self.revision)) {
return Err(
"Applications changed their inhibitors; review the confirmation again".into(),
);
}
}
Ok(())
}
}
fn login_inhibitors(mode: Mode) -> Result<Vec<String>, String> {
if !matches!(mode, Mode::Shutdown | Mode::Suspend) {
return Ok(Vec::new());
}
let connection = zbus::blocking::connection::Builder::system()
.map_err(|error| error.to_string())?
.method_timeout(Duration::from_secs(2))
.build()
.map_err(|error| error.to_string())?;
let proxy = zbus::blocking::Proxy::new(
&connection,
"org.freedesktop.login1",
"/org/freedesktop/login1",
"org.freedesktop.login1.Manager",
)
.map_err(|error| error.to_string())?;
let inhibitors: Vec<(String, String, String, String, u32, u32)> = proxy
.call("ListInhibitors", &())
.map_err(|error| error.to_string())?;
let what = if mode == Mode::Suspend {
"sleep"
} else {
"shutdown"
};
let mut reasons = inhibitors
.into_iter()
.filter(|(flags, _, _, inhibition, _, _)| {
inhibition == "block" && flags.split(':').any(|flag| flag == what)
})
.map(|(_, app, reason, _, _, _)| format!("{app}: {reason}"))
.collect::<Vec<_>>();
reasons.sort();
reasons.dedup();
Ok(reasons)
}
pub(super) fn inhibitors(mode: Mode) -> Result<Approval, String> {
let connection = if mode == Mode::Shutdown {
Some(std::sync::Arc::new(SessionConnection::connect()?))
} else {
None
};
let mut state = match &connection {
Some(connection) => connection.request("begin-session-end", json!({}))?,
None => call("get-session-state")?,
};
let token = state["query-token"]
.as_u64()
.and_then(|token| u32::try_from(token).ok())
.unwrap_or(0);
let result = (|| {
if matches!(mode, Mode::Logout | Mode::Shutdown) {
let deadline = Instant::now() + Duration::from_millis(1500);
while state["query-ready"] != true {
if state["session-state"] != 2
|| state["query-token"].as_u64() != Some(u64::from(token))
|| Instant::now() >= deadline
{
return Err("Session-ending request was cancelled or timed out".into());
}
std::thread::sleep(Duration::from_millis(50));
state = match &connection {
Some(connection) => connection.request("get-session-state", json!({}))?,
None => call("get-session-state")?,
};
}
}
let flags = if mode == Mode::Suspend { 4 } else { 1 };
let entries = state["inhibitors"]
.as_array()
.ok_or("Invalid inhibitor list")?;
let mut reasons = entries
.iter()
.filter(|entry| entry["flags"].as_u64().unwrap_or(0) & flags != 0)
.map(|entry| {
let app = entry["app"]
.as_str()
.filter(|app| !app.is_empty())
.unwrap_or("Application");
let reason = entry["reason"]
.as_str()
.unwrap_or("Requested session inhibition");
format!("{app}: {reason}")
})
.collect::<Vec<_>>();
let external = login_inhibitors(mode)?;
reasons.extend(external.iter().cloned());
reasons.sort();
reasons.dedup();
Ok(Approval {
reasons,
revision: state["inhibitor-revision"]
.as_u64()
.and_then(|revision| u32::try_from(revision).ok())
.ok_or("Missing inhibitor revision")?,
token,
mode,
external,
connection,
})
})();
if result.is_err() && mode == Mode::Shutdown {
let _ = request("cancel-session-end", json!({"token": token}));
}
result
}
+5 -6
View File
@@ -81,7 +81,7 @@ impl ShellControl {
let connection = control_connection()?;
let (globals, mut queue) = registry_queue_init::<ControlState>(&connection)?;
let qh = queue.handle();
let manager = globals.bind::<FereseShellManagerV1, _, _>(&qh, 1..=3, ())?;
let manager = globals.bind::<FereseShellManagerV1, _, _>(&qh, 4..=4, ())?;
let shell = manager.get_shell(&qh, ());
let (sender, updates) = mpsc::channel();
let mut state = ControlState::new(sender);
@@ -150,11 +150,10 @@ impl ShellControl {
let _ = self.connection.flush();
}
pub(crate) fn confirm_logout(&self, serial: u32) {
if self.shell.version() >= 3 {
self.shell.confirm_logout(serial);
let _ = self.connection.flush();
}
pub(crate) fn confirm_logout(&self, serial: u32, revision: u32, token: u32, force: bool) {
self.shell
.confirm_logout_with_inhibitors(serial, revision, token, u32::from(force));
let _ = self.connection.flush();
}
pub(crate) fn cancel_logout(&self, serial: u32) {
+3 -28
View File
@@ -1,5 +1,3 @@
use std::{os::unix::net::UnixStream, path::PathBuf, time::Duration};
#[derive(Clone, Debug, PartialEq, serde::Deserialize)]
pub(super) struct Entry {
pub keys: String,
@@ -7,32 +5,9 @@ pub(super) struct Entry {
}
pub(super) fn load() -> Result<Vec<Entry>, String> {
let path = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.ok_or("Missing runtime directory")?
.join("ferese/control.sock");
let mut stream = UnixStream::connect(path).map_err(|error| error.to_string())?;
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.map_err(|error| error.to_string())?;
stream
.set_write_timeout(Some(Duration::from_secs(2)))
.map_err(|error| error.to_string())?;
let request = ferese_ipc::Request {
version: ferese_ipc::VERSION,
id: 1,
kind: "command".into(),
command: "get-keybindings".into(),
args: serde_json::json!({}),
};
ferese_ipc::write_frame(&mut stream, &request).map_err(|error| error.to_string())?;
let response: ferese_ipc::Response =
ferese_ipc::read_frame(&mut stream).map_err(|error| error.to_string())?;
if let Some(error) = response.error {
return Err(error.message);
}
let entries: Vec<Entry> = serde_json::from_value(response.result.ok_or("Missing keybindings")?)
.map_err(|error| error.to_string())?;
let entries: Vec<Entry> =
serde_json::from_value(crate::compositor_ipc::call("get-keybindings")?)
.map_err(|error| error.to_string())?;
if entries.len() > 256
|| entries
.iter()
+3
View File
@@ -1,3 +1,4 @@
mod compositor_ipc;
mod config;
mod control;
mod keybinding_guide;
@@ -256,6 +257,7 @@ enum Message {
Control(status::Action),
ShowGuide,
GuideLoaded(Result<Vec<keybinding_guide::Entry>, String>),
SystemInhibitors(window::Id, Result<compositor_ipc::Approval, String>),
ConfirmPower(status::Action),
CancelPower,
ExecutePower,
@@ -690,6 +692,7 @@ impl cosmic::Application for FereseShell {
}
}
}
Message::SystemInhibitors(id, result) => self.set_system_inhibitors(id, result),
Message::ConfirmPower(action) => {
if let Some(action) = system_modal::PowerAction::from_status(action) {
return self.open_system_modal(action, None);
+7 -2
View File
@@ -523,14 +523,19 @@ fn notifications(bus: &mut StatusBus) -> Option<Notifications> {
Some(Notifications { count, dnd })
}
pub(super) fn execute_power(action: Action) -> Result<(), String> {
pub(super) fn execute_power(action: Action, force: bool) -> Result<(), String> {
let action = match action {
Action::Poweroff => "poweroff",
Action::Reboot => "reboot",
Action::Suspend => "suspend",
_ => return Err("Invalid power action".to_owned()),
};
run_with_timeout("systemctl", &[action], "120s").map(|_| ())
let check = if force {
"--check-inhibitors=no"
} else {
"--check-inhibitors=yes"
};
run_with_timeout("systemctl", &[check, action], "120s").map(|_| ())
}
fn execute(action: &Action, settings: Option<&[String]>) -> Result<(), String> {
+120 -14
View File
@@ -65,6 +65,7 @@ pub(super) struct SystemModal {
surfaces: Vec<ModalSurface>,
pub(super) motion: motion::PopupMotion,
error: Option<String>,
inhibitors: Option<compositor_ipc::Approval>,
}
impl SystemModal {
@@ -183,15 +184,65 @@ impl FereseShell {
);
tasks.push(cosmic::task::message(cosmic::Action::Surface(action)));
}
let mode = match content {
Content::Power(PowerAction::Suspend) => Some(compositor_ipc::Mode::Suspend),
Content::Power(PowerAction::Logout(_)) => Some(compositor_ipc::Mode::Logout),
Content::Power(_) => Some(compositor_ipc::Mode::Shutdown),
Content::Guide(_) => None,
};
if let Some(mode) = mode {
let id = surfaces[0].id;
tasks.push(Task::perform(
async move {
tokio::task::spawn_blocking(move || compositor_ipc::inhibitors(mode))
.await
.map_err(|error| error.to_string())
.and_then(|result| result)
},
move |result| cosmic::Action::App(Message::SystemInhibitors(id, result)),
));
}
self.system_modal = Some(SystemModal {
content,
surfaces,
motion: motion::PopupMotion::new(self.config.animations),
error: None,
inhibitors: mode.is_none().then(compositor_ipc::Approval::default),
});
Task::batch(tasks)
}
pub(super) fn set_system_inhibitors(
&mut self,
id: window::Id,
result: Result<compositor_ipc::Approval, String>,
) -> Task<Message> {
if let Some(modal) = &mut self.system_modal
&& modal.contains(id)
&& matches!(modal.content, Content::Power(_))
&& !modal.motion.closing()
{
match result {
Ok(inhibitors) => modal.inhibitors = Some(inhibitors),
Err(error) => {
modal.error = Some(error);
}
}
} else if let Ok(approval) = result {
return Self::cancel_query(approval);
}
Task::none()
}
fn cancel_query(approval: compositor_ipc::Approval) -> Task<Message> {
Task::perform(
async move {
let _ = tokio::task::spawn_blocking(move || approval.cancel()).await;
},
|_| cosmic::Action::App(Message::AnimatePower),
)
}
pub(super) fn attach_power_material(
&mut self,
id: window::Id,
@@ -232,7 +283,13 @@ impl FereseShell {
{
control.cancel_logout(serial);
}
let cancel = modal
.inhibitors
.take()
.map(Self::cancel_query)
.unwrap_or_else(Task::none);
modal.motion.retarget(0.0, Instant::now());
return Task::batch([cancel, self.animate_system_modal()]);
}
self.animate_system_modal()
}
@@ -259,12 +316,15 @@ impl FereseShell {
{
control.cancel_logout(serial);
}
Task::batch(
modal
.surfaces
.into_iter()
.map(|surface| destroy_layer_surface(surface.id)),
)
let mut tasks = modal
.surfaces
.into_iter()
.map(|surface| destroy_layer_surface(surface.id))
.collect::<Vec<_>>();
if cancel && let Some(approval) = modal.inhibitors {
tasks.push(Self::cancel_query(approval));
}
Task::batch(tasks)
}
pub(super) fn cancel_logout_modal(&mut self, serial: u32) -> Task<Message> {
@@ -283,16 +343,22 @@ impl FereseShell {
let Some(modal) = &mut self.system_modal else {
return Task::none();
};
if modal.motion.closing() || self.pending_power.is_some() {
if modal.motion.closing() || self.pending_power.is_some() || modal.inhibitors.is_none() {
return Task::none();
}
let Content::Power(power) = modal.content else {
return self.close_system_modal();
};
let approval = modal.inhibitors.as_ref().unwrap().clone();
let action = match power {
PowerAction::Logout(serial) => {
if let Some(control) = &self.control {
control.confirm_logout(serial);
control.confirm_logout(
serial,
approval.revision,
approval.token,
approval.force(),
);
return self.destroy_system_modal(false);
}
modal.error = Some("Ferese shell control is unavailable".to_owned());
@@ -307,10 +373,18 @@ impl FereseShell {
let destroy = self.destroy_system_modal(false);
let execute = Task::perform(
async move {
tokio::task::spawn_blocking(move || status::execute_power(action))
.await
.map_err(|error| error.to_string())
.and_then(|result| result)
tokio::task::spawn_blocking(move || {
let result = approval
.prepare()
.and_then(|()| status::execute_power(action, approval.force()));
if result.is_err() {
approval.cancel();
}
result
})
.await
.map_err(|error| error.to_string())
.and_then(|result| result)
},
move |result| cosmic::Action::App(Message::PowerCompleted(id, result)),
);
@@ -369,6 +443,33 @@ impl FereseShell {
.class(theme::Text::Color(palette.muted)),
]
.spacing(12);
if let Some(inhibitors) = &modal.inhibitors
&& !inhibitors.reasons.is_empty()
{
rows = rows.push(
text("Applications requested that this action be prevented:")
.font(shell_font())
.size(13)
.class(theme::Text::Color(palette.muted)),
);
let items = inhibitors.reasons.iter().fold(
column::with_capacity(inhibitors.reasons.len()).spacing(6),
|rows, reason| rows.push(text(reason).font(shell_font()).size(13)),
);
rows = rows.push(
container(cosmic::widget::scrollable(items).height(Length::Shrink))
.max_height(160),
);
}
let label = if modal
.inhibitors
.as_ref()
.is_some_and(|approval| approval.force())
{
format!("{} anyway", action.label())
} else {
action.label().to_owned()
};
if let Some(error) = &modal.error {
rows = rows.push(text(error).size(13));
}
@@ -378,9 +479,14 @@ impl FereseShell {
button::text("Cancel")
.class(ferese_theme::controls::button_style(palette, false))
.on_press(cosmic::Action::App(Message::CancelPower)),
button::text(action.label())
button::text(label)
.class(ferese_theme::controls::button_style(palette, true))
.on_press(cosmic::Action::App(Message::ExecutePower)),
.on_press_maybe(
modal
.inhibitors
.is_some()
.then_some(cosmic::Action::App(Message::ExecutePower))
),
]
.spacing(10),
)