- Add system tray (vietc-tray) to Flatpak build; command changed to
vietc-tray which spawns the daemon
- Desktop menu entry: Viet+ appears in app launcher for search/install/uninstall
- Tray fixes: find_sibling_binary tries {name}-daemon fallback for Flatpak;
is_daemon_running checks both vietc and vietc-daemon process names
- Native X11 _NET_ACTIVE_WINDOW via dlopen(libX11.so.6) — third fallback in
get_active_window_id() that works inside Flatpak sandbox (no xdotool/xprop)
- Update README with install/uninstall commands
- Update CHANGELOG
393 lines
12 KiB
Rust
393 lines
12 KiB
Rust
// SPDX-License-Identifier: MIT
|
|
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::process::Command;
|
|
|
|
/// 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 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 xdotool first (fast, direct)
|
|
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
|
|
}
|
|
|
|
/// Detect the currently focused window's class name
|
|
pub fn get_focused_window_class() -> Option<String> {
|
|
// Try Wayland first (wlr-foreign-toplevel)
|
|
if let Some(class) = get_wayland_window_class() {
|
|
return Some(class);
|
|
}
|
|
|
|
// Try X11 via xdotool
|
|
if let Some(class) = get_x11_window_class() {
|
|
return Some(class);
|
|
}
|
|
|
|
// Fallback: try reading from /proc
|
|
if let Some(class) = get_proc_window_class() {
|
|
return Some(class);
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn get_x11_window_class() -> Option<String> {
|
|
let output = Command::new("xdotool")
|
|
.args(["getactivewindow", "getwindowclassname"])
|
|
.output()
|
|
.ok()?;
|
|
|
|
if output.status.success() {
|
|
let class = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
if !class.is_empty() {
|
|
return Some(class.to_lowercase());
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn get_wayland_window_class() -> Option<String> {
|
|
// Try wlr-foreign-toplevel-management protocol via wlrctl
|
|
let output = Command::new("wlrctl")
|
|
.args(["toplevel", "list", "--format", "%app-id"])
|
|
.output()
|
|
.ok()?;
|
|
|
|
if output.status.success() {
|
|
let lines = String::from_utf8_lossy(&output.stdout);
|
|
// First line is typically the focused window
|
|
if let Some(class) = lines.lines().next() {
|
|
let class = class.trim().to_string();
|
|
if !class.is_empty() {
|
|
return Some(class.to_lowercase());
|
|
}
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn get_proc_window_class() -> Option<String> {
|
|
// Read /proc/active-windows if available (some compositors expose this)
|
|
let content = fs::read_to_string("/proc/active-windows").ok()?;
|
|
// Format: pid window_class window_title
|
|
content
|
|
.lines()
|
|
.next()?
|
|
.split_whitespace()
|
|
.nth(1)
|
|
.map(|s| s.to_lowercase())
|
|
}
|
|
|
|
/// Manages per-app IME state
|
|
pub struct AppStateManager {
|
|
/// Current app class (lowercase)
|
|
current_app: String,
|
|
/// Per-app overrides (user toggled manually)
|
|
overrides: HashMap<String, bool>,
|
|
/// Default English apps from config
|
|
english_apps: Vec<String>,
|
|
/// Default Vietnamese apps from config
|
|
vietnamese_apps: Vec<String>,
|
|
/// Bypass apps from config
|
|
bypass_apps: Vec<String>,
|
|
/// Global enabled state
|
|
global_enabled: bool,
|
|
}
|
|
|
|
impl AppStateManager {
|
|
pub fn new(
|
|
english_apps: Vec<String>,
|
|
vietnamese_apps: Vec<String>,
|
|
bypass_apps: Vec<String>,
|
|
global_enabled: bool,
|
|
) -> Self {
|
|
Self {
|
|
current_app: String::new(),
|
|
overrides: HashMap::new(),
|
|
english_apps: english_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(),
|
|
global_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> {
|
|
if new_class == self.current_app {
|
|
return None; // No change
|
|
}
|
|
|
|
let old_app = self.current_app.clone();
|
|
self.current_app = new_class;
|
|
|
|
eprintln!("[vietc] App: {} → {}", old_app, self.current_app);
|
|
|
|
let should_enable = self.get_default_state();
|
|
Some(should_enable)
|
|
}
|
|
|
|
/// Get the default Vietnamese state for the current app
|
|
fn get_default_state(&self) -> bool {
|
|
if !self.global_enabled {
|
|
return false;
|
|
}
|
|
|
|
// Check user override first
|
|
if let Some(&override_state) = self.overrides.get(&self.current_app) {
|
|
return override_state;
|
|
}
|
|
|
|
// Check config defaults
|
|
for pattern in &self.english_apps {
|
|
if self.current_app.contains(pattern.as_str()) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
for pattern in &self.vietnamese_apps {
|
|
if self.current_app.contains(pattern.as_str()) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Default: enabled
|
|
true
|
|
}
|
|
|
|
/// Toggle the IME state for the current app (manual override)
|
|
pub fn toggle_current_app(&mut self) -> bool {
|
|
let current_state = self.get_default_state();
|
|
let new_state = !current_state;
|
|
self.overrides.insert(self.current_app.clone(), new_state);
|
|
eprintln!(
|
|
"[vietc] {} → {} (manual override)",
|
|
self.current_app,
|
|
if new_state { "Vietnamese" } else { "English" }
|
|
);
|
|
if let Err(e) = self.save_overrides() {
|
|
eprintln!("[vietc] Failed to save app overrides: {}", e);
|
|
}
|
|
new_state
|
|
}
|
|
|
|
/// Clear all overrides
|
|
#[allow(dead_code)]
|
|
pub fn clear_overrides(&mut self) {
|
|
self.overrides.clear();
|
|
eprintln!("[vietc] All app overrides cleared");
|
|
}
|
|
|
|
/// Update app lists from reloaded config
|
|
pub fn update_lists(
|
|
&mut self,
|
|
english_apps: Vec<String>,
|
|
vietnamese_apps: Vec<String>,
|
|
bypass_apps: Vec<String>,
|
|
) -> &Self {
|
|
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.bypass_apps = bypass_apps.iter().map(|s| s.to_lowercase()).collect();
|
|
eprintln!(
|
|
"[vietc] App lists updated: {} English, {} Vietnamese, {} Bypass",
|
|
self.english_apps.len(),
|
|
self.vietnamese_apps.len(),
|
|
self.bypass_apps.len()
|
|
);
|
|
self
|
|
}
|
|
|
|
/// Check if the currently active application should bypass the IME completely
|
|
pub fn is_current_app_bypassed(&self) -> bool {
|
|
for pattern in &self.bypass_apps {
|
|
if self.current_app.contains(pattern.as_str()) {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Save overrides to config file
|
|
#[allow(dead_code)]
|
|
pub fn save_overrides(&self) -> Result<(), Box<dyn std::error::Error>> {
|
|
let path = override_path();
|
|
let content = toml::to_string(&self.overrides)?;
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
fs::write(&path, content)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Load overrides from config file
|
|
pub fn load_overrides(&mut self) {
|
|
let path = override_path();
|
|
if let Ok(content) = fs::read_to_string(&path) {
|
|
if let Ok(overrides) = toml::from_str::<HashMap<String, bool>>(&content) {
|
|
self.overrides = overrides;
|
|
eprintln!("[vietc] Loaded {} app overrides", self.overrides.len());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn current_app(&self) -> &str {
|
|
&self.current_app
|
|
}
|
|
}
|
|
|
|
fn override_path() -> std::path::PathBuf {
|
|
std::env::var("XDG_CONFIG_HOME")
|
|
.ok()
|
|
.map(std::path::PathBuf::from)
|
|
.or_else(|| {
|
|
std::env::var("HOME")
|
|
.ok()
|
|
.map(|h| std::path::PathBuf::from(h).join(".config"))
|
|
})
|
|
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
|
.join("vietc")
|
|
.join("overrides.toml")
|
|
}
|