Add configurable session-scoped global shortcut portal
This commit is contained in:
1 parent
e71cd88ff8
commit
c8ed8e6c30
16 files changed
+1496
-25
No files matched your search
@@ -485,6 +485,64 @@ pub enum BindingAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Binding {
|
impl Binding {
|
||||||
|
pub(crate) fn portal_trigger(chord: &str) -> Result<Self, ConfigError> {
|
||||||
|
if chord.len() > 256 {
|
||||||
|
return Err(ConfigError::InvalidBinding("Shortcut is too long".into()));
|
||||||
|
}
|
||||||
|
let parts = chord.split('+').collect::<Vec<_>>();
|
||||||
|
if parts
|
||||||
|
.iter()
|
||||||
|
.filter(|part| part.trim().eq_ignore_ascii_case("num"))
|
||||||
|
.count()
|
||||||
|
> 1
|
||||||
|
{
|
||||||
|
return Err(ConfigError::InvalidBinding("Duplicate NUM modifier".into()));
|
||||||
|
}
|
||||||
|
let chord = parts
|
||||||
|
.into_iter()
|
||||||
|
.filter(|part| !part.trim().eq_ignore_ascii_case("num"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("+");
|
||||||
|
let (modifiers, key) = parse_chord(&chord)?;
|
||||||
|
let symbol = parse_keysym(&key)?;
|
||||||
|
if (modifiers.ctrl
|
||||||
|
&& modifiers.alt
|
||||||
|
&& matches!(
|
||||||
|
symbol,
|
||||||
|
keysyms::KEY_Escape | keysyms::KEY_F1..=keysyms::KEY_F12
|
||||||
|
))
|
||||||
|
|| (keysyms::KEY_XF86Switch_VT_1..=keysyms::KEY_XF86Switch_VT_12).contains(&symbol)
|
||||||
|
{
|
||||||
|
return Err(ConfigError::InvalidBinding(
|
||||||
|
"Reserved system shortcut".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
modifiers,
|
||||||
|
trigger: BindingTrigger::Keysym(symbol),
|
||||||
|
action: BindingAction::None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn conflicts(&self, other: &Self, map: &xkb::Keymap) -> bool {
|
||||||
|
if self.modifiers != other.modifiers {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match (&self.trigger, &other.trigger) {
|
||||||
|
(BindingTrigger::Keysym(left), BindingTrigger::Keysym(right)) => left == right,
|
||||||
|
(BindingTrigger::Keysym(symbol), BindingTrigger::Physical(code))
|
||||||
|
| (BindingTrigger::Physical(code), BindingTrigger::Keysym(symbol)) => {
|
||||||
|
(0..map.num_layouts_for_key(*code)).any(|layout| {
|
||||||
|
map.key_get_syms_by_level(*code, layout, 0)
|
||||||
|
.iter()
|
||||||
|
.any(|key| key.raw() == *symbol)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
(BindingTrigger::Physical(left), BindingTrigger::Physical(right)) => left == right,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn matches(
|
pub fn matches(
|
||||||
&self,
|
&self,
|
||||||
keycode: Keycode,
|
keycode: Keycode,
|
||||||
@@ -1197,7 +1255,7 @@ fn validate_commands(commands: &HashMap<String, Vec<String>>) -> Result<(), Conf
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn physical_keymap(input: &InputSettings) -> Result<xkb::Keymap, ConfigError> {
|
pub(crate) fn physical_keymap(input: &InputSettings) -> Result<xkb::Keymap, ConfigError> {
|
||||||
let context = xkb::Context::new(xkb::CONTEXT_NO_FLAGS);
|
let context = xkb::Context::new(xkb::CONTEXT_NO_FLAGS);
|
||||||
let options = (!input.xkb_options.is_empty()).then(|| input.xkb_options.join(","));
|
let options = (!input.xkb_options.is_empty()).then(|| input.xkb_options.join(","));
|
||||||
|
|
||||||
|
|||||||
+17
-4
@@ -150,13 +150,15 @@ impl Ferese {
|
|||||||
SERIAL_COUNTER.next_serial(),
|
SERIAL_COUNTER.next_serial(),
|
||||||
Event::time(&event) as u32,
|
Event::time(&event) as u32,
|
||||||
|data, modifiers, keysym| {
|
|data, modifiers, keysym| {
|
||||||
|
if state == KeyState::Released {
|
||||||
|
data.portal_shortcuts.release(keycode, Event::time(&event));
|
||||||
|
if data.intercepted_keys.remove(&keycode) {
|
||||||
|
return FilterResult::Intercept(());
|
||||||
|
}
|
||||||
|
}
|
||||||
if data.session_lock.active {
|
if data.session_lock.active {
|
||||||
return FilterResult::Forward;
|
return FilterResult::Forward;
|
||||||
}
|
}
|
||||||
if state == KeyState::Released && data.intercepted_keys.remove(&keycode) {
|
|
||||||
return FilterResult::Intercept(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let symbol = keysym.modified_sym().raw();
|
let symbol = keysym.modified_sym().raw();
|
||||||
if data.overview.is_active()
|
if data.overview.is_active()
|
||||||
&& matches!(symbol, keysyms::KEY_Return | keysyms::KEY_KP_Enter)
|
&& matches!(symbol, keysyms::KEY_Return | keysyms::KEY_KP_Enter)
|
||||||
@@ -225,6 +227,17 @@ impl Ferese {
|
|||||||
.then(|| binding.action.clone())
|
.then(|| binding.action.clone())
|
||||||
});
|
});
|
||||||
let Some(action) = action else {
|
let Some(action) = action else {
|
||||||
|
if state == KeyState::Pressed
|
||||||
|
&& data.portal_shortcuts.press(
|
||||||
|
keycode,
|
||||||
|
&raw_symbols,
|
||||||
|
modifiers,
|
||||||
|
Event::time(&event),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
data.intercepted_keys.insert(keycode);
|
||||||
|
return FilterResult::Intercept(());
|
||||||
|
}
|
||||||
return FilterResult::Forward;
|
return FilterResult::Forward;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+65
-13
@@ -5,7 +5,7 @@ use std::os::fd::AsRawFd;
|
|||||||
use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
|
use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
|
||||||
use std::os::unix::net::{UnixListener, UnixStream};
|
use std::os::unix::net::{UnixListener, UnixStream};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
Arc,
|
Arc,
|
||||||
mpsc::{SyncSender, sync_channel},
|
mpsc::{SyncSender, sync_channel},
|
||||||
@@ -47,10 +47,28 @@ const DEADLINE_IDLE: Duration = Duration::from_secs(1);
|
|||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct IpcCall {
|
struct IpcCall {
|
||||||
|
owner: u64,
|
||||||
request: Request,
|
request: Request,
|
||||||
response: SyncSender<Response>,
|
response: SyncSender<Response>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum IpcEvent {
|
||||||
|
Call(IpcCall),
|
||||||
|
Closed(u64),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct IpcConnection {
|
||||||
|
owner: u64,
|
||||||
|
sender: channel::SyncSender<IpcEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for IpcConnection {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = self.sender.send(IpcEvent::Closed(self.owner));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ConnectionPermit {
|
struct ConnectionPermit {
|
||||||
active: Arc<AtomicUsize>,
|
active: Arc<AtomicUsize>,
|
||||||
}
|
}
|
||||||
@@ -94,18 +112,25 @@ pub(crate) fn init(
|
|||||||
inode: metadata.ino(),
|
inode: metadata.ino(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let (sender, receiver): (channel::SyncSender<IpcCall>, channel::Channel<IpcCall>) =
|
let (sender, receiver): (channel::SyncSender<IpcEvent>, channel::Channel<IpcEvent>) =
|
||||||
channel::sync_channel(REQUEST_QUEUE_CAPACITY);
|
channel::sync_channel(REQUEST_QUEUE_CAPACITY);
|
||||||
event_loop
|
event_loop
|
||||||
.handle()
|
.handle()
|
||||||
.insert_source(receiver, |event, _, state| {
|
.insert_source(receiver, |event, _, state| {
|
||||||
if let channel::Event::Msg(call) = event {
|
if let channel::Event::Msg(event) = event {
|
||||||
|
let call = match event {
|
||||||
|
IpcEvent::Call(call) => call,
|
||||||
|
IpcEvent::Closed(owner) => {
|
||||||
|
state.portal_shortcuts.remove(owner);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
if call.request.command == "screenshot" {
|
if call.request.command == "screenshot" {
|
||||||
// Deferred: this path answers the caller itself, exactly
|
// Deferred: this path answers the caller itself, exactly
|
||||||
// once, whenever the request finishes or is terminated.
|
// once, whenever the request finishes or is terminated.
|
||||||
state.start_screenshot(call.request, call.response);
|
state.start_screenshot(call.request, call.response);
|
||||||
} else {
|
} else {
|
||||||
let response = state.handle_ipc_request(call.request);
|
let response = state.handle_ipc_request(call.owner, call.request);
|
||||||
let _ = call.response.send(response);
|
let _ = call.response.send(response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,10 +212,11 @@ pub(crate) struct ScreenshotInit {
|
|||||||
|
|
||||||
fn accept_connections(
|
fn accept_connections(
|
||||||
listener: UnixListener,
|
listener: UnixListener,
|
||||||
sender: channel::SyncSender<IpcCall>,
|
sender: channel::SyncSender<IpcEvent>,
|
||||||
signal: LoopSignal,
|
signal: LoopSignal,
|
||||||
) {
|
) {
|
||||||
let active_connections = Arc::new(AtomicUsize::new(0));
|
let active_connections = Arc::new(AtomicUsize::new(0));
|
||||||
|
let connection_ids = AtomicU64::new(1);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let (stream, _) = match listener.accept() {
|
let (stream, _) = match listener.accept() {
|
||||||
@@ -222,12 +248,13 @@ fn accept_connections(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let sender = sender.clone();
|
let sender = sender.clone();
|
||||||
|
let owner = connection_ids.fetch_add(1, Ordering::Relaxed);
|
||||||
let signal = signal.clone();
|
let signal = signal.clone();
|
||||||
if let Err(error) = thread::Builder::new()
|
if let Err(error) = thread::Builder::new()
|
||||||
.name("ferese-ipc-client".to_owned())
|
.name("ferese-ipc-client".to_owned())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
let _permit = permit;
|
let _permit = permit;
|
||||||
serve_connection(stream, sender, signal);
|
serve_connection(stream, sender, signal, owner);
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
tracing::warn!(%error, "could not start IPC connection worker");
|
tracing::warn!(%error, "could not start IPC connection worker");
|
||||||
@@ -254,9 +281,14 @@ fn try_acquire_connection(active: &Arc<AtomicUsize>) -> Option<ConnectionPermit>
|
|||||||
|
|
||||||
fn serve_connection(
|
fn serve_connection(
|
||||||
mut stream: UnixStream,
|
mut stream: UnixStream,
|
||||||
sender: channel::SyncSender<IpcCall>,
|
sender: channel::SyncSender<IpcEvent>,
|
||||||
signal: LoopSignal,
|
signal: LoopSignal,
|
||||||
|
owner: u64,
|
||||||
) {
|
) {
|
||||||
|
let _connection = IpcConnection {
|
||||||
|
owner,
|
||||||
|
sender: sender.clone(),
|
||||||
|
};
|
||||||
loop {
|
loop {
|
||||||
let request: Request = match read_frame(&mut stream) {
|
let request: Request = match read_frame(&mut stream) {
|
||||||
Ok(request) => request,
|
Ok(request) => request,
|
||||||
@@ -277,7 +309,11 @@ fn serve_connection(
|
|||||||
};
|
};
|
||||||
let (response, receiver) = sync_channel(1);
|
let (response, receiver) = sync_channel(1);
|
||||||
let exit_requested = request.command == "exit";
|
let exit_requested = request.command == "exit";
|
||||||
let call = IpcCall { request, response };
|
let call = IpcEvent::Call(IpcCall {
|
||||||
|
owner,
|
||||||
|
request,
|
||||||
|
response,
|
||||||
|
});
|
||||||
if sender.try_send(call).is_err() {
|
if sender.try_send(call).is_err() {
|
||||||
tracing::warn!("disconnecting IPC client because the request queue is full");
|
tracing::warn!("disconnecting IPC client because the request queue is full");
|
||||||
return;
|
return;
|
||||||
@@ -300,8 +336,8 @@ fn serve_connection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Ferese {
|
impl Ferese {
|
||||||
fn handle_ipc_request(&mut self, request: Request) -> Response {
|
fn handle_ipc_request(&mut self, owner: u64, request: Request) -> Response {
|
||||||
if self.session_lock.active {
|
if self.session_lock.active && request.command != "portal-shortcuts-poll" {
|
||||||
return Response::error(
|
return Response::error(
|
||||||
request.id,
|
request.id,
|
||||||
"session_locked",
|
"session_locked",
|
||||||
@@ -312,14 +348,30 @@ impl Ferese {
|
|||||||
return Response::error(request.id, error.code, error.message);
|
return Response::error(request.id, error.code, error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
match self.dispatch_ipc_command(&request.command, &request.args) {
|
match self.dispatch_ipc_command(owner, &request.command, &request.args) {
|
||||||
Ok(result) => Response::success(request.id, result),
|
Ok(result) => Response::success(request.id, result),
|
||||||
Err(error) => Response::error(request.id, error.code, error.message),
|
Err(error) => Response::error(request.id, error.code, error.message),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dispatch_ipc_command(&mut self, command: &str, args: &Value) -> Result<Value, CommandError> {
|
fn dispatch_ipc_command(
|
||||||
|
&mut self,
|
||||||
|
owner: u64,
|
||||||
|
command: &str,
|
||||||
|
args: &Value,
|
||||||
|
) -> Result<Value, CommandError> {
|
||||||
match command {
|
match command {
|
||||||
|
"portal-shortcuts-register" => {
|
||||||
|
let shortcuts = serde_json::from_value(args.clone())
|
||||||
|
.map_err(|error| CommandError::new("invalid_argument", error.to_string()))?;
|
||||||
|
self.portal_shortcuts
|
||||||
|
.register(owner, shortcuts, &self.bindings, &self.input_settings)
|
||||||
|
.map_err(|error| CommandError::new("shortcut_conflict", error))?;
|
||||||
|
return Ok(json!({}));
|
||||||
|
}
|
||||||
|
"portal-shortcuts-poll" => {
|
||||||
|
return Ok(self.portal_shortcuts.poll(owner, self.session_lock.active));
|
||||||
|
}
|
||||||
"exit" => {} // The IPC worker stops the loop after writing the response.
|
"exit" => {} // The IPC worker stops the loop after writing the response.
|
||||||
"request-logout" => self.request_logout_confirmation(),
|
"request-logout" => self.request_logout_confirmation(),
|
||||||
"reload-config" => self
|
"reload-config" => self
|
||||||
@@ -961,7 +1013,7 @@ mod tests {
|
|||||||
let (sender, _receiver) = channel::sync_channel(1);
|
let (sender, _receiver) = channel::sync_channel(1);
|
||||||
let event_loop = EventLoop::<()>::try_new().unwrap();
|
let event_loop = EventLoop::<()>::try_new().unwrap();
|
||||||
let signal = event_loop.get_signal();
|
let signal = event_loop.get_signal();
|
||||||
let worker = thread::spawn(move || serve_connection(server, sender, signal));
|
let worker = thread::spawn(move || serve_connection(server, sender, signal, 1));
|
||||||
let invalid = b"not-json";
|
let invalid = b"not-json";
|
||||||
|
|
||||||
client
|
client
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ mod input;
|
|||||||
mod ipc;
|
mod ipc;
|
||||||
mod metrics;
|
mod metrics;
|
||||||
mod overview;
|
mod overview;
|
||||||
|
mod portal_shortcuts;
|
||||||
mod presentation;
|
mod presentation;
|
||||||
mod private_client;
|
mod private_client;
|
||||||
mod reload;
|
mod reload;
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
use std::{
|
||||||
|
collections::{HashMap, VecDeque},
|
||||||
|
time::Instant,
|
||||||
|
};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use smithay::input::keyboard::{Keycode, ModifiersState};
|
||||||
|
|
||||||
|
use crate::config::{Binding, InputSettings};
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub(crate) struct Shortcut {
|
||||||
|
pub id: String,
|
||||||
|
pub trigger: String,
|
||||||
|
#[serde(skip)]
|
||||||
|
num_required: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Registration {
|
||||||
|
shortcuts: Vec<(Shortcut, Binding)>,
|
||||||
|
held: HashMap<Keycode, String>,
|
||||||
|
events: VecDeque<Value>,
|
||||||
|
latest: (u64, Instant),
|
||||||
|
closed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Registration {
|
||||||
|
fn timestamp(&self) -> u64 {
|
||||||
|
self.latest
|
||||||
|
.0
|
||||||
|
.saturating_add(self.latest.1.elapsed().as_millis() as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn release_all(&mut self) {
|
||||||
|
let timestamp = self.timestamp();
|
||||||
|
for id in self.held.drain().map(|(_, id)| id) {
|
||||||
|
self.events
|
||||||
|
.push_back(json!({"id":id,"active":false,"timestamp":timestamp}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct PortalShortcuts(HashMap<u64, Registration>);
|
||||||
|
|
||||||
|
impl PortalShortcuts {
|
||||||
|
pub(crate) fn register(
|
||||||
|
&mut self,
|
||||||
|
owner: u64,
|
||||||
|
shortcuts: Vec<Shortcut>,
|
||||||
|
configured: &[Binding],
|
||||||
|
input: &InputSettings,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if shortcuts.len() > 64 || (!self.0.contains_key(&owner) && self.0.len() >= 16) {
|
||||||
|
return Err("Shortcut limit reached".into());
|
||||||
|
}
|
||||||
|
let keymap = crate::config::physical_keymap(input).map_err(|error| error.to_string())?;
|
||||||
|
if self
|
||||||
|
.0
|
||||||
|
.get(&owner)
|
||||||
|
.is_some_and(|registration| registration.closed)
|
||||||
|
{
|
||||||
|
return Err("Shortcut session has ended".into());
|
||||||
|
}
|
||||||
|
let mut parsed: Vec<(Shortcut, Binding)> = Vec::new();
|
||||||
|
for mut shortcut in shortcuts {
|
||||||
|
shortcut.num_required = shortcut
|
||||||
|
.trigger
|
||||||
|
.split('+')
|
||||||
|
.any(|part| part.trim().eq_ignore_ascii_case("num"));
|
||||||
|
if shortcut.id.is_empty()
|
||||||
|
|| shortcut.id.len() > 256
|
||||||
|
|| shortcut.id.chars().any(char::is_control)
|
||||||
|
{
|
||||||
|
return Err("Invalid shortcut identifier".into());
|
||||||
|
}
|
||||||
|
let binding =
|
||||||
|
Binding::portal_trigger(&shortcut.trigger).map_err(|error| error.to_string())?;
|
||||||
|
if configured
|
||||||
|
.iter()
|
||||||
|
.any(|other| binding.conflicts(other, &keymap))
|
||||||
|
|| parsed
|
||||||
|
.iter()
|
||||||
|
.any(|(other, key)| other.id == shortcut.id || binding.conflicts(key, &keymap))
|
||||||
|
|| self.0.iter().any(|(connection, registration)| {
|
||||||
|
*connection != owner
|
||||||
|
&& registration
|
||||||
|
.shortcuts
|
||||||
|
.iter()
|
||||||
|
.any(|(_, other)| binding.conflicts(other, &keymap))
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err(format!("Shortcut {} is already in use", shortcut.trigger));
|
||||||
|
}
|
||||||
|
parsed.push((shortcut, binding));
|
||||||
|
}
|
||||||
|
let registration = self.0.entry(owner).or_insert_with(|| Registration {
|
||||||
|
shortcuts: Vec::new(),
|
||||||
|
held: HashMap::new(),
|
||||||
|
events: VecDeque::new(),
|
||||||
|
latest: (0, Instant::now()),
|
||||||
|
closed: false,
|
||||||
|
});
|
||||||
|
registration.release_all();
|
||||||
|
registration.shortcuts = parsed;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn remove(&mut self, owner: u64) {
|
||||||
|
self.0.remove(&owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn reconcile(&mut self, configured: &[Binding], input: &InputSettings) {
|
||||||
|
let Ok(keymap) = crate::config::physical_keymap(input) else {
|
||||||
|
for registration in self.0.values_mut() {
|
||||||
|
registration.closed = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for registration in self.0.values_mut() {
|
||||||
|
registration.shortcuts.retain(|(_, binding)| {
|
||||||
|
!configured.iter().any(|key| binding.conflicts(key, &keymap))
|
||||||
|
});
|
||||||
|
let active_ids = registration
|
||||||
|
.shortcuts
|
||||||
|
.iter()
|
||||||
|
.map(|(shortcut, _)| shortcut.id.as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let timestamp = registration.timestamp();
|
||||||
|
registration.held.retain(|_, id| {
|
||||||
|
if active_ids.contains(&id.as_str()) {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
registration
|
||||||
|
.events
|
||||||
|
.push_back(json!({"id":id,"active":false,"timestamp":timestamp}));
|
||||||
|
false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn poll(&mut self, owner: u64, locked: bool) -> Value {
|
||||||
|
let Some(registration) = self.0.get_mut(&owner) else {
|
||||||
|
return json!({"closed":true});
|
||||||
|
};
|
||||||
|
if registration.closed {
|
||||||
|
return json!({"closed":true});
|
||||||
|
}
|
||||||
|
if locked {
|
||||||
|
registration.release_all();
|
||||||
|
}
|
||||||
|
json!({
|
||||||
|
"shortcuts": registration.shortcuts.iter().map(|(shortcut, _)| json!({"id":shortcut.id,"trigger":shortcut.trigger})).collect::<Vec<_>>(),
|
||||||
|
"events": registration.events.drain(..).collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn press(
|
||||||
|
&mut self,
|
||||||
|
key: Keycode,
|
||||||
|
symbols: &[u32],
|
||||||
|
modifiers: &ModifiersState,
|
||||||
|
timestamp: u64,
|
||||||
|
) -> bool {
|
||||||
|
for registration in self.0.values_mut() {
|
||||||
|
let Some((shortcut, _)) = registration.shortcuts.iter().find(|(shortcut, binding)| {
|
||||||
|
(!shortcut.num_required || modifiers.num_lock)
|
||||||
|
&& binding.matches(
|
||||||
|
key,
|
||||||
|
symbols,
|
||||||
|
modifiers.logo,
|
||||||
|
modifiers.ctrl,
|
||||||
|
modifiers.alt,
|
||||||
|
modifiers.shift,
|
||||||
|
)
|
||||||
|
}) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !registration.held.contains_key(&key) {
|
||||||
|
if registration.events.len() >= 256 {
|
||||||
|
registration.closed = true;
|
||||||
|
registration.shortcuts.clear();
|
||||||
|
registration.held.clear();
|
||||||
|
registration.events.clear();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
registration.latest = (timestamp, Instant::now());
|
||||||
|
registration.held.insert(key, shortcut.id.clone());
|
||||||
|
registration
|
||||||
|
.events
|
||||||
|
.push_back(json!({"id":shortcut.id,"active":true,"timestamp":timestamp}));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn release(&mut self, key: Keycode, timestamp: u64) {
|
||||||
|
for registration in self.0.values_mut() {
|
||||||
|
if let Some(id) = registration.held.remove(&key) {
|
||||||
|
registration.latest = (timestamp, Instant::now());
|
||||||
|
registration
|
||||||
|
.events
|
||||||
|
.push_back(json!({"id":id,"active":false,"timestamp":timestamp}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use smithay::input::keyboard::keysyms;
|
||||||
|
|
||||||
|
fn shortcut(id: &str, trigger: &str) -> Shortcut {
|
||||||
|
Shortcut {
|
||||||
|
id: id.into(),
|
||||||
|
trigger: trigger.into(),
|
||||||
|
num_required: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registrations_are_atomic_and_connection_scoped() {
|
||||||
|
let input = crate::config::Config::parse_source("")
|
||||||
|
.unwrap()
|
||||||
|
.input_settings()
|
||||||
|
.unwrap();
|
||||||
|
let configured = vec![Binding::portal_trigger("Super+q").unwrap()];
|
||||||
|
let mut shortcuts = PortalShortcuts::default();
|
||||||
|
shortcuts
|
||||||
|
.register(1, vec![shortcut("one", "Ctrl+Alt+a")], &configured, &input)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
shortcuts
|
||||||
|
.register(2, vec![shortcut("two", "Ctrl+Alt+a")], &configured, &input)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
shortcuts
|
||||||
|
.register(1, vec![shortcut("one", "Super+q")], &configured, &input)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
shortcuts.poll(1, false)["shortcuts"][0]["trigger"],
|
||||||
|
"Ctrl+Alt+a"
|
||||||
|
);
|
||||||
|
shortcuts.remove(1);
|
||||||
|
assert_eq!(shortcuts.poll(1, false)["closed"], true);
|
||||||
|
shortcuts
|
||||||
|
.register(2, vec![shortcut("two", "Ctrl+Alt+a")], &configured, &input)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn releases_follow_the_pressed_key_and_lock_releases_held_shortcuts() {
|
||||||
|
let mut shortcuts = PortalShortcuts::default();
|
||||||
|
shortcuts
|
||||||
|
.register(
|
||||||
|
1,
|
||||||
|
vec![shortcut("one", "Ctrl+Alt+a")],
|
||||||
|
&[],
|
||||||
|
&crate::config::Config::parse_source("")
|
||||||
|
.unwrap()
|
||||||
|
.input_settings()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let key = Keycode::new(38);
|
||||||
|
let modifiers = ModifiersState {
|
||||||
|
ctrl: true,
|
||||||
|
alt: true,
|
||||||
|
..ModifiersState::default()
|
||||||
|
};
|
||||||
|
assert!(shortcuts.press(key, &[keysyms::KEY_a], &modifiers, 10));
|
||||||
|
assert!(shortcuts.press(key, &[keysyms::KEY_a], &modifiers, 11));
|
||||||
|
shortcuts.release(key, 12);
|
||||||
|
let result = shortcuts.poll(1, false);
|
||||||
|
assert_eq!(result["events"].as_array().unwrap().len(), 2);
|
||||||
|
assert_eq!(result["events"][1]["active"], false);
|
||||||
|
assert!(shortcuts.press(key, &[keysyms::KEY_a], &modifiers, 13));
|
||||||
|
assert_eq!(shortcuts.poll(1, true)["events"][1]["active"], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn configuration_changes_revoke_conflicting_shortcuts() {
|
||||||
|
let mut shortcuts = PortalShortcuts::default();
|
||||||
|
let input = crate::config::Config::parse_source("")
|
||||||
|
.unwrap()
|
||||||
|
.input_settings()
|
||||||
|
.unwrap();
|
||||||
|
shortcuts
|
||||||
|
.register(1, vec![shortcut("one", "Ctrl+Alt+a")], &[], &input)
|
||||||
|
.unwrap();
|
||||||
|
shortcuts.reconcile(&[Binding::portal_trigger("Ctrl+Alt+a").unwrap()], &input);
|
||||||
|
assert!(
|
||||||
|
shortcuts.poll(1, false)["shortcuts"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert!(Binding::portal_trigger("Ctrl+Alt+Escape").is_err());
|
||||||
|
assert!(Binding::portal_trigger("Ctrl+Alt+F3").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn queue_overflow_closes_the_session_instead_of_leaving_held_keys() {
|
||||||
|
let mut shortcuts = PortalShortcuts::default();
|
||||||
|
let input = crate::config::Config::parse_source("")
|
||||||
|
.unwrap()
|
||||||
|
.input_settings()
|
||||||
|
.unwrap();
|
||||||
|
shortcuts
|
||||||
|
.register(1, vec![shortcut("one", "Ctrl+Alt+a")], &[], &input)
|
||||||
|
.unwrap();
|
||||||
|
let key = Keycode::new(38);
|
||||||
|
let modifiers = ModifiersState {
|
||||||
|
ctrl: true,
|
||||||
|
alt: true,
|
||||||
|
..ModifiersState::default()
|
||||||
|
};
|
||||||
|
for timestamp in 0..200 {
|
||||||
|
shortcuts.press(key, &[keysyms::KEY_a], &modifiers, timestamp * 2);
|
||||||
|
shortcuts.release(key, timestamp * 2 + 1);
|
||||||
|
}
|
||||||
|
assert_eq!(shortcuts.poll(1, false)["closed"], true);
|
||||||
|
assert!(!shortcuts.press(key, &[keysyms::KEY_a], &modifiers, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn physical_conflicts_include_secondary_layouts_and_num_modifier_is_honored() {
|
||||||
|
let config = crate::config::Config::parse_source(
|
||||||
|
"binding keys=\"Ctrl+AD01\" match=\"physical\" action=\"none\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut input = config.input_settings().unwrap();
|
||||||
|
input.xkb_layout = "us,fr".into();
|
||||||
|
let configured = config.bindings(&input).unwrap();
|
||||||
|
let mut shortcuts = PortalShortcuts::default();
|
||||||
|
assert!(
|
||||||
|
shortcuts
|
||||||
|
.register(1, vec![shortcut("one", "Ctrl+a")], &configured, &input)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
shortcuts
|
||||||
|
.register(1, vec![shortcut("one", "NUM+Ctrl+z")], &[], &input)
|
||||||
|
.unwrap();
|
||||||
|
let key = Keycode::new(52);
|
||||||
|
let mut modifiers = ModifiersState {
|
||||||
|
ctrl: true,
|
||||||
|
..ModifiersState::default()
|
||||||
|
};
|
||||||
|
assert!(!shortcuts.press(key, &[keysyms::KEY_z], &modifiers, 1));
|
||||||
|
modifiers.num_lock = true;
|
||||||
|
assert!(shortcuts.press(key, &[keysyms::KEY_z], &modifiers, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -257,6 +257,7 @@ pub struct Ferese {
|
|||||||
pub(crate) overview_scrims: HashMap<OutputId, crate::winit::OverviewScrim>,
|
pub(crate) overview_scrims: HashMap<OutputId, crate::winit::OverviewScrim>,
|
||||||
pub(crate) material_programs: HashMap<ErasedContextId, crate::winit::MaterialProgram>,
|
pub(crate) material_programs: HashMap<ErasedContextId, crate::winit::MaterialProgram>,
|
||||||
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) pending_logout: Option<u32>,
|
pub(crate) pending_logout: 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>,
|
||||||
@@ -480,6 +481,7 @@ impl Ferese {
|
|||||||
overview_scrims: HashMap::new(),
|
overview_scrims: HashMap::new(),
|
||||||
material_programs: HashMap::new(),
|
material_programs: HashMap::new(),
|
||||||
material_buffers: HashMap::new(),
|
material_buffers: HashMap::new(),
|
||||||
|
portal_shortcuts: crate::portal_shortcuts::PortalShortcuts::default(),
|
||||||
pending_logout: None,
|
pending_logout: None,
|
||||||
logout_owner: None,
|
logout_owner: None,
|
||||||
blur_programs: HashMap::new(),
|
blur_programs: HashMap::new(),
|
||||||
@@ -626,6 +628,8 @@ impl Ferese {
|
|||||||
self.gap_config = config.gap_config;
|
self.gap_config = config.gap_config;
|
||||||
self.input_settings = config.input_settings;
|
self.input_settings = config.input_settings;
|
||||||
self.bindings = config.bindings;
|
self.bindings = config.bindings;
|
||||||
|
self.portal_shortcuts
|
||||||
|
.reconcile(&self.bindings, &self.input_settings);
|
||||||
let old_rules = std::mem::replace(&mut self.window_rules, config.window_rules);
|
let old_rules = std::mem::replace(&mut self.window_rules, config.window_rules);
|
||||||
self.theme_settings = config.theme_settings;
|
self.theme_settings = config.theme_settings;
|
||||||
self.column_width_presets = config.column_width_presets;
|
self.column_width_presets = config.column_width_presets;
|
||||||
|
|||||||
+14
-1
@@ -31,6 +31,12 @@ standard file, print and application dialogs. Install both backends.
|
|||||||
policy. Only the portal frontend may write backend policy properties.
|
policy. Only the portal frontend may write backend policy properties.
|
||||||
- **USB:** native confirmation showing device vendor/model and requested read-only
|
- **USB:** native confirmation showing device vendor/model and requested read-only
|
||||||
or read/write access. The portal frontend handles device enumeration and opening.
|
or read/write access. The portal frontend handles device enumeration and opening.
|
||||||
|
- **GlobalShortcuts:** session-scoped keyboard shortcuts with native trigger
|
||||||
|
selection, conflict checking, activation/release signals and reconfiguration.
|
||||||
|
Applications cannot replace Ferese bindings or reserved escape/VT 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
|
||||||
|
future sessions, with explicit approval before registering them.
|
||||||
- **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.
|
||||||
|
|
||||||
@@ -59,7 +65,7 @@ Matching D-Bus signatures does not establish complete feature parity.
|
|||||||
| Access, Account, AppChooser, DynamicLauncher, FileChooser, Notification, Print | Delegated to GTK |
|
| Access, Account, AppChooser, DynamicLauncher, FileChooser, Notification, Print | Delegated to GTK |
|
||||||
| 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 | Missing session-scoped compositor registration and activation signals |
|
| GlobalShortcuts | Native sessions, configurable keyboard triggers, conflict checks and activation/release signals |
|
||||||
| RemoteDesktop | Missing authorized input injection and EIS transport |
|
| RemoteDesktop | Missing authorized input injection and EIS transport |
|
||||||
| Clipboard | Missing remote-session clipboard transfer |
|
| Clipboard | Missing remote-session clipboard transfer |
|
||||||
| InputCapture | Missing zones, pointer barriers and EIS transport |
|
| InputCapture | Missing zones, pointer barriers and EIS transport |
|
||||||
@@ -85,6 +91,13 @@ cargo test --locked -p xdg-desktop-portal-ferese
|
|||||||
python3 scripts/tests/test_portal_contracts.py
|
python3 scripts/tests/test_portal_contracts.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
With a Wayland host and a built compositor, check shortcut connection cleanup
|
||||||
|
and configuration conflicts in an isolated nested compositor:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
FERESE_TEST_SHORTCUTS=1 python3 scripts/tests/test_shortcuts_isolated.py
|
||||||
|
```
|
||||||
|
|
||||||
The contract test compares introspection against the installed official backend
|
The contract test compares introspection against the installed official backend
|
||||||
XML, verifies appearance changes and invalid-save retention, and checks that
|
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
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ org.freedesktop.impl.portal.Wallpaper=ferese;
|
|||||||
org.freedesktop.impl.portal.Background=ferese;
|
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;
|
||||||
@@ -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;
|
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;
|
||||||
UseIn=Ferese;
|
UseIn=Ferese;
|
||||||
@@ -668,6 +668,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let requests = crate::desktop::Requests::default();
|
let requests = crate::desktop::Requests::default();
|
||||||
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 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())?
|
||||||
@@ -677,6 +678,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.serve_at(PATH, crate::permissions::Usb(requests.clone()))?
|
.serve_at(PATH, crate::permissions::Usb(requests.clone()))?
|
||||||
.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(
|
.serve_at(
|
||||||
"/org/ferese/ScreenRecorder",
|
"/org/ferese/ScreenRecorder",
|
||||||
RecorderControl(backend.clone()),
|
RecorderControl(backend.clone()),
|
||||||
@@ -698,6 +700,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Err(_) => None,
|
Err(_) => None,
|
||||||
};
|
};
|
||||||
requests.revoke_stale(owner.as_deref()).await;
|
requests.revoke_stale(owner.as_deref()).await;
|
||||||
|
shortcuts.revoke_stale(&connection, owner.as_deref()).await;
|
||||||
let stale: Vec<_> = backend
|
let stale: Vec<_> = backend
|
||||||
.sessions
|
.sessions
|
||||||
.lock()
|
.lock()
|
||||||
|
|||||||
+44
-4
@@ -2,7 +2,7 @@ use cosmic::{
|
|||||||
Element,
|
Element,
|
||||||
app::{Core, Settings, Task},
|
app::{Core, Settings, Task},
|
||||||
iced::{Alignment, Length},
|
iced::{Alignment, Length},
|
||||||
widget::{button, column, container, image, row, scrollable},
|
widget::{button, column, container, image, row, scrollable, text_input},
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
@@ -14,6 +14,15 @@ pub(crate) struct Prompt {
|
|||||||
pub accept: String,
|
pub accept: String,
|
||||||
pub parent: String,
|
pub parent: String,
|
||||||
pub image: Option<std::path::PathBuf>,
|
pub image: Option<std::path::PathBuf>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub shortcuts: Vec<ShortcutField>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct ShortcutField {
|
||||||
|
pub id: String,
|
||||||
|
pub description: String,
|
||||||
|
pub trigger: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -30,6 +39,7 @@ enum Message {
|
|||||||
),
|
),
|
||||||
Accept,
|
Accept,
|
||||||
Cancel,
|
Cancel,
|
||||||
|
Shortcut(usize, String),
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Consent {
|
struct Consent {
|
||||||
@@ -45,11 +55,18 @@ struct Consent {
|
|||||||
pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
|
pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let mut input = String::new();
|
let mut input = String::new();
|
||||||
std::io::stdin()
|
std::io::stdin()
|
||||||
.take(64 * 1024)
|
.take(256 * 1024 + 1)
|
||||||
.read_to_string(&mut input)?;
|
.read_to_string(&mut input)?;
|
||||||
|
if input.len() > 256 * 1024 {
|
||||||
|
return Err("Consent request is too large".into());
|
||||||
|
}
|
||||||
let prompt: Prompt = serde_json::from_str(&input)?;
|
let prompt: Prompt = serde_json::from_str(&input)?;
|
||||||
let (theme, font, background, _) = crate::picker::appearance();
|
let (theme, font, background, _) = crate::picker::appearance();
|
||||||
let height = if prompt.image.is_some() { 430. } else { 220. };
|
let height = if prompt.image.is_some() || !prompt.shortcuts.is_empty() {
|
||||||
|
430.
|
||||||
|
} else {
|
||||||
|
220.
|
||||||
|
};
|
||||||
cosmic::app::run::<Consent>(
|
cosmic::app::run::<Consent>(
|
||||||
Settings::default()
|
Settings::default()
|
||||||
.size(cosmic::iced::Size::new(440., height))
|
.size(cosmic::iced::Size::new(440., height))
|
||||||
@@ -73,6 +90,7 @@ impl cosmic::Application for Consent {
|
|||||||
fn core(&self) -> &Core {
|
fn core(&self) -> &Core {
|
||||||
&self.core
|
&self.core
|
||||||
}
|
}
|
||||||
|
|
||||||
fn core_mut(&mut self) -> &mut Core {
|
fn core_mut(&mut self) -> &mut Core {
|
||||||
&mut self.core
|
&mut self.core
|
||||||
}
|
}
|
||||||
@@ -134,11 +152,20 @@ impl cosmic::Application for Consent {
|
|||||||
return cosmic::iced::exit();
|
return cosmic::iced::exit();
|
||||||
}
|
}
|
||||||
Message::Accept => {
|
Message::Accept => {
|
||||||
|
if self.prompt.shortcuts.is_empty() {
|
||||||
println!("true");
|
println!("true");
|
||||||
|
} else {
|
||||||
|
println!("{}", serde_json::to_string(&self.prompt.shortcuts).unwrap());
|
||||||
|
}
|
||||||
let _ = std::io::stdout().flush();
|
let _ = std::io::stdout().flush();
|
||||||
return cosmic::iced::exit();
|
return cosmic::iced::exit();
|
||||||
}
|
}
|
||||||
Message::Cancel => return cosmic::iced::exit(),
|
Message::Cancel => return cosmic::iced::exit(),
|
||||||
|
Message::Shortcut(index, value) => {
|
||||||
|
if let Some(shortcut) = self.prompt.shortcuts.get_mut(index) {
|
||||||
|
shortcut.trigger = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
@@ -153,9 +180,22 @@ impl cosmic::Application for Consent {
|
|||||||
|
|
||||||
fn view(&self) -> Element<'_, Message> {
|
fn view(&self) -> Element<'_, Message> {
|
||||||
let text = |value: String| ferese_theme::text(value, self.font);
|
let text = |value: String| ferese_theme::text(value, self.font);
|
||||||
|
let mut details = column![text(self.prompt.description.clone())].spacing(12);
|
||||||
|
for (index, shortcut) in self.prompt.shortcuts.iter().enumerate() {
|
||||||
|
details = details.push(
|
||||||
|
column![
|
||||||
|
text(shortcut.description.clone()),
|
||||||
|
text_input("Ctrl+Alt+k (leave empty to disable)", &shortcut.trigger)
|
||||||
|
.font(self.font)
|
||||||
|
.style(ferese_theme::controls::settings_input(self.palette))
|
||||||
|
.on_input(move |value| Message::Shortcut(index, value))
|
||||||
|
]
|
||||||
|
.spacing(6),
|
||||||
|
);
|
||||||
|
}
|
||||||
let mut content = column![
|
let mut content = column![
|
||||||
text(self.prompt.title.clone()).size(22),
|
text(self.prompt.title.clone()).size(22),
|
||||||
scrollable(text(self.prompt.description.clone())).height(Length::Fill)
|
scrollable(details).height(Length::Fill)
|
||||||
]
|
]
|
||||||
.spacing(16);
|
.spacing(16);
|
||||||
if let Some(path) = &self.prompt.image {
|
if let Some(path) = &self.prompt.image {
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ pub(crate) async fn consent(
|
|||||||
},
|
},
|
||||||
accept: accept.into(),
|
accept: accept.into(),
|
||||||
image,
|
image,
|
||||||
|
shortcuts: Vec::new(),
|
||||||
};
|
};
|
||||||
let mut child = crate::backend::child_command()?
|
let mut child = crate::backend::child_command()?
|
||||||
.arg("--consent")
|
.arg("--consent")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ mod parent;
|
|||||||
mod permissions;
|
mod permissions;
|
||||||
mod picker;
|
mod picker;
|
||||||
mod settings;
|
mod settings;
|
||||||
|
mod shortcuts;
|
||||||
mod stream;
|
mod stream;
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
|||||||
@@ -0,0 +1,803 @@
|
|||||||
|
use std::{
|
||||||
|
collections::HashMap, io::Read, os::unix::net::UnixStream, process::Stdio, sync::Arc,
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use serde_json::{Value as Json, json};
|
||||||
|
use tokio::{io::AsyncWriteExt, sync::Mutex};
|
||||||
|
use zbus::{
|
||||||
|
Connection,
|
||||||
|
message::Header,
|
||||||
|
object_server::SignalEmitter,
|
||||||
|
zvariant::{OwnedObjectPath, OwnedValue, Value},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
backend::{Cancel, Options, authorize},
|
||||||
|
consent::{Prompt, ShortcutField},
|
||||||
|
desktop::Requests,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PATH: &str = "/org/freedesktop/portal/desktop";
|
||||||
|
type Shortcuts = Vec<(String, Options)>;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Bridge(Arc<BridgeInner>);
|
||||||
|
|
||||||
|
struct BridgeInner {
|
||||||
|
stream: std::sync::Mutex<UnixStream>,
|
||||||
|
shutdown: UnixStream,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bridge {
|
||||||
|
fn connect() -> Result<Self, String> {
|
||||||
|
let path = std::env::var_os("XDG_RUNTIME_DIR")
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.ok_or("Missing runtime directory")?
|
||||||
|
.join("ferese/control.sock");
|
||||||
|
let stream = UnixStream::connect(path).map_err(|error| error.to_string())?;
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
stream
|
||||||
|
.set_write_timeout(Some(Duration::from_secs(2)))
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let shutdown = stream.try_clone().map_err(|error| error.to_string())?;
|
||||||
|
Ok(Self(Arc::new(BridgeInner {
|
||||||
|
stream: std::sync::Mutex::new(stream),
|
||||||
|
shutdown,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call(&self, command: &'static str, args: Json) -> Result<Json, String> {
|
||||||
|
let bridge = self.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let mut stream = bridge
|
||||||
|
.0
|
||||||
|
.stream
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "Shortcut connection failed")?;
|
||||||
|
let request = ferese_ipc::Request {
|
||||||
|
version: ferese_ipc::VERSION,
|
||||||
|
id: 1,
|
||||||
|
kind: "command".into(),
|
||||||
|
command: command.into(),
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
ferese_ipc::write_frame(&mut *stream, &request).map_err(|error| error.to_string())?;
|
||||||
|
let response: ferese_ipc::Response =
|
||||||
|
ferese_ipc::read_frame(&mut *stream).map_err(|error| error.to_string())?;
|
||||||
|
if let Some(error) = response.error {
|
||||||
|
return Err(error.message);
|
||||||
|
}
|
||||||
|
response.result.ok_or("Missing shortcut response".into())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close(&self) {
|
||||||
|
let _ = self.0.shutdown.shutdown(std::net::Shutdown::Both);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Session {
|
||||||
|
owner: String,
|
||||||
|
app: String,
|
||||||
|
cancel: Arc<Cancel>,
|
||||||
|
state: Mutex<SessionState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SessionState {
|
||||||
|
fields: Vec<ShortcutField>,
|
||||||
|
bound: bool,
|
||||||
|
busy: bool,
|
||||||
|
bridge: Option<Bridge>,
|
||||||
|
revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub(crate) struct GlobalShortcuts {
|
||||||
|
sessions: Arc<Mutex<HashMap<String, Arc<Session>>>>,
|
||||||
|
requests: Requests,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GlobalShortcuts {
|
||||||
|
pub(crate) fn new(requests: Requests) -> Self {
|
||||||
|
Self {
|
||||||
|
requests,
|
||||||
|
..Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn session(
|
||||||
|
&self,
|
||||||
|
connection: &Connection,
|
||||||
|
header: &Header<'_>,
|
||||||
|
path: &OwnedObjectPath,
|
||||||
|
) -> zbus::fdo::Result<Arc<Session>> {
|
||||||
|
let owner = authorize(connection, header).await?;
|
||||||
|
self.sessions
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(path.as_str())
|
||||||
|
.filter(|session| session.owner == owner)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| zbus::fdo::Error::AccessDenied("Not your shortcut session".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn revoke_stale(&self, connection: &Connection, owner: Option<&str>) {
|
||||||
|
let paths = self
|
||||||
|
.sessions
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, session)| Some(session.owner.as_str()) != owner)
|
||||||
|
.map(|(path, _)| path.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for path in paths {
|
||||||
|
self.end(connection, &path).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn end(&self, connection: &Connection, path: &str) {
|
||||||
|
if let Some(session) = self.sessions.lock().await.remove(path) {
|
||||||
|
let state = session.state.lock().await;
|
||||||
|
session.cancel.stop();
|
||||||
|
if let Some(bridge) = &state.bridge {
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
if let Ok(emitter) = SignalEmitter::new(connection, path) {
|
||||||
|
let _ = SessionObject::closed(&emitter).await;
|
||||||
|
}
|
||||||
|
let _ = connection
|
||||||
|
.object_server()
|
||||||
|
.remove::<SessionObject, _>(path)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn configure(
|
||||||
|
&self,
|
||||||
|
connection: &Connection,
|
||||||
|
path: &OwnedObjectPath,
|
||||||
|
session: Arc<Session>,
|
||||||
|
parent: &str,
|
||||||
|
fields: Vec<ShortcutField>,
|
||||||
|
cancel: Arc<Cancel>,
|
||||||
|
) -> Result<Option<Shortcuts>, String> {
|
||||||
|
let (bridge, first, previous) = {
|
||||||
|
let state = session.state.lock().await;
|
||||||
|
let first = state.bridge.is_none();
|
||||||
|
let bridge = match &state.bridge {
|
||||||
|
Some(bridge) => bridge.clone(),
|
||||||
|
None => Bridge::connect()?,
|
||||||
|
};
|
||||||
|
(bridge, first, state.fields.clone())
|
||||||
|
};
|
||||||
|
let mut fields = fields;
|
||||||
|
let mut message = "Choose shortcuts for this application. Leave a shortcut empty to disable it. System shortcuts cannot be overridden.".to_owned();
|
||||||
|
loop {
|
||||||
|
let selected = tokio::select! {
|
||||||
|
biased;
|
||||||
|
_ = session.cancel.wait() => return Ok(None),
|
||||||
|
_ = cancel.wait() => return Ok(None),
|
||||||
|
selected = dialog(&session.app, parent, &message, fields.clone()) => selected?,
|
||||||
|
};
|
||||||
|
let Some(selected) = selected else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
validate_selection(&fields, &selected)?;
|
||||||
|
let registration = selected
|
||||||
|
.iter()
|
||||||
|
.filter(|field| !field.trigger.trim().is_empty())
|
||||||
|
.map(|field| json!({"id":field.id,"trigger":field.trigger.trim()}))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
match bridge
|
||||||
|
.call("portal-shortcuts-register", json!(registration))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
let mut state = session.state.lock().await;
|
||||||
|
if session
|
||||||
|
.cancel
|
||||||
|
.stopped
|
||||||
|
.load(std::sync::atomic::Ordering::SeqCst)
|
||||||
|
|| cancel.stopped.load(std::sync::atomic::Ordering::SeqCst)
|
||||||
|
{
|
||||||
|
if first
|
||||||
|
|| session
|
||||||
|
.cancel
|
||||||
|
.stopped
|
||||||
|
.load(std::sync::atomic::Ordering::SeqCst)
|
||||||
|
{
|
||||||
|
bridge.close();
|
||||||
|
} else {
|
||||||
|
let previous = previous
|
||||||
|
.iter()
|
||||||
|
.filter(|field| !field.trigger.trim().is_empty())
|
||||||
|
.map(|field| json!({"id":field.id,"trigger":field.trigger}))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if bridge
|
||||||
|
.call("portal-shortcuts-register", json!(previous))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
state.fields = selected.clone();
|
||||||
|
state.bound = true;
|
||||||
|
state.bridge = Some(bridge.clone());
|
||||||
|
state.revision = state.revision.wrapping_add(1);
|
||||||
|
drop(state);
|
||||||
|
if let Err(error) = save(&session.app, &selected) {
|
||||||
|
eprintln!("ferese shortcuts: could not save preferences: {error}");
|
||||||
|
}
|
||||||
|
if first {
|
||||||
|
let backend = self.clone();
|
||||||
|
let connection = connection.clone();
|
||||||
|
let path = path.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
backend.watch(connection, path, session, bridge).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Ok(Some(results(&selected)));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
message = format!("{error}\nChoose another shortcut.");
|
||||||
|
fields = selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn watch(
|
||||||
|
&self,
|
||||||
|
connection: Connection,
|
||||||
|
path: OwnedObjectPath,
|
||||||
|
session: Arc<Session>,
|
||||||
|
bridge: Bridge,
|
||||||
|
) {
|
||||||
|
let emitter = SignalEmitter::new(&connection, PATH).unwrap();
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = session.cancel.wait() => break,
|
||||||
|
_ = tokio::time::sleep(Duration::from_millis(50)) => {},
|
||||||
|
}
|
||||||
|
let revision = session.state.lock().await.revision;
|
||||||
|
let value = match bridge.call("portal-shortcuts-poll", json!({})).await {
|
||||||
|
Ok(value) if value["closed"] != true => value,
|
||||||
|
_ => break,
|
||||||
|
};
|
||||||
|
let mut state = session.state.lock().await;
|
||||||
|
if session
|
||||||
|
.cancel
|
||||||
|
.stopped
|
||||||
|
.load(std::sync::atomic::Ordering::SeqCst)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some(active) = value["shortcuts"]
|
||||||
|
.as_array()
|
||||||
|
.filter(|_| !state.busy && state.revision == revision)
|
||||||
|
{
|
||||||
|
let mut changed = false;
|
||||||
|
for field in &mut state.fields {
|
||||||
|
if !field.trigger.is_empty()
|
||||||
|
&& !active.iter().any(|item| item["id"] == field.id)
|
||||||
|
{
|
||||||
|
field.trigger.clear();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
let _ = Self::shortcuts_changed(&emitter, path.clone(), results(&state.fields))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for event in value["events"].as_array().into_iter().flatten() {
|
||||||
|
let Some(id) = event["id"].as_str() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let timestamp = event["timestamp"].as_u64().unwrap_or(0);
|
||||||
|
if event["active"] == true {
|
||||||
|
let _ = Self::activated(&emitter, path.clone(), id, timestamp, Options::new())
|
||||||
|
.await;
|
||||||
|
} else {
|
||||||
|
let _ =
|
||||||
|
Self::deactivated(&emitter, path.clone(), id, timestamp, Options::new())
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.end(&connection, path.as_str()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[zbus::interface(name = "org.freedesktop.impl.portal.GlobalShortcuts")]
|
||||||
|
impl GlobalShortcuts {
|
||||||
|
#[zbus(property, name = "version")]
|
||||||
|
fn version(&self) -> u32 {
|
||||||
|
2
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_session(
|
||||||
|
&self,
|
||||||
|
handle: OwnedObjectPath,
|
||||||
|
session_handle: OwnedObjectPath,
|
||||||
|
app_id: String,
|
||||||
|
_options: Options,
|
||||||
|
#[zbus(connection)] connection: &Connection,
|
||||||
|
#[zbus(header)] header: Header<'_>,
|
||||||
|
) -> zbus::fdo::Result<(u32, Options)> {
|
||||||
|
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 shortcut session".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let cancel = self.requests.begin(connection, &header, &handle).await?;
|
||||||
|
let mut sessions = self.sessions.lock().await;
|
||||||
|
let result = if sessions.len() >= 8
|
||||||
|
|| sessions.contains_key(session_handle.as_str())
|
||||||
|
|| cancel.stopped.load(std::sync::atomic::Ordering::SeqCst)
|
||||||
|
{
|
||||||
|
Ok((2, Options::new()))
|
||||||
|
} else {
|
||||||
|
match connection
|
||||||
|
.object_server()
|
||||||
|
.at(
|
||||||
|
&session_handle,
|
||||||
|
SessionObject {
|
||||||
|
backend: self.clone(),
|
||||||
|
path: session_handle.to_string(),
|
||||||
|
owner: owner.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Err(error) => Err(zbus::fdo::Error::Failed(error.to_string())),
|
||||||
|
Ok(false) => Ok((2, Options::new())),
|
||||||
|
Ok(true) if cancel.stopped.load(std::sync::atomic::Ordering::SeqCst) => {
|
||||||
|
let _ = connection
|
||||||
|
.object_server()
|
||||||
|
.remove::<SessionObject, _>(&session_handle)
|
||||||
|
.await;
|
||||||
|
Ok((1, Options::new()))
|
||||||
|
}
|
||||||
|
Ok(true) => {
|
||||||
|
let fields = load(&app_id).unwrap_or_default();
|
||||||
|
sessions.insert(
|
||||||
|
session_handle.to_string(),
|
||||||
|
Arc::new(Session {
|
||||||
|
owner,
|
||||||
|
app: app_id,
|
||||||
|
cancel: Arc::default(),
|
||||||
|
state: Mutex::new(SessionState {
|
||||||
|
fields,
|
||||||
|
bound: false,
|
||||||
|
busy: false,
|
||||||
|
bridge: None,
|
||||||
|
revision: 0,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
Ok((0, Options::new()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
drop(sessions);
|
||||||
|
self.requests.end(connection, &handle).await;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn bind_shortcuts(
|
||||||
|
&self,
|
||||||
|
handle: OwnedObjectPath,
|
||||||
|
session_handle: OwnedObjectPath,
|
||||||
|
shortcuts: Shortcuts,
|
||||||
|
parent_window: String,
|
||||||
|
_options: Options,
|
||||||
|
#[zbus(connection)] connection: &Connection,
|
||||||
|
#[zbus(header)] header: Header<'_>,
|
||||||
|
) -> zbus::fdo::Result<(u32, Options)> {
|
||||||
|
let session = self.session(connection, &header, &session_handle).await?;
|
||||||
|
let mut fields = fields(shortcuts).map_err(zbus::fdo::Error::InvalidArgs)?;
|
||||||
|
{
|
||||||
|
let mut state = session.state.lock().await;
|
||||||
|
if state.bound || state.busy {
|
||||||
|
return Err(zbus::fdo::Error::InvalidArgs(
|
||||||
|
"Shortcuts already bound or being configured".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for field in &mut fields {
|
||||||
|
if let Some(saved) = state.fields.iter().find(|saved| saved.id == field.id) {
|
||||||
|
field.trigger = saved.trigger.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.busy = true;
|
||||||
|
state.revision = state.revision.wrapping_add(1);
|
||||||
|
}
|
||||||
|
let cancel = match self.requests.begin(connection, &header, &handle).await {
|
||||||
|
Ok(cancel) => cancel,
|
||||||
|
Err(error) => {
|
||||||
|
session.state.lock().await.busy = false;
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let response = self
|
||||||
|
.configure(
|
||||||
|
connection,
|
||||||
|
&session_handle,
|
||||||
|
session.clone(),
|
||||||
|
&parent_window,
|
||||||
|
fields,
|
||||||
|
cancel,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
session.state.lock().await.busy = false;
|
||||||
|
self.requests.end(connection, &handle).await;
|
||||||
|
Ok(reply(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_shortcuts(
|
||||||
|
&self,
|
||||||
|
handle: OwnedObjectPath,
|
||||||
|
session_handle: OwnedObjectPath,
|
||||||
|
#[zbus(connection)] connection: &Connection,
|
||||||
|
#[zbus(header)] header: Header<'_>,
|
||||||
|
) -> zbus::fdo::Result<(u32, Options)> {
|
||||||
|
let session = self.session(connection, &header, &session_handle).await?;
|
||||||
|
self.requests.begin(connection, &header, &handle).await?;
|
||||||
|
let result = results(&session.state.lock().await.fields);
|
||||||
|
self.requests.end(connection, &handle).await;
|
||||||
|
Ok((
|
||||||
|
0,
|
||||||
|
HashMap::from([("shortcuts".into(), Value::from(result).try_into().unwrap())]),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn configure_shortcuts(
|
||||||
|
&self,
|
||||||
|
session_handle: OwnedObjectPath,
|
||||||
|
parent_window: String,
|
||||||
|
_options: Options,
|
||||||
|
#[zbus(connection)] connection: &Connection,
|
||||||
|
#[zbus(header)] header: Header<'_>,
|
||||||
|
) -> zbus::fdo::Result<()> {
|
||||||
|
let session = self.session(connection, &header, &session_handle).await?;
|
||||||
|
let fields = {
|
||||||
|
let mut state = session.state.lock().await;
|
||||||
|
if state.busy || !state.bound {
|
||||||
|
return Err(zbus::fdo::Error::InvalidArgs(
|
||||||
|
"Shortcuts are not ready to configure".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
state.busy = true;
|
||||||
|
state.revision = state.revision.wrapping_add(1);
|
||||||
|
state.fields.clone()
|
||||||
|
};
|
||||||
|
let backend = self.clone();
|
||||||
|
let connection = connection.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let result = backend
|
||||||
|
.configure(
|
||||||
|
&connection,
|
||||||
|
&session_handle,
|
||||||
|
session.clone(),
|
||||||
|
&parent_window,
|
||||||
|
fields,
|
||||||
|
Arc::default(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
session.state.lock().await.busy = false;
|
||||||
|
if let Ok(Some(shortcuts)) = result {
|
||||||
|
let emitter = SignalEmitter::new(&connection, PATH).unwrap();
|
||||||
|
let _ = Self::shortcuts_changed(&emitter, session_handle, shortcuts).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[zbus(signal)]
|
||||||
|
async fn activated(
|
||||||
|
emitter: &SignalEmitter<'_>,
|
||||||
|
session_handle: OwnedObjectPath,
|
||||||
|
shortcut_id: &str,
|
||||||
|
timestamp: u64,
|
||||||
|
options: Options,
|
||||||
|
) -> zbus::Result<()>;
|
||||||
|
|
||||||
|
#[zbus(signal)]
|
||||||
|
async fn deactivated(
|
||||||
|
emitter: &SignalEmitter<'_>,
|
||||||
|
session_handle: OwnedObjectPath,
|
||||||
|
shortcut_id: &str,
|
||||||
|
timestamp: u64,
|
||||||
|
options: Options,
|
||||||
|
) -> zbus::Result<()>;
|
||||||
|
|
||||||
|
#[zbus(signal)]
|
||||||
|
async fn shortcuts_changed(
|
||||||
|
emitter: &SignalEmitter<'_>,
|
||||||
|
session_handle: OwnedObjectPath,
|
||||||
|
shortcuts: Shortcuts,
|
||||||
|
) -> zbus::Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SessionObject {
|
||||||
|
backend: GlobalShortcuts,
|
||||||
|
path: String,
|
||||||
|
owner: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[zbus::interface(name = "org.freedesktop.impl.portal.Session")]
|
||||||
|
impl SessionObject {
|
||||||
|
#[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 header.sender().map(|sender| sender.as_str()) != Some(self.owner.as_str()) {
|
||||||
|
return Err(zbus::fdo::Error::AccessDenied(
|
||||||
|
"Not your shortcut session".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.backend.end(connection, &self.path).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[zbus(signal)]
|
||||||
|
async fn closed(emitter: &SignalEmitter<'_>) -> zbus::Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fields(shortcuts: Shortcuts) -> Result<Vec<ShortcutField>, String> {
|
||||||
|
if shortcuts.is_empty() || shortcuts.len() > 64 {
|
||||||
|
return Err("Expected 1–64 shortcuts".into());
|
||||||
|
}
|
||||||
|
let mut fields: Vec<ShortcutField> = Vec::new();
|
||||||
|
for (id, properties) in shortcuts {
|
||||||
|
let description = properties
|
||||||
|
.get("description")
|
||||||
|
.and_then(|value| <&str>::try_from(value).ok())
|
||||||
|
.ok_or("Missing shortcut description")?;
|
||||||
|
let trigger = properties
|
||||||
|
.get("preferred_trigger")
|
||||||
|
.map(|value| <&str>::try_from(value).map(str::to_owned))
|
||||||
|
.transpose()
|
||||||
|
.map_err(|_| "Invalid shortcut trigger")?
|
||||||
|
.unwrap_or_default();
|
||||||
|
if id.is_empty()
|
||||||
|
|| id.len() > 256
|
||||||
|
|| description.is_empty()
|
||||||
|
|| description.len() > 1024
|
||||||
|
|| trigger.len() > 256
|
||||||
|
|| id.chars().chain(description.chars()).any(char::is_control)
|
||||||
|
|| fields.iter().any(|field| field.id == id)
|
||||||
|
{
|
||||||
|
return Err("Invalid or duplicate shortcut".into());
|
||||||
|
}
|
||||||
|
fields.push(ShortcutField {
|
||||||
|
id,
|
||||||
|
description: description.into(),
|
||||||
|
trigger,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_selection(
|
||||||
|
requested: &[ShortcutField],
|
||||||
|
selected: &[ShortcutField],
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if selected.len() != requested.len()
|
||||||
|
|| selected.iter().zip(requested).any(|(chosen, original)| {
|
||||||
|
chosen.id != original.id
|
||||||
|
|| chosen.description != original.description
|
||||||
|
|| chosen.trigger.len() > 256
|
||||||
|
|| chosen.trigger.chars().any(char::is_control)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err("Invalid shortcut selection".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn results(fields: &[ShortcutField]) -> Shortcuts {
|
||||||
|
fields
|
||||||
|
.iter()
|
||||||
|
.filter(|field| !field.trigger.trim().is_empty())
|
||||||
|
.map(|field| {
|
||||||
|
(
|
||||||
|
field.id.clone(),
|
||||||
|
HashMap::from([
|
||||||
|
(
|
||||||
|
"description".into(),
|
||||||
|
OwnedValue::from(zbus::zvariant::Str::from(field.description.as_str())),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"trigger_description".into(),
|
||||||
|
OwnedValue::from(zbus::zvariant::Str::from(field.trigger.as_str())),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply(response: Result<Option<Shortcuts>, String>) -> (u32, Options) {
|
||||||
|
match response {
|
||||||
|
Ok(Some(shortcuts)) => (
|
||||||
|
0,
|
||||||
|
HashMap::from([(
|
||||||
|
"shortcuts".into(),
|
||||||
|
Value::from(shortcuts).try_into().unwrap(),
|
||||||
|
)]),
|
||||||
|
),
|
||||||
|
Ok(None) => (1, Options::new()),
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("ferese shortcuts: {error}");
|
||||||
|
(2, Options::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dialog(
|
||||||
|
app: &str,
|
||||||
|
parent: &str,
|
||||||
|
description: &str,
|
||||||
|
fields: Vec<ShortcutField>,
|
||||||
|
) -> Result<Option<Vec<ShortcutField>>, String> {
|
||||||
|
let prompt = Prompt {
|
||||||
|
title: "Application shortcuts".into(),
|
||||||
|
description: format!("{app}\n{description}"),
|
||||||
|
accept: "Allow shortcuts".into(),
|
||||||
|
parent: parent.into(),
|
||||||
|
image: None,
|
||||||
|
shortcuts: fields,
|
||||||
|
};
|
||||||
|
let mut child = crate::backend::child_command()?
|
||||||
|
.arg("--consent")
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::inherit())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let mut input = child.stdin.take().unwrap();
|
||||||
|
input
|
||||||
|
.write_all(&serde_json::to_vec(&prompt).map_err(|error| error.to_string())?)
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
drop(input);
|
||||||
|
let output = tokio::time::timeout(Duration::from_secs(300), child.wait_with_output())
|
||||||
|
.await
|
||||||
|
.map_err(|_| "Shortcut configuration timed out")?
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
if !output.status.success() || output.stdout.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
serde_json::from_slice(&output.stdout)
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_path(app: &str) -> Option<std::path::PathBuf> {
|
||||||
|
if app.is_empty()
|
||||||
|
|| app.len() > 200
|
||||||
|
|| !app
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_'))
|
||||||
|
|| app.starts_with('.')
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(
|
||||||
|
dirs::data_local_dir()?
|
||||||
|
.join("ferese/portal/shortcuts")
|
||||||
|
.join(format!("{app}.json")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load(app: &str) -> Option<Vec<ShortcutField>> {
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
let file = std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW)
|
||||||
|
.open(cache_path(app)?)
|
||||||
|
.ok()?;
|
||||||
|
if !file.metadata().ok()?.is_file() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
file.take(128 * 1024 + 1).read_to_end(&mut bytes).ok()?;
|
||||||
|
if bytes.len() > 128 * 1024 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let saved: Vec<ShortcutField> = serde_json::from_slice(&bytes).ok()?;
|
||||||
|
let shortcuts = saved
|
||||||
|
.into_iter()
|
||||||
|
.map(|field| {
|
||||||
|
(
|
||||||
|
field.id,
|
||||||
|
HashMap::from([
|
||||||
|
(
|
||||||
|
"description".into(),
|
||||||
|
OwnedValue::from(zbus::zvariant::Str::from(field.description)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"preferred_trigger".into(),
|
||||||
|
OwnedValue::from(zbus::zvariant::Str::from(field.trigger)),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
fields(shortcuts).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save(app: &str, fields: &[ShortcutField]) -> Result<(), String> {
|
||||||
|
let Some(path) = cache_path(app) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let directory = path.parent().unwrap();
|
||||||
|
std::fs::create_dir_all(directory).map_err(|error| error.to_string())?;
|
||||||
|
let mut file = tempfile::NamedTempFile::new_in(directory).map_err(|error| error.to_string())?;
|
||||||
|
std::io::Write::write_all(
|
||||||
|
&mut file,
|
||||||
|
&serde_json::to_vec(fields).map_err(|error| error.to_string())?,
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
file.persist(path).map_err(|error| error.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn choices_keep_requested_ids_and_descriptions_and_allow_disabling() {
|
||||||
|
let request = vec![ShortcutField {
|
||||||
|
id: "record".into(),
|
||||||
|
description: "Record screen".into(),
|
||||||
|
trigger: "Ctrl+Alt+r".into(),
|
||||||
|
}];
|
||||||
|
let mut selected = request.clone();
|
||||||
|
selected[0].trigger.clear();
|
||||||
|
assert!(validate_selection(&request, &selected).is_ok());
|
||||||
|
assert!(results(&selected).is_empty());
|
||||||
|
selected[0].id = "other".into();
|
||||||
|
assert!(validate_selection(&request, &selected).is_err());
|
||||||
|
let mut selected = request.clone();
|
||||||
|
selected[0].trigger = "a\nb".into();
|
||||||
|
assert!(validate_selection(&request, &selected).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_shortcuts_and_unsafe_cache_names_are_rejected() {
|
||||||
|
let shortcut = (
|
||||||
|
"record".into(),
|
||||||
|
HashMap::from([(
|
||||||
|
"description".into(),
|
||||||
|
OwnedValue::from(zbus::zvariant::Str::from("Record screen")),
|
||||||
|
)]),
|
||||||
|
);
|
||||||
|
assert!(fields(vec![shortcut.clone(), shortcut]).is_err());
|
||||||
|
for app in ["../other", "/etc/file", ".hidden", "bad/name", ""] {
|
||||||
|
assert!(cache_path(app).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"):
|
for name in ("ScreenCast", "Settings", "Screenshot", "Wallpaper", "Background", "Usb", "Lockdown", "GlobalShortcuts"):
|
||||||
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")
|
||||||
@@ -90,6 +90,14 @@ def private_bus_checks():
|
|||||||
raise AssertionError("Untrusted backend caller succeeded")
|
raise AssertionError("Untrusted backend caller succeeded")
|
||||||
except GLib.Error:
|
except GLib.Error:
|
||||||
pass
|
pass
|
||||||
|
shortcut_session = PATH + "/session/test/shortcuts"
|
||||||
|
shortcut_handle = PATH + "/request/test/shortcuts"
|
||||||
|
create_shortcuts = GLib.Variant("(oosa{sv})", (shortcut_handle, shortcut_session, "org.test.Shortcuts", {}))
|
||||||
|
try:
|
||||||
|
call("org.freedesktop.impl.portal.GlobalShortcuts", "CreateSession", create_shortcuts)
|
||||||
|
raise AssertionError("Untrusted shortcuts creator succeeded")
|
||||||
|
except GLib.Error as error:
|
||||||
|
assert "AccessDenied" in str(error), error
|
||||||
original_config = config.read_text()
|
original_config = config.read_text()
|
||||||
try:
|
try:
|
||||||
call("org.freedesktop.DBus.Properties", "Set", GLib.Variant("(ssv)", ("org.freedesktop.impl.portal.Lockdown", "disable-printing", GLib.Variant("b", True))))
|
call("org.freedesktop.DBus.Properties", "Set", GLib.Variant("(ssv)", ("org.freedesktop.impl.portal.Lockdown", "disable-printing", GLib.Variant("b", True))))
|
||||||
@@ -129,6 +137,24 @@ def private_bus_checks():
|
|||||||
config.write_text(valid_policy)
|
config.write_text(valid_policy)
|
||||||
bus.signal_unsubscribe(subscription)
|
bus.signal_unsubscribe(subscription)
|
||||||
|
|
||||||
|
response, values = call("org.freedesktop.impl.portal.GlobalShortcuts", "CreateSession", create_shortcuts, "(ua{sv})")
|
||||||
|
assert response == 0 and values == {}
|
||||||
|
response, values = call("org.freedesktop.impl.portal.GlobalShortcuts", "ListShortcuts", GLib.Variant("(oo)", (shortcut_handle, shortcut_session)), "(ua{sv})")
|
||||||
|
assert response == 0 and values == {"shortcuts": []}, values
|
||||||
|
duplicate = [("record", {"description": GLib.Variant("s", "Record screen")})] * 2
|
||||||
|
try:
|
||||||
|
call("org.freedesktop.impl.portal.GlobalShortcuts", "BindShortcuts", GLib.Variant("(ooa(sa{sv})sa{sv})", (shortcut_handle, shortcut_session, duplicate, "", {})))
|
||||||
|
raise AssertionError("Duplicate shortcut IDs accepted")
|
||||||
|
except GLib.Error as error:
|
||||||
|
assert "InvalidArgs" in str(error), error
|
||||||
|
bus.call_sync(NAME, shortcut_session, "org.freedesktop.impl.portal.Session", "Close", None, None,
|
||||||
|
Gio.DBusCallFlags.NO_AUTO_START, 5000, None)
|
||||||
|
try:
|
||||||
|
call("org.freedesktop.impl.portal.GlobalShortcuts", "ListShortcuts", GLib.Variant("(oo)", (shortcut_handle, shortcut_session)))
|
||||||
|
raise AssertionError("Closed shortcut session remains accessible")
|
||||||
|
except GLib.Error as error:
|
||||||
|
assert "AccessDenied" in str(error), error
|
||||||
|
|
||||||
usb_params = GLib.Variant("(oss a(sa{sv}a{sv}) a{sv})".replace(" ", ""),
|
usb_params = GLib.Variant("(oss a(sa{sv}a{sv}) a{sv})".replace(" ", ""),
|
||||||
(PATH + "/request/test/usb", "", "org.test.App", [], {}))
|
(PATH + "/request/test/usb", "", "org.test.App", [], {}))
|
||||||
response, values = call("org.freedesktop.impl.portal.Usb", "AcquireDevices", usb_params, "(ua{sv})")
|
response, values = call("org.freedesktop.impl.portal.Usb", "AcquireDevices", usb_params, "(ua{sv})")
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Exercise shortcut connection lifetimes in an isolated nested compositor."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(os.environ.get("FERESE_TEST_SHORTCUTS") == "1", "requires a Wayland host and a built compositor")
|
||||||
|
class ShortcutConnections(unittest.TestCase):
|
||||||
|
def test_disconnect_conflicts_and_configuration_reload(self):
|
||||||
|
host_runtime = Path(os.environ["XDG_RUNTIME_DIR"])
|
||||||
|
host_display = Path(os.environ["WAYLAND_DISPLAY"])
|
||||||
|
if not host_display.is_absolute():
|
||||||
|
host_display = host_runtime / host_display
|
||||||
|
with tempfile.TemporaryDirectory(prefix="ferese-shortcut-test-") 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("")
|
||||||
|
log = (root / "compositor.log").open("w")
|
||||||
|
env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"), WAYLAND_DISPLAY=str(host_display))
|
||||||
|
env.pop("WAYLAND_SOCKET", None)
|
||||||
|
env.pop("FERESE_SHELL_CONTROL_SOCKET", None)
|
||||||
|
process = subprocess.Popen([str(REPO / "target/debug/ferese"), "--backend=nested"], env=env,
|
||||||
|
stdout=log, stderr=log, start_new_session=True)
|
||||||
|
connections = []
|
||||||
|
try:
|
||||||
|
deadline = time.monotonic() + 15
|
||||||
|
while not (runtime / "ferese/control.sock").exists():
|
||||||
|
if process.poll() is not None or time.monotonic() >= deadline:
|
||||||
|
self.fail((root / "compositor.log").read_text())
|
||||||
|
time.sleep(0.02)
|
||||||
|
|
||||||
|
def connect():
|
||||||
|
connection = socket.socket(socket.AF_UNIX)
|
||||||
|
connection.settimeout(2)
|
||||||
|
connection.connect(str(runtime / "ferese/control.sock"))
|
||||||
|
connections.append(connection)
|
||||||
|
return connection
|
||||||
|
|
||||||
|
def call(connection, command, args=None):
|
||||||
|
data = json.dumps({"version": 1, "id": 1, "type": "command", "command": command, "args": args}).encode()
|
||||||
|
connection.sendall(struct.pack(">I", len(data)) + data)
|
||||||
|
|
||||||
|
def receive(length):
|
||||||
|
data = b""
|
||||||
|
while len(data) < length:
|
||||||
|
chunk = connection.recv(length - len(data))
|
||||||
|
if not chunk:
|
||||||
|
raise AssertionError("IPC connection closed")
|
||||||
|
data += chunk
|
||||||
|
return data
|
||||||
|
|
||||||
|
length, = struct.unpack(">I", receive(4))
|
||||||
|
self.assertLessEqual(length, 1024 * 1024)
|
||||||
|
return json.loads(receive(length))
|
||||||
|
|
||||||
|
first = connect()
|
||||||
|
second = connect()
|
||||||
|
shortcut = [{"id": "record", "trigger": "Ctrl+Alt+a"}]
|
||||||
|
self.assertNotIn("error", call(first, "portal-shortcuts-register", shortcut))
|
||||||
|
conflict = call(second, "portal-shortcuts-register", shortcut)
|
||||||
|
self.assertEqual(conflict["error"]["code"], "shortcut_conflict")
|
||||||
|
self.assertTrue(call(second, "portal-shortcuts-poll")["result"]["closed"])
|
||||||
|
first.close()
|
||||||
|
deadline = time.monotonic() + 3
|
||||||
|
while "error" in call(second, "portal-shortcuts-register", shortcut):
|
||||||
|
self.assertLess(time.monotonic(), deadline, "disconnected owner kept its binding")
|
||||||
|
time.sleep(0.02)
|
||||||
|
self.assertEqual(call(second, "portal-shortcuts-poll")["result"]["shortcuts"], shortcut)
|
||||||
|
config.write_text('binding keys="Ctrl+Alt+a" action="none"\n')
|
||||||
|
deadline = time.monotonic() + 5
|
||||||
|
while call(second, "portal-shortcuts-poll")["result"]["shortcuts"]:
|
||||||
|
self.assertLess(time.monotonic(), deadline, "configuration did not revoke conflicting shortcut")
|
||||||
|
time.sleep(0.05)
|
||||||
|
finally:
|
||||||
|
for connection in connections:
|
||||||
|
connection.close()
|
||||||
|
os.killpg(process.pid, signal.SIGTERM)
|
||||||
|
process.wait(timeout=5)
|
||||||
|
log.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user