diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..c763641
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,107 @@
+name: Build & Release
+
+# Builds the .deb and AppImage 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 + AppImage
+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 packages
+ 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: Fetch appimagetool
+ run: |
+ curl -fsSL -o packaging/appimage/appimagetool \
+ https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
+ chmod +x packaging/appimage/appimagetool
+
+ - name: Build .deb
+ run: bash packaging/deb/build-deb.sh "${{ steps.ver.outputs.version }}"
+
+ - name: Build AppImage
+ # appimagetool is invoked with --appimage-extract-and-run by the build
+ # script, so no FUSE is required on the runner.
+ run: bash packaging/appimage/build-appimage.sh "${{ steps.ver.outputs.version }}"
+
+ - name: Collect artifacts
+ run: |
+ mkdir -p dist
+ cp packaging/deb/*.deb dist/
+ cp packaging/appimage/*.AppImage dist/
+ ls -la dist
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: vietc-packages-${{ 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
diff --git a/packaging/appimage/build-appimage.sh b/packaging/appimage/build-appimage.sh
index cf75827..caecd03 100644
--- a/packaging/appimage/build-appimage.sh
+++ b/packaging/appimage/build-appimage.sh
@@ -179,14 +179,16 @@ else
SVGEOF
fi
-# Convert SVG to PNG for appimagetool (it prefers PNG for the root icon)
+# Convert SVG to PNG for appimagetool (it prefers PNG for the root icon).
+# This is best-effort: if no converter works, appimagetool falls back to the
+# SVG, so a conversion failure must never abort the build.
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"
+ rsvg-convert -w 256 -h 256 "$APPDIR/vietc.svg" -o "$APPDIR/vietc.png" || true
elif command -v inkscape &>/dev/null; then
- inkscape -w 256 -h 256 "$APPDIR/vietc.svg" --export-filename="$APPDIR/vietc.png" 2>/dev/null
+ inkscape -w 256 -h 256 "$APPDIR/vietc.svg" --export-filename="$APPDIR/vietc.png" 2>/dev/null || true
elif command -v convert &>/dev/null; then
- convert -background none "$APPDIR/vietc.svg" -resize 256x256 "$APPDIR/vietc.png" 2>/dev/null
+ convert -background none "$APPDIR/vietc.svg" -resize 256x256 "$APPDIR/vietc.png" 2>/dev/null || true
elif command -v python3 &>/dev/null; then
python3 -c "
import subprocess, sys
@@ -194,7 +196,7 @@ try:
subprocess.check_call(['rsvg-convert', '-w', '256', '-h', '256', '$APPDIR/vietc.svg', '-o', '$APPDIR/vietc.png'])
except Exception:
pass
-" 2>/dev/null
+" 2>/dev/null || true
fi
# If no converter, appimagetool can use SVG directly
fi
diff --git a/packaging/deb/build-deb.sh b/packaging/deb/build-deb.sh
index 3d80118..7c4e01e 100755
--- a/packaging/deb/build-deb.sh
+++ b/packaging/deb/build-deb.sh
@@ -31,8 +31,19 @@ mkdir -p "$STAGING/usr/share/metainfo"
echo "[3/5] Installing binaries..."
cp "$PROJECT_ROOT/target/release/vietc" "$STAGING/usr/bin/"
cp "$PROJECT_ROOT/target/release/vietc-cli" "$STAGING/usr/bin/"
+# Privileged uinput injection daemon — required for Unicode (Vietnamese) output.
+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/"
+# Compile and bundle vietc-xrecord (C helper for X11 XRecord keyboard capture)
+if command -v gcc &>/dev/null; then
+ gcc -O2 -o "$STAGING/usr/bin/vietc-xrecord" "$PROJECT_ROOT/packaging/appimage/vietc-xrecord.c" -lX11 -lXtst \
+ && echo " vietc-xrecord compiled" \
+ || echo " WARNING: vietc-xrecord compile failed (libX11/libXtst dev headers missing)"
+else
+ echo " WARNING: no gcc, vietc-xrecord not bundled"
+fi
+
# Desktop file
cp "$PROJECT_ROOT/packaging/appimage/vietc.desktop" "$STAGING/usr/share/applications/"
diff --git a/protocol/src/uinput_monitor.rs b/protocol/src/uinput_monitor.rs
index d78e070..95f5f50 100644
--- a/protocol/src/uinput_monitor.rs
+++ b/protocol/src/uinput_monitor.rs
@@ -1,8 +1,17 @@
use std::fs::{File, OpenOptions};
use std::os::unix::io::AsRawFd;
+use std::sync::{Arc, Condvar, Mutex};
+use std::time::{Duration, Instant};
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 UI_SET_EVBIT: u64 = 0x40045564;
const UI_SET_KEYBIT: u64 = 0x40045565;
@@ -16,14 +25,31 @@ const EV_KEY: u16 = 0x01;
const EV_ABS: u16 = 0x03;
const KEY_MAX: u32 = 0x1ff;
-pub struct UinputInjector {
- file: File,
+/// 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: std::sync::Mutex