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 @@
-
-
+
+
@@ -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##""##;
+
let svg_en = r##"