Compare commits

...

56 commits
v0.1.6 ... main

Author SHA1 Message Date
Khoa Vo
1295f1ce7b docs: update version badge to 0.1.18 and roadmap targets to v0.1.19/v0.1.20 on main
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-04 22:18:11 +07:00
Khoa Vo
dba73c8a7a docs: convert roadmap items to checkboxes in English and Vietnamese READMEs
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-04 22:15:18 +07:00
Khoa Vo
7192f1aeab docs: add Vietnamese translations for README and CHANGELOG
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-04 22:13:29 +07:00
Khoa Vo
9d35db103e docs: fix broken nested markdown code blocks and format configuration section in README.md 2026-07-04 22:12:20 +07:00
Khoa Vo
0495c7cbd7 debug: add evdev event logging + non-grabbed backspace fix
Some checks failed
Build & Release / Build & test (push) Has been cancelled
Build & Release / Build .deb (push) Has been cancelled
2026-07-02 19:41:34 +07:00
Khoa Vo
6d9e8ba4f9 docs: announce terminal support in Features table + Configuration 2026-07-02 16:25:42 +07:00
Khoa Vo
035aeca997 fix: VNI/Telex control key flicker in non-grabbed mode
When a VNI/Telex control key (e.g. digit 6 for ô, w for â/ê/ô) is
pressed, the engine absorbs it in-place without emitting an event. In
non-grabbed mode the raw character already reached the terminal. Fix:
capture buf_before, compare with buf_after, and synthesize
Backspace(len+1) + Type(buf_after) when the buffer changed.
2026-07-02 15:35:53 +07:00
Khoa Vo
2553f20466 password: call check_password_field() instead of stale cached is_password_field()
The keymap and evdev paths were calling is_password_field() which returns
the cached value from AppStateManager. But check_password_field() (the
fresh AT-SPI2 check) was never called in these paths, so password
detection always returned false — engine remained enabled in password
fields, causing VNI processing of password input.
2026-07-02 14:54:22 +07:00
Khoa Vo
fbe0baf7ab password: reset engine buffer and replay on password field transition
When switching between a text field and a password field within the same
window, the engine buffer and event store retained stale content from the
previous field, causing old text to bleed into the password input.

- Add daemon.engine.reset() + daemon.replay_reset() on window change
- Add same resets when password detection fires (both keymap and evdev
  non-grabbed paths)
- Add same resets on periodic password re-check (XRecord path)
- Add same resets when re-enabling engine after leaving password field
2026-07-02 14:49:35 +07:00
Khoa Vo
6b2b42639f evdev: poll all keyboard devices simultaneously; x11: replace XRecord capture with XQueryKeymap polling
- open_keyboard_device() -> open_keyboard_devices(): returns Vec of all
  keyboard-capable evdev devices instead of just the first one
- run_with_evdev() polls all device FDs via single libc::poll() call
- Each device maintains independent key_state tracking
- Added XQueryKeymap/XLookupString to X11Lib in protocol crate
- X11KeymapCapture: new struct that polls X11 keymap every 10ms via
  XQueryKeymap, diffs consecutive polls for press/release detection,
  and uses XLookupString/Xutf8LookupString for char conversion
- run_with_x11_keymap(): replaces segfaulting XRecord-based run_with_x11
  as the primary X11 fallback path
2026-07-02 14:10:54 +07:00
Khoa Vo
88a64224b6 x11_capture: pass shared_window_class to run_with_x11; fix app change detection
The X11 capture path was calling check_app_change_with X11 window IDs
instead of class names (gnome-terminal-server), corrupting the app
state. Also pass shared_window_class to run_with_x11 so it can use
the correct class for app detection.
2026-07-02 13:41:44 +07:00
Khoa Vo
8d68edb321 daemon: fast grab fallback (300ms) to non-grabbed evdev when grab produces no events
In VM environments, EVIOCGRAB on the AT keyboard device succeeds but
produces no events — the kernel/VM routing prevents event delivery
to the grabber. Previously the daemon waited 30 seconds then exited.

Now: after 3 consecutive 100ms poll timeouts (~300ms) with no events
received, the grab is released and the daemon continues in non-grabbed
evdev mode. In this mode events reach both X (characters appear on
screen) and the daemon simultaneously; the daemon applies backspace
corrections via uinput.

Also removes the 30-second-exit behavior (which locked the keyboard
for 30 seconds unnecessarily) and replaces it with the fast fallback.
2026-07-02 13:41:01 +07:00
Khoa Vo
24f9bc8c7e daemon: fall back to X11 capture when evdev produces no events
When evdev's EVIOCGRAB works (returns success) but no keyboard events
arrive (common in VMs where input bypasses the grabbed device), the
daemon previously exited silently after the 30-second safety timeout.
Now it falls through to X11 XRecord capture as a fallback, which works
reliably in VMs by intercepting keystrokes at the X11 protocol level
rather than the evdev level.

- run_with_evdev no longer uses 'return', so main() continues to X11
  capture after evdev exits (timeout or error)
2026-07-02 13:33:41 +07:00
Khoa Vo
41ecc48b0a daemon: use poll() with 100ms timeout for evdev reads instead of blocking fetch_events
The original fetch_events() call blocked indefinitely on the evdev device.
In VM environments, the grabbed keyboard device may not deliver events
after the initial batch, causing the 30-second safety timeout to trigger
silently — the daemon exits, the grab is released, and subsequent
keystrokes bypass the IME entirely.

Replace with libc::poll() with a 100ms timeout so the event loop stays
responsive. When poll returns 0 (timeout), the loop checks signals, the
30-second grab-safety timeout, and also polls for background window
changes. This ensures the safety timeout actually fires as expected,
and the daemon correctly detects and handles the no-event condition.

Also check for background window class changes during idle periods
(no keypress events) so app detection works consistently.
2026-07-02 13:32:54 +07:00
Khoa Vo
8f8b4abf6d daemon: add diagnostics logging + guard VNI control key consumption behind engine.is_enabled()
- Add 'Event loop started' log at beginning of run_with_evdev
- Add reason for non-interrupted fetch_events errors
- Log each injected key with engine state, character, buffer length, and commands
- Fix VNI control digits being silently consumed when engine is disabled
2026-07-02 13:19:18 +07:00
Khoa Vo
fcd465c2b0 fix: exclude daemon's own sudo ancestor from password detection
When the daemon is started via `sudo vietc-daemon` from a terminal,
is_sudo_process would see sudo( in the terminal's process tree and
disable the engine, making all keystrokes pass through untransformed.

Now is_sudo_process builds the daemon's ancestor PID chain and skips
any sudo process that is an ancestor of the daemon itself.
2026-07-02 12:12:32 +07:00
Khoa Vo
4baa460562 fix: use xdotool for Unicode injection instead of clipboard paste
On X11 (Linux Mint, Ubuntu), clipboard-based Unicode injection was
failing — backspace was sent but the Vietnamese character never
appeared because xclip paste via Ctrl+V wasn't reliable from a
root uinput process.

Now send_string tries xdotool type first (XTest-based, doesn't touch
the user's clipboard), falling back to clipboard paste only on Wayland
or when xdotool is unavailable.
2026-07-02 12:03:21 +07:00
Khoa Vo
d48bccd531 feat: add test VM setup script for Linux Mint/Ubuntu 2026-07-02 11:56:01 +07:00
Khoa Vo
3ccf243f52 feat: terminal VNI input — force VNI in terminals, remove from bypass_apps
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
- Add terminal_apps / terminal_input_method to config
- AppStateManager tracks global vs effective method
- Engine uses effective method (VNI in terminals, global elsewhere)
- Terminals removed from bypass_apps, moved to terminal_apps
- Tray still shows global method (user's setting)
- NOTE: NOTES/terminal-vni.md documents the design
2026-07-02 08:57:17 +07:00
Khoa Vo
7e5281244b docs: update changelog for distro support, roadmap, deps fixes
Some checks failed
Build & Release / Build & test (push) Has been cancelled
Build & Release / Build .deb (push) Has been cancelled
2026-07-01 17:07:40 +07:00
Khoa Vo
473773abf2 docs: add distro support table, fix deps and config typo
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-01 17:05:41 +07:00
Khoa Vo
a09ba8ed63 chore: remove RELEASE_CHECKLIST.md, add roadmap to README
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-01 16:43:46 +07:00
Khoa Vo
d6e1f4d89c uninstall script supports curl pipe, update README with GitHub/Forgejo commands
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-01 16:38:46 +07:00
Khoa Vo
83c3e3d1fa simplify install/uninstall scripts, update README
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-01 16:35:56 +07:00
Khoa Vo
7a8f409d20 docs: rewrite README and CHANGELOG for v0.1.7 release
Some checks failed
Build & Release / Build & test (push) Has been cancelled
Build & Release / Build .deb (push) Has been cancelled
2026-07-01 16:25:36 +07:00
Khoa Vo
a9844221a7 Revert "debug: trace space handling in grab mode"
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
This reverts commit efed6f7e30.
2026-07-01 16:17:47 +07:00
Khoa Vo
efed6f7e30 debug: trace space handling in grab mode
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-01 16:11:45 +07:00
Khoa Vo
3c12aa3233 fix: auto-load uinput kernel module in injector
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-01 16:06:00 +07:00
Khoa Vo
58ff9e145e fix: handle EINTR in evdev loop, silence SelectionRequest log spam
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-01 16:01:15 +07:00
Khoa Vo
db140c3ca6 fix: use sigaction without SA_RESTART so Ctrl+C interrupts blocking fetch_events
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-01 15:56:34 +07:00
Khoa Vo
9e073714f1 fix: non-blocking evdev poll (200ms timeout) so Ctrl+C ungrabs reliably
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-01 15:53:25 +07:00
Khoa Vo
ffd0bc26c8 debug: add keystroke tracing for space forwarding in grab mode
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-01 15:31:56 +07:00
Khoa Vo
e7b7864937 fix: revert non-grab mode to process_key with +1 backspace for control keys
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-01 15:02:17 +07:00
Khoa Vo
3612939643 fix: non-grab mode uses event sourcing (replay_and_inject) to avoid double-letter race conditions
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-01 13:42:02 +07:00
Khoa Vo
82d0796059 fix: non-grabbing mode double-injection — extra backspace for control keys on screen
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-01 13:34:11 +07:00
Khoa Vo
7fe03b7f44 fix: detect Wayland window switches via class change (not just X11 window ID)
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-01 13:28:18 +07:00
Khoa Vo
19ee25784d fix: add SIGINT/SIGTERM handler to release keyboard grab before exit
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-01 13:21:50 +07:00
Khoa Vo
36a6426894 fix: disable auto-restore by default (fixes space consumption on valid Vietnamese)
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-01 13:11:51 +07:00
Khoa Vo
63c495894e fix: GNOME Shell D-Bus queries via gdbus subprocess as original user
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-01 13:05:28 +07:00
Khoa Vo
e025ead244 fix: add GNOME Shell D-Bus PID query for Wayland password detection
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-01 12:59:00 +07:00
Khoa Vo
ddf9f34ad0 fix: add process-based sudo/passwd detection for terminal password prompts
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-01 12:52:07 +07:00
Khoa Vo
f77b7ea682 fix: recover DBUS_SESSION_BUS_ADDRESS when running as root for AT-SPI2 password detection
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-01 12:46:38 +07:00
Khoa Vo
cc05e02559 fix: wl-copy --paste-once for fast clipboard on Wayland/GNOME
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-01 12:41:56 +07:00
Khoa Vo
800d33e6a7 docs: update CHANGELOG with recent fixes, update test count
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-01 12:11:14 +07:00
Khoa Vo
7ac73485e4 fix: add xprop/wmctrl fallbacks for window detection when xdotool is not installed
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-01 12:05:20 +07:00
Khoa Vo
81b483e7ac fix: AT-SPI2 connects to a11y bus, not session bus (password detection was silently failing)
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-01 11:59:40 +07:00
Khoa Vo
94c08bb0da fix: periodic password field re-check every 30 keystrokes for in-terminal prompts
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-01 11:51:57 +07:00
Khoa Vo
ff607f0559 fix: double space on Ctrl+Space toggle (flush char forwarded twice when engine disabled)
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-01 11:34:25 +07:00
Khoa Vo
81a2baa5eb fix: improve single-instance lock with PID + stale detection
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-01 11:25:53 +07:00
Khoa Vo
6beeee2e69 release: v0.1.7 — password detection, Telex enabled, GNOME Wayland support 2026-07-01 11:00:11 +07:00
Khoa Vo
d34180537a fix: improve Ubuntu and derivatives support in install scripts
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-07-01 08:12:47 +07:00
Khoa Vo
d7260917b4 docs: separate installation instructions for github and forgejo
Some checks failed
Build & Release / Build & test (push) Has been cancelled
Build & Release / Build .deb (push) Has been cancelled
2026-06-29 21:19:35 +07:00
Khoa Vo
5242473b93 fix: use file locking instead of abstract socket for single instance to avoid rust null byte error
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-06-29 21:09:48 +07:00
Khoa Vo
66351de4fd fix: remove invalid local keyword usage outside function
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-06-29 20:55:27 +07:00
Khoa Vo
d9091ff66f fix: linuxmint distro detection and rustup default toolchain issue for sudo users
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-06-29 20:50:15 +07:00
Khoa Vo
efa831bd0b add installation and packaging scripts
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build .deb (push) Blocked by required conditions
2026-06-29 20:45:48 +07:00
28 changed files with 3308 additions and 720 deletions

View file

@ -1,32 +1,111 @@
# Changelog # 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
- **AT-SPI2 D-Bus integration**: Queries `org.a11y.atspi.Accessible.GetRole` on the a11y bus (not session bus) to detect password fields. Works in GUI password dialogs and a11y-enabled apps.
- **Process-tree sudo detection**: Scans `pstree` for `sudo`/`passwd` processes — auto-disables Vietnamese when sudo prompts in terminals.
- **Window-title fallback**: Window titles containing "password", "sudo", "mật khẩu" trigger automatic English mode.
- **Window-class fallback**: Known password dialogs (pinentry, polkit, kwallet) detected via `password_apps` config.
- **Periodic re-check**: Re-evaluates password status every 30 keystrokes (catches in-terminal prompts).
### Telex Input Method
- **Telex now fully enabled**: Both VNI and Telex are fully supported. Switch via Ctrl+Shift or tray menu "Input Method > Telex / VNI".
- **Method status file** (`~/.config/vietc/method`): Daemon writes the current method; tray reads it to display.
- **Tray indicator**: Red "VN" for VNI, Blue "TLX" for Telex, Gray "EN" for English mode.
- **Config option**: `toggle_method_key = "shift"` configures the method toggle combo.
### GNOME/Wayland Support
- **GNOME Shell D-Bus integration**: Queries `org.gnome.Shell.Eval` for focused window class, ID, title, and PID — works on Wayland GNOME where xdotool/xprop are unavailable.
- **Window detection chain**: GNOME Shell D-Bus → xprop → wlrctl → xdotool → wmctrl → /proc — works across all environments.
- **Compositor detection**: GNOME/Mutter detected via `pgrep gnome-shell` and `XDG_CURRENT_DESKTOP`.
- **Dependencies**: `dbus` crate (0.9) for AT-SPI2 and GNOME Shell D-Bus.
### Keyboard Grab Safety
- **sigaction without SA_RESTART**: Ctrl+C and SIGTERM now properly interrupt the blocking evdev read, releasing the grab before exit.
- **uinput auto-load**: The injector runs `modprobe uinput` before opening `/dev/uinput`.
- **EINTR handling**: Interrupted system calls are caught and re-check the signal flag.
- **30-second safety timeout**: Auto-releases grab if no events arrive (prevents permanent lockout).
### Clipboard & Injection
- **`wl-copy --paste-once`**: Keeps the clipboard process alive until pasted, eliminating 300-900ms delays on Wayland/GNOME.
- **X11 SelectionRequest log silenced**: No more clipboard spam in the terminal.
- **uinput priority**: uinput is always preferred over X11 XTest injection.
### Config Changes
- **Auto-restore disabled by default**: Prevents space consumption on valid Vietnamese words. Enable via `[auto_restore] enabled = true` if desired.
### CLI Enhancements
- **Pass-through characters**: All characters appear in output (not just engine events).
- **Screen display**: Backspaces properly applied for realistic on-screen view.
- **State reset**: Each input line starts with a clean engine state.
- **New commands**: `:help`, `:status`, `:vi`, `:en`, `:ar on|off`, `:macros`, `:macro add/rm/clear`, `:events`.
### Bug Fixes
- **Double space on Ctrl+Space toggle**: Raw key forwarding now checks engine enabled state.
- **Single-instance lock**: PID written to lock file; stale locks auto-detected and cleaned.
- **xprop/wmctrl fallbacks**: Window detection works without `xdotool` installed.
- **AT-SPI2 a11y bus connection**: Was connecting to session bus; now correctly queries the private a11y bus.
- **Engine state reset between CLI input lines**.
---
## v0.1.6 (2026-06-29) ## v0.1.6 (2026-06-29)
### uinput-First Injection ### uinput-First Injection
- **Injection priority reversed**: uinput (`/dev/uinput`) is now the primary injection backend on X11, with X11 XTest as fallback. uinput sends evdev keycodes that route correctly through libinput — no X11 keycode offset needed. - **Injection priority reversed**: uinput (`/dev/uinput`) is now the primary injection backend on X11, with X11 XTest as fallback.
- **X11 XTest keycode fix**: X11 injector was sending evdev keycodes directly to `XTestFakeKeyEvent`, which expects X11 keycodes (evdev + 8). Backspace sent keycode 14 (evdev) = X11 keycode 14 = "5" key. Fixed by adding +8 offset in all `send_keycode` paths. - **X11 XTest keycode fix**: +8 offset applied to all evdev keycodes for XTest compatibility.
- **`paste_via_clipboard()` backspace fixed**: was hardcoded to X11 keycode 14 (actually "5"), now uses evdev 14 + 8 = 22 (correct X11 backspace). - **`paste_via_clipboard()` backspace fixed**: was sending X11 keycode 14 (= "5"), now sends correct keycode 22.
### Window-Switch Detection ### Window-Switch Detection
- **Active window ID verified on every keystroke**: removed the `gap > 100ms` guard — the daemon now polls `xdotool`/`xprop` directly for every character keypress. This catches window switches that complete in under 100ms, preventing old engine buffer from leaking into the new window. - **Active window ID verified on every keystroke**: removed the 100ms guard — catches sub-100ms window switches.
### Input Method ### Input Method
- **Telex disabled in tray**: greyed out with "(next version)" label and `Disposition::Informative`. Only VNI is functional. - **Telex disabled in tray**: greyed out as "(next version)". Only VNI was functional.
- **Default input method changed** from `"telex"` to `"vni"` in config fallback. - **Default input method changed** to `"vni"`.
### Packaging ### Packaging
- **Flatpak and AppImage removed**: only `.deb` packaging is maintained. `packaging/flatpak/` and `packaging/appimage/` directories deleted. - **Flatpak and AppImage removed**: only `.deb` packaging is maintained.
- **Postinst improvements**: removes stale `/usr/local/bin/vietc*` binaries, deletes old `~/.config/vietc/config.toml` + `overrides.toml` + `.first-launch-done`, shows logout popup (notify-send + zenity). - **Postinst improvements**: cleans stale binaries, config files; shows logout popup.
- **CI workflow**: only `.deb` artifact collected (no AppImage).
--- ---
## v0.1.5 (2026-06-29) ## v0.1.5 (2026-06-29)
## v0.1.5 (2026-06-29)
### Window-Switch Engine Reset ### Window-Switch Engine Reset
- **Engine state now clears on window switch** — when Alt+Tab'ing between apps, the composition buffer is properly reset before the next keystroke. Previously, keystrokes could still apply Vietnamese tone/mark rules across app boundaries, producing corrupted text. - **Engine state now clears on window switch** — when Alt+Tab'ing between apps, the composition buffer is properly reset before the next keystroke. Previously, keystrokes could still apply Vietnamese tone/mark rules across app boundaries, producing corrupted text.
- **`last_key_time` only on character key presses** — modifier-only events (Alt, Ctrl, Shift) no longer update the gap timer, so the 100ms inline xprop poll fires reliably after every window switch, regardless of held modifiers. - **`last_key_time` only on character key presses** — modifier-only events (Alt, Ctrl, Shift) no longer update the gap timer, so the 100ms inline xprop poll fires reliably after every window switch, regardless of held modifiers.

180
CHANGELOG.vi.md Normal file
View file

@ -0,0 +1,180 @@
# 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.

110
NOTES/terminal-vni.md Normal file
View file

@ -0,0 +1,110 @@
# 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 |

438
README.md
View file

@ -2,8 +2,8 @@
<img src="https://img.shields.io/badge/Platform-Linux-blue?style=for-the-badge" alt="Platform"> <img src="https://img.shields.io/badge/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/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/License-MIT-green?style=for-the-badge" alt="License">
<img src="https://img.shields.io/badge/Version-0.1.6-purple?style=for-the-badge" alt="Version"> <img src="https://img.shields.io/badge/Version-0.1.18-purple?style=for-the-badge" alt="Version">
<img src="https://img.shields.io/badge/Tests-106_passing-brightgreen?style=for-the-badge" alt="Tests"> <img src="https://img.shields.io/badge/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"> <img src="https://img.shields.io/badge/Event_Sourcing-✓-blueviolet?style=for-the-badge" alt="Event Sourcing">
</p> </p>
@ -18,283 +18,175 @@
<sub>Zero underline &bull; No pre-edit buffer &bull; Backspace-Replay sync &bull; Built in Rust</sub> <sub>Zero underline &bull; No pre-edit buffer &bull; Backspace-Replay sync &bull; Built in Rust</sub>
</p> </p>
<p align="center">
<a href="README.vi.md">Tiếng Việt</a>
</p>
--- ---
## What is Viet+? ## What is Viet+?
Viet+ is a Vietnamese input method for Linux that takes a fundamentally different approach from every other IME: **Direct Input**. Viet+ is a Vietnamese input method for Linux that takes a fundamentally different approach from every other IME: **Direct Input**.
Most Vietnamese IMEs use a **pre-edit buffer** — you type into a temporary buffer with an ugly underline, and the text only becomes real Vietnamese when you commit it. This causes: Most Vietnamese IMEs use a **pre-edit buffer** — you type into a temporary buffer with an ugly underline, and the text only becomes real Vietnamese when you commit it. This causes duplicate text, underline distraction, broken copy/paste, and desync between the engine state and what's on screen.
- Duplicate text (buffer + committed)
- Underline distraction
- Broken copy/paste
- Desync between engine state and what's on screen
Viet+ eliminates all of this. Keystrokes are **instantly converted to Unicode** — what you type is what you see. No buffer. No underline. No duplication. Viet+ eliminates all of this. Keystrokes are **instantly converted to Unicode** — what you type is what you see. No buffer. No underline. No duplication.
--- ---
## How It Works
### Data Flow: Keypress to Screen
```
Physical Keyboard
┌──────────────────────────────────────────────────────────────┐
│ Stage 1: KEY CAPTURE │
│ │
│ evdev: /dev/input/event* grabs keyboard (primary, reliable) │
│ X11: XRecord passive monitoring (fallback) │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ evdev grab │ │ X11Capture │ │ Window switch │ │
│ │ (libevdev) │ │ (XRecord) │ │ detection (250ms)│ │
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Stage 2: KEY ROUTING │
│ │
│ Modifier keys (Ctrl/Alt/Super) → forward directly │
│ Ctrl+Space → toggle Vietnamese ON/OFF │
│ Backspace → replay_backspace() │
│ Characters → replay_and_inject(ch) │
│ VNI control keys → consume when no match │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Stage 3: BAMBOO ENGINE │
│ │
│ Transformation model: keystrokes produce composition │
│ changes. Marks and tones modify existing characters. │
│ Flexible backtracking scans up to 5 chars for vowels. │
│ Smart uo→ươ cluster with backtrack. │
│ Only emits Replace events when output actually changes. │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Stage 4: KEY INJECTION │
│ │
│ Primary: uinput injection (evdev keycodes, correct on all │
│ display servers — routed through libinput on modern X11) │
│ ASCII: direct Linux keycodes via /dev/uinput │
│ Backspace: Linux keycode 14 via uinput │
│ Vietnamese Unicode: clipboard paste + trailing ASCII via │
│ uinput (split only at whitespace/punctuation boundary) │
│ uinput Ctrl+V via /dev/uinput (no X11 dependency) │
│ │
│ Fallback: X11 XTest injection (X11 keycodes = evdev + 8) │
└──────────────────────────────────────────────────────────────┘
Application receives keystrokes
and renders Vietnamese text on screen
```
### Event Sourcing + Backspace-Replay
This is Viet+'s core innovation. Traditional IMEs track state incrementally — each keystroke updates an internal buffer. But this buffer can **desync** from what's actually on screen (due to focus changes, external pastes, etc.).
Viet+ uses **Event Sourcing**: every input action is recorded as a typed `InputEvent` (`KeyTyped`, `Backspace`, `Flush`, `Paste`) in an `EventStore`. On every keystroke, the entire event history is **replayed from scratch** through a fresh engine to compute the correct diff — no incremental state to desync.
```
Traditional IME:
keystroke → update buffer → emit event → hope it matches screen
Viet+ (Event Sourcing):
keystroke → append InputEvent → replay ALL events in fresh engine → compute diff
```
On every keystroke:
1. The keystroke is appended as an `InputEvent` to the `EventStore`
2. A **brand new** `Engine` is created
3. The **entire** event history is replayed through it via `Engine::replay_events()`
4. The engine's buffer is the **correct** screen output
5. Viet+ computes the diff: `Engine::replay_events_to_commands()` returns Type/Backspace commands
This means:
- **Zero state desync** — always recomputed from scratch
- **Self-healing** — if anything goes wrong, the next keystroke fixes it
- **Privacy-safe**`EventStore::pattern_hash()` provides a sha256 of the event type sequence for pattern detection without any ability to recover original text
- **Simple** — no complex state tracking or synchronization
---
## Architecture
```
vietc/
├── engine/ # Vietnamese composition engine (bamboo-core Rust port)
│ ├── engine.rs # Orchestrator + replay_events(), replay_events_to_commands()
│ ├── event.rs # Event Sourcing: InputEvent, EventStore, Command
│ ├── bamboo.rs # Bamboo engine: transformation model, composition, tone placement
│ ├── input_method.rs # VNI rule definitions
│ └── spelling.rs # Vietnamese syllable validation
├── protocol/ # Keyboard capture & injection
│ ├── inject.rs # KeyInjector trait
│ ├── x11_capture.rs # XRecord keyboard capture via C helper
│ ├── x11_inject.rs # XTest injection (fallback)
│ ├── uinput_monitor.rs # /dev/uinput injection (primary)
│ ├── uinput_client.rs # Unix socket client for vietc-uinputd
│ └── wayland_im.rs # Wayland IM protocol
├── daemon/ # Main daemon process
│ ├── main.rs # Event loops, Backspace-Replay, CPU pinning
│ ├── config.rs # TOML config loader + hot reload
│ ├── app_state.rs # Per-app Vietnamese/English memory
│ └── display.rs # X11/Wayland/compositor detection
├── uinputd/ # Privileged uinput backspace daemon (VMK-style)
│ └── main.rs # Unix socket server for /dev/uinput injection
├── ui/ # System tray icon
│ └── main.rs # Tray + daemon launcher
├── cli/ # Interactive test harness
├── packaging/ # .deb packaging scripts
└── vietc.toml # Default configuration
```
### Component Interaction
```
┌─────────────────────────────────────────────────────────────┐
│ vietc-tray │
│ (System tray icon, daemon launcher) │
└───────────────────────┬─────────────────────────────────────┘
│ starts
┌─────────────────────────────────────────────────────────────┐
│ vietc-daemon │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Config │ │ App State │ │ Display │ │
│ │ (hot reload) │ │ (per-app) │ │ (X11/Wayland) │ │
│ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │
│ │ │ │ │
│ └─────────────────┼────────────────────┘ │
│ │ │
│ ┌──────▼──────┐ │
│ │ Event Loop │ │
│ │ │ │
│ │ evdev: grab │ │
│ │ keyboard │ │
│ │ │ │
│ │ Process │ │
│ │ keystroke │ │
│ │ │ │
│ │ Replay all │ │
│ │ history │ │
│ │ │ │
│ │ Inject │ │
│ │ diff │ │
│ └─────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ vietc-engine │ │
│ │ VniEngine / EnglishDict / Spelling │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ vietc-protocol │ │
│ │ UinputInjector / X11Injector / X11Capture / Wayland │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
---
## Input Methods
### VNI (default, Telex coming in next version)
| Key | Result | Example |
|-----|--------|---------|
| `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``đ` |
Flexible typing: type the full syllable, then add marks/tone keys at the end. Example: `nguye6n4``nguyễn`. The engine scans backward up to 5 characters to find the target vowel.
---
## Features ## Features
| Feature | How It Works | | Feature | How It Works |
|---------|-------------| |---------|-------------|
| **Direct Input** | No pre-edit buffer. Keystrokes instantly become text via uinput injection | | **Direct Input** | No pre-edit buffer. Keystrokes instantly become text via uinput injection |
| **Bamboo Engine** | Transformation model ported from bamboo-core — composition, marks, tones, flexible backtracking | | **VNI & Telex** | Both input methods fully supported, switchable at runtime via Ctrl+Shift |
| **Flexible Backtrack** | Type tone/modifier at end of syllable (`tran5` → `trạn`). Scans up to 5 chars backward | | **Bamboo Engine** | Transformation model — composition, marks, tones, flexible backtracking |
| **Smart Clusters** | `uo``ươ` with backtrack (`chuong7` → `chương`) | | **Smart Clusters** | `uo→ươ` with backtrack, `ua→ưa` horn placement |
| **Tone Placement** | Correct tone positioning for all Vietnamese diphthongs (io→gió, uâ→xuất, yê→nguyễn) | | **Macro Expansion** | `ko → không`, `dc → được`, add your own |
| **Macro Expansion** | `ko``không`, `dc``được`, custom shortcuts | | **Casing Preservation** | `Tieengs → Tiếng`, `TIEENGS → TIẾNG` |
| **Casing Preservation** | `Tieengs``Tiếng`, `TIEENGS``TIẾNG` |
| **App Memory** | Per-app Vietnamese/English state, saved to `overrides.toml` | | **App Memory** | Per-app Vietnamese/English state, saved to `overrides.toml` |
| **Hot Reload** | Config changes apply without restart (polls mtime every 1.5s) | | **Hot Reload** | Config changes apply without restart |
| **Window-Switch Reset** | Active window ID verified on every keystroke — Alt+Tab instantly clears engine state. No stale composition across apps | | **Window-Switch Reset** | Engine clears automatically on Alt+Tab |
| **CPU Priority** | Pins daemon to P-cores (0-3) + nice(-10) for low-latency input | | **CPU Priority** | Pinned to P-cores (0-3) + nice(-10) for low-latency input |
| **Uinput Injection** | Uses `/dev/uinput` for reliable keyboard injection without X11 dependency. Falls back to XTest on systems without uinput access | | **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 |
---
## Input Methods
Both **VNI** and **Telex** are fully supported. Switch via **Ctrl+LeftShift** or the tray menu.
### VNI
| Key | Result | Example |
|-----|--------|---------|
| `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
| Key | Result | Example |
|-----|--------|---------|
| `s` | á (sắc) | `as→á` |
| `f` | à (huyền) | `af→à` |
| `r` | ả (hỏi) | `ar→ả` |
| `x` | ã (ngã) | `ax→ã` |
| `j` | ạ (nặng) | `aj→ạ` |
| `aa` | â | `aa→â` |
| `ee` | ê | `ee→ê` |
| `oo` | ô | `oo→ô` |
| `ow` | ơ | `ow→ơ` |
| `aw` | ă | `aw→ă` |
| `uw` | ư | `uw→ư` |
| `dd` | đ | `dd→đ` |
| `w` | ươ | `chuongw→chương` |
---
## Key Bindings
| Combo | Action |
|-------|--------|
| **Ctrl+Space** | Toggle Vietnamese ON/OFF |
| **Ctrl+LeftShift** | Toggle VNI ↔ Telex |
---
## Password Detection
4-layer automatic detection. When a password field is detected, Vietnamese is automatically disabled:
| Layer | Method | Detects |
|-------|--------|---------|
| 1 | AT-SPI2 D-Bus (a11y role check) | Password fields in accessible apps |
| 2 | Process tree (pstree) | `sudo` / `passwd` in terminal |
| 3 | Window title keywords | `password`, `sudo` in title |
| 4 | Window class matching | pinentry, polkit, kwallet dialogs |
---
## 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 ## Installation
### Single Command (from Source) ### One-Command Install
To install or update Viet+ from the latest source in a single command, run: Works on all ✅ **Supported** distros above. The script auto-detects your package manager:
**From GitHub (recommended):**
```bash ```bash
git clone https://git.khoavo.myds.me/vndangkhoa/vietc.git /tmp/vietc && cd /tmp/vietc && sudo ./install.sh git clone https://github.com/vndangkhoa/vietc.git /tmp/vietc \
&& cd /tmp/vietc && sudo ./install.sh
``` ```
To **uninstall** in a single command, run: **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 ```bash
curl -sSL https://git.khoavo.myds.me/vndangkhoa/vietc/raw/branch/main/uninstall.sh | sudo bash curl -sSL https://git.khoavo.myds.me/vndangkhoa/vietc/raw/branch/main/uninstall.sh | sudo bash
``` ```
### Debian Package (recommended) ### Manual Build & Run
System tray icon + daemon + desktop entry. Requires user to be in the `input` group for keyboard capture.
```bash ```bash
# Install # Install dependencies
sudo dpkg -i vietc_0.1.6-1_amd64.deb sudo apt install git curl build-essential pkg-config \
libx11-dev libxtst-dev libevdev-dev libdbus-1-dev libwayland-dev wl-clipboard
# Log out and log back in (for input group membership to take effect) # Enable accessibility (Ubuntu Wayland — for password detection)
# Then launch "Viet+" from your application menu gsettings set org.gnome.desktop.a11y.applications screen-reader-enabled true
```
The post-install script will: # Build
- Kill any running tray/daemon processes
- Remove stale binaries from `/usr/local/bin/`
- Add your user to the `input` group
- Prompt you to log out and back in
### Build from Source
```bash
git clone https://github.com/vndangkhoa/vietc.git git clone https://github.com/vndangkhoa/vietc.git
cd vietc cd vietc
make deb cargo build --release
sudo dpkg -i packaging/deb/vietc_0.1.6-1_amd64.deb
```
Requires Rust toolchain, `pkg-config`, `libx11-dev`, `libxtst-dev`, `libevdev-dev`. See `packaging/deb/build-deb.sh` for details. # Run (Mint — no sudo needed for uinput)
./target/release/vietc
# Run (Ubuntu — needs sudo for keyboard grab)
sudo ./target/release/vietc
```
--- ---
@ -304,26 +196,84 @@ Config file: `~/.config/vietc/config.toml` or `./vietc.toml`
```toml ```toml
input_method = "vni" # "vni" or "telex" input_method = "vni" # "vni" or "telex"
toggle_key = "space" # Ctrl+Space to toggle toggle_key = "space" # Ctrl+Space to toggle VN/EN
toggle_method_key = "shift" # Ctrl+Shift to toggle VNI/Telex
start_enabled = true # Vietnamese by default start_enabled = true # Vietnamese by default
grab = true # grab keyboard (evdev) grab = true # grab keyboard (evdev)
[auto_restore] [auto_restore]
enabled = true enabled = false # Auto-restore English words (defaults to false)
trigger_keys = ["space", "escape"] 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] [app_state]
enabled = true enabled = true
english_apps = ["code", "vim", "kitty", "foot"] english_apps = ["code", "vim"]
vietnamese_apps = ["telegram", "discord", "firefox"] 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
[macros] [macros]
ko = "không" ko = "không"
dc = "được" dc = "được"
vs = "với" vs = "với"
lm = "làm"
``` ```
### 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
```
vietc/
├── engine/ # Vietnamese composition engine (bamboo-core port)
├── protocol/ # Keyboard capture & injection
│ ├── uinput_monitor.rs # /dev/uinput injection (primary)
│ ├── x11_inject.rs # XTest injection (fallback)
│ ├── x11_capture.rs # XRecord key capture
│ └── wayland_im.rs # Wayland IM protocol (stub)
├── daemon/ # Main daemon process
│ ├── main.rs # Event loops, grab, signal handling
│ ├── config.rs # TOML config loader + hot reload
│ ├── app_state.rs # Per-app VN/EN memory + password detection
│ ├── password_detector.rs # AT-SPI2 D-Bus password field detection
│ └── display.rs # X11/Wayland/compositor detection
├── ui/ # System tray icon (ksni)
│ └── tray.rs # Tray with VN/TLX/EN mode display
├── cli/ # Interactive test harness
└── uinputd/ # Privileged uinput socket daemon
```
---
## 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 ## License

287
README.vi.md Normal file
View file

@ -0,0 +1,287 @@
<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>

View file

@ -1,87 +0,0 @@
# 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

@ -1,6 +1,6 @@
[package] [package]
name = "vietc-cli" name = "vietc-cli"
version = "0.1.6" version = "0.1.7"
edition = "2021" edition = "2021"
description = "Viet+ CLI Test Harness" description = "Viet+ CLI Test Harness"

View file

@ -1,16 +1,47 @@
// SPDX-License-Identifier: MIT
use std::io::{self, Write}; use std::io::{self, Write};
use vietc_engine::{Engine, EngineEvent, InputMethod}; use vietc_engine::{Engine, EngineEvent, EventStore, InputEvent, InputMethod};
struct CliState {
engine: Engine,
method: InputMethod,
events: EventStore,
macros: Vec<(String, String)>,
auto_restore: bool,
}
impl CliState {
fn new() -> Self {
Self {
engine: Engine::new(InputMethod::Telex),
method: InputMethod::Telex,
events: EventStore::new(),
macros: Vec::new(),
auto_restore: true,
}
}
fn set_method(&mut self, method: InputMethod) {
self.method = method;
self.engine.set_method(method);
}
fn status(&self) {
println!(" Method: {:?}", self.method);
println!(" Enabled: {}", self.engine.is_enabled());
println!(" Auto-restore: {}", self.auto_restore);
println!(" Buffer: {:?}", self.engine.buffer());
println!(" Macros: {} defined", self.macros.len());
for (s, e) in &self.macros {
println!(" {} -> {}", s, e);
}
println!(" Events: {} recorded", self.events.len());
}
}
fn main() { fn main() {
let mut engine = Engine::new(InputMethod::Telex); let mut state = CliState::new();
println!("Viet+ IME - Test Harness"); print_help();
println!("==========================");
println!("Type Vietnamese using Telex input.");
println!("Press Enter to flush, type 'quit' to exit.");
println!("Toggle method with ':vni' or ':telex'");
println!();
loop { loop {
print!("> "); print!("> ");
@ -20,82 +51,69 @@ fn main() {
io::stdin().read_line(&mut input).unwrap(); io::stdin().read_line(&mut input).unwrap();
let input = input.trim(); let input = input.trim();
if input.is_empty() {
continue;
}
if input == "quit" || input == "exit" { if input == "quit" || input == "exit" {
break; break;
} }
if input == ":vni" { if input.starts_with(':') {
engine.set_method(InputMethod::Vni); handle_command(&mut state, input);
println!("[Switched to VNI]");
continue; continue;
} }
if input == ":telex" { state.engine.reset();
engine.set_method(InputMethod::Telex);
println!("[Switched to Telex]");
continue;
}
if input == ":reset" {
engine.reset();
println!("[Engine reset]");
continue;
}
if input == ":buffer" {
println!("[Buffer: {:?}]", engine.buffer());
continue;
}
let mut output = String::new(); let mut output = String::new();
let mut events = Vec::new(); let mut events = Vec::new();
for ch in input.chars() { for ch in input.chars() {
if let Some(event) = engine.process_key(ch) { state.events.push(InputEvent::KeyTyped(ch));
match state.engine.process_key(ch) {
None => {
output.push(ch);
}
Some(event) => {
events.push((ch, event.clone())); events.push((ch, event.clone()));
match &event { match &event {
EngineEvent::Flush(text) => { EngineEvent::Insert(text) | EngineEvent::Flush(text) => {
output.push_str(text); output.push_str(text);
} }
EngineEvent::Insert(text) => { EngineEvent::Paste(text) => {
output.push_str(text); output.push_str(text);
} }
EngineEvent::AutoRestore(word) => {
// Auto-restore: delete the word and re-insert it
for _ in 0..word.len() {
output.push('\x08'); // backspace
}
output.push_str(word);
}
EngineEvent::Replace { backspaces, insert } => { EngineEvent::Replace { backspaces, insert } => {
for _ in 0..*backspaces { for _ in 0..*backspaces {
output.push('\x08'); output.push('\x08');
} }
output.push_str(insert); output.push_str(insert);
if is_flush_char(ch) {
output.push(ch);
} }
EngineEvent::UndoTones { }
backspaces, EngineEvent::UndoTones { backspaces, restored } => {
restored,
} => {
for _ in 0..*backspaces { for _ in 0..*backspaces {
output.push('\x08'); output.push('\x08');
} }
output.push_str(restored); output.push_str(restored);
} }
EngineEvent::Paste(text) => { EngineEvent::AutoRestore(word) => {
output.push_str(text); for _ in 0..word.len() {
output.push('\x08');
}
output.push_str(word);
}
} }
} }
} }
} }
// Flush remaining buffer if let Some(event) = state.engine.flush() {
if let Some(event) = engine.flush() {
match &event { match &event {
EngineEvent::Flush(text) => { EngineEvent::Flush(text) | EngineEvent::Insert(text) => {
output.push_str(text);
}
EngineEvent::Insert(text) => {
output.push_str(text); output.push_str(text);
} }
_ => {} _ => {}
@ -104,10 +122,204 @@ fn main() {
} }
println!(" Events: {:?}", events); println!(" Events: {:?}", events);
println!(" Output: {:?}", output); println!(" Raw: {:?}", output);
// Show what it would look like let display = apply_backspaces(&output);
let display: String = output.chars().filter(|c| *c != '\x08').collect(); println!(" Screen: {}", display);
println!(" Display: {}", display);
} }
} }
fn print_help() {
println!("Viet+ IME - Test Harness");
println!("=========================");
println!("Type text with VNI/Telex to see engine output.");
println!();
println!("Commands:");
println!(" :help Show this help");
println!(" :status Show engine state");
println!(" :vi Enable Vietnamese mode");
println!(" :en Disable Vietnamese mode");
println!(" :ar on|off Toggle auto-restore");
println!(" :vni Switch to VNI input");
println!(" :telex Switch to Telex input");
println!(" :reset Reset engine buffer");
println!(" :buffer Show composing buffer");
println!(" :events Show event store history");
println!(" :events clear Clear event store");
println!(" :macros List macros");
println!(" :macro add <s> <e> Add macro shortcut->expansion");
println!(" :macro rm <s> Remove a macro");
println!(" :macro clear Clear all macros");
println!(" quit/exit Quit");
println!();
}
fn handle_command(state: &mut CliState, input: &str) {
let parts: Vec<&str> = input.splitn(4, ' ').collect();
let cmd = parts[0];
match cmd {
":help" | ":h" => print_help(),
":status" | ":st" => state.status(),
":vi" => {
state.engine.set_enabled(true);
println!("[Vietnamese mode ON]");
}
":en" => {
state.engine.set_enabled(false);
println!("[Vietnamese mode OFF]");
}
":ar" => {
if parts.len() < 2 {
println!("[Usage: :ar on|off]");
return;
}
match parts[1] {
"on" => {
state.auto_restore = true;
state.engine.set_auto_restore(true);
println!("[Auto-restore ON]");
}
"off" => {
state.auto_restore = false;
state.engine.set_auto_restore(false);
println!("[Auto-restore OFF]");
}
_ => println!("[Usage: :ar on|off]"),
}
}
":vni" => {
state.set_method(InputMethod::Vni);
println!("[Switched to VNI]");
}
":telex" => {
state.set_method(InputMethod::Telex);
println!("[Switched to Telex]");
}
":reset" => {
state.engine.reset();
println!("[Engine reset]");
}
":buffer" => {
println!("[Buffer: {:?}]", state.engine.buffer());
}
":events" | ":ev" => {
if parts.len() > 1 && parts[1] == "clear" {
state.events.clear();
println!("[Event store cleared]");
return;
}
if state.events.is_empty() {
println!("[No events]");
} else {
println!("[Events: {}]", state.events.len());
for (i, event) in state.events.iter().enumerate() {
println!(" {}: {:?}", i, event);
}
println!(" Raw keystrokes: {:?}", state.events.raw_keystrokes());
println!(" Pattern hash: {}", state.events.pattern_hash());
}
}
":macros" => {
if state.macros.is_empty() {
println!("[No macros defined]");
} else {
println!("[Macros: {}]", state.macros.len());
for (s, e) in &state.macros {
println!(" {} -> {}", s, e);
}
}
}
":macro" => {
if parts.len() < 2 {
println!("[Usage: :macro add <shortcut> <expansion> or :macro rm <s> or :macro clear]");
return;
}
match parts[1] {
"add" | "a" => {
if parts.len() < 4 {
println!("[Usage: :macro add <shortcut> <expansion>]");
return;
}
let shortcut = parts[2].to_string();
let expansion = parts[3].to_string();
state.engine.add_macro(shortcut.clone(), expansion.clone());
if let Some(pos) = state.macros.iter().position(|(s, _)| *s == shortcut) {
state.macros[pos].1 = expansion.clone();
} else {
state.macros.push((shortcut.clone(), expansion.clone()));
}
println!("[Macro added: {} -> {}]", shortcut, expansion);
}
"rm" | "remove" | "del" => {
if parts.len() < 3 {
println!("[Usage: :macro rm <shortcut>]");
return;
}
let shortcut = parts[2];
if let Some(pos) = state.macros.iter().position(|(s, _)| s == shortcut) {
state.macros.remove(pos);
state.engine.clear_macros();
for (s, e) in &state.macros {
state.engine.add_macro(s.clone(), e.clone());
}
println!("[Macro removed: {}]", shortcut);
} else {
println!("[Macro not found: {}]", shortcut);
}
}
"clear" | "c" => {
state.engine.clear_macros();
state.macros.clear();
println!("[All macros cleared]");
}
_ => {
let shortcut = parts[1].to_string();
let expansion = parts.get(2).map(|s| s.to_string()).unwrap_or_default();
if expansion.is_empty() {
println!("[Usage: :macro add <shortcut> <expansion>]");
return;
}
state.engine.add_macro(shortcut.clone(), expansion.clone());
if let Some(pos) = state.macros.iter().position(|(s, _)| *s == shortcut) {
state.macros[pos].1 = expansion.clone();
} else {
state.macros.push((shortcut.clone(), expansion.clone()));
}
println!("[Macro added: {} -> {}]", shortcut, expansion);
}
}
}
_ => {
println!("[Unknown command: {}. Type :help for available commands]", cmd);
}
}
}
fn is_flush_char(ch: char) -> bool {
matches!(ch, ' ' | '\t' | '.' | ',' | '!' | '?' | ';' | ':' | '\n')
}
fn apply_backspaces(s: &str) -> String {
let mut result = String::new();
for ch in s.chars() {
if ch == '\x08' {
result.pop();
} else {
result.push(ch);
}
}
result
}

View file

@ -1,6 +1,6 @@
[package] [package]
name = "vietc-daemon" name = "vietc-daemon"
version = "0.1.6" version = "0.1.7"
edition = "2021" edition = "2021"
description = "Viet+ background daemon" description = "Viet+ background daemon"
@ -21,3 +21,4 @@ serde = { version = "1", features = ["derive"] }
evdev = "0.12" evdev = "0.12"
libc = "0.2" libc = "0.2"
dirs = "5" dirs = "5"
dbus = "0.9"

View file

@ -3,6 +3,8 @@ use std::collections::HashMap;
use std::fs; use std::fs;
use std::process::Command; use std::process::Command;
use crate::password_detector::PasswordDetector;
/// Query _NET_ACTIVE_WINDOW directly via X11 client library (dlopen). /// Query _NET_ACTIVE_WINDOW directly via X11 client library (dlopen).
/// Works inside the Flatpak sandbox where xdotool/xprop are unavailable /// Works inside the Flatpak sandbox where xdotool/xprop are unavailable
/// but libX11.so.6 is present in the GNOME runtime. No external process /// but libX11.so.6 is present in the GNOME runtime. No external process
@ -111,10 +113,87 @@ fn get_active_window_x11_dlopen() -> Option<String> {
} }
} }
/// Get the active window's PID (works on Wayland GNOME via D-Bus, X11 via xprop)
pub fn get_active_window_pid() -> Option<u32> {
// Try GNOME Shell D-Bus first (works on Wayland)
if let Some(pid) = get_gnome_focused_pid() {
return Some(pid);
}
// Fallback: X11 via xprop
get_xprop_window_pid()
}
/// Query GNOME Shell via D-Bus for the focused window's PID
fn get_gnome_focused_pid() -> Option<u32> {
let js = "global.display.focus_window?.get_pid()?.toString() ?? ''";
let (_, pid_str) = gnome_shell_eval(js)?;
pid_str.parse::<u32>().ok().filter(|&p| p > 0)
}
/// Get active window PID via xprop (X11/XWayland)
fn get_xprop_window_pid() -> Option<u32> {
let id = get_active_window_id_xprop()?;
let output = Command::new("xprop")
.args(["-id", &id, "_NET_WM_PID"])
.output()
.ok()?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if let Some(pid_str) = stdout.split("= ").nth(1) {
if let Ok(pid) = pid_str.trim().parse::<u32>() {
if pid > 0 {
return Some(pid);
}
}
}
}
None
}
/// Get the active window's title (lowercase)
pub fn get_active_window_title() -> Option<String> {
// Try GNOME Shell D-Bus (Wayland GNOME)
if let Some(title) = get_gnome_window_title() {
return Some(title.to_lowercase());
}
// Try X11 via xdotool
if let Ok(output) = Command::new("xdotool")
.args(["getactivewindow", "getwindowname"])
.output()
{
if output.status.success() {
let title = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !title.is_empty() {
return Some(title.to_lowercase());
}
}
}
// Try X11 via xprop/wmctrl (fallback when xdotool not installed)
if let Some(title) = get_wmctrl_window_title() {
return Some(title);
}
None
}
/// Query GNOME Shell via D-Bus for the focused window's title
fn get_gnome_window_title() -> Option<String> {
let js = "global.display.focus_window?.get_title() ?? ''";
let (_, title) = gnome_shell_eval(js)?;
if title.is_empty() { None } else { Some(title) }
}
/// Get the active window's X11 ID (unique per window — even within the same /// Get the active window's X11 ID (unique per window — even within the same
/// application). Returns a unique window-identifier string. /// application). Returns a unique window-identifier string.
pub fn get_active_window_id() -> Option<String> { pub fn get_active_window_id() -> Option<String> {
// Try xdotool first (fast, direct) // Try GNOME Shell D-Bus (Wayland GNOME) — returns hex window ID
if let Some(id) = get_gnome_active_window_id() {
return Some(id);
}
// Try xdotool first (fast, direct, X11)
if let Ok(output) = Command::new("xdotool") if let Ok(output) = Command::new("xdotool")
.args(["getactivewindow"]) .args(["getactivewindow"])
.output() .output()
@ -152,9 +231,21 @@ pub fn get_active_window_id() -> Option<String> {
None None
} }
/// Query GNOME Shell via D-Bus for the focused window's XID
fn get_gnome_active_window_id() -> Option<String> {
let js = "global.display.focus_window?.get_id()?.toString(16) ?? ''";
let (_, id) = gnome_shell_eval(js)?;
if id.is_empty() { None } else { Some(format!("0x{}", id)) }
}
/// Detect the currently focused window's class name /// Detect the currently focused window's class name
pub fn get_focused_window_class() -> Option<String> { pub fn get_focused_window_class() -> Option<String> {
// Try Wayland first (wlr-foreign-toplevel) // Try GNOME Shell D-Bus (Wayland GNOME)
if let Some(class) = get_gnome_focused_wm_class() {
return Some(class);
}
// Try Wayland via wlrctl (wlroots compositors)
if let Some(class) = get_wayland_window_class() { if let Some(class) = get_wayland_window_class() {
return Some(class); return Some(class);
} }
@ -164,6 +255,16 @@ pub fn get_focused_window_class() -> Option<String> {
return Some(class); return Some(class);
} }
// Try X11 via xprop (fallback when xdotool is not installed)
if let Some(class) = get_xprop_window_class() {
return Some(class);
}
// Try X11 via wmctrl (fallback)
if let Some(class) = get_wmctrl_window_class() {
return Some(class);
}
// Fallback: try reading from /proc // Fallback: try reading from /proc
if let Some(class) = get_proc_window_class() { if let Some(class) = get_proc_window_class() {
return Some(class); return Some(class);
@ -172,6 +273,70 @@ pub fn get_focused_window_class() -> Option<String> {
None None
} }
/// Query GNOME Shell via D-Bus for the focused window's WM class (app ID)
fn get_gnome_focused_wm_class() -> Option<String> {
let js = "global.display.focus_window?.get_wm_class() ?? ''";
let (_, result) = gnome_shell_eval(js)?;
if result.is_empty() { None } else { Some(result.to_lowercase()) }
}
/// Execute JavaScript in GNOME Shell and return (success, output)
fn gnome_shell_eval(js: &str) -> Option<(bool, String)> {
// Use gdbus as a subprocess running as the original user.
// Direct D-Bus from a root process gets rejected by GNOME Shell.
let output = run_as_user("gdbus")
.args([
"call",
"--session",
"--dest", "org.gnome.Shell",
"--object-path", "/org/gnome/Shell",
"--method", "org.gnome.Shell.Eval",
js,
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
// Output format: (true, 'result_string') or (false, 'error_message')
if let Some(rest) = stdout.strip_prefix("(true, ") {
let result = rest.trim_end_matches(')');
// Remove surrounding quotes: "'value'" -> "value"
let result = result.trim_matches('\'');
Some((true, result.to_string()))
} else if let Some(rest) = stdout.strip_prefix("(false, ") {
let error = rest.trim_end_matches(')');
let error = error.trim_matches('\'');
Some((false, error.to_string()))
} else {
None
}
}
/// Build a Command that runs as the original user (not root).
/// Sets display environment variables for the user's session.
fn run_as_user(program: &str) -> std::process::Command {
let is_root = unsafe { libc::getuid() == 0 };
if is_root {
if let Ok(uid_str) = std::env::var("SUDO_UID") {
if let Ok(uid) = uid_str.parse::<u32>() {
use std::os::unix::process::CommandExt;
let mut cmd = std::process::Command::new(program);
cmd.uid(uid).gid(u32::MAX);
if let Ok(v) = std::env::var("HOME") { cmd.env("HOME", &v); }
if let Ok(v) = std::env::var("DISPLAY") { cmd.env("DISPLAY", v); }
if let Ok(v) = std::env::var("WAYLAND_DISPLAY") { cmd.env("WAYLAND_DISPLAY", v); }
if let Ok(v) = std::env::var("XDG_RUNTIME_DIR") { cmd.env("XDG_RUNTIME_DIR", v); }
if let Ok(v) = std::env::var("DBUS_SESSION_BUS_ADDRESS") { cmd.env("DBUS_SESSION_BUS_ADDRESS", v); }
if let Ok(v) = std::env::var("XAUTHORITY") { cmd.env("XAUTHORITY", v); }
return cmd;
}
}
}
std::process::Command::new(program)
}
fn get_x11_window_class() -> Option<String> { fn get_x11_window_class() -> Option<String> {
let output = Command::new("xdotool") let output = Command::new("xdotool")
.args(["getactivewindow", "getwindowclassname"]) .args(["getactivewindow", "getwindowclassname"])
@ -188,6 +353,103 @@ fn get_x11_window_class() -> Option<String> {
None None
} }
/// Get WM_CLASS via xprop (works on X11 without xdotool)
fn get_xprop_window_class() -> Option<String> {
// First get the active window ID
let id = get_active_window_id_xprop()?;
// Then get WM_CLASS for that window
let output = Command::new("xprop")
.args(["-id", &id, "WM_CLASS"])
.output()
.ok()?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
// Format: WM_CLASS(STRING) = "gnome-terminal", "Gnome-terminal"
// We want the first string (application name)
if let Some(class_part) = stdout.split('"').nth(1) {
let class = class_part.trim().to_lowercase();
if !class.is_empty() {
return Some(class);
}
}
}
None
}
/// Get active window ID via xprop (X11, no xdotool needed)
fn get_active_window_id_xprop() -> Option<String> {
let output = Command::new("xprop")
.args(["-root", "_NET_ACTIVE_WINDOW"])
.output()
.ok()?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
// Format: "_NET_ACTIVE_WINDOW(WINDOW): window id # 0x3a00004"
if let Some(hex) = stdout.split("window id # ").nth(1) {
let hex = hex.trim();
if !hex.is_empty() {
return Some(hex.to_string());
}
}
}
None
}
/// Get window class via wmctrl (X11 fallback)
fn get_wmctrl_window_class() -> Option<String> {
// Only try wmctrl if xdotool is unavailable
if Command::new("xdotool").output().is_ok() {
return None; // xdotool exists, prefer it
}
// wmctrl -l -x lists windows with their class (WM_CLASS)
let output = Command::new("wmctrl")
.args(["-l", "-x"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
// Each line format: 0x00a00001 desktop_num class_name title
// Class name format: "gnome-terminal.Gnome-terminal"
// We want the part before the dot
for line in stdout.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 3 {
let class_part = parts[2];
// Get the app part before the dot if present
let class = class_part.split('.').next().unwrap_or(class_part).to_lowercase();
if !class.is_empty() && class != "nvidia-settings" {
// wmctrl returns ALL windows, not just focused.
// We check if the first listed window is focused,
// or if there's an active-state marker.
return Some(class);
}
}
}
None
}
/// Get active window title using wmctrl
fn get_wmctrl_window_title() -> Option<String> {
let id = get_active_window_id_xprop()?;
let output = Command::new("xprop")
.args(["-id", &id, "WM_NAME"])
.output()
.ok()?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
// Format: "WM_NAME(STRING) = "title""
if let Some(title) = stdout.split('"').nth(1) {
let title = title.trim();
if !title.is_empty() {
return Some(title.to_lowercase());
}
}
}
None
}
fn get_wayland_window_class() -> Option<String> { fn get_wayland_window_class() -> Option<String> {
// Try wlr-foreign-toplevel-management protocol via wlrctl // Try wlr-foreign-toplevel-management protocol via wlrctl
let output = Command::new("wlrctl") let output = Command::new("wlrctl")
@ -233,8 +495,26 @@ pub struct AppStateManager {
vietnamese_apps: Vec<String>, vietnamese_apps: Vec<String>,
/// Bypass apps from config /// Bypass apps from config
bypass_apps: Vec<String>, 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 state
global_enabled: bool, global_enabled: bool,
/// Password detection config
password_enabled: bool,
check_atspi2: bool,
check_window_title: bool,
title_keywords: Vec<String>,
password_apps: Vec<String>,
/// Password detector (AT-SPI2)
password_detector: PasswordDetector,
/// Cached password field state
is_password_field: bool,
} }
impl AppStateManager { impl AppStateManager {
@ -242,18 +522,171 @@ impl AppStateManager {
english_apps: Vec<String>, english_apps: Vec<String>,
vietnamese_apps: Vec<String>, vietnamese_apps: Vec<String>,
bypass_apps: Vec<String>, bypass_apps: Vec<String>,
terminal_apps: Vec<String>,
terminal_input_method: String,
global_method: String,
global_enabled: bool, global_enabled: bool,
) -> Self { ) -> Self {
let effective_method = Self::compute_effective_method(
&global_method,
&terminal_input_method,
&terminal_apps,
"",
);
Self { Self {
current_app: String::new(), current_app: String::new(),
overrides: HashMap::new(), overrides: HashMap::new(),
english_apps: english_apps.iter().map(|s| s.to_lowercase()).collect(), english_apps: english_apps.iter().map(|s| s.to_lowercase()).collect(),
vietnamese_apps: vietnamese_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(), 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, global_enabled,
password_enabled: false,
check_atspi2: true,
check_window_title: true,
title_keywords: Vec::new(),
password_apps: Vec::new(),
password_detector: PasswordDetector::new(),
is_password_field: false,
} }
} }
/// 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,
enabled: bool,
check_atspi2: bool,
check_window_title: bool,
title_keywords: Vec<String>,
password_apps: Vec<String>,
) {
self.password_enabled = enabled;
self.check_atspi2 = check_atspi2;
self.check_window_title = check_window_title;
self.title_keywords = title_keywords.iter().map(|s| s.to_lowercase()).collect();
self.password_apps = password_apps.iter().map(|s| s.to_lowercase()).collect();
}
/// 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 {
if !self.password_enabled {
self.is_password_field = false;
return false;
}
// Layer 1: AT-SPI2 (most accurate, works in terminals and dialogs)
if self.check_atspi2 {
if let Some(is_password) = self.password_detector.check() {
self.is_password_field = is_password;
if is_password {
log_password_detection("AT-SPI2", &self.current_app);
}
return is_password;
}
}
// Layer 2: Window class match (for known password dialogs)
for pattern in &self.password_apps {
if self.current_app.contains(pattern.as_str()) {
self.is_password_field = true;
log_password_detection("window-class", &self.current_app);
return true;
}
}
// Layer 3: Window title heuristic (for sudo prompts, browser dialogs)
if self.check_window_title {
if let Some(title) = get_active_window_title() {
for keyword in &self.title_keywords {
if title.contains(keyword.as_str()) {
self.is_password_field = true;
log_password_detection("window-title", &title);
return true;
}
}
}
}
// Layer 4: Process-based detection (for terminal sudo/passwd prompts)
if let Some(pid) = get_active_window_pid() {
if is_sudo_process(pid) {
self.is_password_field = true;
log_password_detection("process-sudo", &format!("PID {}", pid));
return true;
}
}
self.is_password_field = false;
false
}
/// Is the current widget a password field? (cached)
pub fn is_password_field(&self) -> bool {
self.is_password_field
}
/// Check if focused app changed with a pre-detected class and return whether engine should be enabled /// Check if focused app changed with a pre-detected class and return whether engine should be enabled
pub fn update_with_app(&mut self, new_class: String) -> Option<bool> { pub fn update_with_app(&mut self, new_class: String) -> Option<bool> {
if new_class == self.current_app { if new_class == self.current_app {
@ -261,16 +694,26 @@ impl AppStateManager {
} }
let old_app = self.current_app.clone(); let old_app = self.current_app.clone();
let old_is_terminal = self.is_terminal_app();
self.current_app = new_class; self.current_app = new_class;
eprintln!("[vietc] App: {}{}", old_app, self.current_app); 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(); let should_enable = self.get_default_state();
if method_changed {
Some(should_enable) // signal caller that method might have changed
} else {
Some(should_enable) Some(should_enable)
} }
}
/// Get the default Vietnamese state for the current app /// Get the default Vietnamese state for the current app
fn get_default_state(&self) -> bool { pub fn get_default_state(&self) -> bool {
if !self.global_enabled { if !self.global_enabled {
return false; return false;
} }
@ -326,15 +769,21 @@ impl AppStateManager {
english_apps: Vec<String>, english_apps: Vec<String>,
vietnamese_apps: Vec<String>, vietnamese_apps: Vec<String>,
bypass_apps: Vec<String>, bypass_apps: Vec<String>,
terminal_apps: Vec<String>,
terminal_input_method: String,
) -> &Self { ) -> &Self {
self.english_apps = english_apps.iter().map(|s| s.to_lowercase()).collect(); 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.vietnamese_apps = vietnamese_apps.iter().map(|s| s.to_lowercase()).collect();
self.bypass_apps = bypass_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!( eprintln!(
"[vietc] App lists updated: {} English, {} Vietnamese, {} Bypass", "[vietc] App lists updated: {} English, {} Vietnamese, {} Bypass, {} Terminal",
self.english_apps.len(), self.english_apps.len(),
self.vietnamese_apps.len(), self.vietnamese_apps.len(),
self.bypass_apps.len() self.bypass_apps.len(),
self.terminal_apps.len()
); );
self self
} }
@ -378,6 +827,91 @@ impl AppStateManager {
} }
} }
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.
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;
}
}
return true;
}
}
return false;
}
// Fallback if pstree is not installed: check children directly
if let Ok(output) = Command::new("ps")
.args(["--ppid", &pid.to_string(), "-o", "comm="])
.output()
{
let output = String::from_utf8_lossy(&output.stdout);
for line in output.lines() {
let comm = line.trim();
if comm == "sudo" || comm == "passwd" || comm == "pkexec" {
return true;
}
}
}
false
}
fn override_path() -> std::path::PathBuf { fn override_path() -> std::path::PathBuf {
std::env::var("XDG_CONFIG_HOME") std::env::var("XDG_CONFIG_HOME")
.ok() .ok()

View file

@ -14,12 +14,18 @@ pub struct Config {
#[serde(default = "default_toggle_key")] #[serde(default = "default_toggle_key")]
pub toggle_key: String, pub toggle_key: String,
#[serde(default = "default_toggle_method_key")]
pub toggle_method_key: String,
#[serde(default = "default_start_enabled")] #[serde(default = "default_start_enabled")]
pub start_enabled: bool, pub start_enabled: bool,
#[serde(default)] #[serde(default)]
pub auto_restore: AutoRestoreConfig, pub auto_restore: AutoRestoreConfig,
#[serde(default)]
pub password_detection: PasswordDetectionConfig,
#[serde(default)] #[serde(default)]
pub app_state: AppStateConfig, pub app_state: AppStateConfig,
@ -33,6 +39,37 @@ pub struct Config {
pub debug: bool, pub debug: bool,
} }
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct PasswordDetectionConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_true")]
pub check_atspi2: bool,
#[serde(default = "default_true")]
pub check_window_title: bool,
#[serde(default = "default_title_keywords")]
pub title_keywords: Vec<String>,
#[serde(default = "default_password_apps")]
pub password_apps: Vec<String>,
}
impl Default for PasswordDetectionConfig {
fn default() -> Self {
Self {
enabled: true,
check_atspi2: true,
check_window_title: true,
title_keywords: default_title_keywords(),
password_apps: default_password_apps(),
}
}
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
pub struct AutoRestoreConfig { pub struct AutoRestoreConfig {
@ -57,12 +94,18 @@ pub struct AppStateConfig {
#[serde(default = "default_bypass_apps")] #[serde(default = "default_bypass_apps")]
pub bypass_apps: Vec<String>, 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 { impl Default for AutoRestoreConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
enabled: true, enabled: false,
trigger_keys: default_restore_keys(), trigger_keys: default_restore_keys(),
} }
} }
@ -75,6 +118,8 @@ impl Default for AppStateConfig {
english_apps: default_english_apps(), english_apps: default_english_apps(),
vietnamese_apps: default_vietnamese_apps(), vietnamese_apps: default_vietnamese_apps(),
bypass_apps: default_bypass_apps(), bypass_apps: default_bypass_apps(),
terminal_apps: default_terminal_apps(),
terminal_input_method: default_terminal_method(),
} }
} }
} }
@ -85,6 +130,9 @@ fn default_input_method() -> String {
fn default_toggle_key() -> String { fn default_toggle_key() -> String {
"space".into() "space".into()
} }
fn default_toggle_method_key() -> String {
"shift".into()
}
fn default_start_enabled() -> bool { fn default_start_enabled() -> bool {
true true
} }
@ -97,6 +145,30 @@ fn default_false() -> bool {
fn default_restore_keys() -> Vec<String> { fn default_restore_keys() -> Vec<String> {
vec!["space".into(), "escape".into()] vec!["space".into(), "escape".into()]
} }
fn default_title_keywords() -> Vec<String> {
vec![
"password".into(),
"passphrase".into(),
"secret".into(),
"mật khẩu".into(),
"mk".into(),
"sudo".into(),
]
}
fn default_password_apps() -> Vec<String> {
vec![
"pinentry".into(),
"pinentry-gtk-2".into(),
"pinentry-qt".into(),
"lxqt-sudo".into(),
"kdesudo".into(),
"gksudo".into(),
"polkit-gnome-authentication-agent-1".into(),
"kwallet".into(),
"gnome-keyring".into(),
"ssh-askpass".into(),
]
}
fn default_english_apps() -> Vec<String> { fn default_english_apps() -> Vec<String> {
vec![ vec![
@ -111,6 +183,16 @@ fn default_english_apps() -> Vec<String> {
} }
fn default_bypass_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![ vec![
"terminal".into(), "terminal".into(),
"kitty".into(), "kitty".into(),
@ -119,17 +201,26 @@ fn default_bypass_apps() -> Vec<String> {
"wezterm".into(), "wezterm".into(),
"konsole".into(), "konsole".into(),
"gnome-terminal".into(), "gnome-terminal".into(),
"gnome-terminal-server".into(),
"kgx".into(),
"st".into(), "st".into(),
"urxvt".into(), "urxvt".into(),
"xterm".into(), "xterm".into(),
"steam".into(), "termite".into(),
"dota".into(), "terminator".into(),
"csgo".into(), "tilix".into(),
"minecraft".into(), "deepin-terminal".into(),
"factorio".into(), "pantheon-terminal".into(),
"blackbox".into(),
"contour".into(),
"cool-retro-term".into(),
] ]
} }
fn default_terminal_method() -> String {
"vni".into()
}
fn default_vietnamese_apps() -> Vec<String> { fn default_vietnamese_apps() -> Vec<String> {
vec![ vec![
"telegram".into(), "telegram".into(),
@ -192,8 +283,10 @@ impl Default for Config {
Self { Self {
input_method: default_input_method(), input_method: default_input_method(),
toggle_key: default_toggle_key(), toggle_key: default_toggle_key(),
toggle_method_key: default_toggle_method_key(),
start_enabled: default_start_enabled(), start_enabled: default_start_enabled(),
auto_restore: AutoRestoreConfig::default(), auto_restore: AutoRestoreConfig::default(),
password_detection: PasswordDetectionConfig::default(),
app_state: AppStateConfig::default(), app_state: AppStateConfig::default(),
macros, macros,
grab: false, // default false so daemon works without root (needs input group for uinput) grab: false, // default false so daemon works without root (needs input group for uinput)
@ -282,7 +375,7 @@ vs = "với"
assert_eq!(config.input_method, "vni"); assert_eq!(config.input_method, "vni");
assert_eq!(config.toggle_key, "space"); assert_eq!(config.toggle_key, "space");
assert!(config.start_enabled); // default changed to true assert!(config.start_enabled); // default changed to true
assert!(config.auto_restore.enabled); assert!(!config.auto_restore.enabled);
assert!(config.app_state.enabled); assert!(config.app_state.enabled);
assert!(!config.app_state.english_apps.is_empty()); assert!(!config.app_state.english_apps.is_empty());
assert!(!config.app_state.vietnamese_apps.is_empty()); assert!(!config.app_state.vietnamese_apps.is_empty());
@ -327,12 +420,16 @@ foo = "bar"
[app_state] [app_state]
english_apps = ["vim", "neovim"] english_apps = ["vim", "neovim"]
vietnamese_apps = ["zalo", "messenger"] vietnamese_apps = ["zalo", "messenger"]
bypass_apps = ["kitty"] bypass_apps = ["steam"]
terminal_apps = ["kitty"]
terminal_input_method = "telex"
"#; "#;
let config: Config = toml::from_str(toml).unwrap(); let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.app_state.english_apps, vec!["vim", "neovim"]); 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.vietnamese_apps, vec!["zalo", "messenger"]);
assert_eq!(config.app_state.bypass_apps, vec!["kitty"]); 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");
} }
#[test] #[test]
@ -354,11 +451,31 @@ bypass_apps = ["kitty"]
#[test] #[test]
fn default_config_bypass_apps() { fn default_config_bypass_apps() {
let config = Config::default(); let config = Config::default();
assert!(config.app_state.bypass_apps.contains(&"kitty".to_string())); assert!(config.app_state.bypass_apps.contains(&"steam".to_string()));
assert!(config assert!(!config
.app_state .app_state
.bypass_apps .bypass_apps
.contains(&"alacritty".to_string())); .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");
} }
#[test] #[test]
@ -401,4 +518,30 @@ unknown_field = "value"
let config: Config = toml::from_str(toml).unwrap(); let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.input_method, "telex"); assert_eq!(config.input_method, "telex");
} }
#[test]
fn parse_password_detection() {
let toml = r#"
[password_detection]
enabled = true
check_atspi2 = true
check_window_title = true
title_keywords = ["password", "passphrase"]
password_apps = ["pinentry", "kwallet"]
"#;
let config: Config = toml::from_str(toml).unwrap();
assert!(config.password_detection.enabled);
assert!(config.password_detection.check_atspi2);
assert_eq!(config.password_detection.title_keywords, vec!["password", "passphrase"]);
assert_eq!(config.password_detection.password_apps, vec!["pinentry", "kwallet"]);
}
#[test]
fn parse_toggle_method_key() {
let toml = r#"
toggle_method_key = "shift"
"#;
let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.toggle_method_key, "shift");
}
} }

View file

@ -85,5 +85,19 @@ pub fn detect_compositor() -> Option<String> {
} }
} }
// Check for GNOME/Mutter (Ubuntu default)
if let Ok(output) = Command::new("pgrep").arg("-x").arg("gnome-shell").output() {
if output.status.success() {
return Some("GNOME (Mutter)".into());
}
}
// Check XDG_CURRENT_DESKTOP for GNOME
if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") {
if desktop.to_lowercase().contains("gnome") {
return Some("GNOME".into());
}
}
None None
} }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,75 @@
use std::collections::HashMap;
use std::time::Duration;
use dbus::arg::{RefArg, Variant};
use dbus::blocking::Connection;
const ROLE_PASSWORD_TEXT: i32 = 62;
pub struct PasswordDetector {
cached: Option<bool>,
atspi_ok: bool,
}
impl PasswordDetector {
pub fn new() -> Self {
Self { cached: None, atspi_ok: false }
}
pub fn check(&mut self) -> Option<bool> {
let r = self.check_atspi2();
self.atspi_ok = r.is_some();
if let Some(v) = r {
self.cached = Some(v);
}
r
}
pub fn is_available(&self) -> bool {
self.atspi_ok
}
pub fn cached_result(&self) -> Option<bool> {
self.cached
}
/// Get the AT-SPI2 accessibility bus address via session bus
fn get_a11y_bus_address() -> Option<String> {
let conn = Connection::new_session().ok()?;
let proxy = conn.with_proxy(
"org.a11y.Bus",
"/org/a11y/bus",
Duration::from_secs(2),
);
let (addr,): (String,) = proxy
.method_call("org.a11y.Bus", "GetAddress", ())
.ok()?;
Some(addr)
}
fn check_atspi2(&self) -> Option<bool> {
// AT-SPI2 runs on its own private D-Bus (accessibility bus),
// NOT on the session bus. We must first get the a11y bus address.
let addr = Self::get_a11y_bus_address()?;
let conn = Connection::new_address(&addr).ok()?;
let timeout = Duration::from_secs(2);
let proxy = conn.with_proxy(
"org.a11y.atspi.Registry",
"/org/a11y/atspi/registry",
timeout,
);
let (bus_name, props, _children): (String, HashMap<String, Variant<Box<dyn RefArg>>>, Vec<Variant<Box<dyn RefArg>>>) =
proxy.method_call("org.a11y.atspi.Registry", "GetFocus", ()).ok()?;
let path_variant = props.get("path")?;
let path_str = path_variant.0.as_str()?;
let acc_proxy = conn.with_proxy(&bus_name, path_str, timeout);
let (role,): (i32,) = acc_proxy.method_call("org.a11y.atspi.Accessible", "GetRole", ()).ok()?;
Some(role == ROLE_PASSWORD_TEXT)
}
}

View file

@ -1,6 +1,6 @@
[package] [package]
name = "vietc-engine" name = "vietc-engine"
version = "0.1.6" version = "0.1.7"
edition = "2021" edition = "2021"
description = "Viet+ Vietnamese IME Core Engine" description = "Viet+ Vietnamese IME Core Engine"

119
install.sh Executable file
View file

@ -0,0 +1,119 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MIT
# Viet+ — Vietnamese Input Method Installer
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m'; NC='\033[0m'
[ "$EUID" -ne 0 ] && echo -e "${RED}Please run with sudo.${NC}" && exit 1
echo -e "${GREEN}=== Viet+ Installer ===${NC}"
# Detect distro
[ -f /etc/os-release ] && . /etc/os-release
DISTRO="${ID:-unknown}"
echo "Detected: $DISTRO"
# Install dependencies
install_deps() {
case "$DISTRO" in
ubuntu|debian|linuxmint|mint|pop|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
;;
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
;;
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
;;
*)
echo -e "${YELLOW}Unsupported: $DISTRO. Install deps manually.${NC}"
;;
esac
}
install_deps
# Install Rust if missing
if ! command -v cargo &>/dev/null; then
echo "Installing Rust..."
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
export PATH="$HOME/.cargo/bin:$PATH"
fi
# Kill old processes
pkill -x vietc-tray 2>/dev/null || true
pkill -x vietc-daemon 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
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
# 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
fi
# Create default config
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
[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"]
[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 -e "${GREEN}=== Done! ===${NC}"
echo -e "${YELLOW}Log out and log back in, then run: vietc-tray${NC}"

3
packaging/99-vietc.rules Normal file
View file

@ -0,0 +1,3 @@
# Udev rule for Viet+ input method
# Allows non-root users in the 'input' group to write to /dev/uinput
KERNEL=="uinput", GROUP="input", MODE="0660", OPTIONS+="static_node=uinput"

60
packaging/build-tarball.sh Executable file
View file

@ -0,0 +1,60 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MIT
# Viet+ — Generic Linux Tarball Packager
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
VERSION=$(grep '^version' "$PROJECT_ROOT/engine/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
PACKAGE_NAME="vietc_${VERSION}_linux_amd64"
DIST_DIR="$PROJECT_ROOT/target/dist"
STAGING="$DIST_DIR/$PACKAGE_NAME"
echo "=== Building Viet+ release tarball v${VERSION} ==="
# 1. Compile all components
echo "[1/4] Compiling components in release mode..."
cargo build --release --manifest-path "$PROJECT_ROOT/Cargo.toml"
(cd "$PROJECT_ROOT/ui" && cargo build --release)
gcc -O2 -o "$PROJECT_ROOT/target/release/vietc-xrecord" "$PROJECT_ROOT/packaging/deb/vietc-xrecord.c" -lX11 -lXtst
# 2. Recreate staging directory
echo "[2/4] Assembling package structure..."
rm -rf "$DIST_DIR"
mkdir -p "$STAGING/bin"
mkdir -p "$STAGING/udev"
mkdir -p "$STAGING/desktop"
mkdir -p "$STAGING/icons"
mkdir -p "$STAGING/config"
# 3. Copy binaries & rename vietc -> vietc-daemon
cp "$PROJECT_ROOT/target/release/vietc" "$STAGING/bin/vietc-daemon"
cp "$PROJECT_ROOT/target/release/vietc-cli" "$STAGING/bin/"
cp "$PROJECT_ROOT/target/release/vietc-uinputd" "$STAGING/bin/"
cp "$PROJECT_ROOT/ui/target/release/vietc-tray" "$STAGING/bin/"
cp "$PROJECT_ROOT/target/release/vietc-xrecord" "$STAGING/bin/"
# 4. Copy assets & support files
cp "$PROJECT_ROOT/packaging/99-vietc.rules" "$STAGING/udev/"
cp "$PROJECT_ROOT/packaging/deb/vietc.desktop" "$STAGING/desktop/"
cp "$PROJECT_ROOT/vietc.toml" "$STAGING/config/config.toml"
cp "$PROJECT_ROOT/packaging/icons/vietc.svg" "$STAGING/icons/"
cp "$PROJECT_ROOT/packaging/icons/vietc-vn.svg" "$STAGING/icons/"
cp "$PROJECT_ROOT/packaging/icons/vietc-en.svg" "$STAGING/icons/"
cp "$PROJECT_ROOT/install.sh" "$STAGING/"
chmod +x "$STAGING/install.sh"
cp "$PROJECT_ROOT/README.md" "$STAGING/"
cp "$PROJECT_ROOT/LICENSE" "$STAGING/"
# 5. Compress
echo "[3/4] Creating tarball archive..."
(cd "$DIST_DIR" && tar -czf "${PACKAGE_NAME}.tar.gz" "$PACKAGE_NAME")
# 6. Cleanup temp staging
rm -rf "$STAGING"
echo -e "\n=== Package successfully built: ==="
echo "target/dist/${PACKAGE_NAME}.tar.gz"

View file

@ -27,6 +27,8 @@ mkdir -p "$STAGING/usr/share/icons/hicolor/256x256/apps"
mkdir -p "$STAGING/usr/share/doc/vietc" mkdir -p "$STAGING/usr/share/doc/vietc"
mkdir -p "$STAGING/usr/share/metainfo" mkdir -p "$STAGING/usr/share/metainfo"
mkdir -p "$STAGING/etc/xdg/autostart" mkdir -p "$STAGING/etc/xdg/autostart"
mkdir -p "$STAGING/lib/udev/rules.d"
# Copy binaries # Copy binaries
echo "[3/5] Installing binaries..." echo "[3/5] Installing binaries..."
@ -46,6 +48,10 @@ cp "$PROJECT_ROOT/packaging/icons/vietc-en.svg" "$STAGING/usr/share/icons/hicolo
# Desktop file # Desktop file
cp "$SCRIPT_DIR/vietc.desktop" "$STAGING/usr/share/applications/" cp "$SCRIPT_DIR/vietc.desktop" "$STAGING/usr/share/applications/"
# Udev rules
cp "$PROJECT_ROOT/packaging/99-vietc.rules" "$STAGING/lib/udev/rules.d/"
# XDG autostart — launches tray on every login for all users # XDG autostart — launches tray on every login for all users
cat > "$STAGING/etc/xdg/autostart/vietc-tray.desktop" << 'AUTOSTART' cat > "$STAGING/etc/xdg/autostart/vietc-tray.desktop" << 'AUTOSTART'
[Desktop Entry] [Desktop Entry]
@ -122,7 +128,7 @@ Section: utils
Priority: optional Priority: optional
Architecture: amd64 Architecture: amd64
Depends: libc6 (>= 2.31), libevdev2 (>= 1.9.0) Depends: libc6 (>= 2.31), libevdev2 (>= 1.9.0)
Recommends: libwayland-client0 (>= 1.20), libx11-6, libxtst6, libdbus-1-3, xclip Recommends: libwayland-client0 (>= 1.20), libx11-6, libxtst6, libdbus-1-3, xclip, wl-clipboard
Maintainer: Khoa Vo <vndangkhoa@gmail.com> Maintainer: Khoa Vo <vndangkhoa@gmail.com>
Description: Viet+ — Vietnamese Input Method for Linux Description: Viet+ — Vietnamese Input Method for Linux
Zero-configuration Vietnamese input method engine supporting Zero-configuration Vietnamese input method engine supporting
@ -206,6 +212,13 @@ case "$1" in
if command -v gtk-update-icon-cache >/dev/null 2>&1; then 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 gtk-update-icon-cache -f /usr/share/icons/hicolor/ >/dev/null 2>&1 || true
fi fi
# Reload udev rules to apply the new uinput rule
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
;; ;;
esac esac
POSTINST POSTINST

View file

@ -1,6 +1,6 @@
[package] [package]
name = "vietc-protocol" name = "vietc-protocol"
version = "0.1.6" version = "0.1.7"
edition = "2021" edition = "2021"
description = "Viet+ keystroke injection backends (X11/Wayland)" description = "Viet+ keystroke injection backends (X11/Wayland)"

View file

@ -72,6 +72,10 @@ impl UinputInjector {
} }
pub fn new(name: &str) -> Result<Self, Box<dyn std::error::Error>> { pub fn new(name: &str) -> Result<Self, Box<dyn std::error::Error>> {
// Ensure uinput kernel module is loaded before trying to open the device
let _ = std::process::Command::new("modprobe")
.args(["uinput"])
.output();
let file = OpenOptions::new() let file = OpenOptions::new()
.read(true) .read(true)
.write(true) .write(true)
@ -218,7 +222,12 @@ impl KeyInjector for UinputInjector {
return InjectResult::Success; return InjectResult::Success;
} }
// Unicode text: clipboard copy + paste (reliable method) // 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
if !self.paste_via_clipboard(s) { if !self.paste_via_clipboard(s) {
eprintln!( eprintln!(
"[vietc] send_string failed for '{}' (clipboard unavailable)", "[vietc] send_string failed for '{}' (clipboard unavailable)",
@ -384,6 +393,27 @@ impl UinputInjector {
InjectResult::Success 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 /// 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. /// on X11). Returns None if no clipboard tool is available or it is empty.
fn read_clipboard() -> Option<String> { fn read_clipboard() -> Option<String> {
@ -495,28 +525,44 @@ impl UinputInjector {
fn copy_to_clipboard(s: &str) -> bool { fn copy_to_clipboard(s: &str) -> bool {
let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok(); let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
let (prog, args): (&str, &[&str]) = if is_wayland { let (prog, args): (&str, &[&str]) = if is_wayland {
("wl-copy", &[]) // On Wayland/GNOME, wl-copy exits before the compositor reads
// the clipboard data. --paste-once keeps it alive until pasted,
// eliminating the 300900ms compositor lookup delay. We spawn
// it detached (no .wait()) — the child lives until Ctrl+V lands.
("wl-copy", &["--paste-once"])
} else { } else {
("xclip", &["-selection", "clipboard", "-i"]) ("xclip", &["-selection", "clipboard", "-i"])
}; };
let mut cmd = Self::user_cmd(prog); let mut cmd = Self::user_cmd(prog);
cmd.args(args); cmd.args(args);
let result = cmd cmd.stdin(std::process::Stdio::piped());
.stdin(std::process::Stdio::piped()) cmd.stdout(std::process::Stdio::null());
.spawn() cmd.stderr(std::process::Stdio::null());
.and_then(|mut child| {
match cmd.spawn() {
Ok(mut child) => {
use std::io::Write; use std::io::Write;
child.stdin.take().unwrap().write_all(s.as_bytes())?; if let Some(mut stdin) = child.stdin.take() {
child.wait() let _ = stdin.write_all(s.as_bytes());
}
if is_wayland {
// --paste-once: don't wait — child stays alive until the
// compositor reads the data (Ctrl+V arrives later).
// Detach the wait so we don't block.
std::thread::spawn(move || {
let _ = child.wait();
}); });
if let Ok(status) = result {
if status.success() {
return true; return true;
} }
// X11: wait for xclip to finish writing
child.wait().map(|s| s.success()).unwrap_or(false)
} }
eprintln!("[vietc] copy_to_clipboard: {} failed", prog); Err(e) => {
eprintln!("[vietc] copy_to_clipboard: {} spawn failed: {}", prog, e);
false false
} }
}
}
/// Send Ctrl+V through our uinput device. /// Send Ctrl+V through our uinput device.
fn send_ctrl_v(&self) { fn send_ctrl_v(&self) {

View file

@ -7,6 +7,26 @@ type Display = c_void;
type Window = u64; type Window = u64;
type Atom = u64; type Atom = u64;
type Time = 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" { extern "C" {
fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void; fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void;
@ -40,6 +60,9 @@ struct X11Lib {
x_destroy_window: unsafe extern "C" fn(*mut Display, Window) -> c_int, x_destroy_window: unsafe extern "C" fn(*mut Display, Window) -> c_int,
x_pending: unsafe extern "C" fn(*mut Display) -> c_int, x_pending: unsafe extern "C" fn(*mut Display) -> c_int,
x_next_event: unsafe extern "C" fn(*mut Display, *mut XEvent), 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 { impl X11Lib {
@ -95,6 +118,12 @@ impl X11Lib {
let x_destroy_window = sym!(x11_handle, "XDestroyWindow"); let x_destroy_window = sym!(x11_handle, "XDestroyWindow");
let x_pending = sym!(x11_handle, "XPending"); let x_pending = sym!(x11_handle, "XPending");
let x_next_event = sym!(x11_handle, "XNextEvent"); 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"); let x_test_fake_key_event = sym!(xtst_handle, "XTestFakeKeyEvent");
Ok(Self { Ok(Self {
@ -114,6 +143,9 @@ impl X11Lib {
x_destroy_window, x_destroy_window,
x_pending, x_pending,
x_next_event, x_next_event,
x_query_keymap,
x_lookup_string,
x_utf8_lookup_string,
}) })
} }
} }
@ -312,10 +344,7 @@ impl X11Injector {
} }
fn handle_selection_request(&self, req: &XSelectionRequestEvent) { fn handle_selection_request(&self, req: &XSelectionRequestEvent) {
eprintln!( // Silently handle — SelectionRequest floods the log on X11
"[vietc] SelectionRequest: target={} requestor={}",
req.target, req.requestor
);
// Determine what property to use for the response // Determine what property to use for the response
let property = if req.property == 0 { let property = if req.property == 0 {
@ -556,3 +585,109 @@ impl KeyInjector for X11Injector {
InjectResult::Success 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);
}
}
}

65
scripts/setup-test-vm.sh Normal file
View file

@ -0,0 +1,65 @@
#!/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,6 +1,6 @@
[package] [package]
name = "vietc-tray" name = "vietc-tray"
version = "0.1.6" version = "0.1.7"
edition = "2021" edition = "2021"
description = "Viet+ system tray icon" description = "Viet+ system tray icon"
@ -14,3 +14,4 @@ toml = "0.8"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
dirs = "5" dirs = "5"
libc = "0.2.186"

View file

@ -179,15 +179,25 @@ fn config_path() -> PathBuf {
.join("vietc") .join("vietc")
} }
fn main() { fn ensure_single_instance(name: &str) {
// Ensure single instance to avoid duplicate tray icons let uid = unsafe { libc::getuid() };
let _listener = match std::os::unix::net::UnixListener::bind("\0vietc-tray-lock") { let path = format!("/tmp/{}-{}.lock", name, uid);
Ok(l) => l, let path_c = std::ffi::CString::new(path).unwrap();
Err(_) => { let fd = unsafe { libc::open(path_c.as_ptr(), libc::O_CREAT | libc::O_RDWR, 0o600) };
eprintln!("[vietc-tray] Another instance is already running. Exiting."); if fd < 0 {
eprintln!("[{}] Failed to open lock file", name);
std::process::exit(1);
}
let res = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
if res < 0 {
eprintln!("[{}] Another instance is already running. Exiting.", name);
std::process::exit(0); std::process::exit(0);
} }
}; }
fn main() {
// Ensure single instance to avoid duplicate tray icons
ensure_single_instance("vietc-tray");
eprintln!("[vietc-tray] Starting"); eprintln!("[vietc-tray] Starting");

View file

@ -12,6 +12,23 @@ fn write_status(state: &str) {
} }
} }
fn read_method() -> String {
let path = dirs::config_dir()
.map(|d| d.join("vietc").join("method"))
.unwrap_or_else(|| std::path::PathBuf::from("/tmp/vietc-method"));
std::fs::read_to_string(&path)
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| {
config::Config::load().input_method
})
}
fn write_method(method: &str) {
if let Some(config_dir) = dirs::config_dir() {
let _ = std::fs::write(config_dir.join("vietc").join("method"), method);
}
}
fn read_status() -> String { fn read_status() -> String {
let path = dirs::config_dir() let path = dirs::config_dir()
.map(|d| d.join("vietc").join("status")) .map(|d| d.join("vietc").join("status"))
@ -68,6 +85,11 @@ fn ensure_icons() {
<text x="64" y="96" text-anchor="middle" fill="#ffffff" font-size="48" font-weight="bold" font-family="system-ui, sans-serif">VN</text> <text x="64" y="96" text-anchor="middle" fill="#ffffff" font-size="48" font-weight="bold" font-family="system-ui, sans-serif">VN</text>
</svg>"##; </svg>"##;
let svg_tlx = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<rect x="8" y="8" width="112" height="112" rx="24" fill="#2563eb"/>
<text x="64" y="96" text-anchor="middle" fill="#ffffff" font-size="48" font-weight="bold" font-family="system-ui, sans-serif">TLX</text>
</svg>"##;
let svg_en = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"> let svg_en = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<rect x="8" y="8" width="112" height="112" rx="24" fill="#4b5563"/> <rect x="8" y="8" width="112" height="112" rx="24" fill="#4b5563"/>
<text x="64" y="96" text-anchor="middle" fill="#ffffff" font-size="48" font-weight="bold" font-family="system-ui, sans-serif">EN</text> <text x="64" y="96" text-anchor="middle" fill="#ffffff" font-size="48" font-weight="bold" font-family="system-ui, sans-serif">EN</text>
@ -78,11 +100,15 @@ fn ensure_icons() {
if let Some(home_icons) = &home { if let Some(home_icons) = &home {
let _ = std::fs::create_dir_all(&home_icons); let _ = std::fs::create_dir_all(&home_icons);
let vn_path = home_icons.join("vietc-vn.svg"); let vn_path = home_icons.join("vietc-vn.svg");
let tlx_path = home_icons.join("vietc-tlx.svg");
let en_path = home_icons.join("vietc-en.svg"); let en_path = home_icons.join("vietc-en.svg");
if !vn_path.exists() { if !vn_path.exists() {
let _ = std::fs::write(&vn_path, svg_vn); let _ = std::fs::write(&vn_path, svg_vn);
} }
if !tlx_path.exists() {
let _ = std::fs::write(&tlx_path, svg_tlx);
}
if !en_path.exists() { if !en_path.exists() {
let _ = std::fs::write(&en_path, svg_en); let _ = std::fs::write(&en_path, svg_en);
} }
@ -95,11 +121,15 @@ fn ensure_icons() {
let _ = std::fs::create_dir_all(&icons_dir); let _ = std::fs::create_dir_all(&icons_dir);
let vn_theme = icons_dir.join("hicolor/scalable/apps/vietc-vn.svg"); let vn_theme = icons_dir.join("hicolor/scalable/apps/vietc-vn.svg");
let tlx_theme = icons_dir.join("hicolor/scalable/apps/vietc-tlx.svg");
let en_theme = icons_dir.join("hicolor/scalable/apps/vietc-en.svg"); let en_theme = icons_dir.join("hicolor/scalable/apps/vietc-en.svg");
if !vn_theme.exists() { if !vn_theme.exists() {
let _ = std::fs::write(&vn_theme, svg_vn); let _ = std::fs::write(&vn_theme, svg_vn);
} }
if !tlx_theme.exists() {
let _ = std::fs::write(&tlx_theme, svg_tlx);
}
if !en_theme.exists() { if !en_theme.exists() {
let _ = std::fs::write(&en_theme, svg_en); let _ = std::fs::write(&en_theme, svg_en);
} }
@ -247,12 +277,17 @@ impl Tray for VietTray {
} }
fn icon_name(&self) -> String { fn icon_name(&self) -> String {
let is_tlx = self.mode == "vn" && self.im == "telex";
if is_flatpak() { if is_flatpak() {
if self.mode == "vn" { if is_tlx {
"io.github.vietc.VietPlus.vietc-tlx".into()
} else if self.mode == "vn" {
"io.github.vietc.VietPlus.vietc-vn".into() "io.github.vietc.VietPlus.vietc-vn".into()
} else { } else {
"io.github.vietc.VietPlus.vietc-en".into() "io.github.vietc.VietPlus.vietc-en".into()
} }
} else if is_tlx {
"vietc-tlx".into()
} else if self.mode == "vn" { } else if self.mode == "vn" {
"vietc-vn".into() "vietc-vn".into()
} else { } else {
@ -280,10 +315,13 @@ impl Tray for VietTray {
fn icon_pixmap(&self) -> Vec<ksni::Icon> { fn icon_pixmap(&self) -> Vec<ksni::Icon> {
let is_vn = self.mode == "vn"; let is_vn = self.mode == "vn";
let bg_color = if is_vn { let is_tlx = self.mode == "vn" && self.im == "telex";
[255, 224, 36, 36] // A, R, G, B let bg_color = if is_vn && !is_tlx {
[255, 224, 36, 36] // Red for VNI
} else if is_tlx {
[255, 37, 99, 235] // Blue for Telex
} else { } else {
[255, 75, 85, 99] [255, 75, 85, 99] // Gray for English
}; };
let fg_color = [255, 255, 255, 255]; let fg_color = [255, 255, 255, 255];
@ -319,7 +357,18 @@ impl Tray for VietTray {
} }
} }
if is_vn { if is_tlx {
// T
draw_line(&mut data, 6, 10, 15, 10, fg_color);
draw_line(&mut data, 6, 11, 15, 11, fg_color);
draw_line(&mut data, 10, 10, 10, 21, fg_color);
draw_line(&mut data, 11, 10, 11, 21, fg_color);
// X
draw_line(&mut data, 18, 10, 26, 21, fg_color);
draw_line(&mut data, 19, 10, 27, 21, fg_color);
draw_line(&mut data, 26, 10, 18, 21, fg_color);
draw_line(&mut data, 27, 10, 19, 21, fg_color);
} else if is_vn {
// V // V
draw_line(&mut data, 6, 10, 11, 21, fg_color); draw_line(&mut data, 6, 10, 11, 21, fg_color);
draw_line(&mut data, 7, 10, 12, 21, fg_color); draw_line(&mut data, 7, 10, 12, 21, fg_color);
@ -369,7 +418,7 @@ impl Tray for VietTray {
fn menu(&self) -> Vec<MenuItem<Self>> { fn menu(&self) -> Vec<MenuItem<Self>> {
let is_vn = self.mode == "vn"; let is_vn = self.mode == "vn";
let im_index = if self.im == "telex" { 0 } else { 1 }; let im_index = if self.im == "telex" { 0_usize } else { 1_usize };
let mut items = vec![ let mut items = vec![
CheckmarkItem { CheckmarkItem {
@ -409,13 +458,12 @@ impl Tray for VietTray {
let mut cfg = config::Config::load(); let mut cfg = config::Config::load();
cfg.input_method = im.into(); cfg.input_method = im.into();
let _ = cfg.save(); let _ = cfg.save();
write_method(im);
this.im = im.into(); this.im = im.into();
}), }),
options: vec![ options: vec![
RadioItem { RadioItem {
label: "Telex (next version)".into(), label: "Telex".into(),
enabled: false,
disposition: Disposition::Informative,
..Default::default() ..Default::default()
}, },
RadioItem { RadioItem {
@ -542,7 +590,7 @@ pub fn run() {
loop { loop {
std::thread::sleep(std::time::Duration::from_millis(100)); std::thread::sleep(std::time::Duration::from_millis(100));
let mode = read_status(); let mode = read_status();
let im = current_im(); let im = read_method();
let autostart = config::is_autostart_installed(); let autostart = config::is_autostart_installed();
// Also check status_changed flag for immediate updates // Also check status_changed flag for immediate updates
let _ = handle.update(move |t| { let _ = handle.update(move |t| {

View file

@ -1,6 +1,6 @@
[package] [package]
name = "vietc-uinputd" name = "vietc-uinputd"
version = "0.1.6" version = "0.1.7"
edition = "2021" edition = "2021"
description = "Viet+ privileged uinput backspace injection daemon" description = "Viet+ privileged uinput backspace injection daemon"

View file

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