Compare commits

..

No commits in common. "main" and "v0.1.7" have entirely different histories.
main ... v0.1.7

14 changed files with 725 additions and 1828 deletions

View file

@ -1,25 +1,5 @@
# Changelog
<p align="center">
<a href="CHANGELOG.vi.md">Tiếng Việt</a>
</p>
## Unreleased
### Distro Support
- **Distro support table**: README now lists supported (Ubuntu, Debian, Mint, Pop!_OS, elementary, Zorin, Neon, Fedora, RHEL, CentOS, Arch, Manjaro), might-support (openSUSE, Solus, Void), and not-supported (NixOS, Alpine, Gentoo) distros.
- **libwayland-dev** added to install.sh for all distro families (was missing — caused build failures on X11-only systems like Linux Mint).
- **libwayland-client0** added to runtime deps (was missing — caused "cannot open shared object file" on Mint).
- **Config typo fixed**: `mặt khẩu``mật khẩu` in default config and README.
### Documentation
- **Roadmap section** added to README (v0.1.19: Wayland IM protocol, event-based AT-SPI2; v0.1.20: CI, Flatpak).
- **RELEASE_CHECKLIST.md** removed (process now documented in the release commit messages).
---
## v0.1.7 (2026-07-01)
### Password Auto-Detection

View file

@ -1,180 +0,0 @@
# Nhật ký thay đổi (Changelog)
<p align="center">
<a href="CHANGELOG.md">English</a>
</p>
## Chưa phát hành (Unreleased)
### Hỗ trợ Bản phân phối (Distro Support)
- **Bảng hỗ trợ Distro**: README hiện đã liệt kê các distro hỗ trợ tốt (Ubuntu, Debian, Mint, Pop!_OS, elementary, Zorin, Neon, Fedora, RHEL, CentOS, Arch, Manjaro), có thể hỗ trợ (openSUSE, Solus, Void), và chưa hỗ trợ (NixOS, Alpine, Gentoo).
- **libwayland-dev** được thêm vào install.sh cho tất cả các họ distro (trước đây bị thiếu — gây lỗi biên dịch trên các hệ thống chỉ có X11 như Linux Mint).
- **libwayland-client0** được thêm vào các gói phụ thuộc runtime (trước đây bị thiếu — gây lỗi "cannot open shared object file" trên Mint).
- **Sửa lỗi chính tả cấu hình**: `mặt khẩu``mật khẩu` trong cấu hình mặc định và README.
### Tài liệu hướng dẫn (Documentation)
- Thêm mục **Roadmap** vào README (v0.1.19: Giao thức Wayland IM, AT-SPI2 hướng sự kiện; v0.1.20: CI, Flatpak).
- Loại bỏ **RELEASE_CHECKLIST.md** (quy trình phát hành hiện được ghi nhận trong nội dung các commit phát hành).
---
## v0.1.7 (01-07-2026)
### Tự động nhận diện mật khẩu (Password Auto-Detection)
- **Tích hợp AT-SPI2 D-Bus**: Truy vấn `org.a11y.atspi.Accessible.GetRole` trên a11y bus (không phải session bus) để phát hiện các trường mật khẩu. Hoạt động trên các hộp thoại mật khẩu GUI và các ứng dụng có bật hỗ trợ tiếp cận (a11y).
- **Phát hiện sudo qua cây tiến trình**: Quét `pstree` để tìm các tiến trình `sudo`/`passwd` — tự động tắt tiếng Việt khi có yêu cầu sudo xuất hiện trong terminal.
- **Dự phòng tiêu đề cửa sổ**: Các cửa sổ có tiêu đề chứa "password", "sudo", "mật khẩu" sẽ tự động chuyển sang chế độ gõ tiếng Anh.
- **Dự phòng lớp cửa sổ (Window class)**: Nhận diện các hộp thoại mật khẩu phổ biến (pinentry, polkit, kwallet) thông qua danh sách ứng dụng `password_apps` trong cấu hình.
- **Kiểm tra định kỳ**: Đánh giá lại trạng thái trường mật khẩu sau mỗi 30 phím gõ (giúp phát hiện kịp thời các prompt nhập mật khẩu xuất hiện trong terminal).
### Phương thức gõ Telex (Telex Input Method)
- **Hỗ trợ đầy đủ Telex**: Cả hai phương thức gõ VNI và Telex hiện đã được hỗ trợ toàn diện. Chuyển đổi nhanh qua Ctrl+Shift hoặc menu khay hệ thống "Input Method > Telex / VNI".
- **Tệp lưu phương thức gõ** (`~/.config/vietc/method`): Tiến trình nền (daemon) ghi phương thức gõ hiện tại; khay hệ thống đọc tệp này để hiển thị icon tương ứng.
- **Biểu tượng khay hệ thống**: Màu đỏ "VN" cho VNI, màu xanh dương "TLX" cho Telex, màu xám "EN" cho chế độ tiếng Anh.
- **Cấu hình**: Phím nóng `toggle_method_key = "shift"` dùng để thiết lập tổ hợp phím đổi phương thức gõ.
### Hỗ trợ GNOME/Wayland (GNOME/Wayland Support)
- **Tích hợp D-Bus của GNOME Shell**: Truy vấn `org.gnome.Shell.Eval` để lấy thông tin về lớp cửa sổ (window class), ID, tiêu đề và PID của ứng dụng đang hoạt động — giải pháp thay thế hoàn hảo trên Wayland GNOME nơi xdotool/xprop không khả dụng.
- **Chuỗi nhận diện cửa sổ**: GNOME Shell D-Bus → xprop → wlrctl → xdotool → wmctrl → /proc — hoạt động ổn định trên mọi môi trường máy tính.
- **Nhận diện Compositor**: Tự động phát hiện GNOME/Mutter qua `pgrep gnome-shell``XDG_CURRENT_DESKTOP`.
- **Thư viện phụ thuộc**: Sử dụng thư viện `dbus` (0.9) để giao tiếp với AT-SPI2 và GNOME Shell D-Bus.
### Chiếm quyền bàn phím an toàn (Keyboard Grab Safety)
- **Sử dụng sigaction không có SA_RESTART**: Tổ hợp Ctrl+C và tín hiệu SIGTERM hiện đã có thể ngắt lệnh đọc evdev đang bị chặn, giải phóng quyền chiếm giữ bàn phím trước khi thoát.
- **Tự động tải uinput**: Bộ giả lập sẽ tự chạy lệnh `modprobe uinput` trước khi mở `/dev/uinput`.
- **Xử lý EINTR**: Bắt các cuộc gọi hệ thống bị ngắt quãng và tiến hành kiểm tra lại cờ tín hiệu hệ thống.
- **Thời gian chờ an toàn 30 giây**: Tự động giải phóng quyền chiếm giữ bàn phím nếu không nhận được sự kiện nào sau 30 giây (tránh việc người dùng bị khóa bàn phím vĩnh viễn khi bộ gõ gặp sự cố).
### Clipboard & Giả lập nhập liệu (Clipboard & Injection)
- **Tối ưu hóa `wl-copy --paste-once`**: Giữ tiến trình clipboard hoạt động cho đến khi thao tác dán được thực hiện xong, loại bỏ hoàn toàn độ trễ từ 300-900ms trên môi trường Wayland/GNOME.
- **Tắt log SelectionRequest trên X11**: Loại bỏ hoàn toàn các dòng log rác liên quan đến clipboard trong terminal.
- **Ưu tiên uinput**: Giả lập qua uinput luôn được ưu tiên hơn so với giả lập qua X11 XTest.
### Thay đổi cấu hình (Config Changes)
- **Mặc định tắt tính năng tự động khôi phục từ tiếng Anh (auto-restore)**: Tránh việc lặp hoặc mất dấu trên các từ tiếng Việt hợp lệ. Người dùng có thể kích hoạt lại nếu muốn bằng cách đặt `[auto_restore] enabled = true`.
### Cải tiến dòng lệnh (CLI Enhancements)
- **Chuyển tiếp ký tự**: Tất cả các ký tự đều hiển thị trên đầu ra (thay vì chỉ hiển thị các sự kiện chuyển đổi của bộ xử lý Bamboo).
- **Màn hình hiển thị**: Các phím xóa ngược (backspace) được áp dụng trực quan để mang lại trải nghiệm giống thực tế nhất.
- **Đặt lại trạng thái**: Mỗi dòng nhập mới sẽ bắt đầu với trạng thái bộ xử lý hoàn toàn sạch.
- **Lệnh mới**: Thêm các lệnh hỗ trợ `:help`, `:status`, `:vi`, `:en`, `:ar on|off`, `:macros`, `:macro add/rm/clear`, `:events`.
### Sửa lỗi (Bug Fixes)
- **Lỗi lặp dấu cách khi bật/tắt bằng Ctrl+Space**: Chuyển tiếp phím thô hiện đã kiểm tra trạng thái hoạt động của bộ gõ.
- **Khóa một phiên chạy (Single-instance lock)**: Ghi PID vào tệp khóa; tự động phát hiện và dọn dẹp các tệp khóa cũ khi daemon tắt không bình thường.
- **Dự phòng xprop/wmctrl**: Nhận diện cửa sổ vẫn hoạt động tốt ngay cả khi hệ thống không cài đặt `xdotool`.
- **Kết nối AT-SPI2 a11y bus**: Sửa lỗi kết nối nhầm vào session bus; hiện đã kết nối chính xác vào a11y bus riêng biệt.
- **Đặt lại trạng thái bộ xử lý giữa các dòng nhập trong CLI**.
---
## v0.1.6 (29-06-2026)
### Ưu tiên giả lập uinput (uinput-First Injection)
- **Đảo ngược độ ưu tiên giả lập**: Giả lập qua uinput (`/dev/uinput`) trở thành phương thức giả lập nhập liệu chính trên X11, trong khi XTest chỉ đóng vai trò dự phòng.
- **Sửa mã phím (keycode) X11 XTest**: Áp dụng độ lệch (offset) +8 cho tất cả các mã phím evdev để đảm bảo tương thích với XTest.
- **Sửa lỗi xóa ngược trong `paste_via_clipboard()`**: Khắc phục lỗi gửi nhầm mã phím 14 (tương ứng với số "5") thành mã phím 22 (phím Backspace).
### Nhận diện chuyển đổi cửa sổ (Window-Switch Detection)
- **Xác thực ID cửa sổ trên mỗi phím gõ**: Loại bỏ khoảng thời gian bảo vệ 100ms — nhận diện ngay cả khi chuyển cửa sổ cực nhanh dưới 100ms.
### Phương thức nhập liệu (Input Method)
- **Tạm ẩn Telex trên khay hệ thống**: Hiển thị màu xám đi kèm ghi chú "(phiên bản tiếp theo)". Chỉ có phương thức gõ VNI hoạt động ở bản này.
- **Đổi phương thức gõ mặc định** thành `"vni"`.
### Đóng gói (Packaging)
- **Gỡ bỏ Flatpak và AppImage**: Hiện tại chỉ duy trì và phân phối gói cài đặt `.deb`.
- **Cải tiến postinst**: Tự động dọn dẹp tệp tin cũ và cấu hình lỗi thời; hiển thị thông báo yêu cầu đăng xuất để áp dụng thay đổi.
---
## v0.1.5 (29-06-2026)
### Đặt lại bộ gõ khi chuyển cửa sổ (Window-Switch Engine Reset)
- **Đặt lại trạng thái bộ gõ khi chuyển cửa sổ**: Khi Alt+Tab giữa các ứng dụng, bộ đệm ký tự của bộ gõ sẽ được xóa sạch. Tránh tình trạng ký tự gõ ở ứng dụng cũ áp dụng quy tắc gõ dấu sang ứng dụng mới gây lỗi hiển thị.
- **Bỏ tính năng ghi nhận `last_key_time` cho phím điều hướng/bổ trợ**: Các phím bổ trợ đơn thuần (Alt, Ctrl, Shift) không còn làm mới bộ đếm thời gian, giúp việc kiểm tra cửa sổ bằng xprop kích hoạt chính xác sau khi chuyển đổi ứng dụng.
### Nhận diện cửa sổ hoạt động (Active Window Detection)
- **Dự phòng xprop**: Thử gọi `xdotool` trước, sau đó tự động chuyển sang `xprop -root _NET_ACTIVE_WINDOW` (có sẵn trong `x11-utils`). Hoạt động ổn định dưới quyền sudo kể cả khi không cài `xdotool`.
### Dọn dẹp mã nguồn (Code Cleanup)
- **Gỡ bỏ khoảng 400 dòng mã không an toàn (unsafe) không sử dụng**: Xóa toàn bộ khối quản lý chia sẻ trạng thái clipboard X11. Loại bỏ hoàn toàn các cảnh báo `#[warn(dead_code)]``#[warn(static_mut_refs)]`.
- **Xóa mã chết trong bộ gõ**: Loại bỏ các phương thức không dùng đến trong bộ xử lý `BambooEngine``InputMethodRules`.
- **Ghi nhật ký vận hành**: Gỡ bỏ các lệnh `eprintln!` in thông tin theo từng phím gõ trong vòng lặp evdev và luồng dán uinput. Chỉ giữ lại các bản ghi quan trọng (khởi động, lỗi, chuyển cửa sổ) ghi ra stderr và tệp log.
### Biên dịch Flatpak & Khay hệ thống (Flatpak Build & System Tray)
- **Tích hợp khay hệ thống** (`vietc-tray` viết bằng thư viện ksni/DBus) vào trong gói cài đặt Flatpak. Khay hệ thống sẽ tự khởi chạy daemon và hiển thị trạng thái hiện tại.
- **Lối tắt Menu ứng dụng**: Bộ gõ hiển thị đầy đủ khi tìm kiếm từ khóa **"Viet+"** trên khay hệ thống.
- **Bỏ hộp thoại mật khẩu khi chạy Flatpak**: Skip sudo khi ứng dụng chạy trong Flatpak do Flatpak đã có sẵn quyền truy cập thiết bị thông qua cờ `--device=all`.
### Nhận diện cửa sổ hoạt động (Sửa lỗi cho Flatpak)
- **Tự động gọi thư viện hệ thống X11** `libX11.so.6` thông qua `dlopen`: Đóng vai trò là phương án dự phòng thứ ba. Giải pháp này giúp nhận diện cửa sổ hoạt động bình thường bên trong sandbox Flatpak nơi `xdotool`/`xprop` bị chặn quyền truy cập.
### Chế độ mặc định (Default Mode)
- **Mặc định bật bộ gõ**: `start_enabled` hiện mặc định là `true` — chế độ tiếng Việt sẽ kích hoạt ngay khi mở ứng dụng.
---
## v0.1.4 (28-06-2026)
### Đóng gói Flatpak (Flatpak Packaging)
- Cung cấp đầy đủ các thành phần đóng gói Flatpak bao gồm daemon, CLI, khay hệ thống, uinputd, và kịch bản khởi chạy.
### Tài liệu hướng dẫn (Documentation)
- Cập nhật README chi tiết về hướng dẫn cài đặt và biên dịch ứng dụng thông qua Flatpak.
### Clipboard & Giả lập nhập liệu (Clipboard & Injection)
- Khắc phục triệt để lỗi tranh chấp clipboard khi giả lập ký tự Unicode tiếng Việt.
- Thiết lập quy trình tự động đóng gói `.deb``.AppImage` trên mỗi lượt đẩy mã nguồn lên GitHub thông qua GitHub Actions.
### Kiểm thử (Tests)
- Hoàn thành **106 bài kiểm thử** đạt yêu cầu (72 bài cho nhân bộ gõ, 16 cho dòng lệnh CLI, 12 cho giao thức, 5 cho tính năng tự động phục hồi và 1 cho quy tắc đặt dấu thanh).
---
## v0.1.3 (26-06-2026)
- Sửa lỗi cụm nguyên âm `ua-horn`, lưu và khôi phục ngữ cảnh clipboard, tối ưu hóa các phím chức năng điều khiển.
---
## v0.1.2 (26-06-2026)
- Chuyển tiếp phím gốc khi xóa đệm, tự động khôi phục từ tiếng Anh gõ nhầm.
- Sửa quy tắc dấu cho các cụm `qu`/`gi`/`uê`/`uơ`, bỏ lặp phím tự động, tối ưu hóa phím Enter.
---
## v0.1.1 (26-06-2026)
- Khắc phục lỗi nuốt phím Telex khi gõ dấu, duy trì kết nối X11 liên tục.
---
## v0.1.0 (26-06-2026)
- Bản phát hành đầu tiên — chuyển đổi từ bamboo engine, bắt phím evdev, giả lập uinput.

View file

@ -1,110 +0,0 @@
# Terminal VNI Input — Design & Implementation
## Goal
Make Vietnamese input work in terminal emulators without breaking TUI keyboard shortcuts.
## Approach: A + C
### A — Remove terminals from `bypass_apps`
All terminals are currently in `bypass_apps` (default config), which skips ALL engine
processing when the active window is a terminal. Removing them lets keystrokes flow
through the bamboo engine.
### C — Force VNI when terminal detected
When the active window is a terminal, the engine automatically uses VNI rules
(`1-9` for tones/marks) regardless of the global VNI/Telex setting.
This avoids key conflicts with TUI apps (vim's `j`, less's `s`, shell's `x`, etc.).
## How It Works
```
User config: input_method = "telex"
Terminal window focused → effective method = "vni" (forced by terminal_apps)
GUI window focused → effective method = "telex" (user's global setting)
```
- **Engine** runs with effective method
- **Tray** shows global method (so user sees their configured setting)
- **Ctrl+LeftShift** toggles global method, recomputes effective method
- **Ctrl+Space** toggles VN/EN as before
## Config Changes
```toml
[app_state]
terminal_apps = ["kitty", "alacritty", "foot", "wezterm", "konsole",
"gnome-terminal", "gnome-terminal-server", "kgx", "st", "urxvt", "xterm",
"termite", "terminator", "tilix", "deepin-terminal", "pantheon-terminal"]
terminal_input_method = "vni"
```
`bypass_apps` reduced to: `["steam", "dota", "csgo", "minecraft", "factorio"]`
## Implementation
### 1. `daemon/src/config.rs`
- Add `terminal_apps` (`Vec<String>`) and `terminal_input_method` (`String`) to `AppStateConfig`
- Add `default_terminal_apps()` returning the terminal list
- Add `default_terminal_method()` returning `"vni"`
- Remove all terminal names from `default_bypass_apps()`
### 2. `daemon/src/app_state.rs`
- Add fields: `terminal_apps`, `terminal_input_method`, `global_method`, `effective_method`
- `new()` accepts `terminal_apps`, `terminal_input_method`, `global_method`
- `update_effective_method()`: if current_app matches any terminal, effective = terminal method; else effective = global method. Called on window change.
- `set_terminal_config()`: updates terminal_apps/terminal_input_method from config reload
- `set_global_method()`: updates global_method, recomputes effective
- `effective_method()` getter
- `is_terminal_app()` — checks if current_app is a terminal
- `update_with_app()` calls `update_effective_method()` internally
- `update_lists()` also handles terminal_apps
### 3. `daemon/src/main.rs`
- `Daemon::new()` — pass terminal config to `AppStateManager`, call `update_effective_method()`
- `toggle_method()` — after toggling global method, call `app_state.set_global_method()` then `engine.set_method(app_state.effective_method())`
- `check_app_change_with()` — after app change, if effective method changed from engine's current, call `engine.set_method(effective)`
- `is_vn_control_key()` calls — change from `daemon.config.input_method` to `daemon.app_state.effective_method()`
- Config reload — update `update_lists()` call to include terminal fields
- Method status file — still writes **global** method (for tray display)
### 4. `install.sh` — Update default config block
### 5. `README.md` — Update config example
### 6. `NOTES/terminal-vni.md` — This file
## Testing Checklist
### Linux Mint (X11)
- [ ] Type VNI in shell: `viet1 nam``viết nam`
- [ ] Type Telex in shell: `vieets nam``vieets nam` (Telex NOT active in terminal)
- [ ] Ctrl+Space toggles VN/EN
- [ ] Ctrl+LeftShift toggles global method (terminal unaffected, tray shows global)
- [ ] Vim insert mode: VNI works, `j`/`x`/`s` pass through as regular keys
- [ ] Gemini-cli: VNI typed text appears correctly
- [ ] sudo passwd: engine auto-disables
- [ ] Switch terminal ↔ GUI: method resets per app
- [ ] Tray icon shows global method, not terminal override
### Ubuntu 24.04+ (Wayland)
- [ ] Same VNI typing tests
- [ ] GNOME Shell D-Bus window detection
- [ ] wl-copy paste-once path for Unicode chars
## Edge Cases
| Case | Behavior |
|------|----------|
| Terminal in bypass_apps | No IME at all (configurable override for power users) |
| User wants Telex in terminals | Set `terminal_input_method = "telex"` in config |
| Multiple terminals open | Each follows the same rule |
| IDE integrated terminal | Window class is "code", not terminal. Needs manual config |
| Password prompt in terminal | Process-tree detection still disables engine regardless of method |

121
README.md
View file

@ -2,7 +2,7 @@
<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.18-purple?style=for-the-badge" alt="Version">
<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-108_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>
@ -18,10 +18,6 @@
<sub>Zero underline &bull; No pre-edit buffer &bull; Backspace-Replay sync &bull; Built in Rust</sub>
</p>
<p align="center">
<a href="README.vi.md">Tiếng Việt</a>
</p>
---
## What is Viet+?
@ -49,7 +45,6 @@ Viet+ eliminates all of this. Keystrokes are **instantly converted to Unicode**
| **Window-Switch Reset** | Engine clears automatically on Alt+Tab |
| **CPU Priority** | Pinned to P-cores (0-3) + nice(-10) for low-latency input |
| **Uinput Injection** | `/dev/uinput` for reliable injection on X11 and Wayland |
| **Terminal Support** | ✅ Works in all major terminals: kitty, alacritty, gnome-terminal, konsole, foot, wezterm, st, urxvt, xterm |
| **Password Auto-Detection** | 4 layers: AT-SPI2 → sudo process → window-title → window-class |
| **Tray Icon** | Shows current mode: Red VN / Blue TLX / Gray EN |
| **GNOME/Wayland** | Native GNOME Shell D-Bus integration |
@ -116,76 +111,41 @@ Both **VNI** and **Telex** are fully supported. Switch via **Ctrl+LeftShift** or
---
## Distro Support
| Tier | Distro | Install Method | Status |
|------|--------|---------------|--------|
| ✅ **Supported** | Ubuntu, Debian, Linux Mint, Pop!_OS, elementary OS, Zorin, Neon | `apt` (auto-detected) | Tested, one-command install |
| ✅ **Supported** | Fedora, RHEL, CentOS | `dnf` (auto-detected) | Tested, one-command install |
| ✅ **Supported** | Arch, Manjaro | `pacman` (auto-detected) | Tested, one-command install |
| ⚠️ **Might support** | openSUSE, Solus, Void | `zypper`/`eopkg`/`xbps` (manual) | Package names may differ; run install.sh and install missing deps manually if it fails |
| ❌ **Not supported** | NixOS, Alpine, Gentoo, others | N/A | No package manager entry — install deps manually, then `cargo build --release` |
> **⚠️ Tray icon note:** GNOME (Ubuntu) and Cinnamon (Mint) need a StatusNotifier watcher for the tray to appear:
> - Ubuntu: `sudo apt install gnome-shell-extension-appindicator`
> - Mint: pre-installed; works out of the box
---
## Installation
### One-Command Install
Works on all ✅ **Supported** distros above. The script auto-detects your package manager:
**From GitHub (recommended):**
```bash
git clone https://github.com/vndangkhoa/vietc.git /tmp/vietc \
&& cd /tmp/vietc && sudo ./install.sh
```
**From Forgejo (self-hosted):**
```bash
git clone https://git.khoavo.myds.me/vndangkhoa/vietc.git /tmp/vietc \
&& cd /tmp/vietc && sudo ./install.sh
```
The script installs dependencies, compiles, installs to `/usr/bin/`, sets up uinput udev rules, and adds your user to the `input` group.
**After install:** Log out and log back in, then launch `vietc-tray` from your application menu.
### One-Command Uninstall
**From GitHub:**
```bash
curl -sSL https://raw.githubusercontent.com/vndangkhoa/vietc/main/uninstall.sh | sudo bash
```
**From Forgejo:**
```bash
curl -sSL https://git.khoavo.myds.me/vndangkhoa/vietc/raw/branch/main/uninstall.sh | sudo bash
```
### Manual Build & Run
### Build from Source
```bash
# Install dependencies
# Dependencies (Ubuntu/Debian)
sudo apt install git curl build-essential pkg-config \
libx11-dev libxtst-dev libevdev-dev libdbus-1-dev libwayland-dev wl-clipboard
libx11-dev libxtst-dev libevdev-dev libdbus-1-dev
# Enable accessibility (Ubuntu Wayland — for password detection)
gsettings set org.gnome.desktop.a11y.applications screen-reader-enabled true
# Build
# Clone and build
git clone https://github.com/vndangkhoa/vietc.git
cd vietc
cargo build --release
# Run (Mint — no sudo needed for uinput)
./target/release/vietc
# Add user to input group (for keyboard capture)
sudo usermod -aG input $USER
# Log out and log back in
# Run (Ubuntu — needs sudo for keyboard grab)
sudo ./target/release/vietc
# Run
./target/release/vietc
```
### Wayland (Ubuntu 24.04+) — Additional steps
```bash
sudo apt install wl-clipboard
gsettings set org.gnome.desktop.a11y.applications screen-reader-enabled true
```
### uinput Access (recommended)
```bash
sudo modprobe uinput
echo 'KERNEL=="uinput", GROUP="input", MODE="0660"' | sudo tee /etc/udev/rules.d/99-uinput.rules
sudo udevadm control --reload-rules && sudo udevadm trigger
```
---
@ -201,10 +161,6 @@ toggle_method_key = "shift" # Ctrl+Shift to toggle VNI/Telex
start_enabled = true # Vietnamese by default
grab = true # grab keyboard (evdev)
[auto_restore]
enabled = false # Auto-restore English words (defaults to false)
trigger_keys = ["space", "escape"]
[password_detection]
enabled = true
check_atspi2 = true
@ -217,12 +173,9 @@ password_apps = ["pinentry", "pinentry-gtk-2", "pinentry-qt",
[app_state]
enabled = true
english_apps = ["code", "vim"]
english_apps = ["code", "vim", "kitty", "foot"]
vietnamese_apps = ["telegram", "discord", "firefox"]
bypass_apps = ["steam"]
terminal_apps = ["kitty", "alacritty", "gnome-terminal", "konsole", "foot",
"wezterm", "st", "urxvt", "xterm"]
terminal_input_method = "vni" # Automatically switch to VNI when running in a terminal app
bypass_apps = ["kitty", "alacritty", "steam"]
[macros]
ko = "không"
@ -230,14 +183,6 @@ dc = "được"
vs = "với"
```
### Terminal Usage
Viet+ works perfectly in terminals. When running inside a terminal (e.g., gnome-terminal, kitty), Vietnamese input is automatically enabled using the input method specified by `terminal_input_method` under `[app_state]`.
Supported terminals: `kitty`, `alacritty`, `gnome-terminal`, `konsole`, `foot`, `wezterm`, `st`, `urxvt`, `xterm`
Type Vietnamese directly — no pre-edit buffer, no underline, no duplication. Just type VNI or Telex digits and see Unicode characters instantly!
---
## Architecture
@ -264,18 +209,6 @@ vietc/
---
## Roadmap
### v0.1.19
- [ ] Wayland input method protocol (`zwp_input_method_v2`) — eliminates clipboard + backspace race, fixes missing spaces permanently
- [ ] Event-based AT-SPI2 focus monitoring (subscribe to a11y focus events, no polling)
### v0.1.20
- [ ] GitHub Actions CI for automated .deb builds
- [ ] Flatpak re-add for immutable distros
---
## License
MIT License — see [LICENSE](LICENSE) for details.

View file

@ -1,287 +0,0 @@
<p align="center">
<img src="https://img.shields.io/badge/Nền_tảng-Linux-blue?style=for-the-badge" alt="Platform">
<img src="https://img.shields.io/badge/Ngôn_ngữ-Rust-orange?style=for-the-badge" alt="Rust">
<img src="https://img.shields.io/badge/Giấy_phép-MIT-green?style=for-the-badge" alt="License">
<img src="https://img.shields.io/badge/Phiên_bản-0.1.18-purple?style=for-the-badge" alt="Version">
<img src="https://img.shields.io/badge/Kiểm_thử-108_đạt-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>
<h1 align="center">
<br>
Viet+
<br>
</h1>
<p align="center">
<b>Bộ gõ tiếng Việt cho Linux</b><br>
<sub>Không gạch chân &bull; Không bộ đệm pre-edit &bull; Đồng bộ Backspace-Replay &bull; Viết bằng Rust</sub>
</p>
<p align="center">
<a href="README.md">English</a>
</p>
---
## Viet+ là gì?
Viet+ là một bộ gõ tiếng Việt dành cho Linux sử dụng hướng tiếp cận hoàn toàn khác biệt so với tất cả các bộ gõ khác: **Gõ trực tiếp (Direct Input)**.
Hầu hết các bộ gõ tiếng Việt hiện nay sử dụng **bộ đệm pre-edit** — khi gõ, các ký tự sẽ nằm trong một bộ đệm tạm thời với dấu gạch chân bên dưới, và chỉ thực sự được gửi đi khi bạn hoàn thành từ đó. Điều này gây ra lỗi lặp từ, xao nhãng bởi dấu gạch chân, lỗi sao chép/dán, và mất đồng bộ giữa bộ gõ với nội dung hiển thị trên màn hình.
Viet+ loại bỏ hoàn toàn những nhược điểm trên. Các phím gõ được **chuyển đổi ngay lập tức sang Unicode** — những gì bạn gõ là những gì bạn thấy. Không bộ đệm tạm thời. Không gạch chân. Không lặp chữ.
---
## Tính năng nổi bật
| Tính năng | Nguyên lý hoạt động |
|-----------|---------------------|
| **Gõ trực tiếp** | Không dùng bộ đệm pre-edit. Ký tự được hiển thị ngay lập tức thông qua cơ chế giả lập bàn phím uinput |
| **VNI & Telex** | Hỗ trợ đầy đủ cả hai phương thức gõ, chuyển đổi nhanh bằng phím nóng Ctrl+Shift |
| **Bamboo Engine** | Sử dụng mô hình biến đổi Bamboo — ghép âm, bỏ dấu, đặt dấu và xóa dấu linh hoạt |
| **Ghép âm thông minh** | Hỗ trợ tự động thêm râu/mũ như `uo→ươ` (có hỗ trợ xóa ngược), tự động đặt dấu móc `ua→ưa` |
| **Gõ tắt (Macro)** | Hỗ trợ mở rộng viết tắt như `ko → không`, `dc → được`, và cho phép tự định nghĩa |
| **Giữ nguyên hoa/thường** | Bảo toàn định dạng viết hoa như `Tieengs → Tiếng`, `TIEENGS → TIẾNG` |
| **Nhớ trạng thái theo ứng dụng** | Tự động nhớ trạng thái gõ Anh/Việt cho từng ứng dụng riêng biệt, lưu trữ tại `overrides.toml` |
| **Tải lại cấu hình nóng** | Các thay đổi trong tệp cấu hình được áp dụng ngay lập tức mà không cần khởi động lại bộ gõ |
| **Đặt lại khi chuyển cửa sổ** | Tự động xóa bộ đệm của bộ gõ khi nhấn Alt+Tab chuyển ứng dụng |
| **Độ ưu tiên CPU cao** | Được gán cố định vào các nhân P-core (0-3) và mức ưu tiên nice(-10) để giảm tối đa độ trễ |
| **Giả lập uinput** | Sử dụng `/dev/uinput` giúp hoạt động ổn định trên cả X11 và Wayland |
| **Hỗ trợ Terminal** | ✅ Hoạt động mượt mà trên tất cả các terminal phổ biến: kitty, alacritty, gnome-terminal, konsole, foot, wezterm, st, urxvt, xterm |
| **Tự động nhận diện mật khẩu** | 4 lớp bảo vệ: AT-SPI2 → tiến trình sudo → tiêu đề cửa sổ → lớp (class) cửa sổ |
| **Biểu tượng khay hệ thống** | Hiển thị trạng thái hiện tại: Đỏ (VN) / Xanh dương (TLX) / Xám (EN) |
| **GNOME/Wayland** | Tích hợp sâu thông qua cơ chế D-Bus của GNOME Shell |
---
## Phương thức gõ
Viet+ hỗ trợ đầy đủ hai phương thức gõ **VNI****Telex**. Bạn có thể chuyển đổi qua lại bằng phím tắt **Ctrl+LeftShift** hoặc qua menu khay hệ thống.
### VNI
| Phím gõ | Kết quả | Ví dụ |
|---------|---------|-------|
| `1` | á (sắc) | `a1``á` |
| `2` | à (huyền) | `a2``à` |
| `3` | ả (hỏi) | `a3``ả` |
| `4` | ã (ngã) | `a4``ã` |
| `5` | ạ (nặng) | `a5``ạ` |
| `6` | â/ê/ô | `a6→â`, `e6→ê`, `o6→ô` |
| `7` | ơ/ư | `o7→ơ`, `u7→ư` |
| `8` | ă | `a8→ă` |
| `9` | đ | `d9→đ` |
### Telex
| Phím gõ | Kết quả | Ví dụ |
|---------|---------|-------|
| `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` | ươ | `chuongw→chương` |
---
## Phím tắt mặc định
| Tổ hợp phím | Hành động |
|-------------|-----------|
| **Ctrl+Space** | Bật/Tắt bộ gõ tiếng Việt |
| **Ctrl+LeftShift** | Chuyển đổi giữa VNI ↔ Telex |
---
## Tự động nhận diện mật khẩu
Viet+ tích hợp hệ thống nhận diện mật khẩu 4 lớp tự động. Khi phát hiện trường nhập mật khẩu, bộ gõ tiếng Việt sẽ tự động tạm thời tắt để tránh lỗi gõ ký tự đặc biệt:
| Lớp nhận diện | Phương pháp | Đối tượng phát hiện |
|---------------|-------------|---------------------|
| 1 | AT-SPI2 D-Bus (kiểm tra thuộc tính a11y) | Các trường mật khẩu trong các ứng dụng có hỗ trợ a11y |
| 2 | Cây tiến trình (pstree) | Tiến trình `sudo` / `passwd` chạy trong terminal |
| 3 | Từ khóa tiêu đề cửa sổ | Cửa sổ có tiêu đề chứa các từ khóa như `password`, `sudo`, `mật khẩu` |
| 4 | Lớp cửa sổ (Window class) | Các hộp thoại bảo mật như pinentry, polkit, kwallet |
---
## Khả năng hỗ trợ Distro
| Mức độ | Bản phân phối (Distro) | Cách cài đặt | Trạng thái |
|--------|------------------------|--------------|------------|
| ✅ **Hỗ trợ tốt** | Ubuntu, Debian, Linux Mint, Pop!_OS, elementary OS, Zorin, Neon | Trình quản lý `apt` (tự động nhận diện) | Đã kiểm thử, cài đặt bằng một câu lệnh |
| ✅ **Hỗ trợ tốt** | Fedora, RHEL, CentOS | Trình quản lý `dnf` (tự động nhận diện) | Đã kiểm thử, cài đặt bằng một câu lệnh |
| ✅ **Hỗ trợ tốt** | Arch, Manjaro | Trình quản lý `pacman` (tự động nhận diện) | Đã kiểm thử, cài đặt bằng một câu lệnh |
| ⚠️ **Có thể hỗ trợ** | openSUSE, Solus, Void | Trình quản lý `zypper`/`eopkg`/`xbps` (thủ công) | Tên gói phụ thuộc có thể khác biệt; chạy install.sh và tự cài thủ công các gói thiếu nếu lỗi |
| ❌ **Chưa hỗ trợ** | NixOS, Alpine, Gentoo, các hệ thống khác | N/A | Không có sẵn trong quản lý gói — cần cài gói phụ thuộc thủ công rồi chạy `cargo build --release` |
> **⚠️ Lưu ý về biểu tượng khay hệ thống:** Môi trường GNOME (Ubuntu) và Cinnamon (Mint) cần có phần mềm theo dõi StatusNotifier để hiển thị khay hệ thống:
> - Ubuntu: `sudo apt install gnome-shell-extension-appindicator`
> - Mint: Đã được tích hợp sẵn, hoạt động ngay sau khi cài đặt
---
## Cài đặt
### Cài đặt nhanh bằng một câu lệnh
Áp dụng cho tất cả các distro được đánh dấu ✅ **Hỗ trợ tốt** ở trên. Kịch bản cài đặt sẽ tự động nhận diện trình quản lý gói của hệ thống:
**Từ GitHub (khuyên dùng):**
```bash
git clone https://github.com/vndangkhoa/vietc.git /tmp/vietc \
&& cd /tmp/vietc && sudo ./install.sh
```
**Từ Forgejo (máy chủ riêng):**
```bash
git clone https://git.khoavo.myds.me/vndangkhoa/vietc.git /tmp/vietc \
&& cd /tmp/vietc && sudo ./install.sh
```
Kịch bản sẽ tự động cài các thư viện phụ thuộc, biên dịch mã nguồn, cài đặt chương trình vào `/usr/bin/`, thiết lập phân quyền cho uinput qua udev rules, và thêm người dùng hiện tại vào nhóm `input`.
**Sau khi cài đặt:** Đăng xuất (Log out) và đăng nhập lại hệ thống, sau đó khởi chạy ứng dụng `vietc-tray` từ menu ứng dụng.
### Gỡ cài đặt nhanh bằng một câu lệnh
**Từ GitHub:**
```bash
curl -sSL https://raw.githubusercontent.com/vndangkhoa/vietc/main/uninstall.sh | sudo bash
```
**Từ Forgejo:**
```bash
curl -sSL https://git.khoavo.myds.me/vndangkhoa/vietc/raw/branch/main/uninstall.sh | sudo bash
```
### Biên dịch & Chạy thủ công
```bash
# Cài đặt các thư viện phụ thuộc
sudo apt install git curl build-essential pkg-config \
libx11-dev libxtst-dev libevdev-dev libdbus-1-dev libwayland-dev wl-clipboard
# Kích hoạt tính năng hỗ trợ tiếp cận (Ubuntu Wayland — dùng cho nhận diện mật khẩu)
gsettings set org.gnome.desktop.a11y.applications screen-reader-enabled true
# Biên dịch mã nguồn
git clone https://github.com/vndangkhoa/vietc.git
cd vietc
cargo build --release
# Chạy (Hệ điều hành Mint — không cần quyền sudo cho uinput)
./target/release/vietc
# Chạy (Hệ điều hành Ubuntu — cần quyền sudo để bắt sự kiện bàn phím)
sudo ./target/release/vietc
```
---
## Cấu hình
Tệp cấu hình đặt tại: `~/.config/vietc/config.toml` hoặc `./vietc.toml`
```toml
input_method = "vni" # "vni" hoặc "telex"
toggle_key = "space" # Ctrl+Space để bật/tắt gõ tiếng Việt
toggle_method_key = "shift" # Ctrl+Shift để chuyển đổi VNI/Telex
start_enabled = true # Mặc định bật tiếng Việt khi khởi động
grab = true # Độc chiếm bàn phím (evdev)
[auto_restore]
enabled = false # Tự động hoàn tác từ tiếng Anh gõ nhầm (mặc định tắt)
trigger_keys = ["space", "escape"]
[password_detection]
enabled = true
check_atspi2 = true
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"]
vietnamese_apps = ["telegram", "discord", "firefox"]
bypass_apps = ["steam"]
terminal_apps = ["kitty", "alacritty", "gnome-terminal", "konsole", "foot",
"wezterm", "st", "urxvt", "xterm"]
terminal_input_method = "vni" # Tự động chuyển sang VNI khi chạy trong terminal
[macros]
ko = "không"
dc = "được"
vs = "với"
```
### Sử dụng trong Terminal
Viet+ hoạt động cực kỳ mượt mà trong các môi trường terminal. Khi bạn sử dụng một terminal (ví dụ: gnome-terminal, kitty), bộ gõ tiếng Việt sẽ tự động áp dụng phương thức gõ được thiết lập tại cấu hình `terminal_input_method` trong mục `[app_state]`.
Các terminal được hỗ trợ sẵn: `kitty`, `alacritty`, `gnome-terminal`, `konsole`, `foot`, `wezterm`, `st`, `urxvt`, `xterm`
Gõ tiếng Việt trực tiếp — không có thanh gạch chân khó chịu, không lặp từ. Chỉ cần gõ phím số VNI hoặc phím chữ Telex và ký tự Unicode sẽ xuất hiện ngay lập tức!
---
## Kiến trúc hệ thống
```
vietc/
├── engine/ # Bộ xử lý chuyển đổi chữ tiếng Việt (chuyển đổi từ bamboo-core)
├── protocol/ # Thư viện bắt và giả lập sự kiện bàn phím
│ ├── uinput_monitor.rs # Giả lập qua /dev/uinput (chính)
│ ├── x11_inject.rs # Giả lập qua XTest (dự phòng)
│ ├── x11_capture.rs # Bắt phím qua XRecord
│ └── wayland_im.rs # Giao thức Wayland IM (đang phát triển)
├── daemon/ # Tiến trình nền chính điều khiển bộ gõ
│ ├── main.rs # Vòng lặp sự kiện, chiếm quyền bàn phím, xử lý tín hiệu
│ ├── config.rs # Tải tệp cấu hình TOML + tự động cập nhật cấu hình nóng
│ ├── app_state.rs # Quản lý bộ nhớ trạng thái theo ứng dụng + nhận diện mật khẩu
│ ├── password_detector.rs # Nhận diện trường mật khẩu qua AT-SPI2 D-Bus
│ └── display.rs # Nhận diện máy chủ đồ họa X11/Wayland/Compositor
├── ui/ # Biểu tượng khay hệ thống (sử dụng ksni)
│ └── tray.rs # Khay hiển thị chế độ VN/TLX/EN
├── cli/ # Công cụ dòng lệnh kiểm thử bộ xử lý tiếng Việt
└── uinputd/ # Tiến trình đặc quyền quản lý socket uinput
```
---
## Lộ trình phát triển
### Phiên bản v0.1.19
- [ ] Hỗ trợ giao thức nhập liệu Wayland (`zwp_input_method_v2`) — loại bỏ việc dùng clipboard và tranh chấp Backspace, khắc phục triệt để lỗi mất khoảng trắng.
- [ ] Cơ chế giám sát tiêu điểm AT-SPI2 hướng sự kiện (đăng ký nhận sự kiện tiêu điểm từ a11y thay vì liên tục truy vấn).
### Phiên bản v0.1.20
- [ ] Tự động hóa việc đóng gói tệp tin `.deb` bằng GitHub Actions CI.
- [ ] Khôi phục hỗ trợ Flatpak cho các hệ điều hành bất biến (immutable distros).
---
## Giấy phép sử dụng
Giấy phép MIT — xem chi tiết tại tệp tin [LICENSE](LICENSE).
---
<p align="center">
<sub>Được phát triển với tình yêu dành cho cộng đồng Linux Việt Nam</sub>
</p>

87
RELEASE_CHECKLIST.md Normal file
View file

@ -0,0 +1,87 @@
# Release Checklist
## When to release
- New feature or bugfix that should be distributed to users
- .deb packaging changes validated
- All tests passing (`cargo test`)
---
## Step-by-step
### 1. Bump version
Update version in:
- `daemon/Cargo.toml`
- `cli/Cargo.toml`
- `engine/Cargo.toml`
- `protocol/Cargo.toml`
- `ui/Cargo.toml`
- `uinputd/Cargo.toml`
- `README.md` version badge
### 2. Update CHANGELOG.md
Add a new entry under the version heading:
```markdown
## vX.Y.Z (YYYY-MM-DD)
### Added
- new features...
### Fixed
- bug fixes...
### Changed
- behavior changes...
```
### 3. Build the .deb
```bash
make deb
```
Verify the package was created:
```bash
ls -lh packaging/deb/vietc_*.deb
```
### 4. Install & test
```bash
sudo dpkg -i packaging/deb/vietc_X.Y.Z-1_amd64.deb
```
Test:
- Search "Viet+" in the application menu — the tray icon entry should appear
- Launch from menu — tray icon should show, Vietnamese input should work (VNI, Ctrl+Space to toggle)
- The tray should autostart on next login (XDG autostart installed)
### 5. Commit and push
```bash
git add -A
git commit -m "release: vX.Y.Z — <summary>"
git push origin main
```
### 6. Create a release on Forgejo/GitHub
Attach the .deb package (`vietc_X.Y.Z-1_amd64.deb`) as a release asset.
---
## Quick command
```bash
VERSION=X.Y.Z && \
sed -i "s/^version = .*/version = \"$VERSION\"/" \
daemon/Cargo.toml cli/Cargo.toml engine/Cargo.toml \
protocol/Cargo.toml ui/Cargo.toml uinputd/Cargo.toml && \
sed -i "s/Version-[0-9.]*-purple/Version-$VERSION-purple/" README.md && \
echo "Version bumped to $VERSION"
```

View file

@ -495,14 +495,6 @@ pub struct AppStateManager {
vietnamese_apps: Vec<String>,
/// Bypass apps from config
bypass_apps: Vec<String>,
/// Terminal emulator class names (force VNI mode)
terminal_apps: Vec<String>,
/// Input method forced in terminals
terminal_input_method: String,
/// User's global input method (VNI/Telex)
global_method: String,
/// Effective method after terminal override
effective_method: String,
/// Global enabled state
global_enabled: bool,
/// Password detection config
@ -522,27 +514,14 @@ impl AppStateManager {
english_apps: Vec<String>,
vietnamese_apps: Vec<String>,
bypass_apps: Vec<String>,
terminal_apps: Vec<String>,
terminal_input_method: String,
global_method: String,
global_enabled: bool,
) -> Self {
let effective_method = Self::compute_effective_method(
&global_method,
&terminal_input_method,
&terminal_apps,
"",
);
Self {
current_app: String::new(),
overrides: HashMap::new(),
english_apps: english_apps.iter().map(|s| s.to_lowercase()).collect(),
vietnamese_apps: vietnamese_apps.iter().map(|s| s.to_lowercase()).collect(),
bypass_apps: bypass_apps.iter().map(|s| s.to_lowercase()).collect(),
terminal_apps: terminal_apps.iter().map(|s| s.to_lowercase()).collect(),
terminal_input_method,
global_method,
effective_method,
global_enabled,
password_enabled: false,
check_atspi2: true,
@ -554,22 +533,6 @@ impl AppStateManager {
}
}
/// Compute effective method: use terminal override if current_app is a terminal,
/// otherwise use the global method.
fn compute_effective_method(
global_method: &str,
terminal_method: &str,
terminal_apps: &[String],
current_app: &str,
) -> String {
for pattern in terminal_apps {
if current_app.contains(pattern.as_str()) {
return terminal_method.to_string();
}
}
global_method.to_string()
}
/// Update password detection config
pub fn set_password_config(
&mut self,
@ -586,48 +549,6 @@ impl AppStateManager {
self.password_apps = password_apps.iter().map(|s| s.to_lowercase()).collect();
}
/// Update terminal detection config
pub fn set_terminal_config(
&mut self,
terminal_apps: Vec<String>,
terminal_input_method: String,
) {
self.terminal_apps = terminal_apps.iter().map(|s| s.to_lowercase()).collect();
self.terminal_input_method = terminal_input_method;
self.update_effective_method();
}
/// Set the user's global input method and recompute effective method
pub fn set_global_method(&mut self, method: &str) {
self.global_method = method.to_string();
self.update_effective_method();
}
/// Recompute effective method based on terminal override
pub fn update_effective_method(&mut self) {
self.effective_method = Self::compute_effective_method(
&self.global_method,
&self.terminal_input_method,
&self.terminal_apps,
&self.current_app,
);
}
/// Get the effective input method (terminal override applied)
pub fn effective_method(&self) -> &str {
&self.effective_method
}
/// Check if the current app is a terminal emulator
pub fn is_terminal_app(&self) -> bool {
for pattern in &self.terminal_apps {
if self.current_app.contains(pattern.as_str()) {
return true;
}
}
false
}
/// 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 {
@ -694,23 +615,13 @@ impl AppStateManager {
}
let old_app = self.current_app.clone();
let old_is_terminal = self.is_terminal_app();
self.current_app = new_class;
eprintln!("[vietc] App: {}{}", old_app, self.current_app);
// Recompute effective method on window change
self.update_effective_method();
let new_is_terminal = self.is_terminal_app();
let method_changed = old_is_terminal != new_is_terminal;
let should_enable = self.get_default_state();
if method_changed {
Some(should_enable) // signal caller that method might have changed
} else {
Some(should_enable)
}
}
/// Get the default Vietnamese state for the current app
pub fn get_default_state(&self) -> bool {
@ -769,21 +680,15 @@ impl AppStateManager {
english_apps: Vec<String>,
vietnamese_apps: Vec<String>,
bypass_apps: Vec<String>,
terminal_apps: Vec<String>,
terminal_input_method: String,
) -> &Self {
self.english_apps = english_apps.iter().map(|s| s.to_lowercase()).collect();
self.vietnamese_apps = vietnamese_apps.iter().map(|s| s.to_lowercase()).collect();
self.bypass_apps = bypass_apps.iter().map(|s| s.to_lowercase()).collect();
self.terminal_apps = terminal_apps.iter().map(|s| s.to_lowercase()).collect();
self.terminal_input_method = terminal_input_method;
self.update_effective_method();
eprintln!(
"[vietc] App lists updated: {} English, {} Vietnamese, {} Bypass, {} Terminal",
"[vietc] App lists updated: {} English, {} Vietnamese, {} Bypass",
self.english_apps.len(),
self.vietnamese_apps.len(),
self.bypass_apps.len(),
self.terminal_apps.len()
self.bypass_apps.len()
);
self
}
@ -831,64 +736,18 @@ fn log_password_detection(method: &str, context: &str) {
eprintln!("[vietc] Password field detected via {}: {}", method, context);
}
/// Walk up the process tree from `pid` to PID 1 and return all ancestor PIDs.
fn get_ancestor_pids(pid: u32) -> Vec<u32> {
let mut ancestors = Vec::new();
let mut current = pid;
loop {
ancestors.push(current);
let path = format!("/proc/{}/status", current);
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => break,
};
let ppid = match content
.lines()
.find(|l| l.starts_with("PPid:"))
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|s| s.parse::<u32>().ok())
{
Some(0) | None => break,
Some(p) => p,
};
if ppid == current {
break;
}
current = ppid;
}
ancestors
}
/// Check if the given PID or any of its descendants is running a password
/// prompt (sudo, passwd, pkexec). Excludes the daemon's own ancestor chain
/// so that running `sudo vietc-daemon` from the same terminal doesn't cause
/// a false positive.
/// Check if the given PID or any of its descendants is running sudo/passwd.
/// Uses pstree to scan the full process tree (handles any depth):
/// terminal → shell → sudo → passwd
/// gnome-terminal-server → gnome-terminal → bash → sudo
fn is_sudo_process(pid: u32) -> bool {
// Build the daemon's ancestor PID chain to exclude self from detection
let daemon_pid = unsafe { libc::getpid() as u32 };
let daemon_ancestors = get_ancestor_pids(daemon_pid);
if let Ok(output) = Command::new("pstree")
.args(["-p", &pid.to_string()])
.output()
{
let tree = String::from_utf8_lossy(&output.stdout);
for target in &["sudo(", "passwd(", "pkexec("] {
let mut start = 0;
while let Some(pos) = tree[start..].find(target) {
let abs_pos = start + pos;
let after = &tree[abs_pos + target.len()..];
let found_pid = after
.split(')')
.next()
.and_then(|s| s.parse::<u32>().ok());
// Skip if this PID is the daemon's own ancestor (e.g. `sudo vietc-daemon`)
if let Some(p) = found_pid {
if target == &"sudo(" && daemon_ancestors.contains(&p) {
start = abs_pos + 1;
continue;
}
}
if tree.contains(target) {
return true;
}
}

View file

@ -94,12 +94,6 @@ pub struct AppStateConfig {
#[serde(default = "default_bypass_apps")]
pub bypass_apps: Vec<String>,
#[serde(default = "default_terminal_apps")]
pub terminal_apps: Vec<String>,
#[serde(default = "default_terminal_method")]
pub terminal_input_method: String,
}
impl Default for AutoRestoreConfig {
@ -118,8 +112,6 @@ impl Default for AppStateConfig {
english_apps: default_english_apps(),
vietnamese_apps: default_vietnamese_apps(),
bypass_apps: default_bypass_apps(),
terminal_apps: default_terminal_apps(),
terminal_input_method: default_terminal_method(),
}
}
}
@ -183,16 +175,6 @@ fn default_english_apps() -> Vec<String> {
}
fn default_bypass_apps() -> Vec<String> {
vec![
"steam".into(),
"dota".into(),
"csgo".into(),
"minecraft".into(),
"factorio".into(),
]
}
fn default_terminal_apps() -> Vec<String> {
vec![
"terminal".into(),
"kitty".into(),
@ -201,26 +183,17 @@ fn default_terminal_apps() -> Vec<String> {
"wezterm".into(),
"konsole".into(),
"gnome-terminal".into(),
"gnome-terminal-server".into(),
"kgx".into(),
"st".into(),
"urxvt".into(),
"xterm".into(),
"termite".into(),
"terminator".into(),
"tilix".into(),
"deepin-terminal".into(),
"pantheon-terminal".into(),
"blackbox".into(),
"contour".into(),
"cool-retro-term".into(),
"steam".into(),
"dota".into(),
"csgo".into(),
"minecraft".into(),
"factorio".into(),
]
}
fn default_terminal_method() -> String {
"vni".into()
}
fn default_vietnamese_apps() -> Vec<String> {
vec![
"telegram".into(),
@ -420,16 +393,12 @@ foo = "bar"
[app_state]
english_apps = ["vim", "neovim"]
vietnamese_apps = ["zalo", "messenger"]
bypass_apps = ["steam"]
terminal_apps = ["kitty"]
terminal_input_method = "telex"
bypass_apps = ["kitty"]
"#;
let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.app_state.english_apps, vec!["vim", "neovim"]);
assert_eq!(config.app_state.vietnamese_apps, vec!["zalo", "messenger"]);
assert_eq!(config.app_state.bypass_apps, vec!["steam"]);
assert_eq!(config.app_state.terminal_apps, vec!["kitty"]);
assert_eq!(config.app_state.terminal_input_method, "telex");
assert_eq!(config.app_state.bypass_apps, vec!["kitty"]);
}
#[test]
@ -451,31 +420,11 @@ terminal_input_method = "telex"
#[test]
fn default_config_bypass_apps() {
let config = Config::default();
assert!(config.app_state.bypass_apps.contains(&"steam".to_string()));
assert!(!config
assert!(config.app_state.bypass_apps.contains(&"kitty".to_string()));
assert!(config
.app_state
.bypass_apps
.contains(&"kitty".to_string()));
}
#[test]
fn default_config_terminal_apps() {
let config = Config::default();
assert!(config.app_state.terminal_apps.contains(&"kitty".to_string()));
assert!(config.app_state.terminal_apps.contains(&"gnome-terminal".to_string()));
assert_eq!(config.app_state.terminal_input_method, "vni");
}
#[test]
fn parse_terminal_config() {
let toml = r#"
[app_state]
terminal_apps = ["foot", "alacritty"]
terminal_input_method = "telex"
"#;
let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.app_state.terminal_apps, vec!["foot", "alacritty"]);
assert_eq!(config.app_state.terminal_input_method, "telex");
.contains(&"alacritty".to_string()));
}
#[test]

View file

@ -1,7 +1,6 @@
// SPDX-License-Identifier: MIT
use std::collections::HashSet;
use std::fs;
use std::os::unix::io::AsRawFd;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
@ -143,9 +142,6 @@ impl Daemon {
config.app_state.english_apps.clone(),
config.app_state.vietnamese_apps.clone(),
config.app_state.bypass_apps.clone(),
config.app_state.terminal_apps.clone(),
config.app_state.terminal_input_method.clone(),
config.input_method.clone(),
config.start_enabled,
);
app_state.load_overrides();
@ -193,23 +189,17 @@ impl Daemon {
}
fn toggle_method(&mut self) {
let new_global = match self.config.input_method.as_str() {
"vni" => "telex",
_ => "vni",
let new_method = match self.config.input_method.as_str() {
"vni" => InputMethod::Telex,
_ => InputMethod::Vni,
};
self.config.input_method = new_global.into();
self.app_state.set_global_method(new_global);
let effective = self.app_state.effective_method();
let engine_method = match effective {
"vni" => InputMethod::Vni,
_ => InputMethod::Telex,
self.config.input_method = match new_method {
InputMethod::Vni => "vni".into(),
InputMethod::Telex => "telex".into(),
};
self.engine.set_method(engine_method);
self.engine.set_method(new_method);
self.write_method_status();
log_info(&format!(
"[vietc] Input method toggled: global={}, effective={}",
self.config.input_method, effective
));
log_info(&format!("[vietc] Input method toggled to: {}", self.config.input_method));
}
fn sync_status_file(&mut self) {
@ -236,6 +226,11 @@ impl Daemon {
match Config::load_from(&self.config_path) {
Ok(new_config) => {
let method = match new_config.input_method.as_str() {
"vni" => InputMethod::Vni,
_ => InputMethod::Telex,
};
self.engine.set_method(method);
self.engine
.set_auto_restore(new_config.auto_restore.enabled);
@ -244,23 +239,12 @@ impl Daemon {
self.engine.add_macro(shortcut.clone(), expansion.clone());
}
self.app_state.set_global_method(&new_config.input_method);
self.app_state.update_lists(
new_config.app_state.english_apps.clone(),
new_config.app_state.vietnamese_apps.clone(),
new_config.app_state.bypass_apps.clone(),
new_config.app_state.terminal_apps.clone(),
new_config.app_state.terminal_input_method.clone(),
);
// Apply effective method (terminal override considered)
let effective = self.app_state.effective_method();
let engine_method = match effective {
"vni" => InputMethod::Vni,
_ => InputMethod::Telex,
};
self.engine.set_method(engine_method);
self.app_state.set_password_config(
new_config.password_detection.enabled,
new_config.password_detection.check_atspi2,
@ -310,6 +294,8 @@ impl Daemon {
commands.push(OutputCommand::Type(text));
}
}
} else {
// No event — key was consumed or ignored by engine
}
commands
@ -478,14 +464,6 @@ impl Daemon {
self.engine.set_enabled(should_enable);
self.write_status();
}
// Apply effective method (terminal override)
let effective = self.app_state.effective_method();
let engine_method = match effective {
"vni" => InputMethod::Vni,
_ => InputMethod::Telex,
};
// set_method also resets the engine buffer (safe — window already changed)
self.engine.set_method(engine_method);
}
}
@ -853,31 +831,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
// Try evdev first: open ALL keyboard-capable devices and poll them
// simultaneously. This handles VMs where input arrives on a different
// event node than the first device found.
match open_keyboard_devices() {
Ok(mut devices) => {
match run_with_evdev(
&mut devices,
// Try evdev first (more reliable than X11 XRecord)
match open_keyboard_device() {
Ok((device, path)) => {
log_info(&format!("[vietc] Keyboard device: {}", path));
return run_with_evdev(
device,
&mut daemon,
shared_active_window.clone(),
shared_window_class.clone(),
config_changed.clone(),
status_changed.clone(),
engine_enabled.clone(),
shared_active_window,
shared_window_class,
config_changed,
status_changed,
engine_enabled,
display,
) {
Ok(()) => {
log_info("[vietc] evdev returned, trying X11 capture as fallback");
}
Err(e) => {
log_info(&format!(
"[vietc] evdev exited with error: {} — trying X11 capture",
e
));
}
}
);
}
Err(e) => {
log_info(&format!("[vietc] evdev not available: {}", e));
@ -886,25 +853,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
#[cfg(feature = "x11")]
if display != display::DisplayServer::Wayland {
log_info("[vietc] Trying X11 keymap-based capture");
match run_with_x11_keymap(
if let Some(capture) = X11Capture::new() {
log_info("[vietc] X11 XRecord capture active — using X11 capture/injection");
return run_with_x11(
capture,
&mut daemon,
shared_active_window.clone(),
shared_window_class.clone(),
config_changed.clone(),
status_changed.clone(),
engine_enabled.clone(),
display,
) {
Ok(()) => {
log_info("[vietc] X11 keymap returned, falling through to stdin mode");
}
Err(e) => {
log_info(&format!(
"[vietc] X11 keymap exited with error: {} — falling back",
e
));
}
);
} else {
log_info("[vietc] X11 not available, falling back");
}
}
@ -922,13 +882,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
fn open_keyboard_devices() -> Result<Vec<(evdev::Device, String)>, Box<dyn std::error::Error>> {
fn open_keyboard_device() -> Result<(evdev::Device, String), Box<dyn std::error::Error>> {
let dir = std::path::Path::new("/dev/input");
if !dir.exists() {
return Err("No /dev/input directory".into());
}
let mut devices: Vec<(evdev::Device, String)> = Vec::new();
let mut permission_denied_count = 0u32;
let mut total_event_count = 0u32;
@ -950,12 +909,7 @@ fn open_keyboard_devices() -> Result<Vec<(evdev::Device, String)>, Box<dyn std::
.supported_keys()
.is_some_and(|k| k.contains(evdev::Key::KEY_A))
{
log_info(&format!(
"[vietc] Found keyboard device: {} ({})",
entry.path().display(),
dev_name
));
devices.push((device, format!("{} ({})", entry.path().display(), dev_name)));
return Ok((device, format!("{} ({})", entry.path().display(), dev_name)));
}
}
Err(e) => {
@ -968,12 +922,8 @@ fn open_keyboard_devices() -> Result<Vec<(evdev::Device, String)>, Box<dyn std::
}
}
if !devices.is_empty() {
log_info(&format!("[vietc] Opened {} keyboard device(s)", devices.len()));
return Ok(devices);
}
if permission_denied_count > 0 {
// Check if user is in the group but session hasn't refreshed
let in_group_db = std::process::Command::new("groups")
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).contains("input"))
@ -1006,7 +956,6 @@ fn run_with_x11(
mut capture: X11Capture,
daemon: &mut Daemon,
shared_active_window: Arc<Mutex<String>>,
shared_window_class: Arc<Mutex<String>>,
config_changed: Arc<AtomicBool>,
status_changed: Arc<AtomicBool>,
_engine_enabled: Arc<AtomicBool>,
@ -1018,27 +967,19 @@ fn run_with_x11(
// press+release immediately, breaking held-key combos (Ctrl+C, Alt+Tab…).
let mut pressed_keys: HashSet<u32> = HashSet::new();
use std::io::Write;
let _ = std::io::stderr().write_all(b"[vietc] X11 event loop starting\n");
std::io::stderr().flush().ok();
eprintln!("[vietc] X11 event loop starting");
loop {
let _ = std::io::stderr().write_all(b"[vietc] X11: check status_changed\n");
std::io::stderr().flush().ok();
if status_changed.load(Ordering::SeqCst) {
daemon.sync_status_file();
status_changed.store(false, Ordering::SeqCst);
}
let _ = std::io::stderr().write_all(b"[vietc] X11: check config_changed\n");
std::io::stderr().flush().ok();
if config_changed.load(Ordering::SeqCst) {
daemon.reload_config();
config_changed.store(false, Ordering::SeqCst);
}
let _ = std::io::stderr().write_all(b"[vietc] X11: lock active_window\n");
std::io::stderr().flush().ok();
{
let active_window = shared_active_window.lock().unwrap().clone();
if active_window != last_active_window {
@ -1047,13 +988,9 @@ fn run_with_x11(
}
}
let _ = std::io::stderr().write_all(b"[vietc] X11: lock window_class\n");
std::io::stderr().flush().ok();
if daemon.config.app_state.enabled {
let class = shared_window_class.lock().unwrap().clone();
if !class.is_empty() {
daemon.check_app_change_with(class);
}
let active_window = shared_active_window.lock().unwrap().clone();
daemon.check_app_change_with(active_window);
}
// Reset on focus loss (VMK technique)
@ -1064,13 +1001,11 @@ fn run_with_x11(
}
// Wait for events with 100ms timeout.
let _ = std::io::stderr().write_all(b"[vietc] X11: wait_for_event\n");
std::io::stderr().flush().ok();
// SKIP_RECORD_EVENTS may still be true from a previous injection —
// drain_pipe drops any stale injected events while flag is true.
let _got_data = capture.wait_for_event(100);
// NOW safe to clear: any injected events from last iteration were dropped.
SKIP_RECORD_EVENTS.store(false, Ordering::Relaxed);
let _ = std::io::stderr().write_all(b"[vietc] X11: next_event\n");
std::io::stderr().flush().ok();
let evt = capture.next_event();
if evt.is_none() {
continue;
@ -1142,152 +1077,8 @@ fn run_with_x11(
}
}
#[cfg(feature = "x11")]
fn run_with_x11_keymap(
daemon: &mut Daemon,
shared_active_window: Arc<Mutex<String>>,
shared_window_class: Arc<Mutex<String>>,
config_changed: Arc<AtomicBool>,
status_changed: Arc<AtomicBool>,
_engine_enabled: Arc<AtomicBool>,
display: display::DisplayServer,
) -> Result<(), Box<dyn std::error::Error>> {
use vietc_protocol::x11_inject::X11KeymapCapture;
let mut capture = X11KeymapCapture::new()?;
let injector = create_injector(display)?;
let mut last_active_window = String::new();
let mut last_window_class = String::new();
let mut key_state: HashSet<u32> = HashSet::new();
log_info("[vietc] X11 keymap capture active");
loop {
if SIGNAL_EXIT.load(Ordering::SeqCst) {
log_info("[vietc] Exiting on signal");
return Ok(());
}
if status_changed.load(Ordering::SeqCst) {
daemon.sync_status_file();
status_changed.store(false, Ordering::SeqCst);
}
if config_changed.load(Ordering::SeqCst) {
daemon.reload_config();
config_changed.store(false, Ordering::SeqCst);
}
{
let active_window = shared_active_window.lock().unwrap().clone();
if active_window != last_active_window {
last_active_window = active_window.clone();
daemon.engine.reset();
daemon.replay_reset();
}
}
if daemon.config.app_state.enabled {
let class = shared_window_class.lock().unwrap().clone();
if !class.is_empty() && class != last_window_class {
last_window_class = class.clone();
daemon.check_app_change_with(class.clone());
}
}
// Poll keymap for changes every 10ms
let events = capture.poll();
if events.is_empty() {
std::thread::sleep(std::time::Duration::from_millis(10));
continue;
}
// Update persistent key state
for (keycode, pressed) in &events {
if *pressed {
key_state.insert(*keycode);
} else {
key_state.remove(keycode);
}
}
for (keycode, pressed) in &events {
if !*pressed {
continue;
}
let keycode = *keycode;
let shift_pressed = key_state.contains(&42) || key_state.contains(&54);
let ctrl_pressed = key_state.contains(&29) || key_state.contains(&97);
let alt_pressed = key_state.contains(&56) || key_state.contains(&100);
let caps_state = key_state.contains(&58);
let mut mod_state = 0i32;
if shift_pressed { mod_state |= 1; }
if caps_state { mod_state |= 2; }
if ctrl_pressed { mod_state |= 4; }
if alt_pressed { mod_state |= 8; }
let is_mod = ctrl_pressed || alt_pressed || key_state.contains(&125);
if is_mod {
continue;
}
// Engine toggle: Ctrl+Space
if ctrl_pressed && keycode == 57 {
daemon.toggle();
continue;
}
// Method toggle: Ctrl+LeftShift
if ctrl_pressed && shift_pressed {
daemon.toggle_method();
continue;
}
// Password detection (fresh AT-SPI2 check) — also reset buffers
// on transition to prevent stale engine content bleeding into
// the password field (same-window field switch).
if daemon.config.app_state.enabled {
let is_pw = daemon.app_state.check_password_field();
let currently_enabled = daemon.engine.is_enabled();
if is_pw && currently_enabled {
daemon.engine.set_enabled(false);
daemon.engine.reset();
daemon.replay_reset();
daemon.write_status();
} else if !is_pw && !currently_enabled && daemon.config.start_enabled {
let default_state = daemon.app_state.get_default_state();
if default_state {
daemon.engine.set_enabled(true);
daemon.engine.reset();
daemon.replay_reset();
daemon.write_status();
}
}
}
// Use keymap lookup for character conversion
if let Some(ch) = capture.lookup_keycode(keycode, mod_state) {
let buf_before = daemon.engine.buffer();
let mut commands = daemon.process_key(ch);
if commands.is_empty()
&& daemon.engine.is_enabled()
&& is_vn_control_key(daemon.app_state.effective_method(), ch)
{
let buf_after = daemon.engine.buffer();
if buf_after != buf_before && !buf_before.is_empty() {
let len = buf_before.chars().count();
commands.push(OutputCommand::Backspace(len + 1));
commands.push(OutputCommand::Type(buf_after));
}
}
execute_commands(&*injector, &commands, false);
}
}
}
}
fn run_with_evdev(
devices: &mut Vec<(evdev::Device, String)>,
mut device: evdev::Device,
daemon: &mut Daemon,
shared_active_window: Arc<Mutex<String>>,
shared_window_class: Arc<Mutex<String>>,
@ -1298,10 +1089,8 @@ fn run_with_evdev(
) -> Result<(), Box<dyn std::error::Error>> {
let injector = create_injector(display)?;
// Use the first device for grab (only one device can be grabbed at a time)
let primary_idx = 0usize;
let mut grabbed = if daemon.grab_enabled && !devices.is_empty() {
match devices[primary_idx].0.grab() {
let grabbed = if daemon.grab_enabled {
match device.grab() {
Ok(()) => {
log_info("[vietc] Keyboard grabbed — race condition eliminated");
true
@ -1316,95 +1105,62 @@ fn run_with_evdev(
}
}
} else {
if !daemon.grab_enabled {
log_info("[vietc] Keyboard grab disabled (config grab = false)");
log_info("[vietc] Set grab = true in vietc.toml to enable (needs root)");
}
false
};
let mut consumed_keys: HashSet<u16> = HashSet::new();
let mut last_active_window = String::new();
let mut last_window_class = String::new();
// Skip counter: after Unicode injection, skip N upcoming events
// (they're auto-repeat pile-up from the injection delay)
let mut skip_count = 0u32;
// Password detection: re-check every N key presses even without window change
// (catches in-terminal sudo prompts where window stays the same)
let mut password_check_counter: u32 = 0;
// Safety: if grab is active and no events arrive for 30 seconds,
// release the grab so the user isn't locked out.
let mut last_event_time = std::time::Instant::now();
let mut last_key_time = std::time::Instant::now();
let mut idle_polls: u32 = 0;
// Track key states for each device independently
let mut device_states: Vec<(evdev::AttributeSet<evdev::Key>, bool)> = devices
.iter()
.map(|(d, _)| {
let caps = is_caps_lock_on(d);
let state = d.get_key_state().ok().unwrap_or_else(evdev::AttributeSet::new);
(state, caps)
})
.collect();
log_info("[vietc] Event loop started");
loop {
// Check for signal (Ctrl+C, SIGTERM) — release grab before exit
if SIGNAL_EXIT.load(Ordering::SeqCst) {
if grabbed && !devices.is_empty() {
let _ = devices[primary_idx].0.ungrab();
if grabbed {
let _ = device.ungrab();
log_info("[vietc] Signal received — keyboard grab released");
}
log_info("[vietc] Exiting on signal");
return Ok(());
}
if grabbed && idle_polls >= 3 && last_event_time.elapsed() > std::time::Duration::from_millis(200) {
// Check for event timeout (grab safety)
if grabbed && last_event_time.elapsed() > std::time::Duration::from_secs(30) {
log_info(
"[vietc] No events received via grab — releasing grab, continuing in non-grabbed evdev mode",
"[vietc] No events for 30s — releasing grab timeout, releasing grab for safety",
);
let _ = devices[primary_idx].0.ungrab();
grabbed = false;
log_info("[vietc] Non-grabbed mode: polling all evdev devices for keystrokes");
continue;
let _ = device.ungrab();
return Ok(());
}
// Poll ALL devices simultaneously
let mut pfds: Vec<libc::pollfd> = devices
.iter()
.map(|(d, _)| libc::pollfd {
fd: d.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
})
.collect();
let poll_ret = unsafe { libc::poll(pfds.as_mut_ptr(), pfds.len() as libc::nfds_t, 100) };
if poll_ret < 0 {
let err = std::io::Error::last_os_error();
if err.kind() == std::io::ErrorKind::Interrupted {
let caps = is_caps_lock_on(&device);
let mut key_state = device
.get_key_state()
.ok()
.unwrap_or_else(evdev::AttributeSet::new);
let events = match device.fetch_events() {
Ok(events) => events,
Err(e) => {
if e.kind() == std::io::ErrorKind::Interrupted {
// SIGINT/SIGTERM received — loop back to signal check
continue;
}
log_info(&format!(
"[vietc] poll error on evdev fd: {:?} — exiting",
err
));
return Err(err.into());
}
if poll_ret == 0 {
idle_polls += 1;
if daemon.config.app_state.enabled {
let class = shared_window_class.lock().unwrap().clone();
if !class.is_empty() && class != last_window_class {
last_window_class = class.clone();
daemon.check_app_change_with(last_window_class.clone());
}
}
continue;
}
idle_polls = 0;
if !grabbed {
log_info(&format!(
"[vietc] evdev: {} device(s) have events after ungrab",
pfds.iter().filter(|p| (p.revents & libc::POLLIN) != 0).count()
));
return Err(e.into());
}
};
last_event_time = std::time::Instant::now();
// Check for status changes instantly
if status_changed.load(Ordering::SeqCst) {
@ -1418,35 +1174,8 @@ fn run_with_evdev(
config_changed.store(false, Ordering::SeqCst);
}
// Process events from whichever device(s) have data ready
for (i, pfd) in pfds.iter().enumerate() {
if (pfd.revents & libc::POLLIN) == 0 {
continue;
}
let (ref mut device, ref _name) = devices[i];
let caps = device_states[i].1;
let mut key_state = std::mem::take(&mut device_states[i].0);
let events = match device.fetch_events() {
Ok(events) => events,
Err(e) => {
if e.kind() == std::io::ErrorKind::Interrupted {
continue;
}
log_info(&format!(
"[vietc] fetch_events error on device {}: {:?} — exiting",
i, e
));
return Err(e.into());
}
};
last_event_time = std::time::Instant::now();
let mut non_key_logged = 0u32;
for event in events {
match event.kind() {
evdev::InputEventKind::Key(key) => {
if let evdev::InputEventKind::Key(key) = event.kind() {
let value = event.value();
let keycode = key.0;
@ -1478,30 +1207,28 @@ fn run_with_evdev(
continue;
}
// Password field check (fresh AT-SPI2 check): disable engine if typing
// into a password field. Also reset buffers on transition to prevent
// stale engine content bleeding into the password field.
// Password field check: disable engine if typing into a password field
if value == 1 {
let is_pw = daemon.app_state.check_password_field();
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.engine.reset();
daemon.replay_reset();
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.engine.reset();
daemon.replay_reset();
daemon.write_status();
}
}
}
if !grabbed {
// Legacy mode: raw keystrokes reach the application directly.
// Use process_key for corrections; +1 backspace for control
// keys that landed on screen as literal characters.
if value != 1 {
continue;
}
@ -1509,49 +1236,29 @@ fn run_with_evdev(
continue;
}
if let Some(ch) = key_to_char(key) {
let buf_before = daemon.engine.buffer();
let mut commands = daemon.process_key(ch);
if commands.is_empty()
&& daemon.engine.is_enabled()
&& is_vn_control_key(daemon.app_state.effective_method(), ch)
{
let buf_after = daemon.engine.buffer();
if buf_after != buf_before && !buf_before.is_empty() {
let len = buf_before.chars().count();
commands.push(OutputCommand::Backspace(len + 1));
commands.push(OutputCommand::Type(buf_after));
}
}
// Non-grabbed fix: the VNI/Telex control key character reached
// the app directly. Add 1 extra backspace to remove it.
if !commands.is_empty()
&& is_vn_control_key(daemon.app_state.effective_method(), ch)
&& is_vn_control_key(&daemon.config.input_method, ch)
{
for cmd in &mut commands {
if let OutputCommand::Backspace(ref mut n) = cmd {
*n += 1;
break;
}
}
log_info(&format!(
"[vietc] non-grabbed: ch='{}' adjusted backspace+1",
ch.escape_default()
));
}
if !commands.is_empty() {
log_info(&format!(
"[vietc] non-grabbed inject: ch='{}' cmds={:?}",
ch.escape_default(),
commands
));
}
execute_commands(&*injector, &commands, false);
}
} else {
// Grabbing mode: all output goes through uinput only.
// If Ctrl, Alt, or Meta/Super is pressed, bypass the engine completely and forward raw key events.
if is_modifier_pressed(&key_state) {
injector.send_key_event(keycode, value);
continue;
}
// Backspace in grab mode: pop engine, inject via uinput.
if key == evdev::Key::KEY_BACKSPACE {
if value == 1 || value == 2 {
daemon.engine.process_key('\x08');
@ -1563,15 +1270,23 @@ fn run_with_evdev(
}
if value == 1 {
// Press: process through engine
if consumed_keys.contains(&keycode) {
consumed_keys.remove(&keycode);
}
if let Some(mut ch) = key_to_char(key) {
// Window change detection: only on character key presses.
// Modifier keys (Ctrl, Alt, Super) skip this block, so
// last_key_time is preserved across Alt+Tab sequences.
let gap = last_key_time.elapsed();
last_key_time = std::time::Instant::now();
// Fast path: check shared window ID from background thread (250ms polling)
let active_window_id = shared_active_window.lock().unwrap().clone();
let mut new_window = None;
// On Wayland, window ID may not change (native Wayland apps
// don't have X11 IDs), so also check window class as a fallback.
let active_window_class = shared_window_class.lock().unwrap().clone();
if active_window_id != last_active_window {
@ -1579,8 +1294,12 @@ fn run_with_evdev(
} else if !active_window_class.is_empty()
&& active_window_class != last_window_class
{
// Window ID same but class changed — treat as window switch
// (this covers Wayland native app switches)
new_window = Some(active_window_class.clone());
} else {
// Always verify active window on every keypress — window
// switches under 100ms can leak the old engine buffer.
if let Some(id) = app_state::get_active_window_id() {
if id != active_window_id {
new_window = Some(id);
@ -1594,6 +1313,8 @@ fn run_with_evdev(
last_active_window, id, gap
));
last_active_window = id.clone();
// Save the window class when it changes (covers Wayland
// where IDs might be identical for different apps)
if !active_window_class.is_empty() {
last_window_class = active_window_class.clone();
}
@ -1610,12 +1331,11 @@ 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.engine.reset();
daemon.replay_reset();
daemon.write_status();
}
}
@ -1626,6 +1346,10 @@ fn run_with_evdev(
}
}
// Periodic password re-check (every 30 keystrokes) —
// catches in-terminal sudo prompts where the window
// doesn't change but the focused widget becomes a
// password field (detected via AT-SPI2).
if daemon.config.password_detection.enabled {
password_check_counter += 1;
if password_check_counter >= 30 {
@ -1634,15 +1358,11 @@ fn run_with_evdev(
let currently_enabled = daemon.engine.is_enabled();
if is_pw && currently_enabled {
daemon.engine.set_enabled(false);
daemon.engine.reset();
daemon.replay_reset();
daemon.write_status();
log_info("[vietc] Password field detected (periodic) — engine disabled");
} else if !is_pw && !currently_enabled {
if daemon.app_state.get_default_state() {
daemon.engine.set_enabled(true);
daemon.engine.reset();
daemon.replay_reset();
daemon.write_status();
}
}
@ -1656,24 +1376,27 @@ fn run_with_evdev(
let buf_before = daemon.engine.buffer().chars().count();
let commands = daemon.process_key(ch);
if !commands.is_empty() {
log_info(&format!(
"[vietc] inject: engine={} ch='{}' buf={} cmds={:?}",
if daemon.engine.is_enabled() { "VN" } else { "EN" },
ch,
buf_before,
commands
));
consumed_keys.insert(keycode);
execute_commands(&*injector, &commands, false);
// Flush chars: forward raw key after injection.
// When engine is disabled (English mode), the Insert event
// already contains the character — forwarding raw key
// would double-inject (double space on Ctrl+Space toggle).
if is_flush_char(ch) && daemon.engine.is_enabled() {
injector.send_key_event(keycode, 1);
injector.send_key_event(keycode, 0);
}
// Skip upcoming auto-repeat pile-up from injection delay
skip_count = 3;
} else if daemon.engine.is_enabled()
&& is_vn_control_key(daemon.app_state.effective_method(), ch)
} else if is_vn_control_key(&daemon.config.input_method, ch)
&& daemon.engine.buffer().chars().count() <= buf_before
{
// Tone/mark key truly absorbed with no effect (no
// literal character appended) — consume silently.
// When the key is instead kept as a literal base
// letter (e.g. leading "x", the "r" in "tr"), the
// buffer grows and we must forward it like any
// other character so it reaches the screen.
consumed_keys.insert(keycode);
} else {
injector.send_key_event(keycode, 1);
@ -1682,12 +1405,14 @@ fn run_with_evdev(
injector.send_key_event(keycode, 1);
}
} else if value == 2 {
// Auto-repeat: skip if consumed or during injection drain
if consumed_keys.contains(&keycode) || skip_count > 0 {
if skip_count > 0 { skip_count -= 1; }
continue;
}
injector.send_key_event(keycode, 2);
} else if value == 0 {
// Release: skip if consumed, else forward
if consumed_keys.contains(&keycode) {
consumed_keys.remove(&keycode);
continue;
@ -1696,22 +1421,6 @@ fn run_with_evdev(
}
}
}
_ => {
if non_key_logged < 5 {
log_info(&format!(
"[vietc] evdev: non-key event type={:?} code={} value={}",
event.event_type(),
event.code(),
event.value()
));
non_key_logged += 1;
}
}
}
}
// Save updated key state back
device_states[i].0 = key_state;
}
}
}
@ -1748,10 +1457,10 @@ fn run_stdin_mode(
config_changed.store(false, Ordering::SeqCst);
}
if let Ok(mut devices) = open_keyboard_devices() {
log_info(&format!("[vietc] Keyboard device(s) found: {}", devices.len()));
if let Ok((device, path)) = open_keyboard_device() {
log_info(&format!("[vietc] Keyboard device found: {}", path));
return run_with_evdev(
&mut devices,
device,
daemon,
shared_active_window,
shared_window_class,

View file

@ -3,117 +3,285 @@
# Viet+ — Vietnamese Input Method Installer
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m'; NC='\033[0m'
# Color codes for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
NC='\033[0;69m' # No Color
NC='\033[0m'
[ "$EUID" -ne 0 ] && echo -e "${RED}Please run with sudo.${NC}" && exit 1
echo -e "${BLUE}=== Viet+ Installation Script ===${NC}"
echo -e "${GREEN}=== Viet+ Installer ===${NC}"
# Check for root privilege requirement
check_root() {
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}Error: This script must be run as root (or with sudo) to install system files and update permissions.${NC}"
echo -e "Please re-run as: ${YELLOW}sudo $0${NC}"
exit 1
fi
}
# Detect distro
[ -f /etc/os-release ] && . /etc/os-release
DISTRO="${ID:-unknown}"
echo "Detected: $DISTRO"
# Detect distribution
detect_distro() {
if [ -f /etc/os-release ]; then
. /etc/os-release
DISTRO=$ID
DISTRO_LIKE=${ID_LIKE:-""}
else
echo -e "${RED}Error: Cannot detect Linux distribution (/etc/os-release missing).${NC}"
exit 1
fi
echo -e "Detected OS: ${GREEN}${DISTRO}${NC}"
}
# Install dependencies using package manager
install_dependencies() {
local mode=$1 # "build" or "run"
echo -e "Installing ${mode} dependencies..."
# Install dependencies
install_deps() {
case "$DISTRO" in
ubuntu|debian|linuxmint|mint|pop|neon|zorin|elementary)
ubuntu|debian|raspbian|pop|mint|linuxmint|neon|zorin|elementary)
export DEBIAN_FRONTEND=noninteractive
apt-get update -y
apt-get install -y build-essential pkg-config libx11-dev libxtst-dev \
libdbus-1-dev libevdev-dev libwayland-dev curl git
apt-get install -y libevdev2 libdbus-1-3 libx11-6 libxtst6 \
libwayland-client0 xclip wl-clipboard
if [ "$mode" = "build" ]; then
apt-get install -y build-essential pkg-config libx11-dev libxtst-dev libdbus-1-dev libevdev-dev libwayland-dev curl
fi
apt-get install -y libevdev2 libdbus-1-3 libx11-6 libxtst6 xclip wl-clipboard libwayland-client0 curl
;;
fedora|rhel|centos)
dnf install -y gcc pkgconfig libX11-devel libXtst-devel dbus-devel \
libevdev-devel libwayland-devel curl git
dnf install -y libevdev libX11 libXtst dbus-libs libwayland-client xclip wl-clipboard
if [ "$mode" = "build" ]; then
dnf install -y gcc pkgconfig libX11-devel libXtst-devel dbus-devel libevdev-devel
fi
dnf install -y libevdev libX11 libXtst dbus-libs xclip wl-clipboard
;;
arch|manjaro)
pacman -Sy --needed --noconfirm base-devel pkgconf libx11 libxtst dbus \
libevdev wayland curl git
pacman -Sy --needed --noconfirm libevdev libx11 libxtst dbus \
libwayland xclip wl-clipboard
arch|manjaro|arco)
if [ "$mode" = "build" ]; then
pacman -Sy --needed --noconfirm base-devel pkgconf libx11 libxtst dbus libevdev
fi
pacman -Sy --needed --noconfirm libevdev libx11 libxtst dbus xclip wl-clipboard
;;
*)
echo -e "${YELLOW}Unsupported: $DISTRO. Install deps manually.${NC}"
if [[ "$DISTRO_LIKE" == *"ubuntu"* || "$DISTRO_LIKE" == *"debian"* ]]; then
export DEBIAN_FRONTEND=noninteractive
apt-get update -y
if [ "$mode" = "build" ]; then
apt-get install -y build-essential pkg-config libx11-dev libxtst-dev libdbus-1-dev libevdev-dev libwayland-dev curl
fi
apt-get install -y libevdev2 libdbus-1-3 libx11-6 libxtst6 xclip wl-clipboard libwayland-client0 curl
else
echo -e "${YELLOW}Warning: Unsupported distribution '${DISTRO}'. Please make sure you have the following packages installed manually:${NC}"
echo -e " - libevdev, libdbus-1, libx11, libxtst, xclip, wl-clipboard"
if [ "$mode" = "build" ]; then
echo -e " - gcc, pkg-config, and development headers for the above libraries."
fi
fi
;;
esac
}
install_deps
# Check for Rust compiler (needed only for source build)
check_rust() {
if [ -n "${SUDO_USER:-}" ]; then
local user_home
user_home="$(getent passwd "$SUDO_USER" 2>/dev/null | cut -d: -f6 || echo "/home/$SUDO_USER")"
export CARGO_HOME="$user_home/.cargo"
export RUSTUP_HOME="$user_home/.rustup"
export PATH="$CARGO_HOME/bin:$PATH"
fi
# Install Rust if missing
if ! command -v cargo &>/dev/null; then
echo "Installing Rust..."
if ! command -v cargo >/dev/null 2>&1; then
echo -e "${YELLOW}Rust toolchain not found.${NC}"
read -p "Would you like to install Rust toolchain now? [Y/n] " -n 1 -r
echo
if [[ $REPLY =~ ^[Nn]$ ]]; then
echo -e "${RED}Error: Cargo is required to build from source.${NC}"
exit 1
fi
echo "Installing Rust via rustup.rs..."
# Run rustup installer as the original non-root user if SUDO_USER is set
if [ -n "${SUDO_USER:-}" ]; then
su - "$SUDO_USER" -c "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y"
else
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
export PATH="$HOME/.cargo/bin:$PATH"
fi
fi
echo -e "Rust version: ${GREEN}$(rustc --version)${NC}"
}
# Determine if we are installing from source tree or prebuilt binaries
check_root # Need root to perform OS checks and dependencies install
detect_distro
SOURCE_DIR=""
if [ -f "./Cargo.toml" ] && [ -d "./engine" ] && [ -d "./ui" ]; then
SOURCE_DIR=$(pwd)
echo -e "Status: Running from ${GREEN}Source Tree${NC}"
else
echo -e "Status: Running from ${GREEN}Release Package${NC}"
fi
# Kill old processes
# 1. Compile or Stage Binaries
if [ -n "$SOURCE_DIR" ]; then
install_dependencies "build"
check_rust
echo -e "=== Compiling Viet+ ==="
# Build core components
cargo build --release
# Build UI
(cd ui && cargo build --release)
# Compile C helper
gcc -O2 -o target/release/vietc-xrecord packaging/deb/vietc-xrecord.c -lX11 -lXtst
BIN_DAEMON="target/release/vietc"
BIN_CLI="target/release/vietc-cli"
BIN_UINPUTD="target/release/vietc-uinputd"
BIN_TRAY="ui/target/release/vietc-tray"
BIN_XRECORD="target/release/vietc-xrecord"
FILE_RULES="packaging/99-vietc.rules"
FILE_DESKTOP="packaging/deb/vietc.desktop"
FILE_CONFIG="vietc.toml"
DIR_ICONS="packaging/icons"
else
# Install from prebuilt release folder
install_dependencies "run"
BIN_DAEMON="bin/vietc-daemon"
BIN_CLI="bin/vietc-cli"
BIN_UINPUTD="bin/vietc-uinputd"
BIN_TRAY="bin/vietc-tray"
BIN_XRECORD="bin/vietc-xrecord"
FILE_RULES="udev/99-vietc.rules"
FILE_DESKTOP="desktop/vietc.desktop"
FILE_CONFIG="config/config.toml"
DIR_ICONS="icons"
# Validation
if [ ! -f "$BIN_DAEMON" ] && [ -f "bin/vietc" ]; then
BIN_DAEMON="bin/vietc" # Fallback if not renamed in build
fi
fi
# Kill running instances before installing new files
echo "Stopping any running Viet+ processes..."
pkill -x vietc-tray 2>/dev/null || true
pkill -x vietc-daemon 2>/dev/null || true
pkill -x vietc-uinputd 2>/dev/null || true
pkill -x vietc 2>/dev/null || true
# Build
echo "Building..."
cargo build --release
(cd ui && cargo build --release)
if command -v gcc &>/dev/null && [ -f packaging/deb/vietc-xrecord.c ]; then
gcc -O2 -o target/release/vietc-xrecord packaging/deb/vietc-xrecord.c -lX11 -lXtst 2>/dev/null || true
# 2. Install Binaries
echo "Installing binaries to /usr/bin/..."
cp "$BIN_DAEMON" /usr/bin/vietc-daemon
cp "$BIN_CLI" /usr/bin/
cp "$BIN_UINPUTD" /usr/bin/
cp "$BIN_TRAY" /usr/bin/
cp "$BIN_XRECORD" /usr/bin/
chmod 755 /usr/bin/vietc-daemon /usr/bin/vietc-cli /usr/bin/vietc-uinputd /usr/bin/vietc-tray /usr/bin/vietc-xrecord
# Remove old local path binaries to prevent shadows
rm -f /usr/local/bin/vietc-tray /usr/local/bin/vietc /usr/local/bin/vietc-daemon \
/usr/local/bin/vietc-cli /usr/local/bin/vietc-uinputd /usr/local/bin/vietc-xrecord 2>/dev/null || true
# 3. Install Icons
echo "Installing icons..."
mkdir -p /usr/share/icons/hicolor/256x256/apps
cp "$DIR_ICONS/vietc.svg" /usr/share/icons/hicolor/256x256/apps/
cp "$DIR_ICONS/vietc-vn.svg" /usr/share/icons/hicolor/256x256/apps/
cp "$DIR_ICONS/vietc-en.svg" /usr/share/icons/hicolor/256x256/apps/
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
gtk-update-icon-cache -f /usr/share/icons/hicolor/ >/dev/null 2>&1 || true
fi
# Install binaries
echo "Installing to /usr/bin/..."
cp target/release/vietc /usr/bin/vietc-daemon
cp target/release/vietc-cli /usr/bin/
cp target/release/vietc-uinputd /usr/bin/
cp ui/target/release/vietc-tray /usr/bin/
[ -f target/release/vietc-xrecord ] && cp target/release/vietc-xrecord /usr/bin/
chmod 755 /usr/bin/vietc-daemon /usr/bin/vietc-cli /usr/bin/vietc-uinputd /usr/bin/vietc-tray 2>/dev/null || true
# 4. Install Desktop File
echo "Installing desktop launcher..."
mkdir -p /usr/share/applications
cp "$FILE_DESKTOP" /usr/share/applications/
# Clean old /usr/local/bin/ binaries
rm -f /usr/local/bin/vietc /usr/local/bin/vietc-daemon /usr/local/bin/vietc-cli \
/usr/local/bin/vietc-uinputd /usr/local/bin/vietc-tray /usr/local/bin/vietc-xrecord 2>/dev/null || true
# Udev rules for uinput
echo 'KERNEL=="uinput", GROUP="input", MODE="0660"' > /etc/udev/rules.d/99-vietc.rules
udevadm control --reload-rules 2>/dev/null || true
udevadm trigger 2>/dev/null || true
# User setup
INSTALLING_USER="${SUDO_USER:-$USER}"
if [ -n "$INSTALLING_USER" ] && [ "$INSTALLING_USER" != "root" ]; then
adduser "$INSTALLING_USER" input 2>/dev/null || true
rm -f "$(getent passwd "$INSTALLING_USER" | cut -d: -f6)/.config/vietc/config.toml" 2>/dev/null || true
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
fi
# Create default config
# 5. Install Systemd User Service
echo "Installing systemd user service..."
mkdir -p /usr/lib/systemd/user
cat > /usr/lib/systemd/user/vietc.service << 'SERVICE'
[Unit]
Description=Viet+ Vietnamese IME Tray
PartOf=graphical-session.target
[Service]
Type=simple
ExecStart=/usr/bin/vietc-tray
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
SERVICE
# 6. Install XDG Autostart
echo "Setting up autostart..."
mkdir -p /etc/xdg/autostart
cat > /etc/xdg/autostart/vietc-tray.desktop << 'AUTOSTART'
[Desktop Entry]
Type=Application
Name=Viet+ Tray
Comment=Vietnamese Input Method Tray
Exec=vietc-tray
Icon=vietc
Terminal=false
Categories=Utility;
StartupNotify=false
NoDisplay=true
AUTOSTART
# 7. Install default config
echo "Installing default configuration..."
mkdir -p /etc/vietc
cat > /etc/vietc/config.toml << 'EOF'
input_method = "vni"
toggle_key = "space"
toggle_method_key = "shift"
start_enabled = true
grab = true
cp "$FILE_CONFIG" /etc/vietc/config.toml
chmod 644 /etc/vietc/config.toml
[password_detection]
enabled = true
check_atspi2 = true
check_window_title = true
title_keywords = ["password", "passphrase", "secret", "mật khẩu", "sudo"]
password_apps = ["pinentry", "pinentry-gtk-2", "pinentry-qt", "kwallet"]
# 8. Configure permissions (uinput)
echo "Installing udev rules for uinput access..."
mkdir -p /etc/udev/rules.d
cp "$FILE_RULES" /etc/udev/rules.d/99-vietc.rules
chmod 644 /etc/udev/rules.d/99-vietc.rules
[app_state]
enabled = true
english_apps = ["code", "vim"]
vietnamese_apps = ["telegram", "discord", "firefox"]
bypass_apps = ["steam"]
terminal_apps = ["kitty", "alacritty", "gnome-terminal", "konsole", "foot",
"wezterm", "st", "urxvt", "xterm"]
terminal_input_method = "vni"
EOF
echo "Reloading udev rules..."
if command -v udevadm >/dev/null 2>&1; then
udevadm control --reload-rules >/dev/null 2>&1 || true
udevadm trigger --subsystem-match=misc >/dev/null 2>&1 || true
fi
echo -e "${GREEN}=== Done! ===${NC}"
echo -e "${YELLOW}Log out and log back in, then run: vietc-tray${NC}"
# Add active users to input group
INSTALLING_USER="${SUDO_USER:-${USER:-}}"
if [ -n "$INSTALLING_USER" ] && [ "$INSTALLING_USER" != "root" ]; then
echo "Adding user '$INSTALLING_USER' to group 'input'..."
if ! groups "$INSTALLING_USER" 2>/dev/null | grep -qw input; then
if command -v usermod >/dev/null 2>&1; then
usermod -aG input "$INSTALLING_USER" || true
elif command -v adduser >/dev/null 2>&1; then
adduser "$INSTALLING_USER" input || true
fi
fi
# Remove any old conflicting configurations in user home
user_home="$(getent passwd "$INSTALLING_USER" 2>/dev/null | cut -d: -f6 || true)"
if [ -n "$user_home" ]; then
rm -f "$user_home/.config/vietc/config.toml" 2>/dev/null || true
rm -f "$user_home/.config/vietc/overrides.toml" 2>/dev/null || true
rm -f "$user_home/.config/vietc/.first-launch-done" 2>/dev/null || true
fi
fi
if command -v systemctl >/dev/null 2>&1; then
systemctl --global daemon-reload >/dev/null 2>&1 || true
fi
echo -e "\n${GREEN}=== Installation Completed Successfully! ===${NC}"
echo -e "Please ${YELLOW}LOG OUT and LOG BACK IN${NC} to activate group permissions."
echo -e "Once logged back in, you can start Viet+ from your application launcher menu."

View file

@ -222,12 +222,7 @@ impl KeyInjector for UinputInjector {
return InjectResult::Success;
}
// Unicode text: try xdotool type first (works on X11, doesn't touch clipboard)
if self.type_via_xdotool(s) {
return InjectResult::Success;
}
// Fallback: clipboard copy + paste
// Unicode text: clipboard copy + paste (reliable method)
if !self.paste_via_clipboard(s) {
eprintln!(
"[vietc] send_string failed for '{}' (clipboard unavailable)",
@ -393,27 +388,6 @@ impl UinputInjector {
InjectResult::Success
}
/// Type Unicode text via xdotool (X11 only). Returns true on success.
/// More reliable than clipboard paste — doesn't overwrite the user's clipboard
/// and works with XTest directly for proper Unicode key injection.
fn type_via_xdotool(&self, text: &str) -> bool {
let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
if is_wayland {
return false;
}
let mut cmd = Self::user_cmd("xdotool");
cmd.args(["type", "--clearmodifiers", text]);
cmd.stdout(std::process::Stdio::null());
cmd.stderr(std::process::Stdio::null());
match cmd.output() {
Ok(output) => output.status.success(),
Err(e) => {
eprintln!("[vietc] xdotool type failed: {}", e);
false
}
}
}
/// Read the user's current clipboard contents (wl-paste on Wayland, xclip
/// on X11). Returns None if no clipboard tool is available or it is empty.
fn read_clipboard() -> Option<String> {

View file

@ -7,26 +7,6 @@ type Display = c_void;
type Window = u64;
type Atom = u64;
type Time = u64;
type KeySym = u64;
#[repr(C)]
struct XKeyEvent {
_type: c_int,
_serial: u64,
_send_event: c_int,
_display: *mut Display,
window: u64,
_root: u64,
_subwindow: u64,
_time: u64,
_x: c_int,
_y: c_int,
_x_root: c_int,
_y_root: c_int,
state: c_int,
keycode: u32,
_same_screen: c_int,
}
extern "C" {
fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void;
@ -60,9 +40,6 @@ struct X11Lib {
x_destroy_window: unsafe extern "C" fn(*mut Display, Window) -> c_int,
x_pending: unsafe extern "C" fn(*mut Display) -> c_int,
x_next_event: unsafe extern "C" fn(*mut Display, *mut XEvent),
x_query_keymap: unsafe extern "C" fn(*mut Display, *mut c_char) -> c_int,
x_lookup_string: unsafe extern "C" fn(*const XKeyEvent, *mut c_char, c_int, *mut KeySym, *mut c_int) -> c_int,
x_utf8_lookup_string: Option<unsafe extern "C" fn(*mut c_void, *const XKeyEvent, *mut c_char, c_int, *mut KeySym, *mut c_int) -> c_int>,
}
impl X11Lib {
@ -118,12 +95,6 @@ impl X11Lib {
let x_destroy_window = sym!(x11_handle, "XDestroyWindow");
let x_pending = sym!(x11_handle, "XPending");
let x_next_event = sym!(x11_handle, "XNextEvent");
let x_query_keymap = sym!(x11_handle, "XQueryKeymap");
let x_lookup_string = sym!(x11_handle, "XLookupString");
let x_utf8_lookup_string = {
let p = dlsym(x11_handle, b"Xutf8LookupString\0".as_ptr() as *const c_char);
if p.is_null() { None } else { Some(std::mem::transmute(p)) }
};
let x_test_fake_key_event = sym!(xtst_handle, "XTestFakeKeyEvent");
Ok(Self {
@ -143,9 +114,6 @@ impl X11Lib {
x_destroy_window,
x_pending,
x_next_event,
x_query_keymap,
x_lookup_string,
x_utf8_lookup_string,
})
}
}
@ -585,109 +553,3 @@ impl KeyInjector for X11Injector {
InjectResult::Success
}
}
/// X11 keymap-based capture: polls XQueryKeymap periodically to detect
/// key presses/releases. No XRecord, no XGrabKeyboard — works on any X11
/// system including VMs where evdev produces no events.
pub struct X11KeymapCapture {
lib: X11Lib,
display: *mut Display,
prev_keys: [u8; 32],
}
unsafe impl Send for X11KeymapCapture {}
impl X11KeymapCapture {
pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
let lib = X11Lib::new()?;
unsafe {
let display = (lib.x_open_display)(std::ptr::null());
if display.is_null() {
return Err("Cannot open X11 display".into());
}
Ok(Self {
lib,
display,
prev_keys: [0u8; 32],
})
}
}
/// Poll the current keymap and return any newly pressed or released keycodes.
/// Returns a Vec of (keycode_in_evdev_format, pressed) tuples.
/// X11 keycodes use offset 8 from evdev codes: evdev = x11 - 8.
pub fn poll(&mut self) -> Vec<(u32, bool)> {
let mut keys = [0u8; 32];
unsafe {
(self.lib.x_query_keymap)(self.display, keys.as_mut_ptr() as *mut c_char);
}
let mut events = Vec::new();
for i in 0..32 {
let changed = keys[i] ^ self.prev_keys[i];
if changed == 0 {
continue;
}
for bit in 0..8 {
if (changed >> bit) & 1 != 0 {
let x11_keycode = (i * 8 + bit) as u32;
let pressed = (keys[i] >> bit) & 1;
// Convert from X11 keycode to evdev keycode (subtract 8)
if x11_keycode >= 8 {
events.push((x11_keycode - 8, pressed == 1));
}
}
}
}
self.prev_keys = keys;
events
}
/// Convert an evdev keycode + modifier state to a character.
/// `state` is the X11 modifier bitmask (Shift=1, Lock=2, Ctrl=4, Mod1=8, etc.)
pub fn lookup_keycode(&self, keycode: u32, state: c_int) -> Option<char> {
let x11_keycode = keycode + 8;
unsafe {
let mut xke: XKeyEvent = std::mem::zeroed();
xke._type = 2; // KeyPress
xke._display = self.display;
xke.keycode = x11_keycode;
xke.state = state;
let mut buf = [0u8; 32];
let mut keysym: KeySym = 0;
let len = if let Some(xutf8) = self.lib.x_utf8_lookup_string {
xutf8(
std::ptr::null_mut(),
&mut xke as *mut XKeyEvent,
buf.as_mut_ptr() as *mut c_char,
buf.len() as c_int,
&mut keysym,
std::ptr::null_mut(),
)
} else {
(self.lib.x_lookup_string)(
&mut xke as *mut XKeyEvent,
buf.as_mut_ptr() as *mut c_char,
buf.len() as c_int,
&mut keysym,
std::ptr::null_mut(),
)
};
if len > 0 {
let s = std::str::from_utf8(&buf[..len as usize]).ok()?;
s.chars().next()
} else {
None
}
}
}
}
impl Drop for X11KeymapCapture {
fn drop(&mut self) {
unsafe {
(self.lib.x_close_display)(self.display);
}
}
}

View file

@ -1,65 +0,0 @@
#!/usr/bin/env bash
# Viet+ — Linux Mint / Ubuntu test VM setup script
# Usage: curl -fsSL <url> | bash
# or: bash scripts/setup-test-vm.sh
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m'; NC='\033[0m'
echo -e "${GREEN}=== Viet+ Test VM Setup ===${NC}"
# 1. Install system deps
echo -e "${YELLOW}[1/5] Installing system dependencies...${NC}"
sudo apt update -y
sudo apt install -y build-essential pkg-config libx11-dev libxtst-dev \
libdbus-1-dev libevdev-dev libwayland-dev curl git \
libevdev2 libdbus-1-3 libx11-6 libxtst6 libwayland-client0 \
wl-clipboard xclip
# 2. Install Rust if missing
echo -e "${YELLOW}[2/5] Installing Rust...${NC}"
if ! command -v cargo &>/dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source "$HOME/.cargo/env"
else
echo " Rust already installed."
fi
# 3. Clone and build
echo -e "${YELLOW}[3/5] Cloning and building...${NC}"
if [ ! -d vietc ]; then
git clone https://github.com/vndangkhoa/vietc.git
fi
cd vietc && git checkout staging && cargo build --release
# 4. Install
echo -e "${YELLOW}[4/5] Installing...${NC}"
sudo ./install.sh
# 5. Done
echo -e "${YELLOW}[5/5] Setup complete!${NC}"
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} Reboot to apply group + udev changes ${NC}"
echo -e "${GREEN} Then: vietc-tray & ${NC}"
echo -e "${GREEN} Or: sudo vietc-daemon ${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo "Quick test:"
echo " cargo run --bin vietc-cli"
echo ""
echo "Terminal typing (VNI mode auto-enabled in terminals):"
echo " cha2o -> chào"
echo " ba5n -> bạn"
echo " to6i te6n la2 Khoa3 -> tôi tên là Khỏa"
echo " d9o7i -> đời"
echo ""
echo "Telex (Ctrl+LeftShift to switch):"
echo " chaof -> chào"
echo " banj -> bạn"
echo " tooi teen laf Khoar -> tôi tên là Khỏa"
echo " ddoi -> đời"
echo ""
echo "Macros:"
echo " ko[space] -> không"
echo " dc[space] -> được"
echo " vs[space] -> với"

View file

@ -1,44 +1,62 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MIT
# Viet+ — Vietnamese Input Method Uninstaller
# Usage: curl -sSL <url> | sudo bash
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; NC='\033[0m'
# Color codes for output
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color
[ "$EUID" -ne 0 ] && echo -e "${RED}Please run with sudo.${NC}" && exit 1
echo -e "${RED}=== Viet+ Uninstallation Script ===${NC}"
echo -e "${RED}=== Viet+ Uninstaller ===${NC}"
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}Error: This script must be run as root (or with sudo).${NC}"
exit 1
fi
# Kill running processes
echo "Stopping any running Viet+ processes..."
pkill -x vietc-tray 2>/dev/null || true
pkill -x vietc-daemon 2>/dev/null || true
pkill -x vietc-uinputd 2>/dev/null || true
pkill -x vietc 2>/dev/null || true
# Remove binaries
rm -f /usr/bin/vietc-daemon /usr/bin/vietc-cli /usr/bin/vietc-uinputd \
/usr/bin/vietc-tray /usr/bin/vietc-xrecord
rm -f /usr/local/bin/vietc /usr/local/bin/vietc-daemon /usr/local/bin/vietc-cli \
/usr/local/bin/vietc-uinputd /usr/local/bin/vietc-tray /usr/local/bin/vietc-xrecord
echo "Removing binaries..."
rm -f /usr/bin/vietc-daemon /usr/bin/vietc-cli /usr/bin/vietc-uinputd /usr/bin/vietc-tray /usr/bin/vietc-xrecord
rm -f /usr/local/bin/vietc-daemon /usr/local/bin/vietc-cli /usr/local/bin/vietc-uinputd /usr/local/bin/vietc-tray /usr/local/bin/vietc-xrecord /usr/local/bin/vietc
# Remove udev rules
rm -f /etc/udev/rules.d/99-vietc.rules
echo "Removing icons..."
rm -f /usr/share/icons/hicolor/256x256/apps/vietc.svg
rm -f /usr/share/icons/hicolor/256x256/apps/vietc-vn.svg
rm -f /usr/share/icons/hicolor/256x256/apps/vietc-en.svg
# Remove config
rm -rf /etc/vietc
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
gtk-update-icon-cache -f /usr/share/icons/hicolor/ >/dev/null 2>&1 || true
fi
# Remove systemd service
rm -f /usr/lib/systemd/user/vietc.service
# Remove icons
rm -f /usr/share/icons/hicolor/256x256/apps/vietc*.svg
# Remove desktop file
echo "Removing desktop files and autostart..."
rm -f /usr/share/applications/vietc.desktop
rm -f /etc/xdg/autostart/vietc-tray.desktop
# Reload
udevadm control --reload-rules 2>/dev/null || true
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
fi
echo -e "${GREEN}=== Viet+ removed ===${NC}"
echo "Removing systemd service..."
rm -f /usr/lib/systemd/user/vietc.service
echo "Removing udev rules..."
rm -f /etc/udev/rules.d/99-vietc.rules
if command -v udevadm >/dev/null 2>&1; then
udevadm control --reload-rules >/dev/null 2>&1 || true
udevadm trigger --subsystem-match=misc >/dev/null 2>&1 || true
fi
echo "Removing global configuration..."
rm -rf /etc/vietc
if command -v systemctl >/dev/null 2>&1; then
systemctl --global daemon-reload >/dev/null 2>&1 || true
fi
echo -e "\n${GREEN}=== Uninstallation Completed Successfully! ===${NC}"