422 lines
12 KiB
Rust
422 lines
12 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::{Duration, Instant};
|
|
use tauri::{AppHandle, Emitter, State};
|
|
use x11rb::atom_manager;
|
|
use x11rb::connection::Connection;
|
|
use x11rb::protocol::xproto::{
|
|
AtomEnum, ChangeWindowAttributesAux, ClientMessageEvent, ConfigureWindowAux,
|
|
ConnectionExt as XprotoConnectionExt, EventMask, StackMode,
|
|
};
|
|
use x11rb::protocol::Event;
|
|
use x11rb::rust_connection::RustConnection;
|
|
|
|
atom_manager! {
|
|
pub Atoms: AtomsCookie {
|
|
_NET_CLIENT_LIST,
|
|
_NET_WM_PID,
|
|
_NET_WM_NAME,
|
|
_XEMBED,
|
|
_XEMBED_INFO,
|
|
WM_PROTOCOLS,
|
|
WM_DELETE_WINDOW,
|
|
UTF8_STRING,
|
|
}
|
|
}
|
|
|
|
// XEmbed protocol message opcodes (subset).
|
|
const XEMBED_EMBEDDED_NOTIFY: u32 = 0;
|
|
|
|
#[derive(Clone)]
|
|
struct X11Conn {
|
|
conn: Arc<RustConnection>,
|
|
atoms: Atoms,
|
|
root: u32,
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Clone)]
|
|
pub struct AppInfo {
|
|
pub xid: u32,
|
|
pub pid: u32,
|
|
pub command: String,
|
|
}
|
|
|
|
pub struct X11State {
|
|
conn: Mutex<Option<X11Conn>>,
|
|
tauri_xid: Mutex<Option<u32>>,
|
|
windows: Arc<Mutex<HashMap<u32, AppInfo>>>,
|
|
}
|
|
|
|
impl X11State {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
conn: Mutex::new(None),
|
|
tauri_xid: Mutex::new(None),
|
|
windows: Arc::new(Mutex::new(HashMap::new())),
|
|
}
|
|
}
|
|
|
|
fn ensure_conn(&self, app: &AppHandle) -> Result<X11Conn, String> {
|
|
let mut guard = self.conn.lock().unwrap();
|
|
if let Some(c) = guard.as_ref() {
|
|
return Ok(c.clone());
|
|
}
|
|
let (raw, screen_num) = RustConnection::connect(None).map_err(|e| e.to_string())?;
|
|
let conn = Arc::new(raw);
|
|
let atoms = Atoms::new(&*conn)
|
|
.map_err(|e| e.to_string())?
|
|
.reply()
|
|
.map_err(|e| e.to_string())?;
|
|
let root = conn.setup().roots[screen_num].root;
|
|
|
|
let conn_t = conn.clone();
|
|
let windows_t = self.windows.clone();
|
|
let app_t = app.clone();
|
|
std::thread::spawn(move || event_loop(conn_t, windows_t, app_t));
|
|
|
|
let c = X11Conn { conn, atoms, root };
|
|
*guard = Some(c.clone());
|
|
Ok(c)
|
|
}
|
|
|
|
fn ensure_xid(
|
|
&self,
|
|
app: &AppHandle,
|
|
window: &tauri::WebviewWindow,
|
|
) -> Result<u32, String> {
|
|
let mut guard = self.tauri_xid.lock().unwrap();
|
|
if let Some(xid) = *guard {
|
|
return Ok(xid);
|
|
}
|
|
let xid = fetch_tauri_xid(app, window.clone())?;
|
|
*guard = Some(xid);
|
|
Ok(xid)
|
|
}
|
|
}
|
|
|
|
fn fetch_tauri_xid(app: &AppHandle, window: tauri::WebviewWindow) -> Result<u32, String> {
|
|
use std::sync::mpsc;
|
|
let (tx, rx) = mpsc::sync_channel(1);
|
|
app.run_on_main_thread(move || {
|
|
let result: Result<u32, String> = (|| {
|
|
use gtk::prelude::*;
|
|
let gtk_win = window.gtk_window().map_err(|e| e.to_string())?;
|
|
let gdk_win = gtk_win.window().ok_or_else(|| "no GDK window yet".to_string())?;
|
|
let x11: gdkx11::X11Window = gdk_win.downcast().map_err(|_| {
|
|
"Tauri's window is not an X11 window — likely running on Wayland. \
|
|
GDK_BACKEND=x11 should force X11; check that XWayland is installed \
|
|
(apt install xwayland) and restart the app."
|
|
.to_string()
|
|
})?;
|
|
Ok(x11.xid() as u32)
|
|
})();
|
|
let _ = tx.send(result);
|
|
})
|
|
.map_err(|e| e.to_string())?;
|
|
rx.recv().map_err(|e| e.to_string())?
|
|
}
|
|
|
|
fn event_loop(
|
|
conn: Arc<RustConnection>,
|
|
windows: Arc<Mutex<HashMap<u32, AppInfo>>>,
|
|
app: AppHandle,
|
|
) {
|
|
loop {
|
|
match conn.wait_for_event() {
|
|
Ok(Event::DestroyNotify(e)) => {
|
|
let removed = windows.lock().unwrap().remove(&e.window);
|
|
if removed.is_some() {
|
|
let _ = app.emit("app:destroyed", e.window);
|
|
}
|
|
}
|
|
Ok(_) => {}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct LaunchOpts {
|
|
pub command: String,
|
|
}
|
|
|
|
#[derive(Serialize, Clone)]
|
|
pub struct LaunchedApp {
|
|
pub xid: u32,
|
|
pub title: String,
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn app_launch(
|
|
app: AppHandle,
|
|
state: State<'_, X11State>,
|
|
window: tauri::WebviewWindow,
|
|
opts: LaunchOpts,
|
|
) -> Result<LaunchedApp, String> {
|
|
let c = state.ensure_conn(&app)?;
|
|
let parent_xid = state.ensure_xid(&app, &window)?;
|
|
|
|
let parts = shell_words::split(&opts.command).map_err(|e| e.to_string())?;
|
|
if parts.is_empty() {
|
|
return Err("empty command".into());
|
|
}
|
|
let (program, args) = parts.split_first().unwrap();
|
|
let child = std::process::Command::new(program)
|
|
.args(args)
|
|
.stdin(std::process::Stdio::null())
|
|
.spawn()
|
|
.map_err(|e| format!("spawn failed: {}", e))?;
|
|
let root_pid = child.id();
|
|
// Detach: we don't wait() — Tauri exiting will SIGHUP it via the X11 parent.
|
|
std::mem::forget(child);
|
|
|
|
let client_xid = wait_for_window_by_pid(&c, root_pid, Duration::from_secs(20))
|
|
.ok_or_else(|| "could not find window for launched process".to_string())?;
|
|
|
|
embed_window(&c, client_xid, parent_xid)?;
|
|
|
|
let title = read_window_title(&c, client_xid).unwrap_or_else(|| opts.command.clone());
|
|
|
|
state.windows.lock().unwrap().insert(
|
|
client_xid,
|
|
AppInfo {
|
|
xid: client_xid,
|
|
pid: root_pid,
|
|
command: opts.command.clone(),
|
|
},
|
|
);
|
|
|
|
Ok(LaunchedApp { xid: client_xid, title })
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn app_set_geometry(
|
|
app: AppHandle,
|
|
state: State<'_, X11State>,
|
|
window: tauri::WebviewWindow,
|
|
xid: u32,
|
|
x: f64,
|
|
y: f64,
|
|
width: f64,
|
|
height: f64,
|
|
) -> Result<(), String> {
|
|
let c = state.ensure_conn(&app)?;
|
|
let sf = window.scale_factor().map_err(|e| e.to_string())?;
|
|
let outer = window.outer_position().map_err(|e| e.to_string())?;
|
|
let inner = window.inner_position().map_err(|e| e.to_string())?;
|
|
let off_x = inner.x - outer.x;
|
|
let off_y = inner.y - outer.y;
|
|
let phys_x = (x * sf).round() as i32 + off_x;
|
|
let phys_y = (y * sf).round() as i32 + off_y;
|
|
let phys_w = ((width * sf).round() as i32).max(1) as u32;
|
|
let phys_h = ((height * sf).round() as i32).max(1) as u32;
|
|
c.conn
|
|
.configure_window(
|
|
xid,
|
|
&ConfigureWindowAux::new()
|
|
.x(phys_x)
|
|
.y(phys_y)
|
|
.width(phys_w)
|
|
.height(phys_h)
|
|
.stack_mode(StackMode::ABOVE),
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
c.conn.flush().map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn app_set_visible(
|
|
app: AppHandle,
|
|
state: State<'_, X11State>,
|
|
xid: u32,
|
|
visible: bool,
|
|
) -> Result<(), String> {
|
|
let c = state.ensure_conn(&app)?;
|
|
if visible {
|
|
c.conn.map_window(xid).map_err(|e| e.to_string())?;
|
|
} else {
|
|
c.conn.unmap_window(xid).map_err(|e| e.to_string())?;
|
|
}
|
|
c.conn.flush().map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn app_set_all_visible(
|
|
app: AppHandle,
|
|
state: State<'_, X11State>,
|
|
visible: bool,
|
|
) -> Result<(), String> {
|
|
let c = state.ensure_conn(&app)?;
|
|
let xids: Vec<u32> = state.windows.lock().unwrap().keys().copied().collect();
|
|
for xid in xids {
|
|
if visible {
|
|
let _ = c.conn.map_window(xid);
|
|
} else {
|
|
let _ = c.conn.unmap_window(xid);
|
|
}
|
|
}
|
|
let _ = c.conn.flush();
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn app_close(app: AppHandle, state: State<'_, X11State>, xid: u32) -> Result<(), String> {
|
|
let c = state.ensure_conn(&app)?;
|
|
let event = ClientMessageEvent::new(
|
|
32,
|
|
xid,
|
|
c.atoms.WM_PROTOCOLS,
|
|
[c.atoms.WM_DELETE_WINDOW, 0, 0, 0, 0],
|
|
);
|
|
let _ = c.conn.send_event(false, xid, EventMask::NO_EVENT, event);
|
|
c.conn.flush().map_err(|e| e.to_string())?;
|
|
state.windows.lock().unwrap().remove(&xid);
|
|
Ok(())
|
|
}
|
|
|
|
fn embed_window(c: &X11Conn, client_xid: u32, parent_xid: u32) -> Result<(), String> {
|
|
// Listen for destroy / property changes on the embedded window.
|
|
c.conn
|
|
.change_window_attributes(
|
|
client_xid,
|
|
&ChangeWindowAttributesAux::new()
|
|
.event_mask(EventMask::STRUCTURE_NOTIFY | EventMask::PROPERTY_CHANGE),
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
// Reparent into Tauri's X11 toplevel.
|
|
c.conn
|
|
.reparent_window(client_xid, parent_xid, 0, 0)
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
c.conn.map_window(client_xid).map_err(|e| e.to_string())?;
|
|
|
|
// Stack above the webview's GDK X11 surface (siblings under Tauri toplevel).
|
|
c.conn
|
|
.configure_window(
|
|
client_xid,
|
|
&ConfigureWindowAux::new().stack_mode(StackMode::ABOVE),
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
c.conn.flush().map_err(|e| e.to_string())?;
|
|
|
|
// Inform the client it is now embedded (XEmbed protocol).
|
|
let event = ClientMessageEvent::new(
|
|
32,
|
|
client_xid,
|
|
c.atoms._XEMBED,
|
|
[x11rb::CURRENT_TIME, XEMBED_EMBEDDED_NOTIFY, 0, parent_xid, 0],
|
|
);
|
|
let _ = c.conn.send_event(false, client_xid, EventMask::NO_EVENT, event);
|
|
c.conn.flush().map_err(|e| e.to_string())?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn wait_for_window_by_pid(c: &X11Conn, root_pid: u32, timeout: Duration) -> Option<u32> {
|
|
let deadline = Instant::now() + timeout;
|
|
while Instant::now() < deadline {
|
|
let pids = collect_descendant_pids(root_pid);
|
|
if let Some(xid) = find_window_for_pids(c, &pids) {
|
|
return Some(xid);
|
|
}
|
|
std::thread::sleep(Duration::from_millis(150));
|
|
}
|
|
None
|
|
}
|
|
|
|
fn collect_descendant_pids(root: u32) -> Vec<u32> {
|
|
let mut parents: HashMap<u32, u32> = HashMap::new();
|
|
let Ok(entries) = std::fs::read_dir("/proc") else {
|
|
return vec![root];
|
|
};
|
|
for entry in entries.flatten() {
|
|
let name = entry.file_name();
|
|
let Some(name_str) = name.to_str() else {
|
|
continue;
|
|
};
|
|
let Ok(pid) = name_str.parse::<u32>() else {
|
|
continue;
|
|
};
|
|
let stat_path = entry.path().join("stat");
|
|
let Ok(stat) = std::fs::read_to_string(&stat_path) else {
|
|
continue;
|
|
};
|
|
if let Some(rparen) = stat.rfind(')') {
|
|
let rest = &stat[rparen + 1..];
|
|
let fields: Vec<&str> = rest.split_whitespace().collect();
|
|
if let Some(ppid_str) = fields.get(1) {
|
|
if let Ok(ppid) = ppid_str.parse::<u32>() {
|
|
parents.insert(pid, ppid);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let mut result = vec![root];
|
|
let mut grew = true;
|
|
while grew {
|
|
grew = false;
|
|
for (&pid, &ppid) in &parents {
|
|
if result.contains(&ppid) && !result.contains(&pid) {
|
|
result.push(pid);
|
|
grew = true;
|
|
}
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
fn find_window_for_pids(c: &X11Conn, pids: &[u32]) -> Option<u32> {
|
|
let reply = c
|
|
.conn
|
|
.get_property(false, c.root, c.atoms._NET_CLIENT_LIST, AtomEnum::WINDOW, 0, 1024)
|
|
.ok()?
|
|
.reply()
|
|
.ok()?;
|
|
let windows: Vec<u32> = reply.value32()?.collect();
|
|
for xid in windows {
|
|
if let Some(wpid) = read_window_pid(c, xid) {
|
|
if pids.contains(&wpid) {
|
|
return Some(xid);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn read_window_pid(c: &X11Conn, xid: u32) -> Option<u32> {
|
|
let reply = c
|
|
.conn
|
|
.get_property(false, xid, c.atoms._NET_WM_PID, AtomEnum::CARDINAL, 0, 1)
|
|
.ok()?
|
|
.reply()
|
|
.ok()?;
|
|
reply.value32().and_then(|mut it| it.next())
|
|
}
|
|
|
|
fn read_window_title(c: &X11Conn, xid: u32) -> Option<String> {
|
|
let reply = c
|
|
.conn
|
|
.get_property(false, xid, c.atoms._NET_WM_NAME, c.atoms.UTF8_STRING, 0, 1024)
|
|
.ok()?
|
|
.reply()
|
|
.ok()?;
|
|
let s = String::from_utf8_lossy(&reply.value).to_string();
|
|
if s.is_empty() {
|
|
let r2 = c
|
|
.conn
|
|
.get_property(false, xid, AtomEnum::WM_NAME, AtomEnum::STRING, 0, 1024)
|
|
.ok()?
|
|
.reply()
|
|
.ok()?;
|
|
Some(String::from_utf8_lossy(&r2.value).to_string())
|
|
} else {
|
|
Some(s)
|
|
}
|
|
}
|