Merge pull request #6 from vndangkhoa/devin/1782522350-clipboard-and-cicd

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: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: vndangkhoa <vonguyendangkhoa@gmail.com>
This commit is contained in:
vndangkhoa 2026-06-27 08:18:28 +07:00 committed by GitHub
commit fcd7b4e61f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 349 additions and 63 deletions

107
.github/workflows/build.yml vendored Normal file
View file

@ -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

View file

@ -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

View file

@ -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/"

View file

@ -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<Option<String>>,
/// 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<Option<String>>,
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>,
/// 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 {
file: File,
clip: Arc<ClipState>,
}
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<String> {
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"])
@ -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!((&current, &*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!((&current, &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<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();
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',

View file

@ -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<Option<String>>,
/// 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<Option<String>>,
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 {
fd: i32,
clip: Arc<ClipState>,
}
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!((&current, &*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!((&current, &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<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);
*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");