initial commit

This commit is contained in:
2026-09-26 17:37:13 +02:00
commit 839bebd8d4
226 changed files with 58701 additions and 0 deletions
@@ -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
}