Add configurable session-scoped global shortcut portal
This commit is contained in:
1 parent
e71cd88ff8
commit
c8ed8e6c30
16 files changed
+1497
-26
No files matched your search
@@ -668,6 +668,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let requests = crate::desktop::Requests::default();
|
||||
let background = crate::permissions::Background::new(requests.clone());
|
||||
let lockdown = crate::lockdown::Lockdown::new();
|
||||
let shortcuts = crate::shortcuts::GlobalShortcuts::new(requests.clone());
|
||||
let connection = zbus::connection::Builder::session()?
|
||||
.name("org.freedesktop.impl.portal.desktop.ferese")?
|
||||
.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, background.clone())?
|
||||
.serve_at(PATH, lockdown.clone())?
|
||||
.serve_at(PATH, shortcuts.clone())?
|
||||
.serve_at(
|
||||
"/org/ferese/ScreenRecorder",
|
||||
RecorderControl(backend.clone()),
|
||||
@@ -698,6 +700,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Err(_) => None,
|
||||
};
|
||||
requests.revoke_stale(owner.as_deref()).await;
|
||||
shortcuts.revoke_stale(&connection, owner.as_deref()).await;
|
||||
let stale: Vec<_> = backend
|
||||
.sessions
|
||||
.lock()
|
||||
|
||||
+45
-5
@@ -2,7 +2,7 @@ use cosmic::{
|
||||
Element,
|
||||
app::{Core, Settings, Task},
|
||||
iced::{Alignment, Length},
|
||||
widget::{button, column, container, image, row, scrollable},
|
||||
widget::{button, column, container, image, row, scrollable, text_input},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{Read, Write};
|
||||
@@ -14,6 +14,15 @@ pub(crate) struct Prompt {
|
||||
pub accept: String,
|
||||
pub parent: String,
|
||||
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)]
|
||||
@@ -30,6 +39,7 @@ enum Message {
|
||||
),
|
||||
Accept,
|
||||
Cancel,
|
||||
Shortcut(usize, String),
|
||||
}
|
||||
|
||||
struct Consent {
|
||||
@@ -45,11 +55,18 @@ struct Consent {
|
||||
pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut input = String::new();
|
||||
std::io::stdin()
|
||||
.take(64 * 1024)
|
||||
.take(256 * 1024 + 1)
|
||||
.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 (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>(
|
||||
Settings::default()
|
||||
.size(cosmic::iced::Size::new(440., height))
|
||||
@@ -73,6 +90,7 @@ impl cosmic::Application for Consent {
|
||||
fn core(&self) -> &Core {
|
||||
&self.core
|
||||
}
|
||||
|
||||
fn core_mut(&mut self) -> &mut Core {
|
||||
&mut self.core
|
||||
}
|
||||
@@ -134,11 +152,20 @@ impl cosmic::Application for Consent {
|
||||
return cosmic::iced::exit();
|
||||
}
|
||||
Message::Accept => {
|
||||
println!("true");
|
||||
if self.prompt.shortcuts.is_empty() {
|
||||
println!("true");
|
||||
} else {
|
||||
println!("{}", serde_json::to_string(&self.prompt.shortcuts).unwrap());
|
||||
}
|
||||
let _ = std::io::stdout().flush();
|
||||
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()
|
||||
}
|
||||
@@ -153,9 +180,22 @@ impl cosmic::Application for Consent {
|
||||
|
||||
fn view(&self) -> Element<'_, Message> {
|
||||
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![
|
||||
text(self.prompt.title.clone()).size(22),
|
||||
scrollable(text(self.prompt.description.clone())).height(Length::Fill)
|
||||
scrollable(details).height(Length::Fill)
|
||||
]
|
||||
.spacing(16);
|
||||
if let Some(path) = &self.prompt.image {
|
||||
|
||||
@@ -192,6 +192,7 @@ pub(crate) async fn consent(
|
||||
},
|
||||
accept: accept.into(),
|
||||
image,
|
||||
shortcuts: Vec::new(),
|
||||
};
|
||||
let mut child = crate::backend::child_command()?
|
||||
.arg("--consent")
|
||||
|
||||
@@ -7,6 +7,7 @@ mod parent;
|
||||
mod permissions;
|
||||
mod picker;
|
||||
mod settings;
|
||||
mod shortcuts;
|
||||
mod stream;
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user