mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-08-11 19:52:10 +00:00
Added documentation
This commit is contained in:
parent
ec6a404a4f
commit
aaa8096f4a
@ -17,33 +17,42 @@ use crate::app_window::{Event, TauriEventType};
|
|||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
use ashpd::desktop::{CreateSessionOptions, ResponseError};
|
use ashpd::desktop::{CreateSessionOptions, ResponseError};
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
use ashpd::desktop::global_shortcuts::{
|
use ashpd::desktop::global_shortcuts::{
|
||||||
BindShortcutsOptions, GlobalShortcuts, ListShortcutsOptions, NewShortcut,
|
BindShortcutsOptions, GlobalShortcuts, ListShortcutsOptions, NewShortcut,
|
||||||
};
|
};
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
|
|
||||||
|
/// Serializes access to the active shortcut bindings across API requests.
|
||||||
static SHORTCUT_MANAGER: Lazy<Mutex<ShortcutManager>> = Lazy::new(|| Mutex::new(ShortcutManager::default()));
|
static SHORTCUT_MANAGER: Lazy<Mutex<ShortcutManager>> = Lazy::new(|| Mutex::new(ShortcutManager::default()));
|
||||||
|
|
||||||
|
/// Indicates whether shortcut activations must currently be ignored.
|
||||||
static PROCESSING_SUSPENDED: AtomicBool = AtomicBool::new(false);
|
static PROCESSING_SUSPENDED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
/// Supplies unique generations for portal sessions so stale signal tasks can be ignored.
|
||||||
static NEXT_PORTAL_GENERATION: AtomicU64 = AtomicU64::new(1);
|
static NEXT_PORTAL_GENERATION: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
/// Maps each shortcut to the generation of its currently active portal session.
|
||||||
static ACTIVE_PORTAL_GENERATIONS: Lazy<std::sync::Mutex<HashMap<Shortcut, u64>>> = Lazy::new(|| std::sync::Mutex::new(HashMap::new()));
|
static ACTIVE_PORTAL_GENERATIONS: Lazy<std::sync::Mutex<HashMap<Shortcut, u64>>> = Lazy::new(|| std::sync::Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
/// Enum identifying global keyboard shortcuts.
|
/// Enum identifying global keyboard shortcuts.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display)]
|
||||||
#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
|
#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
|
||||||
pub enum Shortcut {
|
pub enum Shortcut {
|
||||||
|
/// Null value used when no supported shortcut was specified.
|
||||||
None = 0,
|
None = 0,
|
||||||
|
|
||||||
|
/// Toggles voice recording and transcription.
|
||||||
VoiceRecordingToggle,
|
VoiceRecordingToggle,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Shortcut {
|
impl Shortcut {
|
||||||
|
/// Resolves an application-provided portal shortcut ID to its internal identifier.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn from_portal_id(id: &str) -> Option<Self> {
|
fn from_portal_id(id: &str) -> Option<Self> {
|
||||||
match id {
|
match id {
|
||||||
@ -56,9 +65,16 @@ impl Shortcut {
|
|||||||
/// Request payload for registering or disabling a global shortcut.
|
/// Request payload for registering or disabling a global shortcut.
|
||||||
#[derive(Clone, Deserialize)]
|
#[derive(Clone, Deserialize)]
|
||||||
pub struct RegisterShortcutRequest {
|
pub struct RegisterShortcutRequest {
|
||||||
|
/// Identifies the action controlled by the shortcut.
|
||||||
pub id: Shortcut,
|
pub id: Shortcut,
|
||||||
|
|
||||||
|
/// Contains the preferred key combination in Tauri shortcut syntax.
|
||||||
pub shortcut: String,
|
pub shortcut: String,
|
||||||
|
|
||||||
|
/// Contains the localized action description shown by the desktop portal.
|
||||||
pub description: String,
|
pub description: String,
|
||||||
|
|
||||||
|
/// Indicates that the user deliberately requested a different key combination.
|
||||||
pub reconfigure: bool,
|
pub reconfigure: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,22 +82,37 @@ pub struct RegisterShortcutRequest {
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ShortcutBackend {
|
pub enum ShortcutBackend {
|
||||||
|
/// No native shortcut backend is active.
|
||||||
None,
|
None,
|
||||||
|
|
||||||
|
/// The XDG Desktop Portal manages the shortcut.
|
||||||
Portal,
|
Portal,
|
||||||
|
|
||||||
|
/// The Tauri global-shortcut plugin manages the shortcut.
|
||||||
Tauri,
|
Tauri,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Response for shortcut registration and processing state changes.
|
/// Response for shortcut registration and processing state changes.
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct ShortcutResponse {
|
pub struct ShortcutResponse {
|
||||||
|
/// Indicates whether the requested operation completed successfully.
|
||||||
pub success: bool,
|
pub success: bool,
|
||||||
|
|
||||||
|
/// Contains a technical error description when the operation failed.
|
||||||
pub error_message: String,
|
pub error_message: String,
|
||||||
|
|
||||||
|
/// Identifies the backend involved in the operation.
|
||||||
pub backend: ShortcutBackend,
|
pub backend: ShortcutBackend,
|
||||||
|
|
||||||
|
/// Indicates whether the user cancelled the portal request.
|
||||||
pub cancelled: bool,
|
pub cancelled: bool,
|
||||||
|
|
||||||
|
/// Contains the effective, user-facing shortcut label selected by the backend.
|
||||||
pub effective_display_name: String,
|
pub effective_display_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ShortcutResponse {
|
impl ShortcutResponse {
|
||||||
|
/// Creates a successful shortcut response for the selected backend.
|
||||||
fn success(backend: ShortcutBackend, effective_display_name: String) -> Self {
|
fn success(backend: ShortcutBackend, effective_display_name: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
success: true,
|
success: true,
|
||||||
@ -92,6 +123,7 @@ impl ShortcutResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a failed shortcut response with its backend and cancellation state.
|
||||||
fn error(error_message: impl Into<String>, backend: ShortcutBackend, cancelled: bool) -> Self {
|
fn error(error_message: impl Into<String>, backend: ShortcutBackend, cancelled: bool) -> Self {
|
||||||
Self {
|
Self {
|
||||||
success: false,
|
success: false,
|
||||||
@ -104,22 +136,36 @@ impl ShortcutResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
|
/// Owns all currently active shortcut bindings.
|
||||||
struct ShortcutManager {
|
struct ShortcutManager {
|
||||||
|
/// Maps each logical shortcut to its active backend binding.
|
||||||
bindings: HashMap<Shortcut, ActiveBinding>,
|
bindings: HashMap<Shortcut, ActiveBinding>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stores the backend-specific resources required by an active shortcut.
|
||||||
enum ActiveBinding {
|
enum ActiveBinding {
|
||||||
Tauri { shortcut: String },
|
/// Stores a shortcut registered through the Tauri plugin.
|
||||||
#[cfg(target_os = "linux")]
|
Tauri {
|
||||||
Portal {
|
/// Contains the registered shortcut in Tauri syntax.
|
||||||
shortcut: String,
|
shortcut: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
/// Stores a shortcut and its live XDG portal session.
|
||||||
|
Portal {
|
||||||
|
/// Contains the preferred shortcut in Tauri syntax.
|
||||||
|
shortcut: String,
|
||||||
|
/// Contains the effective human-readable trigger selected by the portal.
|
||||||
effective_display_name: String,
|
effective_display_name: String,
|
||||||
|
/// Distinguishes this session from superseded portal signal tasks.
|
||||||
generation: u64,
|
generation: u64,
|
||||||
|
/// Keeps the portal registration active for the binding's lifetime.
|
||||||
session: ashpd::desktop::Session<GlobalShortcuts>,
|
session: ashpd::desktop::Session<GlobalShortcuts>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ActiveBinding {
|
impl ActiveBinding {
|
||||||
|
/// Returns the preferred Tauri-format shortcut associated with the binding.
|
||||||
fn shortcut(&self) -> &str {
|
fn shortcut(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
Self::Tauri { shortcut } => shortcut,
|
Self::Tauri { shortcut } => shortcut,
|
||||||
@ -128,6 +174,7 @@ impl ActiveBinding {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the native backend used by the binding.
|
||||||
fn backend(&self) -> ShortcutBackend {
|
fn backend(&self) -> ShortcutBackend {
|
||||||
match self {
|
match self {
|
||||||
Self::Tauri { .. } => ShortcutBackend::Tauri,
|
Self::Tauri { .. } => ShortcutBackend::Tauri,
|
||||||
@ -136,6 +183,7 @@ impl ActiveBinding {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the shortcut label that should be displayed in the UI.
|
||||||
fn effective_display_name(&self) -> String {
|
fn effective_display_name(&self) -> String {
|
||||||
match self {
|
match self {
|
||||||
Self::Tauri { shortcut } => shortcut.clone(),
|
Self::Tauri { shortcut } => shortcut.clone(),
|
||||||
@ -145,6 +193,7 @@ impl ActiveBinding {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a snapshot of all registered shortcut IDs and their preferred combinations.
|
||||||
pub async fn registered_shortcuts() -> Vec<(Shortcut, String)> {
|
pub async fn registered_shortcuts() -> Vec<(Shortcut, String)> {
|
||||||
SHORTCUT_MANAGER
|
SHORTCUT_MANAGER
|
||||||
.lock()
|
.lock()
|
||||||
@ -155,6 +204,7 @@ pub async fn registered_shortcuts() -> Vec<(Shortcut, String)> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Registers, reconfigures, or disables a global shortcut through the appropriate backend.
|
||||||
pub async fn register(
|
pub async fn register(
|
||||||
app_handle: Option<tauri::AppHandle>,
|
app_handle: Option<tauri::AppHandle>,
|
||||||
event_sender: Option<broadcast::Sender<Event>>,
|
event_sender: Option<broadcast::Sender<Event>>,
|
||||||
@ -232,10 +282,12 @@ pub async fn register(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Determines whether an existing registration already satisfies the request.
|
||||||
fn registration_is_unchanged(current: Option<&str>, requested: &str, reconfigure: bool) -> bool {
|
fn registration_is_unchanged(current: Option<&str>, requested: &str, reconfigure: bool) -> bool {
|
||||||
current.is_some_and(|current| current.eq_ignore_ascii_case(requested)) && !reconfigure
|
current.is_some_and(|current| current.eq_ignore_ascii_case(requested)) && !reconfigure
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removes an active binding and returns a successful disabled response.
|
||||||
async fn disable_binding(
|
async fn disable_binding(
|
||||||
app_handle: &tauri::AppHandle,
|
app_handle: &tauri::AppHandle,
|
||||||
manager: &mut ShortcutManager,
|
manager: &mut ShortcutManager,
|
||||||
@ -250,6 +302,7 @@ async fn disable_binding(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
/// Activates a prepared portal binding before closing the superseded binding.
|
||||||
async fn replace_portal_binding(
|
async fn replace_portal_binding(
|
||||||
app_handle: &tauri::AppHandle,
|
app_handle: &tauri::AppHandle,
|
||||||
manager: &mut ShortcutManager,
|
manager: &mut ShortcutManager,
|
||||||
@ -267,6 +320,7 @@ async fn replace_portal_binding(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Releases the native resources owned by an active shortcut binding.
|
||||||
async fn close_binding(app_handle: &tauri::AppHandle, id: Shortcut, binding: ActiveBinding) {
|
async fn close_binding(app_handle: &tauri::AppHandle, id: Shortcut, binding: ActiveBinding) {
|
||||||
match binding {
|
match binding {
|
||||||
ActiveBinding::Tauri { shortcut } => {
|
ActiveBinding::Tauri { shortcut } => {
|
||||||
@ -288,6 +342,7 @@ async fn close_binding(app_handle: &tauri::AppHandle, id: Shortcut, binding: Act
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Registers a shortcut callback through the Tauri global-shortcut plugin.
|
||||||
fn register_tauri_binding(
|
fn register_tauri_binding(
|
||||||
app_handle: &tauri::AppHandle,
|
app_handle: &tauri::AppHandle,
|
||||||
shortcut: &str,
|
shortcut: &str,
|
||||||
@ -303,6 +358,7 @@ fn register_tauri_binding(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Publishes a shortcut activation using the existing runtime event format.
|
||||||
fn send_shortcut_pressed(event_sender: &broadcast::Sender<Event>, shortcut_id: Shortcut, source: &str) {
|
fn send_shortcut_pressed(event_sender: &broadcast::Sender<Event>, shortcut_id: Shortcut, source: &str) {
|
||||||
info!(Source = "Global shortcuts"; "Global shortcut triggered through {source} for '{}'.", shortcut_id);
|
info!(Source = "Global shortcuts"; "Global shortcut triggered through {source} for '{}'.", shortcut_id);
|
||||||
if let Err(error) = event_sender.send(Event::new(
|
if let Err(error) = event_sender.send(Event::new(
|
||||||
@ -313,6 +369,7 @@ fn send_shortcut_pressed(event_sender: &broadcast::Sender<Event>, shortcut_id: S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Suspends shortcut processing while preserving portal sessions for later use.
|
||||||
pub async fn suspend(app_handle: Option<tauri::AppHandle>) -> ShortcutResponse {
|
pub async fn suspend(app_handle: Option<tauri::AppHandle>) -> ShortcutResponse {
|
||||||
PROCESSING_SUSPENDED.store(true, Ordering::Relaxed);
|
PROCESSING_SUSPENDED.store(true, Ordering::Relaxed);
|
||||||
let Some(app_handle) = app_handle else {
|
let Some(app_handle) = app_handle else {
|
||||||
@ -333,6 +390,7 @@ pub async fn suspend(app_handle: Option<tauri::AppHandle>) -> ShortcutResponse {
|
|||||||
ShortcutResponse::success(ShortcutBackend::None, String::new())
|
ShortcutResponse::success(ShortcutBackend::None, String::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resumes shortcut processing and restores shortcuts owned by the Tauri backend.
|
||||||
pub async fn resume(
|
pub async fn resume(
|
||||||
app_handle: Option<tauri::AppHandle>,
|
app_handle: Option<tauri::AppHandle>,
|
||||||
event_sender: Option<broadcast::Sender<Event>>,
|
event_sender: Option<broadcast::Sender<Event>>,
|
||||||
@ -365,27 +423,42 @@ pub async fn resume(
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||||
|
/// Classifies portal failures so fallback and user feedback remain intentional.
|
||||||
enum PortalFailureKind {
|
enum PortalFailureKind {
|
||||||
|
/// The portal service or GlobalShortcuts interface is not available.
|
||||||
Unavailable,
|
Unavailable,
|
||||||
|
|
||||||
|
/// The user cancelled the portal interaction.
|
||||||
Cancelled,
|
Cancelled,
|
||||||
|
|
||||||
|
/// The portal explicitly denied the shortcut request.
|
||||||
Denied,
|
Denied,
|
||||||
|
|
||||||
|
/// The portal failed for another technical reason.
|
||||||
Technical,
|
Technical,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Determines whether an unavailable portal may safely fall back to Tauri.
|
||||||
fn may_fallback_to_tauri(failure: PortalFailureKind, current_backend: Option<ShortcutBackend>) -> bool {
|
fn may_fallback_to_tauri(failure: PortalFailureKind, current_backend: Option<ShortcutBackend>) -> bool {
|
||||||
failure == PortalFailureKind::Unavailable && current_backend.is_none_or(|backend| backend == ShortcutBackend::Tauri)
|
failure == PortalFailureKind::Unavailable && current_backend.is_none_or(|backend| backend == ShortcutBackend::Tauri)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Determines whether a backend must unregister its shortcut during suspension.
|
||||||
fn unregister_backend_during_suspend(backend: ShortcutBackend) -> bool {
|
fn unregister_backend_during_suspend(backend: ShortcutBackend) -> bool {
|
||||||
backend == ShortcutBackend::Tauri
|
backend == ShortcutBackend::Tauri
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
/// Describes how a newly created portal session should obtain its shortcut.
|
||||||
enum PortalBindingAction {
|
enum PortalBindingAction {
|
||||||
|
/// Reuse a shortcut that the portal restored from an earlier session.
|
||||||
Restore,
|
Restore,
|
||||||
|
|
||||||
|
/// Ask the portal to bind or deliberately reconfigure the shortcut.
|
||||||
Bind,
|
Bind,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Selects restore or bind based on portal state and explicit user intent.
|
||||||
fn portal_binding_action(was_restored: bool, reconfigure: bool) -> PortalBindingAction {
|
fn portal_binding_action(was_restored: bool, reconfigure: bool) -> PortalBindingAction {
|
||||||
if was_restored && !reconfigure {
|
if was_restored && !reconfigure {
|
||||||
PortalBindingAction::Restore
|
PortalBindingAction::Restore
|
||||||
@ -396,13 +469,18 @@ fn portal_binding_action(was_restored: bool, reconfigure: bool) -> PortalBinding
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||||
|
/// Carries a classified portal failure and its technical description.
|
||||||
struct PortalFailure {
|
struct PortalFailure {
|
||||||
|
/// Identifies the semantic failure category.
|
||||||
kind: PortalFailureKind,
|
kind: PortalFailureKind,
|
||||||
|
|
||||||
|
/// Contains the technical error text used for logging and API responses.
|
||||||
message: String,
|
message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PortalFailure {
|
impl PortalFailure {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
/// Converts an `ashpd` error into the application's portal failure categories.
|
||||||
fn from_error(error: ashpd::Error) -> Self {
|
fn from_error(error: ashpd::Error) -> Self {
|
||||||
let kind = match &error {
|
let kind = match &error {
|
||||||
ashpd::Error::Response(ResponseError::Cancelled) => PortalFailureKind::Cancelled,
|
ashpd::Error::Response(ResponseError::Cancelled) => PortalFailureKind::Cancelled,
|
||||||
@ -416,20 +494,28 @@ impl PortalFailure {
|
|||||||
Self { kind, message: error.to_string() }
|
Self { kind, message: error.to_string() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates an explicit portal-permission denial.
|
||||||
fn denied(message: impl Into<String>) -> Self {
|
fn denied(message: impl Into<String>) -> Self {
|
||||||
Self { kind: PortalFailureKind::Denied, message: message.into() }
|
Self { kind: PortalFailureKind::Denied, message: message.into() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
/// Contains the portal-facing ID and effective label of a registered shortcut.
|
||||||
struct PortalShortcutInfo {
|
struct PortalShortcutInfo {
|
||||||
|
/// Contains the stable application-provided shortcut ID.
|
||||||
id: String,
|
id: String,
|
||||||
|
|
||||||
|
/// Contains the human-readable trigger returned by the portal.
|
||||||
effective_display_name: String,
|
effective_display_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Abstracts portal listing and binding operations for deterministic lifecycle tests.
|
||||||
trait PortalAdapter {
|
trait PortalAdapter {
|
||||||
|
/// Lists shortcuts restored into the current portal session.
|
||||||
async fn list_shortcuts(&mut self) -> Result<Vec<PortalShortcutInfo>, PortalFailure>;
|
async fn list_shortcuts(&mut self) -> Result<Vec<PortalShortcutInfo>, PortalFailure>;
|
||||||
|
|
||||||
|
/// Binds a shortcut with a localized description and preferred XDG trigger.
|
||||||
async fn bind_shortcut(
|
async fn bind_shortcut(
|
||||||
&mut self,
|
&mut self,
|
||||||
id: &str,
|
id: &str,
|
||||||
@ -438,6 +524,7 @@ trait PortalAdapter {
|
|||||||
) -> Result<Vec<PortalShortcutInfo>, PortalFailure>;
|
) -> Result<Vec<PortalShortcutInfo>, PortalFailure>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restores an approved portal shortcut or binds it when required.
|
||||||
async fn resolve_portal_shortcut<A: PortalAdapter>(
|
async fn resolve_portal_shortcut<A: PortalAdapter>(
|
||||||
adapter: &mut A,
|
adapter: &mut A,
|
||||||
request: &RegisterShortcutRequest,
|
request: &RegisterShortcutRequest,
|
||||||
@ -469,13 +556,18 @@ async fn resolve_portal_shortcut<A: PortalAdapter>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
/// Implements portal operations through `ashpd` for one live session.
|
||||||
struct AshpdPortalAdapter<'a> {
|
struct AshpdPortalAdapter<'a> {
|
||||||
|
/// Provides access to the GlobalShortcuts portal interface.
|
||||||
portal: &'a GlobalShortcuts,
|
portal: &'a GlobalShortcuts,
|
||||||
|
|
||||||
|
/// Identifies the session whose shortcuts are listed or bound.
|
||||||
session: &'a ashpd::desktop::Session<GlobalShortcuts>,
|
session: &'a ashpd::desktop::Session<GlobalShortcuts>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
impl PortalAdapter for AshpdPortalAdapter<'_> {
|
impl PortalAdapter for AshpdPortalAdapter<'_> {
|
||||||
|
/// Lists and normalizes shortcuts restored by the XDG portal.
|
||||||
async fn list_shortcuts(&mut self) -> Result<Vec<PortalShortcutInfo>, PortalFailure> {
|
async fn list_shortcuts(&mut self) -> Result<Vec<PortalShortcutInfo>, PortalFailure> {
|
||||||
let response = self
|
let response = self
|
||||||
.portal
|
.portal
|
||||||
@ -494,6 +586,7 @@ impl PortalAdapter for AshpdPortalAdapter<'_> {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Binds one shortcut through the XDG portal and normalizes its response.
|
||||||
async fn bind_shortcut(
|
async fn bind_shortcut(
|
||||||
&mut self,
|
&mut self,
|
||||||
id: &str,
|
id: &str,
|
||||||
@ -520,6 +613,7 @@ impl PortalAdapter for AshpdPortalAdapter<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
/// Detects D-Bus error strings that specifically indicate an unavailable portal.
|
||||||
fn portal_error_is_unavailable(message: &str) -> bool {
|
fn portal_error_is_unavailable(message: &str) -> bool {
|
||||||
let normalized = message.to_ascii_lowercase();
|
let normalized = message.to_ascii_lowercase();
|
||||||
normalized.contains("unknownmethod")
|
normalized.contains("unknownmethod")
|
||||||
@ -529,6 +623,7 @@ fn portal_error_is_unavailable(message: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
/// Prepares a complete portal session and its signal listeners without replacing the active binding.
|
||||||
async fn prepare_portal_binding(
|
async fn prepare_portal_binding(
|
||||||
request: &RegisterShortcutRequest,
|
request: &RegisterShortcutRequest,
|
||||||
event_sender: broadcast::Sender<Event>,
|
event_sender: broadcast::Sender<Event>,
|
||||||
@ -600,6 +695,7 @@ async fn prepare_portal_binding(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Converts a Tauri-format shortcut into the XDG shortcuts specification format.
|
||||||
fn tauri_shortcut_to_xdg(shortcut: &str) -> Result<String, String> {
|
fn tauri_shortcut_to_xdg(shortcut: &str) -> Result<String, String> {
|
||||||
let mut converted = Vec::new();
|
let mut converted = Vec::new();
|
||||||
let parts: Vec<&str> = shortcut.split('+').collect();
|
let parts: Vec<&str> = shortcut.split('+').collect();
|
||||||
@ -668,6 +764,7 @@ fn tauri_shortcut_to_xdg(shortcut: &str) -> Result<String, String> {
|
|||||||
Ok(converted.join("+"))
|
Ok(converted.join("+"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Converts Tauri numpad key names into XKB keypad key symbols.
|
||||||
fn numpad_key_to_xdg(key: &str) -> Option<String> {
|
fn numpad_key_to_xdg(key: &str) -> Option<String> {
|
||||||
let suffix = match key {
|
let suffix = match key {
|
||||||
"num0" => "0",
|
"num0" => "0",
|
||||||
@ -699,19 +796,27 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
|
/// Simulates portal listing and binding outcomes for lifecycle tests.
|
||||||
struct FakePortalAdapter {
|
struct FakePortalAdapter {
|
||||||
|
/// Shortcuts returned by the simulated list operation.
|
||||||
listed: Vec<PortalShortcutInfo>,
|
listed: Vec<PortalShortcutInfo>,
|
||||||
|
/// Shortcuts returned by the simulated bind operation.
|
||||||
bound: Vec<PortalShortcutInfo>,
|
bound: Vec<PortalShortcutInfo>,
|
||||||
|
/// Optional failure returned instead of a successful bind result.
|
||||||
bind_failure: Option<PortalFailure>,
|
bind_failure: Option<PortalFailure>,
|
||||||
|
/// Counts bind calls so tests can detect unnecessary portal dialogs.
|
||||||
bind_calls: usize,
|
bind_calls: usize,
|
||||||
|
/// Records the preferred trigger supplied to the simulated portal.
|
||||||
last_preferred_trigger: String,
|
last_preferred_trigger: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PortalAdapter for FakePortalAdapter {
|
impl PortalAdapter for FakePortalAdapter {
|
||||||
|
/// Returns the shortcuts configured as restored by the fake portal.
|
||||||
async fn list_shortcuts(&mut self) -> Result<Vec<PortalShortcutInfo>, PortalFailure> {
|
async fn list_shortcuts(&mut self) -> Result<Vec<PortalShortcutInfo>, PortalFailure> {
|
||||||
Ok(self.listed.clone())
|
Ok(self.listed.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Records the bind request and returns the configured result or failure.
|
||||||
async fn bind_shortcut(
|
async fn bind_shortcut(
|
||||||
&mut self,
|
&mut self,
|
||||||
_id: &str,
|
_id: &str,
|
||||||
@ -727,6 +832,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a voice-recording shortcut request for portal lifecycle tests.
|
||||||
fn portal_request(shortcut: &str, reconfigure: bool) -> RegisterShortcutRequest {
|
fn portal_request(shortcut: &str, reconfigure: bool) -> RegisterShortcutRequest {
|
||||||
RegisterShortcutRequest {
|
RegisterShortcutRequest {
|
||||||
id: Shortcut::VoiceRecordingToggle,
|
id: Shortcut::VoiceRecordingToggle,
|
||||||
@ -736,6 +842,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates normalized portal shortcut data for test responses.
|
||||||
fn portal_shortcut(display_name: &str) -> PortalShortcutInfo {
|
fn portal_shortcut(display_name: &str) -> PortalShortcutInfo {
|
||||||
PortalShortcutInfo {
|
PortalShortcutInfo {
|
||||||
id: Shortcut::VoiceRecordingToggle.to_string(),
|
id: Shortcut::VoiceRecordingToggle.to_string(),
|
||||||
@ -744,6 +851,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
/// Verifies conversion from representative Tauri combinations to XDG triggers.
|
||||||
fn converts_tauri_shortcut_to_xdg_trigger() {
|
fn converts_tauri_shortcut_to_xdg_trigger() {
|
||||||
assert_eq!(tauri_shortcut_to_xdg("CmdOrControl+Shift+1").unwrap(), "CTRL+SHIFT+1");
|
assert_eq!(tauri_shortcut_to_xdg("CmdOrControl+Shift+1").unwrap(), "CTRL+SHIFT+1");
|
||||||
assert_eq!(tauri_shortcut_to_xdg("Control+Alt+Enter").unwrap(), "CTRL+ALT+Return");
|
assert_eq!(tauri_shortcut_to_xdg("Control+Alt+Enter").unwrap(), "CTRL+ALT+Return");
|
||||||
@ -754,12 +862,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
/// Verifies that unsupported modifiers and keys are rejected during conversion.
|
||||||
fn rejects_unsupported_xdg_trigger_parts() {
|
fn rejects_unsupported_xdg_trigger_parts() {
|
||||||
assert!(tauri_shortcut_to_xdg("Hyper+1").is_err());
|
assert!(tauri_shortcut_to_xdg("Hyper+1").is_err());
|
||||||
assert!(tauri_shortcut_to_xdg("Ctrl++").is_err());
|
assert!(tauri_shortcut_to_xdg("Ctrl++").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
/// Verifies that unchanged settings do not cause duplicate native registrations.
|
||||||
fn identical_configuration_is_not_registered_twice() {
|
fn identical_configuration_is_not_registered_twice() {
|
||||||
assert!(registration_is_unchanged(Some("CmdOrControl+Shift+1"), "cmdorcontrol+shift+1", false));
|
assert!(registration_is_unchanged(Some("CmdOrControl+Shift+1"), "cmdorcontrol+shift+1", false));
|
||||||
assert!(!registration_is_unchanged(Some("CmdOrControl+Shift+1"), "CmdOrControl+Shift+2", false));
|
assert!(!registration_is_unchanged(Some("CmdOrControl+Shift+1"), "CmdOrControl+Shift+2", false));
|
||||||
@ -767,6 +877,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
/// Verifies that an approved shortcut is restored without another bind request.
|
||||||
async fn portal_adapter_restores_without_binding() {
|
async fn portal_adapter_restores_without_binding() {
|
||||||
let mut adapter = FakePortalAdapter {
|
let mut adapter = FakePortalAdapter {
|
||||||
listed: vec![portal_shortcut("Ctrl+Shift+1")],
|
listed: vec![portal_shortcut("Ctrl+Shift+1")],
|
||||||
@ -780,6 +891,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
/// Verifies that a missing shortcut is bound with its converted preferred trigger.
|
||||||
async fn portal_adapter_binds_missing_shortcut_with_preferred_trigger() {
|
async fn portal_adapter_binds_missing_shortcut_with_preferred_trigger() {
|
||||||
let mut adapter = FakePortalAdapter {
|
let mut adapter = FakePortalAdapter {
|
||||||
bound: vec![portal_shortcut("Ctrl+Shift+1")],
|
bound: vec![portal_shortcut("Ctrl+Shift+1")],
|
||||||
@ -794,6 +906,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
/// Verifies that deliberate changes bind again instead of restoring the old trigger.
|
||||||
async fn portal_adapter_rebinds_after_deliberate_change() {
|
async fn portal_adapter_rebinds_after_deliberate_change() {
|
||||||
let mut adapter = FakePortalAdapter {
|
let mut adapter = FakePortalAdapter {
|
||||||
listed: vec![portal_shortcut("Ctrl+Shift+1")],
|
listed: vec![portal_shortcut("Ctrl+Shift+1")],
|
||||||
@ -809,6 +922,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
/// Verifies that portal cancellation remains distinguishable from technical errors.
|
||||||
async fn portal_adapter_preserves_cancellation() {
|
async fn portal_adapter_preserves_cancellation() {
|
||||||
let mut adapter = FakePortalAdapter {
|
let mut adapter = FakePortalAdapter {
|
||||||
bind_failure: Some(PortalFailure {
|
bind_failure: Some(PortalFailure {
|
||||||
@ -825,6 +939,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
/// Verifies that fallback is restricted to unavailable portals and safe active states.
|
||||||
fn fallback_is_limited_to_an_unavailable_portal() {
|
fn fallback_is_limited_to_an_unavailable_portal() {
|
||||||
assert!(may_fallback_to_tauri(PortalFailureKind::Unavailable, None));
|
assert!(may_fallback_to_tauri(PortalFailureKind::Unavailable, None));
|
||||||
assert!(may_fallback_to_tauri(PortalFailureKind::Unavailable, Some(ShortcutBackend::Tauri)));
|
assert!(may_fallback_to_tauri(PortalFailureKind::Unavailable, Some(ShortcutBackend::Tauri)));
|
||||||
@ -835,6 +950,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
/// Verifies that suspend keeps portal sessions while unregistering Tauri bindings.
|
||||||
fn suspend_keeps_portal_session_registered() {
|
fn suspend_keeps_portal_session_registered() {
|
||||||
assert!(!unregister_backend_during_suspend(ShortcutBackend::Portal));
|
assert!(!unregister_backend_during_suspend(ShortcutBackend::Portal));
|
||||||
assert!(unregister_backend_during_suspend(ShortcutBackend::Tauri));
|
assert!(unregister_backend_during_suspend(ShortcutBackend::Tauri));
|
||||||
@ -842,6 +958,7 @@ mod tests {
|
|||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[test]
|
#[test]
|
||||||
|
/// Verifies recognition of unavailable-portal D-Bus errors without misclassifying rejection.
|
||||||
fn only_unavailable_portal_errors_allow_fallback() {
|
fn only_unavailable_portal_errors_allow_fallback() {
|
||||||
assert!(portal_error_is_unavailable("org.freedesktop.DBus.Error.UnknownMethod"));
|
assert!(portal_error_is_unavailable("org.freedesktop.DBus.Error.UnknownMethod"));
|
||||||
assert!(portal_error_is_unavailable("ServiceUnknown"));
|
assert!(portal_error_is_unavailable("ServiceUnknown"));
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user