vietc/daemon/src/app_state.rs
Khoa Vo 98ce9def79
Some checks are pending
Build & Release / Build & test (push) Waiting to run
Build & Release / Build packages (push) Blocked by required conditions
feat: Flatpak tray, X11 dlopen window query, desktop menu entry
- 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
2026-06-29 14:32:30 +07:00

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")
}