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:
commit
fcd7b4e61f
5 changed files with 349 additions and 63 deletions
107
.github/workflows/build.yml
vendored
Normal file
107
.github/workflows/build.yml
vendored
Normal 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
|
||||||
|
|
@ -179,14 +179,16 @@ else
|
||||||
SVGEOF
|
SVGEOF
|
||||||
fi
|
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 [ -f "$APPDIR/vietc.svg" ] && ! [ -f "$APPDIR/vietc.png" ]; then
|
||||||
if command -v rsvg-convert &>/dev/null; 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
|
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
|
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
|
elif command -v python3 &>/dev/null; then
|
||||||
python3 -c "
|
python3 -c "
|
||||||
import subprocess, sys
|
import subprocess, sys
|
||||||
|
|
@ -194,7 +196,7 @@ try:
|
||||||
subprocess.check_call(['rsvg-convert', '-w', '256', '-h', '256', '$APPDIR/vietc.svg', '-o', '$APPDIR/vietc.png'])
|
subprocess.check_call(['rsvg-convert', '-w', '256', '-h', '256', '$APPDIR/vietc.svg', '-o', '$APPDIR/vietc.png'])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
" 2>/dev/null
|
" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
# If no converter, appimagetool can use SVG directly
|
# If no converter, appimagetool can use SVG directly
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,19 @@ mkdir -p "$STAGING/usr/share/metainfo"
|
||||||
echo "[3/5] Installing binaries..."
|
echo "[3/5] Installing binaries..."
|
||||||
cp "$PROJECT_ROOT/target/release/vietc" "$STAGING/usr/bin/"
|
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/"
|
||||||
|
# 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/"
|
[ -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
|
# Desktop file
|
||||||
cp "$PROJECT_ROOT/packaging/appimage/vietc.desktop" "$STAGING/usr/share/applications/"
|
cp "$PROJECT_ROOT/packaging/appimage/vietc.desktop" "$STAGING/usr/share/applications/"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,17 @@
|
||||||
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;
|
||||||
|
|
@ -16,14 +25,31 @@ 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;
|
||||||
|
|
||||||
pub struct UinputInjector {
|
/// Shared clipboard bookkeeping between the injection path and the background
|
||||||
file: File,
|
/// restorer thread.
|
||||||
|
struct ClipInner {
|
||||||
/// The user's real clipboard contents, saved before we overwrite the
|
/// The user's real clipboard contents, saved before we overwrite the
|
||||||
/// clipboard to inject Unicode text, so we can restore it afterwards.
|
/// clipboard to inject Unicode text, so we can restore it afterwards.
|
||||||
saved_clipboard: std::sync::Mutex<Option<String>>,
|
saved_clipboard: Option<String>,
|
||||||
/// The last text we injected via the clipboard. Used to tell our own
|
/// The last text we wrote to the clipboard ourselves (an injected word or
|
||||||
/// injected text apart from text the user copied with Ctrl+C.
|
/// the restored user content). Used to tell our own writes apart from text
|
||||||
last_injected: std::sync::Mutex<Option<String>>,
|
/// 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 {}
|
unsafe impl Send for UinputInjector {}
|
||||||
|
|
@ -78,11 +104,21 @@ 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));
|
||||||
|
|
||||||
Ok(Self {
|
let clip = Arc::new(ClipState {
|
||||||
file,
|
inner: Mutex::new(ClipInner {
|
||||||
saved_clipboard: std::sync::Mutex::new(None),
|
saved_clipboard: None,
|
||||||
last_injected: std::sync::Mutex::new(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) {
|
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
|
/// Read the user's current clipboard contents (wl-paste on Wayland, xclip
|
||||||
/// on X11). Returns None if no clipboard tool is available or it is empty.
|
/// on X11). Returns None if no clipboard tool is available or it is empty.
|
||||||
fn read_clipboard(&self) -> Option<String> {
|
fn read_clipboard() -> Option<String> {
|
||||||
let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
|
let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
|
||||||
let (prog, args): (&str, &[&str]) = if is_wayland {
|
let (prog, args): (&str, &[&str]) = if is_wayland {
|
||||||
("wl-paste", &["-n"])
|
("wl-paste", &["-n"])
|
||||||
|
|
@ -403,34 +439,42 @@ impl UinputInjector {
|
||||||
///
|
///
|
||||||
/// Returns whether the text was successfully copied to the clipboard.
|
/// Returns whether the text was successfully copied to the clipboard.
|
||||||
fn paste_via_clipboard(&self, text: &str, use_x11_paste: bool) -> bool {
|
fn paste_via_clipboard(&self, text: &str, use_x11_paste: bool) -> bool {
|
||||||
// Snapshot the clipboard. If it differs from what we last injected, the
|
// Critical section: snapshot the clipboard, decide what to preserve,
|
||||||
// user changed it themselves (a real Ctrl+C), so remember it to restore.
|
// cancel any pending restore so the restorer cannot fire while we
|
||||||
let current = self.read_clipboard();
|
// 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 mut st = self.clip.inner.lock().unwrap();
|
||||||
let is_our_injection = matches!((¤t, &*last), (Some(c), Some(l)) if c == l);
|
let current = Self::read_clipboard();
|
||||||
if !is_our_injection {
|
let is_our_write =
|
||||||
*self.saved_clipboard.lock().unwrap() = current;
|
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) {
|
// Give the selection owner a moment to take ownership before pasting.
|
||||||
return false;
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||||
}
|
|
||||||
if use_x11_paste {
|
if use_x11_paste {
|
||||||
self.send_ctrl_v_x11();
|
self.send_ctrl_v_x11();
|
||||||
} else {
|
} else {
|
||||||
self.send_ctrl_v();
|
self.send_ctrl_v();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore the user's clipboard once the paste has been consumed. The
|
// Schedule a debounced restore. While the user keeps typing this gets
|
||||||
// extra delay gives the target application time to read our text from
|
// pushed back, so the user's clipboard is only restored once typing
|
||||||
// the clipboard before we overwrite it again.
|
// settles — never overwriting our freshly pasted word mid-stream.
|
||||||
std::thread::sleep(std::time::Duration::from_millis(40));
|
{
|
||||||
let saved = self.saved_clipboard.lock().unwrap().clone();
|
let mut st = self.clip.inner.lock().unwrap();
|
||||||
let restored = saved.unwrap_or_default();
|
st.restore_due = Some(Instant::now() + RESTORE_DEBOUNCE);
|
||||||
let _ = self.copy_to_clipboard(&restored);
|
}
|
||||||
*self.last_injected.lock().unwrap() = Some(restored);
|
self.clip.cv.notify_all();
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -461,7 +505,7 @@ impl UinputInjector {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clipboard fallback: copy + paste via our uinput device
|
// Clipboard fallback: copy + paste via our uinput device
|
||||||
let copied = self.copy_to_clipboard(s);
|
let copied = Self::copy_to_clipboard(s);
|
||||||
if copied {
|
if copied {
|
||||||
eprintln!("[vietc] paste_string: clipboard OK, sending Ctrl+V");
|
eprintln!("[vietc] paste_string: clipboard OK, sending Ctrl+V");
|
||||||
self.send_ctrl_v();
|
self.send_ctrl_v();
|
||||||
|
|
@ -510,7 +554,7 @@ impl UinputInjector {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copy text to clipboard using wl-copy (Wayland) or xclip (X11).
|
/// 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
|
// Try wl-copy (Wayland) via user_cmd
|
||||||
{
|
{
|
||||||
let mut cmd = Self::user_cmd("wl-copy");
|
let mut cmd = Self::user_cmd("wl-copy");
|
||||||
|
|
@ -623,10 +667,47 @@ impl UinputInjector {
|
||||||
|
|
||||||
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.
|
||||||
|
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 {
|
fn strip_vn_diacritic(ch: char) -> char {
|
||||||
match ch {
|
match ch {
|
||||||
'à' | 'á' | 'ả' | 'ã' | 'ạ' | 'ă' | 'ằ' | 'ắ' | 'ẳ' | 'ẵ' | 'ặ' | 'â' | 'ầ' | 'ấ' | 'ẩ' | 'ẫ' | 'ậ' => 'a',
|
'à' | 'á' | 'ả' | 'ã' | 'ạ' | 'ă' | 'ằ' | 'ắ' | 'ẳ' | 'ẵ' | 'ặ' | 'â' | 'ầ' | 'ấ' | 'ẩ' | 'ẫ' | 'ậ' => 'a',
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,16 @@ 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;
|
||||||
|
|
@ -45,14 +55,31 @@ struct input_id {
|
||||||
version: u16,
|
version: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct UinputDevice {
|
/// Shared clipboard bookkeeping between the command handler and the background
|
||||||
fd: i32,
|
/// restorer thread.
|
||||||
|
struct ClipInner {
|
||||||
/// The user's real clipboard contents, saved before we overwrite the
|
/// The user's real clipboard contents, saved before we overwrite the
|
||||||
/// clipboard to paste Unicode text, so we can restore it afterwards.
|
/// clipboard to paste Unicode text, so we can restore it afterwards.
|
||||||
saved_clipboard: std::sync::Mutex<Option<String>>,
|
saved_clipboard: Option<String>,
|
||||||
/// The last text we injected via the clipboard, used to distinguish our
|
/// The last text we wrote to the clipboard ourselves (an injected word or
|
||||||
/// own paste content from text the user copied with Ctrl+C.
|
/// the restored user content). Used to distinguish our own writes from
|
||||||
last_injected: std::sync::Mutex<Option<String>>,
|
/// 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 {
|
impl UinputDevice {
|
||||||
|
|
@ -88,12 +115,22 @@ 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 {
|
Ok(Self { fd, clip })
|
||||||
fd,
|
|
||||||
saved_clipboard: std::sync::Mutex::new(None),
|
|
||||||
last_injected: std::sync::Mutex::new(None),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_event(&self, type_: u16, code: u16, value: i32) {
|
fn send_event(&self, type_: u16, code: u16, value: i32) {
|
||||||
|
|
@ -163,20 +200,29 @@ impl UinputDevice {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn paste_unicode(&self, text: &str) {
|
fn paste_unicode(&self, text: &str) {
|
||||||
// Save the user's clipboard before we clobber it, unless what is on the
|
// Critical section: snapshot the clipboard, decide what to preserve,
|
||||||
// clipboard is our own previously-injected text. This keeps Ctrl+C /
|
// cancel any pending restore so the restorer cannot fire while we are
|
||||||
// Ctrl+V working: every Vietnamese word is pasted via the clipboard, so
|
// pasting, and put our word on the clipboard. The read and write happen
|
||||||
// without restoring it the user's copied content would be lost.
|
// under the lock so they can never interleave with the restorer.
|
||||||
let current = read_clipboard();
|
|
||||||
{
|
{
|
||||||
let last = self.last_injected.lock().unwrap();
|
let mut st = self.clip.inner.lock().unwrap();
|
||||||
let is_our_injection = matches!((¤t, &*last), (Some(c), Some(l)) if c == l);
|
let current = read_clipboard();
|
||||||
if !is_our_injection {
|
let is_our_write =
|
||||||
*self.saved_clipboard.lock().unwrap() = current;
|
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);
|
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);
|
||||||
|
|
@ -184,17 +230,56 @@ impl UinputDevice {
|
||||||
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));
|
||||||
|
|
||||||
// Restore the user's clipboard after the paste has been consumed.
|
// Schedule a debounced restore. While the user keeps typing this gets
|
||||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
// pushed back, so the user's clipboard is only restored once typing
|
||||||
let saved = self.saved_clipboard.lock().unwrap().clone();
|
// settles — never overwriting our freshly pasted word mid-stream.
|
||||||
let restored = saved.unwrap_or_default();
|
{
|
||||||
|
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);
|
copy_to_clipboard(&restored);
|
||||||
*self.last_injected.lock().unwrap() = Some(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");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue