Implement native inhibition and acknowledged session-ending confirmation
This commit is contained in:
1 parent
cc441c049e
commit
29a71e9974
27 files changed
+1915
-142
No files matched your search
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
@@ -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()))?;
|
||||
|
||||
@@ -13,6 +13,7 @@ mod input;
|
||||
mod ipc;
|
||||
mod metrics;
|
||||
mod overview;
|
||||
mod portal_session;
|
||||
mod portal_shortcuts;
|
||||
mod presentation;
|
||||
mod private_client;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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,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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
mod backend;
|
||||
mod bridge;
|
||||
mod capture;
|
||||
mod consent;
|
||||
mod desktop;
|
||||
mod inhibit;
|
||||
mod lockdown;
|
||||
mod parent;
|
||||
mod permissions;
|
||||
|
||||
+13
-65
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user