Compare commits
No commits in common. "main" and "v0.1.1" have entirely different histories.
54 changed files with 1347 additions and 5174 deletions
95
.github/workflows/build.yml
vendored
95
.github/workflows/build.yml
vendored
|
|
@ -1,95 +0,0 @@
|
||||||
name: Build & Release
|
|
||||||
|
|
||||||
# Builds the .deb on the CI runner so artifacts are produced
|
|
||||||
# without compiling on a local machine:
|
|
||||||
# - every push to main / pull request -> packages uploaded as workflow artifacts
|
|
||||||
# - pushing a `v*` tag -> a GitHub Release with the .deb
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
tags: ['v*']
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
name: Build & test
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install build dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y --no-install-recommends \
|
|
||||||
pkg-config libdbus-1-dev libx11-dev libxtst-dev libxext-dev
|
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
|
||||||
|
|
||||||
- name: Build workspace
|
|
||||||
run: cargo build --release --features "x11,wayland"
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: cargo test --release
|
|
||||||
|
|
||||||
package:
|
|
||||||
name: Build .deb
|
|
||||||
needs: test
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Install packaging dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y --no-install-recommends \
|
|
||||||
pkg-config libdbus-1-dev libx11-dev libxtst-dev libxext-dev \
|
|
||||||
fakeroot dpkg-dev xclip xdotool desktop-file-utils file curl
|
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
|
||||||
|
|
||||||
- name: Determine version
|
|
||||||
id: ver
|
|
||||||
run: |
|
|
||||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
|
||||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
|
||||||
else
|
|
||||||
BASE=$(grep '^version' engine/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
|
|
||||||
VERSION="$BASE"
|
|
||||||
fi
|
|
||||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "short_sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Building version $VERSION"
|
|
||||||
|
|
||||||
- name: Build .deb
|
|
||||||
run: bash packaging/deb/build-deb.sh "${{ steps.ver.outputs.version }}"
|
|
||||||
|
|
||||||
- name: Collect artifacts
|
|
||||||
run: |
|
|
||||||
mkdir -p dist
|
|
||||||
cp packaging/deb/*.deb dist/
|
|
||||||
ls -la dist
|
|
||||||
|
|
||||||
- name: Upload artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: vietc-deb-${{ steps.ver.outputs.version }}-${{ steps.ver.outputs.short_sha }}
|
|
||||||
path: dist/*
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
- name: Publish GitHub Release
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
files: dist/*
|
|
||||||
generate_release_notes: true
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -13,7 +13,3 @@ packaging/deb/vietc_*/
|
||||||
packaging/appimage/appimagetool
|
packaging/appimage/appimagetool
|
||||||
status
|
status
|
||||||
vietc-xrecord
|
vietc-xrecord
|
||||||
packaging/flatpak/build-dir
|
|
||||||
packaging/flatpak/vietc-repo
|
|
||||||
packaging/flatpak/repo
|
|
||||||
packaging/flatpak/VietPlus-*
|
|
||||||
|
|
|
||||||
225
CHANGELOG.md
225
CHANGELOG.md
|
|
@ -1,187 +1,46 @@
|
||||||
# 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)
|
|
||||||
|
|
||||||
### uinput-First Injection
|
|
||||||
|
|
||||||
- **Injection priority reversed**: uinput (`/dev/uinput`) is now the primary injection backend on X11, with X11 XTest as fallback.
|
|
||||||
- **X11 XTest keycode fix**: +8 offset applied to all evdev keycodes for XTest compatibility.
|
|
||||||
- **`paste_via_clipboard()` backspace fixed**: was sending X11 keycode 14 (= "5"), now sends correct keycode 22.
|
|
||||||
|
|
||||||
### Window-Switch Detection
|
|
||||||
|
|
||||||
- **Active window ID verified on every keystroke**: removed the 100ms guard — catches sub-100ms window switches.
|
|
||||||
|
|
||||||
### Input Method
|
|
||||||
|
|
||||||
- **Telex disabled in tray**: greyed out as "(next version)". Only VNI was functional.
|
|
||||||
- **Default input method changed** to `"vni"`.
|
|
||||||
|
|
||||||
### Packaging
|
|
||||||
|
|
||||||
- **Flatpak and AppImage removed**: only `.deb` packaging is maintained.
|
|
||||||
- **Postinst improvements**: cleans stale binaries, config files; shows logout popup.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v0.1.5 (2026-06-29)
|
|
||||||
|
|
||||||
## v0.1.5 (2026-06-29)
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
- **`last_key_time` only on character key presses** — modifier-only events (Alt, Ctrl, Shift) no longer update the gap timer, so the 100 ms inline xprop poll fires reliably after every window switch, regardless of held modifiers.
|
|
||||||
|
|
||||||
### Active Window Detection
|
|
||||||
- **xprop fallback** — `get_active_window_id()` tries `xdotool` first, falls back to `xprop -root _NET_ACTIVE_WINDOW` (preinstalled `x11-utils`). Works under sudo even when xdotool is absent.
|
|
||||||
|
|
||||||
### Code Cleanup
|
|
||||||
- **Removed ~400 lines of dead unsafe code** — entire X11 clipboard shared-state block (unsafe statics, manual Xlib dlopen, SelectionRequest handling) was unused and has been deleted. All related `#[warn(dead_code)]` and `#[warn(static_mut_refs)]` warnings eliminated.
|
|
||||||
- **Engine dead code removed** — unused methods `is_empty`, `is_tone_or_mark_key`, `process_string`, `last_base_char`, `apply_cluster_mark`, `apply_mark` in `BambooEngine`; `RuleEffect` enum and `special_rules` field in `InputMethodRules`.
|
|
||||||
- **Production logging** — per-key `eprintln!` removed from evdev loop and uinput paste path. Only startup/error/window-change messages remain (`log_info` to both stderr and file).
|
|
||||||
|
|
||||||
### Flatpak Build & System Tray
|
|
||||||
- **System tray** (`vietc-tray` using ksni/DBus StatusNotifier) is now built and included in the Flatpak bundle. The tray launches the daemon and shows Vietnamese/English mode.
|
|
||||||
- **Desktop menu entry** — the app now appears when searching **"Viet+"** in the application menu. Search, launch, or uninstall from there.
|
|
||||||
- **Flatpak command** changed from `vietc-daemon` to `vietc-tray` (the tray spawns the daemon).
|
|
||||||
- **Tray fixes for Flatpak** — `find_sibling_binary()` now tries `{name}-daemon` fallback; `is_daemon_running()` checks both `vietc` and `vietc-daemon` process names.
|
|
||||||
- **Fixed `mkdir -p`** — `build-flatpak.sh` now creates `/app/share/applications` before installing the desktop file.
|
|
||||||
|
|
||||||
### Active Window Detection (Flatpak fix)
|
|
||||||
- **Native X11 `_NET_ACTIVE_WINDOW` query** via `dlopen("libX11.so.6")` — added as third fallback in `get_active_window_id()`. Works inside the Flatpak sandbox where `xdotool`/`xprop` are unavailable. No subprocess, no external dependencies.
|
|
||||||
### Default Mode
|
|
||||||
- **`start_enabled` now defaults to `true`** — Vietnamese mode is active immediately after launch. Press Ctrl+Space to toggle to English.
|
|
||||||
*(Existing users with a custom config.toml are unaffected — the explicit setting overrides the default.)*
|
|
||||||
|
|
||||||
### Tray & Desktop Entry
|
|
||||||
- **No password prompt inside Flatpak** — `needs_root()` detects Flatpak sandbox (`FLATPAK_ID` or `/app/bin` presence) and skips sudo entirely; the sandbox already has device access via `--device=all`.
|
|
||||||
- **First-launch flag always written** — the `.first-launch-done` marker is created even when the password prompt is dismissed, preventing repeated prompts.
|
|
||||||
- **Desktop categories** widened to `Utility;TextTools;X-GNOME-Utilities;` for better visibility in Cinnamon/Mint app menu.
|
|
||||||
- **Bundle**: `VietPlus-0.1.5.flatpak` (66 MB with tray, runtime `org.gnome.Platform//50`). Warning-free build.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v0.1.4 (2026-06-28)
|
|
||||||
|
|
||||||
### Flatpak Packaging
|
|
||||||
- **Flatpak bundle** with all components: daemon, CLI, system tray, uinputd, XRecord, wrapper script
|
|
||||||
- **System tray icon** via D-Bus StatusNotifierItem (ksni)
|
|
||||||
- **Build script** `packaging/flatpak/build-flatpak.sh` — automated build from source
|
|
||||||
- **Permissions:** X11, Wayland, D-Bus session bus, input devices, IPC
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
- README updated with Flatpak-only install/build instructions
|
|
||||||
|
|
||||||
### Clipboard & Injection
|
|
||||||
- **Fix clipboard-into-text race** — Eliminated race condition where clipboard content leaked into typed text during Unicode injection.
|
|
||||||
- **CI/CD pipeline** — GitHub Actions workflow for automatic .deb and AppImage builds on push.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
- **106 tests** passing (72 engine + 16 CLI + 12 protocol + 5 auto-restore + 1 tone placement).
|
|
||||||
|
|
||||||
### Releases
|
|
||||||
- `vietc_0.1.4-1_amd64.deb`, `Viet+-0.1.4-x86_64.AppImage` on GitHub + Forgejo.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v0.1.3 (2026-06-26)
|
|
||||||
|
|
||||||
- ua-horn cluster fix, clipboard_context save/restore, control-key consumption
|
|
||||||
- 106 tests, DEB + AppImage
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v0.1.2 (2026-06-26)
|
|
||||||
|
|
||||||
- Flush char forwarded as raw key, auto-restore English words
|
|
||||||
- Tone placement qu/gi/uê/uơ, skip auto-repeat, Enter key
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v0.1.1 (2026-06-26)
|
|
||||||
|
|
||||||
- Fix Telex tone key consumption, persistent X11 connection
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v0.1.0 (2026-06-26)
|
## v0.1.0 (2026-06-26)
|
||||||
|
|
||||||
Initial release — bamboo engine port, evdev capture, uinput injection.
|
Initial release and major overhaul.
|
||||||
|
|
||||||
|
### Engine (major rewrite)
|
||||||
|
|
||||||
|
- **Bamboo engine port** — Replaced custom Telex/VNI state machines with a Rust port of bamboo-core's transformation model. Marks and tones are applied to characters in a composition buffer, with proper tone placement for all Vietnamese diphthongs.
|
||||||
|
- **Flexible backtracking** — Mark/tone keys scan up to 5 characters backward to find the target vowel. Type the full syllable, then add marks at the end: `nguye6n4` → `nguyễn`.
|
||||||
|
- **Smart uo→ươ cluster** — Single `w`/`7` key after a `uo` pair converts both to `ươ`, even through consonants: `chuong7` → `chương`.
|
||||||
|
- **Correct tone placement** — Fixed tone positioning for `io` (gió), `uâ` (xuất), `yê` (nguyễn), `oa`/`oe`, `uy`, `iê`, `uô`, `ươ` clusters.
|
||||||
|
- **Consume stale marks** — VNI/Telex control keys (digits, `f`/`s`/`r`/`x`/`j`/`w`) are consumed silently when they produce no change (e.g., pressing `5` on an already-toned `ạ`).
|
||||||
|
- **63 focused unit tests** covering Telex, VNI, tone placement, marks, macros, and uppercase.
|
||||||
|
|
||||||
|
### Injection (major overhaul)
|
||||||
|
|
||||||
|
- **Uinput injection** — ASCII and backspace via Linux evdev keycodes (`/dev/uinput`). Correct keycodes per keyboard hardware, no X11 keycode mismatches.
|
||||||
|
- **Vietnamese Unicode** — Clipboard paste via persistent X11 connection + XTest Ctrl+V. Text is split only at trailing whitespace/punctuation boundary (no mid-word splitting). Persistent X11 display opened once and reused.
|
||||||
|
- **Uinput daemon** (`vietc-uinputd`) — Privileged Unix socket server for `/dev/uinput` injection. VMK-style architecture with capability separation. The main daemon communicates via socket, falling back to in-process uinput.
|
||||||
|
- **X11Injector** uses `XKeysymToKeycode` for Ctrl+V keycodes, adapting to the actual keyboard layout.
|
||||||
|
|
||||||
|
### Capture
|
||||||
|
|
||||||
|
- **Evdev preferred** — Keyboard capture via `/dev/input/event*` with device grab is now the primary path. More reliable than X11 XRecord.
|
||||||
|
- **X11 XRecord fallback** — X11 passive monitoring via C helper (`vietc-xrecord`) as fallback when evdev is unavailable.
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **Fix `Xutf8LookupString` signature** — Missing `XIC` parameter caused all keycodes to map to `\0`. Fixed by adding `*mut c_void` as first argument and passing `NULL`.
|
||||||
|
- **Fix `execute_commands` backspace count** — The X11 path incorrectly passed `grabbed=true`, subtracting 1 from every backspace. Changed to `false` so full backspace count is used.
|
||||||
|
- **Fix flush backspace overcount** — `prev_len + 1` erased one character beyond the word. Fixed to `prev_len`.
|
||||||
|
- **Fix `apply_mark` char removal** — Removed `pattern.len()` chars from composition, but the current key hadn't been appended yet. Fixed to `pattern.len() - 1`.
|
||||||
|
- **Fix mark backtrack position** — Marks were applied at the end of composition instead of at the found position. Added position-aware `apply_mark_at`.
|
||||||
|
|
||||||
|
### Packaging
|
||||||
|
|
||||||
|
- AppImage bundles `vietc-uinputd`, `vietc-xrecord`, `xclip`.
|
||||||
|
- AppRun preserves `LD_LIBRARY_PATH` with system library paths for `dlopen`.
|
||||||
|
- AppRun auto-starts `vietc-uinputd` via `pkexec`/`sudo` when available.
|
||||||
|
- Cleaned up `vietc-xrecord` compilation flags (only `-lX11 -lXtst` needed).
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- 63 focused engine tests covering Telex, VNI, marks, tones, macros, casing.
|
||||||
|
- Removed old auto-generated bulk tests (850+ tests for deprecated engine).
|
||||||
|
|
|
||||||
180
CHANGELOG.vi.md
180
CHANGELOG.vi.md
|
|
@ -1,180 +0,0 @@
|
||||||
# Nhật ký thay đổi (Changelog)
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="CHANGELOG.md">English</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## Chưa phát hành (Unreleased)
|
|
||||||
|
|
||||||
### Hỗ trợ Bản phân phối (Distro Support)
|
|
||||||
|
|
||||||
- **Bảng hỗ trợ Distro**: README hiện đã liệt kê các distro hỗ trợ tốt (Ubuntu, Debian, Mint, Pop!_OS, elementary, Zorin, Neon, Fedora, RHEL, CentOS, Arch, Manjaro), có thể hỗ trợ (openSUSE, Solus, Void), và chưa hỗ trợ (NixOS, Alpine, Gentoo).
|
|
||||||
- **libwayland-dev** được thêm vào install.sh cho tất cả các họ distro (trước đây bị thiếu — gây lỗi biên dịch trên các hệ thống chỉ có X11 như Linux Mint).
|
|
||||||
- **libwayland-client0** được thêm vào các gói phụ thuộc runtime (trước đây bị thiếu — gây lỗi "cannot open shared object file" trên Mint).
|
|
||||||
- **Sửa lỗi chính tả cấu hình**: `mặt khẩu` → `mật khẩu` trong cấu hình mặc định và README.
|
|
||||||
|
|
||||||
### Tài liệu hướng dẫn (Documentation)
|
|
||||||
|
|
||||||
- Thêm mục **Roadmap** vào README (v0.1.19: Giao thức Wayland IM, AT-SPI2 hướng sự kiện; v0.1.20: CI, Flatpak).
|
|
||||||
- Loại bỏ **RELEASE_CHECKLIST.md** (quy trình phát hành hiện được ghi nhận trong nội dung các commit phát hành).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v0.1.7 (01-07-2026)
|
|
||||||
|
|
||||||
### Tự động nhận diện mật khẩu (Password Auto-Detection)
|
|
||||||
|
|
||||||
- **Tích hợp AT-SPI2 D-Bus**: Truy vấn `org.a11y.atspi.Accessible.GetRole` trên a11y bus (không phải session bus) để phát hiện các trường mật khẩu. Hoạt động trên các hộp thoại mật khẩu GUI và các ứng dụng có bật hỗ trợ tiếp cận (a11y).
|
|
||||||
- **Phát hiện sudo qua cây tiến trình**: Quét `pstree` để tìm các tiến trình `sudo`/`passwd` — tự động tắt tiếng Việt khi có yêu cầu sudo xuất hiện trong terminal.
|
|
||||||
- **Dự phòng tiêu đề cửa sổ**: Các cửa sổ có tiêu đề chứa "password", "sudo", "mật khẩu" sẽ tự động chuyển sang chế độ gõ tiếng Anh.
|
|
||||||
- **Dự phòng lớp cửa sổ (Window class)**: Nhận diện các hộp thoại mật khẩu phổ biến (pinentry, polkit, kwallet) thông qua danh sách ứng dụng `password_apps` trong cấu hình.
|
|
||||||
- **Kiểm tra định kỳ**: Đánh giá lại trạng thái trường mật khẩu sau mỗi 30 phím gõ (giúp phát hiện kịp thời các prompt nhập mật khẩu xuất hiện trong terminal).
|
|
||||||
|
|
||||||
### Phương thức gõ Telex (Telex Input Method)
|
|
||||||
|
|
||||||
- **Hỗ trợ đầy đủ Telex**: Cả hai phương thức gõ VNI và Telex hiện đã được hỗ trợ toàn diện. Chuyển đổi nhanh qua Ctrl+Shift hoặc menu khay hệ thống "Input Method > Telex / VNI".
|
|
||||||
- **Tệp lưu phương thức gõ** (`~/.config/vietc/method`): Tiến trình nền (daemon) ghi phương thức gõ hiện tại; khay hệ thống đọc tệp này để hiển thị icon tương ứng.
|
|
||||||
- **Biểu tượng khay hệ thống**: Màu đỏ "VN" cho VNI, màu xanh dương "TLX" cho Telex, màu xám "EN" cho chế độ tiếng Anh.
|
|
||||||
- **Cấu hình**: Phím nóng `toggle_method_key = "shift"` dùng để thiết lập tổ hợp phím đổi phương thức gõ.
|
|
||||||
|
|
||||||
### Hỗ trợ GNOME/Wayland (GNOME/Wayland Support)
|
|
||||||
|
|
||||||
- **Tích hợp D-Bus của GNOME Shell**: Truy vấn `org.gnome.Shell.Eval` để lấy thông tin về lớp cửa sổ (window class), ID, tiêu đề và PID của ứng dụng đang hoạt động — giải pháp thay thế hoàn hảo trên Wayland GNOME nơi xdotool/xprop không khả dụng.
|
|
||||||
- **Chuỗi nhận diện cửa sổ**: GNOME Shell D-Bus → xprop → wlrctl → xdotool → wmctrl → /proc — hoạt động ổn định trên mọi môi trường máy tính.
|
|
||||||
- **Nhận diện Compositor**: Tự động phát hiện GNOME/Mutter qua `pgrep gnome-shell` và `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)]` và `#[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` và `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` và `.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.
|
|
||||||
15
Makefile
15
Makefile
|
|
@ -1,4 +1,4 @@
|
||||||
.PHONY: build build-x11 build-wayland build-all build-ui test test-cli run run-x11 run-wayland clean install install-x11 install-wayland install-ui install-config deb fmt lint tree
|
.PHONY: build build-x11 build-wayland build-all build-ui test test-cli run run-x11 run-wayland clean install install-x11 install-wayland install-ui install-config appimage deb fmt lint tree
|
||||||
|
|
||||||
# Build core crates
|
# Build core crates
|
||||||
build:
|
build:
|
||||||
|
|
@ -76,15 +76,26 @@ install-config:
|
||||||
cp vietc.toml ~/.config/vietc/config.toml
|
cp vietc.toml ~/.config/vietc/config.toml
|
||||||
@echo "Config installed to ~/.config/vietc/config.toml"
|
@echo "Config installed to ~/.config/vietc/config.toml"
|
||||||
|
|
||||||
# Build .deb package
|
# Build .deb package (requires dpkg-deb)
|
||||||
deb:
|
deb:
|
||||||
VERSION=$$(grep '^version' engine/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') && \
|
VERSION=$$(grep '^version' engine/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') && \
|
||||||
bash packaging/deb/build-deb.sh "$$VERSION"
|
bash packaging/deb/build-deb.sh "$$VERSION"
|
||||||
|
|
||||||
|
# Build AppImage (requires appimagetool or linuxdeploy)
|
||||||
|
appimage:
|
||||||
|
VERSION=$$(grep '^version' engine/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') && \
|
||||||
|
bash packaging/appimage/build-appimage.sh "$$VERSION"
|
||||||
|
|
||||||
|
# Build Debian package
|
||||||
|
deb:
|
||||||
|
VERSION=$$(grep '^version' engine/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') && \
|
||||||
|
bash packaging/build-deb.sh "$$VERSION"
|
||||||
|
|
||||||
# Clean build artifacts
|
# Clean build artifacts
|
||||||
clean:
|
clean:
|
||||||
cargo clean
|
cargo clean
|
||||||
cd ui && cargo clean
|
cd ui && cargo clean
|
||||||
|
rm -rf packaging/appimage/AppDir packaging/appimage/*.AppImage
|
||||||
|
|
||||||
# Format code
|
# Format code
|
||||||
fmt:
|
fmt:
|
||||||
|
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
# Terminal VNI Input — Design & Implementation
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Make Vietnamese input work in terminal emulators without breaking TUI keyboard shortcuts.
|
|
||||||
|
|
||||||
## Approach: A + C
|
|
||||||
|
|
||||||
### A — Remove terminals from `bypass_apps`
|
|
||||||
|
|
||||||
All terminals are currently in `bypass_apps` (default config), which skips ALL engine
|
|
||||||
processing when the active window is a terminal. Removing them lets keystrokes flow
|
|
||||||
through the bamboo engine.
|
|
||||||
|
|
||||||
### C — Force VNI when terminal detected
|
|
||||||
|
|
||||||
When the active window is a terminal, the engine automatically uses VNI rules
|
|
||||||
(`1-9` for tones/marks) regardless of the global VNI/Telex setting.
|
|
||||||
This avoids key conflicts with TUI apps (vim's `j`, less's `s`, shell's `x`, etc.).
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
```
|
|
||||||
User config: input_method = "telex"
|
|
||||||
Terminal window focused → effective method = "vni" (forced by terminal_apps)
|
|
||||||
GUI window focused → effective method = "telex" (user's global setting)
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Engine** runs with effective method
|
|
||||||
- **Tray** shows global method (so user sees their configured setting)
|
|
||||||
- **Ctrl+LeftShift** toggles global method, recomputes effective method
|
|
||||||
- **Ctrl+Space** toggles VN/EN as before
|
|
||||||
|
|
||||||
## Config Changes
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[app_state]
|
|
||||||
terminal_apps = ["kitty", "alacritty", "foot", "wezterm", "konsole",
|
|
||||||
"gnome-terminal", "gnome-terminal-server", "kgx", "st", "urxvt", "xterm",
|
|
||||||
"termite", "terminator", "tilix", "deepin-terminal", "pantheon-terminal"]
|
|
||||||
terminal_input_method = "vni"
|
|
||||||
```
|
|
||||||
|
|
||||||
`bypass_apps` reduced to: `["steam", "dota", "csgo", "minecraft", "factorio"]`
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
### 1. `daemon/src/config.rs`
|
|
||||||
|
|
||||||
- Add `terminal_apps` (`Vec<String>`) and `terminal_input_method` (`String`) to `AppStateConfig`
|
|
||||||
- Add `default_terminal_apps()` returning the terminal list
|
|
||||||
- Add `default_terminal_method()` returning `"vni"`
|
|
||||||
- Remove all terminal names from `default_bypass_apps()`
|
|
||||||
|
|
||||||
### 2. `daemon/src/app_state.rs`
|
|
||||||
|
|
||||||
- Add fields: `terminal_apps`, `terminal_input_method`, `global_method`, `effective_method`
|
|
||||||
- `new()` accepts `terminal_apps`, `terminal_input_method`, `global_method`
|
|
||||||
- `update_effective_method()`: if current_app matches any terminal, effective = terminal method; else effective = global method. Called on window change.
|
|
||||||
- `set_terminal_config()`: updates terminal_apps/terminal_input_method from config reload
|
|
||||||
- `set_global_method()`: updates global_method, recomputes effective
|
|
||||||
- `effective_method()` getter
|
|
||||||
- `is_terminal_app()` — checks if current_app is a terminal
|
|
||||||
- `update_with_app()` calls `update_effective_method()` internally
|
|
||||||
- `update_lists()` also handles terminal_apps
|
|
||||||
|
|
||||||
### 3. `daemon/src/main.rs`
|
|
||||||
|
|
||||||
- `Daemon::new()` — pass terminal config to `AppStateManager`, call `update_effective_method()`
|
|
||||||
- `toggle_method()` — after toggling global method, call `app_state.set_global_method()` then `engine.set_method(app_state.effective_method())`
|
|
||||||
- `check_app_change_with()` — after app change, if effective method changed from engine's current, call `engine.set_method(effective)`
|
|
||||||
- `is_vn_control_key()` calls — change from `daemon.config.input_method` to `daemon.app_state.effective_method()`
|
|
||||||
- Config reload — update `update_lists()` call to include terminal fields
|
|
||||||
- Method status file — still writes **global** method (for tray display)
|
|
||||||
|
|
||||||
### 4. `install.sh` — Update default config block
|
|
||||||
|
|
||||||
### 5. `README.md` — Update config example
|
|
||||||
|
|
||||||
### 6. `NOTES/terminal-vni.md` — This file
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
### Linux Mint (X11)
|
|
||||||
|
|
||||||
- [ ] Type VNI in shell: `viet1 nam` → `viết nam`
|
|
||||||
- [ ] Type Telex in shell: `vieets nam` → `vieets nam` (Telex NOT active in terminal)
|
|
||||||
- [ ] Ctrl+Space toggles VN/EN
|
|
||||||
- [ ] Ctrl+LeftShift toggles global method (terminal unaffected, tray shows global)
|
|
||||||
- [ ] Vim insert mode: VNI works, `j`/`x`/`s` pass through as regular keys
|
|
||||||
- [ ] Gemini-cli: VNI typed text appears correctly
|
|
||||||
- [ ] sudo passwd: engine auto-disables
|
|
||||||
- [ ] Switch terminal ↔ GUI: method resets per app
|
|
||||||
- [ ] Tray icon shows global method, not terminal override
|
|
||||||
|
|
||||||
### Ubuntu 24.04+ (Wayland)
|
|
||||||
|
|
||||||
- [ ] Same VNI typing tests
|
|
||||||
- [ ] GNOME Shell D-Bus window detection
|
|
||||||
- [ ] wl-copy paste-once path for Unicode chars
|
|
||||||
|
|
||||||
## Edge Cases
|
|
||||||
|
|
||||||
| Case | Behavior |
|
|
||||||
|------|----------|
|
|
||||||
| Terminal in bypass_apps | No IME at all (configurable override for power users) |
|
|
||||||
| User wants Telex in terminals | Set `terminal_input_method = "telex"` in config |
|
|
||||||
| Multiple terminals open | Each follows the same rule |
|
|
||||||
| IDE integrated terminal | Window class is "code", not terminal. Needs manual config |
|
|
||||||
| Password prompt in terminal | Process-tree detection still disables engine regardless of method |
|
|
||||||
420
README.md
420
README.md
|
|
@ -2,9 +2,7 @@
|
||||||
<img src="https://img.shields.io/badge/Platform-Linux-blue?style=for-the-badge" alt="Platform">
|
<img src="https://img.shields.io/badge/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.18-purple?style=for-the-badge" alt="Version">
|
<img src="https://img.shields.io/badge/Version-0.1.0-purple?style=for-the-badge" alt="Version">
|
||||||
<img src="https://img.shields.io/badge/Tests-108_passing-brightgreen?style=for-the-badge" alt="Tests">
|
|
||||||
<img src="https://img.shields.io/badge/Event_Sourcing-✓-blueviolet?style=for-the-badge" alt="Event Sourcing">
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 align="center">
|
<h1 align="center">
|
||||||
|
|
@ -18,47 +16,214 @@
|
||||||
<sub>Zero underline • No pre-edit buffer • Backspace-Replay sync • Built in Rust</sub>
|
<sub>Zero underline • No pre-edit buffer • Backspace-Replay sync • 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 duplicate text, underline distraction, broken copy/paste, and desync between the engine state and what's on screen.
|
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 (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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## How It Works
|
||||||
|
|
||||||
| Feature | How It Works |
|
### Data Flow: Keypress to Screen
|
||||||
|---------|-------------|
|
|
||||||
| **Direct Input** | No pre-edit buffer. Keystrokes instantly become text via uinput injection |
|
```
|
||||||
| **VNI & Telex** | Both input methods fully supported, switchable at runtime via Ctrl+Shift |
|
Physical Keyboard
|
||||||
| **Bamboo Engine** | Transformation model — composition, marks, tones, flexible backtracking |
|
│
|
||||||
| **Smart Clusters** | `uo→ươ` with backtrack, `ua→ưa` horn placement |
|
▼
|
||||||
| **Macro Expansion** | `ko → không`, `dc → được`, add your own |
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
| **Casing Preservation** | `Tieengs → Tiếng`, `TIEENGS → TIẾNG` |
|
│ Stage 1: KEY CAPTURE │
|
||||||
| **App Memory** | Per-app Vietnamese/English state, saved to `overrides.toml` |
|
│ │
|
||||||
| **Hot Reload** | Config changes apply without restart |
|
│ evdev: /dev/input/event* grabs keyboard (primary, reliable) │
|
||||||
| **Window-Switch Reset** | Engine clears automatically on Alt+Tab |
|
│ X11: XRecord passive monitoring (fallback) │
|
||||||
| **CPU Priority** | Pinned to P-cores (0-3) + nice(-10) for low-latency input |
|
│ │
|
||||||
| **Uinput Injection** | `/dev/uinput` for reliable injection on X11 and Wayland |
|
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||||
| **Terminal Support** | ✅ Works in all major terminals: kitty, alacritty, gnome-terminal, konsole, foot, wezterm, st, urxvt, xterm |
|
│ │ evdev grab │ │ X11Capture │ │ FocusIn/FocusOut │ │
|
||||||
| **Password Auto-Detection** | 4 layers: AT-SPI2 → sudo process → window-title → window-class |
|
│ │ (libevdev) │ │ (XRecord) │ │ detection │ │
|
||||||
| **Tray Icon** | Shows current mode: Red VN / Blue TLX / Gray EN |
|
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
||||||
| **GNOME/Wayland** | Native GNOME Shell D-Bus integration |
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ 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/Telex 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 │
|
||||||
|
│ │
|
||||||
|
│ 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) │
|
||||||
|
│ Persistent X11 connection for Ctrl+V (no per-call overhead) │
|
||||||
|
│ │
|
||||||
|
│ Fallback: vietc-uinputd Unix socket daemon (privileged) │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Application receives keystrokes
|
||||||
|
and renders Vietnamese text on screen
|
||||||
|
```
|
||||||
|
|
||||||
|
### The Backspace-Replay Pattern
|
||||||
|
|
||||||
|
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+ solves this by **never tracking incremental state**:
|
||||||
|
|
||||||
|
```
|
||||||
|
Traditional IME:
|
||||||
|
keystroke → update buffer → emit event → hope it matches screen
|
||||||
|
|
||||||
|
Viet+ (Backspace-Replay):
|
||||||
|
keystroke → add to history → replay ALL history in fresh engine → compute diff
|
||||||
|
```
|
||||||
|
|
||||||
|
On every keystroke:
|
||||||
|
|
||||||
|
1. The keystroke is appended to `keystroke_history`
|
||||||
|
2. A **brand new** `Engine` is created
|
||||||
|
3. The **entire** history is replayed through it
|
||||||
|
4. The engine's buffer is the **correct** screen output
|
||||||
|
5. Viet+ computes the diff: how many backspaces to erase old text, what new text to type
|
||||||
|
|
||||||
|
This means:
|
||||||
|
- **Zero state desync** — always recomputed from scratch
|
||||||
|
- **Self-healing** — if anything goes wrong, the next keystroke fixes it
|
||||||
|
- **Simple** — no complex state tracking or synchronization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
vietc/
|
||||||
|
├── engine/ # Vietnamese composition engine (bamboo-core Rust port)
|
||||||
|
│ ├── engine.rs # Orchestrator + replay_keystrokes()
|
||||||
|
│ ├── bamboo.rs # Bamboo engine: transformation model, composition, tone placement
|
||||||
|
│ ├── input_method.rs # Telex/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 + direct clipboard
|
||||||
|
│ ├── uinput_monitor.rs # /dev/uinput injection for ASCII + Unicode
|
||||||
|
│ ├── 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/ # AppImage + deb build scripts
|
||||||
|
└── vietc.toml # Default configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Interaction
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ vietc-tray │
|
||||||
|
│ (System tray icon, daemon launcher, password prompt) │
|
||||||
|
└───────────────────────┬─────────────────────────────────────┘
|
||||||
|
│ starts
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ vietc (daemon) │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||||
|
│ │ Config │ │ App State │ │ Display │ │
|
||||||
|
│ │ (hot reload) │ │ (per-app) │ │ (X11/Wayland) │ │
|
||||||
|
│ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ └─────────────────┼────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────▼──────┐ │
|
||||||
|
│ │ Event Loop │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ X11: grab │ │
|
||||||
|
│ │ keyboard │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Process │ │
|
||||||
|
│ │ keystroke │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Replay all │ │
|
||||||
|
│ │ history │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Inject │ │
|
||||||
|
│ │ diff │ │
|
||||||
|
│ └─────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ vietc-engine │ │
|
||||||
|
│ │ TelexEngine / VniEngine / EnglishDict / Spelling │ │
|
||||||
|
│ └────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ vietc-protocol │ │
|
||||||
|
│ │ X11Capture / X11Injector / UinputInjector / Wayland │ │
|
||||||
|
│ └────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Input Methods
|
## Input Methods
|
||||||
|
|
||||||
Both **VNI** and **Telex** are fully supported. Switch via **Ctrl+LeftShift** or the tray menu.
|
### Telex
|
||||||
|
|
||||||
|
| Key | Result | Example |
|
||||||
|
|-----|--------|---------|
|
||||||
|
| `aa` | â | `tan` → `tân` |
|
||||||
|
| `aw` | ă | `tan` → `tăn` |
|
||||||
|
| `ee` | ê | `men` → `mên` |
|
||||||
|
| `oo` | ô | `to` → `tô` |
|
||||||
|
| `ow` | ơ | `to` → `tơ` |
|
||||||
|
| `uw` | ư | `tu` → `tư` |
|
||||||
|
| `s` | á (sắc) | `as` → `á` |
|
||||||
|
| `f` | à (huyền) | `af` → `à` |
|
||||||
|
| `r` | ả (hỏi) | `ar` → `ả` |
|
||||||
|
| `x` | ã (ngã) | `ax` → `ã` |
|
||||||
|
| `j` | ạ (nặng) | `aj` → `ạ` |
|
||||||
|
| `dd` | đ | `dd` → `đ` |
|
||||||
|
|
||||||
### VNI
|
### VNI
|
||||||
|
|
||||||
|
|
@ -69,123 +234,59 @@ Both **VNI** and **Telex** are fully supported. Switch via **Ctrl+LeftShift** or
|
||||||
| `3` | ả (hỏi) | `a3` → `ả` |
|
| `3` | ả (hỏi) | `a3` → `ả` |
|
||||||
| `4` | ã (ngã) | `a4` → `ã` |
|
| `4` | ã (ngã) | `a4` → `ã` |
|
||||||
| `5` | ạ (nặng) | `a5` → `ạ` |
|
| `5` | ạ (nặng) | `a5` → `ạ` |
|
||||||
| `6` | â/ê/ô | `a6→â`, `e6→ê`, `o6→ô` |
|
| `6` | â/ê/ô | `a6` → `â`, `e6` → `ê`, `o6` → `ô` |
|
||||||
| `7` | ơ/ư | `o7→ơ`, `u7→ư` |
|
| `7` | ơ/ư | `o7` → `ơ`, `u7` → `ư` |
|
||||||
| `8` | ă | `a8→ă` |
|
| `8` | ă | `a8` → `ă` |
|
||||||
| `9` | đ | `d9→đ` |
|
| `9` | đ | `d9` → `đ` |
|
||||||
|
|
||||||
### Telex
|
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.
|
||||||
|
|
||||||
| 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
|
## Features
|
||||||
|
|
||||||
| Combo | Action |
|
| Feature | How It Works |
|
||||||
|-------|--------|
|
|---------|-------------|
|
||||||
| **Ctrl+Space** | Toggle Vietnamese ON/OFF |
|
| **Direct Input** | No pre-edit buffer. Keystrokes instantly become text via uinput/XTest injection |
|
||||||
| **Ctrl+LeftShift** | Toggle VNI ↔ Telex |
|
| **Bamboo Engine** | Transformation model ported from bamboo-core — composition, marks, tones, flexible backtracking |
|
||||||
|
| **Flexible Backtrack** | Type tone/modifier at end of syllable (`tranaf` → `trần`). Scans up to 5 chars backward |
|
||||||
---
|
| **Smart Clusters** | `uo` → `ươ` with backtrack (`chuong7` → `chương`) |
|
||||||
|
| **Tone Placement** | Correct tone positioning for all Vietnamese diphthongs (io→gió, uâ→xuất, yê→nguyễn) |
|
||||||
## Password Detection
|
| **Macro Expansion** | `ko` → `không`, `dc` → `được`, custom shortcuts |
|
||||||
|
| **Casing Preservation** | `Tieengs` → `Tiếng`, `TIEENGS` → `TIẾNG` |
|
||||||
4-layer automatic detection. When a password field is detected, Vietnamese is automatically disabled:
|
| **App Memory** | Per-app Vietnamese/English state, saved to `overrides.toml` |
|
||||||
|
| **Hot Reload** | Config changes apply without restart (polls mtime every 1.5s) |
|
||||||
| Layer | Method | Detects |
|
| **Focus Reset** | Focus change clears engine state — no stale injection on window switch |
|
||||||
|-------|--------|---------|
|
| **CPU Priority** | Pins daemon to P-cores (0-3) + nice(-10) for low-latency input |
|
||||||
| 1 | AT-SPI2 D-Bus (a11y role check) | Password fields in accessible apps |
|
| **Uinput Daemon** | Privileged `vietc-uinputd` for clean backspace injection (Unix socket, VMK-style) |
|
||||||
| 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
|
||||||
|
|
||||||
### One-Command Install
|
### AppImage (recommended)
|
||||||
|
|
||||||
Works on all ✅ **Supported** distros above. The script auto-detects your package manager:
|
|
||||||
|
|
||||||
**From GitHub (recommended):**
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/vndangkhoa/vietc.git /tmp/vietc \
|
./Viet+-0.1.0-x86_64.AppImage
|
||||||
&& cd /tmp/vietc && sudo ./install.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**From Forgejo (self-hosted):**
|
Includes daemon + tray + CLI + xclip. No special permissions needed on X11.
|
||||||
|
|
||||||
|
### Debian/Ubuntu
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.khoavo.myds.me/vndangkhoa/vietc.git /tmp/vietc \
|
sudo dpkg -i vietc_0.1.0-1_amd64.deb
|
||||||
&& 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.
|
Recommends: `libxtst6`, `xclip`
|
||||||
|
|
||||||
**After install:** Log out and log back in, then launch `vietc-tray` from your application menu.
|
### Manual
|
||||||
|
|
||||||
### One-Command Uninstall
|
|
||||||
|
|
||||||
**From GitHub:**
|
|
||||||
```bash
|
|
||||||
curl -sSL https://raw.githubusercontent.com/vndangkhoa/vietc/main/uninstall.sh | sudo bash
|
|
||||||
```
|
|
||||||
|
|
||||||
**From Forgejo:**
|
|
||||||
```bash
|
|
||||||
curl -sSL https://git.khoavo.myds.me/vndangkhoa/vietc/raw/branch/main/uninstall.sh | sudo bash
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual Build & Run
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
git clone https://git.khoavo.myds.me/vndangkhoa/vietc.git
|
||||||
sudo apt install git curl build-essential pkg-config \
|
|
||||||
libx11-dev libxtst-dev libevdev-dev libdbus-1-dev libwayland-dev wl-clipboard
|
|
||||||
|
|
||||||
# Enable accessibility (Ubuntu Wayland — for password detection)
|
|
||||||
gsettings set org.gnome.desktop.a11y.applications screen-reader-enabled true
|
|
||||||
|
|
||||||
# Build
|
|
||||||
git clone https://github.com/vndangkhoa/vietc.git
|
|
||||||
cd vietc
|
cd vietc
|
||||||
cargo build --release
|
make build-all
|
||||||
|
sudo make install
|
||||||
# Run (Mint — no sudo needed for uinput)
|
|
||||||
./target/release/vietc
|
|
||||||
|
|
||||||
# Run (Ubuntu — needs sudo for keyboard grab)
|
|
||||||
sudo ./target/release/vietc
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -196,83 +297,36 @@ 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 VN/EN
|
toggle_key = "space" # Ctrl+Space to toggle
|
||||||
toggle_method_key = "shift" # Ctrl+Shift to toggle VNI/Telex
|
start_enabled = false # English by default
|
||||||
start_enabled = true # Vietnamese by default
|
grab = true # grab keyboard (AppImage)
|
||||||
grab = true # grab keyboard (evdev)
|
|
||||||
|
|
||||||
[auto_restore]
|
[auto_restore]
|
||||||
enabled = false # Auto-restore English words (defaults to false)
|
|
||||||
trigger_keys = ["space", "escape"]
|
|
||||||
|
|
||||||
[password_detection]
|
|
||||||
enabled = true
|
enabled = true
|
||||||
check_atspi2 = true
|
trigger_keys = ["space", "escape"]
|
||||||
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"]
|
english_apps = ["code", "vim", "kitty", "foot"]
|
||||||
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
|
## Building
|
||||||
|
|
||||||
### v0.1.19
|
```bash
|
||||||
- [ ] Wayland input method protocol (`zwp_input_method_v2`) — eliminates clipboard + backspace race, fixes missing spaces permanently
|
make build-all # Build with X11 + Wayland
|
||||||
- [ ] Event-based AT-SPI2 focus monitoring (subscribe to a11y focus events, no polling)
|
make test # Run 255+ tests
|
||||||
|
make deb # Build .deb package
|
||||||
### v0.1.20
|
make appimage # Build AppImage
|
||||||
- [ ] GitHub Actions CI for automated .deb builds
|
```
|
||||||
- [ ] Flatpak re-add for immutable distros
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
287
README.vi.md
287
README.vi.md
|
|
@ -1,287 +0,0 @@
|
||||||
<p align="center">
|
|
||||||
<img src="https://img.shields.io/badge/Nền_tảng-Linux-blue?style=for-the-badge" alt="Platform">
|
|
||||||
<img src="https://img.shields.io/badge/Ngôn_ngữ-Rust-orange?style=for-the-badge" alt="Rust">
|
|
||||||
<img src="https://img.shields.io/badge/Giấy_phép-MIT-green?style=for-the-badge" alt="License">
|
|
||||||
<img src="https://img.shields.io/badge/Phiên_bản-0.1.18-purple?style=for-the-badge" alt="Version">
|
|
||||||
<img src="https://img.shields.io/badge/Kiểm_thử-108_đạt-brightgreen?style=for-the-badge" alt="Tests">
|
|
||||||
<img src="https://img.shields.io/badge/Event_Sourcing-✓-blueviolet?style=for-the-badge" alt="Event Sourcing">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h1 align="center">
|
|
||||||
<br>
|
|
||||||
Viet+
|
|
||||||
<br>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<b>Bộ gõ tiếng Việt cho Linux</b><br>
|
|
||||||
<sub>Không gạch chân • Không bộ đệm pre-edit • Đồng bộ Backspace-Replay • 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** và **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>
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "vietc-cli"
|
name = "vietc-cli"
|
||||||
version = "0.1.7"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Viet+ CLI Test Harness"
|
description = "Viet+ CLI Test Harness"
|
||||||
|
|
||||||
|
|
|
||||||
319
cli/src/main.rs
319
cli/src/main.rs
|
|
@ -1,47 +1,15 @@
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
use vietc_engine::{Engine, EngineEvent, EventStore, InputEvent, InputMethod};
|
use vietc_engine::{Engine, EngineEvent, 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 state = CliState::new();
|
let mut engine = Engine::new(InputMethod::Telex);
|
||||||
|
|
||||||
print_help();
|
println!("Viet+ IME - Test Harness");
|
||||||
|
println!("==========================");
|
||||||
|
println!("Type Vietnamese using Telex input.");
|
||||||
|
println!("Press Enter to flush, type 'quit' to exit.");
|
||||||
|
println!("Toggle method with ':vni' or ':telex'");
|
||||||
|
println!();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
print!("> ");
|
print!("> ");
|
||||||
|
|
@ -51,69 +19,82 @@ 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.starts_with(':') {
|
if input == ":vni" {
|
||||||
handle_command(&mut state, input);
|
engine.set_method(InputMethod::Vni);
|
||||||
|
println!("[Switched to VNI]");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.engine.reset();
|
if input == ":telex" {
|
||||||
|
engine.set_method(InputMethod::Telex);
|
||||||
|
println!("[Switched to Telex]");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if input == ":reset" {
|
||||||
|
engine.reset();
|
||||||
|
println!("[Engine reset]");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if input == ":buffer" {
|
||||||
|
println!("[Buffer: {:?}]", engine.buffer());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
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() {
|
||||||
state.events.push(InputEvent::KeyTyped(ch));
|
if let Some(event) = engine.process_key(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::Insert(text) | EngineEvent::Flush(text) => {
|
EngineEvent::Flush(text) => {
|
||||||
output.push_str(text);
|
output.push_str(text);
|
||||||
}
|
}
|
||||||
EngineEvent::Paste(text) => {
|
EngineEvent::Insert(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 {
|
||||||
EngineEvent::UndoTones { backspaces, restored } => {
|
backspaces,
|
||||||
|
restored,
|
||||||
|
} => {
|
||||||
for _ in 0..*backspaces {
|
for _ in 0..*backspaces {
|
||||||
output.push('\x08');
|
output.push('\x08');
|
||||||
}
|
}
|
||||||
output.push_str(restored);
|
output.push_str(restored);
|
||||||
}
|
}
|
||||||
EngineEvent::AutoRestore(word) => {
|
EngineEvent::Paste(text) => {
|
||||||
for _ in 0..word.len() {
|
output.push_str(text);
|
||||||
output.push('\x08');
|
|
||||||
}
|
|
||||||
output.push_str(word);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(event) = state.engine.flush() {
|
// Flush remaining buffer
|
||||||
|
if let Some(event) = engine.flush() {
|
||||||
match &event {
|
match &event {
|
||||||
EngineEvent::Flush(text) | EngineEvent::Insert(text) => {
|
EngineEvent::Flush(text) => {
|
||||||
|
output.push_str(text);
|
||||||
|
}
|
||||||
|
EngineEvent::Insert(text) => {
|
||||||
output.push_str(text);
|
output.push_str(text);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
@ -122,204 +103,10 @@ fn main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
println!(" Events: {:?}", events);
|
println!(" Events: {:?}", events);
|
||||||
println!(" Raw: {:?}", output);
|
println!(" Output: {:?}", output);
|
||||||
|
|
||||||
let display = apply_backspaces(&output);
|
// Show what it would look like
|
||||||
println!(" Screen: {}", display);
|
let display: String = output.chars().filter(|c| *c != '\x08').collect();
|
||||||
|
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "vietc-daemon"
|
name = "vietc-daemon"
|
||||||
version = "0.1.7"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Viet+ background daemon"
|
description = "Viet+ background daemon"
|
||||||
|
|
||||||
|
|
@ -21,4 +21,3 @@ serde = { version = "1", features = ["derive"] }
|
||||||
evdev = "0.12"
|
evdev = "0.12"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
dbus = "0.9"
|
|
||||||
|
|
|
||||||
|
|
@ -1,251 +1,10 @@
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
use std::collections::HashMap;
|
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).
|
|
||||||
/// Works inside the Flatpak sandbox where xdotool/xprop are unavailable
|
|
||||||
/// but libX11.so.6 is present in the GNOME runtime. No external process
|
|
||||||
/// or subclassing needed — open display, query property, return hex ID.
|
|
||||||
fn get_active_window_x11_dlopen() -> Option<String> {
|
|
||||||
unsafe {
|
|
||||||
let lib = libc::dlopen(
|
|
||||||
b"libX11.so.6\0".as_ptr() as *const libc::c_char,
|
|
||||||
libc::RTLD_LAZY,
|
|
||||||
);
|
|
||||||
if lib.is_null() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
type FnOpenDisplay =
|
|
||||||
unsafe extern "C" fn(*const libc::c_char) -> *mut libc::c_void;
|
|
||||||
type FnDefaultRoot =
|
|
||||||
unsafe extern "C" fn(*mut libc::c_void) -> u64;
|
|
||||||
type FnInternAtom = unsafe extern "C" fn(
|
|
||||||
*mut libc::c_void, *const libc::c_char, libc::c_int,
|
|
||||||
) -> u64;
|
|
||||||
type FnGetProperty = unsafe extern "C" fn(
|
|
||||||
*mut libc::c_void, u64, u64, u64, u64, u64, libc::c_int,
|
|
||||||
*mut u64, *mut libc::c_int, *mut u64, *mut u64,
|
|
||||||
*mut *mut u8,
|
|
||||||
) -> libc::c_int;
|
|
||||||
type FnFree = unsafe extern "C" fn(*mut libc::c_void) -> libc::c_int;
|
|
||||||
type FnCloseDisplay =
|
|
||||||
unsafe extern "C" fn(*mut libc::c_void) -> libc::c_int;
|
|
||||||
|
|
||||||
macro_rules! dlsym_fn {
|
|
||||||
($lib:expr, $name:literal) => {
|
|
||||||
std::mem::transmute::<*mut libc::c_void, _>(libc::dlsym(
|
|
||||||
$lib,
|
|
||||||
concat!($name, "\0").as_ptr() as *const libc::c_char,
|
|
||||||
))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let xopen: FnOpenDisplay = dlsym_fn!(lib, "XOpenDisplay");
|
|
||||||
let xroot: FnDefaultRoot = dlsym_fn!(lib, "XDefaultRootWindow");
|
|
||||||
let xatom: FnInternAtom = dlsym_fn!(lib, "XInternAtom");
|
|
||||||
let xgetprop: FnGetProperty = dlsym_fn!(lib, "XGetProperty");
|
|
||||||
let xfree: FnFree = dlsym_fn!(lib, "XFree");
|
|
||||||
let xclosedpy: FnCloseDisplay = dlsym_fn!(lib, "XCloseDisplay");
|
|
||||||
|
|
||||||
let dpy = xopen(std::ptr::null());
|
|
||||||
if dpy.is_null() {
|
|
||||||
libc::dlclose(lib);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let root = xroot(dpy);
|
|
||||||
let net_active = xatom(
|
|
||||||
dpy,
|
|
||||||
b"_NET_ACTIVE_WINDOW\0".as_ptr() as *const libc::c_char,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
// XA_WINDOW = 33 (the standard X11 atom for Window type)
|
|
||||||
let xa_window: u64 = 33;
|
|
||||||
let mut actual_type: u64 = 0;
|
|
||||||
let mut actual_format: libc::c_int = 0;
|
|
||||||
let mut nitems: u64 = 0;
|
|
||||||
let mut bytes_after: u64 = 0;
|
|
||||||
let mut data: *mut u8 = std::ptr::null_mut();
|
|
||||||
|
|
||||||
let status = xgetprop(
|
|
||||||
dpy,
|
|
||||||
root,
|
|
||||||
net_active,
|
|
||||||
xa_window,
|
|
||||||
0, // offset
|
|
||||||
1, // length
|
|
||||||
0, // delete
|
|
||||||
&mut actual_type,
|
|
||||||
&mut actual_format,
|
|
||||||
&mut nitems,
|
|
||||||
&mut bytes_after,
|
|
||||||
&mut data,
|
|
||||||
);
|
|
||||||
|
|
||||||
let result = if status != 0
|
|
||||||
&& !data.is_null()
|
|
||||||
&& nitems > 0
|
|
||||||
&& actual_format == 32
|
|
||||||
{
|
|
||||||
// Format=32 elements are returned as unsigned long arrays
|
|
||||||
let id = *(data as *const u64);
|
|
||||||
if id != 0 {
|
|
||||||
Some(format!("0x{:x}", id))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
if !data.is_null() {
|
|
||||||
xfree(data as *mut libc::c_void);
|
|
||||||
}
|
|
||||||
xclosedpy(dpy);
|
|
||||||
libc::dlclose(lib);
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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
|
|
||||||
/// application). Returns a unique window-identifier string.
|
|
||||||
pub fn get_active_window_id() -> Option<String> {
|
|
||||||
// Try GNOME Shell D-Bus (Wayland GNOME) — returns hex window ID
|
|
||||||
if let Some(id) = get_gnome_active_window_id() {
|
|
||||||
return Some(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try xdotool first (fast, direct, X11)
|
|
||||||
if let Ok(output) = Command::new("xdotool")
|
|
||||||
.args(["getactivewindow"])
|
|
||||||
.output()
|
|
||||||
{
|
|
||||||
if output.status.success() {
|
|
||||||
let id = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
||||||
if !id.is_empty() {
|
|
||||||
return Some(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: xprop -root _NET_ACTIVE_WINDOW (x11-utils, preinstalled on most distros)
|
|
||||||
if let Ok(output) = Command::new("xprop")
|
|
||||||
.args(["-root", "_NET_ACTIVE_WINDOW"])
|
|
||||||
.output()
|
|
||||||
{
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Final fallback: direct X11 client library query (works in Flatpak sandbox)
|
|
||||||
if let Some(id) = get_active_window_x11_dlopen() {
|
|
||||||
return Some(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 GNOME Shell D-Bus (Wayland GNOME)
|
// Try Wayland first (wlr-foreign-toplevel)
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
@ -255,16 +14,6 @@ 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);
|
||||||
|
|
@ -273,70 +22,6 @@ 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"])
|
||||||
|
|
@ -353,103 +38,6 @@ 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")
|
||||||
|
|
@ -495,26 +83,8 @@ 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 {
|
||||||
|
|
@ -522,171 +92,18 @@ 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 {
|
||||||
|
|
@ -694,26 +111,16 @@ 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
|
||||||
pub fn get_default_state(&self) -> bool {
|
fn get_default_state(&self) -> bool {
|
||||||
if !self.global_enabled {
|
if !self.global_enabled {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -769,21 +176,15 @@ 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, {} Terminal",
|
"[vietc] App lists updated: {} English, {} Vietnamese, {} Bypass",
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
@ -827,91 +228,6 @@ 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()
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
@ -14,18 +13,12 @@ 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,
|
||||||
|
|
||||||
|
|
@ -39,37 +32,6 @@ 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 {
|
||||||
|
|
@ -94,18 +56,12 @@ 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: false,
|
enabled: true,
|
||||||
trigger_keys: default_restore_keys(),
|
trigger_keys: default_restore_keys(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -118,8 +74,6 @@ 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(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -130,11 +84,8 @@ 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
|
false
|
||||||
}
|
}
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
|
|
@ -145,30 +96,6 @@ 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![
|
||||||
|
|
@ -183,16 +110,6 @@ 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(),
|
||||||
|
|
@ -201,26 +118,17 @@ fn default_terminal_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(),
|
||||||
"termite".into(),
|
"steam".into(),
|
||||||
"terminator".into(),
|
"dota".into(),
|
||||||
"tilix".into(),
|
"csgo".into(),
|
||||||
"deepin-terminal".into(),
|
"minecraft".into(),
|
||||||
"pantheon-terminal".into(),
|
"factorio".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(),
|
||||||
|
|
@ -283,13 +191,11 @@ 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,
|
||||||
debug: false,
|
debug: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -355,7 +261,7 @@ vs = "với"
|
||||||
let config: Config = toml::from_str(toml).unwrap();
|
let config: Config = toml::from_str(toml).unwrap();
|
||||||
assert_eq!(config.input_method, "vni");
|
assert_eq!(config.input_method, "vni");
|
||||||
assert_eq!(config.toggle_key, "shift");
|
assert_eq!(config.toggle_key, "shift");
|
||||||
assert!(!config.start_enabled); // explicitly set to false in test toml
|
assert!(!config.start_enabled);
|
||||||
assert!(!config.auto_restore.enabled);
|
assert!(!config.auto_restore.enabled);
|
||||||
assert!(config.app_state.enabled);
|
assert!(config.app_state.enabled);
|
||||||
assert_eq!(config.app_state.english_apps, vec!["code", "vim"]);
|
assert_eq!(config.app_state.english_apps, vec!["code", "vim"]);
|
||||||
|
|
@ -374,8 +280,8 @@ vs = "với"
|
||||||
let config: Config = toml::from_str(toml).unwrap();
|
let config: Config = toml::from_str(toml).unwrap();
|
||||||
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);
|
||||||
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());
|
||||||
|
|
@ -389,7 +295,7 @@ input_method = "vni"
|
||||||
let config: Config = toml::from_str(toml).unwrap();
|
let config: Config = toml::from_str(toml).unwrap();
|
||||||
assert_eq!(config.input_method, "vni");
|
assert_eq!(config.input_method, "vni");
|
||||||
assert_eq!(config.toggle_key, "space"); // default
|
assert_eq!(config.toggle_key, "space"); // default
|
||||||
assert!(config.start_enabled); // default changed to true
|
assert!(!config.start_enabled); // default
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -420,16 +326,12 @@ foo = "bar"
|
||||||
[app_state]
|
[app_state]
|
||||||
english_apps = ["vim", "neovim"]
|
english_apps = ["vim", "neovim"]
|
||||||
vietnamese_apps = ["zalo", "messenger"]
|
vietnamese_apps = ["zalo", "messenger"]
|
||||||
bypass_apps = ["steam"]
|
bypass_apps = ["kitty"]
|
||||||
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!["steam"]);
|
assert_eq!(config.app_state.bypass_apps, vec!["kitty"]);
|
||||||
assert_eq!(config.app_state.terminal_apps, vec!["kitty"]);
|
|
||||||
assert_eq!(config.app_state.terminal_input_method, "telex");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -451,31 +353,11 @@ terminal_input_method = "telex"
|
||||||
#[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(&"steam".to_string()));
|
assert!(config.app_state.bypass_apps.contains(&"kitty".to_string()));
|
||||||
assert!(!config
|
assert!(config
|
||||||
.app_state
|
.app_state
|
||||||
.bypass_apps
|
.bypass_apps
|
||||||
.contains(&"kitty".to_string()));
|
.contains(&"alacritty".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]
|
||||||
|
|
@ -518,30 +400,4 @@ 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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
@ -85,19 +84,5 @@ 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1185
daemon/src/main.rs
1185
daemon/src/main.rs
File diff suppressed because it is too large
Load diff
|
|
@ -1,75 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
[package]
|
[package]
|
||||||
name = "vietc-engine"
|
name = "vietc-engine"
|
||||||
version = "0.1.7"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Viet+ Vietnamese IME Core Engine"
|
description = "Viet+ Vietnamese IME Core Engine"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
sha2 = "0.10"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
insta = { version = "1.34", features = ["yaml"] }
|
insta = { version = "1.34", features = ["yaml"] }
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
use crate::input_method::{InputMethod, InputMethodRules, get_rules};
|
use crate::input_method::{InputMethod, InputMethodRules, get_rules};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
|
@ -66,6 +65,10 @@ impl BambooEngine {
|
||||||
self.macro_buf.clear();
|
self.macro_buf.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.composition.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn process_key(&mut self, ch: char) -> Option<String> {
|
pub fn process_key(&mut self, ch: char) -> Option<String> {
|
||||||
if !self.mode.is_vn() {
|
if !self.mode.is_vn() {
|
||||||
return Some(ch.to_string());
|
return Some(ch.to_string());
|
||||||
|
|
@ -86,18 +89,12 @@ impl BambooEngine {
|
||||||
self.macro_buf.clear();
|
self.macro_buf.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check tone keys — only apply if composition has a vowel, else treat as normal char
|
// Check tone keys
|
||||||
if let Some(&(tone_char, _tone_name)) = self.rules.tone_keys.get(&lower) {
|
if let Some(&(tone_char, _tone_name)) = self.rules.tone_keys.get(&lower) {
|
||||||
let has_vowel = self.composition.iter().any(|t| {
|
|
||||||
is_vowel(t.mark_applied.unwrap_or(t.base_char))
|
|
||||||
});
|
|
||||||
if has_vowel {
|
|
||||||
return self.apply_tone(tone_char);
|
return self.apply_tone(tone_char);
|
||||||
}
|
}
|
||||||
// Fall through: append as normal character
|
|
||||||
}
|
|
||||||
|
|
||||||
// Smart "uo" → "ươ" shortcut with flexible backtrack":
|
// Smart "uo" → "ươ" shortcut with flexible backtrack:
|
||||||
// Scan backward through consonants to find the "uo" pair
|
// Scan backward through consonants to find the "uo" pair
|
||||||
if self.rules.method == InputMethod::Telex && lower == 'w'
|
if self.rules.method == InputMethod::Telex && lower == 'w'
|
||||||
|| self.rules.method == InputMethod::Vni && lower == '7'
|
|| self.rules.method == InputMethod::Vni && lower == '7'
|
||||||
|
|
@ -124,33 +121,9 @@ impl BambooEngine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Smart "ua" → "ưa": the horn goes on the u (xưa, chưa, mưa, lửa),
|
|
||||||
// not the breve on the a ("xuă" is not a valid syllable). Skip the
|
|
||||||
// "qu" glide case, where the u belongs to the initial consonant and
|
|
||||||
// the a takes the breve instead (quă → quăng).
|
|
||||||
if self.composition.len() >= 2 {
|
|
||||||
let a_idx = self.composition.len() - 1;
|
|
||||||
let u_idx = a_idx - 1;
|
|
||||||
let a_ch = self.composition[a_idx].base_char.to_ascii_lowercase();
|
|
||||||
let u_ch = self.composition[u_idx].base_char.to_ascii_lowercase();
|
|
||||||
let preceded_by_q = u_idx > 0
|
|
||||||
&& self.composition[u_idx - 1]
|
|
||||||
.base_char
|
|
||||||
.eq_ignore_ascii_case(&'q');
|
|
||||||
if a_ch == 'a'
|
|
||||||
&& u_ch == 'u'
|
|
||||||
&& self.composition[u_idx].mark_applied.is_none()
|
|
||||||
&& !preceded_by_q
|
|
||||||
{
|
|
||||||
self.composition[u_idx].base_char = 'ư';
|
|
||||||
self.composition[u_idx].mark_applied = Some('ư');
|
|
||||||
return Some(self.flatten());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try mark rules with flexible backtrack" (scan up to 3 chars backward)
|
// Try mark rules with flexible backtrack (scan up to 3 chars backward)
|
||||||
let mark_match = self.find_mark_backtrack(lower);
|
let mark_match = self.find_mark_backtrack(lower);
|
||||||
|
|
||||||
if let Some((idx, pattern, result)) = mark_match {
|
if let Some((idx, pattern, result)) = mark_match {
|
||||||
|
|
@ -177,6 +150,11 @@ impl BambooEngine {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_tone_or_mark_key(&self, lower: char) -> bool {
|
||||||
|
self.rules.tone_keys.contains_key(&lower)
|
||||||
|
|| self.rules.mark_rules.iter().any(|(p, _)| p.ends_with(lower))
|
||||||
|
}
|
||||||
|
|
||||||
fn apply_mark_at(&mut self, idx: usize, _pattern: &str, result: &str) {
|
fn apply_mark_at(&mut self, idx: usize, _pattern: &str, result: &str) {
|
||||||
let result_chars: Vec<char> = result.chars().collect();
|
let result_chars: Vec<char> = result.chars().collect();
|
||||||
let was_upper = self.composition[idx].is_upper;
|
let was_upper = self.composition[idx].is_upper;
|
||||||
|
|
@ -194,6 +172,16 @@ impl BambooEngine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn process_string(&mut self, s: &str) -> String {
|
||||||
|
let mut last = String::new();
|
||||||
|
for ch in s.chars() {
|
||||||
|
if let Some(out) = self.process_key(ch) {
|
||||||
|
last = out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
last
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn debug_composition(&self) -> Vec<(char, Option<char>, Option<char>)> {
|
pub fn debug_composition(&self) -> Vec<(char, Option<char>, Option<char>)> {
|
||||||
self.composition.iter().map(|t| (t.base_char, t.mark_applied, t.tone_applied)).collect()
|
self.composition.iter().map(|t| (t.base_char, t.mark_applied, t.tone_applied)).collect()
|
||||||
|
|
@ -220,6 +208,50 @@ impl BambooEngine {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn last_base_char(&self) -> char {
|
||||||
|
self.composition.last().map(|t| t.base_char).unwrap_or(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_cluster_mark(&mut self, pattern: &str, result: &str) {
|
||||||
|
let result_chars: Vec<char> = result.chars().collect();
|
||||||
|
// For cluster marks, all pattern chars are already in composition
|
||||||
|
let to_remove = pattern.chars().count();
|
||||||
|
let remove_start = self.composition.len().saturating_sub(to_remove);
|
||||||
|
let removed: Vec<_> = self.composition.drain(remove_start..).collect();
|
||||||
|
|
||||||
|
let was_upper = removed.first().map(|t| t.is_upper).unwrap_or(false);
|
||||||
|
|
||||||
|
for &ch in &result_chars {
|
||||||
|
self.composition.push(Transformation {
|
||||||
|
base_char: ch,
|
||||||
|
mark_applied: Some(ch),
|
||||||
|
tone_applied: removed.last().and_then(|t| t.tone_applied),
|
||||||
|
is_upper: was_upper && ch == result_chars[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_mark(&mut self, pattern: &str, result: &str) {
|
||||||
|
let result_chars: Vec<char> = result.chars().collect();
|
||||||
|
// Remove (pattern.len() - 1) chars from composition:
|
||||||
|
// the current key being processed is NOT yet in composition,
|
||||||
|
// so we only remove the chars from composition that form the mark pattern
|
||||||
|
let to_remove = pattern.chars().count().saturating_sub(1);
|
||||||
|
let remove_start = self.composition.len().saturating_sub(to_remove);
|
||||||
|
let removed: Vec<_> = self.composition.drain(remove_start..).collect();
|
||||||
|
|
||||||
|
let was_upper = removed.first().map(|t| t.is_upper).unwrap_or(false);
|
||||||
|
|
||||||
|
for &ch in &result_chars {
|
||||||
|
self.composition.push(Transformation {
|
||||||
|
base_char: ch,
|
||||||
|
mark_applied: Some(ch),
|
||||||
|
tone_applied: removed.last().and_then(|t| t.tone_applied),
|
||||||
|
is_upper: was_upper && ch == result_chars[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn apply_tone(&mut self, tone_char: char) -> Option<String> {
|
fn apply_tone(&mut self, tone_char: char) -> Option<String> {
|
||||||
if self.composition.is_empty() {
|
if self.composition.is_empty() {
|
||||||
return Some(tone_char.to_string());
|
return Some(tone_char.to_string());
|
||||||
|
|
@ -250,7 +282,6 @@ impl BambooEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn find_tone_position(&self, range: std::ops::Range<usize>) -> usize {
|
fn find_tone_position(&self, range: std::ops::Range<usize>) -> usize {
|
||||||
let start = range.start;
|
|
||||||
let mut vowels: Vec<usize> = Vec::new();
|
let mut vowels: Vec<usize> = Vec::new();
|
||||||
|
|
||||||
for i in range {
|
for i in range {
|
||||||
|
|
@ -264,18 +295,6 @@ impl BambooEngine {
|
||||||
return self.composition.len().saturating_sub(1);
|
return self.composition.len().saturating_sub(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exclude onset glides: in "qu…" the u and in "gi…" the i belong to the
|
|
||||||
// initial consonant, not the vowel nucleus — so they must never carry the
|
|
||||||
// tone (e.g. "quả" not "qủa", "giờ" not "gìơ"). Only strip the glide when
|
|
||||||
// another vowel follows it; bare "gì"/"qu" keep the letter as the nucleus.
|
|
||||||
if vowels.len() >= 2 && vowels[0] == start + 1 {
|
|
||||||
let onset = self.composition[start].base_char.to_ascii_lowercase();
|
|
||||||
let glide = self.composition[start + 1].base_char.to_ascii_lowercase();
|
|
||||||
if (onset == 'q' && glide == 'u') || (onset == 'g' && glide == 'i') {
|
|
||||||
vowels.remove(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if vowels.len() == 1 {
|
if vowels.len() == 1 {
|
||||||
return vowels[0];
|
return vowels[0];
|
||||||
}
|
}
|
||||||
|
|
@ -297,7 +316,7 @@ impl BambooEngine {
|
||||||
let tone_on_second = matches!((cv1, cv2),
|
let tone_on_second = matches!((cv1, cv2),
|
||||||
('o', 'a') | ('o', 'e') | ('u', 'y') |
|
('o', 'a') | ('o', 'e') | ('u', 'y') |
|
||||||
('i', 'ê') | ('y', 'ê') | ('u', 'ô') | ('ư', 'ơ') |
|
('i', 'ê') | ('y', 'ê') | ('u', 'ô') | ('ư', 'ơ') |
|
||||||
('i', 'o') | ('u', 'â') | ('u', 'ê') | ('u', 'ơ')
|
('i', 'o') | ('u', 'â')
|
||||||
);
|
);
|
||||||
|
|
||||||
if tone_on_second {
|
if tone_on_second {
|
||||||
|
|
@ -509,80 +528,4 @@ mod tests {
|
||||||
assert_eq!(process(InputMethod::Telex, "chafo"), "chào");
|
assert_eq!(process(InputMethod::Telex, "chafo"), "chào");
|
||||||
assert_eq!(process(InputMethod::Vni, "chao2"), "chào");
|
assert_eq!(process(InputMethod::Vni, "chao2"), "chào");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_telex_tuaan() {
|
|
||||||
let mut e = crate::bamboo::BambooEngine::new(crate::input_method::InputMethod::Telex);
|
|
||||||
let mut out = String::new();
|
|
||||||
for ch in "Tuaans".chars() {
|
|
||||||
if let Some(o) = e.process_key(ch) { out = o; }
|
|
||||||
}
|
|
||||||
assert_eq!(out, "Tuấn", "Expected Tuấn, got {}", out);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_telex_nguyeenx() {
|
|
||||||
let mut e = crate::bamboo::BambooEngine::new(crate::input_method::InputMethod::Telex);
|
|
||||||
let mut out = String::new();
|
|
||||||
for ch in "nguyeenx".chars() {
|
|
||||||
if let Some(o) = e.process_key(ch) { out = o; }
|
|
||||||
}
|
|
||||||
assert_eq!(out, "nguyễn", "Expected nguyễn, got {}", out);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_telex_gios() {
|
|
||||||
let mut e = crate::bamboo::BambooEngine::new(crate::input_method::InputMethod::Telex);
|
|
||||||
let mut out = String::new();
|
|
||||||
for ch in "gios".chars() {
|
|
||||||
if let Some(o) = e.process_key(ch) { out = o; }
|
|
||||||
}
|
|
||||||
assert_eq!(out, "gió", "Expected gió, got {}", out);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_telex_ua_horn() {
|
|
||||||
// "w" after a "ua" cluster puts the horn on the u (ưa), it must not
|
|
||||||
// put the breve on the a ("xuă" is not a valid Vietnamese syllable).
|
|
||||||
assert_eq!(process(InputMethod::Telex, "xuaw"), "xưa");
|
|
||||||
assert_eq!(process(InputMethod::Telex, "chuaw"), "chưa");
|
|
||||||
assert_eq!(process(InputMethod::Telex, "muaw"), "mưa");
|
|
||||||
assert_eq!(process(InputMethod::Telex, "Xuaw"), "Xưa");
|
|
||||||
// With a following tone the horn target still carries the tone.
|
|
||||||
assert_eq!(process(InputMethod::Telex, "luawr"), "lửa");
|
|
||||||
// "qu" glide exception: the u belongs to the initial, a takes the breve.
|
|
||||||
assert_eq!(process(InputMethod::Telex, "quawng"), "quăng");
|
|
||||||
// VNI parity.
|
|
||||||
assert_eq!(process(InputMethod::Vni, "xua7"), "xưa");
|
|
||||||
assert_eq!(process(InputMethod::Vni, "qua8ng"), "quăng");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_telex_r_as_normal_char() {
|
|
||||||
let mut e = BambooEngine::new(InputMethod::Telex);
|
|
||||||
let mut out = String::new();
|
|
||||||
for ch in "tr".chars() {
|
|
||||||
if let Some(o) = e.process_key(ch) { out = o; }
|
|
||||||
}
|
|
||||||
assert_eq!(out, "tr");
|
|
||||||
out.clear(); e.reset();
|
|
||||||
for ch in "traf".chars() {
|
|
||||||
if let Some(o) = e.process_key(ch) { out = o; }
|
|
||||||
}
|
|
||||||
assert_eq!(out, "trà");
|
|
||||||
out.clear(); e.reset();
|
|
||||||
for ch in "tar".chars() {
|
|
||||||
if let Some(o) = e.process_key(ch) { out = o; }
|
|
||||||
}
|
|
||||||
assert_eq!(out, "tả");
|
|
||||||
out.clear(); e.reset();
|
|
||||||
for ch in "tramr".chars() {
|
|
||||||
if let Some(o) = e.process_key(ch) { out = o; }
|
|
||||||
}
|
|
||||||
assert_eq!(out, "trảm");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,6 @@
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
use crate::bamboo::BambooEngine;
|
use crate::bamboo::BambooEngine;
|
||||||
use crate::english::EnglishDict;
|
|
||||||
use crate::event::{Command, EventStore};
|
|
||||||
use crate::input_method::InputMethod;
|
use crate::input_method::InputMethod;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::OnceLock;
|
|
||||||
|
|
||||||
fn english_dict() -> &'static EnglishDict {
|
|
||||||
static DICT: OnceLock<EnglishDict> = OnceLock::new();
|
|
||||||
DICT.get_or_init(EnglishDict::new)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||||
pub enum EngineEvent {
|
pub enum EngineEvent {
|
||||||
|
|
@ -26,7 +17,6 @@ pub struct Engine {
|
||||||
macros: HashMap<String, String>,
|
macros: HashMap<String, String>,
|
||||||
raw_buffer: String,
|
raw_buffer: String,
|
||||||
paste_mode: bool,
|
paste_mode: bool,
|
||||||
auto_restore: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Engine {
|
impl Engine {
|
||||||
|
|
@ -36,41 +26,9 @@ impl Engine {
|
||||||
macros: HashMap::new(),
|
macros: HashMap::new(),
|
||||||
raw_buffer: String::new(),
|
raw_buffer: String::new(),
|
||||||
paste_mode: false,
|
paste_mode: false,
|
||||||
auto_restore: true,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_auto_restore(&mut self, enabled: bool) {
|
|
||||||
self.auto_restore = enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decide whether a committed word should be reverted to the raw keystrokes
|
|
||||||
/// the user typed instead of the Vietnamese transformation. Returns true for
|
|
||||||
/// words that are clearly English / non-Vietnamese: a known English word, a
|
|
||||||
/// result that isn't a phonologically valid Vietnamese syllable, or one that
|
|
||||||
/// contains letters foreign to Vietnamese. `composed` is the transformed
|
|
||||||
/// output; `raw` is the literal keystrokes typed.
|
|
||||||
pub fn should_restore_word(composed: &str, raw: &str) -> bool {
|
|
||||||
// No transformation happened — English already passed through untouched.
|
|
||||||
if composed == raw {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let dict = english_dict();
|
|
||||||
let raw_lower = raw.to_lowercase();
|
|
||||||
let composed_lower = composed.to_lowercase();
|
|
||||||
|
|
||||||
// Genuine Vietnamese words that happen to look like English stay as-is.
|
|
||||||
if dict.is_vietnamese_override(&composed_lower) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if dict.is_english_word(&raw_lower) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
!crate::spelling::is_valid_vietnamese_syllable(composed)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_enabled(&mut self, enabled: bool) {
|
pub fn set_enabled(&mut self, enabled: bool) {
|
||||||
self.bamboo.set_enabled(enabled);
|
self.bamboo.set_enabled(enabled);
|
||||||
if !enabled {
|
if !enabled {
|
||||||
|
|
@ -150,88 +108,6 @@ impl Engine {
|
||||||
(if did_flush { String::new() } else { last_output }, did_flush)
|
(if did_flush { String::new() } else { last_output }, did_flush)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replay events through a fresh engine, returning (expected_output, did_flush).
|
|
||||||
/// This is the Event Sourcing equivalent of replay_keystrokes.
|
|
||||||
pub fn replay_events(
|
|
||||||
method: InputMethod,
|
|
||||||
macros: &HashMap<String, String>,
|
|
||||||
events: &EventStore,
|
|
||||||
) -> (String, bool) {
|
|
||||||
let mut engine = Engine::new(method);
|
|
||||||
for (shortcut, expansion) in macros {
|
|
||||||
engine.add_macro(shortcut.clone(), expansion.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut last_output = String::new();
|
|
||||||
let mut composing = String::new();
|
|
||||||
|
|
||||||
for event in events.iter() {
|
|
||||||
match event {
|
|
||||||
crate::event::InputEvent::KeyTyped(ch) => {
|
|
||||||
if let Some(out) = engine.bamboo.process_key(*ch) {
|
|
||||||
composing = out.clone();
|
|
||||||
last_output = out;
|
|
||||||
} else {
|
|
||||||
composing = engine.bamboo.get_output();
|
|
||||||
last_output = composing.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
crate::event::InputEvent::Backspace => {
|
|
||||||
let _ = engine.bamboo.pop_last();
|
|
||||||
composing = engine.bamboo.get_output();
|
|
||||||
last_output = composing.clone();
|
|
||||||
}
|
|
||||||
crate::event::InputEvent::Flush(_) => {
|
|
||||||
if !composing.is_empty() {
|
|
||||||
last_output = composing.clone();
|
|
||||||
}
|
|
||||||
composing.clear();
|
|
||||||
engine.bamboo.reset();
|
|
||||||
}
|
|
||||||
crate::event::InputEvent::Paste(text) => {
|
|
||||||
for ch in text.chars() {
|
|
||||||
if let Some(out) = engine.bamboo.process_key(ch) {
|
|
||||||
composing = out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
last_output = composing.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let output = engine.bamboo.get_output();
|
|
||||||
let output_is_empty = output.is_empty();
|
|
||||||
if !output.is_empty() {
|
|
||||||
last_output = output;
|
|
||||||
}
|
|
||||||
|
|
||||||
let did_flush = output_is_empty && composing.is_empty();
|
|
||||||
(if did_flush { String::new() } else { last_output }, did_flush)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Event Sourcing + Command Pattern: replay events and return diff commands.
|
|
||||||
/// Compares expected output against screen_output and generates backspace/type commands.
|
|
||||||
pub fn replay_events_to_commands(
|
|
||||||
method: InputMethod,
|
|
||||||
macros: &HashMap<String, String>,
|
|
||||||
events: &EventStore,
|
|
||||||
screen_output: &str,
|
|
||||||
) -> Vec<Command> {
|
|
||||||
let (new_output, _) = Engine::replay_events(method, macros, events);
|
|
||||||
|
|
||||||
let mut commands = Vec::new();
|
|
||||||
if new_output != screen_output {
|
|
||||||
let backspaces = screen_output.chars().count();
|
|
||||||
if backspaces > 0 {
|
|
||||||
commands.push(Command::Backspace(backspaces));
|
|
||||||
}
|
|
||||||
if !new_output.is_empty() {
|
|
||||||
commands.push(Command::Type(new_output));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
commands
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_with_pasted_text(&mut self, text: &str) {
|
pub fn update_with_pasted_text(&mut self, text: &str) {
|
||||||
self.raw_buffer.clear();
|
self.raw_buffer.clear();
|
||||||
self.raw_buffer.push_str(text);
|
self.raw_buffer.push_str(text);
|
||||||
|
|
@ -291,28 +167,17 @@ impl Engine {
|
||||||
self.reset();
|
self.reset();
|
||||||
return Some(EngineEvent::Replace {
|
return Some(EngineEvent::Replace {
|
||||||
backspaces: prev_len,
|
backspaces: prev_len,
|
||||||
insert: expansion,
|
insert: format!("{}{}", expansion, ch),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let raw = self.raw_buffer.clone();
|
|
||||||
self.reset();
|
self.reset();
|
||||||
if prev_len > 0 {
|
if prev_len > 0 {
|
||||||
// Auto-restore: if the committed word is English / not valid
|
|
||||||
// Vietnamese, revert to the raw keystrokes the user typed. This
|
|
||||||
// genuinely changes the on-screen word, so a Replace is needed.
|
|
||||||
if self.auto_restore && Engine::should_restore_word(&previous, &raw) {
|
|
||||||
return Some(EngineEvent::Replace {
|
return Some(EngineEvent::Replace {
|
||||||
backspaces: prev_len,
|
backspaces: prev_len,
|
||||||
insert: raw,
|
insert: format!("{}{}", previous, ch),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Normal case: the composed word is already correctly on screen.
|
|
||||||
// Re-typing it would trigger a redundant backspace + retype that
|
|
||||||
// races against the separately-forwarded flush char, eating
|
|
||||||
// spaces and merging words. Finalize and let the flush char
|
|
||||||
// through untouched.
|
|
||||||
}
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
pub struct EnglishDict {
|
pub struct EnglishDict {
|
||||||
|
|
@ -365,11 +364,6 @@ impl EnglishDict {
|
||||||
self.words.contains(word)
|
self.words.contains(word)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_vietnamese_override(&self, word: &str) -> bool {
|
|
||||||
self.vietnamese_overrides.contains(word)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn should_restore(&self, word: &str) -> bool {
|
pub fn should_restore(&self, word: &str) -> bool {
|
||||||
if self.vietnamese_overrides.contains(word) {
|
if self.vietnamese_overrides.contains(word) {
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
|
|
||||||
/// Typed input event - the core of Event Sourcing.
|
|
||||||
/// KHÔNG lưu nội dung nhạy cảm, chỉ lưu event sequence.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub enum InputEvent {
|
|
||||||
/// A character key was typed
|
|
||||||
KeyTyped(char),
|
|
||||||
/// Backspace was pressed
|
|
||||||
Backspace,
|
|
||||||
/// A flush character (space, punctuation, enter, tab)
|
|
||||||
Flush(char),
|
|
||||||
/// Text was pasted
|
|
||||||
Paste(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Append-only event store.
|
|
||||||
/// Source of truth for all user input.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct EventStore {
|
|
||||||
events: Vec<InputEvent>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventStore {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self { events: Vec::new() }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn push(&mut self, event: InputEvent) {
|
|
||||||
self.events.push(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pop(&mut self) -> Option<InputEvent> {
|
|
||||||
self.events.pop()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clear(&mut self) {
|
|
||||||
self.events.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.events.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn len(&self) -> usize {
|
|
||||||
self.events.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn iter(&self) -> impl Iterator<Item = &InputEvent> {
|
|
||||||
self.events.iter()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn as_slice(&self) -> &[InputEvent] {
|
|
||||||
&self.events
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract raw keystrokes from event log (for auto-restore comparison).
|
|
||||||
/// Only reconstructs the literal characters typed, excluding backspaces.
|
|
||||||
pub fn raw_keystrokes(&self) -> String {
|
|
||||||
let mut s = String::new();
|
|
||||||
for event in &self.events {
|
|
||||||
match event {
|
|
||||||
InputEvent::KeyTyped(c) => s.push(*c),
|
|
||||||
InputEvent::Backspace => { s.pop(); }
|
|
||||||
InputEvent::Flush(_) => {}
|
|
||||||
InputEvent::Paste(text) => s.push_str(text),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hash the event type sequence (not content) for privacy-safe pattern detection.
|
|
||||||
/// Output: sha256 hex of event type characters (K=KeyTyped, B=Backspace, F=Flush, P=Paste).
|
|
||||||
/// Không thể recover text gốc — chỉ biết "có X events với pattern Y".
|
|
||||||
pub fn pattern_hash(&self) -> String {
|
|
||||||
let types: String = self.events.iter().map(|e| match e {
|
|
||||||
InputEvent::KeyTyped(_) => 'K',
|
|
||||||
InputEvent::Backspace => 'B',
|
|
||||||
InputEvent::Flush(_) => 'F',
|
|
||||||
InputEvent::Paste(_) => 'P',
|
|
||||||
}).collect();
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(types.as_bytes());
|
|
||||||
format!("{:x}", hasher.finalize())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for EventStore {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Formalized output commands (Command Pattern).
|
|
||||||
/// Chỉ chứa diff instruction, không chứa text nhạy cảm.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum Command {
|
|
||||||
/// Type a string of characters
|
|
||||||
Type(String),
|
|
||||||
/// Backspace N times
|
|
||||||
Backspace(usize),
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
@ -7,11 +6,19 @@ pub enum InputMethod {
|
||||||
Vni,
|
Vni,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum RuleEffect {
|
||||||
|
Appending(char),
|
||||||
|
MarkTransformation { base: char, marked: char },
|
||||||
|
ToneTransformation { tone: char, name: &'static str },
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct InputMethodRules {
|
pub struct InputMethodRules {
|
||||||
pub method: InputMethod,
|
pub method: InputMethod,
|
||||||
pub tone_keys: HashMap<char, (char, &'static str)>,
|
pub tone_keys: HashMap<char, (char, &'static str)>,
|
||||||
pub mark_rules: Vec<(String, String)>,
|
pub mark_rules: Vec<(String, String)>,
|
||||||
|
pub special_rules: Vec<RuleEffect>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tone_map(entries: &[(char, char, &'static str)]) -> HashMap<char, (char, &'static str)> {
|
fn tone_map(entries: &[(char, char, &'static str)]) -> HashMap<char, (char, &'static str)> {
|
||||||
|
|
@ -38,6 +45,7 @@ pub fn get_rules(method: InputMethod) -> InputMethodRules {
|
||||||
("uw".into(), "ư".into()),
|
("uw".into(), "ư".into()),
|
||||||
("dd".into(), "đ".into()),
|
("dd".into(), "đ".into()),
|
||||||
],
|
],
|
||||||
|
special_rules: vec![],
|
||||||
},
|
},
|
||||||
InputMethod::Vni => InputMethodRules {
|
InputMethod::Vni => InputMethodRules {
|
||||||
method,
|
method,
|
||||||
|
|
@ -57,6 +65,7 @@ pub fn get_rules(method: InputMethod) -> InputMethodRules {
|
||||||
("a8".into(), "ă".into()),
|
("a8".into(), "ă".into()),
|
||||||
("d9".into(), "đ".into()),
|
("d9".into(), "đ".into()),
|
||||||
],
|
],
|
||||||
|
special_rules: vec![],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
mod bamboo;
|
mod bamboo;
|
||||||
mod engine;
|
mod engine;
|
||||||
mod english;
|
|
||||||
pub mod event;
|
|
||||||
mod input_method;
|
mod input_method;
|
||||||
pub mod spelling;
|
pub mod spelling;
|
||||||
|
|
||||||
|
|
@ -11,5 +8,4 @@ mod tests;
|
||||||
|
|
||||||
pub use engine::Engine;
|
pub use engine::Engine;
|
||||||
pub use engine::EngineEvent;
|
pub use engine::EngineEvent;
|
||||||
pub use event::{Command, EventStore, InputEvent};
|
|
||||||
pub use input_method::InputMethod;
|
pub use input_method::InputMethod;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
const FIRST_CONSONANT_SEQS: &[&str] = &[
|
const FIRST_CONSONANT_SEQS: &[&str] = &[
|
||||||
"b d đ g gh m n nh p ph r s t tr v z",
|
"b d đ g gh m n nh p ph r s t tr v z",
|
||||||
"c h k kh qu th",
|
"c h k kh qu th",
|
||||||
|
|
|
||||||
|
|
@ -6,23 +6,16 @@ mod tests {
|
||||||
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) {
|
if let Some(event) = engine.process_key(ch) {
|
||||||
let is_replace = matches!(&event, EngineEvent::Replace { .. });
|
|
||||||
let fl = is_flush_char(ch);
|
|
||||||
events.push(event);
|
events.push(event);
|
||||||
if is_replace && fl {
|
|
||||||
events.push(EngineEvent::Insert(ch.to_string()));
|
|
||||||
}
|
|
||||||
} else if engine.is_enabled() {
|
} else if engine.is_enabled() {
|
||||||
|
// Engine didn't produce an event — the daemon would forward the raw key.
|
||||||
|
// Track this as an Insert for display reconstruction.
|
||||||
events.push(EngineEvent::Insert(ch.to_string()));
|
events.push(EngineEvent::Insert(ch.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
events
|
events
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_flush_char(ch: char) -> bool {
|
|
||||||
matches!(ch, ' ' | '\t' | '.' | ',' | '!' | '?' | ';' | ':' | '\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_display(events: &[EngineEvent]) -> String {
|
fn get_display(events: &[EngineEvent]) -> String {
|
||||||
let mut display = String::new();
|
let mut display = String::new();
|
||||||
for ev in events {
|
for ev in events {
|
||||||
|
|
@ -455,52 +448,4 @@ mod tests {
|
||||||
e.set_method(InputMethod::Vni);
|
e.set_method(InputMethod::Vni);
|
||||||
assert_eq!(get_display(&process_input(&mut e, "a1")), "á");
|
assert_eq!(get_display(&process_input(&mut e, "a1")), "á");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================================================================
|
|
||||||
// Spacing / flush behavior (regression)
|
|
||||||
// ================================================================
|
|
||||||
|
|
||||||
// A space after a finished word must NOT re-emit the word as a Replace
|
|
||||||
// (backspace + retype). Re-typing the already-on-screen word races with
|
|
||||||
// the separately-forwarded space in the daemon, eating spaces and merging
|
|
||||||
// words (e.g. "mất sự" -> "mấtsự"). The flush should produce no engine
|
|
||||||
// event so the space simply passes through.
|
|
||||||
#[test]
|
|
||||||
fn flush_after_word_emits_no_replace() {
|
|
||||||
let mut e = Engine::new(InputMethod::Telex);
|
|
||||||
// Compose "chào".
|
|
||||||
for ch in "chaof".chars() {
|
|
||||||
e.process_key(ch);
|
|
||||||
}
|
|
||||||
// Space finalizes the word — engine must return None.
|
|
||||||
assert_eq!(e.process_key(' '), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Punctuation flush chars behave the same as space.
|
|
||||||
#[test]
|
|
||||||
fn flush_punctuation_emits_no_replace() {
|
|
||||||
let mut e = Engine::new(InputMethod::Telex);
|
|
||||||
for ch in "chaof".chars() {
|
|
||||||
e.process_key(ch);
|
|
||||||
}
|
|
||||||
assert_eq!(e.process_key('.'), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full multi-word sentence keeps every space and never concatenates words.
|
|
||||||
#[test]
|
|
||||||
fn multi_word_keeps_spacing() {
|
|
||||||
let mut e = Engine::new(InputMethod::Telex);
|
|
||||||
// "toio is" with telex: "tooi" -> "tôi"; "ddi" -> "đi"
|
|
||||||
let events = process_input(&mut e, "tooi ddi hocj ");
|
|
||||||
assert_eq!(get_display(&events), "tôi đi học ");
|
|
||||||
}
|
|
||||||
|
|
||||||
// A macro flush still expands (Replace) and keeps the trailing space.
|
|
||||||
#[test]
|
|
||||||
fn macro_flush_still_replaces() {
|
|
||||||
let mut e = Engine::new(InputMethod::Telex);
|
|
||||||
e.add_macro("vn".into(), "Việt Nam".into());
|
|
||||||
let events = process_input(&mut e, "vn ");
|
|
||||||
assert_eq!(get_display(&events), "Việt Nam ");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
//! Tests for smart English auto-restore: when Vietnamese mode is on, words that
|
|
||||||
//! are clearly English / not valid Vietnamese revert to the raw keystrokes the
|
|
||||||
//! user typed, while genuine Vietnamese is kept.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use vietc_engine::{Engine, InputMethod};
|
|
||||||
|
|
||||||
fn telex(keys: &str) -> String {
|
|
||||||
Engine::replay_keystrokes(InputMethod::Telex, &HashMap::new(), &keys.chars().collect::<Vec<_>>()).0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve what would actually be committed for a Telex keystroke sequence,
|
|
||||||
/// applying the auto-restore decision the daemon makes on word commit.
|
|
||||||
fn committed(keys: &str) -> String {
|
|
||||||
let composed = telex(keys);
|
|
||||||
let raw: String = keys.chars().collect();
|
|
||||||
if Engine::should_restore_word(&composed, &raw) {
|
|
||||||
raw
|
|
||||||
} else {
|
|
||||||
composed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn english_words_are_restored() {
|
|
||||||
// (telex keystrokes, expected committed word)
|
|
||||||
let cases = [
|
|
||||||
("fix", "fix"), // foreign letter f
|
|
||||||
("cargo", "cargo"), // invalid onset/coda
|
|
||||||
("status", "status"), // invalid cluster
|
|
||||||
("world", "world"), // invalid coda
|
|
||||||
("english", "english"),
|
|
||||||
("sweet", "sweet"), // invalid onset "sw"
|
|
||||||
];
|
|
||||||
for (keys, want) in cases {
|
|
||||||
assert_eq!(committed(keys), want, "expected {keys} to restore to {want}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn vietnamese_words_are_kept() {
|
|
||||||
let cases = [
|
|
||||||
("tieengs", "tiếng"),
|
|
||||||
("vieejt", "việt"),
|
|
||||||
("quar", "quả"),
|
|
||||||
("gif", "gì"),
|
|
||||||
("khoong", "không"),
|
|
||||||
("tooi", "tôi"),
|
|
||||||
("banhf", "bành"),
|
|
||||||
("ddi", "đi"),
|
|
||||||
];
|
|
||||||
for (keys, want) in cases {
|
|
||||||
assert_eq!(committed(keys), want, "expected {keys} to stay {want}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn untransformed_english_passes_through() {
|
|
||||||
// Words with no tone/mark letters never transform, so nothing to restore.
|
|
||||||
for keys in ["type", "code", "hello", "the", "and"] {
|
|
||||||
assert_eq!(committed(keys), keys);
|
|
||||||
assert!(!Engine::should_restore_word(&telex(keys), keys));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn process_key_restores_on_flush() {
|
|
||||||
// Drive the per-keystroke engine API and confirm the flush commits English.
|
|
||||||
let mut engine = Engine::new(InputMethod::Telex);
|
|
||||||
engine.set_enabled(true);
|
|
||||||
for ch in "cargo".chars() {
|
|
||||||
engine.process_key(ch);
|
|
||||||
}
|
|
||||||
// Mid-word the buffer is the Vietnamese composition.
|
|
||||||
assert_eq!(engine.buffer(), "cảgo");
|
|
||||||
// On flush the engine should emit a Replace back to the raw English word.
|
|
||||||
let event = engine.process_key(' ');
|
|
||||||
match event {
|
|
||||||
Some(vietc_engine::EngineEvent::Replace { insert, .. }) => {
|
|
||||||
assert_eq!(insert, "cargo");
|
|
||||||
}
|
|
||||||
other => panic!("expected Replace to 'cargo', got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn auto_restore_can_be_disabled() {
|
|
||||||
let mut engine = Engine::new(InputMethod::Telex);
|
|
||||||
engine.set_enabled(true);
|
|
||||||
engine.set_auto_restore(false);
|
|
||||||
for ch in "cargo".chars() {
|
|
||||||
engine.process_key(ch);
|
|
||||||
}
|
|
||||||
// With auto-restore off the Vietnamese composition is kept on screen
|
|
||||||
// (no restore back to the raw English keystrokes).
|
|
||||||
assert_eq!(engine.buffer(), "cảgo");
|
|
||||||
// The composed word is already correct on screen, so flushing emits no
|
|
||||||
// Replace — re-typing it would race with the forwarded flush char and eat
|
|
||||||
// the spacing. (Contrast with auto-restore on, which emits Replace→"cargo".)
|
|
||||||
let event = engine.process_key(' ');
|
|
||||||
assert!(
|
|
||||||
event.is_none(),
|
|
||||||
"with auto-restore off the composed VN word stays untouched on flush, got {event:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
//! Regression tests for tone placement on syllables whose onset contains a
|
|
||||||
//! glide letter ("qu", "gi") and on the "uê"/"uơ" vowel clusters.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use vietc_engine::{Engine, InputMethod};
|
|
||||||
|
|
||||||
fn telex(keys: &str) -> String {
|
|
||||||
Engine::replay_keystrokes(InputMethod::Telex, &HashMap::new(), &keys.chars().collect::<Vec<_>>()).0
|
|
||||||
}
|
|
||||||
|
|
||||||
fn vni(keys: &str) -> String {
|
|
||||||
Engine::replay_keystrokes(InputMethod::Vni, &HashMap::new(), &keys.chars().collect::<Vec<_>>()).0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// (telex keystrokes, vni keystrokes, expected word)
|
|
||||||
const CASES: &[(&str, &str, &str)] = &[
|
|
||||||
// "qu" onset: the u is part of the consonant, tone stays on the nucleus.
|
|
||||||
("quar", "qua3", "quả"),
|
|
||||||
("quaf", "qua2", "quà"),
|
|
||||||
("quas", "qua1", "quá"),
|
|
||||||
("quaj", "qua5", "quạ"),
|
|
||||||
// "gi" onset: the i is part of the consonant, tone stays on the nucleus.
|
|
||||||
("gias", "gia1", "giá"),
|
|
||||||
("giof", "gio2", "giò"),
|
|
||||||
("giowf", "gio72", "giờ"),
|
|
||||||
("giups", "giu1p", "giúp"),
|
|
||||||
("gieets", "gie61t", "giết"),
|
|
||||||
("giuwowngf", "giuo7ng2", "giường"),
|
|
||||||
// "uê"/"uơ" clusters: tone belongs on the second vowel.
|
|
||||||
("thuees", "thue61", "thuế"),
|
|
||||||
("hueej", "hue65", "huệ"),
|
|
||||||
// Controls that must keep working: bare "gì", "uy", "uâ", "uô".
|
|
||||||
("gif", "gi2", "gì"),
|
|
||||||
("quys", "quy1", "quý"),
|
|
||||||
("quaanf", "qua62n", "quần"),
|
|
||||||
("muoons", "muo61n", "muốn"),
|
|
||||||
];
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn onset_glide_and_cluster_tone_placement() {
|
|
||||||
let mut fails = Vec::new();
|
|
||||||
for &(tk, vk, want) in CASES {
|
|
||||||
let gt = telex(tk);
|
|
||||||
if gt != want {
|
|
||||||
fails.push(format!("TELEX {tk:>10} -> {gt:>8} want {want}"));
|
|
||||||
}
|
|
||||||
let gv = vni(vk);
|
|
||||||
if gv != want {
|
|
||||||
fails.push(format!("VNI {vk:>10} -> {gv:>8} want {want}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(fails.is_empty(), "tone placement mismatches:\n{}", fails.join("\n"));
|
|
||||||
}
|
|
||||||
119
install.sh
119
install.sh
|
|
@ -1,119 +0,0 @@
|
||||||
#!/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}"
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# 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"
|
|
||||||
332
packaging/appimage/build-appimage.sh
Normal file
332
packaging/appimage/build-appimage.sh
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Ensure cargo is in PATH
|
||||||
|
if ! command -v cargo &>/dev/null; then
|
||||||
|
if [ -f "$HOME/.cargo/bin/cargo" ]; then
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
APPDIR="$SCRIPT_DIR/AppDir"
|
||||||
|
VERSION="${1:-0.1.0}"
|
||||||
|
|
||||||
|
echo "=== Building Viet+ AppImage v${VERSION} ==="
|
||||||
|
|
||||||
|
# Clean
|
||||||
|
rm -rf "$APPDIR"
|
||||||
|
mkdir -p "$APPDIR/usr/bin"
|
||||||
|
mkdir -p "$APPDIR/usr/share/applications"
|
||||||
|
mkdir -p "$APPDIR/usr/share/icons/hicolor/256x256/apps"
|
||||||
|
mkdir -p "$APPDIR/usr/share/doc/vietc"
|
||||||
|
mkdir -p "$APPDIR/etc/vietc"
|
||||||
|
mkdir -p "$APPDIR/usr/lib/systemd/user"
|
||||||
|
mkdir -p "$APPDIR/usr/share/metainfo"
|
||||||
|
|
||||||
|
# Build binaries
|
||||||
|
echo "[1/5] Building binaries..."
|
||||||
|
if [ ! -f "target/release/vietc" ]; then
|
||||||
|
cargo build --release
|
||||||
|
cd "$PROJECT_ROOT/ui" && cargo build --release && cd "$PROJECT_ROOT"
|
||||||
|
fi
|
||||||
|
echo " Built with x11 + wayland"
|
||||||
|
|
||||||
|
# Copy binaries from deb-build if they exist, otherwise from target/release
|
||||||
|
echo "[2/5] Installing binaries..."
|
||||||
|
if [ -d "deb-build/usr/bin" ]; then
|
||||||
|
cp -r deb-build/usr/bin/* "$APPDIR/usr/bin/"
|
||||||
|
else
|
||||||
|
cp target/release/vietc "$APPDIR/usr/bin/"
|
||||||
|
cp target/release/vietc-cli "$APPDIR/usr/bin/"
|
||||||
|
cp target/release/vietc-uinputd "$APPDIR/usr/bin/"
|
||||||
|
[ -f ui/target/release/vietc-tray ] && cp ui/target/release/vietc-tray "$APPDIR/usr/bin/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Bundle xclip as fallback for clipboard operations
|
||||||
|
echo " Bundling xclip..."
|
||||||
|
if command -v xclip &>/dev/null; then
|
||||||
|
cp "$(which xclip)" "$APPDIR/usr/bin/"
|
||||||
|
echo " xclip bundled"
|
||||||
|
else
|
||||||
|
echo " xclip not found on system, skipping"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Compile and bundle vietc-xrecord (C helper for XRecord keyboard capture)
|
||||||
|
echo " Compiling vietc-xrecord..."
|
||||||
|
if command -v gcc &>/dev/null; then
|
||||||
|
gcc -O2 -o "$APPDIR/usr/bin/vietc-xrecord" "$SCRIPT_DIR/vietc-xrecord.c" -lX11 -lXtst
|
||||||
|
echo " vietc-xrecord bundled"
|
||||||
|
elif command -v cc &>/dev/null; then
|
||||||
|
cc -O2 -o "$APPDIR/usr/bin/vietc-xrecord" "$SCRIPT_DIR/vietc-xrecord.c" -lX11 -lXtst
|
||||||
|
echo " vietc-xrecord bundled"
|
||||||
|
else
|
||||||
|
echo " WARNING: No C compiler found, vietc-xrecord not bundled — X11 capture will fail"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Desktop integration
|
||||||
|
echo "[3/5] Installing desktop integration..."
|
||||||
|
if [ -f "deb-build/vietc.desktop" ]; then
|
||||||
|
cp deb-build/vietc.desktop "$APPDIR/usr/share/applications/"
|
||||||
|
else
|
||||||
|
cp "$SCRIPT_DIR/vietc.desktop" "$APPDIR/usr/share/applications/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Icons
|
||||||
|
if [ -f "deb-build/vietc.svg" ]; then
|
||||||
|
cp deb-build/vietc.svg "$APPDIR/usr/share/icons/hicolor/256x256/apps/"
|
||||||
|
cp deb-build/vietc.png "$APPDIR/usr/share/icons/hicolor/256x256/apps/"
|
||||||
|
cp deb-build/vietc.png "$APPDIR/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# AppStream metadata
|
||||||
|
if [ -f "deb-build/usr/share/metainfo/io.github.anomalyco.vietc.appdata.xml" ]; then
|
||||||
|
cp deb-build/usr/share/metainfo/io.github.anomalyco.vietc.appdata.xml "$APPDIR/usr/share/metainfo/"
|
||||||
|
else
|
||||||
|
cat > "$APPDIR/usr/share/metainfo/io.github.anomalyco.vietc.appdata.xml" << 'XML'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<component type="console-application">
|
||||||
|
<id>io.github.anomalyco.vietc</id>
|
||||||
|
<name>Viet+</name>
|
||||||
|
<summary>Vietnamese Input Method for Linux</summary>
|
||||||
|
<description>
|
||||||
|
<p>Zero-configuration Vietnamese input method engine supporting Telex and VNI input methods. Works natively on both X11 and Wayland via evdev uinput injection.</p>
|
||||||
|
</description>
|
||||||
|
<metadata_license>MIT</metadata_license>
|
||||||
|
<project_license>MIT</project_license>
|
||||||
|
<url type="homepage">https://github.com/anomalyco/vietc</url>
|
||||||
|
<provides><binary>vietc</binary></provides>
|
||||||
|
<categories><category>Utility</category></categories>
|
||||||
|
</component>
|
||||||
|
XML
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Config
|
||||||
|
echo "[4/5] Installing config..."
|
||||||
|
if [ -f "deb-build/etc/vietc/config.toml" ]; then
|
||||||
|
cp deb-build/etc/vietc/config.toml "$APPDIR/etc/vietc/"
|
||||||
|
else
|
||||||
|
sed 's/^grab = false/grab = true/' "$PROJECT_ROOT/vietc.toml" > "$APPDIR/etc/vietc/config.toml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Docs
|
||||||
|
if [ -f "deb-build/usr/share/doc/vietc/README.md" ]; then
|
||||||
|
cp deb-build/usr/share/doc/vietc/README.md "$APPDIR/usr/share/doc/vietc/"
|
||||||
|
else
|
||||||
|
cp "$PROJECT_ROOT/README.md" "$APPDIR/usr/share/doc/vietc/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Systemd service
|
||||||
|
if [ -f "deb-build/usr/lib/systemd/user/vietc.service" ]; then
|
||||||
|
cp deb-build/usr/lib/systemd/user/vietc.service "$APPDIR/usr/lib/systemd/user/"
|
||||||
|
else
|
||||||
|
cp "$PROJECT_ROOT/vietc.service" "$APPDIR/usr/lib/systemd/user/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Desktop file in AppDir root
|
||||||
|
if [ -f "deb-build/vietc.desktop" ]; then
|
||||||
|
cp deb-build/vietc.desktop "$APPDIR/"
|
||||||
|
else
|
||||||
|
cp "$APPDIR/usr/share/applications/vietc.desktop" "$APPDIR/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Icon — required by appimagetool (desktop file has Icon=vietc)
|
||||||
|
# Use SVG from deb build if available, otherwise generate a keyboard icon
|
||||||
|
if [ -f "deb-build/usr/share/icons/hicolor/256x256/apps/vietc.svg" ]; then
|
||||||
|
cp "deb-build/usr/share/icons/hicolor/256x256/apps/vietc.svg" "$APPDIR/vietc.svg"
|
||||||
|
elif [ -f "deb-build/usr/share/icons/hicolor/256x256/apps/vietc.png" ]; then
|
||||||
|
cp "deb-build/usr/share/icons/hicolor/256x256/apps/vietc.png" "$APPDIR/vietc.png"
|
||||||
|
else
|
||||||
|
# Generate a proper keyboard+VN icon as SVG
|
||||||
|
cat > "$APPDIR/vietc.svg" << 'SVGEOF'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||||
|
<rect x="20" y="60" width="216" height="140" rx="16" fill="#2d2d2d" stroke="#1a1a1a" stroke-width="4"/>
|
||||||
|
<rect x="36" y="76" width="184" height="108" rx="8" fill="#3d3d3d"/>
|
||||||
|
<rect x="48" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="78" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="108" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="138" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="168" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="198" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="54" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="84" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="114" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="144" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="174" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="60" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="90" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="120" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="150" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="180" y="140" width="42" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="72" y="166" width="112" height="16" rx="3" fill="#f0f0f0"/>
|
||||||
|
<circle cx="216" cy="48" r="28" fill="#da251d"/>
|
||||||
|
<text x="216" y="56" text-anchor="middle" fill="white" font-size="18" font-weight="bold" font-family="sans-serif">VN</text>
|
||||||
|
</svg>
|
||||||
|
SVGEOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Convert SVG to PNG for appimagetool (it prefers PNG for the root icon)
|
||||||
|
if [ -f "$APPDIR/vietc.svg" ] && ! [ -f "$APPDIR/vietc.png" ]; then
|
||||||
|
if command -v rsvg-convert &>/dev/null; then
|
||||||
|
rsvg-convert -w 256 -h 256 "$APPDIR/vietc.svg" -o "$APPDIR/vietc.png"
|
||||||
|
elif command -v inkscape &>/dev/null; then
|
||||||
|
inkscape -w 256 -h 256 "$APPDIR/vietc.svg" --export-filename="$APPDIR/vietc.png" 2>/dev/null
|
||||||
|
elif command -v convert &>/dev/null; then
|
||||||
|
convert -background none "$APPDIR/vietc.svg" -resize 256x256 "$APPDIR/vietc.png" 2>/dev/null
|
||||||
|
elif command -v python3 &>/dev/null; then
|
||||||
|
python3 -c "
|
||||||
|
import subprocess, sys
|
||||||
|
try:
|
||||||
|
subprocess.check_call(['rsvg-convert', '-w', '256', '-h', '256', '$APPDIR/vietc.svg', '-o', '$APPDIR/vietc.png'])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
" 2>/dev/null
|
||||||
|
fi
|
||||||
|
# If no converter, appimagetool can use SVG directly
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Also put icon in hicolor for system installs via AppImage
|
||||||
|
mkdir -p "$APPDIR/usr/share/icons/hicolor/256x256/apps"
|
||||||
|
[ -f "$APPDIR/vietc.svg" ] && cp "$APPDIR/vietc.svg" "$APPDIR/usr/share/icons/hicolor/256x256/apps/"
|
||||||
|
[ -f "$APPDIR/vietc.png" ] && cp "$APPDIR/vietc.png" "$APPDIR/usr/share/icons/hicolor/256x256/apps/"
|
||||||
|
|
||||||
|
# Create custom AppRun script
|
||||||
|
cat > "$APPDIR/AppRun" << 'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
HERE="$(dirname "$(readlink -f "${0}")")"
|
||||||
|
|
||||||
|
# Export our bin dir on PATH so child processes can find sibling binaries
|
||||||
|
export PATH="$HERE/usr/bin:$PATH"
|
||||||
|
|
||||||
|
# Build display env prefix for elevation commands.
|
||||||
|
# Capture from current user env (DISPLAY, XAUTHORITY, WAYLAND_DISPLAY, XDG_RUNTIME_DIR)
|
||||||
|
# so they are available inside the root daemon. Without this, xdotool/xclip/wtype
|
||||||
|
# fail silently because sudo/pkexec strip display env vars.
|
||||||
|
ENV_PREFIX="env"
|
||||||
|
[ -n "$DISPLAY" ] && ENV_PREFIX="$ENV_PREFIX DISPLAY=$DISPLAY"
|
||||||
|
[ -n "$XAUTHORITY" ] && ENV_PREFIX="$ENV_PREFIX XAUTHORITY=$XAUTHORITY"
|
||||||
|
[ -n "$WAYLAND_DISPLAY" ] && ENV_PREFIX="$ENV_PREFIX WAYLAND_DISPLAY=$WAYLAND_DISPLAY"
|
||||||
|
[ -n "$XDG_RUNTIME_DIR" ] && ENV_PREFIX="$ENV_PREFIX XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR"
|
||||||
|
|
||||||
|
# Ensure system library paths are available for dlopen (libX11, libXtst, etc.)
|
||||||
|
# AppImage runtime may override LD_LIBRARY_PATH; append system paths as fallback
|
||||||
|
SYSLIB_PATHS="/usr/lib/x86_64-linux-gnu:/usr/lib64:/usr/lib:/lib/x86_64-linux-gnu:/lib64:/lib"
|
||||||
|
if [ -n "$LD_LIBRARY_PATH" ]; then
|
||||||
|
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$SYSLIB_PATHS"
|
||||||
|
else
|
||||||
|
export LD_LIBRARY_PATH="$SYSLIB_PATHS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start daemon (kill old non-root one first if we have root)
|
||||||
|
# On X11 we can run without root (XGrabKeyboard + XTest injection needs no special permissions).
|
||||||
|
# On Wayland, evdev requires root (input group) or uinput.
|
||||||
|
NEED_ROOT=""
|
||||||
|
if [ -n "$WAYLAND_DISPLAY" ]; then
|
||||||
|
NEED_ROOT="yes"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$NEED_ROOT" ]; then
|
||||||
|
# X11: no root needed for capture, but uinputd needs root for injection
|
||||||
|
pkill -x vietc-uinputd 2>/dev/null
|
||||||
|
pkill -x vietc 2>/dev/null; sleep 0.3
|
||||||
|
mkdir -p "$HOME/.config/vietc" "$HOME/.vietc"
|
||||||
|
|
||||||
|
# Try to start the uinputd daemon (preferred injection path)
|
||||||
|
if command -v pkexec >/dev/null 2>&1; then
|
||||||
|
pkexec "$HERE/usr/bin/vietc-uinputd" >/dev/null 2>&1 &
|
||||||
|
UINPUTD_PID=$!
|
||||||
|
sleep 0.3
|
||||||
|
elif command -v sudo >/dev/null 2>&1; then
|
||||||
|
if sudo -n true 2>/dev/null; then
|
||||||
|
sudo "$HERE/usr/bin/vietc-uinputd" >/dev/null 2>&1 &
|
||||||
|
UINPUTD_PID=$!
|
||||||
|
sleep 0.3
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$HERE/usr/bin/vietc" >"$HOME/.config/vietc/vietc-daemon.log" 2>&1 &
|
||||||
|
DAEMON_PID=$!
|
||||||
|
echo "[vietc] Daemon started (PID=$DAEMON_PID), log: $HOME/.config/vietc/vietc-daemon.log"
|
||||||
|
else
|
||||||
|
# Fix Wayland env for root: sudo resets XDG_RUNTIME_DIR, breaking wtype/wl-copy.
|
||||||
|
if [ "$(id -u)" = "0" ] && [ -z "$XDG_RUNTIME_DIR" ] && [ -n "$SUDO_USER" ]; then
|
||||||
|
USER_UID=$(id -u "$SUDO_USER" 2>/dev/null || echo 1000)
|
||||||
|
export XDG_RUNTIME_DIR="/run/user/$USER_UID"
|
||||||
|
if [ -d "/run/user/$USER_UID" ] && ls "/run/user/$USER_UID/wayland-*" >/dev/null 2>&1; then
|
||||||
|
export WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-wayland-0}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v pkexec >/dev/null; then
|
||||||
|
pkill -x vietc 2>/dev/null; sleep 0.5
|
||||||
|
pkexec $ENV_PREFIX "$HERE/usr/bin/vietc" >/dev/null &
|
||||||
|
DAEMON_PID=$!
|
||||||
|
elif [ -n "$WAYLAND_DISPLAY" ]; then
|
||||||
|
password=""
|
||||||
|
if command -v kdialog >/dev/null; then
|
||||||
|
password=$(kdialog --password "Viet+ needs root privileges to grab the keyboard.") || password=""
|
||||||
|
elif command -v zenity >/dev/null; then
|
||||||
|
password=$(zenity --password --title="Viet+ needs root") || password=""
|
||||||
|
elif command -v ssh-askpass >/dev/null; then
|
||||||
|
password=$(ssh-askpass "Viet+ needs root privileges") || password=""
|
||||||
|
fi
|
||||||
|
if [ -n "$password" ]; then
|
||||||
|
pkill -x vietc 2>/dev/null; sleep 0.5
|
||||||
|
echo "$password" | sudo -S $ENV_PREFIX "$HERE/usr/bin/vietc" >/dev/null &
|
||||||
|
DAEMON_PID=$!
|
||||||
|
fi
|
||||||
|
elif command -v sudo >/dev/null; then
|
||||||
|
pkill -x vietc 2>/dev/null; sleep 0.5
|
||||||
|
sudo $ENV_PREFIX "$HERE/usr/bin/vietc" >/dev/null &
|
||||||
|
DAEMON_PID=$!
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$DAEMON_PID" ] && ! pgrep -x vietc >/dev/null; then
|
||||||
|
mkdir -p "$HOME/.config/vietc"
|
||||||
|
"$HERE/usr/bin/vietc" >"$HOME/.config/vietc/vietc-daemon.log" 2>&1 &
|
||||||
|
DAEMON_PID=$!
|
||||||
|
echo "[vietc] Daemon fallback started (PID=$DAEMON_PID), log: $HOME/.config/vietc/vietc-daemon.log"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Keep the AppImage alive with a tray or settings UI.
|
||||||
|
# Run as a child (not exec) so daemon cleanup works on exit.
|
||||||
|
cleanup_daemon() {
|
||||||
|
if [ -n "$DAEMON_PID" ]; then
|
||||||
|
kill "$DAEMON_PID" 2>/dev/null
|
||||||
|
wait "$DAEMON_PID" 2>/dev/null
|
||||||
|
fi
|
||||||
|
if [ -n "$UINPUTD_PID" ]; then
|
||||||
|
kill "$UINPUTD_PID" 2>/dev/null
|
||||||
|
wait "$UINPUTD_PID" 2>/dev/null
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup_daemon EXIT INT TERM
|
||||||
|
|
||||||
|
if [ -f "$HERE/usr/bin/vietc-tray" ]; then
|
||||||
|
"$HERE/usr/bin/vietc-tray" "$@"
|
||||||
|
else
|
||||||
|
echo "[vietc] Tray not available — daemon is running in background."
|
||||||
|
echo "[vietc] Press Ctrl+C or close this terminal to stop."
|
||||||
|
# Keep AppImage alive: wait for daemon to exit
|
||||||
|
wait $DAEMON_PID 2>/dev/null
|
||||||
|
fi
|
||||||
|
EOF
|
||||||
|
chmod +x "$APPDIR/AppRun"
|
||||||
|
|
||||||
|
echo "[5/5] AppDir ready at: $APPDIR"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Auto build if appimagetool exists
|
||||||
|
if [ -f "$SCRIPT_DIR/appimagetool" ]; then
|
||||||
|
echo "=== Running appimagetool FUSE build ==="
|
||||||
|
ARCH=x86_64 "$SCRIPT_DIR/appimagetool" --appimage-extract-and-run "$APPDIR" "$SCRIPT_DIR/Viet+-${VERSION}-x86_64.AppImage"
|
||||||
|
elif command -v appimagetool &>/dev/null; then
|
||||||
|
echo "=== Running system appimagetool ==="
|
||||||
|
ARCH=x86_64 appimagetool "$APPDIR" "$SCRIPT_DIR/Viet+-${VERSION}-x86_64.AppImage"
|
||||||
|
else
|
||||||
|
echo "To build AppImage:"
|
||||||
|
echo " appimagetool $APPDIR Viet+-${VERSION}-x86_64.AppImage"
|
||||||
|
fi
|
||||||
|
|
@ -3,9 +3,9 @@ Type=Application
|
||||||
Name=Viet+
|
Name=Viet+
|
||||||
GenericName=Vietnamese Input Method
|
GenericName=Vietnamese Input Method
|
||||||
Comment=Vietnamese Input Method for Linux — Zero underline, native Wayland/X11
|
Comment=Vietnamese Input Method for Linux — Zero underline, native Wayland/X11
|
||||||
Exec=vietc-tray
|
Exec=vietc
|
||||||
Icon=vietc
|
Icon=vietc
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Categories=Utility;TextTools;X-GNOME-Utilities;
|
Categories=Utility;
|
||||||
Keywords=vietnamese;input;ime;keyboard;viet;gõ tiếng việt;
|
Keywords=vietnamese;input;ime;keyboard;
|
||||||
StartupNotify=true
|
StartupNotify=false
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
#!/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"
|
|
||||||
|
|
@ -3,7 +3,7 @@ set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
VERSION="${1:-0.1.6}"
|
VERSION="${1:-0.1.0}"
|
||||||
PACKAGE="vietc_${VERSION}-1_amd64"
|
PACKAGE="vietc_${VERSION}-1_amd64"
|
||||||
STAGING="$SCRIPT_DIR/$PACKAGE"
|
STAGING="$SCRIPT_DIR/$PACKAGE"
|
||||||
|
|
||||||
|
|
@ -12,7 +12,7 @@ echo "=== Building Viet+ .deb package v${VERSION} ==="
|
||||||
# Build binaries (all features: x11 + wayland)
|
# Build binaries (all features: x11 + wayland)
|
||||||
echo "[1/5] Building binaries..."
|
echo "[1/5] Building binaries..."
|
||||||
cargo build --release --features "x11,wayland" --manifest-path "$PROJECT_ROOT/Cargo.toml"
|
cargo build --release --features "x11,wayland" --manifest-path "$PROJECT_ROOT/Cargo.toml"
|
||||||
(cd "$PROJECT_ROOT/ui" && cargo build --release)
|
(cd "$PROJECT_ROOT/ui" && export PKG_CONFIG_PATH="/tmp/dbus-dev/extracted/usr/lib/x86_64-linux-gnu/pkgconfig:${PKG_CONFIG_PATH:-}" && export RUSTFLAGS="-L /tmp/dbus-dev/lib" && cargo build --release) || echo " Warning: UI tray not built (libdbus-1-dev may be missing)"
|
||||||
echo " Done."
|
echo " Done."
|
||||||
|
|
||||||
# Clean and create staging
|
# Clean and create staging
|
||||||
|
|
@ -26,45 +26,43 @@ mkdir -p "$STAGING/usr/share/applications"
|
||||||
mkdir -p "$STAGING/usr/share/icons/hicolor/256x256/apps"
|
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/lib/udev/rules.d"
|
|
||||||
|
|
||||||
|
|
||||||
# Copy binaries
|
# Copy binaries
|
||||||
echo "[3/5] Installing binaries..."
|
echo "[3/5] Installing binaries..."
|
||||||
cp "$PROJECT_ROOT/target/release/vietc" "$STAGING/usr/bin/vietc-daemon"
|
cp "$PROJECT_ROOT/target/release/vietc" "$STAGING/usr/bin/"
|
||||||
cp "$PROJECT_ROOT/target/release/vietc-cli" "$STAGING/usr/bin/"
|
cp "$PROJECT_ROOT/target/release/vietc-cli" "$STAGING/usr/bin/"
|
||||||
cp "$PROJECT_ROOT/target/release/vietc-uinputd" "$STAGING/usr/bin/"
|
[ -f "$PROJECT_ROOT/ui/target/release/vietc-tray" ] && cp "$PROJECT_ROOT/ui/target/release/vietc-tray" "$STAGING/usr/bin/"
|
||||||
cp "$PROJECT_ROOT/ui/target/release/vietc-tray" "$STAGING/usr/bin/"
|
|
||||||
|
|
||||||
# Compile and bundle vietc-xrecord (C helper for X11 XRecord keyboard capture)
|
|
||||||
gcc -O2 -o "$STAGING/usr/bin/vietc-xrecord" "$SCRIPT_DIR/vietc-xrecord.c" -lX11 -lXtst
|
|
||||||
|
|
||||||
# Icons (main app icon + tray status icons)
|
|
||||||
cp "$PROJECT_ROOT/packaging/icons/vietc.svg" "$STAGING/usr/share/icons/hicolor/256x256/apps/"
|
|
||||||
cp "$PROJECT_ROOT/packaging/icons/vietc-vn.svg" "$STAGING/usr/share/icons/hicolor/256x256/apps/"
|
|
||||||
cp "$PROJECT_ROOT/packaging/icons/vietc-en.svg" "$STAGING/usr/share/icons/hicolor/256x256/apps/"
|
|
||||||
|
|
||||||
# Desktop file
|
# Desktop file
|
||||||
cp "$SCRIPT_DIR/vietc.desktop" "$STAGING/usr/share/applications/"
|
cp "$PROJECT_ROOT/packaging/appimage/vietc.desktop" "$STAGING/usr/share/applications/"
|
||||||
|
|
||||||
# Udev rules
|
# Icon (SVG from AppImage build script)
|
||||||
cp "$PROJECT_ROOT/packaging/99-vietc.rules" "$STAGING/lib/udev/rules.d/"
|
cat > "$STAGING/usr/share/icons/hicolor/256x256/apps/vietc.svg" << 'SVGEOF'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||||
# XDG autostart — launches tray on every login for all users
|
<rect x="20" y="60" width="216" height="140" rx="16" fill="#2d2d2d" stroke="#1a1a1a" stroke-width="4"/>
|
||||||
cat > "$STAGING/etc/xdg/autostart/vietc-tray.desktop" << 'AUTOSTART'
|
<rect x="36" y="76" width="184" height="108" rx="8" fill="#3d3d3d"/>
|
||||||
[Desktop Entry]
|
<rect x="48" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
Type=Application
|
<rect x="78" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
Name=Viet+ Tray
|
<rect x="108" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
Comment=Vietnamese Input Method Tray
|
<rect x="138" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
Exec=vietc-tray
|
<rect x="168" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
Icon=vietc
|
<rect x="198" y="88" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
Terminal=false
|
<rect x="54" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
Categories=Utility;
|
<rect x="84" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
StartupNotify=false
|
<rect x="114" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
NoDisplay=true
|
<rect x="144" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
AUTOSTART
|
<rect x="174" y="114" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="60" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="90" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="120" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="150" y="140" width="24" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="180" y="140" width="42" height="20" rx="3" fill="#f0f0f0"/>
|
||||||
|
<rect x="72" y="166" width="112" height="16" rx="3" fill="#f0f0f0"/>
|
||||||
|
<circle cx="216" cy="48" r="28" fill="#da251d"/>
|
||||||
|
<text x="216" y="56" text-anchor="middle" fill="white" font-size="18" font-weight="bold" font-family="sans-serif">VN</text>
|
||||||
|
</svg>
|
||||||
|
SVGEOF
|
||||||
|
|
||||||
# Documentation
|
# Documentation
|
||||||
cp "$PROJECT_ROOT/README.md" "$STAGING/usr/share/doc/vietc/"
|
cp "$PROJECT_ROOT/README.md" "$STAGING/usr/share/doc/vietc/"
|
||||||
|
|
@ -73,21 +71,8 @@ cp "$PROJECT_ROOT/LICENSE" "$STAGING/usr/share/doc/vietc/"
|
||||||
# Config
|
# Config
|
||||||
cp "$PROJECT_ROOT/vietc.toml" "$STAGING/etc/vietc/config.toml"
|
cp "$PROJECT_ROOT/vietc.toml" "$STAGING/etc/vietc/config.toml"
|
||||||
|
|
||||||
# Systemd user service — tray spawns the daemon internally
|
# Systemd user service
|
||||||
cat > "$STAGING/usr/lib/systemd/user/vietc.service" << 'SERVICE'
|
cp "$PROJECT_ROOT/vietc.service" "$STAGING/usr/lib/systemd/user/"
|
||||||
[Unit]
|
|
||||||
Description=Viet+ Vietnamese IME Tray
|
|
||||||
PartOf=graphical-session.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
ExecStart=/usr/bin/vietc-tray
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=5
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
SERVICE
|
|
||||||
|
|
||||||
# AppStream metadata
|
# AppStream metadata
|
||||||
cat > "$STAGING/usr/share/metainfo/io.github.anomalyco.vietc.appdata.xml" << 'XML'
|
cat > "$STAGING/usr/share/metainfo/io.github.anomalyco.vietc.appdata.xml" << 'XML'
|
||||||
|
|
@ -128,7 +113,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, wl-clipboard
|
Recommends: libwayland-client0 (>= 1.20), libx11-6, libxtst6, xclip
|
||||||
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
|
||||||
|
|
@ -144,81 +129,11 @@ echo "/etc/vietc/config.toml" > "$STAGING/DEBIAN/conffiles"
|
||||||
cat > "$STAGING/DEBIAN/postinst" << 'POSTINST'
|
cat > "$STAGING/DEBIAN/postinst" << 'POSTINST'
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
show_popup() {
|
|
||||||
local user="$1" msg="$2"
|
|
||||||
local display="${DISPLAY:-:0}"
|
|
||||||
local xauth=""
|
|
||||||
if [ -n "$user" ]; then
|
|
||||||
local home
|
|
||||||
home="$(getent passwd "$user" 2>/dev/null | cut -d: -f6 || true)"
|
|
||||||
if [ -n "$home" ]; then
|
|
||||||
xauth="$home/.Xauthority"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
# Try zenity (modal dialog)
|
|
||||||
if command -v zenity >/dev/null 2>&1 && [ -n "$user" ]; then
|
|
||||||
su "$user" -c "DISPLAY='$display' XAUTHORITY='$xauth' \
|
|
||||||
zenity --info --title='Viet+' --text='$msg' --width=400" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
# Also try notify-send (desktop notification)
|
|
||||||
if command -v notify-send >/dev/null 2>&1 && [ -n "$user" ]; then
|
|
||||||
su "$user" -c "DISPLAY='$display' XAUTHORITY='$xauth' \
|
|
||||||
notify-send 'Viet+' '$msg' -t 10000 -i vietc" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup_old_install() {
|
|
||||||
# Remove old binaries from /usr/local/bin/ (shadowed the new /usr/bin/ ones)
|
|
||||||
rm -f /usr/local/bin/vietc-tray /usr/local/bin/vietc /usr/local/bin/vietc-daemon \
|
|
||||||
/usr/local/bin/vietc-cli /usr/local/bin/vietc-uinputd /usr/local/bin/vietc-xrecord 2>/dev/null || true
|
|
||||||
}
|
|
||||||
|
|
||||||
case "$1" in
|
case "$1" in
|
||||||
configure)
|
configure)
|
||||||
# Kill old running daemon/tray so new binaries take effect
|
|
||||||
pkill -x vietc-tray 2>/dev/null || true
|
|
||||||
pkill -x vietc-daemon 2>/dev/null || true
|
|
||||||
pkill -x vietc 2>/dev/null || true
|
|
||||||
|
|
||||||
# Remove old /usr/local/bin/ binaries that shadowed the new ones
|
|
||||||
cleanup_old_install
|
|
||||||
|
|
||||||
# Reload systemd
|
|
||||||
if command -v systemctl >/dev/null 2>&1; then
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
systemctl --global daemon-reload >/dev/null 2>&1 || true
|
systemctl --system daemon-reload >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Add installing user to input group (needed for /dev/uinput access)
|
|
||||||
INSTALLING_USER="${SUDO_USER:-${USER:-}}"
|
|
||||||
if [ -n "$INSTALLING_USER" ] && [ "$INSTALLING_USER" != "root" ]; then
|
|
||||||
if ! groups "$INSTALLING_USER" 2>/dev/null | grep -qw input; then
|
|
||||||
adduser "$INSTALLING_USER" input 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
# Remove stale user config from previous installs
|
|
||||||
USER_HOME="$(getent passwd "$INSTALLING_USER" 2>/dev/null | cut -d: -f6 || true)"
|
|
||||||
if [ -n "$USER_HOME" ]; then
|
|
||||||
rm -f "$USER_HOME/.config/vietc/config.toml" 2>/dev/null || true
|
|
||||||
rm -f "$USER_HOME/.config/vietc/overrides.toml" 2>/dev/null || true
|
|
||||||
rm -f "$USER_HOME/.config/vietc/.first-launch-done" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Show popup
|
|
||||||
show_popup "$INSTALLING_USER" \
|
|
||||||
"Viet+ installed! Please LOG OUT and LOG BACK IN to start typing Vietnamese."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Update icon cache so the app icon appears in the menu
|
|
||||||
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
|
||||||
gtk-update-icon-cache -f /usr/share/icons/hicolor/ >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 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
|
||||||
|
|
@ -230,7 +145,7 @@ set -e
|
||||||
case "$1" in
|
case "$1" in
|
||||||
remove|upgrade|deconfigure)
|
remove|upgrade|deconfigure)
|
||||||
if command -v systemctl >/dev/null 2>&1; then
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
systemctl --global daemon-reload >/dev/null 2>&1 || true
|
systemctl --system daemon-reload >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
|
||||||
<rect x="8" y="8" width="112" height="112" rx="24" fill="#4b5563"/>
|
|
||||||
<text x="64" y="96" text-anchor="middle" fill="#ffffff" font-size="48" font-weight="bold" font-family="system-ui, sans-serif">EN</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 278 B |
|
|
@ -1,4 +0,0 @@
|
||||||
<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="#e02424"/>
|
|
||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 278 B |
|
|
@ -1,32 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="kb-bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" stop-color="#3a3a3a"/>
|
|
||||||
<stop offset="100%" stop-color="#1a1a1a"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="vn-badge" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" stop-color="#e74c3c"/>
|
|
||||||
<stop offset="100%" stop-color="#c0392b"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<rect x="16" y="56" width="224" height="156" rx="18" fill="url(#kb-bg)" stroke="#555" stroke-width="3"/>
|
|
||||||
<rect x="32" y="72" width="192" height="124" rx="10" fill="#2a2a2a"/>
|
|
||||||
<rect x="44" y="84" width="26" height="22" rx="4" fill="#e0e0e0"/>
|
|
||||||
<rect x="76" y="84" width="26" height="22" rx="4" fill="#e0e0e0"/>
|
|
||||||
<rect x="108" y="84" width="26" height="22" rx="4" fill="#e0e0e0"/>
|
|
||||||
<rect x="140" y="84" width="26" height="22" rx="4" fill="#e0e0e0"/>
|
|
||||||
<rect x="172" y="84" width="26" height="22" rx="4" fill="#e0e0e0"/>
|
|
||||||
<rect x="50" y="112" width="26" height="22" rx="4" fill="#e0e0e0"/>
|
|
||||||
<rect x="82" y="112" width="26" height="22" rx="4" fill="#e0e0e0"/>
|
|
||||||
<rect x="114" y="112" width="26" height="22" rx="4" fill="#e0e0e0"/>
|
|
||||||
<rect x="146" y="112" width="26" height="22" rx="4" fill="#e0e0e0"/>
|
|
||||||
<rect x="178" y="112" width="26" height="22" rx="4" fill="#e0e0e0"/>
|
|
||||||
<rect x="56" y="140" width="26" height="22" rx="4" fill="#e0e0e0"/>
|
|
||||||
<rect x="88" y="140" width="26" height="22" rx="4" fill="#e0e0e0"/>
|
|
||||||
<rect x="120" y="140" width="26" height="22" rx="4" fill="#e0e0e0"/>
|
|
||||||
<rect x="152" y="140" width="26" height="22" rx="4" fill="#e0e0e0"/>
|
|
||||||
<rect x="184" y="140" width="30" height="22" rx="4" fill="#e0e0e0"/>
|
|
||||||
<rect x="68" y="168" width="120" height="16" rx="4" fill="#e0e0e0"/>
|
|
||||||
<circle cx="224" cy="44" r="30" fill="url(#vn-badge)"/>
|
|
||||||
<text x="224" y="52" text-anchor="middle" fill="#fff" font-size="20" font-weight="bold" font-family="sans-serif">VN</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.9 KiB |
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "vietc-protocol"
|
name = "vietc-protocol"
|
||||||
version = "0.1.7"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Viet+ keystroke injection backends (X11/Wayland)"
|
description = "Viet+ keystroke injection backends (X11/Wayland)"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
pub mod inject;
|
pub mod inject;
|
||||||
pub mod monitor;
|
pub mod monitor;
|
||||||
pub mod uinput_monitor;
|
pub mod uinput_monitor;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
use crate::inject::KeyEvent;
|
use crate::inject::KeyEvent;
|
||||||
|
|
||||||
pub trait KeyMonitor {
|
pub trait KeyMonitor {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
use std::io::{BufRead, BufReader, Write};
|
use std::io::{BufRead, BufReader, Write};
|
||||||
use std::os::unix::net::UnixStream;
|
use std::os::unix::net::UnixStream;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,8 @@
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
use std::fs::{File, OpenOptions};
|
use std::fs::{File, OpenOptions};
|
||||||
use std::os::unix::io::AsRawFd;
|
use std::os::unix::io::AsRawFd;
|
||||||
use std::sync::{Arc, Condvar, Mutex};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use super::inject::{InjectResult, KeyInjector};
|
use super::inject::{InjectResult, KeyInjector};
|
||||||
|
|
||||||
/// How long to wait after the last Unicode paste before restoring the user's
|
|
||||||
/// real clipboard. Each paste pushes this deadline back, so a burst of typing
|
|
||||||
/// only triggers a single restore once the user pauses — the user's clipboard
|
|
||||||
/// is never pasted into the text while the target app might still be reading
|
|
||||||
/// our freshly pasted word.
|
|
||||||
const RESTORE_DEBOUNCE: Duration = Duration::from_millis(600);
|
|
||||||
|
|
||||||
const UINPUT_MAX_NAME_SIZE: usize = 80;
|
const UINPUT_MAX_NAME_SIZE: usize = 80;
|
||||||
const UI_SET_EVBIT: u64 = 0x40045564;
|
const UI_SET_EVBIT: u64 = 0x40045564;
|
||||||
const UI_SET_KEYBIT: u64 = 0x40045565;
|
const UI_SET_KEYBIT: u64 = 0x40045565;
|
||||||
|
|
@ -26,56 +16,15 @@ const EV_KEY: u16 = 0x01;
|
||||||
const EV_ABS: u16 = 0x03;
|
const EV_ABS: u16 = 0x03;
|
||||||
const KEY_MAX: u32 = 0x1ff;
|
const KEY_MAX: u32 = 0x1ff;
|
||||||
|
|
||||||
/// Shared clipboard bookkeeping between the injection path and the background
|
|
||||||
/// restorer thread.
|
|
||||||
struct ClipInner {
|
|
||||||
/// The user's real clipboard contents, saved before we overwrite the
|
|
||||||
/// clipboard to inject Unicode text, so we can restore it afterwards.
|
|
||||||
saved_clipboard: Option<String>,
|
|
||||||
/// The last text we wrote to the clipboard ourselves (an injected word or
|
|
||||||
/// the restored user content). Used to tell our own writes apart from text
|
|
||||||
/// the user copied with Ctrl+C.
|
|
||||||
last_injected: Option<String>,
|
|
||||||
/// Whether we have already snapshot the user's clipboard this session.
|
|
||||||
/// After the first snapshot, subsequent pastes skip the read_clipboard
|
|
||||||
/// call (saving ~10-50ms per paste).
|
|
||||||
clipboard_saved: bool,
|
|
||||||
/// When set, the restorer thread should rewrite the user's clipboard at
|
|
||||||
/// this instant. `None` means no restore is pending.
|
|
||||||
restore_due: Option<Instant>,
|
|
||||||
/// Set on shutdown so the restorer thread can exit.
|
|
||||||
shutdown: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ClipState {
|
|
||||||
inner: Mutex<ClipInner>,
|
|
||||||
cv: Condvar,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct UinputInjector {
|
pub struct UinputInjector {
|
||||||
file: File,
|
file: File,
|
||||||
clip: Arc<ClipState>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe impl Send for UinputInjector {}
|
unsafe impl Send for UinputInjector {}
|
||||||
unsafe impl Sync for UinputInjector {}
|
unsafe impl Sync for UinputInjector {}
|
||||||
|
|
||||||
impl UinputInjector {
|
impl UinputInjector {
|
||||||
fn send_enter(&self) {
|
|
||||||
self.send_uinput_event(EV_KEY, 28, 1);
|
|
||||||
self.send_uinput_event(0, 0, 0);
|
|
||||||
std::thread::sleep(std::time::Duration::from_micros(100));
|
|
||||||
|
|
||||||
self.send_uinput_event(EV_KEY, 28, 0);
|
|
||||||
self.send_uinput_event(0, 0, 0);
|
|
||||||
std::thread::sleep(std::time::Duration::from_micros(100));
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
||||||
|
|
@ -114,22 +63,7 @@ impl UinputInjector {
|
||||||
// Small delay for device to be ready
|
// Small delay for device to be ready
|
||||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
|
||||||
let clip = Arc::new(ClipState {
|
Ok(Self { file })
|
||||||
inner: Mutex::new(ClipInner {
|
|
||||||
saved_clipboard: None,
|
|
||||||
last_injected: None,
|
|
||||||
clipboard_saved: false,
|
|
||||||
restore_due: None,
|
|
||||||
shutdown: false,
|
|
||||||
}),
|
|
||||||
cv: Condvar::new(),
|
|
||||||
});
|
|
||||||
{
|
|
||||||
let clip = Arc::clone(&clip);
|
|
||||||
std::thread::spawn(move || run_restorer(clip));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Self { file, clip })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_uinput_event(&self, type_: u16, code: u16, value: i32) {
|
fn send_uinput_event(&self, type_: u16, code: u16, value: i32) {
|
||||||
|
|
@ -156,36 +90,36 @@ impl UinputInjector {
|
||||||
|
|
||||||
fn send_key_stroke(&self, keycode: u16, shift: bool) {
|
fn send_key_stroke(&self, keycode: u16, shift: bool) {
|
||||||
if shift {
|
if shift {
|
||||||
self.send_uinput_event(EV_KEY, 42, 1);
|
self.send_uinput_event(EV_KEY, 42, 1); // Shift press
|
||||||
self.send_uinput_event(0, 0, 0);
|
self.send_uinput_event(0, 0, 0); // SYN
|
||||||
std::thread::sleep(std::time::Duration::from_micros(100));
|
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
self.send_uinput_event(EV_KEY, keycode, 1);
|
self.send_uinput_event(EV_KEY, keycode, 1); // Key press
|
||||||
self.send_uinput_event(0, 0, 0);
|
self.send_uinput_event(0, 0, 0); // SYN
|
||||||
std::thread::sleep(std::time::Duration::from_micros(100));
|
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||||
|
|
||||||
self.send_uinput_event(EV_KEY, keycode, 0);
|
self.send_uinput_event(EV_KEY, keycode, 0); // Key release
|
||||||
self.send_uinput_event(0, 0, 0);
|
self.send_uinput_event(0, 0, 0); // SYN
|
||||||
std::thread::sleep(std::time::Duration::from_micros(100));
|
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||||
|
|
||||||
if shift {
|
if shift {
|
||||||
self.send_uinput_event(EV_KEY, 42, 0);
|
self.send_uinput_event(EV_KEY, 42, 0); // Shift release
|
||||||
self.send_uinput_event(0, 0, 0);
|
self.send_uinput_event(0, 0, 0); // SYN
|
||||||
std::thread::sleep(std::time::Duration::from_micros(100));
|
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KeyInjector for UinputInjector {
|
impl KeyInjector for UinputInjector {
|
||||||
fn send_backspace(&self) -> InjectResult {
|
fn send_backspace(&self) -> InjectResult {
|
||||||
self.send_uinput_event(EV_KEY, 14, 1);
|
self.send_uinput_event(EV_KEY, 14, 1); // KEY_BACKSPACE press
|
||||||
self.send_uinput_event(0, 0, 0);
|
self.send_uinput_event(0, 0, 0); // SYN
|
||||||
std::thread::sleep(std::time::Duration::from_micros(100));
|
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||||
|
|
||||||
self.send_uinput_event(EV_KEY, 14, 0);
|
self.send_uinput_event(EV_KEY, 14, 0); // KEY_BACKSPACE release
|
||||||
self.send_uinput_event(0, 0, 0);
|
self.send_uinput_event(0, 0, 0); // SYN
|
||||||
std::thread::sleep(std::time::Duration::from_micros(100));
|
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||||
|
|
||||||
InjectResult::Success
|
InjectResult::Success
|
||||||
}
|
}
|
||||||
|
|
@ -193,6 +127,7 @@ impl KeyInjector for UinputInjector {
|
||||||
fn send_key_event(&self, keycode: u16, value: i32) -> InjectResult {
|
fn send_key_event(&self, keycode: u16, value: i32) -> InjectResult {
|
||||||
self.send_uinput_event(EV_KEY, keycode, value);
|
self.send_uinput_event(EV_KEY, keycode, value);
|
||||||
self.send_uinput_event(0, 0, 0);
|
self.send_uinput_event(0, 0, 0);
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||||
InjectResult::Success
|
InjectResult::Success
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -214,25 +149,41 @@ impl KeyInjector for UinputInjector {
|
||||||
fn send_string(&self, s: &str) -> InjectResult {
|
fn send_string(&self, s: &str) -> InjectResult {
|
||||||
// ASCII characters: inject directly via uinput keycodes
|
// ASCII characters: inject directly via uinput keycodes
|
||||||
let is_ascii = s.chars().all(|c| char_to_linux_keycode(c).is_some());
|
let is_ascii = s.chars().all(|c| char_to_linux_keycode(c).is_some());
|
||||||
|
eprintln!(
|
||||||
|
"[vietc] send_string: len={}, is_ascii={}",
|
||||||
|
s.len(),
|
||||||
|
is_ascii
|
||||||
|
);
|
||||||
|
|
||||||
if is_ascii {
|
if is_ascii {
|
||||||
|
eprintln!(
|
||||||
|
"[vietc] send_string: ASCII '{}' via uinput",
|
||||||
|
s.escape_default()
|
||||||
|
);
|
||||||
for ch in s.chars() {
|
for ch in s.chars() {
|
||||||
self.send_char(ch);
|
self.send_char(ch);
|
||||||
}
|
}
|
||||||
return InjectResult::Success;
|
return InjectResult::Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unicode text: try xdotool type first (works on X11, doesn't touch clipboard)
|
// Unicode text: single clipboard copy + paste (reliable method)
|
||||||
if self.type_via_xdotool(s) {
|
eprintln!(
|
||||||
|
"[vietc] send_string: Unicode '{}' - using clipboard",
|
||||||
|
s.escape_default()
|
||||||
|
);
|
||||||
|
let copied = self.copy_to_clipboard(s);
|
||||||
|
if copied {
|
||||||
|
eprintln!("[vietc] send_string: clipboard OK, sending Ctrl+V");
|
||||||
|
self.send_ctrl_v();
|
||||||
|
eprintln!("[vietc] send_string complete (clipboard)");
|
||||||
return InjectResult::Success;
|
return InjectResult::Success;
|
||||||
}
|
} else {
|
||||||
|
|
||||||
// Fallback: clipboard copy + paste
|
|
||||||
if !self.paste_via_clipboard(s) {
|
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[vietc] send_string failed for '{}' (clipboard unavailable)",
|
"[vietc] send_string failed for '{}' (clipboard unavailable)",
|
||||||
s.escape_default()
|
s.escape_default()
|
||||||
);
|
);
|
||||||
|
// Last resort: try paste_string (will try clipboard internally)
|
||||||
|
self.paste_string(s);
|
||||||
}
|
}
|
||||||
InjectResult::Success
|
InjectResult::Success
|
||||||
}
|
}
|
||||||
|
|
@ -365,122 +316,118 @@ impl UinputInjector {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run an external command as the original user if we're root.
|
||||||
|
/// Uses native OS setuid/setgid to avoid slow PAM/logging/sudo startup overhead.
|
||||||
|
fn run_as_user(program: &str, args: &[&str]) -> std::process::Output {
|
||||||
|
let mut cmd = Self::user_cmd(program);
|
||||||
|
cmd.args(args);
|
||||||
|
match cmd.output() {
|
||||||
|
Ok(output) => output,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[vietc] Failed to run {}: {}", program, e);
|
||||||
|
std::process::Output {
|
||||||
|
status: std::process::ExitStatus::default(),
|
||||||
|
stdout: vec![],
|
||||||
|
stderr: format!("{}\n", e).into_bytes(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Send backspaces and text through a single injection channel to avoid
|
/// Send backspaces and text through a single injection channel to avoid
|
||||||
/// reordering between input methods. Backspaces always go through uinput
|
/// reordering between input methods. Backspaces always go through uinput
|
||||||
/// (kernel device, no display server dependency). Text is typed via the
|
/// (kernel device, no display server dependency). Text is typed via the
|
||||||
/// best available method: ydotool (uinput) for ASCII, xdotool (X11) or
|
/// best available method: ydotool (uinput) for ASCII, xdotool (X11) or
|
||||||
/// clipboard for Unicode.
|
/// clipboard for Unicode.
|
||||||
fn inject_replacement_atomic(&self, backspaces: usize, text: &str) -> InjectResult {
|
fn inject_replacement_atomic(&self, backspaces: usize, text: &str) -> InjectResult {
|
||||||
let t0 = std::time::Instant::now();
|
// If all ASCII, send keycodes directly — fast and reliable
|
||||||
// If all ASCII, send keycodes directly
|
if text.chars().all(|c| char_to_linux_keycode(c).is_some()) {
|
||||||
if text.chars().all(|c| char_to_linux_keycode(c).is_some() || c == '\n') {
|
|
||||||
if backspaces > 0 {
|
if backspaces > 0 {
|
||||||
for _ in 0..backspaces { let _ = self.send_backspace(); }
|
for _ in 0..backspaces {
|
||||||
|
let _ = self.send_backspace();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for ch in text.chars() {
|
for ch in text.chars() {
|
||||||
if ch == '\n' { self.send_enter(); }
|
let _ = self.send_char(ch);
|
||||||
else { let _ = self.send_char(ch); }
|
|
||||||
}
|
}
|
||||||
eprintln!("[vietc] inject: ASCII backspaces={} text='{}' took {}ms", backspaces, text.escape_default(), (std::time::Instant::now() - t0).as_millis());
|
|
||||||
return InjectResult::Success;
|
return InjectResult::Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unicode: backspaces via uinput, then delegate to send_string()
|
// Unicode text: split into Vietnamese portion (clipboard paste) and
|
||||||
if backspaces > 0 {
|
// trailing ASCII whitespace/punctuation (uinput). Clipboard paste
|
||||||
for _ in 0..backspaces { let _ = self.send_backspace(); }
|
// often trims trailing whitespace, so we send it separately.
|
||||||
|
let mut split = text.len();
|
||||||
|
for (i, c) in text.char_indices().rev() {
|
||||||
|
if c.is_ascii() && (c.is_whitespace() || matches!(c, '.' | ',' | '!' | '?' | ';' | ':')) {
|
||||||
|
split = i;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
self.send_string(text);
|
}
|
||||||
|
let (vn_text, ascii_tail) = text.split_at(split);
|
||||||
|
|
||||||
|
// Backspaces via uinput
|
||||||
|
if backspaces > 0 {
|
||||||
|
for _ in 0..backspaces {
|
||||||
|
let _ = self.send_backspace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clipboard paste for Vietnamese text
|
||||||
|
if !vn_text.is_empty() {
|
||||||
|
if self.copy_to_clipboard(vn_text) {
|
||||||
|
self.send_ctrl_v_x11();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trailing ASCII via uinput (spaces, punctuation)
|
||||||
|
for ch in ascii_tail.chars() {
|
||||||
|
if let Some(kc) = char_to_linux_keycode(ch) {
|
||||||
|
self.send_key_stroke(kc, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
InjectResult::Success
|
InjectResult::Success
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Type Unicode text via xdotool (X11 only). Returns true on success.
|
/// Copy text to clipboard and paste via Ctrl+V through our uinput device.
|
||||||
/// More reliable than clipboard paste — doesn't overwrite the user's clipboard
|
/// Only used as a last resort if Wayland/X11 direct typing tools are unavailable.
|
||||||
/// and works with XTest directly for proper Unicode key injection.
|
/// Tries xdotool first (X11/XWayland), then clipboard fallback.
|
||||||
fn type_via_xdotool(&self, text: &str) -> bool {
|
fn paste_string(&self, s: &str) {
|
||||||
let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
|
let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
|
||||||
if is_wayland {
|
if is_wayland {
|
||||||
return false;
|
eprintln!("[vietc] paste_string: trying wtype...");
|
||||||
|
let output = Self::run_as_user("wtype", &["--", s]);
|
||||||
|
if output.status.success() {
|
||||||
|
eprintln!("[vietc] paste_string: wtype success");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
let mut cmd = Self::user_cmd("xdotool");
|
eprintln!("[vietc] paste_string: wtype failed, trying clipboard...");
|
||||||
cmd.args(["type", "--clearmodifiers", text]);
|
|
||||||
cmd.stdout(std::process::Stdio::null());
|
|
||||||
cmd.stderr(std::process::Stdio::null());
|
|
||||||
match cmd.output() {
|
|
||||||
Ok(output) => output.status.success(),
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("[vietc] xdotool type failed: {}", e);
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read the user's current clipboard contents (wl-paste on Wayland, xclip
|
|
||||||
/// on X11). Returns None if no clipboard tool is available or it is empty.
|
|
||||||
fn read_clipboard() -> Option<String> {
|
|
||||||
let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
|
|
||||||
let (prog, args): (&str, &[&str]) = if is_wayland {
|
|
||||||
("wl-paste", &["-n"])
|
|
||||||
} else {
|
} else {
|
||||||
("xclip", &["-selection", "clipboard", "-o"])
|
// Try xdotool first (works on X11 and XWayland for UTF-8)
|
||||||
};
|
eprintln!("[vietc] paste_string: trying xdotool...");
|
||||||
let mut cmd = Self::user_cmd(prog);
|
let output = Self::run_as_user("xdotool", &["type", s]);
|
||||||
cmd.args(args);
|
if output.status.success() {
|
||||||
let output = cmd.output().ok()?;
|
eprintln!("[vietc] paste_string: xdotool success");
|
||||||
if !output.status.success() {
|
// Record pasted text for future delete/backspace operations
|
||||||
return None;
|
let _ = Self::run_as_user("vietc", &["update-pasted", "-text", s]);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
Some(String::from_utf8_lossy(&output.stdout).into_owned())
|
eprintln!("[vietc] paste_string: xdotool failed, trying clipboard...");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inject Unicode `text` by placing it on the clipboard and sending Ctrl+V,
|
// Clipboard fallback: copy + paste via our uinput device
|
||||||
/// while preserving the user's own clipboard contents. Without this, every
|
let copied = self.copy_to_clipboard(s);
|
||||||
/// Vietnamese word the user types would overwrite whatever they had copied
|
if copied {
|
||||||
/// with Ctrl+C, so a subsequent Ctrl+V would paste the wrong thing.
|
eprintln!("[vietc] paste_string: clipboard OK, sending Ctrl+V");
|
||||||
///
|
|
||||||
/// Returns whether the text was successfully copied to the clipboard.
|
|
||||||
fn paste_via_clipboard(&self, text: &str) -> bool {
|
|
||||||
let t_total = std::time::Instant::now();
|
|
||||||
// Critical section: snapshot the clipboard, decide what to preserve,
|
|
||||||
// cancel any pending restore so the restorer cannot fire while we
|
|
||||||
// paste, and put our word on the clipboard. The read and write happen
|
|
||||||
// under the lock so they can never interleave with the restorer.
|
|
||||||
{
|
|
||||||
let mut st = self.clip.inner.lock().unwrap();
|
|
||||||
if !st.clipboard_saved {
|
|
||||||
let current = Self::read_clipboard();
|
|
||||||
let is_our_write =
|
|
||||||
matches!((¤t, &st.last_injected), (Some(c), Some(l)) if c == l);
|
|
||||||
if !is_our_write {
|
|
||||||
st.saved_clipboard = current;
|
|
||||||
}
|
|
||||||
st.clipboard_saved = true;
|
|
||||||
}
|
|
||||||
st.restore_due = None;
|
|
||||||
let copied = Self::copy_to_clipboard(text);
|
|
||||||
if !copied {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
st.last_injected = Some(text.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Give the selection owner a moment to take ownership before pasting.
|
|
||||||
std::thread::sleep(std::time::Duration::from_micros(200));
|
|
||||||
|
|
||||||
self.send_ctrl_v();
|
self.send_ctrl_v();
|
||||||
let elapsed = (std::time::Instant::now() - t_total).as_millis();
|
return;
|
||||||
if elapsed > 20 {
|
|
||||||
eprintln!("[vietc] paste took {}ms", elapsed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule a debounced restore. While the user keeps typing this gets
|
eprintln!(
|
||||||
// pushed back, so the user's clipboard is only restored once typing
|
"[vietc] WARNING: No injection method works for '{}'!",
|
||||||
// settles — never overwriting our freshly pasted word mid-stream.
|
s.escape_default()
|
||||||
{
|
);
|
||||||
let mut st = self.clip.inner.lock().unwrap();
|
|
||||||
st.restore_due = Some(Instant::now() + RESTORE_DEBOUNCE);
|
|
||||||
}
|
|
||||||
self.clip.cv.notify_all();
|
|
||||||
true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a command to run as the original user with display environment.
|
/// Build a command to run as the original user with display environment.
|
||||||
|
|
@ -518,117 +465,124 @@ impl UinputInjector {
|
||||||
std::process::Command::new(program)
|
std::process::Command::new(program)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copy text to clipboard using xclip (X11) or wl-copy (Wayland).
|
/// Copy text to clipboard using wl-copy (Wayland) or xclip (X11).
|
||||||
/// NOTE: direct X11 API is avoided here because it can interact badly with
|
fn copy_to_clipboard(&self, s: &str) -> bool {
|
||||||
/// the evdev keyboard grab and/or focus — xclip is simpler and works reliably
|
// Try wl-copy (Wayland) via user_cmd
|
||||||
/// on the host.
|
{
|
||||||
fn copy_to_clipboard(s: &str) -> bool {
|
let mut cmd = Self::user_cmd("wl-copy");
|
||||||
let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
|
let result = cmd
|
||||||
let (prog, args): (&str, &[&str]) = if is_wayland {
|
.stdin(std::process::Stdio::piped())
|
||||||
// On Wayland/GNOME, wl-copy exits before the compositor reads
|
.spawn()
|
||||||
// the clipboard data. --paste-once keeps it alive until pasted,
|
.and_then(|mut child| {
|
||||||
// eliminating the 300–900 ms compositor lookup delay. We spawn
|
|
||||||
// it detached (no .wait()) — the child lives until Ctrl+V lands.
|
|
||||||
("wl-copy", &["--paste-once"])
|
|
||||||
} else {
|
|
||||||
("xclip", &["-selection", "clipboard", "-i"])
|
|
||||||
};
|
|
||||||
let mut cmd = Self::user_cmd(prog);
|
|
||||||
cmd.args(args);
|
|
||||||
cmd.stdin(std::process::Stdio::piped());
|
|
||||||
cmd.stdout(std::process::Stdio::null());
|
|
||||||
cmd.stderr(std::process::Stdio::null());
|
|
||||||
|
|
||||||
match cmd.spawn() {
|
|
||||||
Ok(mut child) => {
|
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
if let Some(mut stdin) = child.stdin.take() {
|
child.stdin.take().unwrap().write_all(s.as_bytes())?;
|
||||||
let _ = stdin.write_all(s.as_bytes());
|
child.wait()
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
}
|
||||||
eprintln!("[vietc] copy_to_clipboard: {} spawn failed: {}", prog, e);
|
|
||||||
|
// Try xclip (X11) via user_cmd
|
||||||
|
{
|
||||||
|
let mut cmd = Self::user_cmd("xclip");
|
||||||
|
cmd.args(["-selection", "clipboard"]);
|
||||||
|
let result = cmd
|
||||||
|
.stdin(std::process::Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.and_then(|mut child| {
|
||||||
|
use std::io::Write;
|
||||||
|
child.stdin.take().unwrap().write_all(s.as_bytes())?;
|
||||||
|
child.wait()
|
||||||
|
})
|
||||||
|
.map(|status| status.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if result {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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) {
|
||||||
self.send_uinput_event(EV_KEY, 29, 1); // KEY_LEFTCTRL press
|
self.send_uinput_event(EV_KEY, 29, 1); // KEY_LEFTCTRL press
|
||||||
self.send_uinput_event(0, 0, 0); // SYN
|
self.send_uinput_event(0, 0, 0); // SYN
|
||||||
std::thread::sleep(std::time::Duration::from_micros(100));
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||||
|
|
||||||
self.send_uinput_event(EV_KEY, 47, 1); // KEY_V press
|
self.send_uinput_event(EV_KEY, 47, 1); // KEY_V press
|
||||||
self.send_uinput_event(0, 0, 0); // SYN
|
self.send_uinput_event(0, 0, 0); // SYN
|
||||||
std::thread::sleep(std::time::Duration::from_micros(100));
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||||
|
|
||||||
self.send_uinput_event(EV_KEY, 47, 0); // KEY_V release
|
self.send_uinput_event(EV_KEY, 47, 0); // KEY_V release
|
||||||
self.send_uinput_event(0, 0, 0); // SYN
|
self.send_uinput_event(0, 0, 0); // SYN
|
||||||
std::thread::sleep(std::time::Duration::from_micros(100));
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||||
|
|
||||||
self.send_uinput_event(EV_KEY, 29, 0); // KEY_LEFTCTRL release
|
self.send_uinput_event(EV_KEY, 29, 0); // KEY_LEFTCTRL release
|
||||||
self.send_uinput_event(0, 0, 0); // SYN
|
self.send_uinput_event(0, 0, 0); // SYN
|
||||||
std::thread::sleep(std::time::Duration::from_micros(100));
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send Ctrl+V via X11 XTest (avoids uinput kernel feedback loop).
|
||||||
|
/// Uses a lazily-opened persistent X11 connection.
|
||||||
|
fn send_ctrl_v_x11(&self) {
|
||||||
|
if std::env::var("WAYLAND_DISPLAY").is_ok() {
|
||||||
|
self.send_ctrl_v();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Persistent X11 state (raw pointers, only used from injection thread)
|
||||||
|
static mut X11_DPY: *mut libc::c_void = std::ptr::null_mut();
|
||||||
|
static mut X11_KEY: Option<unsafe extern "C" fn(*mut libc::c_void, u32, libc::c_int, u64) -> libc::c_int> = None;
|
||||||
|
static mut X11_FLUSH: Option<unsafe extern "C" fn(*mut libc::c_void) -> libc::c_int> = None;
|
||||||
|
static mut X11_KEYCODE: Option<unsafe extern "C" fn(*mut libc::c_void, u64) -> u32> = None;
|
||||||
|
static X11_INIT: std::sync::Once = std::sync::Once::new();
|
||||||
|
|
||||||
|
X11_INIT.call_once(|| {
|
||||||
|
unsafe {
|
||||||
|
let lib = libc::dlopen(b"libX11.so.6\0".as_ptr() as *const libc::c_char, 1);
|
||||||
|
if lib.is_null() { return; }
|
||||||
|
let xtst = libc::dlopen(b"libXtst.so.6\0".as_ptr() as *const libc::c_char, 1);
|
||||||
|
if xtst.is_null() { libc::dlclose(lib); return; }
|
||||||
|
|
||||||
|
type FnOpen = unsafe extern "C" fn(*const libc::c_char) -> *mut libc::c_void;
|
||||||
|
let xopen: FnOpen = std::mem::transmute(libc::dlsym(lib, b"XOpenDisplay\0".as_ptr() as *const libc::c_char));
|
||||||
|
let dpy = xopen(std::ptr::null());
|
||||||
|
if dpy.is_null() { libc::dlclose(xtst); libc::dlclose(lib); return; }
|
||||||
|
|
||||||
|
X11_DPY = dpy;
|
||||||
|
X11_KEY = Some(std::mem::transmute(libc::dlsym(xtst, b"XTestFakeKeyEvent\0".as_ptr() as *const libc::c_char)));
|
||||||
|
X11_FLUSH = Some(std::mem::transmute(libc::dlsym(lib, b"XFlush\0".as_ptr() as *const libc::c_char)));
|
||||||
|
X11_KEYCODE = Some(std::mem::transmute(libc::dlsym(lib, b"XKeysymToKeycode\0".as_ptr() as *const libc::c_char)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
if X11_DPY.is_null() || X11_KEY.is_none() { self.send_ctrl_v(); return; }
|
||||||
|
let dpy = X11_DPY;
|
||||||
|
let xkey = X11_KEY.unwrap();
|
||||||
|
let xflush = X11_FLUSH.unwrap();
|
||||||
|
let xkeycode = X11_KEYCODE.unwrap();
|
||||||
|
let ctrl_kc = xkeycode(dpy, 0xFFE3);
|
||||||
|
let v_kc = xkeycode(dpy, 0x0076);
|
||||||
|
xkey(dpy, ctrl_kc, 1, 0);
|
||||||
|
xkey(dpy, v_kc, 1, 0);
|
||||||
|
xkey(dpy, v_kc, 0, 0);
|
||||||
|
xkey(dpy, ctrl_kc, 0, 0);
|
||||||
|
xflush(dpy);
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for UinputInjector {
|
impl Drop for UinputInjector {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
{
|
|
||||||
let mut st = self.clip.inner.lock().unwrap();
|
|
||||||
st.shutdown = true;
|
|
||||||
}
|
|
||||||
self.clip.cv.notify_all();
|
|
||||||
let _ = ioctl(self.file.as_raw_fd(), UI_DEV_DESTROY, 0);
|
let _ = ioctl(self.file.as_raw_fd(), UI_DEV_DESTROY, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Background thread: once no Unicode paste has happened for `RESTORE_DEBOUNCE`,
|
|
||||||
/// rewrite the user's real clipboard so Ctrl+V keeps working.
|
|
||||||
fn run_restorer(state: Arc<ClipState>) {
|
|
||||||
loop {
|
|
||||||
let mut st = state.inner.lock().unwrap();
|
|
||||||
loop {
|
|
||||||
if st.shutdown {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
match st.restore_due {
|
|
||||||
None => {
|
|
||||||
st = state.cv.wait(st).unwrap();
|
|
||||||
}
|
|
||||||
Some(due) => {
|
|
||||||
let now = Instant::now();
|
|
||||||
if now >= due {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let (guard, _) = state.cv.wait_timeout(st, due - now).unwrap();
|
|
||||||
st = guard;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Deadline reached. Restore under the lock so the write cannot
|
|
||||||
// interleave with a concurrent paste's clipboard write.
|
|
||||||
if let Some(restored) = st.saved_clipboard.clone() {
|
|
||||||
let _ = UinputInjector::copy_to_clipboard(&restored);
|
|
||||||
st.last_injected = Some(restored);
|
|
||||||
}
|
|
||||||
st.restore_due = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn strip_vn_diacritic(ch: char) -> char {
|
fn strip_vn_diacritic(ch: char) -> char {
|
||||||
match ch {
|
match ch {
|
||||||
'à' | 'á' | 'ả' | 'ã' | 'ạ' | 'ă' | 'ằ' | 'ắ' | 'ẳ' | 'ẵ' | 'ặ' | 'â' | 'ầ' | 'ấ' | 'ẩ' | 'ẫ' | 'ậ' => 'a',
|
'à' | 'á' | 'ả' | 'ã' | 'ạ' | 'ă' | 'ằ' | 'ắ' | 'ẳ' | 'ẵ' | 'ặ' | 'â' | 'ầ' | 'ấ' | 'ẩ' | 'ẫ' | 'ậ' => 'a',
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::inject::{InjectResult, KeyInjector};
|
use crate::inject::{InjectResult, KeyInjector};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::ffi::{c_char, c_int, c_void};
|
use std::ffi::{c_char, c_int, c_void};
|
||||||
use std::io::{Read, BufRead};
|
use std::io::{Read, BufRead};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
use super::inject::{InjectResult, KeyInjector};
|
use super::inject::{InjectResult, KeyInjector};
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::ffi::{c_char, c_int, c_void};
|
use std::ffi::{c_char, c_int, c_void};
|
||||||
|
|
@ -7,26 +6,6 @@ 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;
|
||||||
|
|
@ -60,9 +39,6 @@ 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 {
|
||||||
|
|
@ -118,12 +94,6 @@ 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 {
|
||||||
|
|
@ -143,9 +113,6 @@ 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,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -344,7 +311,10 @@ impl X11Injector {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_selection_request(&self, req: &XSelectionRequestEvent) {
|
fn handle_selection_request(&self, req: &XSelectionRequestEvent) {
|
||||||
// Silently handle — SelectionRequest floods the log on X11
|
eprintln!(
|
||||||
|
"[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 {
|
||||||
|
|
@ -417,19 +387,20 @@ impl X11Injector {
|
||||||
// (unlikely at this point, but be safe)
|
// (unlikely at this point, but be safe)
|
||||||
self.handle_pending_events();
|
self.handle_pending_events();
|
||||||
|
|
||||||
// Send backspaces via XTest (X11 keycode 22 = backspace)
|
// Send backspaces via XTest
|
||||||
if backspaces > 0 {
|
if backspaces > 0 {
|
||||||
for _ in 0..backspaces {
|
for _ in 0..backspaces {
|
||||||
self.send_keycode(22, false);
|
self.send_keycode(14, false); // KEY_BACKSPACE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send Ctrl+V via XTest to paste (evdev codes + 8 = X11)
|
// Send Ctrl+V via XTest to paste
|
||||||
unsafe {
|
unsafe {
|
||||||
(self.lib.x_test_fake_key_event)(self.display, 29 + 8, 1, 0); // Ctrl_L press
|
// X11 keycodes: 37 = Ctrl_L, 55 = V
|
||||||
(self.lib.x_test_fake_key_event)(self.display, 47 + 8, 1, 0); // V press
|
(self.lib.x_test_fake_key_event)(self.display, 37, 1, 0);
|
||||||
(self.lib.x_test_fake_key_event)(self.display, 47 + 8, 0, 0); // V release
|
(self.lib.x_test_fake_key_event)(self.display, 55, 1, 0);
|
||||||
(self.lib.x_test_fake_key_event)(self.display, 29 + 8, 0, 0); // Ctrl_L release
|
(self.lib.x_test_fake_key_event)(self.display, 55, 0, 0);
|
||||||
|
(self.lib.x_test_fake_key_event)(self.display, 37, 0, 0);
|
||||||
(self.lib.x_flush)(self.display);
|
(self.lib.x_flush)(self.display);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -444,16 +415,15 @@ impl X11Injector {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_keycode(&self, evdev_keycode: u32, shift: bool) {
|
fn send_keycode(&self, keycode: u32, shift: bool) {
|
||||||
let x11 = evdev_keycode + 8;
|
|
||||||
unsafe {
|
unsafe {
|
||||||
if shift {
|
if shift {
|
||||||
(self.lib.x_test_fake_key_event)(self.display, 42 + 8, 1, 0); // Shift_L
|
(self.lib.x_test_fake_key_event)(self.display, 50, 1, 0);
|
||||||
}
|
}
|
||||||
(self.lib.x_test_fake_key_event)(self.display, x11, 1, 0);
|
(self.lib.x_test_fake_key_event)(self.display, keycode, 1, 0);
|
||||||
(self.lib.x_test_fake_key_event)(self.display, x11, 0, 0);
|
(self.lib.x_test_fake_key_event)(self.display, keycode, 0, 0);
|
||||||
if shift {
|
if shift {
|
||||||
(self.lib.x_test_fake_key_event)(self.display, 42 + 8, 0, 0);
|
(self.lib.x_test_fake_key_event)(self.display, 50, 0, 0);
|
||||||
}
|
}
|
||||||
(self.lib.x_flush)(self.display);
|
(self.lib.x_flush)(self.display);
|
||||||
}
|
}
|
||||||
|
|
@ -513,17 +483,15 @@ struct XSelectionNotifyEvent {
|
||||||
|
|
||||||
impl KeyInjector for X11Injector {
|
impl KeyInjector for X11Injector {
|
||||||
fn send_key_event(&self, keycode: u16, value: i32) -> InjectResult {
|
fn send_key_event(&self, keycode: u16, value: i32) -> InjectResult {
|
||||||
// X11 keycodes = Linux evdev keycodes + 8
|
|
||||||
let x11_keycode = keycode as u32 + 8;
|
|
||||||
unsafe {
|
unsafe {
|
||||||
(self.lib.x_test_fake_key_event)(self.display, x11_keycode, value, 0);
|
(self.lib.x_test_fake_key_event)(self.display, keycode as u32, value, 0);
|
||||||
(self.lib.x_flush)(self.display);
|
(self.lib.x_flush)(self.display);
|
||||||
}
|
}
|
||||||
InjectResult::Success
|
InjectResult::Success
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_backspace(&self) -> InjectResult {
|
fn send_backspace(&self) -> InjectResult {
|
||||||
self.send_keycode(22, false); // X11 keycode 22 = backspace
|
self.send_keycode(14, false);
|
||||||
InjectResult::Success
|
InjectResult::Success
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -538,18 +506,9 @@ impl KeyInjector for X11Injector {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_string(&self, s: &str) -> InjectResult {
|
fn send_string(&self, s: &str) -> InjectResult {
|
||||||
// ASCII: type individual characters via XTest (fast, no side effects)
|
|
||||||
let is_ascii = s.chars().all(|c| char_to_keycode(c).is_some());
|
|
||||||
if is_ascii {
|
|
||||||
for ch in s.chars() {
|
for ch in s.chars() {
|
||||||
self.send_char(ch);
|
self.send_char(ch);
|
||||||
}
|
}
|
||||||
return InjectResult::Success;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-ASCII (Vietnamese Unicode): use clipboard paste via X11 API + XTest
|
|
||||||
// This avoids xdotool/ydotool subprocesses that silently drop Vietnamese.
|
|
||||||
self.paste_via_clipboard(0, s);
|
|
||||||
InjectResult::Success
|
InjectResult::Success
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -585,109 +544,3 @@ 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Viet+ — Linux Mint / Ubuntu test VM setup script
|
|
||||||
# Usage: curl -fsSL <url> | bash
|
|
||||||
# or: bash scripts/setup-test-vm.sh
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m'; NC='\033[0m'
|
|
||||||
|
|
||||||
echo -e "${GREEN}=== Viet+ Test VM Setup ===${NC}"
|
|
||||||
|
|
||||||
# 1. Install system deps
|
|
||||||
echo -e "${YELLOW}[1/5] Installing system dependencies...${NC}"
|
|
||||||
sudo apt update -y
|
|
||||||
sudo apt install -y build-essential pkg-config libx11-dev libxtst-dev \
|
|
||||||
libdbus-1-dev libevdev-dev libwayland-dev curl git \
|
|
||||||
libevdev2 libdbus-1-3 libx11-6 libxtst6 libwayland-client0 \
|
|
||||||
wl-clipboard xclip
|
|
||||||
|
|
||||||
# 2. Install Rust if missing
|
|
||||||
echo -e "${YELLOW}[2/5] Installing Rust...${NC}"
|
|
||||||
if ! command -v cargo &>/dev/null; then
|
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
|
||||||
source "$HOME/.cargo/env"
|
|
||||||
else
|
|
||||||
echo " Rust already installed."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 3. Clone and build
|
|
||||||
echo -e "${YELLOW}[3/5] Cloning and building...${NC}"
|
|
||||||
if [ ! -d vietc ]; then
|
|
||||||
git clone https://github.com/vndangkhoa/vietc.git
|
|
||||||
fi
|
|
||||||
cd vietc && git checkout staging && cargo build --release
|
|
||||||
|
|
||||||
# 4. Install
|
|
||||||
echo -e "${YELLOW}[4/5] Installing...${NC}"
|
|
||||||
sudo ./install.sh
|
|
||||||
|
|
||||||
# 5. Done
|
|
||||||
echo -e "${YELLOW}[5/5] Setup complete!${NC}"
|
|
||||||
echo -e "${GREEN}========================================${NC}"
|
|
||||||
echo -e "${GREEN} Reboot to apply group + udev changes ${NC}"
|
|
||||||
echo -e "${GREEN} Then: vietc-tray & ${NC}"
|
|
||||||
echo -e "${GREEN} Or: sudo vietc-daemon ${NC}"
|
|
||||||
echo -e "${GREEN}========================================${NC}"
|
|
||||||
echo ""
|
|
||||||
echo "Quick test:"
|
|
||||||
echo " cargo run --bin vietc-cli"
|
|
||||||
echo ""
|
|
||||||
echo "Terminal typing (VNI mode auto-enabled in terminals):"
|
|
||||||
echo " cha2o -> chào"
|
|
||||||
echo " ba5n -> bạn"
|
|
||||||
echo " to6i te6n la2 Khoa3 -> tôi tên là Khỏa"
|
|
||||||
echo " d9o7i -> đời"
|
|
||||||
echo ""
|
|
||||||
echo "Telex (Ctrl+LeftShift to switch):"
|
|
||||||
echo " chaof -> chào"
|
|
||||||
echo " banj -> bạn"
|
|
||||||
echo " tooi teen laf Khoar -> tôi tên là Khỏa"
|
|
||||||
echo " ddoi -> đời"
|
|
||||||
echo ""
|
|
||||||
echo "Macros:"
|
|
||||||
echo " ko[space] -> không"
|
|
||||||
echo " dc[space] -> được"
|
|
||||||
echo " vs[space] -> với"
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "vietc-tray"
|
name = "vietc-tray"
|
||||||
version = "0.1.7"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Viet+ system tray icon"
|
description = "Viet+ system tray icon"
|
||||||
|
|
||||||
|
|
@ -14,4 +14,3 @@ 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"
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
@ -71,7 +70,7 @@ pub struct Config {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_input_method() -> String {
|
fn default_input_method() -> String {
|
||||||
"vni".into()
|
"telex".into()
|
||||||
}
|
}
|
||||||
fn default_toggle_key() -> String {
|
fn default_toggle_key() -> String {
|
||||||
"space".into()
|
"space".into()
|
||||||
|
|
@ -80,7 +79,7 @@ fn default_start_enabled() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
fn default_grab() -> bool {
|
fn default_grab() -> bool {
|
||||||
false
|
true
|
||||||
}
|
}
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
|
|
@ -150,7 +149,6 @@ fn config_paths() -> Vec<PathBuf> {
|
||||||
}
|
}
|
||||||
|
|
||||||
paths.push(PathBuf::from("vietc.toml"));
|
paths.push(PathBuf::from("vietc.toml"));
|
||||||
paths.push(PathBuf::from("/etc/vietc/config.toml"));
|
|
||||||
|
|
||||||
paths
|
paths
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
|
|
@ -12,51 +11,23 @@ fn exe_dir() -> PathBuf {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn find_sibling_binary(name: &str) -> String {
|
fn find_sibling_binary(name: &str) -> String {
|
||||||
let dir = exe_dir();
|
let sibling = exe_dir().join(name);
|
||||||
// Try exact name (e.g. "vietc" outside Flatpak)
|
|
||||||
let sibling = dir.join(name);
|
|
||||||
if sibling.exists() {
|
if sibling.exists() {
|
||||||
return sibling.to_string_lossy().into_owned();
|
return sibling.to_string_lossy().into_owned();
|
||||||
}
|
}
|
||||||
// Try name-daemon (e.g. "vietc-daemon" inside Flatpak)
|
|
||||||
let daemon = dir.join(format!("{}-daemon", name));
|
|
||||||
if daemon.exists() {
|
|
||||||
return daemon.to_string_lossy().into_owned();
|
|
||||||
}
|
|
||||||
name.to_string()
|
name.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_daemon_running() -> bool {
|
fn is_daemon_running() -> bool {
|
||||||
// Check both "vietc" (outside Flatpak) and "vietc-daemon" (inside Flatpak)
|
|
||||||
let check = |name: &str| -> bool {
|
|
||||||
std::process::Command::new("pgrep")
|
std::process::Command::new("pgrep")
|
||||||
.arg("-x")
|
.arg("-x")
|
||||||
.arg(name)
|
.arg("vietc")
|
||||||
.status()
|
.status()
|
||||||
.map(|s| s.success())
|
.map(|s| s.success())
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
};
|
|
||||||
check("vietc") || check("vietc-daemon")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_flatpak() -> bool {
|
|
||||||
std::env::var("FLATPAK_ID").is_ok()
|
|
||||||
|| std::path::Path::new("/app/bin").exists()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn needs_root() -> bool {
|
fn needs_root() -> bool {
|
||||||
if is_flatpak() {
|
|
||||||
// Inside Flatpak the sandbox already has device access; sudo won't work.
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Check if we can access /dev/uinput directly (user in input group or has ACL)
|
|
||||||
let uinput = std::fs::OpenOptions::new()
|
|
||||||
.read(true)
|
|
||||||
.write(true)
|
|
||||||
.open("/dev/uinput");
|
|
||||||
if uinput.is_ok() {
|
|
||||||
return false; // Can grab + inject without root
|
|
||||||
}
|
|
||||||
let cfg = config::Config::load();
|
let cfg = config::Config::load();
|
||||||
cfg.grab
|
cfg.grab
|
||||||
}
|
}
|
||||||
|
|
@ -131,14 +102,18 @@ fn prompt_password() -> String {
|
||||||
fn start_daemon() {
|
fn start_daemon() {
|
||||||
let daemon_bin = find_sibling_binary("vietc");
|
let daemon_bin = find_sibling_binary("vietc");
|
||||||
|
|
||||||
|
if needs_root() && !is_daemon_running() {
|
||||||
|
// Mark that we've attempted first launch
|
||||||
let flag_path = config_path().join(".first-launch-done");
|
let flag_path = config_path().join(".first-launch-done");
|
||||||
|
|
||||||
if needs_root() && !is_daemon_running() && !flag_path.exists() {
|
if !flag_path.exists() {
|
||||||
let password = prompt_password();
|
let password = prompt_password();
|
||||||
if password.is_empty() {
|
if password.is_empty() {
|
||||||
eprintln!("[vietc-tray] No password provided, starting daemon without root");
|
eprintln!("[vietc-tray] No password provided, starting daemon without root");
|
||||||
let _ = std::process::Command::new(&daemon_bin).spawn();
|
let _ = std::process::Command::new(&daemon_bin).spawn();
|
||||||
} else {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Start daemon with sudo
|
// Start daemon with sudo
|
||||||
let mut child = match std::process::Command::new("sudo")
|
let mut child = match std::process::Command::new("sudo")
|
||||||
.args(["-S", &daemon_bin])
|
.args(["-S", &daemon_bin])
|
||||||
|
|
@ -151,7 +126,6 @@ fn start_daemon() {
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[vietc-tray] Failed to start daemon with sudo: {}", e);
|
eprintln!("[vietc-tray] Failed to start daemon with sudo: {}", e);
|
||||||
let _ = std::process::Command::new(&daemon_bin).spawn();
|
let _ = std::process::Command::new(&daemon_bin).spawn();
|
||||||
let _ = std::fs::write(&flag_path, "1");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -161,11 +135,12 @@ fn start_daemon() {
|
||||||
let _ = stdin.write_all(format!("{}\n", password).as_bytes());
|
let _ = stdin.write_all(format!("{}\n", password).as_bytes());
|
||||||
}
|
}
|
||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
}
|
|
||||||
|
|
||||||
|
// Mark first launch as done
|
||||||
let _ = std::fs::write(&flag_path, "1");
|
let _ = std::fs::write(&flag_path, "1");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !is_daemon_running() {
|
if !is_daemon_running() {
|
||||||
eprintln!("[vietc-tray] Starting daemon: {}", daemon_bin);
|
eprintln!("[vietc-tray] Starting daemon: {}", daemon_bin);
|
||||||
|
|
@ -179,26 +154,7 @@ fn config_path() -> PathBuf {
|
||||||
.join("vietc")
|
.join("vietc")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_single_instance(name: &str) {
|
|
||||||
let uid = unsafe { libc::getuid() };
|
|
||||||
let path = format!("/tmp/{}-{}.lock", name, uid);
|
|
||||||
let path_c = std::ffi::CString::new(path).unwrap();
|
|
||||||
let fd = unsafe { libc::open(path_c.as_ptr(), libc::O_CREAT | libc::O_RDWR, 0o600) };
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// Ensure single instance to avoid duplicate tray icons
|
|
||||||
ensure_single_instance("vietc-tray");
|
|
||||||
|
|
||||||
eprintln!("[vietc-tray] Starting");
|
eprintln!("[vietc-tray] Starting");
|
||||||
|
|
||||||
// Start daemon (with password prompt if first launch)
|
// Start daemon (with password prompt if first launch)
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,12 @@
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use ksni::{menu::*, MenuItem, Tray};
|
use ksni::{menu::*, MenuItem, Tray};
|
||||||
|
|
||||||
fn is_flatpak() -> bool {
|
|
||||||
std::path::Path::new("/app/bin/vietc-daemon").exists()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_status(state: &str) {
|
fn write_status(state: &str) {
|
||||||
if let Some(config_dir) = dirs::config_dir() {
|
if let Some(config_dir) = dirs::config_dir() {
|
||||||
let _ = std::fs::write(config_dir.join("vietc").join("status"), state);
|
let _ = std::fs::write(config_dir.join("vietc").join("status"), state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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"))
|
||||||
|
|
@ -85,11 +63,6 @@ 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>
|
||||||
|
|
@ -100,15 +73,11 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
@ -121,15 +90,11 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
@ -277,18 +242,7 @@ impl Tray for VietTray {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn icon_name(&self) -> String {
|
fn icon_name(&self) -> String {
|
||||||
let is_tlx = self.mode == "vn" && self.im == "telex";
|
if self.mode == "vn" {
|
||||||
if is_flatpak() {
|
|
||||||
if is_tlx {
|
|
||||||
"io.github.vietc.VietPlus.vietc-tlx".into()
|
|
||||||
} else if self.mode == "vn" {
|
|
||||||
"io.github.vietc.VietPlus.vietc-vn".into()
|
|
||||||
} else {
|
|
||||||
"io.github.vietc.VietPlus.vietc-en".into()
|
|
||||||
}
|
|
||||||
} else if is_tlx {
|
|
||||||
"vietc-tlx".into()
|
|
||||||
} else if self.mode == "vn" {
|
|
||||||
"vietc-vn".into()
|
"vietc-vn".into()
|
||||||
} else {
|
} else {
|
||||||
"vietc-en".into()
|
"vietc-en".into()
|
||||||
|
|
@ -296,32 +250,18 @@ impl Tray for VietTray {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn icon_theme_path(&self) -> String {
|
fn icon_theme_path(&self) -> String {
|
||||||
// Use XDG user theme path for icons (works in both native and Flatpak)
|
// Use XDG user theme path for icons
|
||||||
if let Some(home) = dirs::home_dir() {
|
dirs::home_dir()
|
||||||
let user_path = home.join(".local/share/icons");
|
.map(|d| d.join(".local/share/icons").to_string_lossy().into_owned())
|
||||||
if user_path.exists() {
|
|
||||||
return user_path.to_string_lossy().into_owned();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Flatpak: icons are in /app/share/icons
|
|
||||||
let flatpak_path = std::path::Path::new("/app/share/icons");
|
|
||||||
if flatpak_path.exists() {
|
|
||||||
return "/app/share/icons".into();
|
|
||||||
}
|
|
||||||
dirs::data_dir()
|
|
||||||
.map(|d| d.join("icons").to_string_lossy().into_owned())
|
|
||||||
.unwrap_or_else(|| "/usr/share/icons".into())
|
.unwrap_or_else(|| "/usr/share/icons".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
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 is_tlx = self.mode == "vn" && self.im == "telex";
|
let bg_color = if is_vn {
|
||||||
let bg_color = if is_vn && !is_tlx {
|
[255, 224, 36, 36] // A, R, G, B
|
||||||
[255, 224, 36, 36] // Red for VNI
|
|
||||||
} else if is_tlx {
|
|
||||||
[255, 37, 99, 235] // Blue for Telex
|
|
||||||
} else {
|
} else {
|
||||||
[255, 75, 85, 99] // Gray for English
|
[255, 75, 85, 99]
|
||||||
};
|
};
|
||||||
let fg_color = [255, 255, 255, 255];
|
let fg_color = [255, 255, 255, 255];
|
||||||
|
|
||||||
|
|
@ -357,18 +297,7 @@ impl Tray for VietTray {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_tlx {
|
if is_vn {
|
||||||
// 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);
|
||||||
|
|
@ -418,7 +347,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_usize } else { 1_usize };
|
let im_index = if self.im == "telex" { 0 } else { 1 };
|
||||||
|
|
||||||
let mut items = vec![
|
let mut items = vec![
|
||||||
CheckmarkItem {
|
CheckmarkItem {
|
||||||
|
|
@ -458,7 +387,6 @@ 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![
|
||||||
|
|
@ -590,7 +518,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 = read_method();
|
let im = current_im();
|
||||||
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| {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "vietc-uinputd"
|
name = "vietc-uinputd"
|
||||||
version = "0.1.7"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Viet+ privileged uinput backspace injection daemon"
|
description = "Viet+ privileged uinput backspace injection daemon"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,9 @@
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::os::unix::io::AsRawFd;
|
use std::os::unix::io::AsRawFd;
|
||||||
use std::os::unix::net::{UnixListener, UnixStream};
|
use std::os::unix::net::{UnixListener, UnixStream};
|
||||||
use std::io::{BufRead, BufReader, Write};
|
use std::io::{BufRead, BufReader, Write};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::sync::{Arc, Condvar, Mutex};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
/// How long to wait after the last Unicode paste before restoring the user's
|
|
||||||
/// real clipboard. Each paste pushes this deadline back, so a burst of typing
|
|
||||||
/// only triggers a single restore once the user pauses. This is what keeps the
|
|
||||||
/// user's clipboard from being pasted into the text mid-typing: we never
|
|
||||||
/// overwrite our just-pasted word with the user's clipboard while the target
|
|
||||||
/// app might still be reading it.
|
|
||||||
const RESTORE_DEBOUNCE: Duration = Duration::from_millis(600);
|
|
||||||
|
|
||||||
const UINPUT_MAX_NAME_SIZE: usize = 80;
|
const UINPUT_MAX_NAME_SIZE: usize = 80;
|
||||||
const UI_SET_EVBIT: u64 = 0x40045564;
|
const UI_SET_EVBIT: u64 = 0x40045564;
|
||||||
|
|
@ -56,31 +45,8 @@ struct input_id {
|
||||||
version: u16,
|
version: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared clipboard bookkeeping between the command handler and the background
|
|
||||||
/// restorer thread.
|
|
||||||
struct ClipInner {
|
|
||||||
/// The user's real clipboard contents, saved before we overwrite the
|
|
||||||
/// clipboard to paste Unicode text, so we can restore it afterwards.
|
|
||||||
saved_clipboard: Option<String>,
|
|
||||||
/// The last text we wrote to the clipboard ourselves (an injected word or
|
|
||||||
/// the restored user content). Used to distinguish our own writes from
|
|
||||||
/// text the user copied with Ctrl+C.
|
|
||||||
last_injected: Option<String>,
|
|
||||||
/// When set, the restorer thread should rewrite the user's clipboard at
|
|
||||||
/// this instant. `None` means no restore is pending.
|
|
||||||
restore_due: Option<Instant>,
|
|
||||||
/// Set on shutdown so the restorer thread can exit.
|
|
||||||
shutdown: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ClipState {
|
|
||||||
inner: Mutex<ClipInner>,
|
|
||||||
cv: Condvar,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct UinputDevice {
|
struct UinputDevice {
|
||||||
fd: i32,
|
fd: i32,
|
||||||
clip: Arc<ClipState>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UinputDevice {
|
impl UinputDevice {
|
||||||
|
|
@ -116,22 +82,8 @@ impl UinputDevice {
|
||||||
std::mem::forget(file);
|
std::mem::forget(file);
|
||||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
|
||||||
let clip = Arc::new(ClipState {
|
|
||||||
inner: Mutex::new(ClipInner {
|
|
||||||
saved_clipboard: None,
|
|
||||||
last_injected: None,
|
|
||||||
restore_due: None,
|
|
||||||
shutdown: false,
|
|
||||||
}),
|
|
||||||
cv: Condvar::new(),
|
|
||||||
});
|
|
||||||
{
|
|
||||||
let clip = Arc::clone(&clip);
|
|
||||||
std::thread::spawn(move || run_restorer(clip));
|
|
||||||
}
|
|
||||||
|
|
||||||
eprintln!("[vietc-uinputd] Device '{}' created", name);
|
eprintln!("[vietc-uinputd] Device '{}' created", name);
|
||||||
Ok(Self { fd, clip })
|
Ok(Self { fd })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_event(&self, type_: u16, code: u16, value: i32) {
|
fn send_event(&self, type_: u16, code: u16, value: i32) {
|
||||||
|
|
@ -201,108 +153,24 @@ impl UinputDevice {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn paste_unicode(&self, text: &str) {
|
fn paste_unicode(&self, text: &str) {
|
||||||
// Critical section: snapshot the clipboard, decide what to preserve,
|
|
||||||
// cancel any pending restore so the restorer cannot fire while we are
|
|
||||||
// pasting, and put our word on the clipboard. The read and write happen
|
|
||||||
// under the lock so they can never interleave with the restorer.
|
|
||||||
{
|
|
||||||
let mut st = self.clip.inner.lock().unwrap();
|
|
||||||
let current = read_clipboard();
|
|
||||||
let is_our_write =
|
|
||||||
matches!((¤t, &st.last_injected), (Some(c), Some(l)) if c == l);
|
|
||||||
if !is_our_write {
|
|
||||||
// The user changed the clipboard themselves (a real Ctrl+C).
|
|
||||||
st.saved_clipboard = current;
|
|
||||||
}
|
|
||||||
// Cancel any pending restore; the restorer parks until we schedule
|
|
||||||
// a new one after the paste.
|
|
||||||
st.restore_due = None;
|
|
||||||
copy_to_clipboard(text);
|
copy_to_clipboard(text);
|
||||||
st.last_injected = Some(text.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Give the selection owner a moment to take ownership before pasting.
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
||||||
|
|
||||||
self.send_key(29, 1);
|
self.send_key(29, 1);
|
||||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||||
self.send_key(47, 1);
|
self.send_key(47, 1);
|
||||||
self.send_key(47, 0);
|
self.send_key(47, 0);
|
||||||
self.send_key(29, 0);
|
self.send_key(29, 0);
|
||||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
|
||||||
// Schedule a debounced restore. While the user keeps typing this gets
|
|
||||||
// pushed back, so the user's clipboard is only restored once typing
|
|
||||||
// settles — never overwriting our freshly pasted word mid-stream.
|
|
||||||
{
|
|
||||||
let mut st = self.clip.inner.lock().unwrap();
|
|
||||||
st.restore_due = Some(Instant::now() + RESTORE_DEBOUNCE);
|
|
||||||
}
|
|
||||||
self.clip.cv.notify_all();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Background thread: once no Unicode paste has happened for `RESTORE_DEBOUNCE`,
|
|
||||||
/// rewrite the user's real clipboard so Ctrl+V keeps working.
|
|
||||||
fn run_restorer(state: Arc<ClipState>) {
|
|
||||||
loop {
|
|
||||||
let mut st = state.inner.lock().unwrap();
|
|
||||||
loop {
|
|
||||||
if st.shutdown {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
match st.restore_due {
|
|
||||||
None => {
|
|
||||||
st = state.cv.wait(st).unwrap();
|
|
||||||
}
|
|
||||||
Some(due) => {
|
|
||||||
let now = Instant::now();
|
|
||||||
if now >= due {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let (guard, _) = state.cv.wait_timeout(st, due - now).unwrap();
|
|
||||||
st = guard;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Deadline reached. Restore under the lock so the write cannot
|
|
||||||
// interleave with a concurrent paste's clipboard write.
|
|
||||||
let restored = st.saved_clipboard.clone().unwrap_or_default();
|
|
||||||
copy_to_clipboard(&restored);
|
|
||||||
st.last_injected = Some(restored);
|
|
||||||
st.restore_due = None;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for UinputDevice {
|
impl Drop for UinputDevice {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
{
|
|
||||||
let mut st = self.clip.inner.lock().unwrap();
|
|
||||||
st.shutdown = true;
|
|
||||||
}
|
|
||||||
self.clip.cv.notify_all();
|
|
||||||
let _ = unsafe { libc::ioctl(self.fd, UI_DEV_DESTROY, 0) };
|
let _ = unsafe { libc::ioctl(self.fd, UI_DEV_DESTROY, 0) };
|
||||||
let _ = unsafe { libc::close(self.fd) };
|
let _ = unsafe { libc::close(self.fd) };
|
||||||
eprintln!("[vietc-uinputd] Device destroyed");
|
eprintln!("[vietc-uinputd] Device destroyed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_clipboard() -> Option<String> {
|
|
||||||
let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
|
|
||||||
let output = if is_wayland {
|
|
||||||
Command::new("wl-paste").arg("-n").output()
|
|
||||||
} else {
|
|
||||||
Command::new("xclip")
|
|
||||||
.args(["-selection", "clipboard", "-o"])
|
|
||||||
.output()
|
|
||||||
};
|
|
||||||
let output = output.ok()?;
|
|
||||||
if !output.status.success() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(String::from_utf8_lossy(&output.stdout).into_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn copy_to_clipboard(text: &str) {
|
fn copy_to_clipboard(text: &str) {
|
||||||
let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
|
let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
|
||||||
if is_wayland {
|
if is_wayland {
|
||||||
|
|
|
||||||
44
uninstall.sh
44
uninstall.sh
|
|
@ -1,44 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# SPDX-License-Identifier: MIT
|
|
||||||
# Viet+ — Vietnamese Input Method Uninstaller
|
|
||||||
# Usage: curl -sSL <url> | sudo bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
RED='\033[0;31m'; GREEN='\033[0;32m'; NC='\033[0m'
|
|
||||||
|
|
||||||
[ "$EUID" -ne 0 ] && echo -e "${RED}Please run with sudo.${NC}" && exit 1
|
|
||||||
|
|
||||||
echo -e "${RED}=== Viet+ Uninstaller ===${NC}"
|
|
||||||
|
|
||||||
# Kill running processes
|
|
||||||
pkill -x vietc-tray 2>/dev/null || true
|
|
||||||
pkill -x vietc-daemon 2>/dev/null || true
|
|
||||||
pkill -x vietc-uinputd 2>/dev/null || true
|
|
||||||
pkill -x vietc 2>/dev/null || true
|
|
||||||
|
|
||||||
# Remove binaries
|
|
||||||
rm -f /usr/bin/vietc-daemon /usr/bin/vietc-cli /usr/bin/vietc-uinputd \
|
|
||||||
/usr/bin/vietc-tray /usr/bin/vietc-xrecord
|
|
||||||
rm -f /usr/local/bin/vietc /usr/local/bin/vietc-daemon /usr/local/bin/vietc-cli \
|
|
||||||
/usr/local/bin/vietc-uinputd /usr/local/bin/vietc-tray /usr/local/bin/vietc-xrecord
|
|
||||||
|
|
||||||
# Remove udev rules
|
|
||||||
rm -f /etc/udev/rules.d/99-vietc.rules
|
|
||||||
|
|
||||||
# Remove config
|
|
||||||
rm -rf /etc/vietc
|
|
||||||
|
|
||||||
# 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 /etc/xdg/autostart/vietc-tray.desktop
|
|
||||||
|
|
||||||
# Reload
|
|
||||||
udevadm control --reload-rules 2>/dev/null || true
|
|
||||||
|
|
||||||
echo -e "${GREEN}=== Viet+ removed ===${NC}"
|
|
||||||
Loading…
Reference in a new issue