// 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 { 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 { // 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 { // 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 { 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 { // 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 { // 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, /// Default English apps from config english_apps: Vec, /// Default Vietnamese apps from config vietnamese_apps: Vec, /// Bypass apps from config bypass_apps: Vec, /// Global enabled state global_enabled: bool, } impl AppStateManager { pub fn new( english_apps: Vec, vietnamese_apps: Vec, bypass_apps: Vec, 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 { 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, vietnamese_apps: Vec, bypass_apps: Vec, ) -> &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> { 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::>(&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") }