Add configurable session-scoped global shortcut portal

This commit is contained in:
Samuel Onoja committed 2026-09-29 23:32:41 +01:00
1 parent e71cd88ff8
commit c8ed8e6c30
16 files changed
+1497 -26

No files matched your search

+3
View File
@@ -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
View File
@@ -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 {
+1
View File
@@ -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")
+1
View File
@@ -7,6 +7,7 @@ mod parent;
mod permissions;
mod picker;
mod settings;
mod shortcuts;
mod stream;
fn main() -> Result<(), Box<dyn std::error::Error>> {
+803
View File
@@ -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());
}
}
}