From 6beeee2e69389f2bc5929ce0c77375cfd419e6e1 Mon Sep 17 00:00:00 2001 From: Khoa Vo Date: Wed, 1 Jul 2026 10:58:16 +0700 Subject: [PATCH] =?UTF-8?q?release:=20v0.1.7=20=E2=80=94=20password=20dete?= =?UTF-8?q?ction,=20Telex=20enabled,=20GNOME=20Wayland=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 36 +++- README.md | 67 ++++-- cli/Cargo.toml | 2 +- cli/src/main.rs | 348 +++++++++++++++++++++++++------- daemon/Cargo.toml | 3 +- daemon/src/app_state.rs | 164 ++++++++++++++- daemon/src/config.rs | 92 +++++++++ daemon/src/display.rs | 14 ++ daemon/src/main.rs | 86 ++++++++ daemon/src/password_detector.rs | 58 ++++++ engine/Cargo.toml | 2 +- protocol/Cargo.toml | 2 +- ui/Cargo.toml | 2 +- ui/src/tray.rs | 68 ++++++- uinputd/Cargo.toml | 2 +- 15 files changed, 845 insertions(+), 101 deletions(-) create mode 100644 daemon/src/password_detector.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c337cee..45e935f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,40 @@ # Changelog -## v0.1.6 (2026-06-29) +## v0.1.7 (2026-07-01) + +### Password Auto-Detection + +- **AT-SPI2 D-Bus integration**: Queries `org.a11y.atspi.Registry.GetFocus` + `GetRole` to detect password fields (role 62 = `PASSWORD_TEXT`). Automatically disables Vietnamese input when typing into a password field, re-enables when focus moves away. +- **Window-class fallback**: Password dialogs (pinentry, polkit, kwallet, ssh-askpass) are detected via `password_apps` config list. +- **Window-title fallback**: Window titles containing "password", "passphrase", "sudo", "mật khẩu" trigger automatic English mode. + +### Telex Input Method + +- **Telex now fully enabled**: Both VNI and Telex are supported. Switch via Ctrl+Shift hotkey or tray menu "Input Method > Telex / VNI". +- **Method status file** (`~/.config/vietc/method`): Daemon writes the current method so the tray can display it. +- **Tray indicator**: Red "VN" for VNI, Blue "TLX" for Telex, Gray "EN" for English mode. +- **Config option**: `toggle_method_key = "shift"` configures the Ctrl+Shift method toggle combo. + +### GNOME/Wayland Support + +- **Native GNOME Shell D-Bus integration**: Queries `org.gnome.Shell.Eval` for focused window class, ID, and title — works on Wayland GNOME where xdotool/xprop are unavailable. +- **Window detection chain**: GNOME Shell D-Bus → wlrctl → xdotool → /proc — ensures window tracking works across all environments. +- **Compositor detection**: Added GNOME/Mutter detection via `pgrep gnome-shell` and `XDG_CURRENT_DESKTOP`. +- **Dependencies**: `dbus` crate (0.9) added for both AT-SPI2 and GNOME Shell D-Bus queries. + +### CLI Enhancements + +- **Pass-through characters**: All characters now appear in output (not just those that emit engine events). +- **Screen display**: Backspace characters are properly applied to show what would appear on screen. +- **State reset**: Each input line starts with a clean engine state. +- **New commands**: `:help`, `:status`, `:vi`, `:en`, `:ar on|off`, `:macros`, `:macro add/rm/clear`, `:events`, `:events clear`. + +### Bug Fixes + +- **Engine state correctly reset between input lines** in CLI test harness. +- **Flush characters forwarded** after macro expansion / auto-restore replacement to preserve spacing. + +--- ### uinput-First Injection diff --git a/README.md b/README.md index af22d00..b406369 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ Platform Rust License - Version - Tests + Version + Tests Event Sourcing

@@ -59,11 +59,13 @@ Physical Keyboard ┌──────────────────────────────────────────────────────────────┐ │ Stage 2: KEY ROUTING │ │ │ -│ Modifier keys (Ctrl/Alt/Super) → forward directly │ -│ Ctrl+Space → toggle Vietnamese ON/OFF │ -│ Backspace → replay_backspace() │ -│ Characters → replay_and_inject(ch) │ -│ VNI control keys → consume when no match │ + │ Modifier keys (Ctrl/Alt/Super) → forward directly │ + │ Ctrl+Space → toggle Vietnamese ON/OFF │ + │ Ctrl+Shift → toggle VNI/Telex input method │ + │ Password detected → auto-disable Vietnamese │ + │ Backspace → replay_backspace() │ + │ Characters → replay_and_inject(ch) │ + │ VNI/Telex control keys → consume when no match │ └──────────────────────────────────────────────────────────────┘ │ ▼ @@ -149,14 +151,15 @@ vietc/ ├── daemon/ # Main daemon process │ ├── main.rs # Event loops, Backspace-Replay, CPU pinning │ ├── config.rs # TOML config loader + hot reload -│ ├── app_state.rs # Per-app Vietnamese/English memory +│ ├── app_state.rs # Per-app VN/EN memory + password detection +│ ├── password_detector.rs # AT-SPI2 D-Bus password field detection │ └── display.rs # X11/Wayland/compositor detection │ ├── uinputd/ # Privileged uinput backspace daemon (VMK-style) │ └── main.rs # Unix socket server for /dev/uinput injection │ ├── ui/ # System tray icon -│ └── main.rs # Tray + daemon launcher +│ └── tray.rs # Tray with VN/TLX/EN mode display │ ├── cli/ # Interactive test harness ├── packaging/ # .deb packaging scripts @@ -214,7 +217,12 @@ vietc/ ## Input Methods -### VNI (default, Telex coming in next version) +Both **VNI** and **Telex** are fully supported. Switch between them via: +- **Ctrl+Shift** hotkey (toggle at runtime) +- **System tray** menu: "Input Method > Telex / VNI" +- **Config file**: `input_method = "vni"` or `"telex"` + +### VNI | Key | Result | Example | |-----|--------|---------| @@ -228,7 +236,25 @@ vietc/ | `8` | ă | `a8` → `ă` | | `9` | đ | `d9` → `đ` | -Flexible typing: type the full syllable, then add marks/tone keys at the end. Example: `nguye6n4` → `nguyễn`. The engine scans backward up to 5 characters to find the target vowel. +### Telex + +| Key | Result | Example | +|-----|--------|---------| +| `s` | á (sắc) | `as` → `á` | +| `f` | à (huyền) | `af` → `à` | +| `r` | ả (hỏi) | `ar` → `ả` | +| `x` | ã (ngã) | `ax` → `ã` | +| `j` | ạ (nặng) | `aj` → `ạ` | +| `aa` | â | `aa` → `â` | +| `ee` | ê | `ee` → `ê` | +| `oo` | ô | `oo` → `ô` | +| `ow` | ơ | `ow` → `ơ` | +| `aw` | ă | `aw` → `ă` | +| `uw` | ư | `uw` → `ư` | +| `dd` | đ | `dd` → `đ` | +| `w` | ươ (uo cluster) | `chuongw` → `chương` | + +Flexible typing: type the full syllable, then add marks/tone keys at the end. Examples: `tieengs` → `tiếng`, `nguyeexn` → `nguyễn`, `chafo` → `chào`. The engine scans backward up to 5 characters to find the target vowel. --- @@ -248,6 +274,10 @@ Flexible typing: type the full syllable, then add marks/tone keys at the end. Ex | **Window-Switch Reset** | Active window ID verified on every keystroke — Alt+Tab instantly clears engine state. No stale composition across apps | | **CPU Priority** | Pins daemon to P-cores (0-3) + nice(-10) for low-latency input | | **Uinput Injection** | Uses `/dev/uinput` for reliable keyboard injection without X11 dependency. Falls back to XTest on systems without uinput access | +| **Password Auto-Detection** | AT-SPI2 + window-class + window-title — automatically disables Vietnamese when typing into password fields | +| **Method Toggle** | Ctrl+Shift switches between VNI and Telex at runtime; tray icon shows current mode (VN/TLX/EN) | +| **GNOME/Wayland Support** | Native GNOME Shell D-Bus integration for window detection, app memory, and password detection on Wayland | +| **VNI & Telex** | Both input methods fully supported, switchable at runtime | --- @@ -287,7 +317,7 @@ System tray icon + daemon + desktop entry. Requires user to be in the `input` gr ```bash # Install -sudo dpkg -i vietc_0.1.6-1_amd64.deb +sudo dpkg -i vietc_0.1.7-1_amd64.deb # Log out and log back in (for input group membership to take effect) # Then launch "Viet+" from your application menu @@ -318,7 +348,8 @@ Config file: `~/.config/vietc/config.toml` or `./vietc.toml` ```toml input_method = "vni" # "vni" or "telex" -toggle_key = "space" # Ctrl+Space to toggle +toggle_key = "space" # Ctrl+Space to toggle VN/EN +toggle_method_key = "shift" # Ctrl+Shift to toggle VNI/Telex start_enabled = true # Vietnamese by default grab = true # grab keyboard (evdev) @@ -326,6 +357,16 @@ grab = true # grab keyboard (evdev) enabled = true trigger_keys = ["space", "escape"] +[password_detection] +enabled = true +check_atspi2 = true # AT-SPI2 accessibility bus detection +check_window_title = true +title_keywords = ["password", "passphrase", "secret", "mật khẩu", "sudo"] +password_apps = ["pinentry", "pinentry-gtk-2", "pinentry-qt", + "lxqt-sudo", "kdesudo", "gksudo", + "polkit-gnome-authentication-agent-1", + "kwallet", "gnome-keyring", "ssh-askpass"] + [app_state] enabled = true english_apps = ["code", "vim", "kitty", "foot"] diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 1a784bf..2460d57 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vietc-cli" -version = "0.1.6" +version = "0.1.7" edition = "2021" description = "Viet+ CLI Test Harness" diff --git a/cli/src/main.rs b/cli/src/main.rs index 1a852a3..d05d4b8 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,16 +1,47 @@ -// SPDX-License-Identifier: MIT use std::io::{self, Write}; -use vietc_engine::{Engine, EngineEvent, InputMethod}; +use vietc_engine::{Engine, EngineEvent, EventStore, InputEvent, InputMethod}; + +struct CliState { + engine: Engine, + method: InputMethod, + events: EventStore, + macros: Vec<(String, String)>, + auto_restore: bool, +} + +impl CliState { + fn new() -> Self { + Self { + engine: Engine::new(InputMethod::Telex), + method: InputMethod::Telex, + events: EventStore::new(), + macros: Vec::new(), + auto_restore: true, + } + } + + fn set_method(&mut self, method: InputMethod) { + self.method = method; + self.engine.set_method(method); + } + + fn status(&self) { + println!(" Method: {:?}", self.method); + println!(" Enabled: {}", self.engine.is_enabled()); + println!(" Auto-restore: {}", self.auto_restore); + println!(" Buffer: {:?}", self.engine.buffer()); + println!(" Macros: {} defined", self.macros.len()); + for (s, e) in &self.macros { + println!(" {} -> {}", s, e); + } + println!(" Events: {} recorded", self.events.len()); + } +} fn main() { - let mut engine = Engine::new(InputMethod::Telex); + let mut state = CliState::new(); - println!("Viet+ IME - Test Harness"); - println!("=========================="); - println!("Type Vietnamese using Telex input."); - println!("Press Enter to flush, type 'quit' to exit."); - println!("Toggle method with ':vni' or ':telex'"); - println!(); + print_help(); loop { print!("> "); @@ -20,82 +51,69 @@ fn main() { io::stdin().read_line(&mut input).unwrap(); let input = input.trim(); + if input.is_empty() { + continue; + } + if input == "quit" || input == "exit" { break; } - if input == ":vni" { - engine.set_method(InputMethod::Vni); - println!("[Switched to VNI]"); + if input.starts_with(':') { + handle_command(&mut state, input); continue; } - if input == ":telex" { - engine.set_method(InputMethod::Telex); - println!("[Switched to Telex]"); - continue; - } - - if input == ":reset" { - engine.reset(); - println!("[Engine reset]"); - continue; - } - - if input == ":buffer" { - println!("[Buffer: {:?}]", engine.buffer()); - continue; - } + state.engine.reset(); let mut output = String::new(); let mut events = Vec::new(); for ch in input.chars() { - if let Some(event) = engine.process_key(ch) { - events.push((ch, event.clone())); - match &event { - EngineEvent::Flush(text) => { - output.push_str(text); - } - EngineEvent::Insert(text) => { - output.push_str(text); - } - EngineEvent::AutoRestore(word) => { - // Auto-restore: delete the word and re-insert it - for _ in 0..word.len() { - output.push('\x08'); // backspace + state.events.push(InputEvent::KeyTyped(ch)); + + match state.engine.process_key(ch) { + None => { + output.push(ch); + } + Some(event) => { + events.push((ch, event.clone())); + match &event { + EngineEvent::Insert(text) | EngineEvent::Flush(text) => { + output.push_str(text); } - output.push_str(word); - } - EngineEvent::Replace { backspaces, insert } => { - for _ in 0..*backspaces { - output.push('\x08'); + EngineEvent::Paste(text) => { + output.push_str(text); } - output.push_str(insert); - } - EngineEvent::UndoTones { - backspaces, - restored, - } => { - for _ in 0..*backspaces { - output.push('\x08'); + EngineEvent::Replace { backspaces, insert } => { + for _ in 0..*backspaces { + output.push('\x08'); + } + output.push_str(insert); + if is_flush_char(ch) { + output.push(ch); + } + } + EngineEvent::UndoTones { backspaces, restored } => { + for _ in 0..*backspaces { + output.push('\x08'); + } + output.push_str(restored); + } + EngineEvent::AutoRestore(word) => { + for _ in 0..word.len() { + output.push('\x08'); + } + output.push_str(word); } - output.push_str(restored); - } - EngineEvent::Paste(text) => { - output.push_str(text); } } } } - // Flush remaining buffer - if let Some(event) = engine.flush() { + if let Some(event) = state.engine.flush() { match &event { - EngineEvent::Flush(text) => { - output.push_str(text); - } - EngineEvent::Insert(text) => { + EngineEvent::Flush(text) | EngineEvent::Insert(text) => { output.push_str(text); } _ => {} @@ -104,10 +122,204 @@ fn main() { } println!(" Events: {:?}", events); - println!(" Output: {:?}", output); + println!(" Raw: {:?}", output); - // Show what it would look like - let display: String = output.chars().filter(|c| *c != '\x08').collect(); - println!(" Display: {}", display); + let display = apply_backspaces(&output); + println!(" Screen: {}", display); } } + +fn print_help() { + println!("Viet+ IME - Test Harness"); + println!("========================="); + println!("Type text with VNI/Telex to see engine output."); + println!(); + println!("Commands:"); + println!(" :help Show this help"); + println!(" :status Show engine state"); + println!(" :vi Enable Vietnamese mode"); + println!(" :en Disable Vietnamese mode"); + println!(" :ar on|off Toggle auto-restore"); + println!(" :vni Switch to VNI input"); + println!(" :telex Switch to Telex input"); + println!(" :reset Reset engine buffer"); + println!(" :buffer Show composing buffer"); + println!(" :events Show event store history"); + println!(" :events clear Clear event store"); + println!(" :macros List macros"); + println!(" :macro add Add macro shortcut->expansion"); + println!(" :macro rm Remove a macro"); + println!(" :macro clear Clear all macros"); + println!(" quit/exit Quit"); + println!(); +} + +fn handle_command(state: &mut CliState, input: &str) { + let parts: Vec<&str> = input.splitn(4, ' ').collect(); + let cmd = parts[0]; + + match cmd { + ":help" | ":h" => print_help(), + + ":status" | ":st" => state.status(), + + ":vi" => { + state.engine.set_enabled(true); + println!("[Vietnamese mode ON]"); + } + + ":en" => { + state.engine.set_enabled(false); + println!("[Vietnamese mode OFF]"); + } + + ":ar" => { + if parts.len() < 2 { + println!("[Usage: :ar on|off]"); + return; + } + match parts[1] { + "on" => { + state.auto_restore = true; + state.engine.set_auto_restore(true); + println!("[Auto-restore ON]"); + } + "off" => { + state.auto_restore = false; + state.engine.set_auto_restore(false); + println!("[Auto-restore OFF]"); + } + _ => println!("[Usage: :ar on|off]"), + } + } + + ":vni" => { + state.set_method(InputMethod::Vni); + println!("[Switched to VNI]"); + } + + ":telex" => { + state.set_method(InputMethod::Telex); + println!("[Switched to Telex]"); + } + + ":reset" => { + state.engine.reset(); + println!("[Engine reset]"); + } + + ":buffer" => { + println!("[Buffer: {:?}]", state.engine.buffer()); + } + + ":events" | ":ev" => { + if parts.len() > 1 && parts[1] == "clear" { + state.events.clear(); + println!("[Event store cleared]"); + return; + } + if state.events.is_empty() { + println!("[No events]"); + } else { + println!("[Events: {}]", state.events.len()); + for (i, event) in state.events.iter().enumerate() { + println!(" {}: {:?}", i, event); + } + println!(" Raw keystrokes: {:?}", state.events.raw_keystrokes()); + println!(" Pattern hash: {}", state.events.pattern_hash()); + } + } + + ":macros" => { + if state.macros.is_empty() { + println!("[No macros defined]"); + } else { + println!("[Macros: {}]", state.macros.len()); + for (s, e) in &state.macros { + println!(" {} -> {}", s, e); + } + } + } + + ":macro" => { + if parts.len() < 2 { + println!("[Usage: :macro add or :macro rm or :macro clear]"); + return; + } + match parts[1] { + "add" | "a" => { + if parts.len() < 4 { + println!("[Usage: :macro add ]"); + return; + } + let shortcut = parts[2].to_string(); + let expansion = parts[3].to_string(); + state.engine.add_macro(shortcut.clone(), expansion.clone()); + if let Some(pos) = state.macros.iter().position(|(s, _)| *s == shortcut) { + state.macros[pos].1 = expansion.clone(); + } else { + state.macros.push((shortcut.clone(), expansion.clone())); + } + println!("[Macro added: {} -> {}]", shortcut, expansion); + } + "rm" | "remove" | "del" => { + if parts.len() < 3 { + println!("[Usage: :macro rm ]"); + return; + } + let shortcut = parts[2]; + if let Some(pos) = state.macros.iter().position(|(s, _)| s == shortcut) { + state.macros.remove(pos); + state.engine.clear_macros(); + for (s, e) in &state.macros { + state.engine.add_macro(s.clone(), e.clone()); + } + println!("[Macro removed: {}]", shortcut); + } else { + println!("[Macro not found: {}]", shortcut); + } + } + "clear" | "c" => { + state.engine.clear_macros(); + state.macros.clear(); + println!("[All macros cleared]"); + } + _ => { + let shortcut = parts[1].to_string(); + let expansion = parts.get(2).map(|s| s.to_string()).unwrap_or_default(); + if expansion.is_empty() { + println!("[Usage: :macro add ]"); + return; + } + state.engine.add_macro(shortcut.clone(), expansion.clone()); + if let Some(pos) = state.macros.iter().position(|(s, _)| *s == shortcut) { + state.macros[pos].1 = expansion.clone(); + } else { + state.macros.push((shortcut.clone(), expansion.clone())); + } + println!("[Macro added: {} -> {}]", shortcut, expansion); + } + } + } + + _ => { + println!("[Unknown command: {}. Type :help for available commands]", cmd); + } + } +} + +fn is_flush_char(ch: char) -> bool { + matches!(ch, ' ' | '\t' | '.' | ',' | '!' | '?' | ';' | ':' | '\n') +} + +fn apply_backspaces(s: &str) -> String { + let mut result = String::new(); + for ch in s.chars() { + if ch == '\x08' { + result.pop(); + } else { + result.push(ch); + } + } + result +} diff --git a/daemon/Cargo.toml b/daemon/Cargo.toml index 8dfc33a..5aabac7 100644 --- a/daemon/Cargo.toml +++ b/daemon/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vietc-daemon" -version = "0.1.6" +version = "0.1.7" edition = "2021" description = "Viet+ background daemon" @@ -21,3 +21,4 @@ serde = { version = "1", features = ["derive"] } evdev = "0.12" libc = "0.2" dirs = "5" +dbus = "0.9" diff --git a/daemon/src/app_state.rs b/daemon/src/app_state.rs index 6ea1a92..29e76ca 100644 --- a/daemon/src/app_state.rs +++ b/daemon/src/app_state.rs @@ -3,6 +3,8 @@ use std::collections::HashMap; use std::fs; use std::process::Command; +use crate::password_detector::PasswordDetector; + /// Query _NET_ACTIVE_WINDOW directly via X11 client library (dlopen). /// Works inside the Flatpak sandbox where xdotool/xprop are unavailable /// but libX11.so.6 is present in the GNOME runtime. No external process @@ -111,10 +113,44 @@ fn get_active_window_x11_dlopen() -> Option { } } +/// Get the active window's title (lowercase) +pub fn get_active_window_title() -> Option { + // Try GNOME Shell D-Bus (Wayland GNOME) + if let Some(title) = get_gnome_window_title() { + return Some(title.to_lowercase()); + } + + // Try X11 via xdotool + if let Ok(output) = Command::new("xdotool") + .args(["getactivewindow", "getwindowname"]) + .output() + { + if output.status.success() { + let title = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !title.is_empty() { + return Some(title.to_lowercase()); + } + } + } + None +} + +/// Query GNOME Shell via D-Bus for the focused window's title +fn get_gnome_window_title() -> Option { + let js = "global.display.focus_window?.get_title() ?? ''"; + let (_, title) = gnome_shell_eval(js)?; + if title.is_empty() { None } else { Some(title) } +} + /// Get the active window's X11 ID (unique per window — even within the same /// application). Returns a unique window-identifier string. pub fn get_active_window_id() -> Option { - // Try xdotool first (fast, direct) + // Try GNOME Shell D-Bus (Wayland GNOME) — returns hex window ID + if let Some(id) = get_gnome_active_window_id() { + return Some(id); + } + + // Try xdotool first (fast, direct, X11) if let Ok(output) = Command::new("xdotool") .args(["getactivewindow"]) .output() @@ -152,9 +188,21 @@ pub fn get_active_window_id() -> Option { None } +/// Query GNOME Shell via D-Bus for the focused window's XID +fn get_gnome_active_window_id() -> Option { + let js = "global.display.focus_window?.get_id()?.toString(16) ?? ''"; + let (_, id) = gnome_shell_eval(js)?; + if id.is_empty() { None } else { Some(format!("0x{}", id)) } +} + /// Detect the currently focused window's class name pub fn get_focused_window_class() -> Option { - // Try Wayland first (wlr-foreign-toplevel) + // Try GNOME Shell D-Bus (Wayland GNOME) + if let Some(class) = get_gnome_focused_wm_class() { + return Some(class); + } + + // Try Wayland via wlrctl (wlroots compositors) if let Some(class) = get_wayland_window_class() { return Some(class); } @@ -172,6 +220,29 @@ pub fn get_focused_window_class() -> Option { None } +/// Query GNOME Shell via D-Bus for the focused window's WM class (app ID) +fn get_gnome_focused_wm_class() -> Option { + let js = "global.display.focus_window?.get_wm_class() ?? ''"; + let (_, result) = gnome_shell_eval(js)?; + if result.is_empty() { None } else { Some(result.to_lowercase()) } +} + +/// Execute JavaScript in GNOME Shell and return (success, output) +fn gnome_shell_eval(js: &str) -> Option<(bool, String)> { + use std::time::Duration; + let conn = dbus::blocking::Connection::new_session().ok()?; + let proxy = dbus::blocking::Proxy::new( + "org.gnome.Shell", + "/org/gnome/Shell", + Duration::from_secs(1), + &conn, + ); + let (success, output): (bool, String) = proxy + .method_call("org.gnome.Shell", "Eval", (js,)) + .ok()?; + Some((success, output)) +} + fn get_x11_window_class() -> Option { let output = Command::new("xdotool") .args(["getactivewindow", "getwindowclassname"]) @@ -235,6 +306,16 @@ pub struct AppStateManager { bypass_apps: Vec, /// Global enabled state global_enabled: bool, + /// Password detection config + password_enabled: bool, + check_atspi2: bool, + check_window_title: bool, + title_keywords: Vec, + password_apps: Vec, + /// Password detector (AT-SPI2) + password_detector: PasswordDetector, + /// Cached password field state + is_password_field: bool, } impl AppStateManager { @@ -251,9 +332,82 @@ impl AppStateManager { vietnamese_apps: vietnamese_apps.iter().map(|s| s.to_lowercase()).collect(), bypass_apps: bypass_apps.iter().map(|s| s.to_lowercase()).collect(), global_enabled, + password_enabled: false, + check_atspi2: true, + check_window_title: true, + title_keywords: Vec::new(), + password_apps: Vec::new(), + password_detector: PasswordDetector::new(), + is_password_field: false, } } + /// Update password detection config + pub fn set_password_config( + &mut self, + enabled: bool, + check_atspi2: bool, + check_window_title: bool, + title_keywords: Vec, + password_apps: Vec, + ) { + self.password_enabled = enabled; + self.check_atspi2 = check_atspi2; + self.check_window_title = check_window_title; + self.title_keywords = title_keywords.iter().map(|s| s.to_lowercase()).collect(); + self.password_apps = password_apps.iter().map(|s| s.to_lowercase()).collect(); + } + + /// Check if the current focused widget is a password field + /// Returns true if password detected, forcing English mode + pub fn check_password_field(&mut self) -> bool { + if !self.password_enabled { + self.is_password_field = false; + return false; + } + + // Layer 1: AT-SPI2 (most accurate, works in terminals and dialogs) + if self.check_atspi2 { + if let Some(is_password) = self.password_detector.check() { + self.is_password_field = is_password; + if is_password { + log_password_detection("AT-SPI2", &self.current_app); + } + return is_password; + } + } + + // Layer 2: Window class match (for known password dialogs) + for pattern in &self.password_apps { + if self.current_app.contains(pattern.as_str()) { + self.is_password_field = true; + log_password_detection("window-class", &self.current_app); + return true; + } + } + + // Layer 3: Window title heuristic (for sudo prompts, browser dialogs) + if self.check_window_title { + if let Some(title) = get_active_window_title() { + for keyword in &self.title_keywords { + if title.contains(keyword.as_str()) { + self.is_password_field = true; + log_password_detection("window-title", &title); + return true; + } + } + } + } + + self.is_password_field = false; + false + } + + /// Is the current widget a password field? (cached) + pub fn is_password_field(&self) -> bool { + self.is_password_field + } + /// Check if focused app changed with a pre-detected class and return whether engine should be enabled pub fn update_with_app(&mut self, new_class: String) -> Option { if new_class == self.current_app { @@ -270,7 +424,7 @@ impl AppStateManager { } /// Get the default Vietnamese state for the current app - fn get_default_state(&self) -> bool { + pub fn get_default_state(&self) -> bool { if !self.global_enabled { return false; } @@ -378,6 +532,10 @@ impl AppStateManager { } } +fn log_password_detection(method: &str, context: &str) { + eprintln!("[vietc] Password field detected via {}: {}", method, context); +} + fn override_path() -> std::path::PathBuf { std::env::var("XDG_CONFIG_HOME") .ok() diff --git a/daemon/src/config.rs b/daemon/src/config.rs index cbdc5b2..b0b6b44 100644 --- a/daemon/src/config.rs +++ b/daemon/src/config.rs @@ -14,12 +14,18 @@ pub struct Config { #[serde(default = "default_toggle_key")] pub toggle_key: String, + #[serde(default = "default_toggle_method_key")] + pub toggle_method_key: String, + #[serde(default = "default_start_enabled")] pub start_enabled: bool, #[serde(default)] pub auto_restore: AutoRestoreConfig, + #[serde(default)] + pub password_detection: PasswordDetectionConfig, + #[serde(default)] pub app_state: AppStateConfig, @@ -33,6 +39,37 @@ pub struct Config { pub debug: bool, } +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct PasswordDetectionConfig { + #[serde(default = "default_true")] + pub enabled: bool, + + #[serde(default = "default_true")] + pub check_atspi2: bool, + + #[serde(default = "default_true")] + pub check_window_title: bool, + + #[serde(default = "default_title_keywords")] + pub title_keywords: Vec, + + #[serde(default = "default_password_apps")] + pub password_apps: Vec, +} + +impl Default for PasswordDetectionConfig { + fn default() -> Self { + Self { + enabled: true, + check_atspi2: true, + check_window_title: true, + title_keywords: default_title_keywords(), + password_apps: default_password_apps(), + } + } +} + #[derive(Debug, Deserialize)] #[allow(dead_code)] pub struct AutoRestoreConfig { @@ -85,6 +122,9 @@ fn default_input_method() -> String { fn default_toggle_key() -> String { "space".into() } +fn default_toggle_method_key() -> String { + "shift".into() +} fn default_start_enabled() -> bool { true } @@ -97,6 +137,30 @@ fn default_false() -> bool { fn default_restore_keys() -> Vec { vec!["space".into(), "escape".into()] } +fn default_title_keywords() -> Vec { + vec![ + "password".into(), + "passphrase".into(), + "secret".into(), + "mật khẩu".into(), + "mk".into(), + "sudo".into(), + ] +} +fn default_password_apps() -> Vec { + vec![ + "pinentry".into(), + "pinentry-gtk-2".into(), + "pinentry-qt".into(), + "lxqt-sudo".into(), + "kdesudo".into(), + "gksudo".into(), + "polkit-gnome-authentication-agent-1".into(), + "kwallet".into(), + "gnome-keyring".into(), + "ssh-askpass".into(), + ] +} fn default_english_apps() -> Vec { vec![ @@ -192,8 +256,10 @@ impl Default for Config { Self { input_method: default_input_method(), toggle_key: default_toggle_key(), + toggle_method_key: default_toggle_method_key(), start_enabled: default_start_enabled(), auto_restore: AutoRestoreConfig::default(), + password_detection: PasswordDetectionConfig::default(), app_state: AppStateConfig::default(), macros, grab: false, // default false so daemon works without root (needs input group for uinput) @@ -401,4 +467,30 @@ unknown_field = "value" let config: Config = toml::from_str(toml).unwrap(); assert_eq!(config.input_method, "telex"); } + + #[test] + fn parse_password_detection() { + let toml = r#" +[password_detection] +enabled = true +check_atspi2 = true +check_window_title = true +title_keywords = ["password", "passphrase"] +password_apps = ["pinentry", "kwallet"] +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert!(config.password_detection.enabled); + assert!(config.password_detection.check_atspi2); + assert_eq!(config.password_detection.title_keywords, vec!["password", "passphrase"]); + assert_eq!(config.password_detection.password_apps, vec!["pinentry", "kwallet"]); + } + + #[test] + fn parse_toggle_method_key() { + let toml = r#" +toggle_method_key = "shift" +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.toggle_method_key, "shift"); + } } diff --git a/daemon/src/display.rs b/daemon/src/display.rs index 95b9869..b364087 100644 --- a/daemon/src/display.rs +++ b/daemon/src/display.rs @@ -85,5 +85,19 @@ pub fn detect_compositor() -> Option { } } + // Check for GNOME/Mutter (Ubuntu default) + if let Ok(output) = Command::new("pgrep").arg("-x").arg("gnome-shell").output() { + if output.status.success() { + return Some("GNOME (Mutter)".into()); + } + } + + // Check XDG_CURRENT_DESKTOP for GNOME + if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") { + if desktop.to_lowercase().contains("gnome") { + return Some("GNOME".into()); + } + } + None } diff --git a/daemon/src/main.rs b/daemon/src/main.rs index a64e3ca..2e53036 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -37,10 +37,12 @@ fn boost_thread_priority() { mod app_state; mod config; mod display; +mod password_detector; use app_state::AppStateManager; use config::Config; + #[cfg(feature = "x11")] use vietc_protocol::x11_capture::X11Capture; use vietc_protocol::x11_capture::SKIP_RECORD_EVENTS; @@ -143,6 +145,13 @@ impl Daemon { config.start_enabled, ); app_state.load_overrides(); + app_state.set_password_config( + config.password_detection.enabled, + config.password_detection.check_atspi2, + config.password_detection.check_window_title, + config.password_detection.title_keywords.clone(), + config.password_detection.password_apps.clone(), + ); let config_modified = fs::metadata(&config_path) .and_then(|m| m.modified()) @@ -171,6 +180,28 @@ impl Daemon { } } + fn write_method_status(&self) { + if let Some(parent) = self.config_path.parent() { + let method_path = parent.join("method"); + let method = &self.config.input_method; + let _ = std::fs::write(method_path, method); + } + } + + fn toggle_method(&mut self) { + let new_method = match self.config.input_method.as_str() { + "vni" => InputMethod::Telex, + _ => InputMethod::Vni, + }; + self.config.input_method = match new_method { + InputMethod::Vni => "vni".into(), + InputMethod::Telex => "telex".into(), + }; + self.engine.set_method(new_method); + self.write_method_status(); + log_info(&format!("[vietc] Input method toggled to: {}", self.config.input_method)); + } + fn sync_status_file(&mut self) { if let Some(parent) = self.config_path.parent() { let status_path = parent.join("status"); @@ -214,6 +245,14 @@ impl Daemon { new_config.app_state.bypass_apps.clone(), ); + self.app_state.set_password_config( + new_config.password_detection.enabled, + new_config.password_detection.check_atspi2, + new_config.password_detection.check_window_title, + new_config.password_detection.title_keywords.clone(), + new_config.password_detection.password_apps.clone(), + ); + self.grab_enabled = new_config.grab; self.config = new_config; self.config_modified = modified; @@ -996,6 +1035,31 @@ fn run_with_evdev( continue; } + // Ctrl+LeftShift: toggle VNI/Telex input method + if value == 1 && is_method_toggle_state(&key_state) + { + daemon.toggle_method(); + continue; + } + + // Password field check: disable engine if typing into a password field + if value == 1 { + let is_pw = daemon.app_state.is_password_field(); + let currently_enabled = daemon.engine.is_enabled(); + if is_pw && currently_enabled { + daemon.engine.set_enabled(false); + daemon.write_status(); + log_info("[vietc] Password field detected — engine disabled"); + } else if !is_pw && !currently_enabled && daemon.config.start_enabled { + // Only re-enable if we're not in a manual toggle state + let default_state = daemon.app_state.get_default_state(); + if default_state { + daemon.engine.set_enabled(true); + daemon.write_status(); + } + } + } + if !grabbed { // Legacy mode: only forward to engine on press events if value != 1 { @@ -1074,6 +1138,15 @@ fn run_with_evdev( }; daemon.check_app_change_with(class); } + + // Re-check password field status on window change + if daemon.config.password_detection.enabled { + let is_pw = daemon.app_state.check_password_field(); + if is_pw && daemon.engine.is_enabled() { + daemon.engine.set_enabled(false); + daemon.write_status(); + } + } } else if daemon.config.app_state.enabled { let class = shared_window_class.lock().unwrap().clone(); if !class.is_empty() { @@ -1351,6 +1424,19 @@ fn is_caps_lock_on(device: &evdev::Device) -> bool { } } +fn is_method_toggle_state(key_state: &evdev::AttributeSet) -> bool { + let ctrl_pressed = key_state.contains(evdev::Key::KEY_LEFTCTRL) + || key_state.contains(evdev::Key::KEY_RIGHTCTRL); + let shift_pressed = key_state.contains(evdev::Key::KEY_LEFTSHIFT); + // Require Ctrl + LeftShift specifically, no other modifiers + ctrl_pressed && shift_pressed + && !key_state.contains(evdev::Key::KEY_RIGHTSHIFT) + && !key_state.contains(evdev::Key::KEY_LEFTALT) + && !key_state.contains(evdev::Key::KEY_RIGHTALT) + && !key_state.contains(evdev::Key::KEY_LEFTMETA) + && !key_state.contains(evdev::Key::KEY_RIGHTMETA) +} + fn is_toggle_combination_state(key_state: &evdev::AttributeSet, key: &str) -> bool { let ctrl_pressed = key_state.contains(evdev::Key::KEY_LEFTCTRL) || key_state.contains(evdev::Key::KEY_RIGHTCTRL); diff --git a/daemon/src/password_detector.rs b/daemon/src/password_detector.rs new file mode 100644 index 0000000..1ae9f86 --- /dev/null +++ b/daemon/src/password_detector.rs @@ -0,0 +1,58 @@ +use std::collections::HashMap; +use std::time::Duration; + +use dbus::arg::{RefArg, Variant}; +use dbus::blocking::Connection; + +const ROLE_PASSWORD_TEXT: i32 = 62; + +pub struct PasswordDetector { + cached: Option, + atspi_ok: bool, +} + +impl PasswordDetector { + pub fn new() -> Self { + Self { cached: None, atspi_ok: false } + } + + pub fn check(&mut self) -> Option { + let r = self.check_atspi2(); + self.atspi_ok = r.is_some(); + if let Some(v) = r { + self.cached = Some(v); + } + r + } + + pub fn is_available(&self) -> bool { + self.atspi_ok + } + + pub fn cached_result(&self) -> Option { + self.cached + } + + fn check_atspi2(&self) -> Option { + let conn = Connection::new_session().ok()?; + let timeout = Duration::from_secs(2); + + let proxy = conn.with_proxy( + "org.a11y.atspi.Registry", + "/org/a11y/atspi/registry", + timeout, + ); + + let (bus_name, props, _children): (String, HashMap>>, Vec>>) = + proxy.method_call("org.a11y.atspi.Registry", "GetFocus", ()).ok()?; + + let path_variant = props.get("path")?; + let path_str = path_variant.0.as_str()?; + + let acc_proxy = conn.with_proxy(&bus_name, path_str, timeout); + + let (role,): (i32,) = acc_proxy.method_call("org.a11y.atspi.Accessible", "GetRole", ()).ok()?; + + Some(role == ROLE_PASSWORD_TEXT) + } +} diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 1440024..1016d7e 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vietc-engine" -version = "0.1.6" +version = "0.1.7" edition = "2021" description = "Viet+ Vietnamese IME Core Engine" diff --git a/protocol/Cargo.toml b/protocol/Cargo.toml index b5ce723..c4e6783 100644 --- a/protocol/Cargo.toml +++ b/protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vietc-protocol" -version = "0.1.6" +version = "0.1.7" edition = "2021" description = "Viet+ keystroke injection backends (X11/Wayland)" diff --git a/ui/Cargo.toml b/ui/Cargo.toml index ce84e0a..b5552ec 100644 --- a/ui/Cargo.toml +++ b/ui/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vietc-tray" -version = "0.1.6" +version = "0.1.7" edition = "2021" description = "Viet+ system tray icon" diff --git a/ui/src/tray.rs b/ui/src/tray.rs index b5f21cc..39797b4 100644 --- a/ui/src/tray.rs +++ b/ui/src/tray.rs @@ -12,6 +12,23 @@ fn write_status(state: &str) { } } +fn read_method() -> String { + let path = dirs::config_dir() + .map(|d| d.join("vietc").join("method")) + .unwrap_or_else(|| std::path::PathBuf::from("/tmp/vietc-method")); + std::fs::read_to_string(&path) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|_| { + config::Config::load().input_method + }) +} + +fn write_method(method: &str) { + if let Some(config_dir) = dirs::config_dir() { + let _ = std::fs::write(config_dir.join("vietc").join("method"), method); + } +} + fn read_status() -> String { let path = dirs::config_dir() .map(|d| d.join("vietc").join("status")) @@ -68,6 +85,11 @@ fn ensure_icons() { VN "##; + let svg_tlx = r##" + + TLX +"##; + let svg_en = r##" EN @@ -78,11 +100,15 @@ fn ensure_icons() { if let Some(home_icons) = &home { let _ = std::fs::create_dir_all(&home_icons); let vn_path = home_icons.join("vietc-vn.svg"); + let tlx_path = home_icons.join("vietc-tlx.svg"); let en_path = home_icons.join("vietc-en.svg"); if !vn_path.exists() { let _ = std::fs::write(&vn_path, svg_vn); } + if !tlx_path.exists() { + let _ = std::fs::write(&tlx_path, svg_tlx); + } if !en_path.exists() { let _ = std::fs::write(&en_path, svg_en); } @@ -95,11 +121,15 @@ fn ensure_icons() { let _ = std::fs::create_dir_all(&icons_dir); let vn_theme = icons_dir.join("hicolor/scalable/apps/vietc-vn.svg"); + let tlx_theme = icons_dir.join("hicolor/scalable/apps/vietc-tlx.svg"); let en_theme = icons_dir.join("hicolor/scalable/apps/vietc-en.svg"); if !vn_theme.exists() { let _ = std::fs::write(&vn_theme, svg_vn); } + if !tlx_theme.exists() { + let _ = std::fs::write(&tlx_theme, svg_tlx); + } if !en_theme.exists() { let _ = std::fs::write(&en_theme, svg_en); } @@ -247,12 +277,17 @@ impl Tray for VietTray { } fn icon_name(&self) -> String { + let is_tlx = self.mode == "vn" && self.im == "telex"; if is_flatpak() { - if self.mode == "vn" { + if is_tlx { + "io.github.vietc.VietPlus.vietc-tlx".into() + } else if self.mode == "vn" { "io.github.vietc.VietPlus.vietc-vn".into() } else { "io.github.vietc.VietPlus.vietc-en".into() } + } else if is_tlx { + "vietc-tlx".into() } else if self.mode == "vn" { "vietc-vn".into() } else { @@ -280,10 +315,13 @@ impl Tray for VietTray { fn icon_pixmap(&self) -> Vec { let is_vn = self.mode == "vn"; - let bg_color = if is_vn { - [255, 224, 36, 36] // A, R, G, B + let is_tlx = self.mode == "vn" && self.im == "telex"; + let bg_color = if is_vn && !is_tlx { + [255, 224, 36, 36] // Red for VNI + } else if is_tlx { + [255, 37, 99, 235] // Blue for Telex } else { - [255, 75, 85, 99] + [255, 75, 85, 99] // Gray for English }; let fg_color = [255, 255, 255, 255]; @@ -319,7 +357,18 @@ impl Tray for VietTray { } } - if is_vn { + if is_tlx { + // T + draw_line(&mut data, 6, 10, 15, 10, fg_color); + draw_line(&mut data, 6, 11, 15, 11, fg_color); + draw_line(&mut data, 10, 10, 10, 21, fg_color); + draw_line(&mut data, 11, 10, 11, 21, fg_color); + // X + draw_line(&mut data, 18, 10, 26, 21, fg_color); + draw_line(&mut data, 19, 10, 27, 21, fg_color); + draw_line(&mut data, 26, 10, 18, 21, fg_color); + draw_line(&mut data, 27, 10, 19, 21, fg_color); + } else if is_vn { // V draw_line(&mut data, 6, 10, 11, 21, fg_color); draw_line(&mut data, 7, 10, 12, 21, fg_color); @@ -369,7 +418,7 @@ impl Tray for VietTray { fn menu(&self) -> Vec> { let is_vn = self.mode == "vn"; - let im_index = if self.im == "telex" { 0 } else { 1 }; + let im_index = if self.im == "telex" { 0_usize } else { 1_usize }; let mut items = vec![ CheckmarkItem { @@ -409,13 +458,12 @@ impl Tray for VietTray { let mut cfg = config::Config::load(); cfg.input_method = im.into(); let _ = cfg.save(); + write_method(im); this.im = im.into(); }), options: vec![ RadioItem { - label: "Telex (next version)".into(), - enabled: false, - disposition: Disposition::Informative, + label: "Telex".into(), ..Default::default() }, RadioItem { @@ -542,7 +590,7 @@ pub fn run() { loop { std::thread::sleep(std::time::Duration::from_millis(100)); let mode = read_status(); - let im = current_im(); + let im = read_method(); let autostart = config::is_autostart_installed(); // Also check status_changed flag for immediate updates let _ = handle.update(move |t| { diff --git a/uinputd/Cargo.toml b/uinputd/Cargo.toml index ce2dc4b..127c920 100644 --- a/uinputd/Cargo.toml +++ b/uinputd/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vietc-uinputd" -version = "0.1.6" +version = "0.1.7" edition = "2021" description = "Viet+ privileged uinput backspace injection daemon"