diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8f372c45a..720a6582c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -189,6 +189,23 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "ashpd" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "618a409b91d5265798a99e3d1d0b226911605e581c4e7255e83c1e397b172bce" +dependencies = [ + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.2", + "serde", + "serde_repr", + "tokio", + "url", + "zbus", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -2412,6 +2429,7 @@ name = "handy" version = "0.7.1" dependencies = [ "anyhow", + "ashpd", "chrono", "cpal", "enigo", @@ -2459,6 +2477,7 @@ dependencies = [ "tauri-specta", "tokio", "transcribe-rs", + "unicode-normalization", "vad-rs", "windows 0.61.3", ] @@ -6778,8 +6797,10 @@ dependencies = [ "libc", "mio 1.1.0", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -7139,6 +7160,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "462eeb75aeb73aea900253ce739c8e18a67423fadf006037cd3ff27e82748a06" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -8530,6 +8560,7 @@ dependencies = [ "ordered-stream", "serde", "serde_repr", + "tokio", "tracing", "uds_windows", "uuid", @@ -8683,6 +8714,7 @@ dependencies = [ "endi", "enumflags2", "serde", + "url", "winnow 0.7.13", "zvariant_derive", "zvariant_utils", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0b7bb0cd2..58356365a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -59,6 +59,7 @@ vad-rs = { git = "https://github.com/cjpais/vad-rs", default-features = false } enigo = "0.6.1" rodio = { git = "https://github.com/cjpais/rodio.git" } reqwest = { version = "0.12", features = ["json", "stream"] } +unicode-normalization = "0.1.23" futures-util = "0.3" rustfft = "6.4.0" strsim = "0.11.0" @@ -97,6 +98,7 @@ windows = { version = "0.61.3", features = [ tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" } [target.'cfg(target_os = "linux")'.dependencies] +ashpd = "0.12.1" gtk-layer-shell = { version = "0.8", features = ["v0_6"] } gtk = "0.18" diff --git a/src-tauri/src/clipboard.rs b/src-tauri/src/clipboard.rs index f5ebf9252..d40918550 100644 --- a/src-tauri/src/clipboard.rs +++ b/src-tauri/src/clipboard.rs @@ -1,11 +1,13 @@ use crate::input::{self, EnigoState}; use crate::settings::{get_settings, ClipboardHandling, PasteMethod}; use enigo::Enigo; -use log::info; +use log::{info, warn}; use std::time::Duration; use tauri::{AppHandle, Manager}; use tauri_plugin_clipboard_manager::ClipboardExt; +#[cfg(target_os = "linux")] +use crate::remote_desktop; #[cfg(target_os = "linux")] use crate::utils::{is_kde_wayland, is_wayland}; #[cfg(target_os = "linux")] @@ -121,13 +123,18 @@ fn try_send_key_combo_linux(paste_method: &PasteMethod) -> Result #[cfg(target_os = "linux")] fn try_direct_typing_linux(text: &str) -> Result { if is_wayland() { + // Wayland: prefer remote_desktop, then wtype, then dotool, then ydotool + if is_remote_desktop_available() { + info!("Using Remote Desktop portal for direct text input"); + type_text_via_remote_desktop(text)?; + return Ok(true); + } // KDE Wayland: prefer kwtype (uses KDE Fake Input protocol, supports umlauts) if is_kde_wayland() && is_kwtype_available() { info!("Using kwtype for direct text input on KDE Wayland"); type_text_via_kwtype(text)?; return Ok(true); } - // Wayland: prefer wtype, then dotool, then ydotool // Note: wtype doesn't work on KDE (no zwp_virtual_keyboard_manager_v1 support) if !is_kde_wayland() && is_wtype_available() { info!("Using wtype for direct text input"); @@ -220,6 +227,23 @@ fn is_wl_copy_available() -> bool { .unwrap_or(false) } +#[cfg(target_os = "linux")] +fn is_remote_desktop_available() -> bool { + remote_desktop::is_available() +} + +/// Type text directly via the Remote Desktop portal. +#[cfg(target_os = "linux")] +fn type_text_via_remote_desktop(text: &str) -> Result<(), String> { + match remote_desktop::send_type_text(text) { + Ok(()) => Ok(()), + Err(err) => { + warn!("Remote Desktop direct input failed: {}", err); + Err(format!("remote_desktop failed: {}", err)) + } + } +} + /// Type text directly via wtype on Wayland. #[cfg(target_os = "linux")] fn type_text_via_wtype(text: &str) -> Result<(), String> { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 015c60bdc..96c53365b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -189,3 +189,72 @@ pub fn initialize_shortcuts(app: AppHandle) -> Result<(), String> { log::info!("Shortcuts initialized successfully"); Ok(()) } + +#[specta::specta] +#[tauri::command] +pub fn is_wayland_active() -> bool { + #[cfg(target_os = "linux")] + { + crate::utils::is_wayland() + } + #[cfg(not(target_os = "linux"))] + { + false + } +} + +#[specta::specta] +#[tauri::command] +pub async fn request_remote_desktop_authorization() -> Result { + #[cfg(target_os = "linux")] + { + if !crate::utils::is_wayland() { + return Ok(false); + } + + // Run the blocking portal request on a blocking thread to avoid freezing the UI. + match tauri::async_runtime::spawn_blocking(|| { + crate::remote_desktop::request_authorization() + }) + .await + { + Ok(Ok(())) => Ok(true), + Ok(Err(err)) => Err(err), + Err(join_err) => Err(format!("remote_desktop join error: {}", join_err)), + } + } + #[cfg(not(target_os = "linux"))] + { + Ok(false) + } +} + +#[specta::specta] +#[tauri::command] +pub fn delete_remote_desktop_authorization() -> Result { + #[cfg(target_os = "linux")] + { + if !crate::utils::is_wayland() { + return Ok(false); + } + crate::remote_desktop::delete_authorization(); + Ok(true) + } + #[cfg(not(target_os = "linux"))] + { + Ok(false) + } +} + +#[specta::specta] +#[tauri::command] +pub fn get_remote_desktop_authorization() -> bool { + #[cfg(target_os = "linux")] + { + crate::remote_desktop::get_authorization() + } + #[cfg(not(target_os = "linux"))] + { + false + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c80a8b98f..372ecfe13 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,6 +10,8 @@ mod input; mod llm_client; mod managers; mod overlay; +#[cfg(target_os = "linux")] +mod remote_desktop; mod settings; mod shortcut; mod signal_handle; @@ -115,6 +117,11 @@ fn initialize_core_logic(app_handle: &AppHandle) { // after onboarding completes. This avoids triggering permission dialogs // on macOS before the user is ready. + #[cfg(target_os = "linux")] + { + crate::remote_desktop::init_authorization(app_handle); + } + // Initialize the managers let recording_manager = Arc::new( AudioRecordingManager::new(app_handle).expect("Failed to initialize recording manager"), @@ -288,6 +295,10 @@ pub fn run() { commands::check_apple_intelligence_available, commands::initialize_enigo, commands::initialize_shortcuts, + commands::is_wayland_active, + commands::request_remote_desktop_authorization, + commands::delete_remote_desktop_authorization, + commands::get_remote_desktop_authorization, commands::models::get_available_models, commands::models::get_model_info, commands::models::download_model, diff --git a/src-tauri/src/remote_desktop.rs b/src-tauri/src/remote_desktop.rs new file mode 100644 index 000000000..d37a31604 --- /dev/null +++ b/src-tauri/src/remote_desktop.rs @@ -0,0 +1,499 @@ +#[cfg(target_os = "linux")] +use ashpd::desktop::remote_desktop::{DeviceType, KeyState, RemoteDesktop}; +#[cfg(target_os = "linux")] +use ashpd::desktop::PersistMode; +#[cfg(target_os = "linux")] +use ashpd::zbus::{self, zvariant::OwnedValue}; +#[cfg(target_os = "linux")] +use unicode_normalization::UnicodeNormalization; +#[cfg(target_os = "linux")] +use log::{debug, warn}; +#[cfg(target_os = "linux")] +use once_cell::sync::{Lazy, OnceCell}; +#[cfg(target_os = "linux")] +use std::collections::HashMap; +#[cfg(target_os = "linux")] +use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(target_os = "linux")] +use std::sync::Mutex; +#[cfg(target_os = "linux")] +use std::time::Duration; +#[cfg(target_os = "linux")] +use tauri::{AppHandle, Emitter}; +#[cfg(target_os = "linux")] +use tokio::runtime::RuntimeFlavor; + +#[cfg(target_os = "linux")] +static REMOTE_DESKTOP_TOKEN: Lazy>> = Lazy::new(|| Mutex::new(None)); +#[cfg(target_os = "linux")] +static PORTAL_RT: OnceCell = OnceCell::new(); +#[cfg(target_os = "linux")] +static PORTAL_APP_HANDLE: OnceCell = OnceCell::new(); +#[cfg(target_os = "linux")] +static AUTHORIZED: AtomicBool = AtomicBool::new(false); +#[cfg(target_os = "linux")] +fn portal_runtime() -> Result<&'static tokio::runtime::Runtime, String> { + PORTAL_RT + .get_or_try_init(|| { + tokio::runtime::Runtime::new() + .map_err(|e| format!("Failed to initialize portal runtime: {}", e)) + }) + .map_err(|e| e.to_string()) +} + +// Safely run portal async code even when we're already inside a Tokio runtime. +// Tokio panics if `block_on` is called on a worker thread that is currently +// driving the runtime. If a runtime handle exists and is multi-threaded, we +// hop into a blocking section so nested `block_on` is allowed. For non- +// multithreaded runtimes we bail out with an explicit error. +#[cfg(target_os = "linux")] +fn block_on_portal(f: F) -> Result +where + F: FnOnce() -> Fut, + Fut: std::future::Future>, +{ + match tokio::runtime::Handle::try_current() { + Ok(handle) if handle.runtime_flavor() == RuntimeFlavor::MultiThread => { + tokio::task::block_in_place(|| handle.block_on(f())) + } + Ok(_) => Err("remote desktop requires a multi-thread Tokio runtime".into()), + Err(_) => { + let runtime = portal_runtime()?; + runtime.block_on(f()) + } + } +} + +// ============================================================================ +// Token State (Memory) +// ============================================================================ +#[cfg(target_os = "linux")] +fn set_token_memory(token: &str) { + if let Ok(mut stored) = REMOTE_DESKTOP_TOKEN.lock() { + *stored = Some(token.to_string()); + } +} + +#[cfg(target_os = "linux")] +fn delete_token_memory() { + if let Ok(mut stored) = REMOTE_DESKTOP_TOKEN.lock() { + *stored = None; + } +} + +#[cfg(target_os = "linux")] +fn get_token_memory() -> Option { + REMOTE_DESKTOP_TOKEN + .lock() + .ok() + .and_then(|token| token.clone()) +} + +// ============================================================================ +// Token Settings (Persistent Storage) +// ============================================================================ +#[cfg(target_os = "linux")] +fn set_token_setting(token: &str) { + if let Some(app) = PORTAL_APP_HANDLE.get() { + crate::settings::set_remote_desktop_token(app, Some(token.to_string())); + } +} + +#[cfg(target_os = "linux")] +fn delete_token_setting() { + if let Some(app) = PORTAL_APP_HANDLE.get() { + crate::settings::set_remote_desktop_token(app, None); + } +} + +#[cfg(target_os = "linux")] +fn get_token_setting() -> Option { + PORTAL_APP_HANDLE + .get() + .and_then(|app| crate::settings::get_remote_desktop_token(app)) +} + +// ============================================================================ +// Authorization State (Memory) +// ============================================================================ +#[cfg(target_os = "linux")] +fn set_authorized(value: bool) { + let previous = AUTHORIZED.swap(value, Ordering::Relaxed); + if previous != value { + if let Some(app) = PORTAL_APP_HANDLE.get() { + let _ = app.emit("remote-desktop-auth-changed", value); + } + } +} + +#[cfg(target_os = "linux")] +fn get_authorized() -> bool { + AUTHORIZED.load(Ordering::Relaxed) +} + +// ============================================================================ +// Token Portal Store (D-Bus) +// ============================================================================ +#[cfg(target_os = "linux")] +async fn delete_token_store_async(token: &str) -> Result<(), String> { + if token.is_empty() { + return Ok(()); + } + + let result = tokio::time::timeout(Duration::from_secs(2), async { + let connection = zbus::Connection::session().await?; + let proxy = zbus::Proxy::new( + &connection, + "org.freedesktop.impl.portal.PermissionStore", + "/org/freedesktop/impl/portal/PermissionStore", + "org.freedesktop.impl.portal.PermissionStore", + ) + .await?; + + let args = ("remote-desktop", token); + let _: () = proxy.call("Delete", &args).await?; + Ok::<(), zbus::Error>(()) + }) + .await; + + match result { + Ok(Ok(())) => Ok(()), + Ok(Err(err)) => { + warn!("PermissionStore.Delete failed for token {}: {}", token, err); + Err(format!("Failed to delete permission entry: {}", err)) + } + Err(_) => { + warn!("PermissionStore.Delete timed out for token: {}", token); + Err("PermissionStore.Delete timed out".to_string()) + } + } +} + +#[cfg(target_os = "linux")] +fn delete_token_store(token: &str) -> Result<(), String> { + block_on_portal(|| delete_token_store_async(token)) +} + +#[cfg(target_os = "linux")] +async fn exists_token_store_async(token: &str) -> Result { + if token.is_empty() { + return Ok(false); + } + + let result = tokio::time::timeout(Duration::from_secs(2), async { + let connection = zbus::Connection::session().await?; + let proxy = zbus::Proxy::new( + &connection, + "org.freedesktop.impl.portal.PermissionStore", + "/org/freedesktop/impl/portal/PermissionStore", + "org.freedesktop.impl.portal.PermissionStore", + ) + .await?; + + let args = ("remote-desktop", token); + let _: (HashMap>, OwnedValue) = proxy.call("Lookup", &args).await?; + Ok::(true) + }) + .await; + + match result { + Ok(Ok(exists)) => Ok(exists), + Ok(Err(err)) => { + debug!("remote_desktop: token lookup error: {}", err); + Ok(false) + } + Err(err) => { + debug!("remote_desktop: token lookup timeout: {}", err); + Ok(false) + } + } +} + +#[cfg(target_os = "linux")] +fn validate_token_store() { + // Check if the stored token exists in the portal store. + let token = get_token_memory().or_else(get_token_setting); + let Some(token) = token else { + debug!("remote_desktop: no token found, AUTHORIZED set false"); + delete_token_everywhere(); + return; + }; + + let exists = match block_on_portal(|| exists_token_store_async(&token)) { + Ok(res) => res, + Err(err) => { + debug!("remote_desktop: portal runtime init failed: {}", err); + return; + } + }; + + if !exists { + debug!("remote_desktop: token missing, deleting via delete_token_everywhere()"); + delete_token_everywhere(); + } +} + +#[cfg(target_os = "linux")] +fn delete_token_everywhere() { + let token_memory = get_token_memory(); + let token_setting = get_token_setting(); + let token = token_memory.as_deref().or(token_setting.as_deref()); + + set_authorized(false); + if let Some(token) = token { + let _ = delete_token_store(token); + } + if token_memory.is_some() { + delete_token_memory(); + } + if token_setting.is_some() { + delete_token_setting(); + } +} +// ============================================================================ +// Keyboard Input via Portal +// ============================================================================ +#[cfg(target_os = "linux")] +fn keysym_for_char(ch: char) -> Option { + match ch { + '\n' | '\r' => Some(0xFF0D), // XK_Return + '\t' => Some(0xFF09), // XK_Tab + '\u{8}' => Some(0xFF08), // XK_BackSpace + // Characters in the ISO‑8859‑1 range (including most accented Latin letters) + // are represented directly as keysyms. Only higher code points should use + // the "Unicode keysym" prefix (0x0100_0000). + _ if (ch as u32) <= 0xFF => Some(ch as u32), + _ => Some(0x0100_0000 | (ch as u32)), // Unicode keysym + } +} + +#[cfg(target_os = "linux")] +async fn type_text_async(text: &str) -> Result<(), String> { + let (proxy, session) = open_session_async(false).await?; + + // Helper to send a press/release pair for a keysym. + async fn send_keysym( + proxy: &RemoteDesktop<'static>, + session: &ashpd::desktop::Session<'static, RemoteDesktop<'static>>, + keysym: u32, + ) -> Result<(), String> { + proxy + .notify_keyboard_keysym(session, keysym as i32, KeyState::Pressed) + .await + .map_err(|e| format!("Failed to send keysym press: {}", e))?; + proxy + .notify_keyboard_keysym(session, keysym as i32, KeyState::Released) + .await + .map_err(|e| format!("Failed to send keysym release: {}", e)) + } + + // Send a non-ASCII character through the Ctrl+Shift+U unicode input sequence to + // stay independent of the current keyboard layout. + async fn send_unicode_via_ctrl_shift_u( + proxy: &RemoteDesktop<'static>, + session: &ashpd::desktop::Session<'static, RemoteDesktop<'static>>, + ch: char, + ) -> Result<(), String> { + // Keysyms for modifiers and validation. + const XK_CONTROL_L: u32 = 0xFFE3; + const XK_SHIFT_L: u32 = 0xFFE1; + const XK_RETURN: u32 = 0xFF0D; + // 1) Press Control_L then Shift_L + proxy + .notify_keyboard_keysym(session, XK_CONTROL_L as i32, KeyState::Pressed) + .await + .map_err(|e| format!("unicode-input failed pressing Control: {e}"))?; + proxy + .notify_keyboard_keysym(session, XK_SHIFT_L as i32, KeyState::Pressed) + .await + .map_err(|e| format!("unicode-input failed pressing Shift: {e}"))?; + // 2) Press/Release 'u' + send_keysym(proxy, session, 'u' as u32).await?; + // 3) Release Shift_L then Control_L + proxy + .notify_keyboard_keysym(session, XK_SHIFT_L as i32, KeyState::Released) + .await + .map_err(|e| format!("unicode-input failed releasing Shift: {e}"))?; + proxy + .notify_keyboard_keysym(session, XK_CONTROL_L as i32, KeyState::Released) + .await + .map_err(|e| format!("unicode-input failed releasing Control: {e}"))?; + + // 4) Send hex digits of the codepoint (lowercase). + let hex = format!("{:x}", ch as u32); + for (idx, digit) in hex.chars().enumerate() { + let keysym = keysym_for_char(digit) + .ok_or_else(|| format!("unicode-input: unsupported hex digit '{digit}'"))?; + send_keysym(proxy, session, keysym) + .await + .map_err(|e| format!("unicode-input failed at hex digit #{idx} '{digit}': {e}"))?; + } + + // 5) Validate with Return. + send_keysym(proxy, session, XK_RETURN).await?; + // Give the portal a brief moment to exit the Ctrl+Shift+U compose state + // before the next character, to avoid the following key being swallowed. + tokio::time::sleep(Duration::from_millis(8)).await; + Ok(()) + } + + let result = (|| async { + // Normalize to NFC so we send precomposed characters (é, ô, …) as single keysyms. + let normalized = text.nfc().collect::(); + for ch in normalized.chars() { + if (ch as u32) > 0x7F { + send_unicode_via_ctrl_shift_u(&proxy, &session, ch).await?; + } else { + let keysym = + keysym_for_char(ch).ok_or_else(|| "Unsupported character".to_string())?; + send_keysym(&proxy, &session, keysym).await?; + } + } + Ok(()) + })() + .await; + + if let Err(err) = close_session_async(&session).await { + debug!("remote_desktop: {}", err); + } + + result +} + +// ============================================================================ +// Remote Desktop Session Management +// ============================================================================ +#[cfg(target_os = "linux")] +async fn close_session_async( + session: &ashpd::desktop::Session<'static, RemoteDesktop<'static>>, +) -> Result<(), String> { + session + .close() + .await + .map_err(|e| format!("Failed to close RemoteDesktop session: {}", e)) +} + +#[cfg(target_os = "linux")] +async fn open_session_async( + allow_prompt: bool, +) -> Result< + ( + RemoteDesktop<'static>, + ashpd::desktop::Session<'static, RemoteDesktop<'static>>, + ), + String, +> { + // Connect to the RemoteDesktop portal. + let proxy = RemoteDesktop::new() + .await + .map_err(|e| format!("Failed to connect to RemoteDesktop portal: {}", e))?; + + // Create a new portal session. + let session = proxy + .create_session() + .await + .map_err(|e| format!("Failed to create RemoteDesktop session: {}", e))?; + + // Check existing token if no prompt is allowed. + let remote_desktop_token = get_token_memory(); + if !allow_prompt { + let Some(token) = remote_desktop_token.as_deref() else { + delete_token_everywhere(); + return Err("portal-permission-not-granted".into()); + }; + let exists = exists_token_store_async(token).await?; + if !exists { + delete_token_everywhere(); + return Err("portal-permission-not-granted".into()); + } + } + + // Request keyboard device access via the portal. + let device_types = DeviceType::Keyboard.into(); + proxy + .select_devices( + &session, + device_types, + remote_desktop_token.as_deref(), + PersistMode::ExplicitlyRevoked, + ) + .await + .map_err(|e| format!("Failed to request RemoteDesktop devices: {}", e))? + .response() + .map_err(|e| format!("RemoteDesktop device request denied: {}", e))?; + // Start the session (may trigger permission UI). + let response = proxy + .start(&session, None) + .await + .map_err(|e| format!("Failed to start RemoteDesktop session: {}", e))? + .response() + .map_err(|e| format!("portal-permission-denied: {e}"))?; + + // Persist any new token returned by the portal. + if let Some(token) = response.restore_token() { + set_authorized(true); + set_token_memory(token); + set_token_setting(token); + } + + Ok((proxy, session)) +} + +// ============================================================================ +// Public Functions - Keyboard Input via Portal +// ============================================================================ +#[cfg(target_os = "linux")] +pub fn send_type_text(text: &str) -> Result<(), String> { + if !crate::utils::is_wayland() { + return Err("not running on Wayland".into()); + } + if !get_authorized() { + return Err("authorization not granted".into()); + } + block_on_portal(|| type_text_async(text)) +} + +#[cfg(target_os = "linux")] +pub fn is_available() -> bool { + crate::utils::is_wayland() && get_authorized() +} + +#[cfg(target_os = "linux")] +pub fn get_authorization() -> bool { + get_authorized() +} + +#[cfg(target_os = "linux")] +pub fn request_authorization() -> Result<(), String> { + if !crate::utils::is_wayland() { + return Ok(()); + } + + let (proxy, session) = block_on_portal(|| open_session_async(true))?; + // Drop proxy after closing to avoid holding session references. + let result = block_on_portal(|| close_session_async(&session)); + drop(proxy); + result +} + +#[cfg(target_os = "linux")] +pub fn delete_authorization() { + delete_token_everywhere(); +} + +#[cfg(target_os = "linux")] +pub fn init_authorization(app: &AppHandle) { + if !crate::utils::is_wayland() { + return; + } + let _ = PORTAL_APP_HANDLE.set(app.clone()); + let token = get_token_setting(); + if let Some(token) = token { + set_authorized(true); + set_token_memory(&token); + validate_token_store(); + debug!("remote_desktop: REMOTE_DESKTOP_TOKEN initialized from settings"); + } else { + debug!("remote_desktop: no REMOTE_DESKTOP_TOKEN in settings"); + } +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 44402bc16..9f3a420c7 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -317,6 +317,8 @@ pub struct AppSettings { pub keyboard_implementation: KeyboardImplementation, #[serde(default = "default_paste_delay_ms")] pub paste_delay_ms: u64, + #[serde(default)] + pub remote_desktop_token: Option, } fn default_model() -> String { @@ -632,9 +634,20 @@ pub fn get_default_settings() -> AppSettings { experimental_enabled: false, keyboard_implementation: KeyboardImplementation::default(), paste_delay_ms: default_paste_delay_ms(), + remote_desktop_token: None, } } +pub fn get_remote_desktop_token(app: &AppHandle) -> Option { + get_settings(app).remote_desktop_token +} + +pub fn set_remote_desktop_token(app: &AppHandle, token: Option) { + let mut settings = get_settings(app); + settings.remote_desktop_token = token; + write_settings(app, settings); +} + impl AppSettings { pub fn active_post_process_provider(&self) -> Option<&PostProcessProvider> { self.post_process_providers diff --git a/src/bindings.ts b/src/bindings.ts index 325dc7f77..8b9959ca9 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -425,6 +425,28 @@ async initializeShortcuts() : Promise> { else return { status: "error", error: e as any }; } }, +async isWaylandActive() : Promise { + return await TAURI_INVOKE("is_wayland_active"); +}, +async requestRemoteDesktopAuthorization() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("request_remote_desktop_authorization") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async deleteRemoteDesktopAuthorization() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("delete_remote_desktop_authorization") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async getRemoteDesktopAuthorization() : Promise { + return await TAURI_INVOKE("get_remote_desktop_authorization"); +}, async getAvailableModels() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("get_available_models") }; @@ -678,10 +700,8 @@ async updateRecordingRetentionPeriod(period: string) : Promise> { try { @@ -703,7 +723,7 @@ async isLaptop() : Promise> { /** user-defined types **/ -export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: Partial<{ [key in string]: string }>; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; experimental_enabled?: boolean; keyboard_implementation?: KeyboardImplementation; paste_delay_ms?: number } +export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: Partial<{ [key in string]: string }>; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; experimental_enabled?: boolean; keyboard_implementation?: KeyboardImplementation; paste_delay_ms?: number; remote_desktop_token?: string | null } export type AudioDevice = { index: string; name: string; is_default: boolean } export type BindingResponse = { success: boolean; binding: ShortcutBinding | null; error: string | null } export type ClipboardHandling = "dont_modify" | "copy_to_clipboard" diff --git a/src/components/settings/PasteMethod.tsx b/src/components/settings/PasteMethod.tsx index 862156789..cdb81f421 100644 --- a/src/components/settings/PasteMethod.tsx +++ b/src/components/settings/PasteMethod.tsx @@ -1,9 +1,15 @@ -import React from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { listen } from "@tauri-apps/api/event"; import { Dropdown } from "../ui/Dropdown"; import { SettingContainer } from "../ui/SettingContainer"; +import { Alert } from "../ui/Alert"; +import { Button } from "../ui/Button"; import { useSettings } from "../../hooks/useSettings"; import { useOsType } from "../../hooks/useOsType"; +import { useWayland } from "../../hooks/useWayland"; +import { commands } from "@/bindings"; import type { PasteMethod } from "@/bindings"; interface PasteMethodProps { @@ -16,6 +22,9 @@ export const PasteMethodSetting: React.FC = React.memo( const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); const osType = useOsType(); + const isWayland = useWayland(); + const [isRDRequesting, setIsRDRequesting] = useState(false); + const [isRDAuthorized, setIsRDAuthorized] = useState(false); const getPasteMethodOptions = (osType: string) => { const mod = osType === "macos" ? "Cmd" : "Ctrl"; @@ -63,23 +72,135 @@ export const PasteMethodSetting: React.FC = React.memo( const pasteMethodOptions = getPasteMethodOptions(osType); + // ========================================================================= + // Remote Desktop Authorization State (Wayland only) + // ========================================================================= + // Helper: fetch remote desktop authorization state. + const fetchRDAuthorization = async () => { + try { + const authorized = await commands.getRemoteDesktopAuthorization(); + setIsRDAuthorized(authorized); + } catch { + setIsRDAuthorized(false); + } + }; + + // Only Wayland (linux) + // Init value for isRDAuthorized + // And listen if there is any change from the backend + useEffect(() => { + if (!isWayland) return; + // Fetch for the initial state. + fetchRDAuthorization(); + // Listen for updates on any changes. + let unlisten: (() => void) | null = null; + listen("remote-desktop-auth-changed", (event) => { + setIsRDAuthorized(Boolean(event.payload)); + }).then((stop) => { + unlisten = stop; + }); + return () => { + if (unlisten) unlisten(); + }; + }, [isWayland]); + + const shouldShowRDRequest = isWayland && selectedMethod === "direct"; + + const handleRDRequest = async () => { + if (isRDRequesting) return; + setIsRDRequesting(true); + const result = await commands.requestRemoteDesktopAuthorization(); + if (result.status === "error") { + toast.error( + t("settings.advanced.pasteMethod.portal.errors.requestFailed"), + ); + } + setIsRDRequesting(false); + }; + + const handleRDRevoke = async () => { + const result = await commands.deleteRemoteDesktopAuthorization(); + if (result.status === "error") { + toast.error( + t("settings.advanced.pasteMethod.portal.errors.revokeFailed"), + ); + } + }; + return ( - - - updateSetting("paste_method", value as PasteMethod) - } - disabled={isUpdating("paste_method")} - /> - +
+ {isRDRequesting && ( +
+
+ {t("settings.advanced.pasteMethod.portal.buttonRequesting")} +
+
+ )} + +
+ { + if (value === selectedMethod) return; + + // Update the paste method setting, then run any side effects we manage here. + await updateSetting("paste_method", value as PasteMethod); + + // If the user switches to the direct method on Linux/Wayland and the + // If Remote Desktop permission is active, it is revoked + if (isRDAuthorized && shouldShowRDRequest) { + await commands.deleteRemoteDesktopAuthorization(); + } + }} + disabled={isUpdating("paste_method")} + /> +
+
+ {shouldShowRDRequest && ( +
+ + {isRDAuthorized ? ( +
+
+ {t("settings.advanced.pasteMethod.portal.authorized")} +
+
+ {t("settings.advanced.pasteMethod.portal.authorizedRappel")} +
+
+ ) : ( + t("settings.advanced.pasteMethod.portal.description") + )} +
+ +
+
+
+ )} +
); }, ); diff --git a/src/hooks/useWayland.ts b/src/hooks/useWayland.ts new file mode 100644 index 000000000..2f46ba7a4 --- /dev/null +++ b/src/hooks/useWayland.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from "react"; +import { commands } from "@/bindings"; +import { useOsType } from "./useOsType"; + +/** + * Detect if the app is running under Wayland (Linux only). + */ +export function useWayland(): boolean { + const osType = useOsType(); + const [isWayland, setIsWayland] = useState(false); + + useEffect(() => { + let isMounted = true; + + if (osType !== "linux") { + setIsWayland(false); + return; + } + + commands + .isWaylandActive() + .then((result) => { + if (!isMounted) return; + setIsWayland(result); + }) + .catch(() => { + if (isMounted) setIsWayland(false); + }); + + return () => { + isMounted = false; + }; + }, [osType]); + + return isWayland; +} diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index f31b72c86..c1a15e644 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -182,6 +182,18 @@ "clipboardShiftInsert": "Schránka (Shift+Insert)", "direct": "Přímé", "none": "Žádné" + }, + "portal": { + "description": "V Wayland vyžaduje režim Přímý vstup oprávnění „Vzdálená plocha“. Dovoluje aplikaci Handy pouze vložit diktovaný text do aktivního okna. Nevytváří žádné vzdálené připojení.", + "authorized": "Aktivní oprávnění: Handy může vložit diktovaný text do aktivního okna přes „Vzdálenou plochu“.", + "authorizedRappel": "Připomenutí: toto oprávnění nevytváří žádné vzdálené připojení.", + "button": "Požádat o oprávnění", + "buttonRequesting": "Probíhá žádost…", + "buttonRevoke": "Odebrat oprávnění", + "errors": { + "requestFailed": "Žádost o oprávnění se nezdařila.", + "revokeFailed": "Odebrání oprávnění se nezdařilo." + } } }, "clipboardHandling": { diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index bb86826c5..dc91eb5ad 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -198,6 +198,18 @@ "clipboardShiftInsert": "Zwischenablage (Umschalt+Einfg)", "direct": "Direkt", "none": "Keine" + }, + "portal": { + "description": "Unter Wayland benötigt der Direkt-Modus eine „Remote-Desktop“-Berechtigung. Sie erlaubt Handy nur, den diktierten Text in das aktive Fenster einzufügen. Es wird keine Remote-Verbindung erstellt.", + "authorized": "Aktive Berechtigung: Handy kann den diktierten Text über „Remote Desktop“ in das aktive Fenster einfügen.", + "authorizedRappel": "Hinweis: Diese Berechtigung erstellt keine Remote-Verbindung.", + "button": "Berechtigung anfordern", + "buttonRequesting": "Wird angefordert…", + "buttonRevoke": "Berechtigung entfernen", + "errors": { + "requestFailed": "Berechtigungsanfrage fehlgeschlagen.", + "revokeFailed": "Entfernen der Berechtigung fehlgeschlagen." + } } }, "clipboardHandling": { diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 8ad3740c5..bd3e176e4 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -206,6 +206,18 @@ "clipboardShiftInsert": "Clipboard (Shift+Insert)", "direct": "Direct", "none": "None" + }, + "portal": { + "description": "On Wayland, Direct mode needs a “Remote Desktop” permission. It only lets Handy insert the dictated text into the active window. No remote connection is created.", + "authorized": "Permission active: Handy can insert dictated text in the active window via “Remote Desktop”.", + "authorizedRappel": "Reminder: this permission does not create any remote connection.", + "button": "Request permission", + "buttonRequesting": "Requesting…", + "buttonRevoke": "Remove permission", + "errors": { + "requestFailed": "Permission request failed.", + "revokeFailed": "Permission removal failed." + } } }, "clipboardHandling": { diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index 57ecbb80a..cee710c4b 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -198,6 +198,18 @@ "clipboardShiftInsert": "Portapapeles (Shift+Insert)", "direct": "Directo", "none": "Ninguno" + }, + "portal": { + "description": "En Wayland, el modo Directo necesita un permiso de «Escritorio remoto». Solo permite que Handy inserte el texto dictado en la ventana activa. No se crea ninguna conexión remota.", + "authorized": "Permiso activo: Handy puede insertar el texto dictado en la ventana activa a través de «Escritorio remoto».", + "authorizedRappel": "Recordatorio: este permiso no crea ninguna conexión remota.", + "button": "Solicitar permiso", + "buttonRequesting": "Solicitando…", + "buttonRevoke": "Eliminar permiso", + "errors": { + "requestFailed": "La solicitud de permiso falló.", + "revokeFailed": "La eliminación del permiso falló." + } } }, "clipboardHandling": { diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index 9290cacb7..c20f771b0 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -199,6 +199,18 @@ "clipboardShiftInsert": "Presse-papiers (Shift+Insert)", "direct": "Direct", "none": "Aucun" + }, + "portal": { + "description": "Sous Wayland, le mode Direct nécessite une autorisation « Bureau à distance ». Elle permet uniquement à Handy d’insérer le texte dicté dans la fenêtre active. Aucune connexion à distance n’est créée.", + "authorized": "Autorisation active : Handy peut insérer le texte dicté dans la fenêtre active via « Bureau à distance ».", + "authorizedRappel": "Rappel : cette autorisation ne crée aucune connexion distante.", + "button": "Demander l'autorisation", + "buttonRequesting": "Demande en cours…", + "buttonRevoke": "Supprimer l’autorisation", + "errors": { + "requestFailed": "La demande d'autorisation a échoué.", + "revokeFailed": "La suppression de l’autorisation a échoué." + } } }, "clipboardHandling": { diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index 1a362d82b..649eb25a9 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -198,6 +198,18 @@ "clipboardShiftInsert": "Appunti (Shift+Insert)", "direct": "Diretto", "none": "Nessuno" + }, + "portal": { + "description": "Su Wayland, la modalità Diretta richiede un’autorizzazione «Desktop remoto». Consente solo a Handy di inserire il testo dettato nella finestra attiva. Non viene creata alcuna connessione remota.", + "authorized": "Autorizzazione attiva: Handy può inserire il testo dettato nella finestra attiva tramite «Desktop remoto».", + "authorizedRappel": "Promemoria: questa autorizzazione non crea alcuna connessione remota.", + "button": "Richiedi autorizzazione", + "buttonRequesting": "Richiesta in corso…", + "buttonRevoke": "Rimuovi autorizzazione", + "errors": { + "requestFailed": "Richiesta di autorizzazione non riuscita.", + "revokeFailed": "Rimozione dell'autorizzazione non riuscita." + } } }, "clipboardHandling": { diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index dc57fc852..b31c397e6 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -198,6 +198,18 @@ "clipboardShiftInsert": "クリップボード (Shift+Insert)", "direct": "直接", "none": "なし" + }, + "portal": { + "description": "Wayland では、ダイレクトモードに「リモートデスクトップ」の権限が必要です。この権限は、Handy がアクティブウィンドウに音声入力したテキストを挿入するためだけのものです。リモート接続は作成されません。", + "authorized": "権限が有効です: Handy は「リモートデスクトップ」を通してアクティブウィンドウに音声入力したテキストを挿入できます。", + "authorizedRappel": "注意: この権限でリモート接続は作成されません。", + "button": "権限をリクエスト", + "buttonRequesting": "リクエスト中…", + "buttonRevoke": "権限を削除", + "errors": { + "requestFailed": "権限のリクエストに失敗しました。", + "revokeFailed": "権限の削除に失敗しました。" + } } }, "clipboardHandling": { diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index e0300efa2..b25e80f4c 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -198,6 +198,18 @@ "clipboardShiftInsert": "Schowek (Shift+Insert)", "direct": "Bezpośrednio", "none": "Brak" + }, + "portal": { + "description": "W Wayland tryb Bezpośredni wymaga uprawnienia „Pulpit zdalny”. Pozwala ono tylko na wstawienie dyktowanego tekstu do aktywnego okna. Nie tworzy się żadne zdalne połączenie.", + "authorized": "Aktywne uprawnienie: Handy może wstawiać dyktowany tekst do aktywnego okna przez „Pulpit zdalny”.", + "authorizedRappel": "Przypomnienie: to uprawnienie nie tworzy zdalnego połączenia.", + "button": "Poprosić o uprawnienie", + "buttonRequesting": "Żądanie…", + "buttonRevoke": "Usunąć uprawnienie", + "errors": { + "requestFailed": "Żądanie uprawnienia nie powiodło się.", + "revokeFailed": "Usunięcie uprawnienia nie powiodło się." + } } }, "clipboardHandling": { diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index 2d518b262..e6a5a731a 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -202,6 +202,18 @@ "clipboardShiftInsert": "Área de Transferência (Shift+Insert)", "direct": "Direto", "none": "Nenhum" + }, + "portal": { + "description": "No Wayland, o modo Direto precisa de uma autorização de «Área de trabalho remota». Ela permite apenas que o Handy insira o texto ditado na janela ativa. Nenhuma conexão remota é criada.", + "authorized": "Autorização ativa: o Handy pode inserir o texto ditado na janela ativa via «Área de trabalho remota».", + "authorizedRappel": "Lembrete: essa autorização não cria nenhuma conexão remota.", + "button": "Solicitar autorização", + "buttonRequesting": "Solicitando…", + "buttonRevoke": "Remover autorização", + "errors": { + "requestFailed": "Falha ao solicitar autorização.", + "revokeFailed": "Falha ao remover autorização." + } } }, "clipboardHandling": { diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index 2bcfe46c4..16b370e46 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -198,6 +198,18 @@ "clipboardShiftInsert": "Буфер обмена (Shift+Insert)", "direct": "Прямой", "none": "Нет" + }, + "portal": { + "description": "В Wayland режим «Прямой ввод» требует разрешение «Удалённый рабочий стол». Оно позволяет Handy лишь вставлять продиктованный текст в активное окно. Никакое удалённое подключение не создаётся.", + "authorized": "Разрешение активно: Handy может вставлять продиктованный текст в активное окно через «Удалённый рабочий стол».", + "authorizedRappel": "Напоминание: это разрешение не создаёт удалённого подключения.", + "button": "Запросить разрешение", + "buttonRequesting": "Запрос…", + "buttonRevoke": "Удалить разрешение", + "errors": { + "requestFailed": "Не удалось запросить разрешение.", + "revokeFailed": "Не удалось удалить разрешение." + } } }, "clipboardHandling": { diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index 4f20c7c65..183ed64c4 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -202,6 +202,18 @@ "clipboardShiftInsert": "Pano (Shift+Insert)", "direct": "Doğrudan", "none": "Yok" + }, + "portal": { + "description": "Wayland’da Doğrudan mod için “Uzak Masaüstü” izni gerekir. Bu izin, Handy’nin yalnızca dikte edilen metni etkin pencereye eklemesine izin verir. Herhangi bir uzak bağlantı oluşturulmaz.", + "authorized": "İzin etkin: Handy, dikte edilen metni “Uzak Masaüstü” üzerinden etkin pencereye ekleyebilir.", + "authorizedRappel": "Hatırlatma: Bu izin uzak bağlantı oluşturmaz.", + "button": "İzin iste", + "buttonRequesting": "İsteniyor…", + "buttonRevoke": "İzni kaldır", + "errors": { + "requestFailed": "İzin isteği başarısız.", + "revokeFailed": "İzin kaldırma başarısız." + } } }, "clipboardHandling": { diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index 6ef8d47c5..4f064600c 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -198,6 +198,18 @@ "clipboardShiftInsert": "Буфер обміну (Shift+Insert)", "direct": "Прямий", "none": "Немає" + }, + "portal": { + "description": "У Wayland режим «Прямий» потребує дозволу «Віддалений робочий стіл». Він лише дозволяє Handy вставляти продиктований текст в активне вікно. Жодного віддаленого з’єднання не створюється.", + "authorized": "Дозвіл активний: Handy може вставляти продиктований текст в активне вікно через «Віддалений робочий стіл».", + "authorizedRappel": "Нагадування: цей дозвіл не створює віддаленого з’єднання.", + "button": "Запросити дозвіл", + "buttonRequesting": "Запит триває…", + "buttonRevoke": "Видалити дозвіл", + "errors": { + "requestFailed": "Не вдалося запитати дозвіл.", + "revokeFailed": "Не вдалося видалити дозвіл." + } } }, "clipboardHandling": { diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index 891dd04da..eb2fd46fb 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -199,6 +199,18 @@ "clipboardShiftInsert": "Clipboard (Shift+Insert)", "direct": "Trực tiếp", "none": "Không có" + }, + "portal": { + "description": "Trên Wayland, chế độ Trực tiếp cần quyền “Máy tính từ xa”. Quyền này chỉ cho phép Handy chèn văn bản đã đọc vào cửa sổ đang hoạt động. Không tạo kết nối từ xa nào.", + "authorized": "Quyền đang hoạt động: Handy có thể chèn văn bản đã đọc vào cửa sổ đang hoạt động qua “Máy tính từ xa”.", + "authorizedRappel": "Lưu ý: quyền này không tạo kết nối từ xa.", + "button": "Yêu cầu quyền", + "buttonRequesting": "Đang yêu cầu…", + "buttonRevoke": "Xóa quyền", + "errors": { + "requestFailed": "Yêu cầu quyền thất bại.", + "revokeFailed": "Xóa quyền thất bại." + } } }, "clipboardHandling": { diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index 75907c278..a3bdb5486 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -198,6 +198,18 @@ "clipboardShiftInsert": "剪贴板 (Shift+Insert)", "direct": "直接", "none": "无" + }, + "portal": { + "description": "在 Wayland 上,直接模式需要“远程桌面”权限。它仅允许 Handy 将口述文本插入到活动窗口,不会创建任何远程连接。", + "authorized": "权限已启用:Handy 可通过“远程桌面”将口述文本插入活动窗口。", + "authorizedRappel": "提醒:此权限不会创建任何远程连接。", + "button": "请求权限", + "buttonRequesting": "正在请求…", + "buttonRevoke": "移除权限", + "errors": { + "requestFailed": "权限请求失败。", + "revokeFailed": "移除权限失败。" + } } }, "clipboardHandling": {