release: v0.1.7 — password detection, Telex enabled, GNOME Wayland support
This commit is contained in:
parent
d34180537a
commit
6beeee2e69
15 changed files with 845 additions and 101 deletions
36
CHANGELOG.md
36
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
|
||||
|
||||
|
|
|
|||
67
README.md
67
README.md
|
|
@ -2,8 +2,8 @@
|
|||
<img src="https://img.shields.io/badge/Platform-Linux-blue?style=for-the-badge" alt="Platform">
|
||||
<img src="https://img.shields.io/badge/Language-Rust-orange?style=for-the-badge" alt="Rust">
|
||||
<img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License">
|
||||
<img src="https://img.shields.io/badge/Version-0.1.6-purple?style=for-the-badge" alt="Version">
|
||||
<img src="https://img.shields.io/badge/Tests-106_passing-brightgreen?style=for-the-badge" alt="Tests">
|
||||
<img src="https://img.shields.io/badge/Version-0.1.7-purple?style=for-the-badge" alt="Version">
|
||||
<img src="https://img.shields.io/badge/Tests-118_passing-brightgreen?style=for-the-badge" alt="Tests">
|
||||
<img src="https://img.shields.io/badge/Event_Sourcing-✓-blueviolet?style=for-the-badge" alt="Event Sourcing">
|
||||
</p>
|
||||
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "vietc-cli"
|
||||
version = "0.1.6"
|
||||
version = "0.1.7"
|
||||
edition = "2021"
|
||||
description = "Viet+ CLI Test Harness"
|
||||
|
||||
|
|
|
|||
320
cli/src/main.rs
320
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) {
|
||||
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::Flush(text) => {
|
||||
EngineEvent::Insert(text) | EngineEvent::Flush(text) => {
|
||||
output.push_str(text);
|
||||
}
|
||||
EngineEvent::Insert(text) => {
|
||||
EngineEvent::Paste(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
|
||||
}
|
||||
output.push_str(word);
|
||||
}
|
||||
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,
|
||||
} => {
|
||||
}
|
||||
EngineEvent::UndoTones { backspaces, restored } => {
|
||||
for _ in 0..*backspaces {
|
||||
output.push('\x08');
|
||||
}
|
||||
output.push_str(restored);
|
||||
}
|
||||
EngineEvent::Paste(text) => {
|
||||
output.push_str(text);
|
||||
EngineEvent::AutoRestore(word) => {
|
||||
for _ in 0..word.len() {
|
||||
output.push('\x08');
|
||||
}
|
||||
output.push_str(word);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 <s> <e> Add macro shortcut->expansion");
|
||||
println!(" :macro rm <s> 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 <shortcut> <expansion> or :macro rm <s> or :macro clear]");
|
||||
return;
|
||||
}
|
||||
match parts[1] {
|
||||
"add" | "a" => {
|
||||
if parts.len() < 4 {
|
||||
println!("[Usage: :macro add <shortcut> <expansion>]");
|
||||
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 <shortcut>]");
|
||||
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 <shortcut> <expansion>]");
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Get the active window's title (lowercase)
|
||||
pub fn get_active_window_title() -> Option<String> {
|
||||
// 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<String> {
|
||||
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<String> {
|
||||
// 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<String> {
|
|||
None
|
||||
}
|
||||
|
||||
/// Query GNOME Shell via D-Bus for the focused window's XID
|
||||
fn get_gnome_active_window_id() -> Option<String> {
|
||||
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<String> {
|
||||
// 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<String> {
|
|||
None
|
||||
}
|
||||
|
||||
/// Query GNOME Shell via D-Bus for the focused window's WM class (app ID)
|
||||
fn get_gnome_focused_wm_class() -> Option<String> {
|
||||
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<String> {
|
||||
let output = Command::new("xdotool")
|
||||
.args(["getactivewindow", "getwindowclassname"])
|
||||
|
|
@ -235,6 +306,16 @@ pub struct AppStateManager {
|
|||
bypass_apps: Vec<String>,
|
||||
/// Global enabled state
|
||||
global_enabled: bool,
|
||||
/// Password detection config
|
||||
password_enabled: bool,
|
||||
check_atspi2: bool,
|
||||
check_window_title: bool,
|
||||
title_keywords: Vec<String>,
|
||||
password_apps: Vec<String>,
|
||||
/// 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<String>,
|
||||
password_apps: Vec<String>,
|
||||
) {
|
||||
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<bool> {
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
|
||||
#[serde(default = "default_password_apps")]
|
||||
pub password_apps: Vec<String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
vec!["space".into(), "escape".into()]
|
||||
}
|
||||
fn default_title_keywords() -> Vec<String> {
|
||||
vec![
|
||||
"password".into(),
|
||||
"passphrase".into(),
|
||||
"secret".into(),
|
||||
"mật khẩu".into(),
|
||||
"mk".into(),
|
||||
"sudo".into(),
|
||||
]
|
||||
}
|
||||
fn default_password_apps() -> Vec<String> {
|
||||
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<String> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,5 +85,19 @@ pub fn detect_compositor() -> Option<String> {
|
|||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<evdev::Key>) -> 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<evdev::Key>, key: &str) -> bool {
|
||||
let ctrl_pressed = key_state.contains(evdev::Key::KEY_LEFTCTRL)
|
||||
|| key_state.contains(evdev::Key::KEY_RIGHTCTRL);
|
||||
|
|
|
|||
58
daemon/src/password_detector.rs
Normal file
58
daemon/src/password_detector.rs
Normal file
|
|
@ -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<bool>,
|
||||
atspi_ok: bool,
|
||||
}
|
||||
|
||||
impl PasswordDetector {
|
||||
pub fn new() -> Self {
|
||||
Self { cached: None, atspi_ok: false }
|
||||
}
|
||||
|
||||
pub fn check(&mut self) -> Option<bool> {
|
||||
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<bool> {
|
||||
self.cached
|
||||
}
|
||||
|
||||
fn check_atspi2(&self) -> Option<bool> {
|
||||
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<String, Variant<Box<dyn RefArg>>>, Vec<Variant<Box<dyn RefArg>>>) =
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "vietc-engine"
|
||||
version = "0.1.6"
|
||||
version = "0.1.7"
|
||||
edition = "2021"
|
||||
description = "Viet+ Vietnamese IME Core Engine"
|
||||
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "vietc-tray"
|
||||
version = "0.1.6"
|
||||
version = "0.1.7"
|
||||
edition = "2021"
|
||||
description = "Viet+ system tray icon"
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<text x="64" y="96" text-anchor="middle" fill="#ffffff" font-size="48" font-weight="bold" font-family="system-ui, sans-serif">VN</text>
|
||||
</svg>"##;
|
||||
|
||||
let svg_tlx = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<rect x="8" y="8" width="112" height="112" rx="24" fill="#2563eb"/>
|
||||
<text x="64" y="96" text-anchor="middle" fill="#ffffff" font-size="48" font-weight="bold" font-family="system-ui, sans-serif">TLX</text>
|
||||
</svg>"##;
|
||||
|
||||
let svg_en = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<rect x="8" y="8" width="112" height="112" rx="24" fill="#4b5563"/>
|
||||
<text x="64" y="96" text-anchor="middle" fill="#ffffff" font-size="48" font-weight="bold" font-family="system-ui, sans-serif">EN</text>
|
||||
|
|
@ -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<ksni::Icon> {
|
||||
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<MenuItem<Self>> {
|
||||
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| {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue