feat(desktop): portal-based Linux global shortcuts and PTT (#3185)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-04 01:08:51 +02:00
1 parent 5799ef705d
commit 8f58fcc4c4
149 files changed
+26849 -8769

No files matched your search

@@ -63,6 +63,28 @@ pub mod windows {
meta: ((lwin_state | rwin_state) & HIGH_BIT) != 0,
}
}
pub fn with_pressed_key(modifiers: Modifiers, vk: u16) -> Modifiers {
match vk {
0x10 | 0xa0 | 0xa1 => Modifiers {
shift: true,
..modifiers
},
0x11 | 0xa2 | 0xa3 => Modifiers {
ctrl: true,
..modifiers
},
0x12 | 0xa4 | 0xa5 => Modifiers {
alt: true,
..modifiers
},
0x5b | 0x5c => Modifiers {
meta: true,
..modifiers
},
_ => modifiers,
}
}
}
#[cfg(test)]
@@ -196,6 +218,72 @@ mod tests {
);
}
#[test]
fn windows_pressed_modifier_adds_its_own_flag() {
let ctrl = Modifiers {
ctrl: true,
..Modifiers::default()
};
assert_eq!(
Modifiers {
ctrl: true,
shift: true,
..Modifiers::default()
},
windows::with_pressed_key(ctrl, 0xa0)
);
assert_eq!(
Modifiers {
shift: true,
..Modifiers::default()
},
windows::with_pressed_key(Modifiers::default(), 0xa1)
);
assert_eq!(ctrl, windows::with_pressed_key(Modifiers::default(), 0xa3));
assert_eq!(
Modifiers {
alt: true,
..Modifiers::default()
},
windows::with_pressed_key(Modifiers::default(), 0xa5)
);
assert_eq!(
Modifiers {
meta: true,
..Modifiers::default()
},
windows::with_pressed_key(Modifiers::default(), 0x5c)
);
assert_eq!(
Modifiers {
ctrl: true,
alt: true,
shift: true,
meta: false,
},
windows::with_pressed_key(
windows::with_pressed_key(
windows::with_pressed_key(Modifiers::default(), 0x10),
0x11
),
0x12
)
);
}
#[test]
fn windows_pressed_non_modifier_keeps_sampled_flags() {
let ctrl = Modifiers {
ctrl: true,
..Modifiers::default()
};
assert_eq!(ctrl, windows::with_pressed_key(ctrl, 0x41));
assert_eq!(
Modifiers::default(),
windows::with_pressed_key(Modifiers::default(), 0x14)
);
}
#[test]
fn windows_either_win_key_sets_meta() {
assert!(windows::from_sampled(0, 0, 0, windows::HIGH_BIT, 0).meta);
@@ -12,23 +12,16 @@ pub const WM_XBUTTONDOWN: u32 = 0x020b;
pub const WM_XBUTTONUP: u32 = 0x020c;
pub const WM_MOUSEHWHEEL: u32 = 0x020e;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Axis {
Vertical,
Horizontal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
Button { down: bool, button: u8 },
Move,
Wheel { axis: Axis, delta: i16 },
Activity,
Ignored,
}
pub fn classify(msg: u32, xbutton: u16, wheel_delta: i16) -> Action {
pub fn classify(msg: u32, xbutton: u16) -> Action {
match msg {
WM_MOUSEMOVE => Action::Move,
WM_MOUSEMOVE | WM_MOUSEWHEEL | WM_MOUSEHWHEEL => Action::Activity,
WM_LBUTTONDOWN => Action::Button {
down: true,
button: 0,
@@ -75,14 +68,6 @@ pub fn classify(msg: u32, xbutton: u16, wheel_delta: i16) -> Action {
},
_ => Action::Ignored,
},
WM_MOUSEWHEEL => Action::Wheel {
axis: Axis::Vertical,
delta: wheel_delta,
},
WM_MOUSEHWHEEL => Action::Wheel {
axis: Axis::Horizontal,
delta: wheel_delta,
},
_ => Action::Ignored,
}
}
@@ -98,21 +83,21 @@ mod tests {
down: true,
button: 0
},
classify(WM_LBUTTONDOWN, 0, 0)
classify(WM_LBUTTONDOWN, 0)
);
assert_eq!(
Action::Button {
down: false,
button: 2
},
classify(WM_RBUTTONUP, 0, 0)
classify(WM_RBUTTONUP, 0)
);
assert_eq!(
Action::Button {
down: true,
button: 1
},
classify(WM_MBUTTONDOWN, 0, 0)
classify(WM_MBUTTONDOWN, 0)
);
}
@@ -123,50 +108,23 @@ mod tests {
down: true,
button: 3
},
classify(WM_XBUTTONDOWN, 1, 0)
classify(WM_XBUTTONDOWN, 1)
);
assert_eq!(
Action::Button {
down: false,
button: 4
},
classify(WM_XBUTTONUP, 2, 0)
classify(WM_XBUTTONUP, 2)
);
assert_eq!(Action::Ignored, classify(WM_XBUTTONDOWN, 7, 0));
assert_eq!(Action::Ignored, classify(WM_XBUTTONDOWN, 7));
}
#[test]
fn vertical_wheel_preserves_signed_delta() {
assert_eq!(
Action::Wheel {
axis: Axis::Vertical,
delta: 120,
},
classify(WM_MOUSEWHEEL, 0, 120)
);
assert_eq!(
Action::Wheel {
axis: Axis::Vertical,
delta: -240,
},
classify(WM_MOUSEWHEEL, 0, -240)
);
}
#[test]
fn horizontal_wheel_reports_horizontal_axis() {
assert_eq!(
Action::Wheel {
axis: Axis::Horizontal,
delta: 120,
},
classify(WM_MOUSEHWHEEL, 0, 120)
);
}
#[test]
fn mouse_move_and_unknown_messages_distinguish_move_vs_ignored() {
assert_eq!(Action::Move, classify(WM_MOUSEMOVE, 0, 0));
assert_eq!(Action::Ignored, classify(0xdead, 0, 0));
fn move_and_wheel_are_activity_and_unknown_messages_are_ignored() {
assert_eq!(Action::Activity, classify(WM_MOUSEMOVE, 0));
assert_eq!(Action::Activity, classify(WM_MOUSEWHEEL, 0));
assert_eq!(Action::Activity, classify(WM_MOUSEHWHEEL, 0));
assert_eq!(Action::Ignored, classify(0xdead, 0));
}
}
@@ -1,232 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PortalEntry {
pub id: String,
pub description: String,
pub preferred_trigger: String,
}
impl PortalEntry {
pub fn new(id: &str, description: &str) -> Self {
Self {
id: id.to_owned(),
description: description.to_owned(),
preferred_trigger: String::new(),
}
}
pub fn with_trigger(id: &str, description: &str, preferred_trigger: &str) -> Self {
Self {
id: id.to_owned(),
description: description.to_owned(),
preferred_trigger: preferred_trigger.to_owned(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PortalBoundShortcut {
pub id: String,
pub description: String,
pub trigger_description: String,
}
impl PortalBoundShortcut {
pub fn new(id: &str, description: &str, trigger_description: &str) -> Self {
Self {
id: id.to_owned(),
description: description.to_owned(),
trigger_description: trigger_description.to_owned(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BindReason {
NoPersistedShortcuts,
NewIdsAdded,
}
impl BindReason {
pub fn name(self) -> &'static str {
match self {
Self::NoPersistedShortcuts => "no-persisted-shortcuts",
Self::NewIdsAdded => "new-ids-added",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigureAction {
Reuse(Vec<PortalBoundShortcut>),
Bind(BindReason),
}
pub fn decide(requested: &[PortalEntry], persisted: &[PortalBoundShortcut]) -> ConfigureAction {
if persisted.is_empty() {
return ConfigureAction::Bind(BindReason::NoPersistedShortcuts);
}
if requested
.iter()
.any(|entry| !has_persisted_shortcut(&entry.id, persisted))
{
return ConfigureAction::Bind(BindReason::NewIdsAdded);
}
ConfigureAction::Reuse(persisted.to_vec())
}
pub fn has_persisted_shortcut(id: &str, persisted: &[PortalBoundShortcut]) -> bool {
persisted.iter().any(|shortcut| shortcut.id == id)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PathError {
NoSpaceLeft,
}
pub fn request_path(
unique_bus_name: &str,
handle_token: &str,
capacity: usize,
) -> Result<String, PathError> {
let trimmed = unique_bus_name.strip_prefix(':').unwrap_or(unique_bus_name);
let normalized = trimmed.replace('.', "_");
let path = format!("/org/freedesktop/portal/desktop/request/{normalized}/{handle_token}");
if path.len() > capacity {
Err(PathError::NoSpaceLeft)
} else {
Ok(path)
}
}
pub fn stable_key(entries: &[PortalEntry]) -> String {
let mut sorted = entries.to_vec();
sorted.sort_by(|a, b| a.id.cmp(&b.id));
let mut out = String::from("[");
for (index, entry) in sorted.iter().enumerate() {
if index != 0 {
out.push(',');
}
out.push_str("{id=");
out.push_str(&entry.id);
out.push_str(",desc=");
out.push_str(&entry.description);
out.push_str(",trig=");
out.push_str(&entry.preferred_trigger);
out.push('}');
}
out.push(']');
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decide_empty_persisted_set_requests_first_time_bind() {
let action = decide(&[PortalEntry::new("mute", "Toggle mute")], &[]);
assert_eq!(
ConfigureAction::Bind(BindReason::NoPersistedShortcuts),
action
);
}
#[test]
fn decide_matching_ids_on_restart_reuses_persisted_bindings() {
let persisted = [
PortalBoundShortcut::new("mute", "Toggle mute", "Ctrl+Shift+M"),
PortalBoundShortcut::new("deafen", "Toggle deafen", "Ctrl+Shift+D"),
];
let requested = [
PortalEntry::new("mute", "Toggle mute"),
PortalEntry::new("deafen", "Toggle deafen"),
];
let action = decide(&requested, &persisted);
assert!(matches!(action, ConfigureAction::Reuse(shortcuts) if shortcuts.len() == 2));
}
#[test]
fn decide_new_id_added_since_last_launch_triggers_fresh_bind() {
let persisted = [PortalBoundShortcut::new(
"mute",
"Toggle mute",
"Ctrl+Shift+M",
)];
let requested = [
PortalEntry::new("mute", "Toggle mute"),
PortalEntry::new("push_to_talk", "Push to talk"),
];
assert_eq!(
ConfigureAction::Bind(BindReason::NewIdsAdded),
decide(&requested, &persisted)
);
}
#[test]
fn decide_removing_previously_persisted_id_does_not_rebind() {
let persisted = [
PortalBoundShortcut::new("mute", "Toggle mute", "Ctrl+Shift+M"),
PortalBoundShortcut::new("deafen", "Toggle deafen", "Ctrl+Shift+D"),
];
let requested = [PortalEntry::new("mute", "Toggle mute")];
assert!(matches!(
decide(&requested, &persisted),
ConfigureAction::Reuse(_)
));
}
#[test]
fn decide_empty_requested_with_empty_persisted_reports_no_persisted() {
assert_eq!(
ConfigureAction::Bind(BindReason::NoPersistedShortcuts),
decide(&[], &[])
);
}
#[test]
fn request_path_well_known_shape_per_portal_spec() {
assert_eq!(
"/org/freedesktop/portal/desktop/request/1_42/fluxer_gs_create_xyz",
request_path(":1.42", "fluxer_gs_create_xyz", 256).unwrap()
);
}
#[test]
fn request_path_handles_unique_names_without_leading_colon() {
assert_eq!(
"/org/freedesktop/portal/desktop/request/1_0_7/tok",
request_path("1.0.7", "tok", 256).unwrap()
);
}
#[test]
fn request_path_rejects_too_small_buffer() {
assert_eq!(
Err(PathError::NoSpaceLeft),
request_path(":1.42", "tok", 16)
);
}
#[test]
fn stable_key_sorts_by_id_and_includes_description_and_trigger() {
let key = stable_key(&[
PortalEntry::new("mute", "Toggle mute"),
PortalEntry::with_trigger("deafen", "Toggle deafen", "Ctrl+D"),
]);
assert_eq!(
"[{id=deafen,desc=Toggle deafen,trig=Ctrl+D},{id=mute,desc=Toggle mute,trig=}]",
key
);
}
#[test]
fn bind_reason_name_matches_js_side_strings() {
assert_eq!(
"no-persisted-shortcuts",
BindReason::NoPersistedShortcuts.name()
);
assert_eq!("new-ids-added", BindReason::NewIdsAdded.name());
}
}
@@ -1,7 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pub mod decide;
pub mod pid_payload;
pub mod portal_snapshot;
pub mod window_pid;
pub mod x11_window_pid;
@@ -1,79 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use super::decide::{PortalBoundShortcut, PortalEntry, has_persisted_shortcut, stable_key};
static TOKEN_SEQ: AtomicU64 = AtomicU64::new(1);
pub fn mint_token(prefix: &str) -> String {
let seq = TOKEN_SEQ.fetch_add(1, Ordering::Relaxed);
let ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0);
format!("{prefix}_{ms:x}_{seq:x}")
}
pub fn compute_stable_key(entries: &[PortalEntry]) -> String {
stable_key(entries)
}
pub fn merge_shortcut_snapshots(
persisted: &[PortalBoundShortcut],
bound: &[PortalBoundShortcut],
) -> Vec<PortalBoundShortcut> {
let mut out = persisted.to_vec();
for shortcut in bound {
if !has_persisted_shortcut(&shortcut.id, &out) {
out.push(shortcut.clone());
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::linux_portals::decide::{PortalBoundShortcut, PortalEntry};
#[test]
fn compute_stable_key_is_deterministic_across_orderings() {
let a = compute_stable_key(&[
PortalEntry::new("mute", "Toggle mute"),
PortalEntry::new("deafen", "Toggle deafen"),
]);
let b = compute_stable_key(&[
PortalEntry::new("deafen", "Toggle deafen"),
PortalEntry::new("mute", "Toggle mute"),
]);
assert_eq!(a, b);
}
#[test]
fn mint_token_is_well_formed_and_monotonically_distinct_within_process() {
let a = mint_token("fluxer_gs_create");
let b = mint_token("fluxer_gs_create");
assert_ne!(a, b);
assert!(a.starts_with("fluxer_gs_create_"));
}
#[test]
fn merge_shortcut_snapshots_preserves_existing_and_adds_newly_bound_ids() {
let persisted = [PortalBoundShortcut::new(
"mute",
"Toggle mute",
"Ctrl+Shift+M",
)];
let bound = [PortalBoundShortcut::new(
"push_to_talk",
"Push to talk",
"Ctrl+Shift+Space",
)];
let merged = merge_shortcut_snapshots(&persisted, &bound);
assert_eq!(2, merged.len());
assert_eq!("mute", merged[0].id);
assert_eq!("push_to_talk", merged[1].id);
}
}