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);
|
self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
|
||||||
crate::backends::direct::render_all(self);
|
crate::backends::direct::render_all(self);
|
||||||
if self.idle_inhibitors.remove(surface).is_some() {
|
if self.idle_inhibitors.remove(surface).is_some() {
|
||||||
self.idle_notifier_state
|
self.refresh_idle_inhibition();
|
||||||
.set_is_inhibited(!self.idle_inhibitors.is_empty());
|
|
||||||
}
|
}
|
||||||
if matches!(&self.cursor_status, smithay::input::pointer::CursorImageStatus::Surface(cursor) if cursor == surface)
|
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_inhibitors.remove(&surface);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.idle_notifier_state
|
self.refresh_idle_inhibition();
|
||||||
.set_is_inhibited(!self.idle_inhibitors.is_empty());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+137
-4
@@ -122,10 +122,33 @@ pub(crate) fn init(
|
|||||||
IpcEvent::Call(call) => call,
|
IpcEvent::Call(call) => call,
|
||||||
IpcEvent::Closed(owner) => {
|
IpcEvent::Closed(owner) => {
|
||||||
state.portal_shortcuts.remove(owner);
|
state.portal_shortcuts.remove(owner);
|
||||||
|
state.portal_session.remove(owner);
|
||||||
|
state.refresh_idle_inhibition();
|
||||||
return;
|
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);
|
state.start_window_screenshot(call.request, call.response);
|
||||||
} else if call.request.command == "screenshot" {
|
} else if call.request.command == "screenshot" {
|
||||||
// Deferred: this path answers the caller itself, exactly
|
// 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");
|
tracing::warn!("disconnecting IPC client because the request queue is full");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Ok(response) = receiver.recv() else {
|
let response = loop {
|
||||||
return;
|
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();
|
let exit_accepted = exit_requested && response.error.is_none();
|
||||||
if let Err(error) = write_frame(&mut stream, &response) {
|
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) {
|
fn discard_undelivered_screenshot(response: &Response) {
|
||||||
if response.error.is_none()
|
if response.error.is_none()
|
||||||
&& let Some(path) = response
|
&& let Some(path) = response
|
||||||
@@ -357,7 +413,18 @@ fn discard_undelivered_screenshot(response: &Response) {
|
|||||||
|
|
||||||
impl Ferese {
|
impl Ferese {
|
||||||
fn handle_ipc_request(&mut self, owner: u64, request: Request) -> Response {
|
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(
|
return Response::error(
|
||||||
request.id,
|
request.id,
|
||||||
"session_locked",
|
"session_locked",
|
||||||
@@ -381,6 +448,72 @@ impl Ferese {
|
|||||||
args: &Value,
|
args: &Value,
|
||||||
) -> Result<Value, CommandError> {
|
) -> Result<Value, CommandError> {
|
||||||
match command {
|
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" => {
|
"portal-shortcuts-register" => {
|
||||||
let shortcuts = serde_json::from_value(args.clone())
|
let shortcuts = serde_json::from_value(args.clone())
|
||||||
.map_err(|error| CommandError::new("invalid_argument", error.to_string()))?;
|
.map_err(|error| CommandError::new("invalid_argument", error.to_string()))?;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ mod input;
|
|||||||
mod ipc;
|
mod ipc;
|
||||||
mod metrics;
|
mod metrics;
|
||||||
mod overview;
|
mod overview;
|
||||||
|
mod portal_session;
|
||||||
mod portal_shortcuts;
|
mod portal_shortcuts;
|
||||||
mod presentation;
|
mod presentation;
|
||||||
mod private_client;
|
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.cancel_logout_confirmation();
|
||||||
self.session_lock.active = true;
|
self.session_lock.active = true;
|
||||||
|
self.portal_session.set_locked(true);
|
||||||
self.session_lock.owner = Some(confirmation.ext_session_lock().clone());
|
self.session_lock.owner = Some(confirmation.ext_session_lock().clone());
|
||||||
self.session_lock.confirmation = Some(confirmation);
|
self.session_lock.confirmation = Some(confirmation);
|
||||||
for capture in self.pending_screencopies.drain(..) {
|
for capture in self.pending_screencopies.drain(..) {
|
||||||
@@ -165,6 +166,7 @@ impl SessionLockHandler for Ferese {
|
|||||||
|
|
||||||
fn unlock(&mut self) {
|
fn unlock(&mut self) {
|
||||||
self.session_lock = Lock::default();
|
self.session_lock = Lock::default();
|
||||||
|
self.portal_session.set_locked(false);
|
||||||
self.restore_keyboard_focus();
|
self.restore_keyboard_focus();
|
||||||
crate::backends::direct::render_all(self);
|
crate::backends::direct::render_all(self);
|
||||||
tracing::info!("session unlocked by lock owner");
|
tracing::info!("session unlocked by lock owner");
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use smithay::{
|
|||||||
use crate::{Ferese, private_client::ClientCapabilities, state::ClientState};
|
use crate::{Ferese, private_client::ClientCapabilities, state::ClientState};
|
||||||
|
|
||||||
pub(crate) fn init_global(display: &DisplayHandle) {
|
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> {
|
pub(crate) fn config_chunks(source: &str) -> Vec<&str> {
|
||||||
@@ -137,11 +137,25 @@ impl Dispatch<FereseShellV1, ()> for Ferese {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ferese_shell_v1::Request::ConfirmLogout { serial } => {
|
ferese_shell_v1::Request::ConfirmLogout { serial } => {
|
||||||
if state.logout_owner.as_ref() == Some(&shell.id())
|
let snapshot = state.portal_session.snapshot();
|
||||||
&& consume_logout_confirmation(&mut state.pending_logout, serial)
|
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.logout_owner = None;
|
state.confirm_portal_logout(shell, serial, token, revision, false);
|
||||||
state.loop_signal.stop();
|
}
|
||||||
|
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 } => {
|
ferese_shell_v1::Request::CancelLogout { serial } => {
|
||||||
@@ -183,12 +197,20 @@ impl Dispatch<FereseShellV1, ()> for Ferese {
|
|||||||
_data: &(),
|
_data: &(),
|
||||||
) {
|
) {
|
||||||
if state.logout_owner.as_ref() == Some(&shell.id()) {
|
if state.logout_owner.as_ref() == Some(&shell.id()) {
|
||||||
state.pending_logout = None;
|
state.cancel_logout_confirmation();
|
||||||
state.logout_owner = None;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
fn consume_logout_confirmation(pending: &mut Option<u32>, serial: u32) -> bool {
|
||||||
if *pending == Some(serial) {
|
if *pending == Some(serial) {
|
||||||
*pending = None;
|
*pending = None;
|
||||||
@@ -199,6 +221,34 @@ fn consume_logout_confirmation(pending: &mut Option<u32>, serial: u32) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Ferese {
|
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) {
|
pub(crate) fn request_logout_confirmation(&mut self) {
|
||||||
if self.session_lock.active {
|
if self.session_lock.active {
|
||||||
return;
|
return;
|
||||||
@@ -216,6 +266,7 @@ impl Ferese {
|
|||||||
self.cancel_logout_confirmation();
|
self.cancel_logout_confirmation();
|
||||||
let serial = u32::from(smithay::utils::SERIAL_COUNTER.next_serial());
|
let serial = u32::from(smithay::utils::SERIAL_COUNTER.next_serial());
|
||||||
let shell = &shells[0];
|
let shell = &shells[0];
|
||||||
|
self.logout_query = Some(self.portal_session.begin_query());
|
||||||
self.pending_logout = Some(serial);
|
self.pending_logout = Some(serial);
|
||||||
self.logout_owner = Some(shell.id());
|
self.logout_owner = Some(shell.id());
|
||||||
let output_name = self
|
let output_name = self
|
||||||
@@ -225,6 +276,9 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn cancel_logout_confirmation(&mut self) {
|
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() {
|
if let Some(serial) = self.pending_logout.take() {
|
||||||
for shell in self
|
for shell in self
|
||||||
.shell_resources
|
.shell_resources
|
||||||
@@ -507,6 +561,18 @@ fn send_request_failed(shell: &FereseShellV1, request: ferese_shell_v1::FailedRe
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]
|
#[test]
|
||||||
fn logout_requires_the_current_confirmation_and_can_be_cancelled() {
|
fn logout_requires_the_current_confirmation_and_can_be_cancelled() {
|
||||||
let mut pending = Some(42);
|
let mut pending = Some(42);
|
||||||
|
|||||||
@@ -231,6 +231,8 @@ pub struct Ferese {
|
|||||||
pub socket_name: OsString,
|
pub socket_name: OsString,
|
||||||
pub display_handle: DisplayHandle,
|
pub display_handle: DisplayHandle,
|
||||||
pub loop_signal: LoopSignal,
|
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 space: Space<Window>,
|
||||||
pub workspaces: WorkspaceSet,
|
pub workspaces: WorkspaceSet,
|
||||||
pub output_workspaces: OutputWorkspaceMap,
|
pub output_workspaces: OutputWorkspaceMap,
|
||||||
@@ -259,6 +261,7 @@ pub struct Ferese {
|
|||||||
pub(crate) material_buffers: HashMap<WlSurface, crate::winit::MaterialBuffers>,
|
pub(crate) material_buffers: HashMap<WlSurface, crate::winit::MaterialBuffers>,
|
||||||
pub(crate) portal_shortcuts: crate::portal_shortcuts::PortalShortcuts,
|
pub(crate) portal_shortcuts: crate::portal_shortcuts::PortalShortcuts,
|
||||||
pub(crate) pending_logout: Option<u32>,
|
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) logout_owner: Option<smithay::reexports::wayland_server::backend::ObjectId>,
|
||||||
pub(crate) blur_programs: HashMap<ErasedContextId, crate::winit::BlurProgram>,
|
pub(crate) blur_programs: HashMap<ErasedContextId, crate::winit::BlurProgram>,
|
||||||
pub(crate) backdrop_generation: u64,
|
pub(crate) backdrop_generation: u64,
|
||||||
@@ -448,6 +451,8 @@ impl Ferese {
|
|||||||
socket_name,
|
socket_name,
|
||||||
display_handle,
|
display_handle,
|
||||||
loop_signal: event_loop.get_signal(),
|
loop_signal: event_loop.get_signal(),
|
||||||
|
loop_handle: event_loop.handle(),
|
||||||
|
portal_session: Default::default(),
|
||||||
space: Space::default(),
|
space: Space::default(),
|
||||||
workspaces: WorkspaceSet::new(
|
workspaces: WorkspaceSet::new(
|
||||||
config.layout_mode,
|
config.layout_mode,
|
||||||
@@ -483,6 +488,7 @@ impl Ferese {
|
|||||||
material_buffers: HashMap::new(),
|
material_buffers: HashMap::new(),
|
||||||
portal_shortcuts: crate::portal_shortcuts::PortalShortcuts::default(),
|
portal_shortcuts: crate::portal_shortcuts::PortalShortcuts::default(),
|
||||||
pending_logout: None,
|
pending_logout: None,
|
||||||
|
logout_query: None,
|
||||||
logout_owner: None,
|
logout_owner: None,
|
||||||
blur_programs: HashMap::new(),
|
blur_programs: HashMap::new(),
|
||||||
backdrop_generation: 0,
|
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;
|
Closing a session or losing its compositor connection releases its shortcuts;
|
||||||
shortcuts do not activate on the lock screen. Saved choices are offered for
|
shortcuts do not activate on the lock screen. Saved choices are offered for
|
||||||
future sessions, with explicit approval before registering them.
|
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.
|
- **ScreenCast:** monitor sharing with a native picker and PipeWire streams.
|
||||||
See [screen sharing](screen-sharing.md) for lifetime and recording details.
|
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 |
|
| Background | Native application state, per-instance consent and XDG autostart |
|
||||||
| Usb | Native consent; standard frontend handles enumeration and device descriptors |
|
| Usb | Native consent; standard frontend handles enumeration and device descriptors |
|
||||||
| GlobalShortcuts | Native sessions, configurable keyboard triggers, conflict checks and activation/release signals |
|
| GlobalShortcuts | Native sessions, configurable keyboard triggers, conflict checks and activation/release signals |
|
||||||
| RemoteDesktop | Missing authorized input injection and EIS transport |
|
| RemoteDesktop | Deferred: authorized input injection and EIS transport |
|
||||||
| Clipboard | Missing remote-session clipboard transfer |
|
| Clipboard | Deferred with RemoteDesktop |
|
||||||
| InputCapture | Missing zones, pointer barriers and EIS transport |
|
| InputCapture | Missing zones, pointer barriers and EIS transport |
|
||||||
| Lockdown | Native seven-property policy provider with persisted configuration and change signals |
|
| 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,
|
The frontend supplies additional APIs such as OpenURI, network monitoring,
|
||||||
document export and permission storage. Adding a similarly named backend service
|
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
|
sensitive methods reject callers outside the portal frontend. It does not open
|
||||||
consent dialogs or capture the host desktop. Interactive consent, fractional-scale
|
consent dialogs or capture the host desktop. Interactive consent, fractional-scale
|
||||||
capture, polkit and hotplug still require a live session test.
|
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.Usb=ferese;
|
||||||
org.freedesktop.impl.portal.Lockdown=ferese;
|
org.freedesktop.impl.portal.Lockdown=ferese;
|
||||||
org.freedesktop.impl.portal.GlobalShortcuts=ferese;
|
org.freedesktop.impl.portal.GlobalShortcuts=ferese;
|
||||||
|
org.freedesktop.impl.portal.Inhibit=ferese;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
[portal]
|
[portal]
|
||||||
DBusName=org.freedesktop.impl.portal.desktop.ferese
|
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;
|
UseIn=Ferese;
|
||||||
+1
-1
@@ -31,7 +31,7 @@ memmap2 = "0.9"
|
|||||||
pipewire = { version = "0.10.1", features = ["v0_3_65"] }
|
pipewire = { version = "0.10.1", features = ["v0_3_65"] }
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.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-client.workspace = true
|
||||||
wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
|
wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
|
||||||
wayland-protocols-wlr.workspace = true
|
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 background = crate::permissions::Background::new(requests.clone());
|
||||||
let lockdown = crate::lockdown::Lockdown::new();
|
let lockdown = crate::lockdown::Lockdown::new();
|
||||||
let shortcuts = crate::shortcuts::GlobalShortcuts::new(requests.clone());
|
let shortcuts = crate::shortcuts::GlobalShortcuts::new(requests.clone());
|
||||||
|
let inhibit = crate::inhibit::Inhibit::default();
|
||||||
let connection = zbus::connection::Builder::session()?
|
let connection = zbus::connection::Builder::session()?
|
||||||
.name("org.freedesktop.impl.portal.desktop.ferese")?
|
.name("org.freedesktop.impl.portal.desktop.ferese")?
|
||||||
.serve_at(PATH, backend.clone())?
|
.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, background.clone())?
|
||||||
.serve_at(PATH, lockdown.clone())?
|
.serve_at(PATH, lockdown.clone())?
|
||||||
.serve_at(PATH, shortcuts.clone())?
|
.serve_at(PATH, shortcuts.clone())?
|
||||||
|
.serve_at(PATH, inhibit.clone())?
|
||||||
.serve_at(
|
.serve_at(
|
||||||
"/org/ferese/ScreenRecorder",
|
"/org/ferese/ScreenRecorder",
|
||||||
RecorderControl(backend.clone()),
|
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(settings.watch(connection.clone()));
|
||||||
tokio::spawn(background.watch(connection.clone()));
|
tokio::spawn(background.watch(connection.clone()));
|
||||||
tokio::spawn(lockdown.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.
|
// Frontend death must revoke every stream, even if Session.Close never arrives.
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
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;
|
requests.revoke_stale(owner.as_deref()).await;
|
||||||
shortcuts.revoke_stale(&connection, owner.as_deref()).await;
|
shortcuts.revoke_stale(&connection, owner.as_deref()).await;
|
||||||
|
inhibit.revoke_stale(&connection, owner.as_deref()).await;
|
||||||
let stale: Vec<_> = backend
|
let stale: Vec<_> = backend
|
||||||
.sessions
|
.sessions
|
||||||
.lock()
|
.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;
|
const MAX_IMAGE_BYTES: u64 = 128 * 1024 * 1024;
|
||||||
type Reply = (u32, Options);
|
type Reply = (u32, Options);
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone)]
|
||||||
pub(crate) struct Requests(Arc<Mutex<HashMap<String, (String, Arc<Cancel>)>>>);
|
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 {
|
impl Requests {
|
||||||
|
pub(crate) fn with_limit(limit: usize) -> Self {
|
||||||
|
Self(Default::default(), limit)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn revoke_stale(&self, owner: Option<&str>) {
|
pub(crate) async fn revoke_stale(&self, owner: Option<&str>) {
|
||||||
for (request_owner, cancel) in self.0.lock().await.values() {
|
for (request_owner, cancel) in self.0.lock().await.values() {
|
||||||
if Some(request_owner.as_str()) != owner {
|
if Some(request_owner.as_str()) != owner {
|
||||||
@@ -45,7 +55,7 @@ impl Requests {
|
|||||||
return Err(zbus::fdo::Error::InvalidArgs("Invalid request path".into()));
|
return Err(zbus::fdo::Error::InvalidArgs("Invalid request path".into()));
|
||||||
}
|
}
|
||||||
let mut pending = self.0.lock().await;
|
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(
|
return Err(zbus::fdo::Error::LimitsExceeded(
|
||||||
"Request limit reached".into(),
|
"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 backend;
|
||||||
|
mod bridge;
|
||||||
mod capture;
|
mod capture;
|
||||||
mod consent;
|
mod consent;
|
||||||
mod desktop;
|
mod desktop;
|
||||||
|
mod inhibit;
|
||||||
mod lockdown;
|
mod lockdown;
|
||||||
mod parent;
|
mod parent;
|
||||||
mod permissions;
|
mod permissions;
|
||||||
|
|||||||
+13
-65
@@ -1,9 +1,6 @@
|
|||||||
use std::{
|
use std::{collections::HashMap, io::Read, process::Stdio, sync::Arc, time::Duration};
|
||||||
collections::HashMap, io::Read, os::unix::net::UnixStream, process::Stdio, sync::Arc,
|
|
||||||
time::Duration,
|
|
||||||
};
|
|
||||||
|
|
||||||
use serde_json::{Value as Json, json};
|
use serde_json::json;
|
||||||
use tokio::{io::AsyncWriteExt, sync::Mutex};
|
use tokio::{io::AsyncWriteExt, sync::Mutex};
|
||||||
use zbus::{
|
use zbus::{
|
||||||
Connection,
|
Connection,
|
||||||
@@ -14,6 +11,7 @@ use zbus::{
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
backend::{Cancel, Options, authorize},
|
backend::{Cancel, Options, authorize},
|
||||||
|
bridge::Bridge,
|
||||||
consent::{Prompt, ShortcutField},
|
consent::{Prompt, ShortcutField},
|
||||||
desktop::Requests,
|
desktop::Requests,
|
||||||
};
|
};
|
||||||
@@ -21,66 +19,6 @@ use crate::{
|
|||||||
const PATH: &str = "/org/freedesktop/portal/desktop";
|
const PATH: &str = "/org/freedesktop/portal/desktop";
|
||||||
type Shortcuts = Vec<(String, Options)>;
|
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 {
|
struct Session {
|
||||||
owner: String,
|
owner: String,
|
||||||
app: String,
|
app: String,
|
||||||
@@ -177,6 +115,7 @@ impl GlobalShortcuts {
|
|||||||
};
|
};
|
||||||
let mut fields = fields;
|
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();
|
let mut message = "Choose shortcuts for this application. Leave a shortcut empty to disable it. System shortcuts cannot be overridden.".to_owned();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let selected = tokio::select! {
|
let selected = tokio::select! {
|
||||||
biased;
|
biased;
|
||||||
@@ -187,7 +126,9 @@ impl GlobalShortcuts {
|
|||||||
let Some(selected) = selected else {
|
let Some(selected) = selected else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
|
||||||
validate_selection(&fields, &selected)?;
|
validate_selection(&fields, &selected)?;
|
||||||
|
|
||||||
let registration = selected
|
let registration = selected
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|field| !field.trigger.trim().is_empty())
|
.filter(|field| !field.trigger.trim().is_empty())
|
||||||
@@ -199,6 +140,7 @@ impl GlobalShortcuts {
|
|||||||
{
|
{
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
let mut state = session.state.lock().await;
|
let mut state = session.state.lock().await;
|
||||||
|
|
||||||
if session
|
if session
|
||||||
.cancel
|
.cancel
|
||||||
.stopped
|
.stopped
|
||||||
@@ -218,6 +160,7 @@ impl GlobalShortcuts {
|
|||||||
.filter(|field| !field.trigger.trim().is_empty())
|
.filter(|field| !field.trigger.trim().is_empty())
|
||||||
.map(|field| json!({"id":field.id,"trigger":field.trigger}))
|
.map(|field| json!({"id":field.id,"trigger":field.trigger}))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
if bridge
|
if bridge
|
||||||
.call("portal-shortcuts-register", json!(previous))
|
.call("portal-shortcuts-register", json!(previous))
|
||||||
.await
|
.await
|
||||||
@@ -226,16 +169,20 @@ impl GlobalShortcuts {
|
|||||||
bridge.close();
|
bridge.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
state.fields = selected.clone();
|
state.fields = selected.clone();
|
||||||
state.bound = true;
|
state.bound = true;
|
||||||
state.bridge = Some(bridge.clone());
|
state.bridge = Some(bridge.clone());
|
||||||
state.revision = state.revision.wrapping_add(1);
|
state.revision = state.revision.wrapping_add(1);
|
||||||
drop(state);
|
drop(state);
|
||||||
|
|
||||||
if let Err(error) = save(&session.app, &selected) {
|
if let Err(error) = save(&session.app, &selected) {
|
||||||
eprintln!("ferese shortcuts: could not save preferences: {error}");
|
eprintln!("ferese shortcuts: could not save preferences: {error}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if first {
|
if first {
|
||||||
let backend = self.clone();
|
let backend = self.clone();
|
||||||
let connection = connection.clone();
|
let connection = connection.clone();
|
||||||
@@ -244,6 +191,7 @@ impl GlobalShortcuts {
|
|||||||
backend.watch(connection, path, session, bridge).await;
|
backend.watch(connection, path, session, bridge).await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(Some(results(&selected)));
|
return Ok(Some(results(&selected)));
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
DEALINGS IN THE SOFTWARE.
|
DEALINGS IN THE SOFTWARE.
|
||||||
</copyright>
|
</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">
|
<description summary="create a capability-scoped Ferese shell controller">
|
||||||
This private global is visible only to clients granted the shell-control
|
This private global is visible only to clients granted the shell-control
|
||||||
capability. It does not assign a Wayland surface role.
|
capability. It does not assign a Wayland surface role.
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
<request name="destroy" type="destructor"/>
|
<request name="destroy" type="destructor"/>
|
||||||
</interface>
|
</interface>
|
||||||
|
|
||||||
<interface name="ferese_shell_v1" version="3">
|
<interface name="ferese_shell_v1" version="4">
|
||||||
<description summary="managed-window metadata and shell control">
|
<description summary="managed-window metadata and shell control">
|
||||||
Window, workspace, and output identifiers are unsigned 64-bit values
|
Window, workspace, and output identifiers are unsigned 64-bit values
|
||||||
encoded as high and low 32-bit words. Each snapshot atomically replaces
|
encoded as high and low 32-bit words. Each snapshot atomically replaces
|
||||||
@@ -156,5 +156,11 @@
|
|||||||
<arg name="serial" type="uint"/>
|
<arg name="serial" type="uint"/>
|
||||||
<arg name="output_name" type="string"/>
|
<arg name="output_name" type="string"/>
|
||||||
</event>
|
</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>
|
</interface>
|
||||||
</protocol>
|
</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())
|
raise AssertionError((root / "backend.log").read_text())
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
node = ET.fromstring(xml)
|
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}']")
|
interface = node.find(f"interface[@name='org.freedesktop.impl.portal.{name}']")
|
||||||
assert interface is not None, 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")
|
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 connection = control_connection()?;
|
||||||
let (globals, mut queue) = registry_queue_init::<ControlState>(&connection)?;
|
let (globals, mut queue) = registry_queue_init::<ControlState>(&connection)?;
|
||||||
let qh = queue.handle();
|
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 shell = manager.get_shell(&qh, ());
|
||||||
let (sender, updates) = mpsc::channel();
|
let (sender, updates) = mpsc::channel();
|
||||||
let mut state = ControlState::new(sender);
|
let mut state = ControlState::new(sender);
|
||||||
@@ -150,11 +150,10 @@ impl ShellControl {
|
|||||||
let _ = self.connection.flush();
|
let _ = self.connection.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn confirm_logout(&self, serial: u32) {
|
pub(crate) fn confirm_logout(&self, serial: u32, revision: u32, token: u32, force: bool) {
|
||||||
if self.shell.version() >= 3 {
|
self.shell
|
||||||
self.shell.confirm_logout(serial);
|
.confirm_logout_with_inhibitors(serial, revision, token, u32::from(force));
|
||||||
let _ = self.connection.flush();
|
let _ = self.connection.flush();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn cancel_logout(&self, serial: u32) {
|
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)]
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize)]
|
||||||
pub(super) struct Entry {
|
pub(super) struct Entry {
|
||||||
pub keys: String,
|
pub keys: String,
|
||||||
@@ -7,32 +5,9 @@ pub(super) struct Entry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn load() -> Result<Vec<Entry>, String> {
|
pub(super) fn load() -> Result<Vec<Entry>, String> {
|
||||||
let path = std::env::var_os("XDG_RUNTIME_DIR")
|
let entries: Vec<Entry> =
|
||||||
.map(PathBuf::from)
|
serde_json::from_value(crate::compositor_ipc::call("get-keybindings")?)
|
||||||
.ok_or("Missing runtime directory")?
|
.map_err(|error| error.to_string())?;
|
||||||
.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())?;
|
|
||||||
if entries.len() > 256
|
if entries.len() > 256
|
||||||
|| entries
|
|| entries
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod compositor_ipc;
|
||||||
mod config;
|
mod config;
|
||||||
mod control;
|
mod control;
|
||||||
mod keybinding_guide;
|
mod keybinding_guide;
|
||||||
@@ -256,6 +257,7 @@ enum Message {
|
|||||||
Control(status::Action),
|
Control(status::Action),
|
||||||
ShowGuide,
|
ShowGuide,
|
||||||
GuideLoaded(Result<Vec<keybinding_guide::Entry>, String>),
|
GuideLoaded(Result<Vec<keybinding_guide::Entry>, String>),
|
||||||
|
SystemInhibitors(window::Id, Result<compositor_ipc::Approval, String>),
|
||||||
ConfirmPower(status::Action),
|
ConfirmPower(status::Action),
|
||||||
CancelPower,
|
CancelPower,
|
||||||
ExecutePower,
|
ExecutePower,
|
||||||
@@ -690,6 +692,7 @@ impl cosmic::Application for FereseShell {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Message::SystemInhibitors(id, result) => self.set_system_inhibitors(id, result),
|
||||||
Message::ConfirmPower(action) => {
|
Message::ConfirmPower(action) => {
|
||||||
if let Some(action) = system_modal::PowerAction::from_status(action) {
|
if let Some(action) = system_modal::PowerAction::from_status(action) {
|
||||||
return self.open_system_modal(action, None);
|
return self.open_system_modal(action, None);
|
||||||
|
|||||||
@@ -523,14 +523,19 @@ fn notifications(bus: &mut StatusBus) -> Option<Notifications> {
|
|||||||
Some(Notifications { count, dnd })
|
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 {
|
let action = match action {
|
||||||
Action::Poweroff => "poweroff",
|
Action::Poweroff => "poweroff",
|
||||||
Action::Reboot => "reboot",
|
Action::Reboot => "reboot",
|
||||||
Action::Suspend => "suspend",
|
Action::Suspend => "suspend",
|
||||||
_ => return Err("Invalid power action".to_owned()),
|
_ => 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> {
|
fn execute(action: &Action, settings: Option<&[String]>) -> Result<(), String> {
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ pub(super) struct SystemModal {
|
|||||||
surfaces: Vec<ModalSurface>,
|
surfaces: Vec<ModalSurface>,
|
||||||
pub(super) motion: motion::PopupMotion,
|
pub(super) motion: motion::PopupMotion,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
|
inhibitors: Option<compositor_ipc::Approval>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SystemModal {
|
impl SystemModal {
|
||||||
@@ -183,15 +184,65 @@ impl FereseShell {
|
|||||||
);
|
);
|
||||||
tasks.push(cosmic::task::message(cosmic::Action::Surface(action)));
|
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 {
|
self.system_modal = Some(SystemModal {
|
||||||
content,
|
content,
|
||||||
surfaces,
|
surfaces,
|
||||||
motion: motion::PopupMotion::new(self.config.animations),
|
motion: motion::PopupMotion::new(self.config.animations),
|
||||||
error: None,
|
error: None,
|
||||||
|
inhibitors: mode.is_none().then(compositor_ipc::Approval::default),
|
||||||
});
|
});
|
||||||
Task::batch(tasks)
|
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(
|
pub(super) fn attach_power_material(
|
||||||
&mut self,
|
&mut self,
|
||||||
id: window::Id,
|
id: window::Id,
|
||||||
@@ -232,7 +283,13 @@ impl FereseShell {
|
|||||||
{
|
{
|
||||||
control.cancel_logout(serial);
|
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());
|
modal.motion.retarget(0.0, Instant::now());
|
||||||
|
return Task::batch([cancel, self.animate_system_modal()]);
|
||||||
}
|
}
|
||||||
self.animate_system_modal()
|
self.animate_system_modal()
|
||||||
}
|
}
|
||||||
@@ -259,12 +316,15 @@ impl FereseShell {
|
|||||||
{
|
{
|
||||||
control.cancel_logout(serial);
|
control.cancel_logout(serial);
|
||||||
}
|
}
|
||||||
Task::batch(
|
let mut tasks = modal
|
||||||
modal
|
.surfaces
|
||||||
.surfaces
|
.into_iter()
|
||||||
.into_iter()
|
.map(|surface| destroy_layer_surface(surface.id))
|
||||||
.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> {
|
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 {
|
let Some(modal) = &mut self.system_modal else {
|
||||||
return Task::none();
|
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();
|
return Task::none();
|
||||||
}
|
}
|
||||||
let Content::Power(power) = modal.content else {
|
let Content::Power(power) = modal.content else {
|
||||||
return self.close_system_modal();
|
return self.close_system_modal();
|
||||||
};
|
};
|
||||||
|
let approval = modal.inhibitors.as_ref().unwrap().clone();
|
||||||
let action = match power {
|
let action = match power {
|
||||||
PowerAction::Logout(serial) => {
|
PowerAction::Logout(serial) => {
|
||||||
if let Some(control) = &self.control {
|
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);
|
return self.destroy_system_modal(false);
|
||||||
}
|
}
|
||||||
modal.error = Some("Ferese shell control is unavailable".to_owned());
|
modal.error = Some("Ferese shell control is unavailable".to_owned());
|
||||||
@@ -307,10 +373,18 @@ impl FereseShell {
|
|||||||
let destroy = self.destroy_system_modal(false);
|
let destroy = self.destroy_system_modal(false);
|
||||||
let execute = Task::perform(
|
let execute = Task::perform(
|
||||||
async move {
|
async move {
|
||||||
tokio::task::spawn_blocking(move || status::execute_power(action))
|
tokio::task::spawn_blocking(move || {
|
||||||
.await
|
let result = approval
|
||||||
.map_err(|error| error.to_string())
|
.prepare()
|
||||||
.and_then(|result| result)
|
.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)),
|
move |result| cosmic::Action::App(Message::PowerCompleted(id, result)),
|
||||||
);
|
);
|
||||||
@@ -369,6 +443,33 @@ impl FereseShell {
|
|||||||
.class(theme::Text::Color(palette.muted)),
|
.class(theme::Text::Color(palette.muted)),
|
||||||
]
|
]
|
||||||
.spacing(12);
|
.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 {
|
if let Some(error) = &modal.error {
|
||||||
rows = rows.push(text(error).size(13));
|
rows = rows.push(text(error).size(13));
|
||||||
}
|
}
|
||||||
@@ -378,9 +479,14 @@ impl FereseShell {
|
|||||||
button::text("Cancel")
|
button::text("Cancel")
|
||||||
.class(ferese_theme::controls::button_style(palette, false))
|
.class(ferese_theme::controls::button_style(palette, false))
|
||||||
.on_press(cosmic::Action::App(Message::CancelPower)),
|
.on_press(cosmic::Action::App(Message::CancelPower)),
|
||||||
button::text(action.label())
|
button::text(label)
|
||||||
.class(ferese_theme::controls::button_style(palette, true))
|
.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),
|
.spacing(10),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in new issue
Block a user