From 4595ce7044aad2e8c83be769a54d4c0bcb02b778 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 01:16:48 +0000 Subject: [PATCH] Fix clipboard-into-text race and add CI/CD for .deb + AppImage Debounce the clipboard restore so the user's clipboard is never written back while a just-pasted Vietnamese word may still be read by the target app, which caused old clipboard content to appear in the text mid-typing. Applied to both vietc-uinputd and the in-process UinputInjector. Add a GitHub Actions workflow that builds the .deb and AppImage on the runner (artifacts on push/PR, GitHub Release on v* tags), and include vietc-uinputd + vietc-xrecord in the .deb. Co-Authored-By: vndangkhoa --- .github/workflows/build.yml | 107 ++++++++++++++++++++ packaging/appimage/build-appimage.sh | 12 ++- packaging/deb/build-deb.sh | 11 ++ protocol/src/uinput_monitor.rs | 145 +++++++++++++++++++++------ uinputd/src/main.rs | 137 ++++++++++++++++++++----- 5 files changed, 349 insertions(+), 63 deletions(-) create mode 100644 .github/workflows/build.yml 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>, - /// The last text we injected via the clipboard. Used to tell our own - /// injected text apart from text the user copied with Ctrl+C. - last_injected: std::sync::Mutex>, + saved_clipboard: Option, + /// 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, + /// When set, the restorer thread should rewrite the user's clipboard at + /// this instant. `None` means no restore is pending. + restore_due: Option, + /// Set on shutdown so the restorer thread can exit. + shutdown: bool, +} + +struct ClipState { + inner: Mutex, + cv: Condvar, +} + +pub struct UinputInjector { + file: File, + clip: Arc, } unsafe impl Send for UinputInjector {} @@ -78,11 +104,21 @@ impl UinputInjector { // Small delay for device to be ready std::thread::sleep(std::time::Duration::from_millis(10)); - Ok(Self { - file, - saved_clipboard: std::sync::Mutex::new(None), - last_injected: std::sync::Mutex::new(None), - }) + 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)); + } + + Ok(Self { file, clip }) } fn send_uinput_event(&self, type_: u16, code: u16, value: i32) { @@ -380,7 +416,7 @@ impl UinputInjector { /// 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(&self) -> Option { + fn read_clipboard() -> Option { let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok(); let (prog, args): (&str, &[&str]) = if is_wayland { ("wl-paste", &["-n"]) @@ -403,34 +439,42 @@ impl UinputInjector { /// /// Returns whether the text was successfully copied to the clipboard. fn paste_via_clipboard(&self, text: &str, use_x11_paste: bool) -> bool { - // Snapshot the clipboard. If it differs from what we last injected, the - // user changed it themselves (a real Ctrl+C), so remember it to restore. - let current = self.read_clipboard(); + // 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 last = self.last_injected.lock().unwrap(); - let is_our_injection = matches!((¤t, &*last), (Some(c), Some(l)) if c == l); - if !is_our_injection { - *self.saved_clipboard.lock().unwrap() = current; + let mut st = self.clip.inner.lock().unwrap(); + 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.restore_due = None; + if !Self::copy_to_clipboard(text) { + return false; + } + st.last_injected = Some(text.to_string()); } - if !self.copy_to_clipboard(text) { - return false; - } + // Give the selection owner a moment to take ownership before pasting. + std::thread::sleep(std::time::Duration::from_millis(5)); + if use_x11_paste { self.send_ctrl_v_x11(); } else { self.send_ctrl_v(); } - // Restore the user's clipboard once the paste has been consumed. The - // extra delay gives the target application time to read our text from - // the clipboard before we overwrite it again. - std::thread::sleep(std::time::Duration::from_millis(40)); - let saved = self.saved_clipboard.lock().unwrap().clone(); - let restored = saved.unwrap_or_default(); - let _ = self.copy_to_clipboard(&restored); - *self.last_injected.lock().unwrap() = Some(restored); + // 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(); true } @@ -461,7 +505,7 @@ impl UinputInjector { } // Clipboard fallback: copy + paste via our uinput device - let copied = self.copy_to_clipboard(s); + let copied = Self::copy_to_clipboard(s); if copied { eprintln!("[vietc] paste_string: clipboard OK, sending Ctrl+V"); self.send_ctrl_v(); @@ -510,7 +554,7 @@ impl UinputInjector { } /// Copy text to clipboard using wl-copy (Wayland) or xclip (X11). - fn copy_to_clipboard(&self, s: &str) -> bool { + fn copy_to_clipboard(s: &str) -> bool { // Try wl-copy (Wayland) via user_cmd { let mut cmd = Self::user_cmd("wl-copy"); @@ -623,10 +667,47 @@ impl UinputInjector { impl Drop for UinputInjector { 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); } } +/// 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) { + 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(); + let _ = UinputInjector::copy_to_clipboard(&restored); + st.last_injected = Some(restored); + st.restore_due = None; + } +} + fn strip_vn_diacritic(ch: char) -> char { match ch { 'à' | 'á' | 'ả' | 'ã' | 'ạ' | 'ă' | 'ằ' | 'ắ' | 'ẳ' | 'ẵ' | 'ặ' | 'â' | 'ầ' | 'ấ' | 'ẩ' | 'ẫ' | 'ậ' => 'a', diff --git a/uinputd/src/main.rs b/uinputd/src/main.rs index 0d5f1ab..e0707a1 100644 --- a/uinputd/src/main.rs +++ b/uinputd/src/main.rs @@ -4,6 +4,16 @@ use std::os::unix::net::{UnixListener, UnixStream}; use std::io::{BufRead, BufReader, Write}; use std::path::Path; 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 UI_SET_EVBIT: u64 = 0x40045564; @@ -45,14 +55,31 @@ struct input_id { version: u16, } -struct UinputDevice { - fd: i32, +/// 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: std::sync::Mutex>, - /// The last text we injected via the clipboard, used to distinguish our - /// own paste content from text the user copied with Ctrl+C. - last_injected: std::sync::Mutex>, + saved_clipboard: Option, + /// 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, + /// When set, the restorer thread should rewrite the user's clipboard at + /// this instant. `None` means no restore is pending. + restore_due: Option, + /// Set on shutdown so the restorer thread can exit. + shutdown: bool, +} + +struct ClipState { + inner: Mutex, + cv: Condvar, +} + +struct UinputDevice { + fd: i32, + clip: Arc, } impl UinputDevice { @@ -88,12 +115,22 @@ impl UinputDevice { std::mem::forget(file); 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); - Ok(Self { - fd, - saved_clipboard: std::sync::Mutex::new(None), - last_injected: std::sync::Mutex::new(None), - }) + Ok(Self { fd, clip }) } fn send_event(&self, type_: u16, code: u16, value: i32) { @@ -163,20 +200,29 @@ impl UinputDevice { } fn paste_unicode(&self, text: &str) { - // Save the user's clipboard before we clobber it, unless what is on the - // clipboard is our own previously-injected text. This keeps Ctrl+C / - // Ctrl+V working: every Vietnamese word is pasted via the clipboard, so - // without restoring it the user's copied content would be lost. - let current = read_clipboard(); + // 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 last = self.last_injected.lock().unwrap(); - let is_our_injection = matches!((¤t, &*last), (Some(c), Some(l)) if c == l); - if !is_our_injection { - *self.saved_clipboard.lock().unwrap() = current; + 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); + st.last_injected = Some(text.to_string()); } - copy_to_clipboard(text); + // 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); std::thread::sleep(std::time::Duration::from_millis(2)); self.send_key(47, 1); @@ -184,17 +230,56 @@ impl UinputDevice { self.send_key(29, 0); std::thread::sleep(std::time::Duration::from_millis(10)); - // Restore the user's clipboard after the paste has been consumed. - std::thread::sleep(std::time::Duration::from_millis(30)); - let saved = self.saved_clipboard.lock().unwrap().clone(); - let restored = saved.unwrap_or_default(); + // 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) { + 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); - *self.last_injected.lock().unwrap() = Some(restored); + st.last_injected = Some(restored); + st.restore_due = None; } } impl Drop for UinputDevice { 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::close(self.fd) }; eprintln!("[vietc-uinputd] Device destroyed");