From 5aeff24b0bf95f0e4d8dd6bce91dc055173e35e5 Mon Sep 17 00:00:00 2001 From: Leviticus-Triage <3xodu2904@gmail.com> Date: Sun, 24 May 2026 10:01:17 +0200 Subject: [PATCH 1/3] feat(linux): AT-SPI text expander, xdg portal capture, and terminal shortcut fixes Wayland GNOME builds now read the focused field over AT-SPI instead of synthesizing clipboard keystrokes into the wrong window. Adds portal-based screenshot/OCR/color capture, restores Terminal Ctrl+Shift+C/V, and syncs the expander hotkey through gsettings with clearer diagnose logging. Co-authored-by: Cursor --- Cargo.lock | 196 +++++++++++- core/frontend/src/App.tsx | 11 + .../frontend/src/components/SettingsPanel.tsx | Bin 79578 -> 81354 bytes core/frontend/src/lib/ipc.ts | 2 +- core/rust-lib/Cargo.toml | 6 + core/rust-lib/src/cli_dispatch.rs | 23 +- core/rust-lib/src/commands.rs | 189 +++++++----- core/rust-lib/src/desktop_shortcuts.rs | 287 +++++++++++++++--- core/rust-lib/src/expander.rs | 97 +++++- core/rust-lib/src/lib.rs | 32 +- core/rust-lib/src/linux_portal.rs | 93 ++++++ core/rust-lib/src/region_picker.rs | 20 +- core/rust-lib/src/text_field/linux.rs | 186 ++++++++++++ core/rust-lib/src/text_field/mod.rs | 21 +- linux/README.md | 26 +- 15 files changed, 1026 insertions(+), 163 deletions(-) create mode 100644 core/rust-lib/src/linux_portal.rs create mode 100644 core/rust-lib/src/text_field/linux.rs diff --git a/Cargo.lock b/Cargo.lock index d9747d0f..3dfd312a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -115,6 +115,24 @@ dependencies = [ "x11rb", ] +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.4", + "serde", + "serde_repr", + "url", + "zbus", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -153,6 +171,17 @@ dependencies = [ "slab", ] +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + [[package]] name = "async-io" version = "2.6.0" @@ -182,6 +211,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + [[package]] name = "async-process" version = "2.5.0" @@ -275,6 +315,57 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atspi" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf601cccedfffec598ec2db1f9d6745885458bccc0e8916d7023f017c94b3d0" +dependencies = [ + "atspi-common", + "atspi-connection", + "atspi-proxies", + "zbus", +] + +[[package]] +name = "atspi-common" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a79bed3f5b408ce3152f36e07327a845e6ed5d7e2821a89264037dbcc11daf" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-connection" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fab8e4f574f5a7d3af280b38eff25fb6f47a537dac9ae39ce152f52b19fb10b" +dependencies = [ + "atspi-common", + "atspi-proxies", + "futures-lite", + "zbus", +] + +[[package]] +name = "atspi-proxies" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53403acd3ab2fdb5914f6558da22e540fc07656fce5510f8c02be0e6ef68413e" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + [[package]] name = "auto-launch" version = "0.5.0" @@ -2215,6 +2306,8 @@ version = "0.38.1" dependencies = [ "aes-gcm", "anyhow", + "ashpd", + "atspi", "base64 0.22.1", "block2 0.6.2", "chrono", @@ -2228,6 +2321,7 @@ dependencies = [ "ort", "oxipng", "parking_lot", + "pollster", "rand 0.8.6", "rusqlite", "serde", @@ -2243,6 +2337,7 @@ dependencies = [ "tauri-plugin-single-instance", "tracing", "tracing-subscriber", + "url", "windows 0.61.3", ] @@ -3654,6 +3749,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + [[package]] name = "polyval" version = "0.6.2" @@ -3817,6 +3918,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" dependencies = [ "memchr", + "serde", ] [[package]] @@ -3871,6 +3973,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -3891,6 +4003,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -3909,6 +4031,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -4596,6 +4727,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.8.9" @@ -7117,6 +7254,30 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + [[package]] name = "zbus_macros" version = "5.14.0" @@ -7134,12 +7295,24 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.1" +version = "4.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", - "winnow 0.7.15", + "winnow 1.0.2", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8067892e940ed1727dea64690378601603b31d62dfde019a5335fbb7c0e0ed9" +dependencies = [ + "quick-xml 0.39.2", + "serde", + "zbus_names", "zvariant", ] @@ -7258,23 +7431,24 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.10.0" +version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" dependencies = [ "endi", "enumflags2", "serde", - "winnow 0.7.15", + "url", + "winnow 1.0.2", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.10.0" +version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", @@ -7285,13 +7459,13 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.3.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" dependencies = [ "proc-macro2", "quote", "serde", "syn 2.0.117", - "winnow 0.7.15", + "winnow 1.0.2", ] diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 2d872b85..dfb55f05 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -645,6 +645,17 @@ function App() { return () => unlisten?.(); }, [refreshNotes]); + // After expander diagnose, backend re-opens the popup — stay on Settings. + useEffect(() => { + let unlisten: UnlistenFn | undefined; + (async () => { + unlisten = await listen("expander-diagnose-ready", () => { + setActiveTab("settings"); + }); + })(); + return () => unlisten?.(); + }, []); + // Backend fires this when the OCR shortcut is pressed but the // Screen Recording TCC grant is missing. Switch to Settings (which // shows the Permissions overview) and surface a banner so the diff --git a/core/frontend/src/components/SettingsPanel.tsx b/core/frontend/src/components/SettingsPanel.tsx index 2aa4731710161f6c359419adf9b48b0af5c2d252..82639f6b2fa4b01bd77510e9a89df73077ce2205 100644 GIT binary patch delta 873 zcmXw1Pe@cj7-vJYHVA?=71J*=M$n3uFy&d-Lyw{tVT{R-5 zi`{~$Hae7q2l0}&)ge3td+So&qFWJk>ekJJ&3mr5fp30)zwgK2y3adxn|H<@jF4;| znE@U~9Tyn~kwb7DSGqt2~!#ekZ@JdHw7 zW^r%{Lt9f99LWS4P|8vL-&ZJ6W;i7%9U>fZ)G&~#Z?{|3;;EY#QZ>Qrfl4CMiSwGr zKrgy)CAw#zvkxM^w&;P23Ah(li!a(w1Ry;zh#-T3HPP>q1=}G8+*eKA!<8FjGFko} z3bm-VrQ_=DN|V~Dgb$w`fQ%I-g$#P?=Ss6`a|hJ!zhmn0?|SuVsiSV3nU0!#*Q6%P zjcNni)ZB8tT3hZaKMjYv%JO8WvG2;{)YVbAsl~M2K^SvH9v8r_u1Qlsbz3uo#cS25 zYwL|_bK|Iry>3un-ycz<&s#48B&7)xl*xH8QKBb6$A;1U{58fsDx?e{g*-1DH#&=6 zo>jM9Q!-0!iN^6ri6UGQp52?yQB(BJ_VmNFt_=m(-6O;^iKH!h&%|Sy1aQ3!AO%>I zMf9{^&Z)%(GWfOh#AiEi5Js{&kSv3SUaaupX;MV!CIxrMza0-Psv%y$m$#{ZRQ*0S zrN8bO>9)mCFeW;rfl}~nPN$n8To55W(0GVrA;D-iC*m>v(o5tV?F{-v6E)jGDF15j zN=INo7_mHUrpLD%6qe38p2$i8rRuh4AaI9iZl+@s$?fU~j%sTCwCZ>mQFB&9dH6u+ zVCCbZ-6pm5s=1yB?$S>6=1+_Ivf8e8U(TG{+emUInw}nqNY+LYRHJhfgM;a0CD+Go UQnU?lbcK+Lj!0fvUcL3~Km8U+bpQYW delta 40 ycmV+@0N4M@`vltU1h893vkppcL6iC1470C~;}(==_U?h%vCqzkj%r|p%t1{MDR diff --git a/core/frontend/src/lib/ipc.ts b/core/frontend/src/lib/ipc.ts index 41309263..00e33c4e 100644 --- a/core/frontend/src/lib/ipc.ts +++ b/core/frontend/src/lib/ipc.ts @@ -470,7 +470,7 @@ export interface DiagnoseResult { matched_abbreviation: string | null; paste_preview: string | null; /** Which capture mechanism was actually used. */ - path: "ax" | "uia" | "clipboard"; + path: "ax" | "uia" | "atspi" | "clipboard"; } /** Capture the word before the cursor (select prev word + copy) and run diff --git a/core/rust-lib/Cargo.toml b/core/rust-lib/Cargo.toml index ae1f1d9e..72f4ba19 100644 --- a/core/rust-lib/Cargo.toml +++ b/core/rust-lib/Cargo.toml @@ -64,6 +64,12 @@ windows = { version = "0.61", features = [ # macOS-only Cocoa bindings for the screen color picker (NSColorSampler). # objc2 + block2 give us safe-ish msg_send + stack-allocated Objective-C # blocks without dragging in the deprecated `cocoa` crate. +[target.'cfg(target_os = "linux")'.dependencies] +ashpd = { version = "0.11", default-features = false, features = ["async-std"] } +atspi = { version = "0.30.0", default-features = true, features = ["zbus"] } +pollster = "0.4" +url = "2" + [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" block2 = "0.6" diff --git a/core/rust-lib/src/cli_dispatch.rs b/core/rust-lib/src/cli_dispatch.rs index 96ea63f9..9e59b21c 100644 --- a/core/rust-lib/src/cli_dispatch.rs +++ b/core/rust-lib/src/cli_dispatch.rs @@ -14,6 +14,8 @@ pub enum CliAction { PickColor, /// Re-scan gsettings conflicts and reinstall desktop shortcuts (Linux). SetupShortcuts, + /// Text expander: capture word before cursor and paste snippet (Linux Wayland). + ExpandAtCursor, } /// Parse `argv` (program name + flags). Returns the first recognized action. @@ -30,6 +32,7 @@ where "--pick-color" | "--color" => return Some(CliAction::PickColor), #[cfg(target_os = "linux")] "--setup-shortcuts" => return Some(CliAction::SetupShortcuts), + "--expand-at-cursor" | "--expand" => return Some(CliAction::ExpandAtCursor), "--help" | "-h" => { print_help(); return None; @@ -62,6 +65,7 @@ pub fn print_help() { --screenshot Capture region to clipboard (Ctrl+Shift+S)\n\ --pick-color Pick pixel color to clipboard (Ctrl+Shift+C)\n\ --setup-shortcuts (Linux) Re-scan shortcut conflicts and reinstall bindings\n\ + --expand-at-cursor (Linux) Run text expander once (if global hotkey does not fire)\n\ \n\ On GNOME/Cinnamon + Wayland, shortcuts are installed automatically on first start\n\ (conflict scan moves Terminal to Ctrl+C/V when needed; fallbacks if keys are taken).\n" @@ -82,7 +86,8 @@ pub fn dispatch(app: &AppHandle, action: CliAction) { Ok(r) if !r.cancelled && r.chars > 0 => { tracing::info!("--ocr: {} chars", r.chars); } - Ok(_) => tracing::debug!("--ocr: cancelled or empty"), + Ok(r) if r.cancelled => tracing::info!("--ocr: cancelled by user"), + Ok(_) => tracing::warn!("--ocr: empty result (no text in region?)"), Err(e) => tracing::warn!("--ocr failed: {e}"), }); } @@ -92,7 +97,8 @@ pub fn dispatch(app: &AppHandle, action: CliAction) { Ok(r) if !r.cancelled && r.bytes > 0 => { tracing::info!("--screenshot: {} bytes", r.bytes); } - Ok(_) => tracing::debug!("--screenshot: cancelled or empty"), + Ok(r) if r.cancelled => tracing::info!("--screenshot: cancelled by user"), + Ok(_) => tracing::warn!("--screenshot: empty capture"), Err(e) => tracing::warn!("--screenshot failed: {e}"), }); } @@ -115,6 +121,19 @@ pub fn dispatch(app: &AppHandle, action: CliAction) { CliAction::SetupShortcuts => { tracing::warn!("--setup-shortcuts is only available on Linux"); } + CliAction::ExpandAtCursor => { + hotkey::hide_popup(app); + let app2 = app.clone(); + let _ = app.run_on_main_thread(move || { + std::thread::sleep(std::time::Duration::from_millis(250)); + if let Some(db) = app2.try_state::() { + match crate::expander::expand_at_cursor(&db) { + Ok(()) => tracing::info!("--expand-at-cursor: expansion completed"), + Err(e) => tracing::warn!("--expand-at-cursor failed: {e:#}"), + } + } + }); + } } } diff --git a/core/rust-lib/src/commands.rs b/core/rust-lib/src/commands.rs index b146c0fd..41a4bb7d 100644 --- a/core/rust-lib/src/commands.rs +++ b/core/rust-lib/src/commands.rs @@ -6,6 +6,7 @@ use crate::backup::{self, BackupImportResult}; use crate::clipboard_watcher::WatcherState; use crate::cutout_ml; use crate::db::{self, DbHandle}; +use crate::desktop_shortcuts; use crate::expander; use crate::hotkey::{self, ExpanderShortcutState}; use crate::models::ClipEntry; @@ -18,8 +19,6 @@ use crate::screen_recording; use crate::seed; use crate::settings; use crate::snippets::{self, ImportResult, Snippet}; -#[cfg(target_os = "linux")] -use crate::desktop_shortcuts; use crate::ui_state::UiState; fn map_err(e: E) -> String { @@ -145,10 +144,7 @@ pub fn get_paste_plain_text_only(db: State<'_, DbHandle>) -> Result, - value: bool, -) -> Result<(), String> { +pub fn set_paste_plain_text_only(db: State<'_, DbHandle>, value: bool) -> Result<(), String> { settings::set( &db, KEY_PLAIN_TEXT_ONLY, @@ -167,10 +163,7 @@ pub fn get_ocr_save_source_image(db: State<'_, DbHandle>) -> Result, - value: bool, -) -> Result<(), String> { +pub fn set_ocr_save_source_image(db: State<'_, DbHandle>, value: bool) -> Result<(), String> { settings::set( &db, KEY_OCR_SAVE_SOURCE, @@ -199,10 +192,7 @@ pub fn get_input_lock_chord(db: State<'_, DbHandle>) -> Result, Stri /// chords so the user can never lock themselves out by saving an /// unusable chord. #[tauri::command] -pub fn set_input_lock_chord( - db: State<'_, DbHandle>, - keys: Vec, -) -> Result<(), String> { +pub fn set_input_lock_chord(db: State<'_, DbHandle>, keys: Vec) -> Result<(), String> { if keys.is_empty() { return Err("chord cannot be empty".into()); } @@ -212,18 +202,14 @@ pub fn set_input_lock_chord( if !any_valid { return Err("chord contains no recognised keys".into()); } - let json = - serde_json::to_string(&keys).map_err(|e| format!("serialise chord: {e}"))?; + let json = serde_json::to_string(&keys).map_err(|e| format!("serialise chord: {e}"))?; settings::set(&db, KEY_INPUT_LOCK_CHORD, &json).map_err(map_err) } /// Activate the input lock. Reads the persisted unlock chord from /// settings and hands it to `input_lock::start_input_lock`. #[tauri::command] -pub fn start_input_lock( - db: State<'_, DbHandle>, - app: AppHandle, -) -> Result<(), String> { +pub fn start_input_lock(db: State<'_, DbHandle>, app: AppHandle) -> Result<(), String> { let chord = get_input_lock_chord(db)?; // Hide the popup so the user isn't visually staring at an open // window that can no longer accept clicks. @@ -350,10 +336,7 @@ pub fn get_theme_preference(db: State<'_, DbHandle>) -> Result { /// Persist the theme preference. Rejects anything that isn't one of /// the three valid values rather than silently storing garbage. #[tauri::command] -pub fn set_theme_preference( - db: State<'_, DbHandle>, - theme: String, -) -> Result<(), String> { +pub fn set_theme_preference(db: State<'_, DbHandle>, theme: String) -> Result<(), String> { let normalised = normalise_theme(&theme); if normalised != theme { return Err(format!( @@ -449,10 +432,7 @@ pub fn list_snippets(db: State<'_, DbHandle>) -> Result, String> { } #[tauri::command] -pub fn find_snippets( - db: State<'_, DbHandle>, - query: String, -) -> Result, String> { +pub fn find_snippets(db: State<'_, DbHandle>, query: String) -> Result, String> { snippets::find_by_query(&db, &query).map_err(map_err) } @@ -468,8 +448,7 @@ pub fn upsert_snippet( match id { None => snippets::create(&db, &abbreviation, &title, &body).map_err(map_err), Some(existing_id) => { - snippets::update(&db, existing_id, &abbreviation, &title, &body) - .map_err(map_err)?; + snippets::update(&db, existing_id, &abbreviation, &title, &body).map_err(map_err)?; Ok(existing_id) } } @@ -505,10 +484,7 @@ pub fn paste_snippet( /// abbreviation are overwritten. Per-row errors are returned in the result /// instead of aborting the whole import. #[tauri::command] -pub fn import_snippets( - db: State<'_, DbHandle>, - json: String, -) -> Result { +pub fn import_snippets(db: State<'_, DbHandle>, json: String) -> Result { snippets::import_from_json(&db, &json).map_err(map_err) } @@ -519,8 +495,7 @@ pub fn import_snippets_from_file( db: State<'_, DbHandle>, path: String, ) -> Result { - let json = std::fs::read_to_string(&path) - .map_err(|e| format!("read {path}: {e}"))?; + let json = std::fs::read_to_string(&path).map_err(|e| format!("read {path}: {e}"))?; snippets::import_from_json(&db, &json).map_err(map_err) } @@ -642,8 +617,7 @@ pub fn paste_note_formatted( hotkey::hide_popup(&app); watcher.mark_self_write(note.content_type, ¬e.content_data); - paste::paste_payload(note.content_type, ¬e.content_data, ¬e.content_text) - .map_err(map_err) + paste::paste_payload(note.content_type, ¬e.content_data, ¬e.content_text).map_err(map_err) } // ── Backup (full app export / import) ──────────────────────────────────────── @@ -693,12 +667,8 @@ pub fn save_backup_to_file( /// (snippets upsert by abbreviation, history dedupes by hash, notes are /// appended). #[tauri::command] -pub fn import_backup( - db: State<'_, DbHandle>, - path: String, -) -> Result { - let json = std::fs::read_to_string(&path) - .map_err(|e| format!("read {path}: {e}"))?; +pub fn import_backup(db: State<'_, DbHandle>, path: String) -> Result { + let json = std::fs::read_to_string(&path).map_err(|e| format!("read {path}: {e}"))?; backup::import_json(&db, &json).map_err(map_err) } @@ -718,8 +688,8 @@ pub struct ExpanderConfig { #[tauri::command] pub fn get_expander_config(db: State<'_, DbHandle>) -> Result { let enabled = settings::get_bool(&db, expander::KEY_ENABLED, false).map_err(map_err)?; - let hotkey = settings::get_or(&db, expander::KEY_HOTKEY, expander::DEFAULT_HOTKEY) - .map_err(map_err)?; + let hotkey = + settings::get_or(&db, expander::KEY_HOTKEY, expander::DEFAULT_HOTKEY).map_err(map_err)?; Ok(ExpanderConfig { enabled, hotkey, @@ -1049,6 +1019,9 @@ pub fn set_expander_config( // don't touch the persisted settings. hotkey::register_expander(&app, &state, &hotkey, enabled).map_err(map_err)?; + #[cfg(target_os = "linux")] + desktop_shortcuts::sync_expander_shortcut(&db, enabled, &hotkey).map_err(map_err)?; + settings::set(&db, expander::KEY_HOTKEY, &hotkey).map_err(map_err)?; settings::set( &db, @@ -1098,9 +1071,7 @@ pub fn trigger_expand_at_cursor(app: AppHandle) -> Result<(), String> { /// blocking `mpsc` to ferry the result back from the main-thread closure /// to the IPC handler thread. #[tauri::command] -pub fn diagnose_expand_at_cursor( - app: AppHandle, -) -> Result { +pub fn diagnose_expand_at_cursor(app: AppHandle) -> Result { hotkey::hide_popup(&app); let app2 = app.clone(); let (tx, rx) = std::sync::mpsc::channel(); @@ -1113,8 +1084,26 @@ pub fn diagnose_expand_at_cursor( let _ = tx.send(result); }) .map_err(|e| format!("dispatch to main thread: {e}"))?; - rx.recv() - .map_err(|e| format!("main thread didn't reply: {e}"))? + let result = rx + .recv() + .map_err(|e| format!("main thread didn't reply: {e}"))?; + match &result { + Ok(data) => { + tracing::info!( + "expander diagnose: path={:?} captured={:?} matched={:?}", + data.path, + data.captured, + data.matched_abbreviation + ); + let _ = app.emit("expander-diagnose-result", data); + let _ = app.emit("expander-diagnose-ready", ()); + } + Err(e) => tracing::warn!("expander diagnose failed: {e}"), + } + // Re-open the popup on Settings so the user sees the result (hide_popup + // alone makes it look like the app crashed). + let _ = hotkey::show_popup(&app); + result } // ── Direct hotkey → snippet slots ─────────────────────────────────────────── @@ -1171,7 +1160,10 @@ pub fn set_direct_slots( }) .collect(); for s in &parsed { - if snippets::get_by_id(&db, s.snippet_id).map_err(map_err)?.is_none() { + if snippets::get_by_id(&db, s.snippet_id) + .map_err(map_err)? + .is_none() + { return Err(format!("snippet id {} no longer exists", s.snippet_id)); } } @@ -1223,7 +1215,10 @@ pub fn recolor_image_entry( // Use the brightness/dimensions plus the chosen tint as the // human-readable preview line. Keeps it visually distinct from the // source entry in the history list. - let summary = format!("[image · tinted #{}]", hex.trim_start_matches('#').to_uppercase()); + let summary = format!( + "[image · tinted #{}]", + hex.trim_start_matches('#').to_uppercase() + ); let new_id = db::upsert_clip( &db, @@ -1245,10 +1240,7 @@ pub fn recolor_image_entry( /// is in [0, 1] — frontend treats anything below ~0.1 as "looks /// monochrome, recolor button worth showing". #[tauri::command] -pub fn image_chromaticity( - db: State<'_, DbHandle>, - id: i64, -) -> Result { +pub fn image_chromaticity(db: State<'_, DbHandle>, id: i64) -> Result { use base64::{engine::general_purpose::STANDARD as B64, Engine}; let entry = db::get(&db, id) @@ -1309,7 +1301,11 @@ pub fn run_ocr_pipeline(app: &AppHandle) -> Result { Err(e) => { // Distinguish "user cancelled" from a real error. if e.downcast_ref::().is_some() { - return Ok(OcrResult { text: String::new(), cancelled: true, chars: 0 }); + return Ok(OcrResult { + text: String::new(), + cancelled: true, + chars: 0, + }); } return Err(format!("region capture failed: {e:#}")); } @@ -1318,7 +1314,11 @@ pub fn run_ocr_pipeline(app: &AppHandle) -> Result { let text = ocr::recognize(&png_bytes).map_err(|e| format!("ocr failed: {e:#}"))?; let trimmed = text.trim(); if trimmed.is_empty() { - return Ok(OcrResult { text: String::new(), cancelled: false, chars: 0 }); + return Ok(OcrResult { + text: String::new(), + cancelled: false, + chars: 0, + }); } // Write to system clipboard. Mark first so the watcher doesn't @@ -1326,8 +1326,7 @@ pub fn run_ocr_pipeline(app: &AppHandle) -> Result { if let Some(watcher) = app.try_state::() { watcher.mark_self_write(crate::models::ContentType::Text, trimmed); } - let ctx = ClipboardContext::new() - .map_err(|e| format!("clipboard ctx init: {e:?}"))?; + let ctx = ClipboardContext::new().map_err(|e| format!("clipboard ctx init: {e:?}"))?; ctx.set_text(trimmed.to_string()) .map_err(|e| format!("set_text: {e:?}"))?; @@ -1370,7 +1369,11 @@ pub fn run_ocr_pipeline(app: &AppHandle) -> Result { let _ = app.emit("clipboard-changed", ()); let chars = trimmed.chars().count(); - Ok(OcrResult { text: trimmed.to_string(), cancelled: false, chars }) + Ok(OcrResult { + text: trimmed.to_string(), + cancelled: false, + chars, + }) } /// IPC entry point — the menu / button caller. Dispatched to a thread @@ -1423,7 +1426,10 @@ pub fn run_screenshot_pipeline(app: &AppHandle) -> Result b, Err(e) => { if e.downcast_ref::().is_some() { - return Ok(ScreenshotResult { cancelled: true, bytes: 0 }); + return Ok(ScreenshotResult { + cancelled: true, + bytes: 0, + }); } return Err(format!("region capture failed: {e:#}")); } @@ -1453,9 +1459,7 @@ pub fn run_screenshot_pipeline(app: &AppHandle) -> Result() { watcher.mark_self_write(crate::models::ContentType::Image, &b64); @@ -1513,7 +1517,10 @@ pub fn run_screenshot_pipeline(app: &AppHandle) -> Result write_eyedropper_result(&app_for_thread, &hex), + Ok(None) => tracing::debug!("color pick cancelled"), + Err(e) => tracing::warn!("eyedropper pipeline (portal): {e:#}"), + } + clear_eyedropper_no_popup(&app_for_thread); + }); + } + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] { clear_eyedropper_no_popup(app); } @@ -1843,10 +1862,21 @@ pub fn strip_vowels(s: &str) -> String { .filter(|c| { !matches!( c, - 'a' | 'e' | 'i' | 'o' | 'u' - | 'A' | 'E' | 'I' | 'O' | 'U' - | 'ä' | 'ö' | 'ü' - | 'Ä' | 'Ö' | 'Ü' + 'a' | 'e' + | 'i' + | 'o' + | 'u' + | 'A' + | 'E' + | 'I' + | 'O' + | 'U' + | 'ä' + | 'ö' + | 'ü' + | 'Ä' + | 'Ö' + | 'Ü' ) }) .collect() @@ -1984,10 +2014,7 @@ fn clear_eyedropper_no_popup(app: &AppHandle) { /// this is a "save the cutout to a file" action, not a clipboard /// modification. #[tauri::command] -pub fn cut_out_image_entry( - db: State<'_, DbHandle>, - id: i64, -) -> Result { +pub fn cut_out_image_entry(db: State<'_, DbHandle>, id: i64) -> Result { use base64::{engine::general_purpose::STANDARD as B64, Engine}; let entry = db::get(&db, id) @@ -2009,10 +2036,7 @@ pub fn cut_out_image_entry( /// recolor. Particularly useful after a recolor since the new tinted /// entry only lives in the SQLite history otherwise. #[tauri::command] -pub fn save_image_entry_to_downloads( - db: State<'_, DbHandle>, - id: i64, -) -> Result { +pub fn save_image_entry_to_downloads(db: State<'_, DbHandle>, id: i64) -> Result { use base64::{engine::general_purpose::STANDARD as B64, Engine}; use chrono::Local; @@ -2114,10 +2138,7 @@ pub fn linux_apply_desktop_shortcuts( db: State<'_, DbHandle>, bindings: Vec, ) -> Result<(), String> { - let pairs: Vec<(String, String)> = bindings - .into_iter() - .map(|b| (b.id, b.binding)) - .collect(); + let pairs: Vec<(String, String)> = bindings.into_iter().map(|b| (b.id, b.binding)).collect(); desktop_shortcuts::apply_shortcut_setup(&db, pairs).map_err(map_err) } diff --git a/core/rust-lib/src/desktop_shortcuts.rs b/core/rust-lib/src/desktop_shortcuts.rs index 21f6731a..4db81807 100644 --- a/core/rust-lib/src/desktop_shortcuts.rs +++ b/core/rust-lib/src/desktop_shortcuts.rs @@ -116,11 +116,13 @@ mod imp { }, ]; - /// GNOME Terminal defaults that steal Inspector's Ctrl+Shift+C/V. + /// GNOME Terminal defaults — Inspector must **not** steal these; pick fallbacks instead. const TERMINAL_SHIFT_COPY: &str = "c"; const TERMINAL_SHIFT_PASTE: &str = "v"; - const TERMINAL_STD_COPY: &str = "c"; - const TERMINAL_STD_PASTE: &str = "v"; + /// Broken state from an earlier Inspector build that wrongly moved Terminal here. + /// In terminals Ctrl+C is SIGINT, not copy — never assign these as copy/paste. + const TERMINAL_BROKEN_COPY: &str = "c"; + const TERMINAL_BROKEN_PASTE: &str = "v"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LinuxDesktop { @@ -286,9 +288,10 @@ mod imp { Ok(occupied) } - /// Move GNOME Terminal off Ctrl+Shift+C/V when that would block Inspector (or is non-standard). - /// Returns number of profiles updated. - pub(crate) fn reconcile_terminal_copy_paste() -> Result { + /// Restore GNOME Terminal copy/paste to Ctrl+Shift+C/V when a previous + /// Inspector build wrongly moved them to Ctrl+C/V (SIGINT / broken paste). + /// Idempotent — safe to call on every startup. + pub(crate) fn restore_terminal_copy_paste() -> Result { let list = gsettings(&["get", "org.gnome.Terminal.ProfilesList", "list"])?; let mut changed = 0u32; for uuid in parse_gsettings_array(&list) { @@ -297,26 +300,116 @@ mod imp { ); let copy = gsettings(&["get", &schema, "copy"]).unwrap_or_default(); let paste = gsettings(&["get", &schema, "paste"]).unwrap_or_default(); - let needs_copy = bindings_conflict(©, TERMINAL_SHIFT_COPY) - || bindings_conflict(©, "C"); - let needs_paste = bindings_conflict(&paste, TERMINAL_SHIFT_PASTE) - || bindings_conflict(&paste, "V"); + let needs_copy = bindings_conflict(©, TERMINAL_BROKEN_COPY) + || bindings_conflict(©, "C"); + let needs_paste = bindings_conflict(&paste, TERMINAL_BROKEN_PASTE) + || bindings_conflict(&paste, "V"); if needs_copy || needs_paste { if needs_copy { - gsettings_set(&["set", &schema, "copy", TERMINAL_STD_COPY])?; + gsettings_set(&["set", &schema, "copy", TERMINAL_SHIFT_COPY])?; } if needs_paste { - gsettings_set(&["set", &schema, "paste", TERMINAL_STD_PASTE])?; + gsettings_set(&["set", &schema, "paste", TERMINAL_SHIFT_PASTE])?; } changed += 1; tracing::info!( - "Terminal profile {uuid}: copy/paste moved to Ctrl+C / Ctrl+V (frees Ctrl+Shift+C/V for Inspector)" + "Terminal profile {uuid}: restored copy/paste to Ctrl+Shift+C / Ctrl+Shift+V" ); } } Ok(changed) } + /// After restoring Terminal keys, move any Inspector shortcut that still + /// collides with Terminal copy/paste (Ctrl+Shift+C/V) to the next free preset. + pub fn restore_terminal_and_fix_shortcut_conflicts(db: &DbHandle) -> Result<()> { + let restored = restore_terminal_copy_paste()?; + if restored > 0 { + tracing::info!( + "Restored {restored} GNOME Terminal profile(s) — Ctrl+Shift+C/V is copy/paste again" + ); + } + + let desktop = detect(); + let Some((list_key, list_schema, custom_schema, path_prefix, id_prefix)) = + gnome_family_config(desktop) + else { + return Ok(()); + }; + + let terminal_occupied = collect_terminal_bindings()?; + let installed = read_our_installed_bindings( + list_schema, + list_key, + custom_schema, + path_prefix, + id_prefix, + )?; + if installed.is_empty() { + return Ok(()); + } + + let mut occupied = + collect_custom_shortcut_bindings(list_schema, list_key, custom_schema, id_prefix)?; + occupied.extend(terminal_occupied.iter().cloned()); + + let mut reserved = HashSet::new(); + let mut resolved: Vec<(String, String)> = Vec::new(); + let mut changed = false; + + for spec in SHORTCUTS { + let current = installed + .iter() + .find(|(id, _)| id == spec.id) + .map(|(_, b)| b.clone()) + .unwrap_or_else(|| spec.binding_candidates[0].to_string()); + + let norm = normalize_binding(¤t); + let collides = terminal_occupied.contains(&norm); + + let chosen = if collides || norm.is_empty() { + pick_binding(spec.binding_candidates, &occupied, &reserved).with_context(|| { + format!("no free binding for {} after Terminal restore", spec.name) + })? + } else { + current.clone() + }; + + let chosen_norm = normalize_binding(&chosen); + if chosen_norm != norm { + changed = true; + tracing::info!( + "{} moved {} → {} (Terminal reserves Ctrl+Shift+C/V)", + spec.name, + binding_label(¤t), + binding_label(&chosen) + ); + } + + if !chosen_norm.is_empty() { + reserved.insert(chosen_norm.clone()); + occupied.insert(chosen_norm); + } + + let path = format!("{path_prefix}{id_prefix}{}/", spec.id); + let schema = format!("{custom_schema}:{path}"); + gsettings_set(&["set", &schema, "binding", &chosen])?; + resolved.push((spec.id.to_string(), chosen)); + } + + if changed { + let summary: String = resolved + .iter() + .map(|(id, b)| format!("{id}={b}")) + .collect::>() + .join(","); + settings::set(db, SETTINGS_BINDINGS_KEY, &summary)?; + tracing::info!("Updated Inspector desktop shortcuts to avoid Terminal copy/paste"); + } + + Ok(()) + } + fn pick_binding( candidates: &[&str], occupied: &HashSet, @@ -392,6 +485,19 @@ mod imp { "Space" => key = "space".into(), "Tab" => key = "Tab".into(), "Escape" => key = "Escape".into(), + "Backquote" => key = "grave".into(), + "Less" => key = "less".into(), + "Minus" => key = "minus".into(), + "Equal" => key = "equal".into(), + "BracketLeft" => key = "bracketleft".into(), + "BracketRight" => key = "bracketright".into(), + "Backslash" => key = "backslash".into(), + "Semicolon" => key = "semicolon".into(), + "Quote" => key = "apostrophe".into(), + "Comma" => key = "comma".into(), + "Period" => key = "period".into(), + "Slash" => key = "slash".into(), + "IntlBackslash" => key = "section".into(), "F1" | "F2" | "F3" | "F4" | "F5" | "F6" | "F7" | "F8" | "F9" | "F10" | "F11" | "F12" => key = p.to_lowercase(), s if s.len() == 1 => key = s.to_lowercase(), @@ -407,7 +513,7 @@ mod imp { Ok(format!("{}{}", mods.concat(), key)) } - fn count_terminal_profiles_needing_fix() -> Result { + fn count_terminal_profiles_needing_restore() -> Result { let list = gsettings(&["get", "org.gnome.Terminal.ProfilesList", "list"])?; let mut count = 0u32; for uuid in parse_gsettings_array(&list) { @@ -416,11 +522,11 @@ mod imp { ); let copy = gsettings(&["get", &schema, "copy"]).unwrap_or_default(); let paste = gsettings(&["get", &schema, "paste"]).unwrap_or_default(); - let needs_copy = bindings_conflict(©, TERMINAL_SHIFT_COPY) - || bindings_conflict(©, "C"); - let needs_paste = bindings_conflict(&paste, TERMINAL_SHIFT_PASTE) - || bindings_conflict(&paste, "V"); - if needs_copy || needs_paste { + let broken_copy = bindings_conflict(©, TERMINAL_BROKEN_COPY) + || bindings_conflict(©, "C"); + let broken_paste = bindings_conflict(&paste, TERMINAL_BROKEN_PASTE) + || bindings_conflict(&paste, "V"); + if broken_copy || broken_paste { count += 1; } } @@ -581,7 +687,7 @@ mod imp { return Ok(base()); }; - let terminal_profiles_to_fix = count_terminal_profiles_needing_fix()?; + let terminal_profiles_to_fix = count_terminal_profiles_needing_restore()?; let occupied = collect_occupied_for_scan(list_schema, list_key, custom_schema, id_prefix)?; let installed: std::collections::HashMap = read_our_installed_bindings( @@ -657,8 +763,9 @@ mod imp { rows, message: if terminal_profiles_to_fix > 0 { Some(format!( - "{terminal_profiles_to_fix} GNOME Terminal profile(s) still use Ctrl+Shift+C/V — \ - saving will move them to Ctrl+C / Ctrl+V automatically." + "{terminal_profiles_to_fix} GNOME Terminal profile(s) still have broken Ctrl+C/V copy/paste \ + from an older Inspector build — saving will restore Ctrl+Shift+C/V and move Inspector \ + shortcuts to free fallbacks." )) } else { None @@ -723,10 +830,7 @@ mod imp { anyhow::bail!("desktop does not support gsettings shortcut configuration"); }; - let term_changed = reconcile_terminal_copy_paste()?; - if term_changed > 0 { - tracing::info!("Adjusted {term_changed} GNOME Terminal profile(s) to Ctrl+C / Ctrl+V"); - } + restore_terminal_copy_paste()?; let mut occupied = collect_custom_shortcut_bindings(list_schema, list_key, custom_schema, id_prefix)?; @@ -787,12 +891,7 @@ mod imp { path_prefix: &str, id_prefix: &str, ) -> Result<()> { - let term_changed = reconcile_terminal_copy_paste()?; - if term_changed > 0 { - tracing::info!( - "Adjusted {term_changed} GNOME Terminal profile(s) to Ctrl+C / Ctrl+V after shortcut conflict scan" - ); - } + restore_terminal_copy_paste()?; let mut occupied = collect_custom_shortcut_bindings(list_schema, list_key, custom_schema, id_prefix)?; @@ -917,6 +1016,96 @@ mod imp { Ok(()) } + const EXPANDER_SHORTCUT_ID: &str = "expander"; + const EXPANDER_SHORTCUT_NAME: &str = "Inspector Rust — Text expander"; + + /// True when the text-expander hotkey is registered via gsettings (GNOME/Cinnamon + /// Wayland), not Tauri's in-app global shortcut. + pub fn expander_hotkey_needs_gsettings() -> bool { + !matches!(detect(), LinuxDesktop::X11 | LinuxDesktop::Kde) + } + + /// Register (or remove) the text-expander hotkey in GNOME/Cinnamon gsettings. + /// On Wayland, Tauri global shortcuts do not fire — this is the only path that + /// makes the expander hotkey work without manual GNOME Settings setup. + pub fn sync_expander_shortcut(_db: &DbHandle, enabled: bool, hotkey: &str) -> Result<()> { + let desktop = detect(); + let Some((list_key, list_schema, custom_schema, path_prefix, id_prefix)) = + gnome_family_config(desktop) + else { + return Ok(()); + }; + + let path = format!("{path_prefix}{id_prefix}{EXPANDER_SHORTCUT_ID}/"); + let mut paths = parse_gsettings_array(&gsettings(&["get", list_schema, list_key])?); + + if !enabled { + if paths.contains(&path) { + paths.retain(|p| p != &path); + gsettings_set(&[ + "set", + list_schema, + list_key, + &format_gsettings_array(&paths), + ])?; + tracing::info!("Removed desktop shortcut for text expander"); + } + return Ok(()); + } + + let gsettings_binding = web_hotkey_to_gsettings(hotkey).map_err(anyhow::Error::msg)?; + let norm = normalize_binding(&gsettings_binding); + if norm.is_empty() { + anyhow::bail!("invalid expander hotkey"); + } + + let mut occupied = + collect_custom_shortcut_bindings(list_schema, list_key, custom_schema, id_prefix)?; + occupied.extend(collect_terminal_bindings()?); + + let schema = format!("{custom_schema}:{path}"); + let current_norm = if paths.contains(&path) { + gsettings(&["get", &schema, "binding"]) + .ok() + .map(|b| normalize_binding(&b)) + } else { + None + }; + + if occupied.contains(&norm) && current_norm.as_ref() != Some(&norm) { + anyhow::bail!( + "{} is already used — pick another hotkey (try Alt+1)", + binding_label(&gsettings_binding) + ); + } + + let cmd = inspector_command(); + if !paths.contains(&path) { + paths.push(path.clone()); + } + gsettings_set(&["set", &schema, "name", EXPANDER_SHORTCUT_NAME])?; + gsettings_set(&[ + "set", + &schema, + "command", + &format!("{cmd} --expand-at-cursor"), + ])?; + gsettings_set(&["set", &schema, "binding", &gsettings_binding])?; + gsettings_set(&[ + "set", + list_schema, + list_key, + &format_gsettings_array(&paths), + ])?; + tracing::info!( + "{} → {} ({})", + EXPANDER_SHORTCUT_NAME, + binding_label(&gsettings_binding), + gsettings_binding + ); + Ok(()) + } + #[cfg(test)] mod tests { use super::{bindings_conflict, normalize_binding, pick_binding}; @@ -948,13 +1137,28 @@ mod imp { Some("c") ); } + + #[test] + fn web_hotkey_converts_backquote() { + use super::web_hotkey_to_gsettings; + assert_eq!( + web_hotkey_to_gsettings("Ctrl+Backquote").unwrap(), + "grave" + ); + assert_eq!(web_hotkey_to_gsettings("Alt+Digit1").unwrap(), "1"); + assert_eq!( + web_hotkey_to_gsettings("Ctrl+Less").unwrap(), + "less" + ); + } } } #[cfg(target_os = "linux")] pub use imp::{ - apply_shortcut_setup, force_reinstall, scan_shortcut_setup, try_auto_install, - web_hotkey_to_gsettings, ShortcutCandidate, ShortcutRow, ShortcutSetupScan, + apply_shortcut_setup, expander_hotkey_needs_gsettings, force_reinstall, + restore_terminal_and_fix_shortcut_conflicts, scan_shortcut_setup, sync_expander_shortcut, + try_auto_install, web_hotkey_to_gsettings, ShortcutSetupScan, }; #[cfg(not(target_os = "linux"))] @@ -968,6 +1172,21 @@ pub fn detect() -> LinuxDesktop { LinuxDesktop::Other } +#[cfg(not(target_os = "linux"))] +pub fn expander_hotkey_needs_gsettings() -> bool { + false +} + +#[cfg(not(target_os = "linux"))] +pub fn restore_terminal_and_fix_shortcut_conflicts(_db: &DbHandle) -> anyhow::Result<()> { + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +pub fn sync_expander_shortcut(_db: &DbHandle, _enabled: bool, _hotkey: &str) -> anyhow::Result<()> { + Ok(()) +} + #[cfg(not(target_os = "linux"))] pub fn try_auto_install(_db: &DbHandle) -> anyhow::Result<()> { Ok(()) diff --git a/core/rust-lib/src/expander.rs b/core/rust-lib/src/expander.rs index 3d032adf..f027d14f 100644 --- a/core/rust-lib/src/expander.rs +++ b/core/rust-lib/src/expander.rs @@ -122,11 +122,15 @@ pub fn accessibility_granted() -> bool { } /// Whether the OS-level synthetic-input permission is active. -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "linux")] +pub fn accessibility_granted() -> bool { + // Always attempt AT-SPI first; fall back to the clipboard path when it + // returns None (same policy as Windows UI Automation). + true +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] pub fn accessibility_granted() -> bool { - // Other platforms either don't gate synthetic input behind a TCC-style - // permission (Windows, X11) or do so through an entirely different - // mechanism (Wayland portals). Optimistic default. true } @@ -343,6 +347,9 @@ pub const KEY_ENABLED: &str = "expander.enabled"; /// migrated to [`DEFAULT_HOTKEY`]. Prevents re-migrating a value the user /// deliberately set back to `Alt+Backquote` afterwards. pub const KEY_HOTKEY_MIGRATED: &str = "expander.hotkey_migrated_v0_12"; +/// One-shot flag: on Linux Wayland, migrate layout-sensitive hotkeys +/// (`Ctrl+Backquote`, German `<` key confusion) to [`DEFAULT_HOTKEY`]. +pub const KEY_HOTKEY_MIGRATED_LINUX: &str = "expander.hotkey_migrated_linux_v1"; /// Default hotkey when no setting has ever been written. `Alt + Digit1` /// (the `1` row key, **not** the numpad) is layout-stable on every @@ -462,7 +469,9 @@ pub struct DirectSlot { /// will let the user re-add them) rather than a hard error. pub fn get_direct_slots(db: &DbHandle) -> Result> { match crate::settings::get(db, KEY_DIRECT_SLOTS)? { - Some(json) if !json.trim().is_empty() => Ok(serde_json::from_str(&json).unwrap_or_default()), + Some(json) if !json.trim().is_empty() => { + Ok(serde_json::from_str(&json).unwrap_or_default()) + } _ => Ok(Vec::new()), } } @@ -606,6 +615,52 @@ pub fn migrate_legacy_default(db: &DbHandle) -> String { upgraded } +/// Linux Wayland hotkeys are registered via GNOME gsettings, not Tauri. +/// Keys like `Ctrl+Backquote` map to `grave`, which is **not** +/// the German `<` key (`less`) — the shortcut looks armed but never fires. +/// Migrate known-broken combos once to the layout-stable default. +#[cfg(target_os = "linux")] +pub fn migrate_linux_wayland_hotkey(db: &DbHandle, stored: String) -> String { + use crate::desktop_shortcuts::expander_hotkey_needs_gsettings; + use crate::settings; + + if settings::get_bool(db, KEY_HOTKEY_MIGRATED_LINUX, false).unwrap_or(false) { + return stored; + } + + if !expander_hotkey_needs_gsettings() { + let _ = settings::set(db, KEY_HOTKEY_MIGRATED_LINUX, "true"); + return stored; + } + + const BROKEN: &[&str] = &[ + "Ctrl+Backquote", + "Control+Backquote", + "Alt+Backquote", + "Ctrl+IntlBackslash", + "Alt+IntlBackslash", + ]; + + let upgraded = if BROKEN.iter().any(|b| stored.eq_ignore_ascii_case(b)) { + let _ = settings::set(db, KEY_HOTKEY, DEFAULT_HOTKEY); + tracing::info!( + "Linux Wayland: migrated expander hotkey {stored} → {DEFAULT_HOTKEY} \ + (Backquote/grave does not match the German < key in gsettings; use Alt+1)" + ); + DEFAULT_HOTKEY.to_string() + } else { + stored + }; + + let _ = settings::set(db, KEY_HOTKEY_MIGRATED_LINUX, "true"); + upgraded +} + +#[cfg(not(target_os = "linux"))] +pub fn migrate_linux_wayland_hotkey(_db: &DbHandle, stored: String) -> String { + stored +} + /// Diagnostic outcome of an expand-cycle attempt: what got captured, /// whether it matched a snippet, and a preview of what would be pasted. /// Used by the Settings panel's "Test now" button so the user can see @@ -695,12 +750,7 @@ pub fn diagnose_at_cursor(db: &DbHandle) -> Result { if !captured.is_empty() { if let Some(snippet) = snippets::find_by_exact_abbreviation(db, &captured)? { // First 80 chars of the body, single-line preview. - let preview: String = snippet - .body - .replace('\n', " ") - .chars() - .take(80) - .collect(); + let preview: String = snippet.body.replace('\n', " ").chars().take(80).collect(); result.matched_abbreviation = Some(snippet.abbreviation); result.paste_preview = Some(preview); } @@ -777,7 +827,14 @@ pub fn expand_at_cursor( if let Some(snippet) = snippets::find_by_exact_abbreviation(db, &word)? { // Try the in-place replace via the same accessibility layer. match access.try_replace_word_before_cursor(&snippet.body) { - Ok(ReplaceOutcome::Replaced) => return Ok(()), + Ok(ReplaceOutcome::Replaced) => { + tracing::info!( + "expander: matched snippet {:?} via {:?}", + snippet.abbreviation, + native_path() + ); + return Ok(()); + } Ok(ReplaceOutcome::SelectionActive) => { // The AX layer *selected* the abbreviation but the // in-place text set was a no-op — the typical @@ -893,6 +950,10 @@ fn expand_via_clipboard( trim_abbreviation(&abbr_raw) }; if abbr.is_empty() { + tracing::info!( + "expander: no abbreviation captured before cursor (clipboard path empty — \ + keystroke synthesis may not reach this app on Wayland, or nothing to select)" + ); restore_clipboard(saved.as_deref()); return Ok(()); } @@ -903,11 +964,16 @@ fn expand_via_clipboard( } else { let hit = snippets::find_by_exact_abbreviation(db, abbr)?; let Some(snippet) = hit else { + tracing::info!("expander: captured {abbr:?} but no snippet matches that abbreviation"); // Selection stays highlighted in the source app — visual cue // that nothing matched. Restore clipboard before bailing. restore_clipboard(saved.as_deref()); return Ok(()); }; + tracing::info!( + "expander: matched snippet {:?} via clipboard path", + snippet.abbreviation + ); snippet.body }; @@ -921,6 +987,10 @@ fn expand_via_clipboard( write_clipboard_text(&body)?; thread::sleep(Duration::from_millis(50)); send_paste()?; + tracing::info!( + "expander: pasted snippet body ({body_len} chars)", + body_len = body.len() + ); // 6) Background restore (v0.35.0+). Same shape as // `paste_over_selection`: return immediately after paste, then @@ -1027,8 +1097,7 @@ fn read_clipboard_text() -> Option { } fn write_clipboard_text(text: &str) -> Result<()> { - let ctx = ClipboardContext::new() - .map_err(|e| anyhow!("clipboard ctx init failed: {e:?}"))?; + let ctx = ClipboardContext::new().map_err(|e| anyhow!("clipboard ctx init failed: {e:?}"))?; ctx.set_text(text.to_string()) .map_err(|e| anyhow!("set_text failed: {e:?}"))?; Ok(()) diff --git a/core/rust-lib/src/lib.rs b/core/rust-lib/src/lib.rs index 31d464ab..361430eb 100644 --- a/core/rust-lib/src/lib.rs +++ b/core/rust-lib/src/lib.rs @@ -15,12 +15,14 @@ mod desktop_shortcuts; mod expander; mod hotkey; mod image_ops; +mod input_lock; +#[cfg(target_os = "linux")] +mod linux_portal; mod models; mod notes; mod ocr; mod paste; mod recolor; -mod input_lock; mod region_picker; mod screen_picker; mod screen_recording; @@ -168,6 +170,9 @@ pub fn run(context: tauri::Context) { let enabled = settings::get_bool(&db_handle, expander::KEY_ENABLED, false) .unwrap_or(false); let hotkey_str = expander::migrate_legacy_default(&db_handle); + #[cfg(target_os = "linux")] + let hotkey_str = + expander::migrate_linux_wayland_hotkey(&db_handle, hotkey_str); let state = app .state::(); if let Err(e) = hotkey::register_expander( @@ -179,6 +184,17 @@ pub fn run(context: tauri::Context) { tracing::warn!("expander hotkey register failed at startup: {e:#}"); } + #[cfg(target_os = "linux")] + if enabled { + if let Err(e) = desktop_shortcuts::sync_expander_shortcut( + &db_handle, + true, + &hotkey_str, + ) { + tracing::warn!("expander desktop shortcut sync at startup: {e:#}"); + } + } + // Direct hotkey→snippet slots (independent of the // abbreviation expander; the only mode that works in // terminals, since it pastes without reading anything). @@ -215,7 +231,21 @@ pub fn run(context: tauri::Context) { #[cfg(target_os = "linux")] { + if let Err(e) = pollster::block_on(atspi::connection::set_session_accessibility( + true, + )) { + tracing::warn!("AT-SPI set_session_accessibility(true): {e}"); + } else { + tracing::info!( + "AT-SPI session accessibility enabled (text expander uses focused field, not clipboard)" + ); + } cli_dispatch::log_wayland_shortcut_hint(); + if let Err(e) = + desktop_shortcuts::restore_terminal_and_fix_shortcut_conflicts(&db_handle) + { + tracing::warn!("restore Terminal / fix shortcut conflicts: {e:#}"); + } if let Err(e) = desktop_shortcuts::try_auto_install(&db_handle) { tracing::warn!("desktop shortcut auto-setup: {e:#}"); } diff --git a/core/rust-lib/src/linux_portal.rs b/core/rust-lib/src/linux_portal.rs new file mode 100644 index 00000000..08ef21d9 --- /dev/null +++ b/core/rust-lib/src/linux_portal.rs @@ -0,0 +1,93 @@ +//! xdg-desktop-portal integration for GNOME/Cinnamon on Wayland. +//! +//! `grim` + `slurp` work on wlroots compositors (Sway, Hyprland) but **slurp +//! hangs with no UI on GNOME** — region capture must go through the portal +//! (same pipeline as Ubuntu's built-in screenshot tool). + +use anyhow::{anyhow, Context, Result}; +use ashpd::desktop::ResponseError; +use ashpd::PortalError; +use std::path::PathBuf; + +pub const ERR_PORTAL_CANCELLED: &str = "portal_cancelled"; + +/// GNOME/Cinnamon Wayland sessions should use the portal, not slurp. +pub fn prefer_portal_capture() -> bool { + if std::env::var_os("WAYLAND_DISPLAY").is_none() { + return false; + } + let desktop = std::env::var("XDG_CURRENT_DESKTOP") + .unwrap_or_default() + .to_uppercase(); + desktop.contains("GNOME") || desktop.contains("CINNAMON") || desktop.contains("UBUNTU") +} + +fn uri_to_path(uri: &url::Url) -> Result { + if uri.scheme() == "file" { + return uri + .to_file_path() + .map_err(|_| anyhow!("portal returned non-local file URI: {uri}")); + } + Err(anyhow!("portal returned unsupported URI scheme: {uri}")) +} + +fn portal_cancelled(err: &ashpd::Error) -> bool { + matches!( + err, + ashpd::Error::Response(ResponseError::Cancelled) + | ashpd::Error::Portal(PortalError::Cancelled(_)) + ) +} + +/// Interactive region/window/screen capture via org.freedesktop.portal.Screenshot. +pub fn capture_region() -> Result> { + use ashpd::desktop::screenshot::Screenshot; + + let response = pollster::block_on(async { + Screenshot::request() + .interactive(true) + .modal(true) + .send() + .await? + .response() + }) + .map_err(|e| { + if portal_cancelled(&e) { + anyhow!(ERR_PORTAL_CANCELLED) + } else { + anyhow!("portal screenshot: {e}") + } + })?; + + let path = uri_to_path(response.uri())?; + let bytes = std::fs::read(&path) + .with_context(|| format!("read portal screenshot {}", path.display()))?; + let _ = std::fs::remove_file(&path); + if bytes.is_empty() { + return Err(anyhow!(ERR_PORTAL_CANCELLED)); + } + Ok(bytes) +} + +/// System eyedropper via org.freedesktop.portal.Screenshot.PickColor. +pub fn pick_color() -> Result> { + use ashpd::desktop::Color; + + match pollster::block_on(async { Color::pick().send().await?.response() }) { + Ok(c) => { + let hex = format!( + "#{:02x}{:02x}{:02x}", + c.red().round() as u8, + c.green().round() as u8, + c.blue().round() as u8, + ); + Ok(Some(hex)) + } + Err(e) if portal_cancelled(&e) => Ok(None), + Err(e) => Err(anyhow!("portal pick color: {e}")), + } +} + +pub fn is_portal_cancelled(err: &anyhow::Error) -> bool { + err.to_string() == ERR_PORTAL_CANCELLED +} diff --git a/core/rust-lib/src/region_picker.rs b/core/rust-lib/src/region_picker.rs index 2d3b5502..4b01b14e 100644 --- a/core/rust-lib/src/region_picker.rs +++ b/core/rust-lib/src/region_picker.rs @@ -384,13 +384,26 @@ mod win_impl { } } -/// Linux: Wayland (`grim` + `slurp`) when available, else X11 (`scrot -s`). +/// Linux: xdg-desktop-portal on GNOME/Cinnamon Wayland, else grim+slurp +/// (wlroots), else X11 scrot. #[cfg(target_os = "linux")] fn capture_impl() -> Result> { use anyhow::Context; use chrono::Utc; use std::process::Command; + if crate::linux_portal::prefer_portal_capture() { + match crate::linux_portal::capture_region() { + Ok(bytes) => return Ok(bytes), + Err(e) if crate::linux_portal::is_portal_cancelled(&e) => { + return Err(Cancelled.into()); + } + Err(e) => { + tracing::warn!("portal region capture failed ({e:#}); trying grim+slurp fallback"); + } + } + } + let tmp = std::env::temp_dir().join(format!( "inspector-rust-region-{}.png", Utc::now().timestamp_millis() @@ -425,9 +438,10 @@ fn capture_impl() -> Result> { .context("spawn scrot")? } else { anyhow::bail!( - "region capture needs scrot (X11) or grim+slurp (Wayland). \ + "region capture needs xdg-desktop-portal (GNOME Wayland), scrot (X11), \ + or grim+slurp (wlroots Wayland). \ Install: sudo apt install scrot # X11\n\ - or: sudo apt install grim slurp # Wayland" + or: sudo apt install grim slurp # Sway/Hyprland" ); }; diff --git a/core/rust-lib/src/text_field/linux.rs b/core/rust-lib/src/text_field/linux.rs new file mode 100644 index 00000000..9255bde4 --- /dev/null +++ b/core/rust-lib/src/text_field/linux.rs @@ -0,0 +1,186 @@ +//! Linux AT-SPI2 implementation of [`FieldAccess`]. +//! +//! On GNOME Wayland, enigo/XTest often injects keystrokes into the wrong +//! window (e.g. the XWayland terminal) while the user types in a native +//! Wayland app (WhatsApp, Firefox, gedit). AT-SPI reads the actually focused +//! text field over D-Bus — no synthetic keys, no clipboard roundtrip. + +use anyhow::{anyhow, Result}; +use atspi::connection::{set_session_accessibility, AccessibilityConnection}; +use atspi::proxy::accessible::AccessibleProxy; +use atspi::proxy::proxy_ext::ProxyExt; +use atspi::zbus::proxy::CacheProperties; +use atspi::{Granularity, ObjectRefOwned, Role, State}; +use std::sync::atomic::{AtomicBool, Ordering}; + +use super::{trim_word, FieldAccess, ReplaceOutcome}; + +const MAX_VISIT_NODES: usize = 800; + +static A11Y_ENABLED: AtomicBool = AtomicBool::new(false); + +fn ensure_a11y_enabled() { + if A11Y_ENABLED.swap(true, Ordering::Relaxed) { + return; + } + if let Err(e) = pollster::block_on(set_session_accessibility(true)) { + tracing::warn!("AT-SPI set_session_accessibility(true): {e}"); + } +} + +async fn open_connection() -> Result { + ensure_a11y_enabled(); + AccessibilityConnection::new() + .await + .map_err(|e| anyhow!("AT-SPI connection failed: {e}")) +} + +async fn proxy_for<'a>( + conn: &'a AccessibilityConnection, + obj: &ObjectRefOwned, +) -> Result> { + let name = obj + .name() + .ok_or_else(|| anyhow!("AT-SPI object missing bus name"))?; + AccessibleProxy::builder(conn.connection()) + .destination(name.to_owned())? + .path(obj.path())? + .cache_properties(CacheProperties::No) + .build() + .await + .map_err(|e| anyhow!("AccessibleProxy build: {e}")) +} + +async fn read_word_async() -> Result> { + let conn = open_connection().await?; + let root = conn + .root_accessible_on_registry() + .await + .map_err(|e| anyhow!("AT-SPI registry root: {e}"))?; + let apps = root + .get_children() + .await + .map_err(|e| anyhow!("AT-SPI get_children: {e}"))?; + let mut stack: Vec = apps; + let mut visited = 0usize; + + while let Some(obj) = stack.pop() { + if visited >= MAX_VISIT_NODES { + break; + } + visited += 1; + let node = proxy_for(&conn, &obj).await?; + + if let Ok(states) = node.get_state().await { + if states.contains(State::Focused) { + if let Ok(role) = node.get_role().await { + if role == Role::PasswordText { + continue; + } + } + if let Ok(proxies) = node.proxies().await { + if let Ok(text) = proxies.text().await { + let caret = text + .caret_offset() + .await + .map_err(|e| anyhow!("AT-SPI caret_offset: {e}"))?; + let gran = Granularity::Word as u32; + let (word, _start, _end) = + text.get_text_before_offset(caret, gran) + .await + .map_err(|e| anyhow!("AT-SPI get_text_before_offset: {e}"))?; + let trimmed = trim_word(&word); + if !trimmed.is_empty() { + tracing::info!( + "AT-SPI: read word before cursor at {}: {trimmed:?}", + node.inner().path() + ); + return Ok(Some(trimmed.to_string())); + } + } + } + } + } + + if let Ok(children) = node.get_children().await { + stack.extend(children); + } + } + + tracing::debug!("AT-SPI: no focused text field found"); + Ok(None) +} + +async fn replace_word_async(replacement: &str) -> Result { + let conn = open_connection().await?; + let root = conn + .root_accessible_on_registry() + .await + .map_err(|e| anyhow!("AT-SPI registry root: {e}"))?; + let apps = root.get_children().await.map_err(|e| anyhow!("{e}"))?; + let mut stack: Vec = apps; + let mut visited = 0usize; + + while let Some(obj) = stack.pop() { + if visited >= MAX_VISIT_NODES { + break; + } + visited += 1; + let node = proxy_for(&conn, &obj).await?; + + if let Ok(states) = node.get_state().await { + if states.contains(State::Focused) { + if let Ok(role) = node.get_role().await { + if role == Role::PasswordText { + continue; + } + } + if let Ok(proxies) = node.proxies().await { + if let Ok(text) = proxies.text().await { + let caret = text.caret_offset().await?; + let gran = Granularity::Word as u32; + let (_word, start, _end) = text.get_text_before_offset(caret, gran).await?; + if let Ok(ed) = proxies.editable_text().await { + let _ = ed.delete_text(start, caret).await; + let len = replacement.chars().count() as i32; + if ed + .insert_text(start, replacement, len) + .await + .unwrap_or(false) + { + tracing::info!("AT-SPI: replaced word via EditableText"); + return Ok(ReplaceOutcome::Replaced); + } + } + let _ = text.set_selection(0, start, caret).await; + tracing::info!("AT-SPI: selected abbreviation; caller should paste"); + return Ok(ReplaceOutcome::SelectionActive); + } + } + } + } + + if let Ok(children) = node.get_children().await { + stack.extend(children); + } + } + + Ok(ReplaceOutcome::Unsupported) +} + +pub struct AtspiFieldAccess; + +impl FieldAccess for AtspiFieldAccess { + fn read_word_before_cursor(&self) -> Result> { + pollster::block_on(read_word_async()) + } + + fn try_replace_word_before_cursor(&self, replacement: &str) -> Result { + pollster::block_on(replace_word_async(replacement)) + } +} + +/// Whether AT-SPI registry is reachable on the session bus. +pub fn atspi_available() -> bool { + pollster::block_on(async { open_connection().await.is_ok() }) +} diff --git a/core/rust-lib/src/text_field/mod.rs b/core/rust-lib/src/text_field/mod.rs index cc5ae10a..9eb520c2 100644 --- a/core/rust-lib/src/text_field/mod.rs +++ b/core/rust-lib/src/text_field/mod.rs @@ -102,7 +102,9 @@ pub enum CapturePath { Ax, /// Windows UI Automation succeeded. Uia, - /// Both AX/UIA returned None — fell back to the clipboard roundtrip. + /// Linux AT-SPI2 succeeded. + Atspi, + /// Native a11y returned None — fell back to the clipboard roundtrip. Clipboard, } @@ -116,7 +118,11 @@ pub fn default_field_access() -> Box { { Box::new(windows::UiaFieldAccess) } - #[cfg(not(any(target_os = "macos", target_os = "windows")))] + #[cfg(target_os = "linux")] + { + Box::new(linux::AtspiFieldAccess) + } + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] { Box::new(stub::StubFieldAccess) } @@ -132,7 +138,11 @@ pub fn native_path() -> CapturePath { { CapturePath::Uia } - #[cfg(not(any(target_os = "macos", target_os = "windows")))] + #[cfg(target_os = "linux")] + { + CapturePath::Atspi + } + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] { CapturePath::Clipboard } @@ -177,7 +187,10 @@ pub mod macos; #[cfg(target_os = "windows")] pub mod windows; -#[cfg(not(any(target_os = "macos", target_os = "windows")))] +#[cfg(target_os = "linux")] +pub mod linux; + +#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] pub mod stub { use super::*; diff --git a/linux/README.md b/linux/README.md index 91815dea..668829d7 100644 --- a/linux/README.md +++ b/linux/README.md @@ -47,10 +47,10 @@ AppImage troubleshooting: `sudo apt install libfuse2`, then rebuild. In Cursor | Global shortcuts + system tray | Yes (X11; Wayland may need compositor support) | | Paste into focused app (`Ctrl+V` via enigo) | Yes | | ML background cutout (ONNX) | Yes (offline) | -| Region screenshot (`Ctrl+Shift+S`) | Yes with **scrot** (X11) or **grim+slurp** (Wayland) | -| Screen OCR (`Ctrl+Shift+O`) | Requires **tesseract** + `tesseract-ocr-eng` (German optional: `tesseract-ocr-deu`) | -| In-app eyedropper | Not yet (macOS/Windows only) | -| Text expander in-place (AX/UIA) | Keystroke/clipboard fallback only | +| Region screenshot (`Ctrl+Shift+S`) | Yes — **xdg-desktop-portal** on GNOME Wayland; **grim+slurp** on wlroots; **scrot** on X11 | +| Screen OCR (`Ctrl+Shift+O`) | Same capture path as screenshot + **tesseract** (`tesseract-ocr-eng`; German: `tesseract-ocr-deu`) | +| Color picker (`Ctrl+Shift+C`) | Yes on GNOME Wayland via **xdg-desktop-portal PickColor** (macOS/Windows use native loupe) | +| Text expander hotkey | **Enable** in Settings → pick **Alt+1** preset → **Save & re-register**. On GNOME Wayland the hotkey is registered via gsettings (not in-app). Avoid `Ctrl+Backquote` on German keyboards — it maps to `grave`, not the `<` key (`less`). Expansion uses **AT-SPI** (reads the focused text field over D-Bus) — **not** synthetic Ctrl+Shift+← in the terminal. | Data path: `~/.local/share/InspectorRust/history.db` @@ -68,8 +68,10 @@ Recommended for full feature set: ```bash sudo apt-get install -y scrot tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu -# Wayland only: +# wlroots Wayland (Sway/Hyprland) — not needed on GNOME (uses xdg-desktop-portal): sudo apt-get install -y grim slurp +# GNOME Wayland needs the portal (usually preinstalled): +sudo apt-get install -y xdg-desktop-portal xdg-desktop-portal-gnome ``` ## Desktop environment notes @@ -89,8 +91,8 @@ Check under **Settings → Keyboard → Custom Shortcuts** (entries named “Ins **Automatic conflict handling (v2):** On first start (and after profile upgrades) Inspector Rust: 1. Scans existing **custom shortcuts** and **GNOME Terminal** copy/paste bindings via `gsettings`. -2. Moves Terminal off **Ctrl+Shift+C/V** to **Ctrl+C/V** when that would clash with Inspector’s color/popup shortcuts. -3. Picks the first free binding per action (defaults: Ctrl+Shift+V/O/S/C; fallbacks e.g. Ctrl+Alt+… if occupied). +2. **Never** changes Terminal — copy/paste stay **Ctrl+Shift+C/V**. If an older build wrongly set Ctrl+C/V, Inspector restores Terminal on startup. +3. Picks the first free binding per action (defaults: Ctrl+Shift+V/O/S/C; fallbacks e.g. Ctrl+Alt+… when Terminal or other apps occupy a key). No manual `ubuntu-terminal-copy-paste-ctrl-cv.sh` required. @@ -106,7 +108,8 @@ bash scripts/install-linux.sh # runs --setup-shortcuts when the binary is on - **X11**: No extra setup — built-in global shortcuts usually work. - **KDE Plasma**: Not automated yet; bind shortcuts manually to the commands above. -- **Wayland region capture**: Install `grim` and `slurp` (used when OCR/Screenshot runs from tray or CLI). +- **Wayland region capture (GNOME)**: Uses **xdg-desktop-portal** (Ubuntu's native screenshot UI). Do **not** rely on `slurp` on GNOME — it hangs with no UI. +- **Wayland region capture (Sway/Hyprland)**: Install `grim` + `slurp`. - **Clipboard on Wayland**: If the log shows `ext-data-control` / `wlr-data-control` is missing, the app falls back to the X11 clipboard bridge. For full Wayland clipboard sync, use a compositor that supports those protocols, or run under an X11/XWayland session. - **Autostart**: Uses the Tauri autostart plugin (typically `~/.config/autostart/`). @@ -116,8 +119,13 @@ bash scripts/install-linux.sh # runs --setup-shortcuts when the binary is on | -------- | ----- | | `webkit2gtk` not found | Run `scripts/install-linux.sh` or install `libwebkit2gtk-4.1-dev` | | `cargo` / edition errors | `rustup default stable` (need Rust ≥ 1.77) | -| Region capture fails | Install `scrot` (X11) or `grim`+`slurp` (Wayland) | +| Region capture fails / nothing on screen | GNOME: ensure `xdg-desktop-portal-gnome` is installed. Sway/Hyprland: install `grim`+`slurp`. X11: `scrot` | | OCR shortcut errors | `sudo apt install tesseract-ocr tesseract-ocr-eng` (optional German: `tesseract-ocr-deu`) | +| Color picker does nothing | Rebuild with latest Linux portal support; needs `xdg-desktop-portal-gnome` | +| Text expander hotkey dead (Wayland) | Settings → **Enable** expander → pick hotkey (e.g. Alt+1) → **Save & re-register** — gsettings entry is created automatically | +| Expander captures terminal scrollback / huge text | You tested with focus in the **terminal** — use WhatsApp, gedit, or Firefox. Log should show `AT-SPI: read word before cursor: "mfg"` not clipboard fallback | +| Diagnose shows **clipboard** path | Focus a normal text field (not Terminal) before Diagnose; ensure `org.a11y.Bus` is running (`gsettings get org.gnome.desktop.a11y.interface toolkit-accessibility`) | +| Diagnose closes Settings | **Expected** — the popup must hide so Inspector can read the field in the app behind it. Type your abbreviation in another app first, then click Diagnose | | `Ctrl+Shift+V` does nothing (Wayland) | Restart app once (auto gsettings), or run `bash scripts/install-desktop-shortcuts.sh` after build | | Conflict with copy/paste (`Ctrl+Shift+C/V`) | Automatic on install/first start; or Settings → Linux desktop shortcuts → **Auto-resolve all** | | Tray icon missing | `libayatana-appindicator3-dev` + log out/in | From a02b2777c70c478c91bb5f322c6a9bc44dfe3555 Mon Sep 17 00:00:00 2001 From: Leviticus-Triage <3xodu2904@gmail.com> Date: Sun, 24 May 2026 10:06:30 +0200 Subject: [PATCH 2/3] fix(linux): pass watcher state to expand_at_cursor after upstream rebase Aligns CLI dispatch and send_backspaces loop with v0.33.0 expander API. Co-authored-by: Cursor --- core/rust-lib/src/cli_dispatch.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/rust-lib/src/cli_dispatch.rs b/core/rust-lib/src/cli_dispatch.rs index 9e59b21c..9ae45253 100644 --- a/core/rust-lib/src/cli_dispatch.rs +++ b/core/rust-lib/src/cli_dispatch.rs @@ -127,7 +127,8 @@ pub fn dispatch(app: &AppHandle, action: CliAction) { let _ = app.run_on_main_thread(move || { std::thread::sleep(std::time::Duration::from_millis(250)); if let Some(db) = app2.try_state::() { - match crate::expander::expand_at_cursor(&db) { + let watcher = app2.try_state::(); + match crate::expander::expand_at_cursor(&db, watcher.as_deref()) { Ok(()) => tracing::info!("--expand-at-cursor: expansion completed"), Err(e) => tracing::warn!("--expand-at-cursor failed: {e:#}"), } From 06dc73a82980837d60ada132c979ecac0f94a31d Mon Sep 17 00:00:00 2001 From: Leviticus-Triage <3xodu2904@gmail.com> Date: Sun, 24 May 2026 17:10:41 +0200 Subject: [PATCH 3/3] fix(frontend): satisfy react-hooks lint in PreviewPanel and ScreenshotEditor Move inline Row/ToolButton components to module scope and avoid reading canvasRef.current during render so CI eslint passes. Co-authored-by: Cursor --- core/frontend/src/components/PreviewPanel.tsx | 89 ++++++++++------- .../src/components/ScreenshotEditor.tsx | 99 ++++++++++++------- 2 files changed, 122 insertions(+), 66 deletions(-) diff --git a/core/frontend/src/components/PreviewPanel.tsx b/core/frontend/src/components/PreviewPanel.tsx index 4c0c3115..ec1865e3 100644 --- a/core/frontend/src/components/PreviewPanel.tsx +++ b/core/frontend/src/components/PreviewPanel.tsx @@ -18,6 +18,47 @@ interface Props { entry: ListEntry | null; } +function BrunoDetailRow({ + k, + v, + accent, +}: { + k: string; + v: string; + accent?: boolean; +}) { + return ( +
+ {k} + {v} +
+ ); +} + +const BRUNO_STATE_LABELS: Record = { + bw: "Baden-Württemberg", + by: "Bayern", + be: "Berlin", + bb: "Brandenburg", + hb: "Bremen", + hh: "Hamburg", + he: "Hessen", + mv: "Mecklenburg-Vorp.", + ni: "Niedersachsen", + nw: "Nordrhein-Westfalen", + rp: "Rheinland-Pfalz", + sl: "Saarland", + sn: "Sachsen", + st: "Sachsen-Anhalt", + sh: "Schleswig-Holstein", + th: "Thüringen", +}; + export function PreviewPanel({ entry }: Props) { const parsedFiles = useMemo(() => { if (!entry || entry.kind !== "clip" || entry.data.content_type !== "files") return null; @@ -233,24 +274,6 @@ export function PreviewPanel({ entry }: Props) { maximumFractionDigits: 1, }); const d = entry.data; - const Row = ({ k, v, accent }: { k: string; v: string; accent?: boolean }) => ( -
- {k} - {v} -
- ); - const STATE_LABELS: Record = { - bw: "Baden-Württemberg", by: "Bayern", be: "Berlin", bb: "Brandenburg", - hb: "Bremen", hh: "Hamburg", he: "Hessen", mv: "Mecklenburg-Vorp.", - ni: "Niedersachsen", nw: "Nordrhein-Westfalen", rp: "Rheinland-Pfalz", - sl: "Saarland", sn: "Sachsen", st: "Sachsen-Anhalt", - sh: "Schleswig-Holstein", th: "Thüringen", - }; return (
@@ -261,7 +284,7 @@ export function PreviewPanel({ entry }: Props) { Annahmen
- Klasse {d.taxClass} · {STATE_LABELS[d.state] ?? d.state.toUpperCase()} ·{" "} + Klasse {d.taxClass} · {BRUNO_STATE_LABELS[d.state] ?? d.state.toUpperCase()} ·{" "} {d.children === 0 ? "kinderlos" : `${d.children} Kind${d.children === 1 ? "" : "er"}`}{" "} · {d.isChurchMember ? "kirchensteuerpflichtig" : "keine Kirchensteuer"}
@@ -271,23 +294,23 @@ export function PreviewPanel({ entry }: Props) {
- - - - - - - - {d.soli > 0 && } - {d.churchTax > 0 && } - - - + + + + + + + + {d.soli > 0 && } + {d.churchTax > 0 && } + + +
- - + +
diff --git a/core/frontend/src/components/ScreenshotEditor.tsx b/core/frontend/src/components/ScreenshotEditor.tsx index 6970fdd1..76b7d235 100644 --- a/core/frontend/src/components/ScreenshotEditor.tsx +++ b/core/frontend/src/components/ScreenshotEditor.tsx @@ -96,6 +96,7 @@ const COLOR_PRESETS = ["#ef4444", "#facc15", "#ffffff", "#000000"] as const; export function ScreenshotEditor() { const canvasRef = useRef(null); + const [canvasEl, setCanvasEl] = useState(null); /** The decoded screenshot image. Kept in a ref because we need it * for every redraw (background) and for blur (sampling source * pixels). State would re-create the Image on every render. */ @@ -409,7 +410,10 @@ export function ScreenshotEditor() { {imgReady ? (
{ + canvasRef.current = el; + setCanvasEl(el); + }} onMouseDown={onCanvasMouseDown} onMouseMove={onCanvasMouseMove} onMouseUp={onCanvasMouseUp} @@ -426,7 +430,7 @@ export function ScreenshotEditor() { /> {textInput && ( void; + icon: React.ReactNode; + label: string; + shortcut: string; +}) { + return ( + + ); +} + function Toolbar({ tool, setTool, @@ -466,43 +499,43 @@ function Toolbar({ strokeWidth: number; setStrokeWidth: (n: number) => void; }) { - const Btn = ({ - t, - icon, - label, - shortcut, - }: { - t: Tool; - icon: React.ReactNode; - label: string; - shortcut: string; - }) => ( - - ); - return (
- } label="Arrow" shortcut="A" /> - } label="Text" shortcut="T" /> - } label="Rectangle" shortcut="R" /> - setTool("arrow")} + icon={} + label="Arrow" + shortcut="A" + /> + setTool("text")} + icon={} + label="Text" + shortcut="T" + /> + setTool("rect")} + icon={} + label="Rectangle" + shortcut="R" + /> + setTool("highlight")} icon={} label="Highlight" shortcut="H" /> - } label="Blur" shortcut="B" /> + setTool("blur")} + icon={} + label="Blur" + shortcut="B" + />
{COLOR_PRESETS.map((c) => (