mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
feat(desktop): portal-based Linux global shortcuts and PTT (#3185)
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user