initial commit
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
use crate::hardware::{hid, sysfs};
|
||||
use crate::ipc::protocol::{DaemonRequest, DaemonResponse};
|
||||
use crate::runtime::client;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_backlight() -> u8 {
|
||||
sysfs::read_backlight_level()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_backlight(level: u8) -> Result<(), String> {
|
||||
set_backlight_daemon_first(level)
|
||||
}
|
||||
|
||||
fn daemon_ack_or_transport_fallback(
|
||||
response: Result<DaemonResponse, String>,
|
||||
action: &str,
|
||||
fallback: impl FnOnce() -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
match response {
|
||||
Ok(DaemonResponse::Ack) => Ok(()),
|
||||
Ok(DaemonResponse::Error { message }) => Err(message),
|
||||
Ok(other) => Err(format!(
|
||||
"Unexpected daemon response while {action}: {other:?}"
|
||||
)),
|
||||
Err(_) => fallback(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_backlight_daemon_first(level: u8) -> Result<(), String> {
|
||||
daemon_ack_or_transport_fallback(
|
||||
client::request(DaemonRequest::SetBacklight { level }),
|
||||
"setting backlight",
|
||||
|| hid::set_backlight(level),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn daemon_ack_mapping_surfaces_error_and_unexpected_response() {
|
||||
assert!(
|
||||
daemon_ack_or_transport_fallback(Ok(DaemonResponse::Ack), "testing", || {
|
||||
Err("fallback should not run".into())
|
||||
})
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
daemon_ack_or_transport_fallback(
|
||||
Ok(DaemonResponse::Error {
|
||||
message: "daemon rejected".into(),
|
||||
}),
|
||||
"testing",
|
||||
|| Ok(()),
|
||||
)
|
||||
.expect_err("daemon error should fail"),
|
||||
"daemon rejected"
|
||||
);
|
||||
|
||||
assert!(
|
||||
daemon_ack_or_transport_fallback(Ok(DaemonResponse::Pong), "testing", || Ok(()))
|
||||
.expect_err("unexpected response should fail")
|
||||
.contains("Unexpected daemon response")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daemon_ack_mapping_falls_back_only_on_transport_failure() {
|
||||
assert!(
|
||||
daemon_ack_or_transport_fallback(Err("socket missing".into()), "testing", || {
|
||||
Ok(())
|
||||
})
|
||||
.is_ok()
|
||||
);
|
||||
assert_eq!(
|
||||
daemon_ack_or_transport_fallback(Err("socket missing".into()), "testing", || {
|
||||
Err("fallback failed".into())
|
||||
})
|
||||
.expect_err("fallback failure should propagate"),
|
||||
"fallback failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EvdevDevice {
|
||||
pub event_path: String,
|
||||
pub name: String,
|
||||
pub phys: Option<String>,
|
||||
pub bustype: Option<String>,
|
||||
pub vendor: Option<String>,
|
||||
pub product: Option<String>,
|
||||
pub cap_ev: Option<String>,
|
||||
pub cap_key: Option<String>,
|
||||
pub cap_abs: Option<String>,
|
||||
pub cap_msc: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EvdevEvent {
|
||||
pub ts_sec: i64,
|
||||
pub ts_usec: i64,
|
||||
pub type_code: u16,
|
||||
pub code: u16,
|
||||
pub value: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EvdevEventMulti {
|
||||
pub event_path: String,
|
||||
pub ts_sec: i64,
|
||||
pub ts_usec: i64,
|
||||
pub type_code: u16,
|
||||
pub code: u16,
|
||||
pub value: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HidDevice {
|
||||
pub id: String,
|
||||
pub driver: Option<String>,
|
||||
pub hid_id: Option<String>,
|
||||
pub hid_name: Option<String>,
|
||||
pub hid_phys: Option<String>,
|
||||
pub hidraw_nodes: Vec<String>,
|
||||
pub input_event_nodes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReportDescriptor {
|
||||
pub len: usize,
|
||||
pub hex: String,
|
||||
pub report_ids: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HidrawSample {
|
||||
pub ts_ms: u128,
|
||||
pub hex: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HidrawCapture {
|
||||
pub hidraw_path: String,
|
||||
pub samples: Vec<HidrawSample>,
|
||||
pub stderr: Option<String>,
|
||||
}
|
||||
|
||||
fn read_trimmed(path: impl AsRef<Path>) -> Option<String> {
|
||||
fs::read_to_string(path).ok().map(|s| s.trim().to_string())
|
||||
}
|
||||
|
||||
fn is_event_node_name(name: &str) -> bool {
|
||||
if !name.starts_with("event") {
|
||||
return false;
|
||||
}
|
||||
name["event".len()..].chars().all(|c| c.is_ascii_digit())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn diag_list_evdev() -> Result<Vec<EvdevDevice>, String> {
|
||||
let base = Path::new("/sys/class/input");
|
||||
let mut out: Vec<EvdevDevice> = Vec::new();
|
||||
let entries =
|
||||
fs::read_dir(base).map_err(|e| format!("Failed to read /sys/class/input: {e}"))?;
|
||||
|
||||
for ent in entries.flatten() {
|
||||
let file_name = ent.file_name();
|
||||
let file_name = file_name.to_string_lossy().to_string();
|
||||
if !is_event_node_name(&file_name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let sys = ent.path();
|
||||
let dev = sys.join("device");
|
||||
|
||||
let name = read_trimmed(dev.join("name")).unwrap_or_else(|| "(unknown)".into());
|
||||
let phys = read_trimmed(dev.join("phys"));
|
||||
|
||||
let bustype = read_trimmed(dev.join("id/bustype"));
|
||||
let vendor = read_trimmed(dev.join("id/vendor"));
|
||||
let product = read_trimmed(dev.join("id/product"));
|
||||
|
||||
let cap_ev = read_trimmed(dev.join("capabilities/ev"));
|
||||
let cap_key = read_trimmed(dev.join("capabilities/key"));
|
||||
let cap_abs = read_trimmed(dev.join("capabilities/abs"));
|
||||
let cap_msc = read_trimmed(dev.join("capabilities/msc"));
|
||||
|
||||
out.push(EvdevDevice {
|
||||
event_path: format!("/dev/input/{file_name}"),
|
||||
name,
|
||||
phys,
|
||||
bustype,
|
||||
vendor,
|
||||
product,
|
||||
cap_ev,
|
||||
cap_key,
|
||||
cap_abs,
|
||||
cap_msc,
|
||||
});
|
||||
}
|
||||
|
||||
out.sort_by(|a, b| a.event_path.cmp(&b.event_path));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn validate_dev_input_event(path: &str) -> Result<(), String> {
|
||||
if !path.starts_with("/dev/input/event") {
|
||||
return Err("Only /dev/input/event* paths are allowed".into());
|
||||
}
|
||||
let suffix = &path["/dev/input/event".len()..];
|
||||
if suffix.is_empty() || !suffix.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err("Invalid event node path".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct InputEvent {
|
||||
time: libc::timeval,
|
||||
type_: u16,
|
||||
code: u16,
|
||||
value: i32,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn diag_capture_evdev(event_path: String, seconds: u32) -> Result<Vec<EvdevEvent>, String> {
|
||||
validate_dev_input_event(&event_path)?;
|
||||
let seconds = seconds.clamp(1, 30);
|
||||
|
||||
let file =
|
||||
fs::File::open(&event_path).map_err(|e| format!("Failed to open {event_path}: {e}"))?;
|
||||
let fd = file.as_raw_fd();
|
||||
|
||||
// Non-blocking reads.
|
||||
let flags = nix::fcntl::fcntl(fd, nix::fcntl::FcntlArg::F_GETFL)
|
||||
.map_err(|e| format!("fcntl(F_GETFL) failed: {e}"))?;
|
||||
let mut oflags = nix::fcntl::OFlag::from_bits_truncate(flags);
|
||||
oflags.insert(nix::fcntl::OFlag::O_NONBLOCK);
|
||||
nix::fcntl::fcntl(fd, nix::fcntl::FcntlArg::F_SETFL(oflags))
|
||||
.map_err(|e| format!("fcntl(F_SETFL) failed: {e}"))?;
|
||||
|
||||
let start = Instant::now();
|
||||
let deadline = start + Duration::from_secs(seconds as u64);
|
||||
let mut out: Vec<EvdevEvent> = Vec::new();
|
||||
let mut buf = vec![0u8; 4096];
|
||||
|
||||
while Instant::now() < deadline {
|
||||
match nix::unistd::read(fd, &mut buf) {
|
||||
Ok(0) => {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
Ok(n) => {
|
||||
let mut offset = 0usize;
|
||||
while offset + std::mem::size_of::<InputEvent>() <= n {
|
||||
let ptr = unsafe { buf.as_ptr().add(offset) as *const InputEvent };
|
||||
let ev = unsafe { *ptr };
|
||||
out.push(EvdevEvent {
|
||||
ts_sec: ev.time.tv_sec as i64,
|
||||
ts_usec: ev.time.tv_usec as i64,
|
||||
type_code: ev.type_,
|
||||
code: ev.code,
|
||||
value: ev.value,
|
||||
});
|
||||
offset += std::mem::size_of::<InputEvent>();
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
if err == nix::errno::Errno::EAGAIN {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
continue;
|
||||
}
|
||||
return Err(format!("Failed to read {event_path}: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn diag_capture_evdev_multi(
|
||||
event_paths: Vec<String>,
|
||||
seconds: u32,
|
||||
) -> Result<Vec<EvdevEventMulti>, String> {
|
||||
if event_paths.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let seconds = seconds.clamp(1, 30);
|
||||
for p in &event_paths {
|
||||
validate_dev_input_event(p)?;
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(seconds as u64);
|
||||
let out: Arc<Mutex<Vec<EvdevEventMulti>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut handles = Vec::new();
|
||||
|
||||
for path in event_paths {
|
||||
let out = out.clone();
|
||||
handles.push(std::thread::spawn(move || {
|
||||
let file = match fs::File::open(&path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return,
|
||||
};
|
||||
let fd = file.as_raw_fd();
|
||||
|
||||
let flags = match nix::fcntl::fcntl(fd, nix::fcntl::FcntlArg::F_GETFL) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
let mut oflags = nix::fcntl::OFlag::from_bits_truncate(flags);
|
||||
oflags.insert(nix::fcntl::OFlag::O_NONBLOCK);
|
||||
let _ = nix::fcntl::fcntl(fd, nix::fcntl::FcntlArg::F_SETFL(oflags));
|
||||
|
||||
let mut buf = vec![0u8; 4096];
|
||||
while Instant::now() < deadline {
|
||||
match nix::unistd::read(fd, &mut buf) {
|
||||
Ok(0) => {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
Ok(n) => {
|
||||
let mut offset = 0usize;
|
||||
while offset + std::mem::size_of::<InputEvent>() <= n {
|
||||
let ptr = unsafe { buf.as_ptr().add(offset) as *const InputEvent };
|
||||
let ev = unsafe { *ptr };
|
||||
let mut guard = out.lock().unwrap();
|
||||
guard.push(EvdevEventMulti {
|
||||
event_path: path.clone(),
|
||||
ts_sec: ev.time.tv_sec as i64,
|
||||
ts_usec: ev.time.tv_usec as i64,
|
||||
type_code: ev.type_,
|
||||
code: ev.code,
|
||||
value: ev.value,
|
||||
});
|
||||
drop(guard);
|
||||
offset += std::mem::size_of::<InputEvent>();
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
if err == nix::errno::Errno::EAGAIN {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
continue;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
for h in handles {
|
||||
let _ = h.join();
|
||||
}
|
||||
|
||||
let mut v = out.lock().unwrap().clone();
|
||||
v.sort_by(|a, b| {
|
||||
(a.ts_sec, a.ts_usec, a.event_path.clone()).cmp(&(
|
||||
b.ts_sec,
|
||||
b.ts_usec,
|
||||
b.event_path.clone(),
|
||||
))
|
||||
});
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn parse_hid_uevent(contents: &str) -> std::collections::HashMap<String, String> {
|
||||
let mut map = std::collections::HashMap::new();
|
||||
for line in contents.lines() {
|
||||
if let Some((k, v)) = line.split_once('=') {
|
||||
map.insert(k.trim().to_string(), v.trim().to_string());
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
fn parse_hid_id(hid_id: &str) -> Option<(String, String, String)> {
|
||||
// HID_ID=0003:00000B05:00001B2C
|
||||
let parts: Vec<&str> = hid_id.split(':').collect();
|
||||
if parts.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
parts[0].to_string(),
|
||||
parts[1].to_string(),
|
||||
parts[2].to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn collect_hidraw_nodes(dev_path: &Path) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let hidraw_dir = dev_path.join("hidraw");
|
||||
if let Ok(entries) = fs::read_dir(hidraw_dir) {
|
||||
for ent in entries.flatten() {
|
||||
let name = ent.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with("hidraw") {
|
||||
out.push(format!("/dev/{name}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
fn collect_input_event_nodes(dev_path: &Path) -> Vec<String> {
|
||||
let mut out: BTreeSet<String> = BTreeSet::new();
|
||||
let input_dir = dev_path.join("input");
|
||||
let Ok(entries) = fs::read_dir(input_dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
for ent in entries.flatten() {
|
||||
let p = ent.path();
|
||||
// /sys/bus/hid/devices/.../input/inputNN
|
||||
if let Ok(sub) = fs::read_dir(&p) {
|
||||
for sub_ent in sub.flatten() {
|
||||
let name = sub_ent.file_name().to_string_lossy().to_string();
|
||||
if is_event_node_name(&name) {
|
||||
out.insert(format!("/dev/input/{name}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.into_iter().collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn diag_list_hid(vid: String, pid: String) -> Result<Vec<HidDevice>, String> {
|
||||
let base = Path::new("/sys/bus/hid/devices");
|
||||
let entries =
|
||||
fs::read_dir(base).map_err(|e| format!("Failed to read /sys/bus/hid/devices: {e}"))?;
|
||||
|
||||
let want_vid = vid.trim().to_ascii_lowercase();
|
||||
let want_pid = pid.trim().to_ascii_lowercase();
|
||||
|
||||
let mut out: Vec<HidDevice> = Vec::new();
|
||||
for ent in entries.flatten() {
|
||||
let id = ent.file_name().to_string_lossy().to_string();
|
||||
let dev_path = ent.path();
|
||||
let uevent = match fs::read_to_string(dev_path.join("uevent")) {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let map = parse_hid_uevent(&uevent);
|
||||
let hid_id = map.get("HID_ID").cloned();
|
||||
let Some(hid_id_val) = hid_id.clone() else {
|
||||
continue;
|
||||
};
|
||||
let Some((_bus, v, p)) = parse_hid_id(&hid_id_val) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if v.trim_start_matches('0').to_ascii_lowercase() != want_vid.trim_start_matches('0')
|
||||
|| p.trim_start_matches('0').to_ascii_lowercase() != want_pid.trim_start_matches('0')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(HidDevice {
|
||||
id,
|
||||
driver: map.get("DRIVER").cloned(),
|
||||
hid_id,
|
||||
hid_name: map.get("HID_NAME").cloned(),
|
||||
hid_phys: map.get("HID_PHYS").cloned(),
|
||||
hidraw_nodes: collect_hidraw_nodes(&dev_path),
|
||||
input_event_nodes: collect_input_event_nodes(&dev_path),
|
||||
});
|
||||
}
|
||||
|
||||
out.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn validate_hid_device_id(id: &str) -> Result<(), String> {
|
||||
// Prevent path traversal; allow only sane sysfs names.
|
||||
if id.is_empty() {
|
||||
return Err("Empty HID device id".into());
|
||||
}
|
||||
if id.contains('/') || id.contains("..") {
|
||||
return Err("Invalid HID device id".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn diag_read_report_descriptor(hid_device_id: String) -> Result<ReportDescriptor, String> {
|
||||
validate_hid_device_id(&hid_device_id)?;
|
||||
let path = PathBuf::from("/sys/bus/hid/devices")
|
||||
.join(&hid_device_id)
|
||||
.join("report_descriptor");
|
||||
let bytes = fs::read(&path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
|
||||
|
||||
// Trim trailing zeros (sysfs often exposes a 4096-byte padded blob).
|
||||
let mut end = bytes.len();
|
||||
while end > 0 && bytes[end - 1] == 0 {
|
||||
end -= 1;
|
||||
}
|
||||
let bytes = &bytes[..end];
|
||||
|
||||
let mut ids: BTreeSet<u8> = BTreeSet::new();
|
||||
let mut i = 0usize;
|
||||
while i + 1 < bytes.len() {
|
||||
if bytes[i] == 0x85 {
|
||||
ids.insert(bytes[i + 1]);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
Ok(ReportDescriptor {
|
||||
len: bytes.len(),
|
||||
hex: bytes.iter().map(|b| format!("{b:02x}")).collect::<String>(),
|
||||
report_ids: ids.into_iter().collect(),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_hidraw(path: &str) -> Result<(), String> {
|
||||
if !path.starts_with("/dev/hidraw") {
|
||||
return Err("Only /dev/hidraw* paths are allowed".into());
|
||||
}
|
||||
let suffix = &path["/dev/hidraw".len()..];
|
||||
if suffix.is_empty() || !suffix.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err("Invalid hidraw path".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn diag_capture_hidraw_pkexec(
|
||||
hidraw_path: String,
|
||||
seconds: u32,
|
||||
) -> Result<HidrawCapture, String> {
|
||||
validate_hidraw(&hidraw_path)?;
|
||||
let seconds = seconds.clamp(1, 15);
|
||||
|
||||
// Use pkexec to run a bounded python reader for root-only hidraw nodes.
|
||||
let script = r#"
|
||||
import sys, os, select, time, json, binascii, errno
|
||||
|
||||
path = sys.argv[1]
|
||||
seconds = float(sys.argv[2])
|
||||
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
|
||||
start = time.time()
|
||||
last = None
|
||||
|
||||
while time.time() - start < seconds:
|
||||
r, _, _ = select.select([fd], [], [], 0.2)
|
||||
if not r:
|
||||
continue
|
||||
try:
|
||||
data = os.read(fd, 64)
|
||||
except OSError as e:
|
||||
if e.errno in (errno.EIO, errno.EAGAIN):
|
||||
continue
|
||||
raise
|
||||
if not data:
|
||||
continue
|
||||
if data != last:
|
||||
out = {"tsMs": int((time.time() - start) * 1000), "hex": binascii.hexlify(data).decode()}
|
||||
print(json.dumps(out), flush=True)
|
||||
last = data
|
||||
|
||||
os.close(fd)
|
||||
"#;
|
||||
|
||||
let mut child = Command::new("pkexec")
|
||||
.arg("/usr/bin/python3")
|
||||
.arg("-")
|
||||
.arg(&hidraw_path)
|
||||
.arg(seconds.to_string())
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn pkexec/python3: {e}"))?;
|
||||
|
||||
{
|
||||
let mut stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| "Failed to open stdin for pkexec child".to_string())?;
|
||||
stdin
|
||||
.write_all(script.as_bytes())
|
||||
.map_err(|e| format!("Failed to write python script: {e}"))?;
|
||||
}
|
||||
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|e| format!("Failed to wait for pkexec: {e}"))?;
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let mut samples: Vec<HidrawSample> = Vec::new();
|
||||
for line in stdout.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match serde_json::from_str::<HidrawSample>(line) {
|
||||
Ok(s) => samples.push(s),
|
||||
Err(_) => {
|
||||
// Ignore parse errors; keep going.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(HidrawCapture {
|
||||
hidraw_path,
|
||||
samples,
|
||||
stderr: if stderr.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(stderr)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
use crate::hardware::display_layout;
|
||||
use crate::ipc::protocol::{DaemonRequest, DaemonResponse};
|
||||
use crate::models::{DisplayLayout, Orientation};
|
||||
use crate::runtime::client;
|
||||
|
||||
fn daemon_display_layout_or_transport_fallback(
|
||||
response: Result<DaemonResponse, String>,
|
||||
fallback: impl FnOnce() -> Result<DisplayLayout, String>,
|
||||
) -> Result<DisplayLayout, String> {
|
||||
match response {
|
||||
Ok(DaemonResponse::DisplayLayout { layout }) => {
|
||||
Ok(display_layout::normalize_display_layout(layout))
|
||||
}
|
||||
Ok(DaemonResponse::Error { message }) => Err(message),
|
||||
Ok(other) => Err(format!(
|
||||
"Unexpected daemon response while reading display layout: {other:?}"
|
||||
)),
|
||||
Err(_) => fallback(),
|
||||
}
|
||||
}
|
||||
|
||||
fn daemon_ack_or_transport_fallback(
|
||||
response: Result<DaemonResponse, String>,
|
||||
action: &str,
|
||||
fallback: impl FnOnce() -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
match response {
|
||||
Ok(DaemonResponse::Ack) => Ok(()),
|
||||
Ok(DaemonResponse::Error { message }) => Err(message),
|
||||
Ok(other) => Err(format!(
|
||||
"Unexpected daemon response while {action}: {other:?}"
|
||||
)),
|
||||
Err(_) => fallback(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_display_layout() -> Result<DisplayLayout, String> {
|
||||
daemon_display_layout_or_transport_fallback(
|
||||
client::request(DaemonRequest::GetDisplayLayout),
|
||||
display_layout::get_display_layout,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn apply_display_layout(layout: DisplayLayout) -> Result<(), String> {
|
||||
let normalized = display_layout::normalize_display_layout(layout);
|
||||
|
||||
daemon_ack_or_transport_fallback(
|
||||
client::request(DaemonRequest::ApplyDisplayLayout {
|
||||
layout: normalized.clone(),
|
||||
}),
|
||||
"applying display layout",
|
||||
|| display_layout::apply_display_layout(&normalized),
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_orientation(orientation: Orientation) -> Result<(), String> {
|
||||
daemon_ack_or_transport_fallback(
|
||||
client::request(DaemonRequest::SetOrientation {
|
||||
orientation: orientation.clone(),
|
||||
}),
|
||||
"setting orientation",
|
||||
|| display_layout::set_orientation(&orientation),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn empty_layout() -> DisplayLayout {
|
||||
DisplayLayout { displays: vec![] }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_layout_mapping_surfaces_daemon_failures() {
|
||||
assert_eq!(
|
||||
daemon_display_layout_or_transport_fallback(
|
||||
Ok(DaemonResponse::Error {
|
||||
message: "daemon rejected".into(),
|
||||
}),
|
||||
|| Ok(empty_layout()),
|
||||
)
|
||||
.expect_err("daemon error should fail"),
|
||||
"daemon rejected"
|
||||
);
|
||||
assert!(
|
||||
daemon_display_layout_or_transport_fallback(Ok(DaemonResponse::Pong), || {
|
||||
Ok(empty_layout())
|
||||
})
|
||||
.expect_err("unexpected response should fail")
|
||||
.contains("Unexpected daemon response")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_layout_mapping_falls_back_on_transport_failure() {
|
||||
assert!(
|
||||
daemon_display_layout_or_transport_fallback(Err("socket missing".into()), || {
|
||||
Ok(empty_layout())
|
||||
})
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_ack_mapping_surfaces_error_and_falls_back_only_on_transport_failure() {
|
||||
assert_eq!(
|
||||
daemon_ack_or_transport_fallback(
|
||||
Ok(DaemonResponse::Error {
|
||||
message: "daemon rejected".into(),
|
||||
}),
|
||||
"testing",
|
||||
|| Ok(()),
|
||||
)
|
||||
.expect_err("daemon error should fail"),
|
||||
"daemon rejected"
|
||||
);
|
||||
assert!(
|
||||
daemon_ack_or_transport_fallback(Ok(DaemonResponse::Pong), "testing", || Ok(()))
|
||||
.expect_err("unexpected response should fail")
|
||||
.contains("Unexpected daemon response")
|
||||
);
|
||||
assert!(
|
||||
daemon_ack_or_transport_fallback(Err("socket missing".into()), "testing", || {
|
||||
Ok(())
|
||||
})
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::ipc::protocol::{DaemonRequest, DaemonResponse};
|
||||
use crate::models::HardwareEvent;
|
||||
use crate::runtime::client;
|
||||
|
||||
pub type EventBuffer = Arc<Mutex<VecDeque<HardwareEvent>>>;
|
||||
|
||||
pub fn create_event_buffer() -> EventBuffer {
|
||||
Arc::new(Mutex::new(VecDeque::with_capacity(500)))
|
||||
}
|
||||
|
||||
pub fn push_event(buffer: &EventBuffer, event: HardwareEvent) {
|
||||
let mut buf = buffer.lock().unwrap();
|
||||
if buf.len() >= 500 {
|
||||
buf.pop_front();
|
||||
}
|
||||
buf.push_back(event);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_recent_events(count: usize, state: tauri::State<'_, EventBuffer>) -> Vec<HardwareEvent> {
|
||||
if let Ok(DaemonResponse::Events { events }) =
|
||||
client::request(DaemonRequest::GetRecentEvents { limit: count })
|
||||
{
|
||||
return events;
|
||||
}
|
||||
|
||||
let buf = state.lock().unwrap();
|
||||
buf.iter()
|
||||
.rev()
|
||||
.take(count)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
use crate::hardware::sysfs;
|
||||
use crate::ipc::protocol::{DaemonRequest, DaemonResponse};
|
||||
use crate::runtime::client;
|
||||
use crate::runtime::logger;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn read_log(lines: usize) -> Vec<String> {
|
||||
match client::request(DaemonRequest::TailLogs { lines }) {
|
||||
Ok(DaemonResponse::Logs { lines }) => lines,
|
||||
_ => sysfs::read_log_lines(lines),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn clear_log() -> Result<(), String> {
|
||||
match client::request(DaemonRequest::ClearLogs) {
|
||||
Ok(DaemonResponse::Ack) => Ok(()),
|
||||
Ok(DaemonResponse::Error { message }) => Err(message),
|
||||
Ok(_) => logger::clear().or_else(|_| sysfs::clear_log()),
|
||||
Err(_) => logger::clear().or_else(|_| sysfs::clear_log()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
pub mod backlight;
|
||||
pub mod diagnostics;
|
||||
pub mod display;
|
||||
pub mod events;
|
||||
pub mod logs;
|
||||
pub mod profiles;
|
||||
pub mod service;
|
||||
pub mod settings;
|
||||
pub mod status;
|
||||
pub mod theme;
|
||||
pub mod touchscreen;
|
||||
pub mod usb_media_remap;
|
||||
@@ -0,0 +1,235 @@
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
use crate::hardware::display_layout;
|
||||
use crate::ipc::protocol::{DaemonRequest, DaemonResponse};
|
||||
use crate::models::{Profile, ProfileList};
|
||||
use crate::runtime::client;
|
||||
|
||||
fn profiles_path() -> PathBuf {
|
||||
let config_dir = crate::commands::settings::config_base_dir().join("zenbook-duo");
|
||||
let _ = fs::create_dir_all(&config_dir);
|
||||
config_dir.join("profiles.json")
|
||||
}
|
||||
|
||||
fn load_profile_list() -> ProfileList {
|
||||
let path = profiles_path();
|
||||
fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_else(|| ProfileList {
|
||||
profiles: Profile::default_profiles(),
|
||||
})
|
||||
}
|
||||
|
||||
fn save_profile_list(list: &ProfileList) -> Result<(), String> {
|
||||
let path = profiles_path();
|
||||
let json = serde_json::to_string_pretty(list).map_err(|e| format!("Serialize error: {e}"))?;
|
||||
fs::write(&path, json).map_err(|e| format!("Write error: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_profiles() -> Vec<Profile> {
|
||||
load_profile_list().profiles
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_profile(profile: Profile) -> Result<(), String> {
|
||||
let mut list = load_profile_list();
|
||||
if let Some(existing) = list.profiles.iter_mut().find(|p| p.id == profile.id) {
|
||||
*existing = profile;
|
||||
} else {
|
||||
list.profiles.push(profile);
|
||||
}
|
||||
save_profile_list(&list)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_profile(id: String) -> Result<(), String> {
|
||||
let mut list = load_profile_list();
|
||||
list.profiles.retain(|p| p.id != id);
|
||||
save_profile_list(&list)
|
||||
}
|
||||
|
||||
fn daemon_response_result(
|
||||
response: Result<DaemonResponse, String>,
|
||||
action: &str,
|
||||
fallback: impl FnOnce() -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
match response {
|
||||
Ok(DaemonResponse::Ack) => Ok(()),
|
||||
Ok(DaemonResponse::Error { message }) => Err(format!("{action} failed: {message}")),
|
||||
Ok(other) => Err(format!(
|
||||
"{action} failed: unexpected daemon response {other:?}"
|
||||
)),
|
||||
Err(_) => fallback().map_err(|message| format!("{action} failed: {message}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn activate_profile(id: String) -> Result<(), String> {
|
||||
let list = load_profile_list();
|
||||
let profile = list
|
||||
.profiles
|
||||
.iter()
|
||||
.find(|p| p.id == id)
|
||||
.ok_or_else(|| format!("Profile '{id}' not found"))?
|
||||
.clone();
|
||||
|
||||
daemon_response_result(
|
||||
client::request(DaemonRequest::SetBacklight {
|
||||
level: profile.backlight_level,
|
||||
}),
|
||||
"Set profile backlight",
|
||||
|| crate::commands::backlight::set_backlight_daemon_first(profile.backlight_level),
|
||||
)?;
|
||||
|
||||
daemon_response_result(
|
||||
client::request(DaemonRequest::SetOrientation {
|
||||
orientation: profile.orientation.clone(),
|
||||
}),
|
||||
"Set profile orientation",
|
||||
|| display_layout::set_orientation(&profile.orientation),
|
||||
)?;
|
||||
|
||||
if let Some(ref layout) = profile.display_layout {
|
||||
daemon_response_result(
|
||||
client::request(DaemonRequest::ApplyDisplayLayout {
|
||||
layout: layout.clone(),
|
||||
}),
|
||||
"Apply profile display layout",
|
||||
|| display_layout::apply_display_layout(layout),
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::Orientation;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
struct TestHome {
|
||||
path: PathBuf,
|
||||
previous_home: Option<String>,
|
||||
}
|
||||
|
||||
impl TestHome {
|
||||
fn new() -> Self {
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock before unix epoch")
|
||||
.as_nanos();
|
||||
let path = env::temp_dir().join(format!(
|
||||
"zenbook-duo-profiles-test-{}-{unique}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&path).expect("create test home");
|
||||
let previous_home = env::var("ZENBOOK_DUO_HOME").ok();
|
||||
env::set_var("ZENBOOK_DUO_HOME", &path);
|
||||
Self {
|
||||
path,
|
||||
previous_home,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestHome {
|
||||
fn drop(&mut self) {
|
||||
if let Some(previous_home) = &self.previous_home {
|
||||
env::set_var("ZENBOOK_DUO_HOME", previous_home);
|
||||
} else {
|
||||
env::remove_var("ZENBOOK_DUO_HOME");
|
||||
}
|
||||
let _ = fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_profile(id: &str) -> Profile {
|
||||
Profile {
|
||||
id: id.into(),
|
||||
name: "Test".into(),
|
||||
backlight_level: 2,
|
||||
scale: 1.5,
|
||||
orientation: Orientation::Normal,
|
||||
dual_screen_enabled: true,
|
||||
display_layout: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_storage_roundtrip_uses_zenbook_duo_home() {
|
||||
let _guard = crate::commands::settings::test_env_lock()
|
||||
.lock()
|
||||
.expect("profiles env lock");
|
||||
let home = TestHome::new();
|
||||
let profile = test_profile("custom");
|
||||
|
||||
save_profile(profile).expect("save profile");
|
||||
|
||||
let profile_path = home.path.join(".config/zenbook-duo/profiles.json");
|
||||
assert!(profile_path.is_file());
|
||||
assert!(list_profiles().iter().any(|profile| profile.id == "custom"));
|
||||
|
||||
delete_profile("custom".into()).expect("delete profile");
|
||||
assert!(!list_profiles().iter().any(|profile| profile.id == "custom"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_storage_loads_defaults_when_no_file_exists() {
|
||||
let _guard = crate::commands::settings::test_env_lock()
|
||||
.lock()
|
||||
.expect("profiles env lock");
|
||||
let _home = TestHome::new();
|
||||
|
||||
let profiles = list_profiles();
|
||||
|
||||
assert!(profiles.iter().any(|profile| profile.id == "docked"));
|
||||
assert!(profiles.iter().any(|profile| profile.id == "tablet"));
|
||||
assert!(profiles.iter().any(|profile| profile.id == "presentation"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_activation_surfaces_daemon_errors_and_unexpected_responses() {
|
||||
let daemon_error = daemon_response_result(
|
||||
Ok(DaemonResponse::Error {
|
||||
message: "no session".into(),
|
||||
}),
|
||||
"Set profile orientation",
|
||||
|| Ok(()),
|
||||
);
|
||||
assert_eq!(
|
||||
daemon_error.expect_err("daemon error should fail"),
|
||||
"Set profile orientation failed: no session"
|
||||
);
|
||||
|
||||
let unexpected =
|
||||
daemon_response_result(Ok(DaemonResponse::Pong), "Set profile backlight", || Ok(()));
|
||||
assert!(unexpected
|
||||
.expect_err("unexpected response should fail")
|
||||
.contains("unexpected daemon response"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_activation_uses_fallback_only_for_daemon_transport_failure() {
|
||||
let fallback_error = daemon_response_result(
|
||||
Err("daemon unavailable".into()),
|
||||
"Apply profile display layout",
|
||||
|| Err("local apply failed".into()),
|
||||
);
|
||||
assert_eq!(
|
||||
fallback_error.expect_err("fallback error should fail"),
|
||||
"Apply profile display layout failed: local apply failed"
|
||||
);
|
||||
|
||||
let fallback_success = daemon_response_result(
|
||||
Err("daemon unavailable".into()),
|
||||
"Set profile backlight",
|
||||
|| Ok(()),
|
||||
);
|
||||
assert!(fallback_success.is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
use std::process::Command;
|
||||
|
||||
use crate::hardware::sysfs;
|
||||
use crate::ipc::protocol::{DaemonRequest, DaemonResponse, PROTOCOL_VERSION};
|
||||
use crate::models::VersionInfo;
|
||||
use crate::runtime::client;
|
||||
|
||||
const LEGACY_DAEMON_RESTART_ERROR: &str = "Service restart not yet owned by rust-daemon";
|
||||
|
||||
#[tauri::command]
|
||||
pub fn is_service_active() -> bool {
|
||||
match client::request(DaemonRequest::GetStatus) {
|
||||
Ok(DaemonResponse::Status { .. }) => true,
|
||||
_ => sysfs::is_service_active(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_version_info() -> VersionInfo {
|
||||
let app_version = env!("CARGO_PKG_VERSION");
|
||||
|
||||
match client::request(DaemonRequest::GetVersion) {
|
||||
Ok(DaemonResponse::Version { version }) => {
|
||||
VersionInfo::from_daemon(app_version, PROTOCOL_VERSION, version)
|
||||
}
|
||||
Ok(DaemonResponse::Error { message }) => parse_daemon_protocol_version(&message)
|
||||
.map(|daemon_protocol_version| {
|
||||
VersionInfo::protocol_mismatch(
|
||||
app_version,
|
||||
PROTOCOL_VERSION,
|
||||
daemon_protocol_version,
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| VersionInfo::unavailable(app_version, PROTOCOL_VERSION)),
|
||||
_ => VersionInfo::unavailable(app_version, PROTOCOL_VERSION),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_daemon_protocol_version(message: &str) -> Option<u32> {
|
||||
message
|
||||
.strip_prefix("Protocol mismatch: expected ")?
|
||||
.split_once(',')?
|
||||
.0
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn restart_service() -> Result<(), String> {
|
||||
match client::request(DaemonRequest::RestartService) {
|
||||
Ok(DaemonResponse::Ack) => return Ok(()),
|
||||
Ok(DaemonResponse::Error { message }) if message != LEGACY_DAEMON_RESTART_ERROR => {
|
||||
return Err(message);
|
||||
}
|
||||
Ok(DaemonResponse::Error { .. }) => {}
|
||||
Ok(_) => return Err("Unexpected daemon response while restarting service".into()),
|
||||
Err(_) => {}
|
||||
}
|
||||
|
||||
let mut errors = Vec::new();
|
||||
|
||||
if let Err(message) = restart_system_unit("zenbook-duo-rust-daemon.service") {
|
||||
errors.push(message);
|
||||
}
|
||||
|
||||
if let Err(message) = restart_user_unit("zenbook-duo-session-agent.service") {
|
||||
errors.push(message);
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(errors.join("; "))
|
||||
}
|
||||
}
|
||||
|
||||
fn restart_user_unit(unit: &str) -> Result<(), String> {
|
||||
let output = Command::new("systemctl")
|
||||
.args(["--user", "restart", unit])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to restart {unit}: {e}"))?;
|
||||
|
||||
if output.status.success() || unit_not_found(&output) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"Failed to restart {unit}: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn restart_system_unit(unit: &str) -> Result<(), String> {
|
||||
let output = Command::new("systemctl")
|
||||
.args(["restart", unit])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to restart {unit}: {e}"))?;
|
||||
|
||||
if output.status.success() || unit_not_found(&output) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if command_exists("pkexec") {
|
||||
let elevated = Command::new("pkexec")
|
||||
.args(["systemctl", "restart", unit])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to restart {unit} with pkexec: {e}"))?;
|
||||
|
||||
if elevated.status.success() || unit_not_found(&elevated) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
return Err(format!(
|
||||
"Failed to restart {unit} with pkexec: {}",
|
||||
String::from_utf8_lossy(&elevated.stderr).trim()
|
||||
));
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Failed to restart {unit}: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
))
|
||||
}
|
||||
|
||||
fn command_exists(program: &str) -> bool {
|
||||
Command::new("sh")
|
||||
.args(["-c", &format!("command -v {program} >/dev/null 2>&1")])
|
||||
.status()
|
||||
.map(|status| status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn unit_not_found(output: &std::process::Output) -> bool {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
stderr.contains("not loaded")
|
||||
|| stderr.contains("could not be found")
|
||||
|| stderr.contains("Unit ")
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::ipc::protocol::{DaemonRequest, DaemonResponse};
|
||||
use crate::models::DuoSettings;
|
||||
use crate::runtime::client;
|
||||
|
||||
const AUTOSTART_FILE_NAME: &str = "zenbook-duo-control.desktop";
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_env_lock() -> &'static std::sync::Mutex<()> {
|
||||
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
|
||||
LOCK.get_or_init(|| std::sync::Mutex::new(()))
|
||||
}
|
||||
|
||||
pub(crate) fn config_base_dir() -> PathBuf {
|
||||
if let Ok(home_override) = env::var("ZENBOOK_DUO_HOME") {
|
||||
PathBuf::from(home_override).join(".config")
|
||||
} else {
|
||||
dirs::config_dir().unwrap_or_else(|| PathBuf::from("~/.config"))
|
||||
}
|
||||
}
|
||||
|
||||
fn settings_path() -> PathBuf {
|
||||
let config_dir = config_base_dir().join("zenbook-duo");
|
||||
let _ = fs::create_dir_all(&config_dir);
|
||||
config_dir.join("settings.json")
|
||||
}
|
||||
|
||||
fn autostart_path() -> PathBuf {
|
||||
config_base_dir()
|
||||
.join("autostart")
|
||||
.join(AUTOSTART_FILE_NAME)
|
||||
}
|
||||
|
||||
fn autostart_enabled() -> bool {
|
||||
autostart_path().is_file()
|
||||
}
|
||||
|
||||
fn desktop_exec_path() -> String {
|
||||
let executable = env::current_exe().unwrap_or_else(|_| PathBuf::from("zenbook-duo-control"));
|
||||
let executable = executable.to_string_lossy();
|
||||
let escaped = executable.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
format!("\"{escaped}\" --start-minimized")
|
||||
}
|
||||
|
||||
fn write_autostart_entry() -> Result<(), String> {
|
||||
let path = autostart_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("Create autostart dir error: {e}"))?;
|
||||
}
|
||||
|
||||
let entry = format!(
|
||||
"[Desktop Entry]\n\
|
||||
Type=Application\n\
|
||||
Name=Zenbook Duo Control\n\
|
||||
Comment=Start Zenbook Duo Control hidden in the system tray\n\
|
||||
Exec={}\n\
|
||||
Icon=zenbook-duo-control\n\
|
||||
Terminal=false\n\
|
||||
Categories=Utility;Settings;\n\
|
||||
NoDisplay=true\n\
|
||||
X-GNOME-Autostart-enabled=true\n",
|
||||
desktop_exec_path()
|
||||
);
|
||||
|
||||
fs::write(&path, entry).map_err(|e| format!("Write autostart entry error: {e}"))
|
||||
}
|
||||
|
||||
fn remove_autostart_entry() -> Result<(), String> {
|
||||
match fs::remove_file(autostart_path()) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(format!("Remove autostart entry error: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_autostart_entry(settings: &DuoSettings) -> Result<(), String> {
|
||||
if settings.start_on_boot_minimized {
|
||||
write_autostart_entry()
|
||||
} else {
|
||||
remove_autostart_entry()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_settings_local() -> DuoSettings {
|
||||
let path = settings_path();
|
||||
let raw = match fs::read_to_string(&path) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return DuoSettings::default(), // no settings file => show setup
|
||||
};
|
||||
|
||||
// Merge defaults + file contents.
|
||||
// Important behavior: when upgrading an existing install, we don't want to force the setup
|
||||
// screen to appear just because `setupCompleted` is a new field.
|
||||
let mut settings: DuoSettings = serde_json::from_str(&raw).unwrap_or_default();
|
||||
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
|
||||
if v.get("setupCompleted").is_none() {
|
||||
settings.setup_completed = true;
|
||||
}
|
||||
}
|
||||
|
||||
settings
|
||||
}
|
||||
|
||||
pub fn save_settings_local(settings: DuoSettings) -> Result<(), String> {
|
||||
let path = settings_path();
|
||||
let json =
|
||||
serde_json::to_string_pretty(&settings).map_err(|e| format!("Serialize error: {e}"))?;
|
||||
fs::write(&path, json).map_err(|e| format!("Write error: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn load_settings() -> DuoSettings {
|
||||
let mut settings = load_settings_local();
|
||||
settings.start_on_boot_minimized = autostart_enabled();
|
||||
settings
|
||||
}
|
||||
|
||||
fn save_settings_daemon_result(response: Result<DaemonResponse, String>) -> Result<(), String> {
|
||||
match response {
|
||||
Ok(DaemonResponse::Ack) => Ok(()),
|
||||
Ok(DaemonResponse::Error { message }) => Err(message),
|
||||
Ok(other) => Err(format!(
|
||||
"Unexpected daemon response while saving settings: {other:?}"
|
||||
)),
|
||||
Err(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_settings(settings: DuoSettings) -> Result<(), String> {
|
||||
sync_autostart_entry(&settings)?;
|
||||
save_settings_local(settings.clone())?;
|
||||
|
||||
save_settings_daemon_result(client::request(DaemonRequest::SaveSettings { settings }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
struct TestHome {
|
||||
path: PathBuf,
|
||||
previous_home: Option<String>,
|
||||
}
|
||||
|
||||
impl TestHome {
|
||||
fn new() -> Self {
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock before unix epoch")
|
||||
.as_nanos();
|
||||
let path = env::temp_dir().join(format!(
|
||||
"zenbook-duo-settings-test-{}-{unique}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&path).expect("create test home");
|
||||
let previous_home = env::var("ZENBOOK_DUO_HOME").ok();
|
||||
env::set_var("ZENBOOK_DUO_HOME", &path);
|
||||
Self {
|
||||
path,
|
||||
previous_home,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestHome {
|
||||
fn drop(&mut self) {
|
||||
if let Some(previous_home) = &self.previous_home {
|
||||
env::set_var("ZENBOOK_DUO_HOME", previous_home);
|
||||
} else {
|
||||
env::remove_var("ZENBOOK_DUO_HOME");
|
||||
}
|
||||
let _ = fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_settings_daemon_result_surfaces_error_and_unexpected_response() {
|
||||
assert!(save_settings_daemon_result(Ok(DaemonResponse::Ack)).is_ok());
|
||||
assert_eq!(
|
||||
save_settings_daemon_result(Ok(DaemonResponse::Error {
|
||||
message: "daemon rejected".into(),
|
||||
}))
|
||||
.expect_err("daemon error should fail"),
|
||||
"daemon rejected"
|
||||
);
|
||||
assert!(save_settings_daemon_result(Ok(DaemonResponse::Pong))
|
||||
.expect_err("unexpected response should fail")
|
||||
.contains("Unexpected daemon response"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_settings_daemon_result_allows_transport_failure_after_local_save() {
|
||||
assert!(save_settings_daemon_result(Err("socket missing".into())).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_settings_roundtrip_preserves_issue_17_switch_fields() {
|
||||
let _guard = test_env_lock().lock().expect("settings env lock");
|
||||
let _home = TestHome::new();
|
||||
let mut settings = DuoSettings::default();
|
||||
settings.start_on_boot_minimized = true;
|
||||
settings.invert_sensor_rotation = true;
|
||||
settings.setup_completed = true;
|
||||
|
||||
save_settings_local(settings).expect("save settings");
|
||||
let loaded = load_settings_local();
|
||||
|
||||
assert!(loaded.start_on_boot_minimized);
|
||||
assert!(loaded.invert_sensor_rotation);
|
||||
assert!(loaded.setup_completed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_settings_uses_autostart_file_as_start_on_boot_source_of_truth() {
|
||||
let _guard = test_env_lock().lock().expect("settings env lock");
|
||||
let _home = TestHome::new();
|
||||
let mut settings = DuoSettings::default();
|
||||
settings.start_on_boot_minimized = true;
|
||||
settings.invert_sensor_rotation = true;
|
||||
|
||||
save_settings_local(settings).expect("save settings");
|
||||
assert!(!load_settings().start_on_boot_minimized);
|
||||
|
||||
let path = autostart_path();
|
||||
fs::create_dir_all(path.parent().expect("autostart parent")).expect("create autostart dir");
|
||||
fs::write(&path, "[Desktop Entry]\nType=Application\n").expect("write autostart file");
|
||||
|
||||
let loaded = load_settings();
|
||||
assert!(loaded.start_on_boot_minimized);
|
||||
assert!(loaded.invert_sensor_rotation);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
use crate::hardware::sysfs;
|
||||
use crate::ipc::protocol::{DaemonRequest, DaemonResponse};
|
||||
use crate::models::DuoStatus;
|
||||
use crate::runtime::client;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_status() -> Result<DuoStatus, String> {
|
||||
match client::request(DaemonRequest::GetStatus) {
|
||||
Ok(DaemonResponse::Status { status }) => Ok(status),
|
||||
Ok(DaemonResponse::Error { message }) => Err(message),
|
||||
Ok(_) => Err("Unexpected daemon response while reading status".into()),
|
||||
Err(_) => Ok(sysfs::get_full_status()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use std::process::Command;
|
||||
|
||||
use crate::models::ThemePreference;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_system_theme() -> Option<ThemePreference> {
|
||||
system_theme_from_portal().or_else(system_theme_from_gsettings)
|
||||
}
|
||||
|
||||
fn system_theme_from_portal() -> Option<ThemePreference> {
|
||||
let output = Command::new("gdbus")
|
||||
.args([
|
||||
"call",
|
||||
"--session",
|
||||
"--dest",
|
||||
"org.freedesktop.portal.Desktop",
|
||||
"--object-path",
|
||||
"/org/freedesktop/portal/desktop",
|
||||
"--method",
|
||||
"org.freedesktop.portal.Settings.Read",
|
||||
"org.freedesktop.appearance",
|
||||
"color-scheme",
|
||||
])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
parse_portal_color_scheme(&stdout)
|
||||
}
|
||||
|
||||
fn system_theme_from_gsettings() -> Option<ThemePreference> {
|
||||
let output = Command::new("gsettings")
|
||||
.args(["get", "org.gnome.desktop.interface", "color-scheme"])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
parse_gsettings_color_scheme(&stdout)
|
||||
}
|
||||
|
||||
fn parse_portal_color_scheme(value: &str) -> Option<ThemePreference> {
|
||||
if value.contains("uint32 1") {
|
||||
Some(ThemePreference::Dark)
|
||||
} else if value.contains("uint32 2") || value.contains("uint32 0") {
|
||||
Some(ThemePreference::Light)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_gsettings_color_scheme(value: &str) -> Option<ThemePreference> {
|
||||
let normalized = value.trim().trim_matches('\'').trim_matches('"');
|
||||
|
||||
if normalized.contains("prefer-dark") {
|
||||
Some(ThemePreference::Dark)
|
||||
} else if normalized.contains("prefer-light") || normalized.contains("default") {
|
||||
Some(ThemePreference::Light)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_portal_color_scheme() {
|
||||
assert_eq!(
|
||||
parse_portal_color_scheme("(<<uint32 1>>,)"),
|
||||
Some(ThemePreference::Dark)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_portal_color_scheme("(<<uint32 0>>,)"),
|
||||
Some(ThemePreference::Light)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_portal_color_scheme("(<<uint32 2>>,)"),
|
||||
Some(ThemePreference::Light)
|
||||
);
|
||||
assert_eq!(parse_portal_color_scheme(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_gsettings_color_scheme() {
|
||||
assert_eq!(
|
||||
parse_gsettings_color_scheme("'prefer-dark'"),
|
||||
Some(ThemePreference::Dark)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_gsettings_color_scheme("'prefer-light'"),
|
||||
Some(ThemePreference::Light)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_gsettings_color_scheme("'default'"),
|
||||
Some(ThemePreference::Light)
|
||||
);
|
||||
assert_eq!(parse_gsettings_color_scheme("''"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use crate::hardware::touchscreen::{self, TouchscreenDevice};
|
||||
use crate::ipc::protocol::{DaemonRequest, DaemonResponse};
|
||||
use crate::runtime::client;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_touchscreens() -> Result<Vec<TouchscreenDevice>, String> {
|
||||
match client::request(DaemonRequest::ListTouchscreens) {
|
||||
Ok(DaemonResponse::Touchscreens { devices }) => Ok(devices),
|
||||
Ok(DaemonResponse::Error { message }) => Err(message),
|
||||
Ok(_) => Ok(touchscreen::list_touchscreens()),
|
||||
Err(_) => Ok(touchscreen::list_touchscreens()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_touchscreen_enabled(connector: String, enabled: bool) -> Result<(), String> {
|
||||
let fallback = || {
|
||||
let devices = touchscreen::list_touchscreens();
|
||||
match devices.iter().find(|d| d.connector == connector) {
|
||||
Some(dev) => touchscreen::set_touchscreen_enabled(&dev.i2c_id, enabled),
|
||||
None => Err(format!("No touchscreen found for {}", connector)),
|
||||
}
|
||||
};
|
||||
match client::request(DaemonRequest::SetTouchscreenEnabled {
|
||||
connector: connector.clone(),
|
||||
enabled,
|
||||
}) {
|
||||
Ok(DaemonResponse::Ack) => Ok(()),
|
||||
Ok(DaemonResponse::Error { message }) => Err(message),
|
||||
Ok(_) => fallback(),
|
||||
Err(_) => fallback(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
use chrono::Local;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use nix::unistd::{Uid, User};
|
||||
|
||||
use crate::ipc::protocol::{DaemonRequest, DaemonResponse};
|
||||
use crate::runtime::client;
|
||||
use crate::runtime::paths;
|
||||
|
||||
const HELPER_BINARY_NAME: &str = "zenbook-duo-usb-remap-helper";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsbMediaRemapStatus {
|
||||
pub running: bool,
|
||||
pub pid: Option<u32>,
|
||||
pub paused: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn usb_media_remap_status() -> UsbMediaRemapStatus {
|
||||
daemon_first_status()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn usb_media_remap_start() -> Result<(), String> {
|
||||
daemon_first_start()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn usb_media_remap_stop() -> Result<(), String> {
|
||||
daemon_first_stop()
|
||||
}
|
||||
|
||||
pub fn get_status() -> UsbMediaRemapStatus {
|
||||
let pid_path = pid_path();
|
||||
let pid = read_pid(&pid_path).or_else(|| recover_running_helper_pid(&pid_path));
|
||||
if let Some(pid) = pid {
|
||||
if is_pid_running(pid) {
|
||||
return UsbMediaRemapStatus {
|
||||
running: true,
|
||||
pid: Some(pid),
|
||||
paused: std::path::Path::new(&pause_file_path()).exists(),
|
||||
};
|
||||
}
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
}
|
||||
|
||||
UsbMediaRemapStatus {
|
||||
running: false,
|
||||
pid: None,
|
||||
paused: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_remap() -> Result<(), String> {
|
||||
if get_status().running {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// `pkexec` can take a few seconds before the auth prompt appears, and the user then needs
|
||||
// time to enter their password. Treat this as part of "startup" so the UI doesn't show a
|
||||
// spurious timeout error before authentication is even possible.
|
||||
const START_TIMEOUT_SECS: u64 = 90;
|
||||
|
||||
let pid_path = pid_path();
|
||||
ensure_duo_dir_for_pid(&pid_path)?;
|
||||
|
||||
let helper_path = helper_binary_path()?;
|
||||
let user = current_username().map_err(log_error)?;
|
||||
|
||||
let (mut cmd, launcher_name) = if running_as_root() {
|
||||
(
|
||||
Command::new(&helper_path),
|
||||
helper_path.display().to_string(),
|
||||
)
|
||||
} else {
|
||||
let mut cmd = Command::new("pkexec");
|
||||
cmd.arg(&helper_path);
|
||||
(cmd, "pkexec".to_string())
|
||||
};
|
||||
cmd.arg("--pid-file").arg(&pid_path).arg("--user").arg(user);
|
||||
|
||||
start_remap_spawn_and_wait(cmd, Duration::from_secs(START_TIMEOUT_SECS), &launcher_name)
|
||||
}
|
||||
|
||||
pub fn stop_remap() -> Result<(), String> {
|
||||
// Clean up pause file on stop.
|
||||
let _ = fs::remove_file(pause_file_path());
|
||||
|
||||
let pid_files = running_pid_files();
|
||||
if pid_files.is_empty() {
|
||||
// Nothing to stop.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let helper_path = helper_binary_path()?;
|
||||
|
||||
for pid_path in pid_files {
|
||||
ensure_duo_dir_for_pid(&pid_path)?;
|
||||
|
||||
let (mut cmd, launcher_name) = if running_as_root() {
|
||||
(
|
||||
Command::new(&helper_path),
|
||||
helper_path.display().to_string(),
|
||||
)
|
||||
} else {
|
||||
let mut cmd = Command::new("pkexec");
|
||||
cmd.arg(&helper_path);
|
||||
(cmd, "pkexec".to_string())
|
||||
};
|
||||
cmd.arg("--stop").arg("--pid-file").arg(&pid_path);
|
||||
|
||||
let status = cmd
|
||||
.status()
|
||||
.map_err(|e| log_error(format!("Failed to stop remapper ({launcher_name}): {e}")))?;
|
||||
if !status.success() {
|
||||
return Err(log_error(format!(
|
||||
"Failed to stop remapper ({launcher_name} exited with {status})"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Wait briefly for pid-file removal / process exit so the UI status doesn't bounce.
|
||||
for _ in 0..30 {
|
||||
if !get_status().running {
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start_remap_spawn_and_wait(
|
||||
mut cmd: Command,
|
||||
timeout: Duration,
|
||||
launcher_name: &str,
|
||||
) -> Result<(), String> {
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| log_error(format!("Failed to start remapper ({launcher_name}): {e}")))?;
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
while start.elapsed() < timeout {
|
||||
if get_status().running {
|
||||
// Ensure we reap the pkexec child process when it eventually exits (avoid zombies).
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
return Err(log_error(format!(
|
||||
"Remapper failed to start ({launcher_name} exited with {status})"
|
||||
)));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
// Timeout elapsed. Keep pkexec running in the background (it might still be waiting for auth).
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
|
||||
Err(log_error(format!(
|
||||
"Timed out waiting for remapper to start (waited {}s). If an authentication prompt appeared, complete it and try again.",
|
||||
timeout.as_secs()
|
||||
)))
|
||||
}
|
||||
|
||||
fn read_pid(path: &str) -> Option<u32> {
|
||||
fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u32>().ok())
|
||||
}
|
||||
|
||||
fn is_pid_running(pid: u32) -> bool {
|
||||
let res = unsafe { libc::kill(pid as i32, 0) };
|
||||
if res == 0 {
|
||||
return true;
|
||||
}
|
||||
|
||||
let err = std::io::Error::last_os_error();
|
||||
matches!(err.raw_os_error(), Some(code) if code == libc::EPERM)
|
||||
}
|
||||
|
||||
fn current_username() -> Result<String, String> {
|
||||
if let Ok(user) = std::env::var("SUDO_USER") {
|
||||
if !user.is_empty() && user != "root" {
|
||||
return Ok(user);
|
||||
}
|
||||
}
|
||||
if let Ok(user) = std::env::var("ZENBOOK_DUO_USER") {
|
||||
if !user.is_empty() {
|
||||
return Ok(user);
|
||||
}
|
||||
}
|
||||
|
||||
let user = User::from_uid(Uid::current())
|
||||
.map_err(|e| format!("Failed to read current user: {e}"))?
|
||||
.ok_or_else(|| "Failed to resolve current user".to_string())?;
|
||||
Ok(user.name)
|
||||
}
|
||||
|
||||
fn pid_path() -> String {
|
||||
runtime_dir_for_target_user()
|
||||
.join("usb_media_remap.pid")
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
pub fn pause_file_path() -> String {
|
||||
runtime_dir_for_target_user()
|
||||
.join("usb_media_remap.paused")
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
pub fn toggle_pause() -> Result<(), String> {
|
||||
let path = pause_file_path();
|
||||
if std::path::Path::new(&path).exists() {
|
||||
fs::remove_file(&path).map_err(|e| format!("Failed to remove pause file: {e}"))?;
|
||||
} else {
|
||||
ensure_duo_dir_for_pid(&pid_path())?;
|
||||
fs::write(&path, "").map_err(|e| format!("Failed to create pause file: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn usb_media_remap_toggle_pause() -> Result<(), String> {
|
||||
daemon_first_toggle_pause()
|
||||
}
|
||||
|
||||
pub fn daemon_first_status() -> UsbMediaRemapStatus {
|
||||
match client::request(DaemonRequest::UsbMediaRemapStatus) {
|
||||
Ok(DaemonResponse::UsbMediaRemapStatus { status }) => status,
|
||||
_ => get_status(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn daemon_first_start() -> Result<(), String> {
|
||||
let result = match client::request(DaemonRequest::UsbMediaRemapStart) {
|
||||
Ok(DaemonResponse::Ack) => Ok(()),
|
||||
Ok(DaemonResponse::Error { message }) => Err(message),
|
||||
Ok(_) => Err("Unexpected daemon response while starting USB remap".into()),
|
||||
Err(_) => start_remap(),
|
||||
};
|
||||
|
||||
if result.is_ok() {
|
||||
let _ = send_desktop_notification("USB Media Remap enabled");
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub fn daemon_first_stop() -> Result<(), String> {
|
||||
let result = match client::request(DaemonRequest::UsbMediaRemapStop) {
|
||||
Ok(DaemonResponse::Ack) => Ok(()),
|
||||
Ok(DaemonResponse::Error { message }) => Err(message),
|
||||
Ok(_) => Err("Unexpected daemon response while stopping USB remap".into()),
|
||||
Err(_) => stop_remap(),
|
||||
};
|
||||
|
||||
if result.is_ok() {
|
||||
let _ = send_desktop_notification("USB Media Remap disabled");
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub fn daemon_first_toggle_pause() -> Result<(), String> {
|
||||
let was_paused = daemon_first_status().paused;
|
||||
let result = match client::request(DaemonRequest::UsbMediaRemapTogglePause) {
|
||||
Ok(DaemonResponse::Ack) => Ok(()),
|
||||
Ok(DaemonResponse::Error { message }) => Err(message),
|
||||
Ok(_) => Err("Unexpected daemon response while toggling USB remap pause".into()),
|
||||
Err(_) => toggle_pause(),
|
||||
};
|
||||
|
||||
if result.is_ok() {
|
||||
let msg = if was_paused {
|
||||
"USB Media Remap resumed"
|
||||
} else {
|
||||
"USB Media Remap paused"
|
||||
};
|
||||
let _ = send_desktop_notification(msg);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn running_pid_files() -> Vec<String> {
|
||||
let p1 = pid_path();
|
||||
if get_status().running {
|
||||
return vec![p1];
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn runtime_dir_for_target_user() -> std::path::PathBuf {
|
||||
paths::user_runtime_dir(target_uid())
|
||||
}
|
||||
|
||||
fn target_uid() -> u32 {
|
||||
std::env::var("SUDO_UID")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.or_else(|| {
|
||||
std::env::var("ZENBOOK_DUO_UID")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
})
|
||||
.unwrap_or_else(|| Uid::current().as_raw())
|
||||
}
|
||||
|
||||
fn send_desktop_notification(message: &str) -> Result<(), String> {
|
||||
let user = current_username()?;
|
||||
let uid = target_uid();
|
||||
let runtime_dir = format!("/run/user/{uid}");
|
||||
let bus_address = format!("unix:path={runtime_dir}/bus");
|
||||
|
||||
let mut cmd = if running_as_root() {
|
||||
let mut cmd = Command::new("runuser");
|
||||
cmd.args(["-u", &user, "--", "env"]);
|
||||
cmd
|
||||
} else {
|
||||
Command::new("env")
|
||||
};
|
||||
|
||||
let status = cmd
|
||||
.args([
|
||||
&format!("XDG_RUNTIME_DIR={runtime_dir}"),
|
||||
&format!("DBUS_SESSION_BUS_ADDRESS={bus_address}"),
|
||||
"notify-send",
|
||||
"-a",
|
||||
"Zenbook Duo Control",
|
||||
"-i",
|
||||
"input-keyboard",
|
||||
message,
|
||||
])
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to launch desktop notification: {e}"))?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("notify-send exited with {status}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn running_as_root() -> bool {
|
||||
Uid::current().is_root()
|
||||
}
|
||||
|
||||
fn helper_binary_path() -> Result<std::path::PathBuf, String> {
|
||||
let current_exe = std::env::current_exe()
|
||||
.map_err(|e| log_error(format!("Failed to find current exe: {e}")))?;
|
||||
let sibling = current_exe.with_file_name(HELPER_BINARY_NAME);
|
||||
if sibling.exists() {
|
||||
return Ok(sibling);
|
||||
}
|
||||
Err(log_error(format!(
|
||||
"Failed to find {} next to {}",
|
||||
HELPER_BINARY_NAME,
|
||||
current_exe.display()
|
||||
)))
|
||||
}
|
||||
|
||||
fn recover_running_helper_pid(pid_path: &str) -> Option<u32> {
|
||||
let pid = find_running_helper_pid(pid_path)?;
|
||||
let _ = ensure_duo_dir_for_pid(pid_path);
|
||||
let _ = fs::write(pid_path, pid.to_string());
|
||||
Some(pid)
|
||||
}
|
||||
|
||||
fn find_running_helper_pid(pid_path: &str) -> Option<u32> {
|
||||
let proc_dir = fs::read_dir("/proc").ok()?;
|
||||
for entry in proc_dir.flatten() {
|
||||
let name = entry.file_name();
|
||||
let Ok(pid) = name.to_string_lossy().parse::<u32>() else {
|
||||
continue;
|
||||
};
|
||||
let cmdline = fs::read(entry.path().join("cmdline")).ok()?;
|
||||
if cmdline.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let args = cmdline
|
||||
.split(|b| *b == 0)
|
||||
.filter(|part| !part.is_empty())
|
||||
.map(|part| String::from_utf8_lossy(part).into_owned())
|
||||
.collect::<Vec<_>>();
|
||||
if !helper_args_match_pid_file(&args, pid_path) {
|
||||
continue;
|
||||
}
|
||||
if is_pid_running(pid) {
|
||||
return Some(pid);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn helper_args_match_pid_file(args: &[String], pid_path: &str) -> bool {
|
||||
let Some(exe) = args.first() else {
|
||||
return false;
|
||||
};
|
||||
if !exe.ends_with(HELPER_BINARY_NAME) {
|
||||
return false;
|
||||
}
|
||||
|
||||
args.windows(2)
|
||||
.any(|window| window[0] == "--pid-file" && window[1] == pid_path)
|
||||
}
|
||||
|
||||
fn ensure_duo_dir_for_pid(pid_file: &str) -> Result<(), String> {
|
||||
let dir = std::path::Path::new(pid_file)
|
||||
.parent()
|
||||
.ok_or_else(|| format!("Invalid pid file path: {pid_file}"))?;
|
||||
crate::runtime::runtime_dir::ensure_dir_owned_like_parent(dir)
|
||||
}
|
||||
|
||||
fn log_error<T: Into<String>>(message: T) -> String {
|
||||
let message = message.into();
|
||||
let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
let log_path = std::path::Path::new(&pid_path())
|
||||
.parent()
|
||||
.map(|p| p.join("duo.log"))
|
||||
.unwrap_or_else(|| std::env::temp_dir().join("zenbook-duo-usb-remap.log"));
|
||||
|
||||
let _ = fs::create_dir_all(
|
||||
log_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("/tmp")),
|
||||
);
|
||||
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(log_path) {
|
||||
let _ = writeln!(file, "{} - USB-REMAP - ERROR: {}", timestamp, message);
|
||||
}
|
||||
message
|
||||
}
|
||||
Reference in New Issue
Block a user