initial commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
[build]
|
||||
# Optional: set this to a sensible value for your machine.
|
||||
# jobs = 8
|
||||
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
linker = "clang"
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
||||
@@ -0,0 +1,37 @@
|
||||
# Zenbook Duo Control UI
|
||||
|
||||
## Build commands
|
||||
|
||||
- `npm run build`: full Tauri release build with configured bundles.
|
||||
- `npm run build:local`: release build without packaging bundles. Faster for local verification.
|
||||
- `npm run build:frontend`: frontend-only production build.
|
||||
- `npm run build:rust`: Rust-only release build for the Tauri backend.
|
||||
|
||||
## Faster builds
|
||||
|
||||
The biggest speed win during iteration is to avoid full packaging unless you need `.deb` or `.rpm`.
|
||||
|
||||
- Use `npm run build:local` for local release checks.
|
||||
- Use `npm run build:frontend` when changing only the UI.
|
||||
- Use `npm run build:rust` when changing only Rust code.
|
||||
- Use `npm run dev` during active development.
|
||||
|
||||
Cargo already uses multiple cores by default. You can cap or tune it explicitly when useful:
|
||||
|
||||
```bash
|
||||
CARGO_BUILD_JOBS=8 npm run build:local
|
||||
```
|
||||
|
||||
If `sccache` is installed, you can enable Rust compile caching:
|
||||
|
||||
```bash
|
||||
RUSTC_WRAPPER=sccache npm run build:local
|
||||
```
|
||||
|
||||
If `mold` is installed on Linux, you can reduce link time with a local Cargo config. An example is provided in `.cargo/config.toml.example`.
|
||||
|
||||
To set up the recommended local tooling automatically:
|
||||
|
||||
```bash
|
||||
./scripts/setup-fast-build.sh
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "radix-mira",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "tabler",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist', 'src-tauri/target']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
// shadcn-style modules intentionally export helpers next to components.
|
||||
'react-refresh/only-export-components': 'off',
|
||||
// Existing memoized views are valid; don't fail lint when React Compiler skips them.
|
||||
'react-hooks/preserve-manual-memoization': 'off',
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Zenbook Duo Control</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+9590
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "ui-tauri-react",
|
||||
"private": true,
|
||||
"version": "0.3.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tauri dev",
|
||||
"build": "tauri build",
|
||||
"build:local": "tauri build --no-bundle",
|
||||
"build:frontend": "vite build",
|
||||
"build:rust": "cargo build --manifest-path src-tauri/Cargo.toml --release",
|
||||
"vite:dev": "vite",
|
||||
"vite:build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@tabler/icons-react": "^3.40.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-resizable-panels": "^4.7.5",
|
||||
"recharts": "^3.8.0",
|
||||
"shadcn": "^4.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "^1.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@tauri-apps/cli": "^2.10.1",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.1",
|
||||
"vite": "^8.0.1"
|
||||
}
|
||||
}
|
||||
Generated
+7030
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
EXAMPLE_CONFIG="$ROOT_DIR/.cargo/config.toml.example"
|
||||
LOCAL_CONFIG="$ROOT_DIR/.cargo/config.toml"
|
||||
|
||||
have_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
install_with_manager() {
|
||||
if have_cmd pacman; then
|
||||
sudo pacman -S --needed clang mold sccache
|
||||
return
|
||||
fi
|
||||
|
||||
if have_cmd apt-get; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y clang mold sccache
|
||||
return
|
||||
fi
|
||||
|
||||
if have_cmd dnf; then
|
||||
sudo dnf install -y clang mold sccache
|
||||
return
|
||||
fi
|
||||
|
||||
if have_cmd zypper; then
|
||||
sudo zypper install -y clang mold sccache
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Unsupported package manager. Install these manually: clang mold sccache" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "Checking build tools..."
|
||||
|
||||
missing=0
|
||||
for tool in clang mold sccache; do
|
||||
if have_cmd "$tool"; then
|
||||
echo " found: $tool"
|
||||
else
|
||||
echo " missing: $tool"
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$missing" -eq 1 ]]; then
|
||||
echo
|
||||
echo "Installing missing tools..."
|
||||
install_with_manager
|
||||
else
|
||||
echo
|
||||
echo "All recommended tools are already installed."
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$LOCAL_CONFIG")"
|
||||
|
||||
if [[ -f "$LOCAL_CONFIG" ]]; then
|
||||
echo
|
||||
echo "Keeping existing Cargo config at $LOCAL_CONFIG"
|
||||
else
|
||||
cp "$EXAMPLE_CONFIG" "$LOCAL_CONFIG"
|
||||
echo
|
||||
echo "Wrote $LOCAL_CONFIG from template."
|
||||
fi
|
||||
|
||||
cat <<'EOF'
|
||||
|
||||
Recommended usage:
|
||||
CARGO_BUILD_JOBS=8 RUSTC_WRAPPER=sccache npm run build:local
|
||||
|
||||
Adjust the jobs value for your machine.
|
||||
EOF
|
||||
Generated
+5505
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
[package]
|
||||
name = "zenbook-duo-control"
|
||||
version = "0.3.7"
|
||||
edition = "2021"
|
||||
description = "Zenbook Duo Linux Control Panel"
|
||||
default-run = "zenbook-duo-control"
|
||||
|
||||
[lib]
|
||||
name = "zenbook_duo_control_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "zenbook-duo-control"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "usb-media-remap"
|
||||
path = "src/bin/usb-media-remap.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "zenbook-duo-daemon"
|
||||
path = "src/bin/zenbook-duo-daemon.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "zenbook-duo-session-agent"
|
||||
path = "src/bin/zenbook-duo-session-agent.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "zenbook-duo-lifecycle"
|
||||
path = "src/bin/zenbook-duo-lifecycle.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "zenbook-duo-usb-remap-helper"
|
||||
path = "src/bin/zenbook-duo-usb-remap-helper.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.5", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.10", features = ["tray-icon"] }
|
||||
tauri-plugin-shell = "2.3"
|
||||
tauri-plugin-single-instance = "2.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
notify = "7"
|
||||
rusb = "0.9"
|
||||
zbus = "5"
|
||||
nix = { version = "0.29", features = ["ioctl", "fs", "user", "signal", "process"] }
|
||||
dirs = "6"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
futures-util = "0.3"
|
||||
libc = "0.2"
|
||||
evdev = "0.12"
|
||||
signal-hook = "0.3"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/tauri/refs/heads/dev/crates/tauri-utils/schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default permissions for the Zenbook Duo Control app",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-start-dragging",
|
||||
"shell:allow-spawn",
|
||||
"shell:allow-execute"
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default permissions for the Zenbook Duo Control app","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-close","core:window:allow-start-dragging","shell:allow-spawn","shell:allow-execute"]}}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Zenbook Duo Control
|
||||
Comment=Zenbook Duo Linux Control Panel
|
||||
Exec=zenbook-duo-control
|
||||
Icon=zenbook-duo-control
|
||||
Terminal=false
|
||||
Categories=Utility;Settings;
|
||||
StartupNotify=true
|
||||
@@ -0,0 +1,9 @@
|
||||
fn main() {
|
||||
zenbook_duo_control_lib::runtime::version::print_and_exit_if_requested("usb-media-remap");
|
||||
|
||||
if let Err(err) = zenbook_duo_control_lib::usb_media_remap_helper::run_from_env() {
|
||||
zenbook_duo_control_lib::usb_media_remap_helper::log_error(&err);
|
||||
eprintln!("{err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
zenbook_duo_control_lib::runtime::version::print_and_exit_if_requested("zenbook-duo-daemon");
|
||||
env_logger::init();
|
||||
|
||||
if let Err(err) = zenbook_duo_control_lib::runtime::daemon::run().await {
|
||||
eprintln!("{err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use zenbook_duo_control_lib::hardware::{hid, sysfs};
|
||||
use zenbook_duo_control_lib::ipc::protocol::{DaemonRequest, DaemonResponse, LifecyclePhase};
|
||||
use zenbook_duo_control_lib::runtime::client;
|
||||
|
||||
fn main() {
|
||||
zenbook_duo_control_lib::runtime::version::print_and_exit_if_requested("zenbook-duo-lifecycle");
|
||||
env_logger::init();
|
||||
|
||||
let Some(raw_phase) = std::env::args().nth(1) else {
|
||||
eprintln!("usage: zenbook-duo-lifecycle <pre|post|hibernate|thaw|boot|shutdown>");
|
||||
std::process::exit(2);
|
||||
};
|
||||
|
||||
let phase = match parse_phase(&raw_phase) {
|
||||
Ok(phase) => phase,
|
||||
Err(message) => {
|
||||
eprintln!("{message}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
|
||||
let result = match client::request(DaemonRequest::HandleLifecycle {
|
||||
phase: phase.clone(),
|
||||
}) {
|
||||
Ok(DaemonResponse::Ack) => Ok(()),
|
||||
Ok(DaemonResponse::Error { message }) => fallback_lifecycle(&phase)
|
||||
.map_err(|fallback| format!("{message}; fallback failed: {fallback}")),
|
||||
Ok(_) => fallback_lifecycle(&phase),
|
||||
Err(_) => fallback_lifecycle(&phase),
|
||||
};
|
||||
|
||||
if let Err(err) = result {
|
||||
eprintln!("{err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_phase(raw: &str) -> Result<LifecyclePhase, String> {
|
||||
match raw {
|
||||
"pre" => Ok(LifecyclePhase::Pre),
|
||||
"post" => Ok(LifecyclePhase::Post),
|
||||
"hibernate" => Ok(LifecyclePhase::Hibernate),
|
||||
"thaw" => Ok(LifecyclePhase::Thaw),
|
||||
"boot" => Ok(LifecyclePhase::Boot),
|
||||
"shutdown" => Ok(LifecyclePhase::Shutdown),
|
||||
_ => Err(format!("unsupported lifecycle phase: {raw}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback_lifecycle(phase: &LifecyclePhase) -> Result<(), String> {
|
||||
match phase {
|
||||
LifecyclePhase::Pre | LifecyclePhase::Hibernate | LifecyclePhase::Shutdown => {
|
||||
hid::set_backlight(0)
|
||||
}
|
||||
LifecyclePhase::Post | LifecyclePhase::Thaw | LifecyclePhase::Boot => {
|
||||
hid::set_backlight(sysfs::read_backlight_level())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
zenbook_duo_control_lib::runtime::version::print_and_exit_if_requested(
|
||||
"zenbook-duo-session-agent",
|
||||
);
|
||||
env_logger::init();
|
||||
|
||||
if let Err(err) = zenbook_duo_control_lib::runtime::session_agent::run().await {
|
||||
eprintln!("{err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
fn main() {
|
||||
zenbook_duo_control_lib::runtime::version::print_and_exit_if_requested(
|
||||
"zenbook-duo-usb-remap-helper",
|
||||
);
|
||||
env_logger::init();
|
||||
|
||||
match zenbook_duo_control_lib::usb_media_remap_helper::run_from_env() {
|
||||
Ok(()) => std::process::exit(0),
|
||||
Err(err) => {
|
||||
zenbook_duo_control_lib::usb_media_remap_helper::log_error(&err);
|
||||
eprintln!("{err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use crate::ipc::protocol::SessionBackend;
|
||||
use crate::models::{DisplayLayout, Orientation};
|
||||
|
||||
/// Interface implemented by each compositor Adapter.
|
||||
///
|
||||
/// Callers use the public display-layout functions in the parent Module; only
|
||||
/// this seam needs to know which compositor command syntax or parser shape is
|
||||
/// active.
|
||||
pub(super) trait CompositorDisplayAdapter {
|
||||
fn layout(&self) -> Result<DisplayLayout, String>;
|
||||
fn apply_layout(&self, layout: &DisplayLayout) -> Result<(), String>;
|
||||
fn set_orientation(&self, orientation: &Orientation) -> Result<(), String>;
|
||||
}
|
||||
|
||||
struct GnomeAdapter;
|
||||
struct KdeAdapter;
|
||||
struct NiriAdapter;
|
||||
|
||||
impl CompositorDisplayAdapter for GnomeAdapter {
|
||||
fn layout(&self) -> Result<DisplayLayout, String> {
|
||||
super::gnome::get_gnome_display_layout()
|
||||
}
|
||||
|
||||
fn apply_layout(&self, layout: &DisplayLayout) -> Result<(), String> {
|
||||
super::gnome::apply_gnome_display_layout(layout)
|
||||
}
|
||||
|
||||
fn set_orientation(&self, orientation: &Orientation) -> Result<(), String> {
|
||||
super::gnome::set_gnome_orientation(orientation)
|
||||
}
|
||||
}
|
||||
|
||||
impl CompositorDisplayAdapter for KdeAdapter {
|
||||
fn layout(&self) -> Result<DisplayLayout, String> {
|
||||
super::kde::get_kde_display_layout()
|
||||
}
|
||||
|
||||
fn apply_layout(&self, layout: &DisplayLayout) -> Result<(), String> {
|
||||
super::kde::apply_kde_display_layout(layout)
|
||||
}
|
||||
|
||||
fn set_orientation(&self, orientation: &Orientation) -> Result<(), String> {
|
||||
super::kde::set_kde_orientation(orientation)
|
||||
}
|
||||
}
|
||||
|
||||
impl CompositorDisplayAdapter for NiriAdapter {
|
||||
fn layout(&self) -> Result<DisplayLayout, String> {
|
||||
super::niri::get_niri_display_layout()
|
||||
}
|
||||
|
||||
fn apply_layout(&self, layout: &DisplayLayout) -> Result<(), String> {
|
||||
super::niri::apply_niri_display_layout(layout)
|
||||
}
|
||||
|
||||
fn set_orientation(&self, orientation: &Orientation) -> Result<(), String> {
|
||||
super::niri::set_niri_orientation(orientation)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn with_display_adapter<T>(
|
||||
backend: SessionBackend,
|
||||
f: impl FnOnce(&dyn CompositorDisplayAdapter) -> Result<T, String>,
|
||||
) -> Result<T, String> {
|
||||
match backend {
|
||||
SessionBackend::Gnome => f(&GnomeAdapter),
|
||||
SessionBackend::Kde => f(&KdeAdapter),
|
||||
SessionBackend::Niri => f(&NiriAdapter),
|
||||
SessionBackend::Unknown => Err("Unsupported session backend for display layout".into()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn get_gnome_display_layout() -> Result<DisplayLayout, String> {
|
||||
let output = compositor::command_output("gdctl", &["show"])?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"gdctl show failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
parse_gdctl_output(&stdout)
|
||||
}
|
||||
|
||||
pub(super) fn parse_gdctl_output(output: &str) -> Result<DisplayLayout, String> {
|
||||
// gdctl output is a tree with box-drawing characters.
|
||||
// We parse two parts:
|
||||
// - "Monitors" section for connectors and current/preferred mode
|
||||
// - "Logical monitors" section for position/scale/transform/primary
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct LogicalProps {
|
||||
x: i32,
|
||||
y: i32,
|
||||
scale: f64,
|
||||
transform: u32,
|
||||
primary: bool,
|
||||
}
|
||||
|
||||
fn extract_connector_from_monitor_header(line: &str) -> Option<String> {
|
||||
// Examples:
|
||||
// "├──Monitor eDP-1 (Built-in display)"
|
||||
// "└──Monitor HDMI-1 (… )"
|
||||
// "Monitor eDP-1 (… )"
|
||||
let idx = line.find("Monitor ")?;
|
||||
let rest = &line[idx + "Monitor ".len()..];
|
||||
let connector = rest.split_whitespace().next().unwrap_or("").trim();
|
||||
if connector.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(connector.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_mode_from_line(line: &str) -> Option<DisplayMode> {
|
||||
// Finds the first token like "2880x1800@120.000" (optionally suffixed with "Hz").
|
||||
let token = line
|
||||
.split_whitespace()
|
||||
.find(|t| t.contains('x') && t.contains('@'))?;
|
||||
|
||||
// Tokens in gdctl output often have tree prefixes (e.g. "└──2880x1800@120.000").
|
||||
let token = token.trim_start_matches(|c: char| !c.is_ascii_digit());
|
||||
let backend_mode_id = token
|
||||
.trim_end_matches("Hz")
|
||||
.trim_end_matches(|c: char| !(c.is_ascii_digit() || c == '.'))
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let (res, rate) = backend_mode_id.split_once('@')?;
|
||||
let (w, h) = res.split_once('x')?;
|
||||
|
||||
let width: u32 = w.trim().parse().ok()?;
|
||||
let height: u32 = h.trim().parse().ok()?;
|
||||
let refresh_rate: f64 = rate.trim().parse().ok()?;
|
||||
|
||||
Some(make_display_mode_with_backend_id(
|
||||
width,
|
||||
height,
|
||||
refresh_rate,
|
||||
Some(backend_mode_id),
|
||||
))
|
||||
}
|
||||
|
||||
fn extract_i32_pair(line: &str) -> Option<(i32, i32)> {
|
||||
// Extracts first two integers (supports negative).
|
||||
let nums: Vec<i32> = line
|
||||
.split(|c: char| !c.is_ascii_digit() && c != '-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter_map(|s| s.parse().ok())
|
||||
.collect();
|
||||
if nums.len() >= 2 {
|
||||
Some((nums[0], nums[1]))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_transform(s: &str) -> u32 {
|
||||
// gdctl prints e.g. "normal" or "90".
|
||||
match s.trim() {
|
||||
"normal" => 0,
|
||||
"90" => 90,
|
||||
"180" => 180,
|
||||
"270" => 270,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
let mut monitor_order: Vec<String> = Vec::new();
|
||||
let mut monitor_modes: std::collections::HashMap<String, Vec<DisplayMode>> =
|
||||
std::collections::HashMap::new();
|
||||
let mut monitor_current_mode: std::collections::HashMap<String, DisplayMode> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
let mut in_monitors = false;
|
||||
let mut in_logical = false;
|
||||
|
||||
let mut current_monitor: Option<String> = None;
|
||||
let mut current_logical = LogicalProps {
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1.0,
|
||||
transform: 0,
|
||||
primary: false,
|
||||
};
|
||||
let mut in_logical_block = false;
|
||||
|
||||
let mut connector_to_logical: std::collections::HashMap<String, LogicalProps> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for raw in output.lines() {
|
||||
let line = raw.trim();
|
||||
|
||||
if line.starts_with("Monitors:") {
|
||||
in_monitors = true;
|
||||
in_logical = false;
|
||||
current_monitor = None;
|
||||
continue;
|
||||
}
|
||||
if line.starts_with("Logical monitors:") {
|
||||
in_logical = true;
|
||||
in_monitors = false;
|
||||
current_monitor = None;
|
||||
in_logical_block = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_monitors {
|
||||
if let Some(connector) = extract_connector_from_monitor_header(line) {
|
||||
current_monitor = Some(connector.clone());
|
||||
if !monitor_order.contains(&connector) {
|
||||
monitor_order.push(connector);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(ref connector) = current_monitor {
|
||||
if let Some(mode) = extract_mode_from_line(line) {
|
||||
monitor_modes
|
||||
.entry(connector.clone())
|
||||
.or_default()
|
||||
.push(mode.clone());
|
||||
|
||||
if line.contains("Current mode")
|
||||
|| line.contains("current mode")
|
||||
|| !monitor_current_mode.contains_key(connector)
|
||||
{
|
||||
monitor_current_mode.insert(connector.clone(), mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if in_logical {
|
||||
if line.contains("Logical monitor #") {
|
||||
// Start a new logical monitor block.
|
||||
current_logical = LogicalProps {
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1.0,
|
||||
transform: 0,
|
||||
primary: false,
|
||||
};
|
||||
in_logical_block = true;
|
||||
continue;
|
||||
}
|
||||
if !in_logical_block {
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.contains("Position:") {
|
||||
if let Some((x, y)) = extract_i32_pair(line) {
|
||||
current_logical.x = x;
|
||||
current_logical.y = y;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if line.contains("Scale:") {
|
||||
if let Some(scale) = line.split_whitespace().last().and_then(|s| s.parse().ok()) {
|
||||
current_logical.scale = scale;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if line.contains("Transform:") {
|
||||
if let Some(t) = line.split_whitespace().last() {
|
||||
current_logical.transform = parse_transform(t);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if line.contains("Primary:") {
|
||||
current_logical.primary = line.contains("yes");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Monitor list item under this logical monitor.
|
||||
// Example: "└──eDP-1 (Built-in display)"
|
||||
if line.contains('(') {
|
||||
let token = line.split_whitespace().next().unwrap_or("");
|
||||
let connector = token
|
||||
.trim()
|
||||
.trim_start_matches(|c: char| !(c.is_ascii_alphanumeric() || c == '-'));
|
||||
// Avoid picking up tree labels like "Monitors:".
|
||||
if !connector.is_empty() && connector.contains('-') && !connector.ends_with(':') {
|
||||
connector_to_logical.insert(connector.to_string(), current_logical.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut displays: Vec<DisplayInfo> = Vec::new();
|
||||
let mut missing_logical: Vec<usize> = Vec::new();
|
||||
|
||||
for connector in monitor_order {
|
||||
let had_logical = connector_to_logical.contains_key(&connector);
|
||||
if !had_logical && !is_internal_connector(&connector) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let current_mode = monitor_current_mode
|
||||
.get(&connector)
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
monitor_modes
|
||||
.get(&connector)
|
||||
.and_then(|modes| modes.first())
|
||||
.cloned()
|
||||
})
|
||||
.unwrap_or_else(|| make_display_mode(0, 0, 60.0));
|
||||
let available_modes = dedupe_modes(
|
||||
monitor_modes
|
||||
.remove(&connector)
|
||||
.filter(|modes| !modes.is_empty())
|
||||
.unwrap_or_else(|| vec![current_mode.clone()]),
|
||||
);
|
||||
let logical = connector_to_logical
|
||||
.get(&connector)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let idx = displays.len();
|
||||
displays.push(DisplayInfo {
|
||||
connector,
|
||||
width: current_mode.width,
|
||||
height: current_mode.height,
|
||||
refresh_rate: current_mode.refresh_rate,
|
||||
scale: if logical.scale == 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
logical.scale
|
||||
},
|
||||
x: logical.x,
|
||||
y: logical.y,
|
||||
transform: logical.transform,
|
||||
primary: logical.primary,
|
||||
current_mode,
|
||||
available_modes,
|
||||
refresh_policy: RefreshPolicy::Fixed,
|
||||
supports_dynamic_refresh: false,
|
||||
});
|
||||
if !had_logical {
|
||||
missing_logical.push(idx);
|
||||
}
|
||||
}
|
||||
|
||||
// If gdctl doesn't report a logical-monitor position for a physical monitor
|
||||
// (e.g. an internal panel that's currently disabled), place it below the primary
|
||||
// as a sensible default for UI editing.
|
||||
if !displays.is_empty() && !missing_logical.is_empty() {
|
||||
let primary_idx = displays.iter().position(|d| d.primary).unwrap_or(0);
|
||||
let anchor = displays[primary_idx].clone();
|
||||
let mut next_y = anchor.y + anchor.height as i32;
|
||||
|
||||
for &i in &missing_logical {
|
||||
if i == primary_idx {
|
||||
continue;
|
||||
}
|
||||
displays[i].x = anchor.x;
|
||||
displays[i].y = next_y;
|
||||
displays[i].scale = anchor.scale;
|
||||
displays[i].transform = anchor.transform;
|
||||
next_y += displays[i].height as i32;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(DisplayLayout { displays })
|
||||
}
|
||||
|
||||
pub(super) fn gnome_mode_arg(
|
||||
display: &DisplayInfo,
|
||||
current_layout: Option<&DisplayLayout>,
|
||||
) -> String {
|
||||
if let Some(mode) = display.current_mode.backend_mode_id.as_ref() {
|
||||
return mode.clone();
|
||||
}
|
||||
|
||||
if let Some(mode) = display
|
||||
.available_modes
|
||||
.iter()
|
||||
.find(|mode| mode.mode_id == display.current_mode.mode_id)
|
||||
.and_then(|mode| mode.backend_mode_id.as_ref())
|
||||
{
|
||||
return mode.clone();
|
||||
}
|
||||
|
||||
if let Some(mode) = current_layout
|
||||
.and_then(|layout| {
|
||||
layout
|
||||
.displays
|
||||
.iter()
|
||||
.find(|d| d.connector == display.connector)
|
||||
})
|
||||
.and_then(|current| {
|
||||
current.available_modes.iter().find(|mode| {
|
||||
mode.mode_id == display.current_mode.mode_id
|
||||
|| (mode.width == display.current_mode.width
|
||||
&& mode.height == display.current_mode.height
|
||||
&& (mode.refresh_rate - display.current_mode.refresh_rate).abs() < 0.001)
|
||||
})
|
||||
})
|
||||
.and_then(|mode| mode.backend_mode_id.as_ref())
|
||||
{
|
||||
return mode.clone();
|
||||
}
|
||||
|
||||
display.current_mode.mode_id.clone()
|
||||
}
|
||||
|
||||
pub(super) fn apply_gnome_display_layout(layout: &DisplayLayout) -> Result<(), String> {
|
||||
if layout.displays.is_empty() {
|
||||
return Err("No displays in layout".into());
|
||||
}
|
||||
|
||||
let current_layout = get_gnome_display_layout().ok();
|
||||
|
||||
// gdctl rejects negative logical monitor positions.
|
||||
// Normalize the layout so the smallest x/y becomes 0.
|
||||
let mut min_x: i32 = 0;
|
||||
let mut min_y: i32 = 0;
|
||||
for d in &layout.displays {
|
||||
min_x = min_x.min(d.x);
|
||||
min_y = min_y.min(d.y);
|
||||
}
|
||||
let shift_x = if min_x < 0 { -min_x } else { 0 };
|
||||
let shift_y = if min_y < 0 { -min_y } else { 0 };
|
||||
|
||||
fn transform_arg(t: u32) -> Option<&'static str> {
|
||||
match t {
|
||||
0 => Some("normal"),
|
||||
90 => Some("90"),
|
||||
180 => Some("180"),
|
||||
270 => Some("270"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// Build gdctl set command in logical-monitor mode.
|
||||
// Keep the transform semantics aligned with `gdctl set --help`.
|
||||
let mut args: Vec<String> = vec!["set".into(), "--layout-mode".into(), "logical".into()];
|
||||
let mut primary_used = false;
|
||||
|
||||
for display in &layout.displays {
|
||||
if display.refresh_policy == RefreshPolicy::Dynamic {
|
||||
return Err(format!(
|
||||
"Dynamic refresh is not supported for {} on GNOME",
|
||||
display.connector
|
||||
));
|
||||
}
|
||||
|
||||
args.push("--logical-monitor".into());
|
||||
|
||||
if display.primary && !primary_used {
|
||||
args.push("--primary".into());
|
||||
primary_used = true;
|
||||
}
|
||||
args.push("--scale".into());
|
||||
args.push(format!("{:.6}", display.scale.max(0.1)));
|
||||
args.push("--monitor".into());
|
||||
args.push(display.connector.clone());
|
||||
args.push("--mode".into());
|
||||
args.push(gnome_mode_arg(display, current_layout.as_ref()));
|
||||
args.push("--x".into());
|
||||
args.push((display.x + shift_x).to_string());
|
||||
args.push("--y".into());
|
||||
args.push((display.y + shift_y).to_string());
|
||||
if let Some(t) = transform_arg(display.transform) {
|
||||
if t != "normal" {
|
||||
args.push("--transform".into());
|
||||
args.push(t.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If caller didn't set a primary at all, make the first display primary.
|
||||
if !primary_used {
|
||||
if let Some(pos) = args.iter().position(|a| a == "--logical-monitor") {
|
||||
args.insert(pos + 1, "--primary".into());
|
||||
}
|
||||
}
|
||||
|
||||
let output = compositor::command_output("gdctl", &args)?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"gdctl set failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn gnome_scale() -> Result<f64, String> {
|
||||
let layout = get_display_layout()?;
|
||||
layout
|
||||
.displays
|
||||
.iter()
|
||||
.find(|display| display.primary)
|
||||
.or_else(|| layout.displays.first())
|
||||
.map(|display| display.scale.max(0.1))
|
||||
.ok_or_else(|| "No GNOME displays available".to_string())
|
||||
}
|
||||
|
||||
fn gnome_logical_monitor_count() -> Result<usize, String> {
|
||||
let output = compositor::command_output("gdctl", &["show"])?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"gdctl show failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.filter(|line| line.contains("Logical monitor #"))
|
||||
.count())
|
||||
}
|
||||
|
||||
pub(super) fn set_gnome_orientation(orientation: &Orientation) -> Result<(), String> {
|
||||
let scale = format!("{:.6}", gnome_scale()?);
|
||||
let transform = match orientation {
|
||||
Orientation::Normal => None,
|
||||
Orientation::Left => Some("90"),
|
||||
Orientation::Right => Some("270"),
|
||||
Orientation::Inverted => Some("180"),
|
||||
};
|
||||
|
||||
let logical_count = gnome_logical_monitor_count().unwrap_or(1);
|
||||
let mut args = vec![
|
||||
"set".to_string(),
|
||||
"--logical-monitor".to_string(),
|
||||
"--primary".to_string(),
|
||||
"--scale".to_string(),
|
||||
scale.clone(),
|
||||
"--monitor".to_string(),
|
||||
PRIMARY_INTERNAL_CONNECTOR.to_string(),
|
||||
];
|
||||
|
||||
if let Some(transform) = transform {
|
||||
args.push("--transform".to_string());
|
||||
args.push(transform.to_string());
|
||||
}
|
||||
|
||||
if logical_count > 1 {
|
||||
args.push("--logical-monitor".to_string());
|
||||
args.push("--scale".to_string());
|
||||
args.push(scale);
|
||||
args.push("--monitor".to_string());
|
||||
args.push(SECONDARY_INTERNAL_CONNECTOR.to_string());
|
||||
|
||||
match orientation {
|
||||
Orientation::Left => {
|
||||
args.extend(["--left-of", PRIMARY_INTERNAL_CONNECTOR].map(str::to_string))
|
||||
}
|
||||
Orientation::Right => {
|
||||
args.extend(["--right-of", PRIMARY_INTERNAL_CONNECTOR].map(str::to_string))
|
||||
}
|
||||
Orientation::Inverted => {
|
||||
args.extend(["--above", PRIMARY_INTERNAL_CONNECTOR].map(str::to_string))
|
||||
}
|
||||
Orientation::Normal => {
|
||||
args.extend(["--below", PRIMARY_INTERNAL_CONNECTOR].map(str::to_string))
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(transform) = transform {
|
||||
args.push("--transform".to_string());
|
||||
args.push(transform.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
run_command("gdctl", &args)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn get_kde_display_layout() -> Result<DisplayLayout, String> {
|
||||
let value = compositor::kscreen_json()?;
|
||||
let outputs = compositor::kde_outputs_from_value(&value)?;
|
||||
|
||||
fn parse_kde_mode(value: &serde_json::Value) -> Option<DisplayMode> {
|
||||
let width = value
|
||||
.get("size")
|
||||
.and_then(|size| size.get("width"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.or_else(|| value.get("width").and_then(|v| v.as_u64()))? as u32;
|
||||
let height = value
|
||||
.get("size")
|
||||
.and_then(|size| size.get("height"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.or_else(|| value.get("height").and_then(|v| v.as_u64()))? as u32;
|
||||
let refresh_rate = value
|
||||
.get("refreshRate")
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|rate| rate / 1000.0)?;
|
||||
Some(make_display_mode(width, height, refresh_rate))
|
||||
}
|
||||
|
||||
let mut displays = Vec::new();
|
||||
for output in outputs {
|
||||
if !output
|
||||
.get("enabled")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let connector = output
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Missing KDE output name".to_string())?;
|
||||
let size = output
|
||||
.get("size")
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or_else(|| format!("Missing KDE size for {connector}"))?;
|
||||
let pos = output
|
||||
.get("pos")
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or_else(|| format!("Missing KDE position for {connector}"))?;
|
||||
let rotation = output
|
||||
.get("rotation")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("none");
|
||||
let scale = output.get("scale").and_then(|v| v.as_f64()).unwrap_or(1.0);
|
||||
let current_mode = output
|
||||
.get("currentMode")
|
||||
.and_then(parse_kde_mode)
|
||||
.unwrap_or_else(|| {
|
||||
make_display_mode(
|
||||
size.get("width").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
|
||||
size.get("height").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
|
||||
output
|
||||
.get("currentMode")
|
||||
.and_then(|mode| mode.get("refreshRate"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|rate| rate / 1000.0)
|
||||
.unwrap_or(60.0),
|
||||
)
|
||||
});
|
||||
let available_modes = dedupe_modes(
|
||||
output
|
||||
.get("modes")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|modes| modes.iter().filter_map(parse_kde_mode).collect())
|
||||
.unwrap_or_else(|| vec![current_mode.clone()]),
|
||||
);
|
||||
|
||||
displays.push(DisplayInfo {
|
||||
connector: connector.to_string(),
|
||||
width: current_mode.width,
|
||||
height: current_mode.height,
|
||||
refresh_rate: current_mode.refresh_rate,
|
||||
scale,
|
||||
x: pos.get("x").and_then(|v| v.as_i64()).unwrap_or(0) as i32,
|
||||
y: pos.get("y").and_then(|v| v.as_i64()).unwrap_or(0) as i32,
|
||||
transform: match rotation {
|
||||
"right" => 90,
|
||||
"left" => 270,
|
||||
"inverted" => 180,
|
||||
_ => 0,
|
||||
},
|
||||
primary: output.get("priority").and_then(|v| v.as_i64()).unwrap_or(0) == 1,
|
||||
current_mode,
|
||||
available_modes,
|
||||
refresh_policy: RefreshPolicy::Fixed,
|
||||
supports_dynamic_refresh: false,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(DisplayLayout { displays })
|
||||
}
|
||||
|
||||
pub(super) fn apply_kde_display_layout(layout: &DisplayLayout) -> Result<(), String> {
|
||||
if layout.displays.is_empty() {
|
||||
return Err("No displays in layout".into());
|
||||
}
|
||||
|
||||
let mut args: Vec<String> = Vec::new();
|
||||
let available_outputs = kde_output_names()?;
|
||||
for connector in omitted_output_names(layout, &available_outputs) {
|
||||
args.push(format!("output.{connector}.disable"));
|
||||
}
|
||||
|
||||
for display in &layout.displays {
|
||||
if display.refresh_policy == RefreshPolicy::Dynamic {
|
||||
return Err(format!(
|
||||
"Dynamic refresh is not supported for {} on KDE",
|
||||
display.connector
|
||||
));
|
||||
}
|
||||
|
||||
args.push(format!("output.{}.enable", display.connector));
|
||||
args.push(format!(
|
||||
"output.{}.mode.{}",
|
||||
display.connector, display.current_mode.mode_id
|
||||
));
|
||||
args.push(format!(
|
||||
"output.{}.position.{},{}",
|
||||
display.connector, display.x, display.y
|
||||
));
|
||||
args.push(format!(
|
||||
"output.{}.rotation.{}",
|
||||
display.connector,
|
||||
match display.transform {
|
||||
90 => "right",
|
||||
180 => "inverted",
|
||||
270 => "left",
|
||||
_ => "none",
|
||||
}
|
||||
));
|
||||
args.push(format!(
|
||||
"output.{}.scale.{:.6}",
|
||||
display.connector,
|
||||
display.scale.max(0.1)
|
||||
));
|
||||
if display.primary {
|
||||
args.push(format!("output.{}.priority.1", display.connector));
|
||||
}
|
||||
}
|
||||
|
||||
run_command("kscreen-doctor", &args)
|
||||
}
|
||||
|
||||
fn kde_output_logical_size(name: &str) -> Result<(i64, i64), String> {
|
||||
compositor::kde_output_logical_size_from_value(&compositor::kscreen_json()?, name)
|
||||
}
|
||||
|
||||
fn kde_enabled_output_count() -> Result<usize, String> {
|
||||
compositor::kde_enabled_output_count_from_value(&compositor::kscreen_json()?)
|
||||
}
|
||||
|
||||
fn kde_rotation_token(orientation: &Orientation) -> &'static str {
|
||||
match orientation {
|
||||
Orientation::Left => "right",
|
||||
Orientation::Right => "left",
|
||||
Orientation::Inverted => "inverted",
|
||||
Orientation::Normal => "none",
|
||||
}
|
||||
}
|
||||
|
||||
fn rotated_secondary_position(
|
||||
orientation: &Orientation,
|
||||
primary_size: Result<(i64, i64), String>,
|
||||
) -> Result<(i64, i64), String> {
|
||||
let (width, height) = primary_size?;
|
||||
let (rot_w, rot_h) = match orientation {
|
||||
Orientation::Left | Orientation::Right => (height, width),
|
||||
Orientation::Normal | Orientation::Inverted => (width, height),
|
||||
};
|
||||
Ok(match orientation {
|
||||
Orientation::Left => (-rot_w, 0),
|
||||
Orientation::Right => (rot_w, 0),
|
||||
Orientation::Inverted => (0, -rot_h),
|
||||
Orientation::Normal => (0, rot_h),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn set_kde_orientation(orientation: &Orientation) -> Result<(), String> {
|
||||
let token = kde_rotation_token(orientation);
|
||||
let enabled_count = kde_enabled_output_count().unwrap_or(1);
|
||||
|
||||
if enabled_count <= 1 {
|
||||
let args = vec![
|
||||
format!("output.{PRIMARY_INTERNAL_CONNECTOR}.enable"),
|
||||
format!("output.{PRIMARY_INTERNAL_CONNECTOR}.position.0,0"),
|
||||
format!("output.{PRIMARY_INTERNAL_CONNECTOR}.rotation.{token}"),
|
||||
];
|
||||
return run_command("kscreen-doctor", &args);
|
||||
}
|
||||
|
||||
let (pos_x, pos_y) = rotated_secondary_position(
|
||||
orientation,
|
||||
kde_output_logical_size(PRIMARY_INTERNAL_CONNECTOR),
|
||||
)?;
|
||||
|
||||
let args = vec![
|
||||
format!("output.{PRIMARY_INTERNAL_CONNECTOR}.enable"),
|
||||
format!("output.{SECONDARY_INTERNAL_CONNECTOR}.enable"),
|
||||
format!("output.{PRIMARY_INTERNAL_CONNECTOR}.rotation.{token}"),
|
||||
format!("output.{SECONDARY_INTERNAL_CONNECTOR}.rotation.{token}"),
|
||||
format!("output.{PRIMARY_INTERNAL_CONNECTOR}.position.0,0"),
|
||||
format!("output.{SECONDARY_INTERNAL_CONNECTOR}.position.{pos_x},{pos_y}"),
|
||||
];
|
||||
|
||||
run_command("kscreen-doctor", &args)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_primary_geometry_is_an_error_for_secondary_positioning() {
|
||||
let result =
|
||||
rotated_secondary_position(&Orientation::Normal, Err("missing geometry".into()));
|
||||
|
||||
assert_eq!(
|
||||
result.expect_err("missing geometry should fail"),
|
||||
"missing geometry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secondary_position_uses_rotated_primary_geometry() {
|
||||
assert_eq!(
|
||||
rotated_secondary_position(&Orientation::Left, Ok((1200, 800))).expect("position"),
|
||||
(-800, 0)
|
||||
);
|
||||
assert_eq!(
|
||||
rotated_secondary_position(&Orientation::Normal, Ok((1200, 800))).expect("position"),
|
||||
(0, 800)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
use crate::hardware::duo::{
|
||||
is_internal_connector, is_primary_internal_connector, PRIMARY_INTERNAL_CONNECTOR,
|
||||
SECONDARY_INTERNAL_CONNECTOR,
|
||||
};
|
||||
mod adapters;
|
||||
mod gnome;
|
||||
mod kde;
|
||||
mod niri;
|
||||
|
||||
use crate::ipc::protocol::SessionBackend;
|
||||
use crate::models::{DisplayInfo, DisplayLayout, DisplayMode, Orientation, RefreshPolicy};
|
||||
use crate::runtime::{compositor, session};
|
||||
use std::collections::HashSet;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
fn format_refresh_rate(refresh_rate: f64) -> String {
|
||||
let rounded = (refresh_rate * 1000.0).round() / 1000.0;
|
||||
let mut value = format!("{rounded:.3}");
|
||||
while value.contains('.') && value.ends_with('0') {
|
||||
value.pop();
|
||||
}
|
||||
if value.ends_with('.') {
|
||||
value.pop();
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn mode_id(width: u32, height: u32, refresh_rate: f64) -> String {
|
||||
format!("{width}x{height}@{}", format_refresh_rate(refresh_rate))
|
||||
}
|
||||
|
||||
fn make_display_mode(width: u32, height: u32, refresh_rate: f64) -> DisplayMode {
|
||||
make_display_mode_with_backend_id(width, height, refresh_rate, None)
|
||||
}
|
||||
|
||||
fn make_display_mode_with_backend_id(
|
||||
width: u32,
|
||||
height: u32,
|
||||
refresh_rate: f64,
|
||||
backend_mode_id: Option<String>,
|
||||
) -> DisplayMode {
|
||||
DisplayMode {
|
||||
mode_id: mode_id(width, height, refresh_rate),
|
||||
backend_mode_id,
|
||||
width,
|
||||
height,
|
||||
refresh_rate,
|
||||
}
|
||||
}
|
||||
|
||||
fn dedupe_modes(modes: Vec<DisplayMode>) -> Vec<DisplayMode> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut deduped = Vec::new();
|
||||
for mode in modes {
|
||||
if seen.insert(mode.mode_id.clone()) {
|
||||
deduped.push(mode);
|
||||
}
|
||||
}
|
||||
deduped
|
||||
}
|
||||
|
||||
fn omitted_output_names(layout: &DisplayLayout, available_outputs: &[String]) -> Vec<String> {
|
||||
let desired = layout
|
||||
.displays
|
||||
.iter()
|
||||
.map(|display| display.connector.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
available_outputs
|
||||
.iter()
|
||||
.filter(|name| !desired.contains(name.as_str()))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn kde_output_names() -> Result<Vec<String>, String> {
|
||||
compositor::kde_output_names_from_value(&compositor::kscreen_json()?)
|
||||
}
|
||||
|
||||
fn niri_output_names() -> Result<Vec<String>, String> {
|
||||
compositor::niri_output_names_from_value(&compositor::niri_outputs_json()?)
|
||||
}
|
||||
|
||||
fn stacked_logical_height(display: &DisplayInfo) -> i32 {
|
||||
let rotated = display.transform == 90 || display.transform == 270;
|
||||
let physical_height = if rotated {
|
||||
display.width
|
||||
} else {
|
||||
display.height
|
||||
};
|
||||
let scale = display.scale.max(0.1);
|
||||
(physical_height as f64 / scale).ceil() as i32
|
||||
}
|
||||
|
||||
pub fn normalize_display_layout(layout: DisplayLayout) -> DisplayLayout {
|
||||
let Some(top_display) = layout
|
||||
.displays
|
||||
.iter()
|
||||
.find(|display| is_primary_internal_connector(&display.connector))
|
||||
.cloned()
|
||||
else {
|
||||
return layout;
|
||||
};
|
||||
|
||||
let top_logical_height = stacked_logical_height(&top_display);
|
||||
let shift_x = top_display.x;
|
||||
let shift_y = top_display.y;
|
||||
|
||||
let displays = layout
|
||||
.displays
|
||||
.into_iter()
|
||||
.map(|display| {
|
||||
if is_primary_internal_connector(&display.connector) {
|
||||
return DisplayInfo {
|
||||
x: 0,
|
||||
y: 0,
|
||||
..display
|
||||
};
|
||||
}
|
||||
|
||||
if display.connector == SECONDARY_INTERNAL_CONNECTOR {
|
||||
return DisplayInfo {
|
||||
x: 0,
|
||||
y: top_logical_height,
|
||||
..display
|
||||
};
|
||||
}
|
||||
|
||||
DisplayInfo {
|
||||
x: display.x - shift_x,
|
||||
y: display.y - shift_y,
|
||||
..display
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
DisplayLayout { displays }
|
||||
}
|
||||
|
||||
/// Get the current display layout through the selected compositor Adapter.
|
||||
pub fn get_display_layout() -> Result<DisplayLayout, String> {
|
||||
let layout = adapters::with_display_adapter(detect_backend(), |adapter| adapter.layout())?;
|
||||
|
||||
Ok(normalize_display_layout(layout))
|
||||
}
|
||||
|
||||
/// Apply a display layout through the selected compositor Adapter.
|
||||
pub fn apply_display_layout(layout: &DisplayLayout) -> Result<(), String> {
|
||||
let normalized = normalize_display_layout(layout.clone());
|
||||
adapters::with_display_adapter(detect_backend(), |adapter| {
|
||||
adapter.apply_layout(&normalized)
|
||||
})
|
||||
}
|
||||
|
||||
fn detect_backend() -> SessionBackend {
|
||||
session::detect_backend_from_env()
|
||||
}
|
||||
|
||||
fn run_command<S: AsRef<str>>(program: &str, args: &[S]) -> Result<(), String> {
|
||||
compositor::run_command(program, args)
|
||||
}
|
||||
|
||||
fn run_niri_command(args: &[&str]) -> Result<(), String> {
|
||||
compositor::run_niri_command(args)
|
||||
}
|
||||
|
||||
fn run_niri_position_command(connector: &str, x: i32, y: i32) -> Result<(), String> {
|
||||
let x_arg = x.to_string();
|
||||
let y_arg = y.to_string();
|
||||
run_niri_command(&[
|
||||
"msg", "output", connector, "position", "set", "--", &x_arg, &y_arg,
|
||||
])
|
||||
}
|
||||
|
||||
/// Set screen orientation through the selected compositor Adapter.
|
||||
pub fn set_orientation(orientation: &Orientation) -> Result<(), String> {
|
||||
adapters::with_display_adapter(detect_backend(), |adapter| {
|
||||
adapter.set_orientation(orientation)
|
||||
})
|
||||
.map_err(|message| {
|
||||
if message == "Unsupported session backend for display layout" {
|
||||
"Unsupported session backend for orientation control".to_string()
|
||||
} else {
|
||||
message
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::adapters::CompositorDisplayAdapter;
|
||||
use super::*;
|
||||
|
||||
fn test_display(connector: &str) -> DisplayInfo {
|
||||
let mode = make_display_mode(2880, 1800, 120.0);
|
||||
DisplayInfo {
|
||||
connector: connector.to_string(),
|
||||
width: mode.width,
|
||||
height: mode.height,
|
||||
refresh_rate: mode.refresh_rate,
|
||||
scale: 1.66,
|
||||
x: 0,
|
||||
y: 0,
|
||||
transform: 0,
|
||||
primary: is_primary_internal_connector(connector),
|
||||
current_mode: mode.clone(),
|
||||
available_modes: vec![mode],
|
||||
refresh_policy: RefreshPolicy::Fixed,
|
||||
supports_dynamic_refresh: false,
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeAdapter;
|
||||
|
||||
impl adapters::CompositorDisplayAdapter for FakeAdapter {
|
||||
fn layout(&self) -> Result<DisplayLayout, String> {
|
||||
Ok(DisplayLayout {
|
||||
displays: vec![test_display(PRIMARY_INTERNAL_CONNECTOR)],
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_layout(&self, layout: &DisplayLayout) -> Result<(), String> {
|
||||
if layout.displays.is_empty() {
|
||||
Err("empty layout".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn set_orientation(&self, _orientation: &Orientation) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_adapter_interface_covers_layout_apply_and_orientation() {
|
||||
let adapter = FakeAdapter;
|
||||
assert_eq!(adapter.layout().expect("layout").displays.len(), 1);
|
||||
assert!(adapter
|
||||
.apply_layout(&DisplayLayout {
|
||||
displays: vec![test_display(PRIMARY_INTERNAL_CONNECTOR)],
|
||||
})
|
||||
.is_ok());
|
||||
assert!(adapter.set_orientation(&Orientation::Left).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_all_outputs_omitted_from_requested_layout() {
|
||||
let layout = DisplayLayout {
|
||||
displays: vec![test_display(PRIMARY_INTERNAL_CONNECTOR)],
|
||||
};
|
||||
let available = vec![
|
||||
PRIMARY_INTERNAL_CONNECTOR.to_string(),
|
||||
SECONDARY_INTERNAL_CONNECTOR.to_string(),
|
||||
"HDMI-A-1".to_string(),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
omitted_output_names(&layout, &available),
|
||||
vec![SECONDARY_INTERNAL_CONNECTOR, "HDMI-A-1"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_only_layout_omits_internal_outputs_for_clamshell() {
|
||||
let layout = DisplayLayout {
|
||||
displays: vec![test_display("HDMI-A-1")],
|
||||
};
|
||||
let available = vec![
|
||||
PRIMARY_INTERNAL_CONNECTOR.to_string(),
|
||||
SECONDARY_INTERNAL_CONNECTOR.to_string(),
|
||||
"HDMI-A-1".to_string(),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
omitted_output_names(&layout, &available),
|
||||
vec![PRIMARY_INTERNAL_CONNECTOR, SECONDARY_INTERNAL_CONNECTOR]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_kde_output_names_for_disable_replay() {
|
||||
let value = serde_json::json!({
|
||||
"outputs": [
|
||||
{ "name": "eDP-1", "enabled": true },
|
||||
{ "name": "eDP-2", "enabled": true }
|
||||
]
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
compositor::kde_output_names_from_value(&value).expect("names should parse"),
|
||||
vec!["eDP-1".to_string(), "eDP-2".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_niri_output_names_for_disable_replay() {
|
||||
let value = serde_json::json!({
|
||||
"eDP-1": { "name": "eDP-1" },
|
||||
"eDP-2": { "name": "eDP-2" }
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
compositor::niri_output_names_from_value(&value).expect("names should parse"),
|
||||
vec!["eDP-1".to_string(), "eDP-2".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_mode_ids_without_trailing_zeroes() {
|
||||
assert_eq!(mode_id(2880, 1800, 120.0), "2880x1800@120");
|
||||
assert_eq!(mode_id(2880, 1800, 60.001), "2880x1800@60.001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_gdctl_modes_into_current_and_available_modes() {
|
||||
let output = r#"Monitors:
|
||||
├──Monitor eDP-1 (Built-in display)
|
||||
│ ├──Current mode
|
||||
│ │ └──2880x1800@120.000
|
||||
│ └──Preferences
|
||||
│ ├──2880x1800@120.000
|
||||
│ └──2880x1800@60.000
|
||||
Logical monitors:
|
||||
└──Logical monitor #1
|
||||
├──Position: 0, 0
|
||||
├──Scale: 1.750000
|
||||
├──Transform: normal
|
||||
├──Primary: yes
|
||||
└──eDP-1 (Built-in display)
|
||||
"#;
|
||||
|
||||
let layout = gnome::parse_gdctl_output(output).expect("gdctl output should parse");
|
||||
let display = layout.displays.first().expect("display should exist");
|
||||
|
||||
assert_eq!(display.current_mode.mode_id, "2880x1800@120");
|
||||
assert_eq!(
|
||||
display.current_mode.backend_mode_id.as_deref(),
|
||||
Some("2880x1800@120.000")
|
||||
);
|
||||
assert_eq!(display.available_modes.len(), 2);
|
||||
assert_eq!(display.available_modes[1].mode_id, "2880x1800@60");
|
||||
assert_eq!(
|
||||
display.available_modes[1].backend_mode_id.as_deref(),
|
||||
Some("2880x1800@60.000")
|
||||
);
|
||||
assert_eq!(display.refresh_policy, RefreshPolicy::Fixed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gnome_parser_skips_non_logical_external_monitors_but_keeps_internal_panels() {
|
||||
let output = r#"Monitors:
|
||||
├──Monitor eDP-1 (Built-in display)
|
||||
│ └──Current mode
|
||||
│ └──2880x1800@120.000
|
||||
├──Monitor eDP-2 (Built-in display)
|
||||
│ └──Preferences
|
||||
│ └──2880x1800@120.000
|
||||
└──Monitor HDMI-A-1 (External display)
|
||||
└──Preferences
|
||||
└──1920x1080@60.000
|
||||
Logical monitors:
|
||||
└──Logical monitor #1
|
||||
├──Position: 0, 0
|
||||
├──Scale: 1.000000
|
||||
├──Transform: normal
|
||||
├──Primary: yes
|
||||
└──eDP-1 (Built-in display)
|
||||
"#;
|
||||
|
||||
let layout = gnome::parse_gdctl_output(output).expect("gdctl output should parse");
|
||||
let connectors = layout
|
||||
.displays
|
||||
.iter()
|
||||
.map(|display| display.connector.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(connectors, vec!["eDP-1", "eDP-2"]);
|
||||
assert_eq!(layout.displays[1].y, 1800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gnome_parser_keeps_external_monitors_that_have_logical_entries() {
|
||||
let output = r#"Monitors:
|
||||
├──Monitor eDP-1 (Built-in display)
|
||||
│ └──Current mode
|
||||
│ └──2880x1800@120.000
|
||||
└──Monitor HDMI-A-1 (External display)
|
||||
└──Current mode
|
||||
└──1920x1080@60.000
|
||||
Logical monitors:
|
||||
├──Logical monitor #1
|
||||
│ ├──Position: 0, 0
|
||||
│ ├──Scale: 1.000000
|
||||
│ ├──Transform: normal
|
||||
│ ├──Primary: yes
|
||||
│ └──eDP-1 (Built-in display)
|
||||
└──Logical monitor #2
|
||||
├──Position: 2880, 0
|
||||
├──Scale: 1.000000
|
||||
├──Transform: normal
|
||||
├──Primary: no
|
||||
└──HDMI-A-1 (External display)
|
||||
"#;
|
||||
|
||||
let layout = gnome::parse_gdctl_output(output).expect("gdctl output should parse");
|
||||
let external = layout
|
||||
.displays
|
||||
.iter()
|
||||
.find(|display| display.connector == "HDMI-A-1")
|
||||
.expect("active external should be included");
|
||||
|
||||
assert_eq!(external.x, 2880);
|
||||
assert!(!external.primary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gnome_mode_arg_prefers_backend_token_and_falls_back_to_current_modes() {
|
||||
let mut display = test_display(PRIMARY_INTERNAL_CONNECTOR);
|
||||
display.current_mode.backend_mode_id = Some("2880x1800@120.000".into());
|
||||
assert_eq!(gnome::gnome_mode_arg(&display, None), "2880x1800@120.000");
|
||||
|
||||
display.current_mode.backend_mode_id = None;
|
||||
display.available_modes[0].backend_mode_id = None;
|
||||
let current_layout = DisplayLayout {
|
||||
displays: vec![DisplayInfo {
|
||||
current_mode: make_display_mode_with_backend_id(
|
||||
2880,
|
||||
1800,
|
||||
120.0,
|
||||
Some("2880x1800@120.000".into()),
|
||||
),
|
||||
available_modes: vec![make_display_mode_with_backend_id(
|
||||
2880,
|
||||
1800,
|
||||
120.0,
|
||||
Some("2880x1800@120.000".into()),
|
||||
)],
|
||||
..test_display(PRIMARY_INTERNAL_CONNECTOR)
|
||||
}],
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
gnome::gnome_mode_arg(&display, Some(¤t_layout)),
|
||||
"2880x1800@120.000"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_niri_transform_from_logical_object() {
|
||||
let value = serde_json::json!({
|
||||
"logical": { "transform": "Normal" }
|
||||
});
|
||||
assert_eq!(niri::parse_niri_transform(&value), 0);
|
||||
|
||||
let value = serde_json::json!({
|
||||
"logical": { "transform": "90" }
|
||||
});
|
||||
assert_eq!(niri::parse_niri_transform(&value), 90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_top_level_niri_transform() {
|
||||
let value = serde_json::json!({
|
||||
"transform": "270"
|
||||
});
|
||||
assert_eq!(niri::parse_niri_transform(&value), 270);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_niri_current_mode_from_index() {
|
||||
let value = serde_json::json!({
|
||||
"current_mode": 1,
|
||||
"modes": [
|
||||
{ "width": 1920, "height": 1200, "refresh_rate": 60000 },
|
||||
{ "width": 2880, "height": 1800, "refresh_rate": 120000 }
|
||||
]
|
||||
});
|
||||
|
||||
let mode = niri::resolve_niri_current_mode(&value).expect("mode should resolve");
|
||||
assert_eq!(mode.get("width").and_then(|v| v.as_u64()), Some(2880));
|
||||
assert_eq!(mode.get("height").and_then(|v| v.as_u64()), Some(1800));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn get_niri_display_layout() -> Result<DisplayLayout, String> {
|
||||
let value = compositor::niri_outputs_json()?;
|
||||
let outputs = compositor::niri_outputs_from_value(&value)?;
|
||||
|
||||
fn parse_niri_mode(value: &serde_json::Value) -> Option<DisplayMode> {
|
||||
Some(make_display_mode(
|
||||
value.get("width").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
|
||||
value.get("height").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
|
||||
value
|
||||
.get("refresh_rate")
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|rate| rate / 1000.0)?,
|
||||
))
|
||||
}
|
||||
|
||||
let mut displays = Vec::new();
|
||||
for output in outputs {
|
||||
if output
|
||||
.get("current_mode")
|
||||
.is_some_and(|value| value.is_null())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let connector = output
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Missing niri output name".to_string())?;
|
||||
let logical = output
|
||||
.get("logical")
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or_else(|| format!("Missing niri logical geometry for {connector}"))?;
|
||||
let current_mode = resolve_niri_current_mode(&output)
|
||||
.and_then(|value| parse_niri_mode(&value))
|
||||
.ok_or_else(|| format!("Missing niri current mode for {connector}"))?;
|
||||
let available_modes = dedupe_modes(
|
||||
output
|
||||
.get("modes")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|modes| modes.iter().filter_map(parse_niri_mode).collect())
|
||||
.unwrap_or_else(|| vec![current_mode.clone()]),
|
||||
);
|
||||
// Niri reports VRR support on the Duo panels, but enabling it can hard-freeze the
|
||||
// machine on this hardware. Keep the control disabled until there is a known-safe path.
|
||||
let supports_dynamic_refresh = false;
|
||||
let refresh_policy = RefreshPolicy::Fixed;
|
||||
|
||||
displays.push(DisplayInfo {
|
||||
connector: connector.to_string(),
|
||||
width: current_mode.width,
|
||||
height: current_mode.height,
|
||||
refresh_rate: current_mode.refresh_rate,
|
||||
scale: logical.get("scale").and_then(|v| v.as_f64()).unwrap_or(1.0),
|
||||
x: logical.get("x").and_then(|v| v.as_i64()).unwrap_or(0) as i32,
|
||||
y: logical.get("y").and_then(|v| v.as_i64()).unwrap_or(0) as i32,
|
||||
transform: parse_niri_transform(&output),
|
||||
primary: is_primary_internal_connector(connector),
|
||||
current_mode,
|
||||
available_modes,
|
||||
refresh_policy,
|
||||
supports_dynamic_refresh,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(DisplayLayout { displays })
|
||||
}
|
||||
|
||||
pub(super) fn resolve_niri_current_mode(output: &serde_json::Value) -> Option<serde_json::Value> {
|
||||
if let Some(mode) = output.get("current_mode").and_then(|v| v.as_object()) {
|
||||
return Some(serde_json::Value::Object(mode.clone()));
|
||||
}
|
||||
|
||||
let index = output.get("current_mode").and_then(|v| v.as_u64())? as usize;
|
||||
output
|
||||
.get("modes")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|modes| modes.get(index))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub(super) fn parse_niri_transform(output: &serde_json::Value) -> u32 {
|
||||
output
|
||||
.get("logical")
|
||||
.and_then(|value| value.get("transform"))
|
||||
.or_else(|| output.get("transform"))
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|value| value.to_ascii_lowercase())
|
||||
.map(|value| match value.as_str() {
|
||||
"90" => 90,
|
||||
"180" => 180,
|
||||
"270" => 270,
|
||||
"flipped" | "inverted" => 180,
|
||||
_ => 0,
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub(super) fn apply_niri_display_layout(layout: &DisplayLayout) -> Result<(), String> {
|
||||
if layout.displays.is_empty() {
|
||||
return Err("No displays in layout".into());
|
||||
}
|
||||
|
||||
let available_outputs = niri_output_names()?;
|
||||
for connector in omitted_output_names(layout, &available_outputs) {
|
||||
run_niri_command(&["msg", "output", &connector, "off"])?;
|
||||
}
|
||||
|
||||
for display in &layout.displays {
|
||||
run_niri_command(&["msg", "output", &display.connector, "on"])?;
|
||||
match display.refresh_policy {
|
||||
RefreshPolicy::Dynamic => {
|
||||
return Err(format!(
|
||||
"Dynamic refresh is disabled on Niri because it is unstable on this hardware ({})",
|
||||
display.connector
|
||||
));
|
||||
}
|
||||
RefreshPolicy::Fixed => {
|
||||
run_niri_command(&["msg", "output", &display.connector, "vrr", "off"])?;
|
||||
}
|
||||
}
|
||||
run_niri_command(&[
|
||||
"msg",
|
||||
"output",
|
||||
&display.connector,
|
||||
"mode",
|
||||
&display.current_mode.mode_id,
|
||||
])?;
|
||||
run_niri_command(&[
|
||||
"msg",
|
||||
"output",
|
||||
&display.connector,
|
||||
"transform",
|
||||
match display.transform {
|
||||
90 => "90",
|
||||
180 => "180",
|
||||
270 => "270",
|
||||
_ => "normal",
|
||||
},
|
||||
])?;
|
||||
run_niri_command(&[
|
||||
"msg",
|
||||
"output",
|
||||
&display.connector,
|
||||
"scale",
|
||||
&format!("{:.6}", display.scale.max(0.1)),
|
||||
])?;
|
||||
run_niri_position_command(&display.connector, display.x, display.y)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn niri_output_logical_size(name: &str) -> Result<(i64, i64), String> {
|
||||
compositor::niri_output_logical_size_from_value(&compositor::niri_outputs_json()?, name)
|
||||
}
|
||||
|
||||
fn niri_enabled_output_count() -> Result<usize, String> {
|
||||
compositor::niri_enabled_output_count_from_value(&compositor::niri_outputs_json()?)
|
||||
}
|
||||
|
||||
fn niri_transform_token(orientation: &Orientation) -> &'static str {
|
||||
match orientation {
|
||||
Orientation::Left => "90",
|
||||
Orientation::Right => "270",
|
||||
Orientation::Inverted => "180",
|
||||
Orientation::Normal => "normal",
|
||||
}
|
||||
}
|
||||
|
||||
fn secondary_position(
|
||||
orientation: &Orientation,
|
||||
primary_size: Result<(i64, i64), String>,
|
||||
) -> Result<(i64, i64), String> {
|
||||
let (width, height) = primary_size?;
|
||||
Ok(match orientation {
|
||||
Orientation::Left => (-width, 0),
|
||||
Orientation::Right => (width, 0),
|
||||
Orientation::Inverted => (0, -height),
|
||||
Orientation::Normal => (0, height),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn set_niri_orientation(orientation: &Orientation) -> Result<(), String> {
|
||||
let token = niri_transform_token(orientation);
|
||||
run_niri_command(&[
|
||||
"msg",
|
||||
"output",
|
||||
PRIMARY_INTERNAL_CONNECTOR,
|
||||
"transform",
|
||||
token,
|
||||
])?;
|
||||
run_niri_position_command(PRIMARY_INTERNAL_CONNECTOR, 0, 0)?;
|
||||
|
||||
if niri_enabled_output_count().unwrap_or(1) <= 1 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
run_niri_command(&[
|
||||
"msg",
|
||||
"output",
|
||||
SECONDARY_INTERNAL_CONNECTOR,
|
||||
"transform",
|
||||
token,
|
||||
])?;
|
||||
thread::sleep(Duration::from_millis(300));
|
||||
|
||||
let (pos_x, pos_y) = secondary_position(
|
||||
orientation,
|
||||
niri_output_logical_size(PRIMARY_INTERNAL_CONNECTOR),
|
||||
)?;
|
||||
|
||||
run_niri_position_command(SECONDARY_INTERNAL_CONNECTOR, pos_x as i32, pos_y as i32)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_primary_geometry_is_an_error_for_secondary_positioning() {
|
||||
let result = secondary_position(&Orientation::Normal, Err("missing geometry".into()));
|
||||
|
||||
assert_eq!(
|
||||
result.expect_err("missing geometry should fail"),
|
||||
"missing geometry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secondary_position_uses_primary_geometry() {
|
||||
assert_eq!(
|
||||
secondary_position(&Orientation::Right, Ok((1200, 800))).expect("position"),
|
||||
(1200, 0)
|
||||
);
|
||||
assert_eq!(
|
||||
secondary_position(&Orientation::Normal, Ok((1200, 800))).expect("position"),
|
||||
(0, 800)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
pub const PRIMARY_INTERNAL_CONNECTOR: &str = "eDP-1";
|
||||
pub const SECONDARY_INTERNAL_CONNECTOR: &str = "eDP-2";
|
||||
pub const INTERNAL_CONNECTORS: [&str; 2] =
|
||||
[PRIMARY_INTERNAL_CONNECTOR, SECONDARY_INTERNAL_CONNECTOR];
|
||||
|
||||
pub fn is_internal_connector(connector: &str) -> bool {
|
||||
INTERNAL_CONNECTORS.contains(&connector)
|
||||
}
|
||||
|
||||
pub fn is_primary_internal_connector(connector: &str) -> bool {
|
||||
connector == PRIMARY_INTERNAL_CONNECTOR
|
||||
}
|
||||
|
||||
pub fn is_secondary_internal_connector(connector: &str) -> bool {
|
||||
connector == SECONDARY_INTERNAL_CONNECTOR
|
||||
}
|
||||
|
||||
pub fn connector_for_elan_name(name: &str) -> Option<&'static str> {
|
||||
if name.contains("ELAN9008") {
|
||||
Some(PRIMARY_INTERNAL_CONNECTOR)
|
||||
} else if name.contains("ELAN9009") {
|
||||
Some(SECONDARY_INTERNAL_CONNECTOR)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn identifies_internal_connectors() {
|
||||
assert!(is_internal_connector(PRIMARY_INTERNAL_CONNECTOR));
|
||||
assert!(is_internal_connector(SECONDARY_INTERNAL_CONNECTOR));
|
||||
assert!(!is_internal_connector("HDMI-A-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_elan_touchscreens_to_internal_connectors() {
|
||||
assert_eq!(
|
||||
connector_for_elan_name("ELAN9008:00"),
|
||||
Some(PRIMARY_INTERNAL_CONNECTOR)
|
||||
);
|
||||
assert_eq!(
|
||||
connector_for_elan_name("ELAN9009:00"),
|
||||
Some(SECONDARY_INTERNAL_CONNECTOR)
|
||||
);
|
||||
assert_eq!(connector_for_elan_name("ELAN0000:00"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
use std::fs;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
use std::path::Path;
|
||||
|
||||
use rusb::UsbContext;
|
||||
|
||||
/// USB HID SET_REPORT for keyboard backlight control using rusb.
|
||||
///
|
||||
/// Protocol:
|
||||
/// Report ID: 0x5A
|
||||
/// Data: [0x5A, 0xBA, 0xC5, 0xC4, level, 0x00 x 11]
|
||||
/// wValue: 0x035A, wIndex: 4, wLength: 16
|
||||
pub fn set_backlight_usb(level: u8) -> Result<(), String> {
|
||||
let level = level.min(3);
|
||||
|
||||
let context = rusb::Context::new().map_err(|e| format!("USB context error: {e}"))?;
|
||||
let devices = context
|
||||
.devices()
|
||||
.map_err(|e| format!("USB device list error: {e}"))?;
|
||||
|
||||
for device in devices.iter() {
|
||||
let desc: rusb::DeviceDescriptor = match device.device_descriptor() {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// ASUS Zenbook Duo keyboard: vendor 0x0B05
|
||||
if desc.vendor_id() != 0x0B05 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let handle: rusb::DeviceHandle<rusb::Context> = match device.open() {
|
||||
Ok(h) => h,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// Check if this is the keyboard by reading product string
|
||||
if let Ok(product) = handle.read_product_string_ascii(&desc) {
|
||||
if !product.contains("Zenbook Duo Keyboard") && !product.contains("ASUS_DUO") {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detach kernel driver if needed
|
||||
let interface = 4u8;
|
||||
let _ = handle.set_auto_detach_kernel_driver(true);
|
||||
let _ = handle.claim_interface(interface as u8);
|
||||
|
||||
// Build the HID SET_REPORT payload
|
||||
let mut data = [0u8; 16];
|
||||
data[0] = 0x5A; // Report ID
|
||||
data[1] = 0xBA;
|
||||
data[2] = 0xC5;
|
||||
data[3] = 0xC4;
|
||||
data[4] = level;
|
||||
|
||||
// HID SET_REPORT: bmRequestType=0x21, bRequest=0x09
|
||||
// wValue = 0x0300 | report_id = 0x035A
|
||||
// wIndex = interface number
|
||||
let request_type = 0x21; // Host-to-device, class, interface
|
||||
let request = 0x09; // SET_REPORT
|
||||
let value = 0x035A; // Feature report, ID 0x5A
|
||||
let index = interface as u16;
|
||||
let timeout = std::time::Duration::from_secs(2);
|
||||
|
||||
handle
|
||||
.write_control(request_type, request, value, index, &data, timeout)
|
||||
.map_err(|e| format!("USB write error: {e}"))?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err("Zenbook Duo keyboard not found via USB".into())
|
||||
}
|
||||
|
||||
/// Bluetooth HID Feature Report for keyboard backlight using ioctl HIDIOCSFEATURE.
|
||||
pub fn set_backlight_bluetooth(level: u8) -> Result<(), String> {
|
||||
let level = level.min(3);
|
||||
|
||||
// Find the hidraw device for the Zenbook keyboard
|
||||
let hidraw_path = find_bt_hidraw()?;
|
||||
|
||||
let file = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&hidraw_path)
|
||||
.map_err(|e| format!("Failed to open {hidraw_path}: {e}"))?;
|
||||
|
||||
let fd = file.as_raw_fd();
|
||||
|
||||
// Build the same payload
|
||||
let mut data = [0u8; 16];
|
||||
data[0] = 0x5A;
|
||||
data[1] = 0xBA;
|
||||
data[2] = 0xC5;
|
||||
data[3] = 0xC4;
|
||||
data[4] = level;
|
||||
|
||||
// HIDIOCSFEATURE = 0xC0104806 + len
|
||||
// This is _IOC(_IOC_WRITE|_IOC_READ, 'H', 0x06, len)
|
||||
// For 16 bytes: 0xC0104806
|
||||
let hidiocsfeature: libc::c_ulong = 0xC010_4806;
|
||||
|
||||
let ret = unsafe { libc::ioctl(fd, hidiocsfeature, data.as_mut_ptr()) };
|
||||
|
||||
if ret < 0 {
|
||||
return Err(format!(
|
||||
"ioctl HIDIOCSFEATURE failed: {}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_bt_hidraw() -> Result<String, String> {
|
||||
let hidraw_dir = Path::new("/sys/class/hidraw");
|
||||
if let Ok(entries) = fs::read_dir(hidraw_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let uevent_path = entry.path().join("device/uevent");
|
||||
if let Ok(contents) = fs::read_to_string(&uevent_path) {
|
||||
if (contents.contains("Zenbook Duo Keyboard") || contents.contains("ASUS_DUO"))
|
||||
&& contents.contains("0005:")
|
||||
{
|
||||
let name = entry.file_name();
|
||||
return Ok(format!("/dev/{}", name.to_string_lossy()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err("Bluetooth hidraw device not found".into())
|
||||
}
|
||||
|
||||
/// Set backlight, trying USB first then Bluetooth.
|
||||
pub fn set_backlight(level: u8) -> Result<(), String> {
|
||||
// Try USB first
|
||||
let usb_err = match set_backlight_usb(level) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => e,
|
||||
};
|
||||
|
||||
// Try Bluetooth
|
||||
let bt_err = match set_backlight_bluetooth(level) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => e,
|
||||
};
|
||||
|
||||
Err(format!(
|
||||
"Failed to set keyboard backlight natively (usb: {usb_err}; bt: {bt_err})"
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod display_layout;
|
||||
pub mod duo;
|
||||
pub mod hid;
|
||||
pub mod sysfs;
|
||||
pub mod touchscreen;
|
||||
@@ -0,0 +1,341 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::models::{ConnectionType, DuoStatus, Orientation};
|
||||
use crate::runtime::{paths, state::RuntimeState};
|
||||
|
||||
const BACKLIGHT_ROOT: &str = "/sys/class/backlight";
|
||||
const POGO_PIN_USB_PORT: &str = "-6/input";
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
struct KeyboardPresence {
|
||||
saw_pogo_usb: bool,
|
||||
saw_other_usb: bool,
|
||||
saw_bluetooth: bool,
|
||||
}
|
||||
|
||||
fn load_runtime_state() -> Option<RuntimeState> {
|
||||
let path = paths::state_file_path();
|
||||
let contents = fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str(&contents).ok()
|
||||
}
|
||||
|
||||
fn backlight_dirs_from(root: &Path) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = fs::read_dir(root)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flat_map(|entries| entries.flatten())
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.join("brightness").exists())
|
||||
.collect();
|
||||
dirs.sort();
|
||||
dirs
|
||||
}
|
||||
|
||||
fn backlight_name(path: &Path) -> String {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn is_secondary_backlight_name(name: &str) -> bool {
|
||||
name.contains("edp-2") || name.contains("edp2")
|
||||
}
|
||||
|
||||
fn primary_backlight_dir_from(root: &Path) -> Option<PathBuf> {
|
||||
let dirs = backlight_dirs_from(root);
|
||||
dirs.iter()
|
||||
.find(|path| backlight_name(path) == "intel_backlight")
|
||||
.or_else(|| {
|
||||
dirs.iter()
|
||||
.find(|path| backlight_name(path).contains("edp-1"))
|
||||
})
|
||||
.or_else(|| {
|
||||
dirs.iter()
|
||||
.find(|path| !is_secondary_backlight_name(&backlight_name(path)))
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn secondary_backlight_dir_from(root: &Path) -> Option<PathBuf> {
|
||||
backlight_dirs_from(root)
|
||||
.into_iter()
|
||||
.find(|path| is_secondary_backlight_name(&backlight_name(path)))
|
||||
}
|
||||
|
||||
pub fn primary_backlight_dir() -> Option<PathBuf> {
|
||||
primary_backlight_dir_from(Path::new(BACKLIGHT_ROOT))
|
||||
}
|
||||
|
||||
pub fn secondary_backlight_dir() -> Option<PathBuf> {
|
||||
secondary_backlight_dir_from(Path::new(BACKLIGHT_ROOT))
|
||||
}
|
||||
|
||||
fn read_backlight_value(path: &Path) -> Option<u32> {
|
||||
fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse().ok())
|
||||
}
|
||||
|
||||
pub fn read_backlight_level() -> u8 {
|
||||
load_runtime_state()
|
||||
.map(|state| state.status.backlight_level)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn read_display_brightness() -> u32 {
|
||||
primary_backlight_dir()
|
||||
.and_then(|dir| read_backlight_value(&dir.join("brightness")))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn read_max_brightness() -> u32 {
|
||||
primary_backlight_dir()
|
||||
.and_then(|dir| read_backlight_value(&dir.join("max_brightness")))
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
pub fn detect_connection_type() -> ConnectionType {
|
||||
let hidraw_dir = Path::new("/sys/class/hidraw");
|
||||
if !hidraw_dir.exists() {
|
||||
return ConnectionType::None;
|
||||
}
|
||||
|
||||
let presence = detect_keyboard_presence(hidraw_dir);
|
||||
|
||||
// The keyboard can expose both paired Bluetooth and docked USB HID interfaces at the
|
||||
// same time. Prefer USB so docked behavior, including the media remap helper, stays active.
|
||||
classify_connection_type(
|
||||
presence.saw_pogo_usb || presence.saw_other_usb,
|
||||
presence.saw_bluetooth,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn keyboard_attached() -> bool {
|
||||
let hidraw_dir = Path::new("/sys/class/hidraw");
|
||||
if !hidraw_dir.exists() {
|
||||
return false;
|
||||
}
|
||||
|
||||
detect_keyboard_presence(hidraw_dir).saw_pogo_usb
|
||||
}
|
||||
|
||||
fn detect_keyboard_presence(hidraw_dir: &Path) -> KeyboardPresence {
|
||||
let mut presence = KeyboardPresence::default();
|
||||
|
||||
if let Ok(entries) = fs::read_dir(hidraw_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let uevent_path = entry.path().join("device/uevent");
|
||||
let Ok(contents) = fs::read_to_string(&uevent_path) else {
|
||||
continue;
|
||||
};
|
||||
update_keyboard_presence(&mut presence, &contents);
|
||||
}
|
||||
}
|
||||
|
||||
presence
|
||||
}
|
||||
|
||||
fn update_keyboard_presence(presence: &mut KeyboardPresence, contents: &str) {
|
||||
if !(contents.contains("Zenbook Duo Keyboard") || contents.contains("ASUS_DUO")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if contents.contains("HID_ID=0005:") {
|
||||
presence.saw_bluetooth = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if is_pogo_pin_usb_keyboard_uevent(contents) {
|
||||
presence.saw_pogo_usb = true;
|
||||
} else {
|
||||
presence.saw_other_usb = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn is_pogo_pin_usb_keyboard_uevent(contents: &str) -> bool {
|
||||
contents.contains("HID_ID=0003:") && contents.contains(POGO_PIN_USB_PORT)
|
||||
}
|
||||
|
||||
fn classify_connection_type(saw_usb: bool, saw_bluetooth: bool) -> ConnectionType {
|
||||
if saw_usb {
|
||||
ConnectionType::Usb
|
||||
} else if saw_bluetooth {
|
||||
ConnectionType::Bluetooth
|
||||
} else {
|
||||
ConnectionType::None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_service_active() -> bool {
|
||||
is_unit_active_system("zenbook-duo-rust-daemon.service")
|
||||
&& is_unit_active_user("zenbook-duo-session-agent.service")
|
||||
}
|
||||
|
||||
fn is_unit_active_user(unit: &str) -> bool {
|
||||
std::process::Command::new("systemctl")
|
||||
.args(["--user", "is-active", unit])
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "active")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_unit_active_system(unit: &str) -> bool {
|
||||
std::process::Command::new("systemctl")
|
||||
.args(["is-active", unit])
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "active")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn get_full_status() -> DuoStatus {
|
||||
if let Some(mut status) = load_runtime_state().map(|state| state.status) {
|
||||
status.display_brightness = read_display_brightness();
|
||||
status.max_brightness = read_max_brightness();
|
||||
status.connection_type = detect_connection_type();
|
||||
status.service_active = is_service_active();
|
||||
return status;
|
||||
}
|
||||
|
||||
DuoStatus {
|
||||
keyboard_attached: false,
|
||||
connection_type: detect_connection_type(),
|
||||
monitor_count: 0,
|
||||
wifi_enabled: false,
|
||||
bluetooth_enabled: false,
|
||||
backlight_level: read_backlight_level(),
|
||||
display_brightness: read_display_brightness(),
|
||||
max_brightness: read_max_brightness(),
|
||||
service_active: is_service_active(),
|
||||
orientation: Orientation::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_log() -> Result<(), String> {
|
||||
let runtime_path = paths::log_file_path();
|
||||
if let Some(parent) = runtime_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
fs::write(runtime_path, "").map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn read_log_lines(count: usize) -> Vec<String> {
|
||||
let runtime_path = paths::log_file_path();
|
||||
let contents = fs::read_to_string(&runtime_path).unwrap_or_default();
|
||||
|
||||
contents
|
||||
.lines()
|
||||
.rev()
|
||||
.take(count)
|
||||
.map(String::from)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prefers_usb_when_both_transport_interfaces_are_visible() {
|
||||
assert_eq!(classify_connection_type(true, true), ConnectionType::Usb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_bluetooth_when_only_bluetooth_is_visible() {
|
||||
assert_eq!(
|
||||
classify_connection_type(false, true),
|
||||
ConnectionType::Bluetooth
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_no_transport_is_visible() {
|
||||
assert_eq!(classify_connection_type(false, false), ConnectionType::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_port_six_usb_keyboard_as_pogo_pin_attached() {
|
||||
let mut presence = KeyboardPresence::default();
|
||||
|
||||
update_keyboard_presence(
|
||||
&mut presence,
|
||||
"HID_ID=0003:00000B05:00001B2C\nHID_NAME=Primax Electronics Ltd. ASUS Zenbook Duo Keyboard\nHID_PHYS=usb-0000:00:14.0-6/input5\n",
|
||||
);
|
||||
|
||||
assert!(presence.saw_pogo_usb);
|
||||
assert!(!presence.saw_other_usb);
|
||||
assert!(!presence.saw_bluetooth);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_cable_only_usb_keyboard_detached() {
|
||||
let mut presence = KeyboardPresence::default();
|
||||
|
||||
update_keyboard_presence(
|
||||
&mut presence,
|
||||
"HID_ID=0003:00000B05:00001B2C\nHID_NAME=Primax Electronics Ltd. ASUS Zenbook Duo Keyboard\nHID_PHYS=usb-0000:00:14.0-4/input5\n",
|
||||
);
|
||||
|
||||
assert!(!presence.saw_pogo_usb);
|
||||
assert!(presence.saw_other_usb);
|
||||
assert!(!presence.saw_bluetooth);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_bluetooth_keyboard_without_marking_usb() {
|
||||
let mut presence = KeyboardPresence::default();
|
||||
|
||||
update_keyboard_presence(
|
||||
&mut presence,
|
||||
"HID_ID=0005:00000B05:00001B2D\nHID_NAME=ASUS Zenbook Duo Keyboard\n",
|
||||
);
|
||||
|
||||
assert!(!presence.saw_pogo_usb);
|
||||
assert!(!presence.saw_other_usb);
|
||||
assert!(presence.saw_bluetooth);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_primary_and_secondary_backlight_devices() {
|
||||
let root = unique_temp_dir("backlight");
|
||||
create_backlight(&root, "card1-eDP-2-backlight");
|
||||
create_backlight(&root, "intel_backlight");
|
||||
|
||||
assert_eq!(
|
||||
primary_backlight_dir_from(&root)
|
||||
.and_then(|path| path.file_name().map(|name| name.to_owned())),
|
||||
Some(std::ffi::OsString::from("intel_backlight"))
|
||||
);
|
||||
assert_eq!(
|
||||
secondary_backlight_dir_from(&root)
|
||||
.and_then(|path| path.file_name().map(|name| name.to_owned())),
|
||||
Some(std::ffi::OsString::from("card1-eDP-2-backlight"))
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).expect("remove temp dir");
|
||||
}
|
||||
|
||||
fn create_backlight(root: &Path, name: &str) {
|
||||
let dir = root.join(name);
|
||||
fs::create_dir_all(&dir).expect("create backlight dir");
|
||||
fs::write(dir.join("brightness"), "100").expect("write brightness");
|
||||
fs::write(dir.join("max_brightness"), "400").expect("write max brightness");
|
||||
}
|
||||
|
||||
fn unique_temp_dir(label: &str) -> PathBuf {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("clock before unix epoch")
|
||||
.as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"zenbook-duo-sysfs-{label}-{}-{nanos}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&dir).expect("create temp dir");
|
||||
dir
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
use crate::hardware::duo;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TouchscreenDevice {
|
||||
pub name: String,
|
||||
pub i2c_id: String,
|
||||
pub connector: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// Maps ELAN model number to display connector.
|
||||
fn elan_to_connector(name: &str) -> Option<&'static str> {
|
||||
duo::connector_for_elan_name(name)
|
||||
}
|
||||
|
||||
/// Reads the device name from sysfs for an i2c device.
|
||||
fn read_i2c_device_name(i2c_id: &str) -> Option<String> {
|
||||
let path = format!("/sys/bus/i2c/devices/{}/name", i2c_id);
|
||||
fs::read_to_string(&path).ok().map(|s| s.trim().to_string())
|
||||
}
|
||||
|
||||
/// Checks if the i2c device is currently bound to its driver.
|
||||
fn is_bound(i2c_id: &str) -> bool {
|
||||
Path::new(&format!("/sys/bus/i2c/drivers/i2c_hid_acpi/{}", i2c_id)).exists()
|
||||
}
|
||||
|
||||
pub fn list_touchscreens() -> Vec<TouchscreenDevice> {
|
||||
let mut devices = Vec::new();
|
||||
let i2c_devices = match fs::read_dir("/sys/bus/i2c/devices") {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return devices,
|
||||
};
|
||||
for entry in i2c_devices.flatten() {
|
||||
let i2c_id = entry.file_name().to_string_lossy().to_string();
|
||||
if !i2c_id.starts_with("i2c-ELAN") {
|
||||
continue;
|
||||
}
|
||||
let name = match read_i2c_device_name(&i2c_id) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
let connector = match elan_to_connector(&name) {
|
||||
Some(c) => c.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
devices.push(TouchscreenDevice {
|
||||
name,
|
||||
i2c_id: i2c_id.clone(),
|
||||
connector,
|
||||
enabled: is_bound(&i2c_id),
|
||||
});
|
||||
}
|
||||
devices
|
||||
}
|
||||
|
||||
pub fn set_touchscreen_enabled(i2c_id: &str, enabled: bool) -> Result<(), String> {
|
||||
let path = if enabled {
|
||||
"/sys/bus/i2c/drivers/i2c_hid_acpi/bind"
|
||||
} else {
|
||||
"/sys/bus/i2c/drivers/i2c_hid_acpi/unbind"
|
||||
};
|
||||
fs::write(path, i2c_id).map_err(|e| {
|
||||
format!(
|
||||
"Failed to {} touchscreen {}: {}",
|
||||
if enabled { "bind" } else { "unbind" },
|
||||
i2c_id,
|
||||
e
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::hardware::duo::{PRIMARY_INTERNAL_CONNECTOR, SECONDARY_INTERNAL_CONNECTOR};
|
||||
|
||||
#[test]
|
||||
fn maps_known_elan_panels_to_duo_connectors() {
|
||||
assert_eq!(
|
||||
elan_to_connector("ELAN9008:00"),
|
||||
Some(PRIMARY_INTERNAL_CONNECTOR)
|
||||
);
|
||||
assert_eq!(
|
||||
elan_to_connector("ELAN9009:00"),
|
||||
Some(SECONDARY_INTERNAL_CONNECTOR)
|
||||
);
|
||||
assert_eq!(elan_to_connector("ELAN1234:00"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod protocol;
|
||||
@@ -0,0 +1,175 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::commands::usb_media_remap::UsbMediaRemapStatus;
|
||||
use crate::models::{
|
||||
DaemonVersionInfo, DisplayLayout, DuoSettings, DuoStatus, HardwareEvent, Orientation,
|
||||
};
|
||||
|
||||
pub const PROTOCOL_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Envelope<T> {
|
||||
pub protocol_version: u32,
|
||||
pub payload: T,
|
||||
}
|
||||
|
||||
impl<T> Envelope<T> {
|
||||
pub fn new(payload: T) -> Self {
|
||||
Self {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
payload,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum DaemonRequest {
|
||||
Ping,
|
||||
HandleLifecycle {
|
||||
phase: LifecyclePhase,
|
||||
},
|
||||
GetStatus,
|
||||
GetVersion,
|
||||
GetDisplayLayout,
|
||||
GetSettings,
|
||||
SaveSettings {
|
||||
settings: DuoSettings,
|
||||
},
|
||||
SetBacklight {
|
||||
level: u8,
|
||||
},
|
||||
SetOrientation {
|
||||
orientation: Orientation,
|
||||
},
|
||||
ApplyDisplayLayout {
|
||||
layout: DisplayLayout,
|
||||
},
|
||||
UsbMediaRemapStatus,
|
||||
UsbMediaRemapStart,
|
||||
UsbMediaRemapStop,
|
||||
UsbMediaRemapTogglePause,
|
||||
RestartService,
|
||||
RegisterSessionAgent {
|
||||
session_id: String,
|
||||
backend: SessionBackend,
|
||||
socket_path: String,
|
||||
},
|
||||
AppendLog {
|
||||
line: String,
|
||||
},
|
||||
TailLogs {
|
||||
lines: usize,
|
||||
},
|
||||
ClearLogs,
|
||||
GetRecentEvents {
|
||||
limit: usize,
|
||||
},
|
||||
ListTouchscreens,
|
||||
SetTouchscreenEnabled {
|
||||
connector: String,
|
||||
enabled: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum DaemonResponse {
|
||||
Pong,
|
||||
Ack,
|
||||
Status {
|
||||
status: DuoStatus,
|
||||
},
|
||||
Version {
|
||||
version: DaemonVersionInfo,
|
||||
},
|
||||
DisplayLayout {
|
||||
layout: DisplayLayout,
|
||||
},
|
||||
Settings {
|
||||
settings: DuoSettings,
|
||||
},
|
||||
UsbMediaRemapStatus {
|
||||
status: UsbMediaRemapStatus,
|
||||
},
|
||||
Logs {
|
||||
lines: Vec<String>,
|
||||
},
|
||||
Events {
|
||||
events: Vec<HardwareEvent>,
|
||||
},
|
||||
Touchscreens {
|
||||
devices: Vec<crate::hardware::touchscreen::TouchscreenDevice>,
|
||||
},
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LifecyclePhase {
|
||||
Pre,
|
||||
Post,
|
||||
Hibernate,
|
||||
Thaw,
|
||||
Boot,
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum DaemonEvent {
|
||||
StatusChanged {
|
||||
status: DuoStatus,
|
||||
},
|
||||
HardwareEvent {
|
||||
event: HardwareEvent,
|
||||
},
|
||||
SessionAgentChanged {
|
||||
connected: bool,
|
||||
backend: Option<SessionBackend>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SessionBackend {
|
||||
Gnome,
|
||||
Kde,
|
||||
Niri,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum SessionCommand {
|
||||
GetDisplayLayout,
|
||||
SetDockMode {
|
||||
attached: bool,
|
||||
scale: f64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
layout: Option<DisplayLayout>,
|
||||
},
|
||||
ApplyDisplayLayout {
|
||||
layout: DisplayLayout,
|
||||
},
|
||||
SetOrientation {
|
||||
orientation: Orientation,
|
||||
},
|
||||
ShowNotification {
|
||||
title: String,
|
||||
message: String,
|
||||
urgent: bool,
|
||||
},
|
||||
OpenEmojiPicker,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum SessionResponse {
|
||||
Ack,
|
||||
DisplayLayout { layout: DisplayLayout },
|
||||
Error { message: String },
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
pub mod commands;
|
||||
pub mod hardware;
|
||||
pub mod ipc;
|
||||
pub mod models;
|
||||
pub mod runtime;
|
||||
pub mod usb_media_remap_helper;
|
||||
mod watchers;
|
||||
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem, PredefinedMenuItem, Submenu},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
Manager,
|
||||
};
|
||||
|
||||
use commands::events::create_event_buffer;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
env_logger::init();
|
||||
|
||||
let event_buffer = create_event_buffer();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
|
||||
if argv.iter().any(|arg| arg == "--start-minimized") {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.manage(event_buffer.clone())
|
||||
.setup(move |app| {
|
||||
let handle = app.handle().clone();
|
||||
let start_minimized = std::env::args().any(|arg| arg == "--start-minimized");
|
||||
|
||||
// Build tray menu
|
||||
build_tray(&handle)?;
|
||||
|
||||
// Start background watchers
|
||||
watchers::start_all_watchers(&handle, event_buffer.clone());
|
||||
|
||||
if !start_minimized {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
// Minimize to tray on close instead of quitting
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::status::get_status,
|
||||
commands::backlight::get_backlight,
|
||||
commands::backlight::set_backlight,
|
||||
commands::display::get_display_layout,
|
||||
commands::display::apply_display_layout,
|
||||
commands::display::set_orientation,
|
||||
commands::service::is_service_active,
|
||||
commands::service::get_version_info,
|
||||
commands::service::restart_service,
|
||||
commands::settings::load_settings,
|
||||
commands::settings::save_settings,
|
||||
commands::theme::get_system_theme,
|
||||
commands::logs::read_log,
|
||||
commands::logs::clear_log,
|
||||
commands::profiles::list_profiles,
|
||||
commands::profiles::save_profile,
|
||||
commands::profiles::delete_profile,
|
||||
commands::profiles::activate_profile,
|
||||
commands::events::get_recent_events,
|
||||
commands::diagnostics::diag_list_evdev,
|
||||
commands::diagnostics::diag_capture_evdev,
|
||||
commands::diagnostics::diag_capture_evdev_multi,
|
||||
commands::diagnostics::diag_list_hid,
|
||||
commands::diagnostics::diag_read_report_descriptor,
|
||||
commands::diagnostics::diag_capture_hidraw_pkexec,
|
||||
commands::usb_media_remap::usb_media_remap_status,
|
||||
commands::usb_media_remap::usb_media_remap_start,
|
||||
commands::usb_media_remap::usb_media_remap_stop,
|
||||
commands::usb_media_remap::usb_media_remap_toggle_pause,
|
||||
commands::touchscreen::list_touchscreens,
|
||||
commands::touchscreen::set_touchscreen_enabled,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
fn build_tray(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let show = MenuItem::with_id(app, "show", "Show Window", true, None::<&str>)?;
|
||||
let separator1 = PredefinedMenuItem::separator(app)?;
|
||||
|
||||
let profile_docked = MenuItem::with_id(app, "profile_docked", "Docked", true, None::<&str>)?;
|
||||
let profile_tablet = MenuItem::with_id(app, "profile_tablet", "Tablet", true, None::<&str>)?;
|
||||
let profile_presentation = MenuItem::with_id(
|
||||
app,
|
||||
"profile_presentation",
|
||||
"Presentation",
|
||||
true,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let profiles_submenu = Submenu::with_items(
|
||||
app,
|
||||
"Profiles",
|
||||
true,
|
||||
&[&profile_docked, &profile_tablet, &profile_presentation],
|
||||
)?;
|
||||
|
||||
let bl_0 = MenuItem::with_id(app, "bl_0", "Backlight Off", true, None::<&str>)?;
|
||||
let bl_1 = MenuItem::with_id(app, "bl_1", "Backlight Low", true, None::<&str>)?;
|
||||
let bl_2 = MenuItem::with_id(app, "bl_2", "Backlight Medium", true, None::<&str>)?;
|
||||
let bl_3 = MenuItem::with_id(app, "bl_3", "Backlight High", true, None::<&str>)?;
|
||||
let backlight_submenu =
|
||||
Submenu::with_items(app, "Backlight", true, &[&bl_0, &bl_1, &bl_2, &bl_3])?;
|
||||
|
||||
let separator2 = PredefinedMenuItem::separator(app)?;
|
||||
let usb_media_remap = MenuItem::with_id(
|
||||
app,
|
||||
"usb_media_remap",
|
||||
"Toggle USB Media Remap",
|
||||
true,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let usb_media_remap_pause = MenuItem::with_id(
|
||||
app,
|
||||
"usb_media_remap_pause",
|
||||
"Pause/Resume Remap",
|
||||
true,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let separator3 = PredefinedMenuItem::separator(app)?;
|
||||
let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
||||
|
||||
let menu = Menu::with_items(
|
||||
app,
|
||||
&[
|
||||
&show,
|
||||
&separator1,
|
||||
&profiles_submenu,
|
||||
&backlight_submenu,
|
||||
&separator2,
|
||||
&usb_media_remap,
|
||||
&usb_media_remap_pause,
|
||||
&separator3,
|
||||
&quit,
|
||||
],
|
||||
)?;
|
||||
|
||||
let tray_builder = TrayIconBuilder::new()
|
||||
.menu(&menu)
|
||||
.tooltip("Zenbook Duo Control")
|
||||
.on_menu_event(move |app, event| {
|
||||
let id = event.id().as_ref();
|
||||
match id {
|
||||
"show" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
"profile_docked" | "profile_tablet" | "profile_presentation" => {
|
||||
let profile_id = id.strip_prefix("profile_").unwrap_or(id);
|
||||
let _ = commands::profiles::activate_profile(profile_id.to_string());
|
||||
}
|
||||
id if id.starts_with("bl_") => {
|
||||
if let Ok(level) = id[3..].parse::<u8>() {
|
||||
let _ = commands::backlight::set_backlight_daemon_first(level);
|
||||
}
|
||||
}
|
||||
"usb_media_remap" => {
|
||||
let status = commands::usb_media_remap::daemon_first_status();
|
||||
if status.running {
|
||||
let _ = commands::usb_media_remap::daemon_first_stop();
|
||||
} else {
|
||||
let _ = commands::usb_media_remap::daemon_first_start();
|
||||
}
|
||||
}
|
||||
"usb_media_remap_pause" => {
|
||||
let _ = commands::usb_media_remap::daemon_first_toggle_pause();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
|
||||
let tray_builder = if let Some(icon) = app.default_window_icon() {
|
||||
tray_builder.icon(icon.clone())
|
||||
} else {
|
||||
tray_builder
|
||||
};
|
||||
|
||||
let _tray = tray_builder
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
let app = tray.app_handle();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
// The app also acts as a small privileged helper when invoked via pkexec.
|
||||
// This avoids needing to bundle a separate binary alongside the main app.
|
||||
if std::env::args().any(|a| a == "--usb-media-remap-helper") {
|
||||
match zenbook_duo_control_lib::usb_media_remap_helper::run_from_env() {
|
||||
Ok(()) => std::process::exit(0),
|
||||
Err(err) => {
|
||||
zenbook_duo_control_lib::usb_media_remap_helper::log_error(&err);
|
||||
eprintln!("{err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if std::env::args().any(|a| a == "--toggle-remap-pause") {
|
||||
match zenbook_duo_control_lib::commands::usb_media_remap::toggle_pause() {
|
||||
Ok(()) => std::process::exit(0),
|
||||
Err(err) => {
|
||||
eprintln!("{err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
zenbook_duo_control_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HardwareEvent {
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub category: EventCategory,
|
||||
pub severity: EventSeverity,
|
||||
pub message: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum EventCategory {
|
||||
Usb,
|
||||
Display,
|
||||
Keyboard,
|
||||
Network,
|
||||
Rotation,
|
||||
Bluetooth,
|
||||
Service,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum EventSeverity {
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl HardwareEvent {
|
||||
pub fn info(
|
||||
category: EventCategory,
|
||||
message: impl Into<String>,
|
||||
source: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
timestamp: Utc::now(),
|
||||
category,
|
||||
severity: EventSeverity::Info,
|
||||
message: message.into(),
|
||||
source: source.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn warning(
|
||||
category: EventCategory,
|
||||
message: impl Into<String>,
|
||||
source: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
timestamp: Utc::now(),
|
||||
category,
|
||||
severity: EventSeverity::Warning,
|
||||
message: message.into(),
|
||||
source: source.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn error(
|
||||
category: EventCategory,
|
||||
message: impl Into<String>,
|
||||
source: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
timestamp: Utc::now(),
|
||||
category,
|
||||
severity: EventSeverity::Error,
|
||||
message: message.into(),
|
||||
source: source.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
pub mod event;
|
||||
pub mod profile;
|
||||
pub mod settings;
|
||||
pub mod status;
|
||||
pub mod version;
|
||||
|
||||
pub use event::*;
|
||||
pub use profile::*;
|
||||
pub use settings::*;
|
||||
pub use status::*;
|
||||
pub use version::*;
|
||||
@@ -0,0 +1,55 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{DisplayLayout, Orientation};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Profile {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub backlight_level: u8,
|
||||
pub scale: f64,
|
||||
pub orientation: Orientation,
|
||||
pub dual_screen_enabled: bool,
|
||||
pub display_layout: Option<DisplayLayout>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileList {
|
||||
pub profiles: Vec<Profile>,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
pub fn default_profiles() -> Vec<Profile> {
|
||||
vec![
|
||||
Profile {
|
||||
id: "docked".into(),
|
||||
name: "Docked".into(),
|
||||
backlight_level: 3,
|
||||
scale: 1.66,
|
||||
orientation: Orientation::Normal,
|
||||
dual_screen_enabled: false,
|
||||
display_layout: None,
|
||||
},
|
||||
Profile {
|
||||
id: "tablet".into(),
|
||||
name: "Tablet".into(),
|
||||
backlight_level: 0,
|
||||
scale: 1.66,
|
||||
orientation: Orientation::Normal,
|
||||
dual_screen_enabled: true,
|
||||
display_layout: None,
|
||||
},
|
||||
Profile {
|
||||
id: "presentation".into(),
|
||||
name: "Presentation".into(),
|
||||
backlight_level: 3,
|
||||
scale: 1.66,
|
||||
orientation: Orientation::Normal,
|
||||
dual_screen_enabled: true,
|
||||
display_layout: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::DisplayLayout;
|
||||
|
||||
pub const DEFAULT_BACKLIGHT_LEVEL: u8 = 0;
|
||||
pub const DEFAULT_SCALE_FACTOR: f64 = 1.66;
|
||||
pub const DEFAULT_USB_MEDIA_REMAP_ENABLED: bool = true;
|
||||
pub const DEFAULT_START_ON_BOOT_MINIMIZED: bool = false;
|
||||
pub const DEFAULT_INVERT_SENSOR_ROTATION: bool = false;
|
||||
pub const DEFAULT_SETUP_COMPLETED: bool = false;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DuoSettings {
|
||||
#[serde(default = "default_backlight")]
|
||||
pub default_backlight: u8,
|
||||
#[serde(default = "default_scale")]
|
||||
pub default_scale: f64,
|
||||
#[serde(default)]
|
||||
pub auto_dual_screen: bool,
|
||||
#[serde(default)]
|
||||
pub sync_brightness: bool,
|
||||
#[serde(default)]
|
||||
pub theme: ThemePreference,
|
||||
#[serde(default = "default_usb_media_remap_enabled")]
|
||||
pub usb_media_remap_enabled: bool,
|
||||
#[serde(default = "default_start_on_boot_minimized")]
|
||||
pub start_on_boot_minimized: bool,
|
||||
#[serde(default = "default_invert_sensor_rotation")]
|
||||
pub invert_sensor_rotation: bool,
|
||||
#[serde(default)]
|
||||
pub setup_completed: bool,
|
||||
#[serde(default)]
|
||||
pub touchscreen_disabled: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub saved_display_layout: Option<DisplayLayout>,
|
||||
}
|
||||
|
||||
impl Default for DuoSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_backlight: default_backlight(),
|
||||
default_scale: default_scale(),
|
||||
auto_dual_screen: true,
|
||||
sync_brightness: true,
|
||||
theme: ThemePreference::System,
|
||||
usb_media_remap_enabled: default_usb_media_remap_enabled(),
|
||||
start_on_boot_minimized: default_start_on_boot_minimized(),
|
||||
invert_sensor_rotation: default_invert_sensor_rotation(),
|
||||
setup_completed: DEFAULT_SETUP_COMPLETED,
|
||||
touchscreen_disabled: Vec::new(),
|
||||
saved_display_layout: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ThemePreference {
|
||||
#[default]
|
||||
System,
|
||||
Light,
|
||||
Dark,
|
||||
}
|
||||
|
||||
fn default_backlight() -> u8 {
|
||||
DEFAULT_BACKLIGHT_LEVEL
|
||||
}
|
||||
|
||||
fn default_scale() -> f64 {
|
||||
DEFAULT_SCALE_FACTOR
|
||||
}
|
||||
|
||||
fn default_usb_media_remap_enabled() -> bool {
|
||||
DEFAULT_USB_MEDIA_REMAP_ENABLED
|
||||
}
|
||||
|
||||
fn default_start_on_boot_minimized() -> bool {
|
||||
DEFAULT_START_ON_BOOT_MINIMIZED
|
||||
}
|
||||
|
||||
fn default_invert_sensor_rotation() -> bool {
|
||||
DEFAULT_INVERT_SENSOR_ROTATION
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_match_installer_and_frontend_contract() {
|
||||
let settings = DuoSettings::default();
|
||||
|
||||
assert_eq!(settings.default_backlight, DEFAULT_BACKLIGHT_LEVEL);
|
||||
assert_eq!(settings.default_scale, DEFAULT_SCALE_FACTOR);
|
||||
assert_eq!(
|
||||
settings.usb_media_remap_enabled,
|
||||
DEFAULT_USB_MEDIA_REMAP_ENABLED
|
||||
);
|
||||
assert_eq!(
|
||||
settings.start_on_boot_minimized,
|
||||
DEFAULT_START_ON_BOOT_MINIMIZED
|
||||
);
|
||||
assert_eq!(
|
||||
settings.invert_sensor_rotation,
|
||||
DEFAULT_INVERT_SENSOR_ROTATION
|
||||
);
|
||||
assert_eq!(settings.setup_completed, DEFAULT_SETUP_COMPLETED);
|
||||
assert!(settings.auto_dual_screen);
|
||||
assert!(settings.sync_brightness);
|
||||
assert_eq!(settings.theme, ThemePreference::System);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DuoStatus {
|
||||
pub keyboard_attached: bool,
|
||||
pub connection_type: ConnectionType,
|
||||
pub monitor_count: u32,
|
||||
pub wifi_enabled: bool,
|
||||
pub bluetooth_enabled: bool,
|
||||
pub backlight_level: u8,
|
||||
pub display_brightness: u32,
|
||||
pub max_brightness: u32,
|
||||
pub service_active: bool,
|
||||
pub orientation: Orientation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ConnectionType {
|
||||
Usb,
|
||||
Bluetooth,
|
||||
#[default]
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Orientation {
|
||||
#[default]
|
||||
Normal,
|
||||
Left,
|
||||
Right,
|
||||
Inverted,
|
||||
}
|
||||
|
||||
impl Orientation {
|
||||
pub fn as_duo_arg(&self) -> &str {
|
||||
match self {
|
||||
Orientation::Normal => "normal",
|
||||
Orientation::Left => "left-up",
|
||||
Orientation::Right => "right-up",
|
||||
Orientation::Inverted => "bottom-up",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RefreshPolicy {
|
||||
#[default]
|
||||
Fixed,
|
||||
Dynamic,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DisplayMode {
|
||||
pub mode_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub backend_mode_id: Option<String>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub refresh_rate: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DisplayInfo {
|
||||
pub connector: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub refresh_rate: f64,
|
||||
pub scale: f64,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub transform: u32,
|
||||
pub primary: bool,
|
||||
pub current_mode: DisplayMode,
|
||||
pub available_modes: Vec<DisplayMode>,
|
||||
pub refresh_policy: RefreshPolicy,
|
||||
pub supports_dynamic_refresh: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DisplayLayout {
|
||||
pub displays: Vec<DisplayInfo>,
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DaemonVersionInfo {
|
||||
pub version: String,
|
||||
pub protocol_version: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VersionInfo {
|
||||
pub app_version: String,
|
||||
pub app_protocol_version: u32,
|
||||
pub daemon_version: Option<String>,
|
||||
pub daemon_protocol_version: Option<u32>,
|
||||
pub service_available: bool,
|
||||
}
|
||||
|
||||
impl VersionInfo {
|
||||
pub fn from_daemon(
|
||||
app_version: impl Into<String>,
|
||||
app_protocol_version: u32,
|
||||
daemon: DaemonVersionInfo,
|
||||
) -> Self {
|
||||
Self {
|
||||
app_version: app_version.into(),
|
||||
app_protocol_version,
|
||||
daemon_version: Some(daemon.version),
|
||||
daemon_protocol_version: Some(daemon.protocol_version),
|
||||
service_available: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn protocol_mismatch(
|
||||
app_version: impl Into<String>,
|
||||
app_protocol_version: u32,
|
||||
daemon_protocol_version: u32,
|
||||
) -> Self {
|
||||
Self {
|
||||
app_version: app_version.into(),
|
||||
app_protocol_version,
|
||||
daemon_version: None,
|
||||
daemon_protocol_version: Some(daemon_protocol_version),
|
||||
service_available: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unavailable(app_version: impl Into<String>, app_protocol_version: u32) -> Self {
|
||||
Self {
|
||||
app_version: app_version.into(),
|
||||
app_protocol_version,
|
||||
daemon_version: None,
|
||||
daemon_protocol_version: None,
|
||||
service_available: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::models::{ConnectionType, EventCategory, HardwareEvent};
|
||||
use crate::runtime::{logger, state::RuntimeState};
|
||||
|
||||
const HIDRAW_ROOT: &str = "/sys/class/hidraw";
|
||||
const REPORT_ID: u8 = 0x5a;
|
||||
const REPORT_RELEASE: u8 = 0x00;
|
||||
const REPORT_BACKLIGHT_CYCLE: u8 = 0xc7;
|
||||
const REPORT_BRIGHTNESS_DOWN: u8 = 0x10;
|
||||
const REPORT_BRIGHTNESS_UP: u8 = 0x20;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum BluetoothHotkeyAction {
|
||||
BacklightCycle,
|
||||
BrightnessDown,
|
||||
BrightnessUp,
|
||||
}
|
||||
|
||||
pub(crate) fn start(state: Arc<RwLock<RuntimeState>>) {
|
||||
match std::thread::Builder::new()
|
||||
.name("zenbook-duo-bt-hotkeys".into())
|
||||
.spawn(move || watch_loop(state))
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
log::warn!("failed to spawn Bluetooth hotkey watcher: {err}");
|
||||
let _ = logger::append_line(format!(
|
||||
"rust-daemon: failed to spawn Bluetooth hotkey watcher: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn watch_loop(state: Arc<RwLock<RuntimeState>>) {
|
||||
loop {
|
||||
let Some(path) = find_bluetooth_keyboard_hidraw() else {
|
||||
std::thread::sleep(Duration::from_secs(5));
|
||||
continue;
|
||||
};
|
||||
|
||||
match fs::OpenOptions::new().read(true).open(&path) {
|
||||
Ok(file) => {
|
||||
let _ = logger::append_line(format!(
|
||||
"rust-daemon: Bluetooth hotkey watcher opened {}",
|
||||
path.display()
|
||||
));
|
||||
if let Err(err) = watch_device(file, state.clone()) {
|
||||
log::warn!("Bluetooth hotkey watcher lost {}: {err}", path.display());
|
||||
let _ = logger::append_line(format!(
|
||||
"rust-daemon: Bluetooth hotkey watcher lost {}: {err}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"failed to open Bluetooth hotkey hidraw {}: {err}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
}
|
||||
}
|
||||
|
||||
fn watch_device(mut file: fs::File, state: Arc<RwLock<RuntimeState>>) -> Result<(), String> {
|
||||
let mut active_action: Option<BluetoothHotkeyAction> = None;
|
||||
let mut buffer = [0_u8; 64];
|
||||
|
||||
loop {
|
||||
match file.read(&mut buffer) {
|
||||
Ok(0) => return Err("hidraw read returned EOF".into()),
|
||||
Ok(count) => {
|
||||
let report = &buffer[..count];
|
||||
if is_release_report(report) {
|
||||
active_action = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(action) = parse_hotkey_report(report) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if active_action == Some(action) {
|
||||
continue;
|
||||
}
|
||||
active_action = Some(action);
|
||||
|
||||
if let Err(err) = handle_action(action, &state) {
|
||||
log::warn!("failed to handle Bluetooth hotkey {action:?}: {err}");
|
||||
let _ = logger::append_line(format!(
|
||||
"rust-daemon: failed to handle Bluetooth hotkey {action:?}: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(err) => return Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_bluetooth_keyboard_hidraw() -> Option<PathBuf> {
|
||||
find_bluetooth_keyboard_hidraw_from(Path::new(HIDRAW_ROOT))
|
||||
}
|
||||
|
||||
fn find_bluetooth_keyboard_hidraw_from(root: &Path) -> Option<PathBuf> {
|
||||
let mut devices = fs::read_dir(root)
|
||||
.ok()?
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
let contents = fs::read_to_string(entry.path().join("device/uevent")).ok()?;
|
||||
if !is_bluetooth_keyboard_uevent(&contents) {
|
||||
return None;
|
||||
}
|
||||
Some(Path::new("/dev").join(entry.file_name()))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
devices.sort();
|
||||
devices.into_iter().next()
|
||||
}
|
||||
|
||||
fn is_bluetooth_keyboard_uevent(contents: &str) -> bool {
|
||||
(contents.contains("Zenbook Duo Keyboard") || contents.contains("ASUS_DUO"))
|
||||
&& contents.contains("HID_ID=0005:")
|
||||
}
|
||||
|
||||
fn parse_hotkey_report(report: &[u8]) -> Option<BluetoothHotkeyAction> {
|
||||
if report.len() < 2 || report[0] != REPORT_ID {
|
||||
return None;
|
||||
}
|
||||
|
||||
match report[1] {
|
||||
REPORT_BACKLIGHT_CYCLE => Some(BluetoothHotkeyAction::BacklightCycle),
|
||||
REPORT_BRIGHTNESS_DOWN => Some(BluetoothHotkeyAction::BrightnessDown),
|
||||
REPORT_BRIGHTNESS_UP => Some(BluetoothHotkeyAction::BrightnessUp),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_release_report(report: &[u8]) -> bool {
|
||||
report.len() >= 2 && report[0] == REPORT_ID && report[1] == REPORT_RELEASE
|
||||
}
|
||||
|
||||
fn handle_action(
|
||||
action: BluetoothHotkeyAction,
|
||||
state: &Arc<RwLock<RuntimeState>>,
|
||||
) -> Result<(), String> {
|
||||
if !matches!(
|
||||
crate::hardware::sysfs::detect_connection_type(),
|
||||
ConnectionType::Bluetooth
|
||||
) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match action {
|
||||
BluetoothHotkeyAction::BacklightCycle => cycle_backlight(state),
|
||||
BluetoothHotkeyAction::BrightnessDown | BluetoothHotkeyAction::BrightnessUp => {
|
||||
step_brightness(action, state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cycle_backlight(state: &Arc<RwLock<RuntimeState>>) -> Result<(), String> {
|
||||
let current = state.blocking_read().status.backlight_level;
|
||||
let next = (current + 1) % 4;
|
||||
crate::hardware::hid::set_backlight(next)?;
|
||||
|
||||
let mut guard = state.blocking_write();
|
||||
guard.status.backlight_level = next;
|
||||
guard.push_recent_event(HardwareEvent::info(
|
||||
EventCategory::Keyboard,
|
||||
format!("Backlight set to {next}"),
|
||||
"rust-daemon",
|
||||
));
|
||||
guard.touch();
|
||||
guard.save()?;
|
||||
let _ = logger::append_line(format!(
|
||||
"rust-daemon: Bluetooth hotkey cycled backlight -> {next}"
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn step_brightness(
|
||||
action: BluetoothHotkeyAction,
|
||||
state: &Arc<RwLock<RuntimeState>>,
|
||||
) -> Result<(), String> {
|
||||
let primary = crate::hardware::sysfs::primary_backlight_dir()
|
||||
.ok_or_else(|| "no primary backlight device found".to_string())?;
|
||||
let max = read_brightness_value(&primary.join("max_brightness"))?;
|
||||
let current = read_brightness_value(&primary.join("brightness"))?;
|
||||
let next = next_brightness_value(current, max, action);
|
||||
|
||||
fs::write(primary.join("brightness"), next.to_string())
|
||||
.map_err(|e| format!("Failed to write primary brightness: {e}"))?;
|
||||
|
||||
let sync_secondary = state.blocking_read().settings.sync_brightness;
|
||||
if sync_secondary {
|
||||
if let Some(secondary) = crate::hardware::sysfs::secondary_backlight_dir() {
|
||||
let secondary_max = read_brightness_value(&secondary.join("max_brightness"))?;
|
||||
let mirrored = next.min(secondary_max);
|
||||
fs::write(secondary.join("brightness"), mirrored.to_string())
|
||||
.map_err(|e| format!("Failed to write secondary brightness: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut guard = state.blocking_write();
|
||||
guard.status.display_brightness = next;
|
||||
guard.push_recent_event(HardwareEvent::info(
|
||||
EventCategory::Display,
|
||||
format!("Display brightness set to {next}"),
|
||||
"rust-daemon",
|
||||
));
|
||||
guard.touch();
|
||||
guard.save()?;
|
||||
let _ = logger::append_line(format!(
|
||||
"rust-daemon: Bluetooth hotkey adjusted brightness -> {next}"
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_brightness_value(path: &Path) -> Result<u32, String> {
|
||||
fs::read_to_string(path)
|
||||
.map_err(|e| format!("Failed to read {}: {e}", path.display()))?
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.map_err(|e| format!("Invalid brightness value in {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
fn next_brightness_value(current: u32, max: u32, action: BluetoothHotkeyAction) -> u32 {
|
||||
let step = (max / 20).max(1);
|
||||
match action {
|
||||
BluetoothHotkeyAction::BrightnessUp => current.saturating_add(step).min(max),
|
||||
BluetoothHotkeyAction::BrightnessDown => current.saturating_sub(step),
|
||||
BluetoothHotkeyAction::BacklightCycle => current,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_captured_bluetooth_hotkey_reports() {
|
||||
assert_eq!(
|
||||
parse_hotkey_report(&[0x5a, 0xc7, 0x00, 0x00, 0x00, 0x00]),
|
||||
Some(BluetoothHotkeyAction::BacklightCycle)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_hotkey_report(&[0x5a, 0x10, 0x00, 0x00, 0x00, 0x00]),
|
||||
Some(BluetoothHotkeyAction::BrightnessDown)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_hotkey_report(&[0x5a, 0x20, 0x00, 0x00, 0x00, 0x00]),
|
||||
Some(BluetoothHotkeyAction::BrightnessUp)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_release_and_unrelated_reports() {
|
||||
assert_eq!(
|
||||
parse_hotkey_report(&[0x5a, 0x00, 0x00, 0x00, 0x00, 0x00]),
|
||||
None
|
||||
);
|
||||
assert_eq!(parse_hotkey_report(&[0x59, 0xc7, 0x00]), None);
|
||||
assert_eq!(parse_hotkey_report(&[0x5a]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifies_bluetooth_keyboard_hidraw_uevents() {
|
||||
assert!(is_bluetooth_keyboard_uevent(
|
||||
"HID_ID=0005:00000B05:00001B2C\nHID_NAME=ASUS Zenbook Duo Keyboard\n"
|
||||
));
|
||||
assert!(!is_bluetooth_keyboard_uevent(
|
||||
"HID_ID=0003:00000B05:00001B2C\nHID_NAME=ASUS Zenbook Duo Keyboard\n"
|
||||
));
|
||||
assert!(!is_bluetooth_keyboard_uevent(
|
||||
"HID_ID=0005:00001234:00005678\nHID_NAME=Other Keyboard\n"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brightness_steps_use_five_percent_chunks() {
|
||||
assert_eq!(
|
||||
next_brightness_value(200, 400, BluetoothHotkeyAction::BrightnessUp),
|
||||
220
|
||||
);
|
||||
assert_eq!(
|
||||
next_brightness_value(200, 400, BluetoothHotkeyAction::BrightnessDown),
|
||||
180
|
||||
);
|
||||
assert_eq!(
|
||||
next_brightness_value(395, 400, BluetoothHotkeyAction::BrightnessUp),
|
||||
400
|
||||
);
|
||||
assert_eq!(
|
||||
next_brightness_value(5, 400, BluetoothHotkeyAction::BrightnessDown),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::ipc::protocol::{DaemonRequest, DaemonResponse, Envelope};
|
||||
use crate::runtime::paths;
|
||||
|
||||
pub fn request(request: DaemonRequest) -> Result<DaemonResponse, String> {
|
||||
let mut stream = UnixStream::connect(paths::daemon_socket_path())
|
||||
.map_err(|e| format!("Failed to connect to daemon socket: {e}"))?;
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.map_err(|e| format!("Failed to set daemon read timeout: {e}"))?;
|
||||
stream
|
||||
.set_write_timeout(Some(Duration::from_secs(3)))
|
||||
.map_err(|e| format!("Failed to set daemon write timeout: {e}"))?;
|
||||
|
||||
let line = serde_json::to_string(&Envelope::new(request))
|
||||
.map_err(|e| format!("Failed to encode daemon request: {e}"))?;
|
||||
stream
|
||||
.write_all(line.as_bytes())
|
||||
.map_err(|e| format!("Failed to write daemon request: {e}"))?;
|
||||
stream
|
||||
.write_all(b"\n")
|
||||
.map_err(|e| format!("Failed to terminate daemon request: {e}"))?;
|
||||
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut reply = String::new();
|
||||
reader
|
||||
.read_line(&mut reply)
|
||||
.map_err(|e| format!("Failed to read daemon response: {e}"))?;
|
||||
if reply.trim().is_empty() {
|
||||
return Err("Daemon returned an empty response".into());
|
||||
}
|
||||
|
||||
let envelope: Envelope<DaemonResponse> = serde_json::from_str(reply.trim_end())
|
||||
.map_err(|e| format!("Invalid daemon response JSON: {e}"))?;
|
||||
Ok(envelope.payload)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
use std::process::{Command, Output};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::runtime::session;
|
||||
|
||||
pub fn command_output<S: AsRef<str>>(program: &str, args: &[S]) -> Result<Output, String> {
|
||||
Command::new(program)
|
||||
.args(args.iter().map(|arg| arg.as_ref()))
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run {program}: {e}"))
|
||||
}
|
||||
|
||||
pub fn command_succeeds<S: AsRef<str>>(program: &str, args: &[S]) -> bool {
|
||||
command_output(program, args)
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn run_command<S: AsRef<str>>(program: &str, args: &[S]) -> Result<(), String> {
|
||||
let output = command_output(program, args)?;
|
||||
ensure_success(output)
|
||||
}
|
||||
|
||||
pub fn niri_command_output(args: &[&str]) -> Result<Output, String> {
|
||||
session::build_niri_command(args)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run niri msg: {e}"))
|
||||
}
|
||||
|
||||
pub fn niri_command_succeeds(args: &[&str]) -> bool {
|
||||
niri_command_output(args)
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn run_niri_command(args: &[&str]) -> Result<(), String> {
|
||||
let output = niri_command_output(args)?;
|
||||
ensure_success(output)
|
||||
}
|
||||
|
||||
pub fn kscreen_json() -> Result<Value, String> {
|
||||
let output = command_output("kscreen-doctor", &["-j"])?;
|
||||
if !output.status.success() {
|
||||
return Err(stderr_trimmed(&output));
|
||||
}
|
||||
serde_json::from_slice(&output.stdout).map_err(|e| format!("Invalid kscreen JSON: {e}"))
|
||||
}
|
||||
|
||||
pub fn niri_outputs_json() -> Result<Value, String> {
|
||||
let output = niri_command_output(&["msg", "--json", "outputs"])?;
|
||||
if !output.status.success() {
|
||||
return Err(stderr_trimmed(&output));
|
||||
}
|
||||
serde_json::from_slice(&output.stdout).map_err(|e| format!("Invalid niri JSON: {e}"))
|
||||
}
|
||||
|
||||
pub fn kde_outputs_from_value(value: &Value) -> Result<Vec<Value>, String> {
|
||||
value
|
||||
.get("outputs")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.ok_or_else(|| "Missing KDE outputs array".to_string())
|
||||
}
|
||||
|
||||
pub fn kde_output_names_from_value(value: &Value) -> Result<Vec<String>, String> {
|
||||
Ok(kde_outputs_from_value(value)?
|
||||
.iter()
|
||||
.filter_map(|output| output.get("name").and_then(|v| v.as_str()))
|
||||
.map(ToString::to_string)
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn niri_outputs_from_value(value: &Value) -> Result<Vec<Value>, String> {
|
||||
if let Some(arr) = value.as_array() {
|
||||
Ok(arr.clone())
|
||||
} else if let Some(obj) = value.as_object() {
|
||||
Ok(obj.values().cloned().collect())
|
||||
} else {
|
||||
Err("Unexpected niri outputs shape".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn niri_output_names_from_value(value: &Value) -> Result<Vec<String>, String> {
|
||||
Ok(niri_outputs_from_value(value)?
|
||||
.iter()
|
||||
.filter_map(|output| output.get("name").and_then(|v| v.as_str()))
|
||||
.map(ToString::to_string)
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn kde_output_logical_size_from_value(value: &Value, name: &str) -> Result<(i64, i64), String> {
|
||||
for output in kde_outputs_from_value(value)? {
|
||||
if output.get("name").and_then(|v| v.as_str()) == Some(name) {
|
||||
let size = output
|
||||
.get("size")
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or_else(|| "Missing KDE output size".to_string())?;
|
||||
let scale = output.get("scale").and_then(|v| v.as_f64()).unwrap_or(1.0);
|
||||
let width = size.get("width").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let height = size.get("height").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
return Ok((
|
||||
(width as f64 / scale).round() as i64,
|
||||
(height as f64 / scale).round() as i64,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("KDE output {name} not found"))
|
||||
}
|
||||
|
||||
pub fn kde_enabled_output_count_from_value(value: &Value) -> Result<usize, String> {
|
||||
Ok(kde_outputs_from_value(value)?
|
||||
.iter()
|
||||
.filter(|output| {
|
||||
output
|
||||
.get("enabled")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.count())
|
||||
}
|
||||
|
||||
pub fn niri_output_logical_size_from_value(
|
||||
value: &Value,
|
||||
name: &str,
|
||||
) -> Result<(i64, i64), String> {
|
||||
for output in niri_outputs_from_value(value)? {
|
||||
if output.get("name").and_then(|v| v.as_str()) == Some(name) {
|
||||
let logical = output
|
||||
.get("logical")
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or_else(|| "Missing niri logical size".to_string())?;
|
||||
let width = logical.get("width").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let height = logical.get("height").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
return Ok((width, height));
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("Niri output {name} not found"))
|
||||
}
|
||||
|
||||
pub fn niri_enabled_output_count_from_value(value: &Value) -> Result<usize, String> {
|
||||
Ok(niri_outputs_from_value(value)?
|
||||
.iter()
|
||||
.filter(|output| {
|
||||
!output
|
||||
.get("current_mode")
|
||||
.is_some_and(|value| value.is_null())
|
||||
})
|
||||
.count())
|
||||
}
|
||||
|
||||
fn ensure_success(output: Output) -> Result<(), String> {
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(stderr_trimmed(&output))
|
||||
}
|
||||
}
|
||||
|
||||
fn stderr_trimmed(output: &Output) -> String {
|
||||
String::from_utf8_lossy(&output.stderr).trim().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_kde_output_names_and_logical_size() {
|
||||
let value = serde_json::json!({
|
||||
"outputs": [
|
||||
{ "name": "eDP-1", "enabled": true, "size": { "width": 2880, "height": 1800 }, "scale": 1.5 },
|
||||
{ "name": "eDP-2", "enabled": false, "size": { "width": 2880, "height": 1800 }, "scale": 2.0 }
|
||||
]
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
kde_output_names_from_value(&value).expect("names should parse"),
|
||||
vec!["eDP-1".to_string(), "eDP-2".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
kde_output_logical_size_from_value(&value, "eDP-1"),
|
||||
Ok((1920, 1200))
|
||||
);
|
||||
assert_eq!(kde_enabled_output_count_from_value(&value), Ok(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_niri_array_and_object_output_shapes() {
|
||||
let array_value = serde_json::json!([
|
||||
{ "name": "eDP-1", "current_mode": { "width": 2880 }, "logical": { "width": 1440, "height": 900 } },
|
||||
{ "name": "eDP-2", "current_mode": null, "logical": { "width": 1440, "height": 900 } }
|
||||
]);
|
||||
let object_value = serde_json::json!({
|
||||
"eDP-1": { "name": "eDP-1", "current_mode": { "width": 2880 }, "logical": { "width": 1440, "height": 900 } },
|
||||
"eDP-2": { "name": "eDP-2", "current_mode": null, "logical": { "width": 1440, "height": 900 } }
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
niri_output_names_from_value(&object_value).expect("names should parse"),
|
||||
vec!["eDP-1".to_string(), "eDP-2".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
niri_output_logical_size_from_value(&array_value, "eDP-1"),
|
||||
Ok((1440, 900))
|
||||
);
|
||||
assert_eq!(niri_enabled_output_count_from_value(&array_value), Ok(1));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) struct NotificationSink;
|
||||
|
||||
impl NotificationSink {
|
||||
pub(crate) async fn runtime_error(
|
||||
state: &Arc<RwLock<RuntimeState>>,
|
||||
title: &str,
|
||||
message: &str,
|
||||
) {
|
||||
notify_runtime_error(state, title, message).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn notify_runtime_error(
|
||||
state: &Arc<RwLock<RuntimeState>>,
|
||||
title: &str,
|
||||
message: &str,
|
||||
) {
|
||||
if !should_notify_runtime_error(title, message) {
|
||||
return;
|
||||
}
|
||||
|
||||
if !should_emit_runtime_notification(state, title, message).await {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(err) = send_runtime_notification_via_session(state, title, message).await {
|
||||
log::warn!("failed to send runtime notification via session agent: {err}");
|
||||
if let Err(fallback_err) = send_runtime_notification_direct(title, message) {
|
||||
log::warn!("failed to send runtime notification directly: {fallback_err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn should_notify_runtime_error(title: &str, message: &str) -> bool {
|
||||
if title != "Zenbook Duo Runtime Error" {
|
||||
return true;
|
||||
}
|
||||
|
||||
!runtime_error_is_display_session_deferral(message)
|
||||
}
|
||||
|
||||
fn runtime_error_is_display_session_deferral(message: &str) -> bool {
|
||||
[
|
||||
"Lifecycle dock refresh skipped: ",
|
||||
"Display-mode policy action failed: ",
|
||||
"Session registration dock replay skipped: ",
|
||||
"Lid state display update failed: ",
|
||||
]
|
||||
.iter()
|
||||
.any(|prefix| {
|
||||
message
|
||||
.strip_prefix(prefix)
|
||||
.is_some_and(is_display_session_deferral)
|
||||
})
|
||||
}
|
||||
|
||||
async fn should_emit_runtime_notification(
|
||||
state: &Arc<RwLock<RuntimeState>>,
|
||||
title: &str,
|
||||
message: &str,
|
||||
) -> bool {
|
||||
let key = format!("{title}\n{message}");
|
||||
let now = Utc::now();
|
||||
let mut guard = state.write().await;
|
||||
if let Some(last) = &guard.last_runtime_notification {
|
||||
if last.key == key && now - last.emitted_at < ChronoDuration::seconds(30) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
guard.last_runtime_notification = Some(crate::runtime::state::RuntimeNotificationState {
|
||||
key,
|
||||
emitted_at: now,
|
||||
});
|
||||
persist_state(&guard);
|
||||
true
|
||||
}
|
||||
|
||||
async fn send_runtime_notification_via_session(
|
||||
state: &Arc<RwLock<RuntimeState>>,
|
||||
title: &str,
|
||||
message: &str,
|
||||
) -> Result<(), String> {
|
||||
match session_bridge::request_session(
|
||||
state.clone(),
|
||||
SessionCommand::ShowNotification {
|
||||
title: title.to_string(),
|
||||
message: message.to_string(),
|
||||
urgent: true,
|
||||
},
|
||||
false,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
SessionResponse::Ack => Ok(()),
|
||||
SessionResponse::Error { message } => Err(message),
|
||||
SessionResponse::DisplayLayout { .. } => {
|
||||
Err("Unexpected display-layout response for notification request".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn send_runtime_notification_direct(title: &str, message: &str) -> Result<(), String> {
|
||||
let target_user = std::env::var("ZENBOOK_DUO_USER")
|
||||
.map_err(|_| "ZENBOOK_DUO_USER is not set for runtime notifications".to_string())?;
|
||||
let target_uid = std::env::var("ZENBOOK_DUO_UID")
|
||||
.map_err(|_| "ZENBOOK_DUO_UID is not set for runtime notifications".to_string())?;
|
||||
let runtime_dir = format!("/run/user/{target_uid}");
|
||||
let bus_address = format!("unix:path={runtime_dir}/bus");
|
||||
|
||||
let status = Command::new("sudo")
|
||||
.args(["-u", &target_user, "env"])
|
||||
.arg(format!("XDG_RUNTIME_DIR={runtime_dir}"))
|
||||
.arg(format!("DBUS_SESSION_BUS_ADDRESS={bus_address}"))
|
||||
.args([
|
||||
"notify-send",
|
||||
"-a",
|
||||
"Zenbook Duo Control",
|
||||
"-u",
|
||||
"critical",
|
||||
"-i",
|
||||
"dialog-error",
|
||||
title,
|
||||
message,
|
||||
])
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to launch runtime notification: {e}"))?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Runtime notification exited with {status}"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) struct SessionBridge;
|
||||
|
||||
impl SessionBridge {
|
||||
pub(crate) async fn request(
|
||||
state: Arc<RwLock<RuntimeState>>,
|
||||
command: SessionCommand,
|
||||
disconnect_on_failure: bool,
|
||||
) -> Result<SessionResponse, String> {
|
||||
request_session(state, command, disconnect_on_failure).await
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn request_session(
|
||||
state: Arc<RwLock<RuntimeState>>,
|
||||
command: SessionCommand,
|
||||
disconnect_on_failure: bool,
|
||||
) -> Result<SessionResponse, String> {
|
||||
let socket_path = {
|
||||
let guard = state.read().await;
|
||||
guard
|
||||
.session_agent
|
||||
.socket_path
|
||||
.clone()
|
||||
.ok_or_else(|| "No session agent registered".to_string())?
|
||||
};
|
||||
|
||||
let stream = match timeout(Duration::from_secs(3), UnixStream::connect(&socket_path)).await {
|
||||
Ok(Ok(stream)) => stream,
|
||||
Ok(Err(e)) => {
|
||||
if disconnect_on_failure {
|
||||
mark_session_agent_disconnected(
|
||||
&state,
|
||||
&format!("Failed to connect to session agent: {e}"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return Err(format!("Failed to connect to session agent: {e}"));
|
||||
}
|
||||
Err(_) => {
|
||||
if disconnect_on_failure {
|
||||
mark_session_agent_disconnected(&state, "Timed out connecting to session agent")
|
||||
.await;
|
||||
}
|
||||
return Err("Timed out connecting to session agent".to_string());
|
||||
}
|
||||
};
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
|
||||
let line = serde_json::to_string(&Envelope::new(command))
|
||||
.map_err(|e| format!("Failed to encode session command: {e}"))?;
|
||||
match timeout(Duration::from_secs(3), writer.write_all(line.as_bytes())).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => {
|
||||
if disconnect_on_failure {
|
||||
mark_session_agent_disconnected(
|
||||
&state,
|
||||
&format!("Failed to write session command: {e}"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return Err(format!("Failed to write session command: {e}"));
|
||||
}
|
||||
Err(_) => {
|
||||
return handle_session_write_timeout(
|
||||
&state,
|
||||
"Timed out writing session command",
|
||||
disconnect_on_failure,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
match timeout(Duration::from_secs(3), writer.write_all(b"\n")).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => {
|
||||
if disconnect_on_failure {
|
||||
mark_session_agent_disconnected(
|
||||
&state,
|
||||
&format!("Failed to terminate session command: {e}"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return Err(format!("Failed to terminate session command: {e}"));
|
||||
}
|
||||
Err(_) => {
|
||||
return handle_session_write_timeout(
|
||||
&state,
|
||||
"Timed out terminating session command",
|
||||
disconnect_on_failure,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
let mut lines = BufReader::new(reader).lines();
|
||||
let reply = match timeout(Duration::from_secs(3), lines.next_line()).await {
|
||||
Err(_) => {
|
||||
if disconnect_on_failure {
|
||||
mark_session_agent_disconnected(&state, "Timed out waiting for session response")
|
||||
.await;
|
||||
}
|
||||
return Err("Timed out waiting for session response".to_string());
|
||||
}
|
||||
Ok(Ok(Some(reply))) => reply,
|
||||
Ok(Ok(None)) => {
|
||||
if disconnect_on_failure {
|
||||
mark_session_agent_disconnected(&state, "Session agent closed before replying")
|
||||
.await;
|
||||
}
|
||||
return Err("Session agent closed before replying".to_string());
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
if disconnect_on_failure {
|
||||
mark_session_agent_disconnected(
|
||||
&state,
|
||||
&format!("Failed to read session response: {e}"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return Err(format!("Failed to read session response: {e}"));
|
||||
}
|
||||
};
|
||||
|
||||
let envelope: Envelope<SessionResponse> =
|
||||
serde_json::from_str(&reply).map_err(|e| format!("Invalid session response JSON: {e}"))?;
|
||||
|
||||
Ok(envelope.payload)
|
||||
}
|
||||
|
||||
pub(super) async fn handle_session_write_timeout<T>(
|
||||
state: &Arc<RwLock<RuntimeState>>,
|
||||
reason: &str,
|
||||
disconnect_on_failure: bool,
|
||||
) -> Result<T, String> {
|
||||
if disconnect_on_failure {
|
||||
mark_session_agent_disconnected(state, reason).await;
|
||||
}
|
||||
Err(reason.to_string())
|
||||
}
|
||||
|
||||
async fn mark_session_agent_disconnected(state: &Arc<RwLock<RuntimeState>>, reason: &str) {
|
||||
let mut guard = state.write().await;
|
||||
let was_connected = guard.session_agent.connected || guard.status.service_active;
|
||||
guard.session_agent = Default::default();
|
||||
guard.status.service_active = false;
|
||||
if was_connected {
|
||||
guard.push_recent_event(HardwareEvent::warning(
|
||||
EventCategory::Service,
|
||||
format!("Session agent disconnected: {reason}"),
|
||||
"rust-daemon",
|
||||
));
|
||||
}
|
||||
guard.touch();
|
||||
persist_state(&guard);
|
||||
let _ = logger::append_line(format!(
|
||||
"rust-daemon: session agent disconnected: {}",
|
||||
reason
|
||||
));
|
||||
drop(guard);
|
||||
|
||||
if was_connected {
|
||||
if let Err(err) =
|
||||
ServiceController::queue_target_user_unit_restart("zenbook-duo-session-agent.service")
|
||||
{
|
||||
log::warn!("failed to queue session agent restart: {err}");
|
||||
let _ = logger::append_line(format!(
|
||||
"rust-daemon: failed to queue session agent restart: {}",
|
||||
err
|
||||
));
|
||||
} else {
|
||||
let _ = logger::append_line("rust-daemon: queued session agent restart");
|
||||
}
|
||||
}
|
||||
|
||||
if should_notify_session_agent_disconnect(reason) {
|
||||
if let Err(err) = notification_sink::send_runtime_notification_direct(
|
||||
"Zenbook Duo Runtime Error",
|
||||
&format!("Session agent disconnected: {reason}"),
|
||||
) {
|
||||
log::warn!("failed to send direct disconnect notification: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn should_notify_session_agent_disconnect(reason: &str) -> bool {
|
||||
!is_display_session_deferral(reason)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
use std::ffi::OsStr;
|
||||
use std::process::{Command, Output};
|
||||
|
||||
/// Narrow Interface for host process execution.
|
||||
///
|
||||
/// Production code uses [`ProcessCommandRunner`]; tests use [`FakeCommandRunner`]
|
||||
/// so Modules can verify behavior without invoking Linux desktop/system services.
|
||||
pub trait CommandRunner {
|
||||
fn output(&self, program: &str, args: &[&str]) -> Result<Output, String>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct ProcessCommandRunner;
|
||||
|
||||
impl CommandRunner for ProcessCommandRunner {
|
||||
fn output(&self, program: &str, args: &[&str]) -> Result<Output, String> {
|
||||
Command::new(program)
|
||||
.args(args.iter().map(OsStr::new))
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run {program}: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod tests {
|
||||
use super::*;
|
||||
use std::collections::VecDeque;
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
use std::process::{ExitStatus, Output};
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FakeCommandRunner {
|
||||
calls: Mutex<Vec<(String, Vec<String>)>>,
|
||||
outputs: Mutex<VecDeque<Result<Output, String>>>,
|
||||
}
|
||||
|
||||
impl FakeCommandRunner {
|
||||
pub fn new(outputs: impl IntoIterator<Item = Result<Output, String>>) -> Self {
|
||||
Self {
|
||||
calls: Mutex::new(Vec::new()),
|
||||
outputs: Mutex::new(outputs.into_iter().collect()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn success(stdout: &str) -> Output {
|
||||
Output {
|
||||
status: ExitStatus::from_raw(0),
|
||||
stdout: stdout.as_bytes().to_vec(),
|
||||
stderr: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn failure(stderr: &str) -> Output {
|
||||
Output {
|
||||
status: ExitStatus::from_raw(1 << 8),
|
||||
stdout: Vec::new(),
|
||||
stderr: stderr.as_bytes().to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn calls(&self) -> Vec<(String, Vec<String>)> {
|
||||
self.calls.lock().expect("calls lock").clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl CommandRunner for FakeCommandRunner {
|
||||
fn output(&self, program: &str, args: &[&str]) -> Result<Output, String> {
|
||||
self.calls.lock().expect("calls lock").push((
|
||||
program.to_string(),
|
||||
args.iter().map(|arg| arg.to_string()).collect(),
|
||||
));
|
||||
self.outputs
|
||||
.lock()
|
||||
.expect("outputs lock")
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| Err(format!("No fake output for {program}")))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
|
||||
use crate::runtime::paths;
|
||||
|
||||
fn ensure_log_parent() -> Result<(), String> {
|
||||
let path = paths::log_file_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("Failed to create log dir: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn append_line(message: impl AsRef<str>) -> Result<(), String> {
|
||||
ensure_log_parent()?;
|
||||
let path = paths::log_file_path();
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.map_err(|e| format!("Failed to open {}: {e}", path.display()))?;
|
||||
|
||||
writeln!(file, "{}", message.as_ref())
|
||||
.map_err(|e| format!("Failed to write {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
pub fn clear() -> Result<(), String> {
|
||||
ensure_log_parent()?;
|
||||
let path = paths::log_file_path();
|
||||
fs::write(&path, "").map_err(|e| format!("Failed to clear {}: {e}", path.display()))
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::models::{EventCategory, HardwareEvent};
|
||||
use crate::runtime::{logger, state::RuntimeState};
|
||||
|
||||
pub fn start(state: Arc<RwLock<RuntimeState>>) {
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = watch_logind(state.clone()).await {
|
||||
log::warn!("logind runtime watcher failed: {err}");
|
||||
crate::runtime::daemon::notify_runtime_error(
|
||||
&state,
|
||||
"Zenbook Duo Runtime Error",
|
||||
&format!("Logind watcher failed: {err}"),
|
||||
)
|
||||
.await;
|
||||
let _ = logger::append_line(format!("rust-daemon: logind watcher failed: {err}"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn watch_logind(state: Arc<RwLock<RuntimeState>>) -> Result<(), zbus::Error> {
|
||||
let connection = zbus::Connection::system().await?;
|
||||
|
||||
let manager = zbus::fdo::PropertiesProxy::builder(&connection)
|
||||
.destination("org.freedesktop.login1")?
|
||||
.path("/org/freedesktop/login1")?
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
let login_manager_interface = "org.freedesktop.login1.Manager"
|
||||
.try_into()
|
||||
.expect("login1 manager interface name should be valid");
|
||||
if let Ok(value) = manager.get(login_manager_interface, "LidClosed").await {
|
||||
if let Ok(lid_closed) = value.downcast_ref::<bool>() {
|
||||
sync_initial_lid_closed_state(&state, lid_closed).await;
|
||||
}
|
||||
}
|
||||
|
||||
let mut stream = manager.receive_properties_changed().await?;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
while let Some(signal) = stream.next().await {
|
||||
let args = signal.args().ok();
|
||||
if let Some(args) = args {
|
||||
if args.changed_properties().contains_key("LockedHint") {
|
||||
let locked = args
|
||||
.changed_properties()
|
||||
.get("LockedHint")
|
||||
.and_then(|value| value.downcast_ref::<bool>().ok());
|
||||
let message = match locked {
|
||||
Some(true) => "Screen locked",
|
||||
Some(false) => "Screen unlocked",
|
||||
None => "Screen lock state changed",
|
||||
};
|
||||
|
||||
let mut guard = state.write().await;
|
||||
guard.push_recent_event(HardwareEvent::info(
|
||||
EventCategory::Display,
|
||||
message,
|
||||
"rust-daemon",
|
||||
));
|
||||
guard.touch();
|
||||
if let Err(err) = guard.save() {
|
||||
log::warn!("failed to save logind runtime state: {err}");
|
||||
let _ = logger::append_line(format!(
|
||||
"rust-daemon: failed to persist logind state: {err}"
|
||||
));
|
||||
}
|
||||
let _ = logger::append_line(format!("rust-daemon: {}", message));
|
||||
}
|
||||
|
||||
if let Some(lid_closed) = args
|
||||
.changed_properties()
|
||||
.get("LidClosed")
|
||||
.and_then(|value| value.downcast_ref::<bool>().ok())
|
||||
{
|
||||
if let Err(err) =
|
||||
crate::runtime::daemon::DisplayReplayPolicy::handle_lid_closed_change(
|
||||
&state,
|
||||
lid_closed,
|
||||
)
|
||||
.await
|
||||
{
|
||||
if crate::runtime::daemon::is_display_session_deferral(&err) {
|
||||
let _ = logger::append_line(format!(
|
||||
"rust-daemon: lid state display update deferred: {err}"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
log::warn!("failed to handle lid state change: {err}");
|
||||
crate::runtime::daemon::notify_runtime_error(
|
||||
&state,
|
||||
"Zenbook Duo Runtime Error",
|
||||
&format!("Lid state display update failed: {err}"),
|
||||
)
|
||||
.await;
|
||||
let _ = logger::append_line(format!(
|
||||
"rust-daemon: lid state display update failed: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sync_initial_lid_closed_state(state: &Arc<RwLock<RuntimeState>>, lid_closed: bool) {
|
||||
if let Err(err) =
|
||||
crate::runtime::daemon::DisplayReplayPolicy::handle_lid_closed_change(state, lid_closed)
|
||||
.await
|
||||
{
|
||||
if crate::runtime::daemon::is_display_session_deferral(&err) {
|
||||
let _ = logger::append_line(format!(
|
||||
"rust-daemon: initial lid state display update deferred: {err}"
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
log::warn!("failed to sync initial lid state: {err}");
|
||||
crate::runtime::daemon::notify_runtime_error(
|
||||
state,
|
||||
"Zenbook Duo Runtime Error",
|
||||
&format!("Initial lid state display update failed: {err}"),
|
||||
)
|
||||
.await;
|
||||
let _ = logger::append_line(format!(
|
||||
"rust-daemon: initial lid state display update failed: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn initial_lid_closed_true_records_runtime_state_even_when_display_defers() {
|
||||
let state = Arc::new(RwLock::new(RuntimeState::default()));
|
||||
|
||||
sync_initial_lid_closed_state(&state, true).await;
|
||||
|
||||
assert!(state.read().await.lid_closed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
pub(crate) mod bluetooth_hotkeys;
|
||||
pub mod client;
|
||||
pub mod compositor;
|
||||
pub mod daemon;
|
||||
pub mod host;
|
||||
pub mod logger;
|
||||
pub mod logind;
|
||||
pub mod monitor;
|
||||
pub mod paths;
|
||||
pub mod policy;
|
||||
pub mod probe;
|
||||
pub mod router;
|
||||
pub mod runtime_dir;
|
||||
pub mod service_control;
|
||||
pub mod session;
|
||||
pub mod session_agent;
|
||||
pub(crate) mod session_display_planner;
|
||||
pub(crate) mod session_ipc;
|
||||
pub(crate) mod session_watchers;
|
||||
pub mod state;
|
||||
pub mod version;
|
||||
@@ -0,0 +1,439 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::models::{ConnectionType, EventCategory, HardwareEvent};
|
||||
use crate::runtime::logger;
|
||||
use crate::runtime::policy::PolicyAction;
|
||||
use crate::runtime::state::RuntimeState;
|
||||
|
||||
pub fn start(state: Arc<RwLock<RuntimeState>>) {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(1));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
let mut next_status = crate::runtime::probe::current_status();
|
||||
let session_connected = {
|
||||
let guard = state.read().await;
|
||||
guard.session_agent.connected
|
||||
};
|
||||
next_status.service_active = session_connected;
|
||||
|
||||
if let Some(layout) =
|
||||
crate::runtime::daemon::session_display_layout_for_liveness(state.clone()).await
|
||||
{
|
||||
crate::runtime::probe::apply_layout_to_status(&mut next_status, Some(&layout));
|
||||
next_status.service_active = true;
|
||||
}
|
||||
|
||||
let mut guard = state.write().await;
|
||||
let previous = guard.status.clone();
|
||||
|
||||
if previous != next_status {
|
||||
let updated = next_status.clone();
|
||||
let _ = logger::append_line(format!(
|
||||
"rust-daemon: status transition attached={} monitors={} wifi={} bluetooth={} connection={}",
|
||||
updated.keyboard_attached,
|
||||
updated.monitor_count,
|
||||
updated.wifi_enabled,
|
||||
updated.bluetooth_enabled,
|
||||
connection_label(&updated.connection_type),
|
||||
));
|
||||
guard.status = next_status;
|
||||
let actions =
|
||||
crate::runtime::policy::apply_transition_policy(&mut guard, &previous);
|
||||
push_status_events(&mut guard, &previous, &updated);
|
||||
guard.touch();
|
||||
if let Err(err) = guard.save() {
|
||||
log::warn!("failed to save monitored runtime state: {err}");
|
||||
}
|
||||
drop(guard);
|
||||
apply_policy_actions(state.clone(), actions).await;
|
||||
} else {
|
||||
drop(guard);
|
||||
}
|
||||
|
||||
reconcile_usb_media_remap(state.clone()).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn queue_usb_media_remap_resume_retry(state: Arc<RwLock<RuntimeState>>) {
|
||||
tokio::spawn(async move {
|
||||
reset_usb_media_remap_retry_cooldown(&state).await;
|
||||
for attempt in 0..10 {
|
||||
if attempt > 0 {
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
reconcile_usb_media_remap(state.clone()).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) async fn reset_usb_media_remap_retry_cooldown(state: &Arc<RwLock<RuntimeState>>) {
|
||||
let mut guard = state.write().await;
|
||||
clear_usb_media_remap_retry_cooldown(&mut guard);
|
||||
guard.touch();
|
||||
if let Err(err) = guard.save() {
|
||||
log::warn!("failed to save usb media remap retry reset: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn reconcile_usb_media_remap(state: Arc<RwLock<RuntimeState>>) {
|
||||
const AUTO_START_RETRY_COOLDOWN_SECS: i64 = 15;
|
||||
|
||||
let (should_run, is_running) = {
|
||||
let guard = state.read().await;
|
||||
let should_run = usb_media_remap_should_run(&guard);
|
||||
let is_running = crate::commands::usb_media_remap::get_status().running;
|
||||
(should_run, is_running)
|
||||
};
|
||||
|
||||
if should_run == is_running {
|
||||
let mut guard = state.write().await;
|
||||
clear_usb_media_remap_retry_cooldown(&mut guard);
|
||||
return;
|
||||
}
|
||||
|
||||
if should_run {
|
||||
let now = Utc::now();
|
||||
{
|
||||
let mut guard = state.write().await;
|
||||
if let Some(last_started_at) = guard.usb_media_remap_reconcile.last_started_at {
|
||||
if (now - last_started_at).num_seconds() < AUTO_START_RETRY_COOLDOWN_SECS {
|
||||
if guard
|
||||
.usb_media_remap_reconcile
|
||||
.last_backoff_log_at
|
||||
.map(|last_backoff_log_at| {
|
||||
(now - last_backoff_log_at).num_seconds()
|
||||
>= AUTO_START_RETRY_COOLDOWN_SECS
|
||||
})
|
||||
.unwrap_or(true)
|
||||
{
|
||||
guard.usb_media_remap_reconcile.last_backoff_log_at = Some(now);
|
||||
let _ = logger::append_line(format!(
|
||||
"rust-daemon: usb media remap auto-start backing off for {}s after repeated failures",
|
||||
AUTO_START_RETRY_COOLDOWN_SECS
|
||||
));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
guard.usb_media_remap_reconcile.last_started_at = Some(now);
|
||||
guard.usb_media_remap_reconcile.last_backoff_log_at = None;
|
||||
}
|
||||
|
||||
match crate::commands::usb_media_remap::start_remap() {
|
||||
Ok(()) => {
|
||||
let mut should_log = false;
|
||||
{
|
||||
let mut guard = state.write().await;
|
||||
clear_usb_media_remap_retry_cooldown(&mut guard);
|
||||
if guard
|
||||
.usb_media_remap_reconcile
|
||||
.last_start_log_at
|
||||
.map(|last_log_at| {
|
||||
(now - last_log_at).num_seconds() >= AUTO_START_RETRY_COOLDOWN_SECS
|
||||
})
|
||||
.unwrap_or(true)
|
||||
{
|
||||
guard.usb_media_remap_reconcile.last_start_log_at = Some(now);
|
||||
should_log = true;
|
||||
}
|
||||
}
|
||||
if should_log {
|
||||
let _ =
|
||||
logger::append_line("rust-daemon: reconciled usb media remap -> started");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("failed to auto-start usb media remap: {err}");
|
||||
crate::runtime::daemon::notify_runtime_error(
|
||||
&state,
|
||||
"Zenbook Duo Runtime Error",
|
||||
&format!("USB media remap auto-start failed: {err}"),
|
||||
)
|
||||
.await;
|
||||
let _ = logger::append_line(format!(
|
||||
"rust-daemon: usb media remap auto-start failed: {}",
|
||||
err
|
||||
));
|
||||
}
|
||||
}
|
||||
} else if let Err(err) = crate::commands::usb_media_remap::stop_remap() {
|
||||
log::warn!("failed to auto-stop usb media remap: {err}");
|
||||
crate::runtime::daemon::notify_runtime_error(
|
||||
&state,
|
||||
"Zenbook Duo Runtime Error",
|
||||
&format!("USB media remap auto-stop failed: {err}"),
|
||||
)
|
||||
.await;
|
||||
let _ = logger::append_line(format!(
|
||||
"rust-daemon: usb media remap auto-stop failed: {}",
|
||||
err
|
||||
));
|
||||
} else {
|
||||
let mut guard = state.write().await;
|
||||
clear_usb_media_remap_retry_cooldown(&mut guard);
|
||||
drop(guard);
|
||||
let _ = logger::append_line("rust-daemon: reconciled usb media remap -> stopped");
|
||||
}
|
||||
}
|
||||
|
||||
fn usb_media_remap_should_run(state: &RuntimeState) -> bool {
|
||||
state.status.keyboard_attached
|
||||
&& matches!(state.status.connection_type, ConnectionType::Usb)
|
||||
&& state.settings.usb_media_remap_enabled
|
||||
}
|
||||
|
||||
fn clear_usb_media_remap_retry_cooldown(state: &mut RuntimeState) {
|
||||
state.usb_media_remap_reconcile.last_started_at = None;
|
||||
state.usb_media_remap_reconcile.last_backoff_log_at = None;
|
||||
}
|
||||
|
||||
async fn apply_policy_actions(state: Arc<RwLock<RuntimeState>>, actions: Vec<PolicyAction>) {
|
||||
for action in actions {
|
||||
match action {
|
||||
PolicyAction::SetWifi(enabled) => {
|
||||
if let Err(err) = crate::runtime::policy::set_wifi_enabled(enabled) {
|
||||
log::warn!("failed to set wifi policy action: {err}");
|
||||
crate::runtime::daemon::notify_runtime_error(
|
||||
&state,
|
||||
"Zenbook Duo Runtime Error",
|
||||
&format!("Wi-Fi policy action failed: {err}"),
|
||||
)
|
||||
.await;
|
||||
let _ = logger::append_line(format!(
|
||||
"rust-daemon: wifi policy action failed (enabled={}): {}",
|
||||
enabled, err
|
||||
));
|
||||
} else {
|
||||
let _ = logger::append_line(format!(
|
||||
"rust-daemon: applied wifi policy action -> {}",
|
||||
enabled
|
||||
));
|
||||
}
|
||||
}
|
||||
PolicyAction::SetBluetooth(enabled) => {
|
||||
if let Err(err) = crate::runtime::policy::set_bluetooth_enabled(enabled) {
|
||||
log::warn!("failed to set bluetooth policy action: {err}");
|
||||
crate::runtime::daemon::notify_runtime_error(
|
||||
&state,
|
||||
"Zenbook Duo Runtime Error",
|
||||
&format!("Bluetooth policy action failed: {err}"),
|
||||
)
|
||||
.await;
|
||||
let _ = logger::append_line(format!(
|
||||
"rust-daemon: bluetooth policy action failed (enabled={}): {}",
|
||||
enabled, err
|
||||
));
|
||||
} else {
|
||||
let _ = logger::append_line(format!(
|
||||
"rust-daemon: applied bluetooth policy action -> {}",
|
||||
enabled
|
||||
));
|
||||
}
|
||||
}
|
||||
PolicyAction::SetBacklight(level) => {
|
||||
if let Err(err) = crate::hardware::hid::set_backlight(level) {
|
||||
log::warn!("failed to set backlight policy action: {err}");
|
||||
crate::runtime::daemon::notify_runtime_error(
|
||||
&state,
|
||||
"Zenbook Duo Runtime Error",
|
||||
&format!("Backlight policy action failed: {err}"),
|
||||
)
|
||||
.await;
|
||||
let _ = logger::append_line(format!(
|
||||
"rust-daemon: backlight policy action failed (level={}): {}",
|
||||
level, err
|
||||
));
|
||||
} else {
|
||||
{
|
||||
let mut guard = state.write().await;
|
||||
guard.status.backlight_level = level;
|
||||
guard.push_recent_event(HardwareEvent::info(
|
||||
EventCategory::Keyboard,
|
||||
format!("Backlight set to {}", level),
|
||||
"rust-daemon",
|
||||
));
|
||||
guard.touch();
|
||||
if let Err(err) = guard.save() {
|
||||
log::warn!("failed to save backlight policy state: {err}");
|
||||
}
|
||||
}
|
||||
let _ = logger::append_line(format!(
|
||||
"rust-daemon: applied backlight policy action -> {}",
|
||||
level
|
||||
));
|
||||
}
|
||||
}
|
||||
PolicyAction::ApplyDisplayMode { attached, scale } => {
|
||||
if let Err(err) =
|
||||
crate::runtime::daemon::replay_current_display_mode(&state, attached, scale)
|
||||
.await
|
||||
{
|
||||
log::warn!("failed to apply display-mode policy action: {err}");
|
||||
crate::runtime::daemon::notify_runtime_error(
|
||||
&state,
|
||||
"Zenbook Duo Runtime Error",
|
||||
&format!("Display-mode policy action failed: {err}"),
|
||||
)
|
||||
.await;
|
||||
let _ = logger::append_line(format!(
|
||||
"rust-daemon: display-mode policy action failed (attached={}, scale={}): {}",
|
||||
attached, scale, err
|
||||
));
|
||||
} else {
|
||||
let _ = logger::append_line(format!(
|
||||
"rust-daemon: applied display-mode policy action (attached={}, scale={})",
|
||||
attached, scale
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_status_events(
|
||||
state: &mut RuntimeState,
|
||||
old: &crate::models::DuoStatus,
|
||||
new: &crate::models::DuoStatus,
|
||||
) {
|
||||
if old.keyboard_attached != new.keyboard_attached {
|
||||
state.push_recent_event(HardwareEvent::info(
|
||||
EventCategory::Usb,
|
||||
if new.keyboard_attached {
|
||||
"Keyboard attached"
|
||||
} else {
|
||||
"Keyboard detached"
|
||||
},
|
||||
"rust-daemon",
|
||||
));
|
||||
}
|
||||
|
||||
if old.connection_type != new.connection_type {
|
||||
state.push_recent_event(HardwareEvent::info(
|
||||
EventCategory::Keyboard,
|
||||
format!(
|
||||
"Connection type changed to {}",
|
||||
connection_label(&new.connection_type)
|
||||
),
|
||||
"rust-daemon",
|
||||
));
|
||||
}
|
||||
|
||||
if old.wifi_enabled != new.wifi_enabled {
|
||||
state.push_recent_event(HardwareEvent::info(
|
||||
EventCategory::Network,
|
||||
if new.wifi_enabled {
|
||||
"Wi-Fi enabled"
|
||||
} else {
|
||||
"Wi-Fi disabled"
|
||||
},
|
||||
"rust-daemon",
|
||||
));
|
||||
}
|
||||
|
||||
if old.bluetooth_enabled != new.bluetooth_enabled {
|
||||
state.push_recent_event(HardwareEvent::info(
|
||||
EventCategory::Bluetooth,
|
||||
if new.bluetooth_enabled {
|
||||
"Bluetooth enabled"
|
||||
} else {
|
||||
"Bluetooth disabled"
|
||||
},
|
||||
"rust-daemon",
|
||||
));
|
||||
}
|
||||
|
||||
if old.monitor_count != new.monitor_count {
|
||||
state.push_recent_event(HardwareEvent::info(
|
||||
EventCategory::Display,
|
||||
format!("Monitor count changed to {}", new.monitor_count),
|
||||
"rust-daemon",
|
||||
));
|
||||
}
|
||||
|
||||
if old.orientation != new.orientation {
|
||||
state.push_recent_event(HardwareEvent::info(
|
||||
EventCategory::Rotation,
|
||||
format!(
|
||||
"Orientation changed to {}",
|
||||
orientation_label(&new.orientation)
|
||||
),
|
||||
"rust-daemon",
|
||||
));
|
||||
}
|
||||
|
||||
if old.backlight_level != new.backlight_level {
|
||||
state.push_recent_event(HardwareEvent::info(
|
||||
EventCategory::Keyboard,
|
||||
format!("Backlight level changed to {}", new.backlight_level),
|
||||
"rust-daemon",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn connection_label(connection_type: &ConnectionType) -> &'static str {
|
||||
match connection_type {
|
||||
ConnectionType::Usb => "usb",
|
||||
ConnectionType::Bluetooth => "bluetooth",
|
||||
ConnectionType::None => "none",
|
||||
}
|
||||
}
|
||||
|
||||
fn orientation_label(orientation: &crate::models::Orientation) -> &'static str {
|
||||
match orientation {
|
||||
crate::models::Orientation::Normal => "normal",
|
||||
crate::models::Orientation::Left => "left",
|
||||
crate::models::Orientation::Right => "right",
|
||||
crate::models::Orientation::Inverted => "inverted",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn usb_media_remap_only_runs_for_enabled_usb_keyboard() {
|
||||
let mut state = RuntimeState::default();
|
||||
state.settings.usb_media_remap_enabled = true;
|
||||
state.status.keyboard_attached = true;
|
||||
state.status.connection_type = ConnectionType::Usb;
|
||||
assert!(usb_media_remap_should_run(&state));
|
||||
|
||||
state.status.connection_type = ConnectionType::Bluetooth;
|
||||
assert!(!usb_media_remap_should_run(&state));
|
||||
|
||||
state.status.connection_type = ConnectionType::Usb;
|
||||
state.status.keyboard_attached = false;
|
||||
assert!(!usb_media_remap_should_run(&state));
|
||||
|
||||
state.status.keyboard_attached = true;
|
||||
state.settings.usb_media_remap_enabled = false;
|
||||
assert!(!usb_media_remap_should_run(&state));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_retry_cooldown_preserves_last_start_log() {
|
||||
let mut state = RuntimeState::default();
|
||||
let now = Utc::now();
|
||||
state.usb_media_remap_reconcile.last_started_at = Some(now);
|
||||
state.usb_media_remap_reconcile.last_backoff_log_at = Some(now);
|
||||
state.usb_media_remap_reconcile.last_start_log_at = Some(now);
|
||||
|
||||
clear_usb_media_remap_retry_cooldown(&mut state);
|
||||
|
||||
assert!(state.usb_media_remap_reconcile.last_started_at.is_none());
|
||||
assert!(state
|
||||
.usb_media_remap_reconcile
|
||||
.last_backoff_log_at
|
||||
.is_none());
|
||||
assert_eq!(state.usb_media_remap_reconcile.last_start_log_at, Some(now));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
use nix::unistd::Uid;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub const APP_DIR_NAME: &str = "zenbook-duo";
|
||||
pub const DAEMON_SOCKET_NAME: &str = "daemon.sock";
|
||||
pub const SESSION_SOCKET_NAME: &str = "session-agent.sock";
|
||||
pub const STATE_FILE_NAME: &str = "state.json";
|
||||
pub const LOG_FILE_NAME: &str = "daemon.log";
|
||||
|
||||
pub fn system_runtime_dir() -> PathBuf {
|
||||
PathBuf::from("/var/lib").join(APP_DIR_NAME)
|
||||
}
|
||||
|
||||
pub fn daemon_socket_path() -> PathBuf {
|
||||
system_runtime_dir().join(DAEMON_SOCKET_NAME)
|
||||
}
|
||||
|
||||
pub fn state_file_path() -> PathBuf {
|
||||
system_runtime_dir().join(STATE_FILE_NAME)
|
||||
}
|
||||
|
||||
pub fn log_file_path() -> PathBuf {
|
||||
system_runtime_dir().join(LOG_FILE_NAME)
|
||||
}
|
||||
|
||||
pub fn user_runtime_dir(uid: u32) -> PathBuf {
|
||||
PathBuf::from(format!("/run/user/{uid}/{APP_DIR_NAME}"))
|
||||
}
|
||||
|
||||
pub fn current_user_runtime_dir() -> PathBuf {
|
||||
user_runtime_dir(Uid::current().as_raw())
|
||||
}
|
||||
|
||||
pub fn current_user_session_socket_path() -> PathBuf {
|
||||
current_user_runtime_dir().join(SESSION_SOCKET_NAME)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
use crate::models::{DuoStatus, EventCategory, HardwareEvent};
|
||||
use crate::runtime::host::{CommandRunner, ProcessCommandRunner};
|
||||
use crate::runtime::state::RuntimeState;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum PolicyAction {
|
||||
SetWifi(bool),
|
||||
SetBluetooth(bool),
|
||||
SetBacklight(u8),
|
||||
ApplyDisplayMode { attached: bool, scale: f64 },
|
||||
}
|
||||
|
||||
pub fn apply_transition_policy(
|
||||
state: &mut RuntimeState,
|
||||
previous: &DuoStatus,
|
||||
) -> Vec<PolicyAction> {
|
||||
let mut actions = Vec::new();
|
||||
let was_attached = display_mode_attached(previous);
|
||||
let is_attached = display_mode_attached(&state.status);
|
||||
|
||||
if was_attached == is_attached {
|
||||
if is_attached {
|
||||
state.remembered_wifi_enabled = Some(state.status.wifi_enabled);
|
||||
state.remembered_bluetooth_enabled = Some(state.status.bluetooth_enabled);
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
if !is_attached {
|
||||
state.remembered_wifi_enabled = Some(previous.wifi_enabled);
|
||||
state.remembered_bluetooth_enabled = Some(previous.bluetooth_enabled);
|
||||
}
|
||||
|
||||
if is_attached {
|
||||
if let Some(wifi_enabled) = state.remembered_wifi_enabled {
|
||||
actions.push(PolicyAction::SetWifi(wifi_enabled));
|
||||
state.push_recent_event(HardwareEvent::info(
|
||||
EventCategory::Network,
|
||||
if wifi_enabled {
|
||||
"Restoring Wi-Fi on attach"
|
||||
} else {
|
||||
"Keeping Wi-Fi disabled on attach"
|
||||
},
|
||||
"rust-daemon",
|
||||
));
|
||||
state.status.wifi_enabled = wifi_enabled;
|
||||
}
|
||||
|
||||
if let Some(bluetooth_enabled) = state.remembered_bluetooth_enabled {
|
||||
actions.push(PolicyAction::SetBluetooth(bluetooth_enabled));
|
||||
state.push_recent_event(HardwareEvent::info(
|
||||
EventCategory::Bluetooth,
|
||||
if bluetooth_enabled {
|
||||
"Restoring Bluetooth on attach"
|
||||
} else {
|
||||
"Keeping Bluetooth disabled on attach"
|
||||
},
|
||||
"rust-daemon",
|
||||
));
|
||||
state.status.bluetooth_enabled = bluetooth_enabled;
|
||||
}
|
||||
|
||||
actions.push(PolicyAction::SetBacklight(state.settings.default_backlight));
|
||||
actions.push(PolicyAction::ApplyDisplayMode {
|
||||
attached: true,
|
||||
scale: state.settings.default_scale,
|
||||
});
|
||||
} else {
|
||||
actions.push(PolicyAction::SetBluetooth(true));
|
||||
actions.push(PolicyAction::ApplyDisplayMode {
|
||||
attached: false,
|
||||
scale: state.settings.default_scale,
|
||||
});
|
||||
state.status.bluetooth_enabled = true;
|
||||
state.push_recent_event(HardwareEvent::info(
|
||||
EventCategory::Bluetooth,
|
||||
"Enabled Bluetooth on detach",
|
||||
"rust-daemon",
|
||||
));
|
||||
}
|
||||
|
||||
actions
|
||||
}
|
||||
|
||||
fn display_mode_attached(status: &DuoStatus) -> bool {
|
||||
status.keyboard_attached
|
||||
}
|
||||
|
||||
pub fn set_wifi_enabled(enabled: bool) -> Result<(), String> {
|
||||
set_wifi_enabled_with(&ProcessCommandRunner, enabled)
|
||||
}
|
||||
|
||||
pub fn set_wifi_enabled_with(host: &impl CommandRunner, enabled: bool) -> Result<(), String> {
|
||||
let target = if enabled { "on" } else { "off" };
|
||||
let output = host.output("nmcli", &["radio", "wifi", target])?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_bluetooth_enabled(enabled: bool) -> Result<(), String> {
|
||||
set_bluetooth_enabled_with(&ProcessCommandRunner, enabled)
|
||||
}
|
||||
|
||||
pub fn set_bluetooth_enabled_with(host: &impl CommandRunner, enabled: bool) -> Result<(), String> {
|
||||
let action = if enabled { "unblock" } else { "block" };
|
||||
let output = host.output("rfkill", &[action, "bluetooth"])?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::ConnectionType;
|
||||
|
||||
fn status(attached: bool, wifi: bool, bluetooth: bool) -> DuoStatus {
|
||||
DuoStatus {
|
||||
keyboard_attached: attached,
|
||||
connection_type: if attached {
|
||||
ConnectionType::Usb
|
||||
} else {
|
||||
ConnectionType::None
|
||||
},
|
||||
wifi_enabled: wifi,
|
||||
bluetooth_enabled: bluetooth,
|
||||
..DuoStatus::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_actions_execute_through_host_adapter() {
|
||||
let host = crate::runtime::host::tests::FakeCommandRunner::new([
|
||||
Ok(crate::runtime::host::tests::FakeCommandRunner::success("")),
|
||||
Ok(crate::runtime::host::tests::FakeCommandRunner::success("")),
|
||||
]);
|
||||
|
||||
set_wifi_enabled_with(&host, false).expect("wifi command succeeds");
|
||||
set_bluetooth_enabled_with(&host, true).expect("bluetooth command succeeds");
|
||||
|
||||
assert_eq!(
|
||||
host.calls(),
|
||||
vec![
|
||||
(
|
||||
"nmcli".to_string(),
|
||||
vec!["radio".to_string(), "wifi".to_string(), "off".to_string()],
|
||||
),
|
||||
(
|
||||
"rfkill".to_string(),
|
||||
vec!["unblock".to_string(), "bluetooth".to_string()],
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restores_disabled_radios_after_detach_attach_cycle() {
|
||||
let mut state = RuntimeState {
|
||||
status: status(false, false, false),
|
||||
..RuntimeState::default()
|
||||
};
|
||||
let previous_attached = status(true, false, false);
|
||||
|
||||
let detach_actions = apply_transition_policy(&mut state, &previous_attached);
|
||||
|
||||
assert_eq!(state.remembered_wifi_enabled, Some(false));
|
||||
assert_eq!(state.remembered_bluetooth_enabled, Some(false));
|
||||
assert!(detach_actions.contains(&PolicyAction::SetBluetooth(true)));
|
||||
assert!(state.status.bluetooth_enabled);
|
||||
|
||||
let previous_detached = state.status.clone();
|
||||
state.status = status(true, true, true);
|
||||
|
||||
let attach_actions = apply_transition_policy(&mut state, &previous_detached);
|
||||
|
||||
assert!(attach_actions.contains(&PolicyAction::SetWifi(false)));
|
||||
assert!(attach_actions.contains(&PolicyAction::SetBluetooth(false)));
|
||||
assert!(!state.status.wifi_enabled);
|
||||
assert!(!state.status.bluetooth_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bluetooth_only_transition_does_not_replay_display_mode() {
|
||||
let mut state = RuntimeState {
|
||||
status: DuoStatus {
|
||||
keyboard_attached: false,
|
||||
connection_type: ConnectionType::Bluetooth,
|
||||
..status(false, true, true)
|
||||
},
|
||||
..RuntimeState::default()
|
||||
};
|
||||
let previous = status(false, true, true);
|
||||
|
||||
let actions = apply_transition_policy(&mut state, &previous);
|
||||
|
||||
assert!(actions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updates_remembered_radios_while_still_attached() {
|
||||
let mut state = RuntimeState {
|
||||
status: status(true, false, true),
|
||||
..RuntimeState::default()
|
||||
};
|
||||
let previous = status(true, true, true);
|
||||
|
||||
let actions = apply_transition_policy(&mut state, &previous);
|
||||
|
||||
assert!(actions.is_empty());
|
||||
assert_eq!(state.remembered_wifi_enabled, Some(false));
|
||||
assert_eq!(state.remembered_bluetooth_enabled, Some(true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
use crate::hardware::{display_layout, sysfs};
|
||||
use crate::models::{DisplayLayout, DuoStatus, Orientation};
|
||||
use crate::runtime::host::{CommandRunner, ProcessCommandRunner};
|
||||
|
||||
pub fn current_status() -> DuoStatus {
|
||||
let mut status = sysfs::get_full_status();
|
||||
let connection_type = sysfs::detect_connection_type();
|
||||
status.keyboard_attached = keyboard_attached();
|
||||
status.connection_type = connection_type;
|
||||
let host = ProcessCommandRunner;
|
||||
status.wifi_enabled = wifi_enabled_with(&host);
|
||||
status.bluetooth_enabled = bluetooth_enabled_with(&host);
|
||||
apply_layout_to_status(
|
||||
&mut status,
|
||||
display_layout::get_display_layout().ok().as_ref(),
|
||||
);
|
||||
status
|
||||
}
|
||||
|
||||
pub fn apply_layout_to_status(status: &mut DuoStatus, layout: Option<&DisplayLayout>) {
|
||||
status.monitor_count = monitor_count(layout, status.monitor_count);
|
||||
status.orientation = inferred_orientation(layout).unwrap_or(status.orientation.clone());
|
||||
}
|
||||
|
||||
pub fn keyboard_attached() -> bool {
|
||||
sysfs::keyboard_attached()
|
||||
}
|
||||
|
||||
pub fn wifi_enabled() -> bool {
|
||||
wifi_enabled_with(&ProcessCommandRunner)
|
||||
}
|
||||
|
||||
pub fn wifi_enabled_with(host: &impl CommandRunner) -> bool {
|
||||
host.output("nmcli", &["radio", "wifi"])
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.lines().next().unwrap_or_default().trim() == "enabled")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn bluetooth_enabled() -> bool {
|
||||
bluetooth_enabled_with(&ProcessCommandRunner)
|
||||
}
|
||||
|
||||
pub fn bluetooth_enabled_with(host: &impl CommandRunner) -> bool {
|
||||
host.output("rfkill", &["-n", "-o", "SOFT", "list", "bluetooth"])
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.lines().next().unwrap_or_default().trim() == "unblocked")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn monitor_count(layout: Option<&crate::models::DisplayLayout>, current: u32) -> u32 {
|
||||
layout
|
||||
.map(|layout| layout.displays.len() as u32)
|
||||
.filter(|count| *count > 0)
|
||||
.or_else(|| if current > 0 { Some(current) } else { None })
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn inferred_orientation(layout: Option<&crate::models::DisplayLayout>) -> Option<Orientation> {
|
||||
let layout = layout?;
|
||||
let display = layout
|
||||
.displays
|
||||
.iter()
|
||||
.find(|display| display.primary)
|
||||
.or_else(|| layout.displays.first())?;
|
||||
|
||||
Some(match display.transform {
|
||||
90 => Orientation::Left,
|
||||
180 => Orientation::Inverted,
|
||||
270 => Orientation::Right,
|
||||
_ => Orientation::Normal,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_radio_status_through_host_adapter() {
|
||||
let host = crate::runtime::host::tests::FakeCommandRunner::new([
|
||||
Ok(crate::runtime::host::tests::FakeCommandRunner::success(
|
||||
"enabled\n",
|
||||
)),
|
||||
Ok(crate::runtime::host::tests::FakeCommandRunner::success(
|
||||
"unblocked\n",
|
||||
)),
|
||||
]);
|
||||
|
||||
assert!(wifi_enabled_with(&host));
|
||||
assert!(bluetooth_enabled_with(&host));
|
||||
assert_eq!(
|
||||
host.calls(),
|
||||
vec![
|
||||
(
|
||||
"nmcli".to_string(),
|
||||
vec!["radio".to_string(), "wifi".to_string()]
|
||||
),
|
||||
(
|
||||
"rfkill".to_string(),
|
||||
vec![
|
||||
"-n".to_string(),
|
||||
"-o".to_string(),
|
||||
"SOFT".to_string(),
|
||||
"list".to_string(),
|
||||
"bluetooth".to_string(),
|
||||
],
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attached_state_comes_from_sysfs_probe() {
|
||||
assert!(!keyboard_attached());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_primary_display_transform_to_status() {
|
||||
let mut status = DuoStatus::default();
|
||||
let layout = DisplayLayout {
|
||||
displays: vec![
|
||||
crate::models::DisplayInfo {
|
||||
connector: "eDP-1".into(),
|
||||
width: 2880,
|
||||
height: 1800,
|
||||
refresh_rate: 120.0,
|
||||
scale: 1.25,
|
||||
x: 0,
|
||||
y: 0,
|
||||
transform: 90,
|
||||
primary: true,
|
||||
current_mode: crate::models::DisplayMode {
|
||||
mode_id: "2880x1800@120".into(),
|
||||
backend_mode_id: None,
|
||||
width: 2880,
|
||||
height: 1800,
|
||||
refresh_rate: 120.0,
|
||||
},
|
||||
available_modes: vec![crate::models::DisplayMode {
|
||||
mode_id: "2880x1800@120".into(),
|
||||
backend_mode_id: None,
|
||||
width: 2880,
|
||||
height: 1800,
|
||||
refresh_rate: 120.0,
|
||||
}],
|
||||
refresh_policy: crate::models::RefreshPolicy::Fixed,
|
||||
supports_dynamic_refresh: false,
|
||||
},
|
||||
crate::models::DisplayInfo {
|
||||
connector: "eDP-2".into(),
|
||||
width: 2880,
|
||||
height: 1800,
|
||||
refresh_rate: 120.0,
|
||||
scale: 1.25,
|
||||
x: 0,
|
||||
y: 1800,
|
||||
transform: 0,
|
||||
primary: false,
|
||||
current_mode: crate::models::DisplayMode {
|
||||
mode_id: "2880x1800@120".into(),
|
||||
backend_mode_id: None,
|
||||
width: 2880,
|
||||
height: 1800,
|
||||
refresh_rate: 120.0,
|
||||
},
|
||||
available_modes: vec![crate::models::DisplayMode {
|
||||
mode_id: "2880x1800@120".into(),
|
||||
backend_mode_id: None,
|
||||
width: 2880,
|
||||
height: 1800,
|
||||
refresh_rate: 120.0,
|
||||
}],
|
||||
refresh_policy: crate::models::RefreshPolicy::Fixed,
|
||||
supports_dynamic_refresh: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
apply_layout_to_status(&mut status, Some(&layout));
|
||||
|
||||
assert_eq!(status.orientation, Orientation::Left);
|
||||
assert_eq!(status.monitor_count, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::ipc::protocol::{DaemonRequest, DaemonResponse, Envelope, PROTOCOL_VERSION};
|
||||
use crate::runtime::{daemon, state::RuntimeState};
|
||||
|
||||
/// Daemon request-router Interface.
|
||||
///
|
||||
/// This Module owns the wire-level loop and protocol error semantics. The
|
||||
/// daemon Implementation owns the command behavior behind `dispatch_request`.
|
||||
pub async fn handle_client(
|
||||
stream: UnixStream,
|
||||
state: Arc<RwLock<RuntimeState>>,
|
||||
) -> Result<(), String> {
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut lines = BufReader::new(reader).lines();
|
||||
|
||||
while let Some(line) = lines
|
||||
.next_line()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read daemon request: {e}"))?
|
||||
{
|
||||
let envelope: Envelope<DaemonRequest> =
|
||||
serde_json::from_str(&line).map_err(|e| format!("Invalid daemon request JSON: {e}"))?;
|
||||
|
||||
if envelope.protocol_version != PROTOCOL_VERSION {
|
||||
write_response(
|
||||
&mut writer,
|
||||
DaemonResponse::Error {
|
||||
message: format!(
|
||||
"Protocol mismatch: expected {}, got {}",
|
||||
PROTOCOL_VERSION, envelope.protocol_version
|
||||
),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
let response = daemon::dispatch_request(envelope.payload, state.clone()).await;
|
||||
write_response(&mut writer, response).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_response<W: AsyncWriteExt + Unpin>(
|
||||
writer: &mut W,
|
||||
response: DaemonResponse,
|
||||
) -> Result<(), String> {
|
||||
let line = serde_json::to_string(&Envelope::new(response))
|
||||
.map_err(|e| format!("Failed to encode daemon response: {e}"))?;
|
||||
writer
|
||||
.write_all(line.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to write daemon response: {e}"))?;
|
||||
writer
|
||||
.write_all(b"\n")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to terminate daemon response: {e}"))
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
use std::fs;
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
use std::path::Path;
|
||||
|
||||
use nix::unistd::Uid;
|
||||
|
||||
use crate::runtime::paths;
|
||||
|
||||
pub fn ensure_target_user_runtime_dir() -> Result<(), String> {
|
||||
let (uid, gid) = target_identity();
|
||||
ensure_dir_with_owner(&paths::user_runtime_dir(uid), Some((uid, gid)))
|
||||
}
|
||||
|
||||
pub fn ensure_current_user_runtime_dir() -> Result<(), String> {
|
||||
let dir = paths::current_user_runtime_dir();
|
||||
ensure_dir_with_owner(&dir, None)?;
|
||||
validate_current_user_dir(&dir)
|
||||
}
|
||||
|
||||
pub fn ensure_dir_owned_like_parent(dir: &Path) -> Result<(), String> {
|
||||
let owner = dir
|
||||
.parent()
|
||||
.and_then(owner_from_metadata)
|
||||
.or_else(target_identity_opt);
|
||||
ensure_dir_with_owner(dir, owner)
|
||||
}
|
||||
|
||||
fn ensure_dir_with_owner(dir: &Path, owner: Option<(u32, u32)>) -> Result<(), String> {
|
||||
fs::create_dir_all(dir).map_err(|e| format!("Failed to create {}: {e}", dir.display()))?;
|
||||
|
||||
if let Some((uid, gid)) = owner {
|
||||
let metadata =
|
||||
fs::metadata(dir).map_err(|e| format!("Failed to stat {}: {e}", dir.display()))?;
|
||||
if should_repair_ownership(
|
||||
Uid::current().as_raw(),
|
||||
metadata.uid(),
|
||||
metadata.gid(),
|
||||
uid,
|
||||
gid,
|
||||
) {
|
||||
chown_path(dir, uid, gid)?;
|
||||
}
|
||||
}
|
||||
|
||||
fs::set_permissions(dir, fs::Permissions::from_mode(0o700))
|
||||
.map_err(|e| format!("Failed to set {} permissions: {e}", dir.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_current_user_dir(dir: &Path) -> Result<(), String> {
|
||||
let metadata =
|
||||
fs::metadata(dir).map_err(|e| format!("Failed to stat {}: {e}", dir.display()))?;
|
||||
let current_uid = Uid::current().as_raw();
|
||||
if metadata.uid() != current_uid {
|
||||
return Err(format!(
|
||||
"Session runtime dir {} is owned by uid {}, expected uid {}. \
|
||||
This usually means a root-owned helper created it; restart the system daemon or repair ownership.",
|
||||
dir.display(),
|
||||
metadata.uid(),
|
||||
current_uid,
|
||||
));
|
||||
}
|
||||
|
||||
let mode = metadata.permissions().mode() & 0o700;
|
||||
if mode != 0o700 {
|
||||
return Err(format!(
|
||||
"Session runtime dir {} has mode {:o}, expected 700.",
|
||||
dir.display(),
|
||||
metadata.permissions().mode() & 0o777,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn target_identity() -> (u32, u32) {
|
||||
let uid = std::env::var("ZENBOOK_DUO_UID")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.unwrap_or_else(|| Uid::current().as_raw());
|
||||
let gid = std::env::var("ZENBOOK_DUO_GID")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.unwrap_or(uid);
|
||||
(uid, gid)
|
||||
}
|
||||
|
||||
fn target_identity_opt() -> Option<(u32, u32)> {
|
||||
let uid = std::env::var("ZENBOOK_DUO_UID")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())?;
|
||||
let gid = std::env::var("ZENBOOK_DUO_GID")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.unwrap_or(uid);
|
||||
Some((uid, gid))
|
||||
}
|
||||
|
||||
fn owner_from_metadata(path: &Path) -> Option<(u32, u32)> {
|
||||
let metadata = fs::metadata(path).ok()?;
|
||||
Some((metadata.uid(), metadata.gid()))
|
||||
}
|
||||
|
||||
fn should_repair_ownership(
|
||||
running_uid: u32,
|
||||
current_uid: u32,
|
||||
current_gid: u32,
|
||||
target_uid: u32,
|
||||
target_gid: u32,
|
||||
) -> bool {
|
||||
running_uid == 0 && (current_uid != target_uid || current_gid != target_gid)
|
||||
}
|
||||
|
||||
fn chown_path(path: &Path, uid: u32, gid: u32) -> Result<(), String> {
|
||||
let path_cstr = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())
|
||||
.map_err(|_| format!("Path contains interior NUL: {}", path.display()))?;
|
||||
let result = unsafe { libc::chown(path_cstr.as_ptr(), uid, gid) };
|
||||
if result == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"Failed to chown {} to {}:{}: {}",
|
||||
path.display(),
|
||||
uid,
|
||||
gid,
|
||||
std::io::Error::last_os_error()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir(name: &str) -> std::path::PathBuf {
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!(
|
||||
"zenbook-duo-{name}-{}-{unique}",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_missing_runtime_dir_with_secure_mode() {
|
||||
let dir = temp_dir("create");
|
||||
ensure_dir_with_owner(&dir, None).unwrap();
|
||||
|
||||
let metadata = fs::metadata(&dir).unwrap();
|
||||
assert!(metadata.is_dir());
|
||||
assert_eq!(metadata.permissions().mode() & 0o777, 0o700);
|
||||
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repairs_existing_runtime_dir_permissions() {
|
||||
let dir = temp_dir("chmod");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
ensure_dir_with_owner(&dir, None).unwrap();
|
||||
|
||||
let metadata = fs::metadata(&dir).unwrap();
|
||||
assert_eq!(metadata.permissions().mode() & 0o777, 0o700);
|
||||
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ownership_repair_only_runs_for_root_with_mismatched_owner() {
|
||||
assert!(should_repair_ownership(0, 0, 0, 1000, 1001));
|
||||
assert!(!should_repair_ownership(1000, 0, 0, 1000, 1001));
|
||||
assert!(!should_repair_ownership(0, 1000, 1001, 1000, 1001));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::process::{Command, Output};
|
||||
|
||||
use nix::unistd::{Gid, Uid};
|
||||
|
||||
/// Adapter for systemd service-control behavior used by daemon requests and
|
||||
/// session lifecycle recovery.
|
||||
pub(crate) struct ServiceController;
|
||||
|
||||
impl ServiceController {
|
||||
pub(crate) fn restart_owned_services() -> Result<(), String> {
|
||||
restart_owned_services()
|
||||
}
|
||||
|
||||
pub(crate) fn queue_target_user_unit_restart(unit: &str) -> Result<(), String> {
|
||||
queue_target_user_unit_restart(unit)
|
||||
}
|
||||
}
|
||||
|
||||
fn restart_owned_services() -> Result<(), String> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
if let Err(message) = restart_target_user_unit("zenbook-duo-session-agent.service") {
|
||||
errors.push(message);
|
||||
}
|
||||
|
||||
if let Err(message) = queue_system_unit_restart("zenbook-duo-rust-daemon.service") {
|
||||
errors.push(message);
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(errors.join("; "))
|
||||
}
|
||||
}
|
||||
|
||||
fn restart_target_user_unit(unit: &str) -> Result<(), String> {
|
||||
let mut command = target_user_systemctl_command();
|
||||
command.args(["--user", "restart", unit]);
|
||||
|
||||
let output = command
|
||||
.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 target_user_systemctl_command() -> Command {
|
||||
let uid = target_uid();
|
||||
let gid = target_gid();
|
||||
let mut command = Command::new("systemctl");
|
||||
command
|
||||
.env("XDG_RUNTIME_DIR", format!("/run/user/{uid}"))
|
||||
.env(
|
||||
"DBUS_SESSION_BUS_ADDRESS",
|
||||
format!("unix:path=/run/user/{uid}/bus"),
|
||||
);
|
||||
|
||||
if Uid::current().is_root() {
|
||||
command.uid(uid).gid(gid);
|
||||
}
|
||||
|
||||
command
|
||||
}
|
||||
|
||||
fn queue_target_user_unit_restart(unit: &str) -> Result<(), String> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
let _ = unit;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
let mut command = target_user_systemctl_command();
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
command
|
||||
.args(["--user", "restart", unit])
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null());
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
command.status()
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn queue_system_unit_restart(unit: &str) -> Result<(), String> {
|
||||
let status = Command::new("systemctl")
|
||||
.args(["status", unit])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to inspect {unit}: {e}"))?;
|
||||
|
||||
if !status.status.success() && unit_not_found(&status) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("sleep 0.2; exec systemctl restart \"$1\"")
|
||||
.arg("zenbook-duo-restart")
|
||||
.arg(unit)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("Failed to queue restart for {unit}: {e}"))
|
||||
}
|
||||
|
||||
fn target_uid() -> u32 {
|
||||
std::env::var("ZENBOOK_DUO_UID")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.unwrap_or_else(|| Uid::current().as_raw())
|
||||
}
|
||||
|
||||
fn target_gid() -> u32 {
|
||||
std::env::var("ZENBOOK_DUO_GID")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.unwrap_or_else(|| Gid::current().as_raw())
|
||||
}
|
||||
|
||||
fn unit_not_found(output: &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,282 @@
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::ipc::protocol::SessionBackend;
|
||||
|
||||
const DESKTOP_ENV_VARS: [&str; 3] = [
|
||||
"XDG_CURRENT_DESKTOP",
|
||||
"XDG_SESSION_DESKTOP",
|
||||
"DESKTOP_SESSION",
|
||||
];
|
||||
|
||||
const GNOME_READINESS_ARGS: &[&str] = &["show"];
|
||||
const KDE_READINESS_ARGS: &[&str] = &["-j"];
|
||||
const NIRI_READINESS_ARGS: &[&str] = &["msg", "--json", "outputs"];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BackendCommandRunner {
|
||||
Compositor,
|
||||
Niri,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct BackendProbe {
|
||||
pub backend: SessionBackend,
|
||||
pub readiness_program: &'static str,
|
||||
pub readiness_args: &'static [&'static str],
|
||||
pub readiness_runner: BackendCommandRunner,
|
||||
pub requires_gui_session: bool,
|
||||
}
|
||||
|
||||
pub const SUPPORTED_BACKENDS: [SessionBackend; 3] = [
|
||||
SessionBackend::Niri,
|
||||
SessionBackend::Gnome,
|
||||
SessionBackend::Kde,
|
||||
];
|
||||
|
||||
pub fn detect_backend_from_env() -> SessionBackend {
|
||||
detect_backend_from_hint(&desktop_hint_from_env(), resolve_niri_socket().is_some())
|
||||
}
|
||||
|
||||
pub fn detect_backend_from_hint(current: &str, has_niri_socket: bool) -> SessionBackend {
|
||||
let current = current.to_ascii_lowercase().replace(':', " ");
|
||||
if contains_desktop_token(¤t, "gnome") {
|
||||
SessionBackend::Gnome
|
||||
} else if contains_desktop_token(¤t, "plasma") || contains_desktop_token(¤t, "kde")
|
||||
{
|
||||
SessionBackend::Kde
|
||||
} else if contains_desktop_token(¤t, "niri") || has_niri_socket {
|
||||
SessionBackend::Niri
|
||||
} else {
|
||||
SessionBackend::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
fn contains_desktop_token(haystack: &str, needle: &str) -> bool {
|
||||
haystack
|
||||
.split_whitespace()
|
||||
.any(|token| token == needle || token.contains(needle))
|
||||
}
|
||||
|
||||
fn desktop_hint_from_env() -> String {
|
||||
DESKTOP_ENV_VARS
|
||||
.iter()
|
||||
.filter_map(|name| env::var(name).ok())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
pub fn backend_probe_order(hinted: &SessionBackend) -> Vec<SessionBackend> {
|
||||
backend_probe_sequence(hinted)
|
||||
.into_iter()
|
||||
.map(|probe| probe.backend)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn backend_probe_sequence(hinted: &SessionBackend) -> Vec<BackendProbe> {
|
||||
let mut order = Vec::new();
|
||||
if hinted != &SessionBackend::Unknown {
|
||||
if let Some(probe) = backend_probe(hinted) {
|
||||
order.push(probe);
|
||||
}
|
||||
}
|
||||
for backend in SUPPORTED_BACKENDS {
|
||||
if !order
|
||||
.iter()
|
||||
.any(|probe: &BackendProbe| probe.backend == backend)
|
||||
{
|
||||
if let Some(probe) = backend_probe(&backend) {
|
||||
order.push(probe);
|
||||
}
|
||||
}
|
||||
}
|
||||
order
|
||||
}
|
||||
|
||||
pub fn backend_probe(backend: &SessionBackend) -> Option<BackendProbe> {
|
||||
match backend {
|
||||
SessionBackend::Gnome => Some(BackendProbe {
|
||||
backend: SessionBackend::Gnome,
|
||||
readiness_program: "gdctl",
|
||||
readiness_args: GNOME_READINESS_ARGS,
|
||||
readiness_runner: BackendCommandRunner::Compositor,
|
||||
requires_gui_session: true,
|
||||
}),
|
||||
SessionBackend::Kde => Some(BackendProbe {
|
||||
backend: SessionBackend::Kde,
|
||||
readiness_program: "kscreen-doctor",
|
||||
readiness_args: KDE_READINESS_ARGS,
|
||||
readiness_runner: BackendCommandRunner::Compositor,
|
||||
requires_gui_session: true,
|
||||
}),
|
||||
SessionBackend::Niri => Some(BackendProbe {
|
||||
backend: SessionBackend::Niri,
|
||||
readiness_program: "niri",
|
||||
readiness_args: NIRI_READINESS_ARGS,
|
||||
readiness_runner: BackendCommandRunner::Niri,
|
||||
requires_gui_session: false,
|
||||
}),
|
||||
SessionBackend::Unknown => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_niri_socket() -> Option<PathBuf> {
|
||||
let env_socket = env::var_os("NIRI_SOCKET").map(PathBuf::from);
|
||||
resolve_niri_socket_from(env_socket.as_deref(), niri_runtime_dir().as_deref())
|
||||
}
|
||||
|
||||
fn niri_runtime_dir() -> Option<PathBuf> {
|
||||
env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from)
|
||||
}
|
||||
|
||||
pub fn resolve_niri_socket_from(
|
||||
env_socket: Option<&Path>,
|
||||
runtime_dir: Option<&Path>,
|
||||
) -> Option<PathBuf> {
|
||||
if let Some(env_socket) = env_socket {
|
||||
if env_socket.exists() {
|
||||
return Some(env_socket.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
let runtime_dir = runtime_dir?;
|
||||
let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
|
||||
|
||||
for entry in std::fs::read_dir(runtime_dir).ok()? {
|
||||
let entry = entry.ok()?;
|
||||
let path = entry.path();
|
||||
let name = path.file_name()?.to_str()?;
|
||||
if !name.starts_with("niri.") || !name.ends_with(".sock") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let modified = entry
|
||||
.metadata()
|
||||
.ok()?
|
||||
.modified()
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
|
||||
match &newest {
|
||||
Some((current_modified, _)) if *current_modified >= modified => {}
|
||||
_ => newest = Some((modified, path)),
|
||||
}
|
||||
}
|
||||
|
||||
newest.map(|(_, path)| path)
|
||||
}
|
||||
|
||||
pub fn build_niri_command(args: &[&str]) -> Command {
|
||||
let mut command = Command::new("niri");
|
||||
command.args(args);
|
||||
if let Some(socket) = resolve_niri_socket() {
|
||||
command.env("NIRI_SOCKET", socket);
|
||||
}
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
static NEXT_ID: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[test]
|
||||
fn detects_gnome_from_desktop_hint() {
|
||||
assert_eq!(
|
||||
detect_backend_from_hint("GNOME", false),
|
||||
SessionBackend::Gnome
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_kde_from_plasma_desktop_hint() {
|
||||
assert_eq!(
|
||||
detect_backend_from_hint("KDE Plasma", false),
|
||||
SessionBackend::Kde
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_niri_from_desktop_hint() {
|
||||
assert_eq!(
|
||||
detect_backend_from_hint("niri", false),
|
||||
SessionBackend::Niri
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_niri_from_socket_without_desktop_hint() {
|
||||
assert_eq!(detect_backend_from_hint("", true), SessionBackend::Niri);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_hint_without_niri_socket_remains_unknown() {
|
||||
assert_eq!(detect_backend_from_hint("", false), SessionBackend::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_order_prefers_hint_then_supported_fallbacks() {
|
||||
assert_eq!(
|
||||
backend_probe_order(&SessionBackend::Kde),
|
||||
vec![
|
||||
SessionBackend::Kde,
|
||||
SessionBackend::Niri,
|
||||
SessionBackend::Gnome,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_niri_socket_prefers_existing_env_socket() {
|
||||
let runtime_dir = temp_runtime_dir("niri-env");
|
||||
let env_socket = runtime_dir.join("niri.wayland-1.env.sock");
|
||||
let listener =
|
||||
std::os::unix::net::UnixListener::bind(&env_socket).expect("bind env socket");
|
||||
|
||||
let resolved =
|
||||
resolve_niri_socket_from(Some(env_socket.as_path()), Some(runtime_dir.as_path()))
|
||||
.expect("resolve niri socket");
|
||||
|
||||
assert_eq!(resolved, env_socket);
|
||||
|
||||
drop(listener);
|
||||
let _ = std::fs::remove_file(&env_socket);
|
||||
let _ = std::fs::remove_dir_all(&runtime_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_niri_socket_falls_back_to_latest_runtime_socket() {
|
||||
let runtime_dir = temp_runtime_dir("niri-fallback");
|
||||
let older_socket = runtime_dir.join("niri.wayland-1.older.sock");
|
||||
let newer_socket = runtime_dir.join("niri.wayland-1.newer.sock");
|
||||
let older_listener =
|
||||
std::os::unix::net::UnixListener::bind(&older_socket).expect("bind older socket");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
let newer_listener =
|
||||
std::os::unix::net::UnixListener::bind(&newer_socket).expect("bind newer socket");
|
||||
|
||||
let resolved = resolve_niri_socket_from(None, Some(runtime_dir.as_path()))
|
||||
.expect("resolve niri socket");
|
||||
|
||||
assert_eq!(resolved, newer_socket);
|
||||
|
||||
drop(older_listener);
|
||||
drop(newer_listener);
|
||||
let _ = std::fs::remove_file(&older_socket);
|
||||
let _ = std::fs::remove_file(&newer_socket);
|
||||
let _ = std::fs::remove_dir_all(&runtime_dir);
|
||||
}
|
||||
|
||||
fn temp_runtime_dir(label: &str) -> PathBuf {
|
||||
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock before unix epoch")
|
||||
.as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("zenbook-duo-{label}-{nanos}-{id}"));
|
||||
std::fs::create_dir_all(&dir).expect("create temp runtime dir");
|
||||
dir
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,919 @@
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::FileTypeExt;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
pub(crate) mod brightness_sync;
|
||||
pub(crate) mod rotation;
|
||||
|
||||
use crate::hardware::duo::{
|
||||
is_internal_connector, PRIMARY_INTERNAL_CONNECTOR, SECONDARY_INTERNAL_CONNECTOR,
|
||||
};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
use tokio::process::Command as TokioCommand;
|
||||
|
||||
use crate::ipc::protocol::{
|
||||
DaemonRequest, DaemonResponse, Envelope, SessionBackend, SessionCommand, SessionResponse,
|
||||
};
|
||||
use crate::models::{DisplayInfo, DisplayLayout, Orientation};
|
||||
use crate::runtime::{
|
||||
compositor, paths, session, session_display_planner::DockModePlanner, session_ipc,
|
||||
session_watchers, state::RuntimeState,
|
||||
};
|
||||
|
||||
pub async fn run() -> Result<(), String> {
|
||||
ensure_user_runtime_dir()?;
|
||||
let listener = bind_session_listener(paths::current_user_session_socket_path().as_path())?;
|
||||
|
||||
let backend = BackendReadiness::wait_for_ready_backend().await;
|
||||
register_with_daemon(backend).await?;
|
||||
session_watchers::start_all();
|
||||
|
||||
loop {
|
||||
let (stream, _) = listener
|
||||
.accept()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to accept session-agent client: {e}"))?;
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = session_ipc::handle_session_command(stream).await {
|
||||
log::warn!("session-agent client error: {err}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn register_with_daemon(backend: SessionBackend) -> Result<(), String> {
|
||||
let stream = UnixStream::connect(paths::daemon_socket_path())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to connect to daemon socket: {e}"))?;
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
|
||||
let request = Envelope::new(DaemonRequest::RegisterSessionAgent {
|
||||
session_id: detect_session_id(),
|
||||
backend,
|
||||
socket_path: paths::current_user_session_socket_path()
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
});
|
||||
let line = serde_json::to_string(&request)
|
||||
.map_err(|e| format!("Failed to encode registration: {e}"))?;
|
||||
writer
|
||||
.write_all(line.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send registration: {e}"))?;
|
||||
writer
|
||||
.write_all(b"\n")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to terminate registration: {e}"))?;
|
||||
|
||||
let mut lines = BufReader::new(reader).lines();
|
||||
let reply = lines
|
||||
.next_line()
|
||||
.await
|
||||
.map_err(|e| format!("Failed reading daemon registration reply: {e}"))?
|
||||
.ok_or_else(|| "Daemon closed before replying to session registration".to_string())?;
|
||||
|
||||
let envelope: Envelope<DaemonResponse> = serde_json::from_str(&reply)
|
||||
.map_err(|e| format!("Invalid daemon registration response: {e}"))?;
|
||||
match envelope.payload {
|
||||
DaemonResponse::Ack => Ok(()),
|
||||
DaemonResponse::Error { message } => Err(message),
|
||||
other => Err(format!(
|
||||
"Unexpected daemon registration response: {other:?}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn dispatch_session_command(payload: SessionCommand) -> SessionResponse {
|
||||
match payload {
|
||||
SessionCommand::GetDisplayLayout => {
|
||||
match crate::hardware::display_layout::get_display_layout() {
|
||||
Ok(layout) => SessionResponse::DisplayLayout { layout },
|
||||
Err(message) => SessionResponse::Error { message },
|
||||
}
|
||||
}
|
||||
SessionCommand::SetDockMode {
|
||||
attached,
|
||||
scale,
|
||||
layout,
|
||||
} => match DockModePlanner::apply(attached, scale, layout) {
|
||||
Ok(()) => SessionResponse::Ack,
|
||||
Err(message) => SessionResponse::Error { message },
|
||||
},
|
||||
SessionCommand::ApplyDisplayLayout { layout } => {
|
||||
match crate::hardware::display_layout::apply_display_layout(&layout) {
|
||||
Ok(()) => SessionResponse::Ack,
|
||||
Err(message) => SessionResponse::Error { message },
|
||||
}
|
||||
}
|
||||
SessionCommand::SetOrientation { orientation } => {
|
||||
match crate::hardware::display_layout::set_orientation(&orientation) {
|
||||
Ok(()) => SessionResponse::Ack,
|
||||
Err(message) => SessionResponse::Error { message },
|
||||
}
|
||||
}
|
||||
SessionCommand::ShowNotification {
|
||||
title,
|
||||
message,
|
||||
urgent,
|
||||
} => match send_runtime_notification(&title, &message, urgent) {
|
||||
Ok(()) => SessionResponse::Ack,
|
||||
Err(message) => SessionResponse::Error { message },
|
||||
},
|
||||
SessionCommand::OpenEmojiPicker => SessionResponse::Ack,
|
||||
}
|
||||
}
|
||||
|
||||
fn bind_session_listener(path: &Path) -> Result<UnixListener, String> {
|
||||
remove_stale_socket(path);
|
||||
UnixListener::bind(path).map_err(|e| format!("Failed to bind session agent socket: {e}"))
|
||||
}
|
||||
|
||||
fn ensure_user_runtime_dir() -> Result<(), String> {
|
||||
crate::runtime::runtime_dir::ensure_current_user_runtime_dir()
|
||||
}
|
||||
|
||||
fn remove_stale_socket(path: &Path) {
|
||||
if let Ok(metadata) = fs::symlink_metadata(path) {
|
||||
if metadata.file_type().is_socket() {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_session_id() -> String {
|
||||
env::var("XDG_SESSION_ID").unwrap_or_else(|_| "unknown-session".to_string())
|
||||
}
|
||||
|
||||
pub(crate) struct BackendReadiness;
|
||||
|
||||
impl BackendReadiness {
|
||||
async fn wait_for_ready_backend() -> SessionBackend {
|
||||
wait_for_ready_backend().await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn detect_ready_backend_from<F>(hinted: SessionBackend, is_ready: F) -> SessionBackend
|
||||
where
|
||||
F: FnMut(&session::BackendProbe) -> bool,
|
||||
{
|
||||
detect_ready_backend_from(hinted, is_ready)
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_backend() -> SessionBackend {
|
||||
session::detect_backend_from_env()
|
||||
}
|
||||
|
||||
fn detect_ready_backend() -> SessionBackend {
|
||||
detect_ready_backend_from(detect_backend(), backend_is_ready)
|
||||
}
|
||||
|
||||
fn detect_ready_backend_from<F>(hinted: SessionBackend, mut is_ready: F) -> SessionBackend
|
||||
where
|
||||
F: FnMut(&session::BackendProbe) -> bool,
|
||||
{
|
||||
for probe in session::backend_probe_sequence(&hinted) {
|
||||
if is_ready(&probe) {
|
||||
return probe.backend;
|
||||
}
|
||||
}
|
||||
SessionBackend::Unknown
|
||||
}
|
||||
|
||||
fn backend_is_ready(probe: &session::BackendProbe) -> bool {
|
||||
if probe.requires_gui_session && !gui_session_env_ready() {
|
||||
return false;
|
||||
}
|
||||
|
||||
match probe.readiness_runner {
|
||||
session::BackendCommandRunner::Compositor => {
|
||||
compositor::command_succeeds(probe.readiness_program, probe.readiness_args)
|
||||
}
|
||||
session::BackendCommandRunner::Niri => {
|
||||
compositor::niri_command_succeeds(probe.readiness_args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn gui_session_env_ready() -> bool {
|
||||
let has_runtime_dir = env::var_os("XDG_RUNTIME_DIR").is_some();
|
||||
let has_wayland = env::var_os("WAYLAND_DISPLAY").is_some();
|
||||
let has_x11 = env::var_os("DISPLAY").is_some();
|
||||
has_runtime_dir && (has_wayland || has_x11)
|
||||
}
|
||||
|
||||
async fn wait_for_ready_backend() -> SessionBackend {
|
||||
wait_for_ready_backend_with(
|
||||
detect_backend,
|
||||
backend_is_ready,
|
||||
Duration::from_secs(15),
|
||||
Duration::from_millis(500),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn wait_for_ready_backend_with<D, R>(
|
||||
mut detect_hint: D,
|
||||
mut is_ready: R,
|
||||
first_notice_after: Duration,
|
||||
retry_delay: Duration,
|
||||
) -> SessionBackend
|
||||
where
|
||||
D: FnMut() -> SessionBackend,
|
||||
R: FnMut(&session::BackendProbe) -> bool,
|
||||
{
|
||||
let first_notice_at = tokio::time::Instant::now() + first_notice_after;
|
||||
let mut warned_after_first_timeout = false;
|
||||
|
||||
loop {
|
||||
let hinted_backend = detect_hint();
|
||||
let backend = detect_ready_backend_from(hinted_backend, &mut is_ready);
|
||||
if backend != SessionBackend::Unknown {
|
||||
return backend;
|
||||
}
|
||||
|
||||
if !warned_after_first_timeout && tokio::time::Instant::now() >= first_notice_at {
|
||||
log::warn!(
|
||||
"No supported session backend became ready before timeout; continuing to wait"
|
||||
);
|
||||
warned_after_first_timeout = true;
|
||||
}
|
||||
|
||||
tokio::time::sleep(retry_delay).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_dock_mode(
|
||||
attached: bool,
|
||||
scale: f64,
|
||||
layout: Option<DisplayLayout>,
|
||||
) -> Result<(), String> {
|
||||
let base_layout = layout.or_else(|| crate::hardware::display_layout::get_display_layout().ok());
|
||||
|
||||
if let Some(layout) = base_layout
|
||||
.as_ref()
|
||||
.and_then(|layout| dock_layout_from_base(layout, attached, scale))
|
||||
{
|
||||
crate::hardware::display_layout::apply_display_layout(&layout)?;
|
||||
} else {
|
||||
log::warn!(
|
||||
"No saved or current display layout available for dock replay; using degraded mode-less dock fallback"
|
||||
);
|
||||
match detect_ready_backend() {
|
||||
SessionBackend::Gnome => apply_gnome_dock_mode(attached, scale),
|
||||
SessionBackend::Kde => apply_kde_dock_mode(attached),
|
||||
SessionBackend::Niri => apply_niri_dock_mode(attached),
|
||||
SessionBackend::Unknown => Err("Unsupported session backend for dock mode".into()),
|
||||
}?;
|
||||
}
|
||||
|
||||
if let Err(err) = send_dock_mode_notification(attached) {
|
||||
log::warn!("failed to send dock-mode notification: {err}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stacked_logical_height(display: &DisplayInfo) -> i32 {
|
||||
let rotated = display.transform == 90 || display.transform == 270;
|
||||
let physical_height = if rotated {
|
||||
display.width
|
||||
} else {
|
||||
display.height
|
||||
};
|
||||
let scale = display.scale.max(0.1);
|
||||
(physical_height as f64 / scale).ceil() as i32
|
||||
}
|
||||
|
||||
pub(crate) fn dock_layout_from_base(
|
||||
layout: &DisplayLayout,
|
||||
attached: bool,
|
||||
scale: f64,
|
||||
) -> Option<DisplayLayout> {
|
||||
let target_scale = scale.max(0.1);
|
||||
let mut primary = layout
|
||||
.displays
|
||||
.iter()
|
||||
.find(|display| display.connector == PRIMARY_INTERNAL_CONNECTOR)
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
layout
|
||||
.displays
|
||||
.iter()
|
||||
.find(|display| is_internal_connector(&display.connector))
|
||||
.cloned()
|
||||
})?;
|
||||
primary.scale = target_scale;
|
||||
primary.x = 0;
|
||||
primary.y = 0;
|
||||
primary.primary = true;
|
||||
|
||||
if attached {
|
||||
return Some(DisplayLayout {
|
||||
displays: vec![primary],
|
||||
});
|
||||
}
|
||||
|
||||
let mut secondary = layout
|
||||
.displays
|
||||
.iter()
|
||||
.find(|display| display.connector == SECONDARY_INTERNAL_CONNECTOR)
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
if primary.connector == SECONDARY_INTERNAL_CONNECTOR {
|
||||
None
|
||||
} else {
|
||||
let mut cloned = primary.clone();
|
||||
cloned.connector = SECONDARY_INTERNAL_CONNECTOR.to_string();
|
||||
cloned.primary = false;
|
||||
Some(cloned)
|
||||
}
|
||||
})?;
|
||||
secondary.scale = target_scale;
|
||||
secondary.x = 0;
|
||||
secondary.y = stacked_logical_height(&primary);
|
||||
secondary.primary = false;
|
||||
|
||||
Some(DisplayLayout {
|
||||
displays: vec![primary, secondary],
|
||||
})
|
||||
}
|
||||
|
||||
fn gnome_dock_mode_args(attached: bool, scale: f64) -> Vec<String> {
|
||||
let scale_str = format!("{scale:.6}");
|
||||
if attached {
|
||||
vec![
|
||||
"set".to_string(),
|
||||
"--logical-monitor".to_string(),
|
||||
"--primary".to_string(),
|
||||
"--scale".to_string(),
|
||||
scale_str,
|
||||
"--monitor".to_string(),
|
||||
PRIMARY_INTERNAL_CONNECTOR.to_string(),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
"set".to_string(),
|
||||
"--logical-monitor".to_string(),
|
||||
"--primary".to_string(),
|
||||
"--scale".to_string(),
|
||||
scale_str.clone(),
|
||||
"--monitor".to_string(),
|
||||
PRIMARY_INTERNAL_CONNECTOR.to_string(),
|
||||
"--logical-monitor".to_string(),
|
||||
"--scale".to_string(),
|
||||
scale_str,
|
||||
"--monitor".to_string(),
|
||||
SECONDARY_INTERNAL_CONNECTOR.to_string(),
|
||||
"--below".to_string(),
|
||||
PRIMARY_INTERNAL_CONNECTOR.to_string(),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_gnome_dock_mode(attached: bool, scale: f64) -> Result<(), String> {
|
||||
run_command("gdctl", &gnome_dock_mode_args(attached, scale))
|
||||
}
|
||||
|
||||
fn kde_dock_mode_args(attached: bool, primary_logical_height: i64) -> Vec<String> {
|
||||
if attached {
|
||||
vec![
|
||||
format!("output.{PRIMARY_INTERNAL_CONNECTOR}.enable"),
|
||||
format!("output.{SECONDARY_INTERNAL_CONNECTOR}.disable"),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
format!("output.{PRIMARY_INTERNAL_CONNECTOR}.enable"),
|
||||
format!("output.{SECONDARY_INTERNAL_CONNECTOR}.enable"),
|
||||
format!("output.{PRIMARY_INTERNAL_CONNECTOR}.position.0,0"),
|
||||
format!("output.{SECONDARY_INTERNAL_CONNECTOR}.position.0,{primary_logical_height}"),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_kde_dock_mode(attached: bool) -> Result<(), String> {
|
||||
ensure_gui_session_env("KDE display control")?;
|
||||
let primary_logical_height = if attached {
|
||||
0
|
||||
} else {
|
||||
kde_output_logical_size(PRIMARY_INTERNAL_CONNECTOR)?.1
|
||||
};
|
||||
run_command(
|
||||
"kscreen-doctor",
|
||||
&kde_dock_mode_args(attached, primary_logical_height),
|
||||
)
|
||||
}
|
||||
|
||||
fn string_args(args: &[&str]) -> Vec<String> {
|
||||
args.iter().map(|arg| (*arg).to_string()).collect()
|
||||
}
|
||||
|
||||
fn niri_dock_mode_commands(attached: bool, primary_logical_height: i64) -> Vec<Vec<String>> {
|
||||
if attached {
|
||||
return vec![
|
||||
string_args(&["msg", "output", PRIMARY_INTERNAL_CONNECTOR, "on"]),
|
||||
string_args(&["msg", "output", SECONDARY_INTERNAL_CONNECTOR, "off"]),
|
||||
];
|
||||
}
|
||||
|
||||
vec![
|
||||
string_args(&["msg", "output", PRIMARY_INTERNAL_CONNECTOR, "on"]),
|
||||
string_args(&["msg", "output", SECONDARY_INTERNAL_CONNECTOR, "on"]),
|
||||
string_args(&[
|
||||
"msg",
|
||||
"output",
|
||||
PRIMARY_INTERNAL_CONNECTOR,
|
||||
"position",
|
||||
"set",
|
||||
"0",
|
||||
"0",
|
||||
]),
|
||||
vec![
|
||||
"msg".to_string(),
|
||||
"output".to_string(),
|
||||
SECONDARY_INTERNAL_CONNECTOR.to_string(),
|
||||
"position".to_string(),
|
||||
"set".to_string(),
|
||||
"0".to_string(),
|
||||
primary_logical_height.to_string(),
|
||||
],
|
||||
]
|
||||
}
|
||||
|
||||
fn apply_niri_dock_mode(attached: bool) -> Result<(), String> {
|
||||
let primary_logical_height = if attached {
|
||||
0
|
||||
} else {
|
||||
niri_output_logical_size(PRIMARY_INTERNAL_CONNECTOR)?.1
|
||||
};
|
||||
for args in niri_dock_mode_commands(attached, primary_logical_height) {
|
||||
run_niri_command_args(&args)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn kde_output_logical_size(name: &str) -> Result<(i64, i64), String> {
|
||||
ensure_gui_session_env("KDE display query")?;
|
||||
compositor::kde_output_logical_size_from_value(&compositor::kscreen_json()?, name)
|
||||
}
|
||||
|
||||
fn niri_output_logical_size(name: &str) -> Result<(i64, i64), String> {
|
||||
compositor::niri_output_logical_size_from_value(&compositor::niri_outputs_json()?, name)
|
||||
}
|
||||
|
||||
fn run_command<S: AsRef<str>>(program: &str, args: &[S]) -> Result<(), String> {
|
||||
compositor::run_command(program, args)
|
||||
}
|
||||
|
||||
fn ensure_gui_session_env(action: &str) -> Result<(), String> {
|
||||
if gui_session_env_ready() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"{action} requires XDG_RUNTIME_DIR and either WAYLAND_DISPLAY or DISPLAY"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn run_niri_command(args: &[&str]) -> Result<(), String> {
|
||||
compositor::run_niri_command(args)
|
||||
}
|
||||
|
||||
fn run_niri_command_args(args: &[String]) -> Result<(), String> {
|
||||
let borrowed_args: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
run_niri_command(&borrowed_args)
|
||||
}
|
||||
|
||||
fn dock_mode_notification_message(attached: bool) -> &'static str {
|
||||
if attached {
|
||||
"Keyboard attached: bottom screen disabled"
|
||||
} else {
|
||||
"Keyboard detached: bottom screen enabled"
|
||||
}
|
||||
}
|
||||
|
||||
fn send_dock_mode_notification(attached: bool) -> Result<(), String> {
|
||||
send_runtime_notification(
|
||||
"Zenbook Duo Control",
|
||||
dock_mode_notification_message(attached),
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn send_runtime_notification(
|
||||
title: &str,
|
||||
message: &str,
|
||||
urgent: bool,
|
||||
) -> Result<(), String> {
|
||||
let runtime_dir = env::var("XDG_RUNTIME_DIR")
|
||||
.map_err(|_| "XDG_RUNTIME_DIR is not set for runtime notifications".to_string())?;
|
||||
let bus_address = env::var("DBUS_SESSION_BUS_ADDRESS")
|
||||
.unwrap_or_else(|_| format!("unix:path={runtime_dir}/bus"));
|
||||
let urgency = if urgent { "critical" } else { "normal" };
|
||||
|
||||
Command::new("notify-send")
|
||||
.args([
|
||||
"-a",
|
||||
"Zenbook Duo Control",
|
||||
"-u",
|
||||
urgency,
|
||||
"-i",
|
||||
"input-keyboard",
|
||||
title,
|
||||
message,
|
||||
])
|
||||
.env("XDG_RUNTIME_DIR", runtime_dir)
|
||||
.env("DBUS_SESSION_BUS_ADDRESS", bus_address)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("Failed to launch runtime notification: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::{DisplayMode, RefreshPolicy};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
static NEXT_ID: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[test]
|
||||
fn dock_mode_notification_mentions_bottom_screen_enabled_on_detach() {
|
||||
assert_eq!(
|
||||
dock_mode_notification_message(false),
|
||||
"Keyboard detached: bottom screen enabled"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_mode_notification_mentions_bottom_screen_disabled_on_attach() {
|
||||
assert_eq!(
|
||||
dock_mode_notification_message(true),
|
||||
"Keyboard attached: bottom screen disabled"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_watcher_restart_delay_is_short_and_nonzero() {
|
||||
let delay = rotation::rotation_watcher_restart_delay();
|
||||
assert!(delay >= Duration::from_secs(1));
|
||||
assert!(delay <= Duration::from_secs(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accelerometer_claim_timeouts_use_bounded_backoff() {
|
||||
assert_eq!(
|
||||
rotation::rotation_watcher_accelerometer_timeout_delay(1),
|
||||
Duration::from_secs(30)
|
||||
);
|
||||
assert_eq!(
|
||||
rotation::rotation_watcher_accelerometer_timeout_delay(2),
|
||||
Duration::from_secs(60)
|
||||
);
|
||||
assert_eq!(
|
||||
rotation::rotation_watcher_accelerometer_timeout_delay(5),
|
||||
Duration::from_secs(480)
|
||||
);
|
||||
assert_eq!(
|
||||
rotation::rotation_watcher_accelerometer_timeout_delay(20),
|
||||
Duration::from_secs(480)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_monitor_sensor_accelerometer_claim_timeout() {
|
||||
assert!(rotation::monitor_sensor_accelerometer_claim_timeout(
|
||||
"** (monitor-sensor:3388909): WARNING **: Failed to claim accelerometer: Timeout was reached"
|
||||
));
|
||||
assert!(!rotation::monitor_sensor_accelerometer_claim_timeout(
|
||||
"** (monitor-sensor:3388909): WARNING **: Failed to claim light sensor: Timeout was reached"
|
||||
));
|
||||
assert!(!rotation::monitor_sensor_accelerometer_claim_timeout(
|
||||
"Accelerometer orientation changed: normal"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_sensor_edge_orientation_as_display_transform() {
|
||||
assert_eq!(
|
||||
rotation::parse_rotation_line(" Accelerometer orientation changed: left-up"),
|
||||
Some(Orientation::Left)
|
||||
);
|
||||
assert_eq!(
|
||||
rotation::parse_rotation_line(" Accelerometer orientation changed: right-up"),
|
||||
Some(Orientation::Right)
|
||||
);
|
||||
assert_eq!(
|
||||
rotation::parse_rotation_line(" Accelerometer orientation changed: bottom-up"),
|
||||
Some(Orientation::Inverted)
|
||||
);
|
||||
assert_eq!(
|
||||
rotation::parse_rotation_line(" Accelerometer orientation changed: normal"),
|
||||
Some(Orientation::Normal)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverted_sensor_rotation_swaps_only_left_and_right() {
|
||||
assert_eq!(
|
||||
rotation::display_orientation_from_sensor_value("left-up", true),
|
||||
Some(Orientation::Right)
|
||||
);
|
||||
assert_eq!(
|
||||
rotation::display_orientation_from_sensor_value("right-up", true),
|
||||
Some(Orientation::Left)
|
||||
);
|
||||
assert_eq!(
|
||||
rotation::display_orientation_from_sensor_value("bottom-up", true),
|
||||
Some(Orientation::Inverted)
|
||||
);
|
||||
assert_eq!(
|
||||
rotation::display_orientation_from_sensor_value("normal", true),
|
||||
Some(Orientation::Normal)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bind_session_listener_creates_socket_before_registration() {
|
||||
let socket_path = unique_test_socket_path("session-listener");
|
||||
let listener = bind_session_listener(&socket_path).expect("bind test session listener");
|
||||
|
||||
assert!(
|
||||
socket_path.exists(),
|
||||
"listener should create the socket path"
|
||||
);
|
||||
|
||||
drop(listener);
|
||||
let _ = fs::remove_file(&socket_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_mode_planner_interface_reuses_existing_refresh_modes_for_attached_replay() {
|
||||
let layout = dual_internal_layout(120.0);
|
||||
let planned =
|
||||
DockModePlanner::layout_from_base(&layout, false, 1.5).expect("planned layout");
|
||||
|
||||
assert_eq!(planned.displays.len(), 2);
|
||||
assert_eq!(planned.displays[0].scale, 1.5);
|
||||
assert_eq!(planned.displays[1].scale, 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_readiness_interface_prefers_ready_hint() {
|
||||
let backend = BackendReadiness::detect_ready_backend_from(SessionBackend::Gnome, |_| true);
|
||||
|
||||
assert_eq!(backend, SessionBackend::Gnome);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_layout_reuses_existing_refresh_modes_for_attached_replay() {
|
||||
let layout = dual_internal_layout(120.0);
|
||||
let docked = dock_layout_from_base(&layout, true, 1.5).expect("dock layout");
|
||||
|
||||
assert_eq!(docked.displays.len(), 1);
|
||||
assert_eq!(docked.displays[0].connector, PRIMARY_INTERNAL_CONNECTOR);
|
||||
assert_eq!(docked.displays[0].current_mode.refresh_rate, 120.0);
|
||||
assert_eq!(docked.displays[0].scale, 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_layout_clones_primary_mode_for_missing_secondary_panel() {
|
||||
let layout = DisplayLayout {
|
||||
displays: vec![display(PRIMARY_INTERNAL_CONNECTOR, 120.0, 0, 0, true)],
|
||||
};
|
||||
let docked = dock_layout_from_base(&layout, false, 1.25).expect("dock layout");
|
||||
|
||||
assert_eq!(docked.displays.len(), 2);
|
||||
assert_eq!(docked.displays[1].connector, SECONDARY_INTERNAL_CONNECTOR);
|
||||
assert_eq!(docked.displays[1].current_mode.refresh_rate, 120.0);
|
||||
assert_eq!(docked.displays[1].scale, 1.25);
|
||||
assert_eq!(docked.displays[1].y, 960);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degraded_gnome_dock_mode_arguments_are_mode_less_and_only_used_without_layout_base() {
|
||||
assert_eq!(
|
||||
gnome_dock_mode_args(true, 1.66),
|
||||
string_args(&[
|
||||
"set",
|
||||
"--logical-monitor",
|
||||
"--primary",
|
||||
"--scale",
|
||||
"1.660000",
|
||||
"--monitor",
|
||||
PRIMARY_INTERNAL_CONNECTOR,
|
||||
])
|
||||
);
|
||||
let detached_args = gnome_dock_mode_args(false, 1.66);
|
||||
assert_eq!(
|
||||
detached_args,
|
||||
string_args(&[
|
||||
"set",
|
||||
"--logical-monitor",
|
||||
"--primary",
|
||||
"--scale",
|
||||
"1.660000",
|
||||
"--monitor",
|
||||
PRIMARY_INTERNAL_CONNECTOR,
|
||||
"--logical-monitor",
|
||||
"--scale",
|
||||
"1.660000",
|
||||
"--monitor",
|
||||
SECONDARY_INTERNAL_CONNECTOR,
|
||||
"--below",
|
||||
PRIMARY_INTERNAL_CONNECTOR,
|
||||
])
|
||||
);
|
||||
assert!(!detached_args.iter().any(|arg| arg == "--mode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degraded_kde_dock_mode_arguments_are_mode_less_and_only_used_without_layout_base() {
|
||||
assert_eq!(
|
||||
kde_dock_mode_args(true, 1200),
|
||||
vec![
|
||||
format!("output.{PRIMARY_INTERNAL_CONNECTOR}.enable"),
|
||||
format!("output.{SECONDARY_INTERNAL_CONNECTOR}.disable"),
|
||||
]
|
||||
);
|
||||
let detached_args = kde_dock_mode_args(false, 1200);
|
||||
assert_eq!(
|
||||
detached_args,
|
||||
vec![
|
||||
format!("output.{PRIMARY_INTERNAL_CONNECTOR}.enable"),
|
||||
format!("output.{SECONDARY_INTERNAL_CONNECTOR}.enable"),
|
||||
format!("output.{PRIMARY_INTERNAL_CONNECTOR}.position.0,0"),
|
||||
format!("output.{SECONDARY_INTERNAL_CONNECTOR}.position.0,1200"),
|
||||
]
|
||||
);
|
||||
assert!(!detached_args.iter().any(|arg| arg.contains(".mode.")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degraded_niri_dock_mode_commands_are_mode_less_and_only_used_without_layout_base() {
|
||||
assert_eq!(
|
||||
niri_dock_mode_commands(true, 1200),
|
||||
vec![
|
||||
string_args(&["msg", "output", PRIMARY_INTERNAL_CONNECTOR, "on"]),
|
||||
string_args(&["msg", "output", SECONDARY_INTERNAL_CONNECTOR, "off"]),
|
||||
]
|
||||
);
|
||||
let detached_commands = niri_dock_mode_commands(false, 1200);
|
||||
assert_eq!(
|
||||
detached_commands,
|
||||
vec![
|
||||
string_args(&["msg", "output", PRIMARY_INTERNAL_CONNECTOR, "on"]),
|
||||
string_args(&["msg", "output", SECONDARY_INTERNAL_CONNECTOR, "on"]),
|
||||
string_args(&[
|
||||
"msg",
|
||||
"output",
|
||||
PRIMARY_INTERNAL_CONNECTOR,
|
||||
"position",
|
||||
"set",
|
||||
"0",
|
||||
"0",
|
||||
]),
|
||||
string_args(&[
|
||||
"msg",
|
||||
"output",
|
||||
SECONDARY_INTERNAL_CONNECTOR,
|
||||
"position",
|
||||
"set",
|
||||
"0",
|
||||
"1200",
|
||||
]),
|
||||
]
|
||||
);
|
||||
assert!(!detached_commands.iter().flatten().any(|arg| arg == "mode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_ready_backend_prefers_hint_when_ready() {
|
||||
let ready = detect_ready_backend_from(SessionBackend::Kde, |probe| {
|
||||
probe.backend == SessionBackend::Kde
|
||||
});
|
||||
assert_eq!(ready, SessionBackend::Kde);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_readiness_metadata_preserves_existing_commands() {
|
||||
let gnome = session::backend_probe(&SessionBackend::Gnome).expect("gnome probe");
|
||||
assert_eq!(gnome.readiness_program, "gdctl");
|
||||
assert_eq!(gnome.readiness_args, ["show"]);
|
||||
assert!(gnome.requires_gui_session);
|
||||
|
||||
let kde = session::backend_probe(&SessionBackend::Kde).expect("kde probe");
|
||||
assert_eq!(kde.readiness_program, "kscreen-doctor");
|
||||
assert_eq!(kde.readiness_args, ["-j"]);
|
||||
assert!(kde.requires_gui_session);
|
||||
|
||||
let niri = session::backend_probe(&SessionBackend::Niri).expect("niri probe");
|
||||
assert_eq!(niri.readiness_program, "niri");
|
||||
assert_eq!(niri.readiness_args, ["msg", "--json", "outputs"]);
|
||||
assert!(!niri.requires_gui_session);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_ready_backend_falls_through_to_other_ready_backend() {
|
||||
let ready = detect_ready_backend_from(SessionBackend::Unknown, |probe| {
|
||||
probe.backend == SessionBackend::Niri
|
||||
});
|
||||
assert_eq!(ready, SessionBackend::Niri);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_ready_backend_uses_hinted_order_then_fallback_metadata() {
|
||||
let mut seen = Vec::new();
|
||||
let ready = detect_ready_backend_from(SessionBackend::Kde, |probe| {
|
||||
seen.push(probe.backend);
|
||||
probe.backend == SessionBackend::Gnome
|
||||
});
|
||||
|
||||
assert_eq!(ready, SessionBackend::Gnome);
|
||||
assert_eq!(
|
||||
seen,
|
||||
vec![
|
||||
SessionBackend::Kde,
|
||||
SessionBackend::Niri,
|
||||
SessionBackend::Gnome,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_ready_backend_returns_unknown_when_nothing_is_ready() {
|
||||
let ready = detect_ready_backend_from(SessionBackend::Gnome, |_| false);
|
||||
assert_eq!(ready, SessionBackend::Unknown);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_for_ready_backend_keeps_retrying_after_initial_timeout() {
|
||||
let mut attempts = 0;
|
||||
let backend = wait_for_ready_backend_with(
|
||||
|| SessionBackend::Kde,
|
||||
|probe| {
|
||||
attempts += 1;
|
||||
probe.backend == SessionBackend::Kde && attempts >= 3
|
||||
},
|
||||
Duration::from_millis(1),
|
||||
Duration::from_millis(1),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(backend, SessionBackend::Kde);
|
||||
assert!(attempts >= 3);
|
||||
}
|
||||
|
||||
fn dual_internal_layout(refresh_rate: f64) -> DisplayLayout {
|
||||
DisplayLayout {
|
||||
displays: vec![
|
||||
display(PRIMARY_INTERNAL_CONNECTOR, refresh_rate, 0, 0, true),
|
||||
display(SECONDARY_INTERNAL_CONNECTOR, refresh_rate, 0, 1200, false),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn display(connector: &str, refresh_rate: f64, x: i32, y: i32, primary: bool) -> DisplayInfo {
|
||||
let mode = DisplayMode {
|
||||
mode_id: format!("1920x1200@{refresh_rate}"),
|
||||
backend_mode_id: None,
|
||||
width: 1920,
|
||||
height: 1200,
|
||||
refresh_rate,
|
||||
};
|
||||
|
||||
DisplayInfo {
|
||||
connector: connector.to_string(),
|
||||
width: mode.width,
|
||||
height: mode.height,
|
||||
refresh_rate: mode.refresh_rate,
|
||||
scale: 1.0,
|
||||
x,
|
||||
y,
|
||||
transform: 0,
|
||||
primary,
|
||||
current_mode: mode.clone(),
|
||||
available_modes: vec![mode],
|
||||
refresh_policy: RefreshPolicy::Fixed,
|
||||
supports_dynamic_refresh: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn unique_test_socket_path(label: &str) -> PathBuf {
|
||||
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock before unix epoch")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("zenbook-duo-{label}-{nanos}-{id}.sock"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) struct BrightnessSync;
|
||||
|
||||
impl BrightnessSync {
|
||||
pub(crate) async fn watch() -> Result<(), String> {
|
||||
watch_brightness_sync().await
|
||||
}
|
||||
}
|
||||
|
||||
async fn watch_brightness_sync() -> Result<(), String> {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
|
||||
let mut last_seen: Option<u32> = None;
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
if !brightness_sync_enabled() || keyboard_attached_from_runtime() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let level = crate::hardware::sysfs::read_display_brightness();
|
||||
if last_seen == Some(level) {
|
||||
continue;
|
||||
}
|
||||
|
||||
sync_secondary_brightness(level)?;
|
||||
last_seen = Some(level);
|
||||
}
|
||||
}
|
||||
|
||||
fn brightness_sync_enabled() -> bool {
|
||||
crate::commands::settings::load_settings_local().sync_brightness
|
||||
}
|
||||
|
||||
fn keyboard_attached_from_runtime() -> bool {
|
||||
let Ok(raw) = fs::read_to_string(paths::state_file_path()) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(state) = serde_json::from_str::<RuntimeState>(&raw) else {
|
||||
return false;
|
||||
};
|
||||
state.status.keyboard_attached
|
||||
}
|
||||
|
||||
fn sync_secondary_brightness(level: u32) -> Result<(), String> {
|
||||
let Some(secondary) = crate::hardware::sysfs::secondary_backlight_dir() else {
|
||||
return Ok(());
|
||||
};
|
||||
let secondary_path = secondary.join("brightness");
|
||||
|
||||
if fs::write(&secondary_path, level.to_string()).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let secondary_path_string = secondary_path.to_string_lossy().into_owned();
|
||||
let mut child = Command::new("sudo")
|
||||
.args(["/usr/bin/tee", secondary_path_string.as_str()])
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to run sudo tee for brightness sync: {e}"))?;
|
||||
|
||||
if let Some(stdin) = child.stdin.as_mut() {
|
||||
stdin
|
||||
.write_all(level.to_string().as_bytes())
|
||||
.and_then(|_| stdin.write_all(b"\n"))
|
||||
.map_err(|e| format!("Failed to write brightness sync value: {e}"))?;
|
||||
}
|
||||
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|e| format!("Failed waiting for brightness sync helper: {e}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"Brightness sync failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) struct RotationWatcherSupervisor;
|
||||
|
||||
impl RotationWatcherSupervisor {
|
||||
pub(crate) async fn supervise() {
|
||||
supervise_rotation_watcher().await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn supervise_rotation_watcher() {
|
||||
let mut notified_failure = false;
|
||||
let mut consecutive_accelerometer_timeouts = 0;
|
||||
|
||||
loop {
|
||||
let restart_delay = match watch_rotation().await {
|
||||
Ok(RotationWatchExit::Clean) => {
|
||||
consecutive_accelerometer_timeouts = 0;
|
||||
runtime_log_warn(format!(
|
||||
"rotation watcher exited cleanly; restarting in {}s",
|
||||
rotation_watcher_restart_delay().as_secs()
|
||||
));
|
||||
rotation_watcher_restart_delay()
|
||||
}
|
||||
Ok(RotationWatchExit::AccelerometerClaimTimeout) => {
|
||||
consecutive_accelerometer_timeouts += 1;
|
||||
let delay = rotation_watcher_accelerometer_timeout_delay(
|
||||
consecutive_accelerometer_timeouts,
|
||||
);
|
||||
runtime_log_warn(format!(
|
||||
"monitor-sensor could not claim accelerometer; retrying in {}s",
|
||||
delay.as_secs()
|
||||
));
|
||||
delay
|
||||
}
|
||||
Err(err) => {
|
||||
consecutive_accelerometer_timeouts = 0;
|
||||
runtime_log_warn(format!(
|
||||
"rotation watcher failed: {err}; restarting in {}s",
|
||||
rotation_watcher_restart_delay().as_secs()
|
||||
));
|
||||
if !notified_failure {
|
||||
let _ = send_runtime_notification(
|
||||
"Zenbook Duo Runtime Error",
|
||||
&format!("Rotation watcher failed and will restart: {err}"),
|
||||
true,
|
||||
);
|
||||
notified_failure = true;
|
||||
}
|
||||
rotation_watcher_restart_delay()
|
||||
}
|
||||
};
|
||||
|
||||
tokio::time::sleep(restart_delay).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum RotationWatchExit {
|
||||
Clean,
|
||||
AccelerometerClaimTimeout,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct MonitorSensorStderrSummary {
|
||||
accelerometer_claim_timeout: bool,
|
||||
}
|
||||
|
||||
pub(super) fn rotation_watcher_restart_delay() -> Duration {
|
||||
Duration::from_secs(2)
|
||||
}
|
||||
|
||||
pub(super) fn rotation_watcher_accelerometer_timeout_delay(consecutive_timeouts: u32) -> Duration {
|
||||
let exponent = consecutive_timeouts.saturating_sub(1).min(4);
|
||||
Duration::from_secs(30 * 2_u64.pow(exponent))
|
||||
}
|
||||
|
||||
async fn watch_rotation() -> Result<RotationWatchExit, String> {
|
||||
let mut child = TokioCommand::new("monitor-sensor")
|
||||
.arg("--accel")
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start monitor-sensor: {e}"))?;
|
||||
|
||||
runtime_log_info("rotation watcher started monitor-sensor --accel");
|
||||
|
||||
let stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.ok_or_else(|| "monitor-sensor stderr unavailable".to_string())?;
|
||||
let stderr_task = tokio::spawn(log_monitor_sensor_stderr(stderr));
|
||||
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| "monitor-sensor stdout unavailable".to_string())?;
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
|
||||
while let Some(line) = lines
|
||||
.next_line()
|
||||
.await
|
||||
.map_err(|e| format!("Failed reading monitor-sensor output: {e}"))?
|
||||
{
|
||||
if let Some(sensor_orientation) = sensor_orientation_value(&line) {
|
||||
let invert_sensor_rotation =
|
||||
crate::commands::settings::load_settings_local().invert_sensor_rotation;
|
||||
match display_orientation_from_sensor_value(sensor_orientation, invert_sensor_rotation)
|
||||
{
|
||||
Some(orientation) => {
|
||||
runtime_log_info(format!(
|
||||
"monitor-sensor orientation changed: sensor={sensor_orientation} mapped_display={orientation:?} invert_sensor_rotation={invert_sensor_rotation}"
|
||||
));
|
||||
if let Err(err) = crate::hardware::display_layout::set_orientation(&orientation)
|
||||
{
|
||||
runtime_log_warn(format!(
|
||||
"failed to apply accelerometer orientation: sensor={sensor_orientation} mapped_display={orientation:?} invert_sensor_rotation={invert_sensor_rotation}: {err}"
|
||||
));
|
||||
} else {
|
||||
runtime_log_info(format!(
|
||||
"applied accelerometer orientation: sensor={sensor_orientation} mapped_display={orientation:?} invert_sensor_rotation={invert_sensor_rotation}"
|
||||
));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
runtime_log_warn(format!(
|
||||
"monitor-sensor reported unsupported accelerometer orientation: {sensor_orientation}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.await
|
||||
.map_err(|e| format!("Failed waiting for monitor-sensor: {e}"))?;
|
||||
let stderr_summary = match stderr_task.await {
|
||||
Ok(summary) => summary,
|
||||
Err(err) => {
|
||||
runtime_log_warn(format!("monitor-sensor stderr logger task failed: {err}"));
|
||||
MonitorSensorStderrSummary::default()
|
||||
}
|
||||
};
|
||||
|
||||
if status.success() {
|
||||
if stderr_summary.accelerometer_claim_timeout {
|
||||
Ok(RotationWatchExit::AccelerometerClaimTimeout)
|
||||
} else {
|
||||
Ok(RotationWatchExit::Clean)
|
||||
}
|
||||
} else {
|
||||
Err(format!("monitor-sensor exited with status {status}"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn log_monitor_sensor_stderr(
|
||||
stderr: tokio::process::ChildStderr,
|
||||
) -> MonitorSensorStderrSummary {
|
||||
let mut lines = BufReader::new(stderr).lines();
|
||||
let mut summary = MonitorSensorStderrSummary::default();
|
||||
loop {
|
||||
match lines.next_line().await {
|
||||
Ok(Some(line)) => {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if monitor_sensor_accelerometer_claim_timeout(line) {
|
||||
summary.accelerometer_claim_timeout = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
runtime_log_warn(format!("monitor-sensor stderr: {line}"));
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(err) => {
|
||||
runtime_log_warn(format!("failed reading monitor-sensor stderr: {err}"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
summary
|
||||
}
|
||||
|
||||
pub(super) fn monitor_sensor_accelerometer_claim_timeout(line: &str) -> bool {
|
||||
line.contains("Failed to claim accelerometer") && line.contains("Timeout was reached")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn parse_rotation_line(line: &str) -> Option<Orientation> {
|
||||
sensor_orientation_value(line)
|
||||
.and_then(|value| display_orientation_from_sensor_value(value, false))
|
||||
}
|
||||
|
||||
fn sensor_orientation_value(line: &str) -> Option<&str> {
|
||||
line.split("Accelerometer orientation changed:")
|
||||
.nth(1)
|
||||
.map(str::trim)
|
||||
}
|
||||
|
||||
pub(super) fn display_orientation_from_sensor_value(
|
||||
value: &str,
|
||||
invert_sensor_rotation: bool,
|
||||
) -> Option<Orientation> {
|
||||
match (value, invert_sensor_rotation) {
|
||||
("left-up", false) => Some(Orientation::Left),
|
||||
("left-up", true) => Some(Orientation::Right),
|
||||
("right-up", false) => Some(Orientation::Right),
|
||||
("right-up", true) => Some(Orientation::Left),
|
||||
("bottom-up", _) => Some(Orientation::Inverted),
|
||||
("normal", _) => Some(Orientation::Normal),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_log_info(message: impl AsRef<str>) {
|
||||
let text = message.as_ref();
|
||||
let _ = crate::runtime::logger::append_line(format!("session-agent: {text}"));
|
||||
log::info!("{text}");
|
||||
}
|
||||
|
||||
fn runtime_log_warn(message: impl AsRef<str>) {
|
||||
let text = message.as_ref();
|
||||
let _ = crate::runtime::logger::append_line(format!("session-agent: {text}"));
|
||||
log::warn!("{text}");
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
use crate::models::DisplayLayout;
|
||||
|
||||
/// In-process Interface for planning and applying dock display modes.
|
||||
///
|
||||
/// The planner is intentionally not a wire or OS Adapter: it converts saved or
|
||||
/// current layouts into desired display outcomes, then delegates compositor
|
||||
/// execution back to the session-agent display helpers.
|
||||
pub(crate) struct DockModePlanner;
|
||||
|
||||
impl DockModePlanner {
|
||||
pub(crate) fn apply(
|
||||
attached: bool,
|
||||
scale: f64,
|
||||
layout: Option<DisplayLayout>,
|
||||
) -> Result<(), String> {
|
||||
super::session_agent::apply_dock_mode(attached, scale, layout)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn layout_from_base(
|
||||
layout: &DisplayLayout,
|
||||
attached: bool,
|
||||
scale: f64,
|
||||
) -> Option<DisplayLayout> {
|
||||
super::session_agent::dock_layout_from_base(layout, attached, scale)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
|
||||
use crate::ipc::protocol::{Envelope, SessionCommand};
|
||||
use crate::runtime::session_agent;
|
||||
|
||||
/// Session-agent IPC Interface.
|
||||
///
|
||||
/// Owns the wire-level command loop and response encoding while the
|
||||
/// session-agent Module keeps command behavior behind `dispatch_session_command`.
|
||||
pub(crate) async fn handle_session_command(stream: UnixStream) -> Result<(), String> {
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut lines = BufReader::new(reader).lines();
|
||||
|
||||
while let Some(line) = lines
|
||||
.next_line()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read session command: {e}"))?
|
||||
{
|
||||
let envelope: Envelope<SessionCommand> = serde_json::from_str(&line)
|
||||
.map_err(|e| format!("Invalid session command JSON: {e}"))?;
|
||||
let response = session_agent::dispatch_session_command(envelope.payload);
|
||||
let line = serde_json::to_string(&Envelope::new(response))
|
||||
.map_err(|e| format!("Failed to encode session response: {e}"))?;
|
||||
writer
|
||||
.write_all(line.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to write session response: {e}"))?;
|
||||
writer
|
||||
.write_all(b"\n")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to terminate session response: {e}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use crate::runtime::session_agent::{
|
||||
brightness_sync::BrightnessSync, rotation::RotationWatcherSupervisor, send_runtime_notification,
|
||||
};
|
||||
|
||||
/// Supervises session-agent watcher lifecycles.
|
||||
///
|
||||
/// The concrete watchers still own their OS-specific implementations; this
|
||||
/// Module owns when and how they are spawned and how failures are surfaced.
|
||||
pub(crate) fn start_all() {
|
||||
tokio::spawn(RotationWatcherSupervisor::supervise());
|
||||
tokio::spawn(async {
|
||||
if let Err(err) = BrightnessSync::watch().await {
|
||||
log::warn!("session-agent brightness watcher failed: {err}");
|
||||
let _ = send_runtime_notification(
|
||||
"Zenbook Duo Runtime Error",
|
||||
&format!("Brightness sync watcher failed: {err}"),
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
|
||||
use crate::ipc::protocol::SessionBackend;
|
||||
use crate::models::{DuoSettings, DuoStatus, HardwareEvent};
|
||||
use crate::runtime::paths;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeState {
|
||||
pub status: DuoStatus,
|
||||
pub settings: DuoSettings,
|
||||
pub session_agent: SessionAgentState,
|
||||
#[serde(default)]
|
||||
pub usb_media_remap_reconcile: UsbMediaRemapReconcileState,
|
||||
#[serde(default)]
|
||||
pub last_runtime_notification: Option<RuntimeNotificationState>,
|
||||
#[serde(default)]
|
||||
pub lid_closed: bool,
|
||||
pub remembered_wifi_enabled: Option<bool>,
|
||||
pub remembered_bluetooth_enabled: Option<bool>,
|
||||
pub last_updated: DateTime<Utc>,
|
||||
pub recent_events: Vec<HardwareEvent>,
|
||||
}
|
||||
|
||||
impl Default for RuntimeState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
status: DuoStatus::default(),
|
||||
settings: DuoSettings::default(),
|
||||
session_agent: SessionAgentState::default(),
|
||||
usb_media_remap_reconcile: UsbMediaRemapReconcileState::default(),
|
||||
last_runtime_notification: None,
|
||||
lid_closed: false,
|
||||
remembered_wifi_enabled: None,
|
||||
remembered_bluetooth_enabled: None,
|
||||
last_updated: Utc::now(),
|
||||
recent_events: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionAgentState {
|
||||
pub connected: bool,
|
||||
pub session_id: Option<String>,
|
||||
pub backend: Option<SessionBackend>,
|
||||
pub socket_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsbMediaRemapReconcileState {
|
||||
pub last_started_at: Option<DateTime<Utc>>,
|
||||
pub last_start_log_at: Option<DateTime<Utc>>,
|
||||
pub last_backoff_log_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeNotificationState {
|
||||
pub key: String,
|
||||
pub emitted_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub const MAX_RECENT_EVENTS: usize = 500;
|
||||
|
||||
impl RuntimeState {
|
||||
pub fn touch(&mut self) {
|
||||
self.last_updated = Utc::now();
|
||||
}
|
||||
|
||||
pub fn push_recent_event(&mut self, event: HardwareEvent) {
|
||||
self.recent_events.push(event);
|
||||
self.trim_recent_events();
|
||||
}
|
||||
|
||||
pub fn trim_recent_events(&mut self) {
|
||||
if self.recent_events.len() > MAX_RECENT_EVENTS {
|
||||
let overflow = self.recent_events.len() - MAX_RECENT_EVENTS;
|
||||
self.recent_events.drain(0..overflow);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load() -> Self {
|
||||
fs::read_to_string(paths::state_file_path())
|
||||
.ok()
|
||||
.and_then(|raw| serde_json::from_str(&raw).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<(), String> {
|
||||
if let Some(parent) = paths::state_file_path().parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create runtime state dir: {e}"))?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(self)
|
||||
.map_err(|e| format!("Failed to serialize runtime state: {e}"))?;
|
||||
fs::write(paths::state_file_path(), json)
|
||||
.map_err(|e| format!("Failed to write runtime state: {e}"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub fn print_and_exit_if_requested(binary_name: &str) {
|
||||
if std::env::args().any(|arg| arg == "--version" || arg == "-V") {
|
||||
println!("{binary_name} {}", env!("CARGO_PKG_VERSION"));
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
use chrono::Local;
|
||||
use evdev::uinput::VirtualDeviceBuilder;
|
||||
use evdev::{AttributeSet, Device, EventType, InputEvent, Key};
|
||||
use nix::fcntl::{fcntl, FcntlArg, Flock, FlockArg, OFlag};
|
||||
use nix::sys::signal::{kill, Signal};
|
||||
use nix::unistd::Pid;
|
||||
use signal_hook::flag;
|
||||
use std::env;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::runtime::paths;
|
||||
|
||||
pub fn run_from_env() -> Result<(), String> {
|
||||
run_with_args(env::args().skip(1))
|
||||
}
|
||||
|
||||
pub fn run_with_args<I>(args: I) -> Result<(), String>
|
||||
where
|
||||
I: Iterator<Item = String>,
|
||||
{
|
||||
let args = parse_args(args);
|
||||
let base_dir = base_dir_from_pid_file(&args.pid_file);
|
||||
|
||||
if args.stop {
|
||||
return stop_process(&args.pid_file);
|
||||
}
|
||||
|
||||
ensure_dir(&base_dir)?;
|
||||
|
||||
let device_path = args
|
||||
.device
|
||||
.clone()
|
||||
.or_else(find_keyboard_device)
|
||||
.ok_or_else(|| "USB keyboard event device not found".to_string())?;
|
||||
|
||||
log_info(&format!(
|
||||
"Starting helper with keyboard device {}",
|
||||
device_path.display()
|
||||
));
|
||||
|
||||
let mut device = Device::open(&device_path)
|
||||
.map_err(|e| format!("Failed to open {}: {e}", device_path.display()))?;
|
||||
device.grab().map_err(|e| {
|
||||
format!(
|
||||
"Failed to grab device {}: {}. Another process may already hold an exclusive grab.",
|
||||
device_path.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
configure_nonblocking(&device).map_err(|e| {
|
||||
format!(
|
||||
"Failed to configure {} as non-blocking: {e}",
|
||||
device_path.display()
|
||||
)
|
||||
})?;
|
||||
log_info(&format!(
|
||||
"Grabbed keyboard device {} successfully",
|
||||
device_path.display()
|
||||
));
|
||||
|
||||
let mut keys = AttributeSet::<Key>::new();
|
||||
if let Some(supported) = device.supported_keys() {
|
||||
for key in supported.iter() {
|
||||
keys.insert(key);
|
||||
}
|
||||
}
|
||||
|
||||
for key in [
|
||||
Key::KEY_MUTE,
|
||||
Key::KEY_VOLUMEDOWN,
|
||||
Key::KEY_VOLUMEUP,
|
||||
Key::KEY_BRIGHTNESSDOWN,
|
||||
Key::KEY_BRIGHTNESSUP,
|
||||
] {
|
||||
keys.insert(key);
|
||||
}
|
||||
|
||||
let mut uinput = VirtualDeviceBuilder::new()
|
||||
.map_err(|e| format!("Failed to init uinput builder: {e}"))?
|
||||
.name("Zenbook Duo USB Remap")
|
||||
.with_keys(&keys)
|
||||
.map_err(|e| format!("Failed to set keys for uinput: {e}"))?
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create uinput device: {e}"))?;
|
||||
|
||||
write_pid(&args.pid_file)?;
|
||||
let _pid_guard = PidFileGuard::new(args.pid_file.clone());
|
||||
|
||||
let terminate = Arc::new(AtomicBool::new(false));
|
||||
flag::register(signal_hook::consts::SIGTERM, Arc::clone(&terminate))
|
||||
.map_err(|e| format!("Failed to register SIGTERM handler: {e}"))?;
|
||||
flag::register(signal_hook::consts::SIGINT, Arc::clone(&terminate))
|
||||
.map_err(|e| format!("Failed to register SIGINT handler: {e}"))?;
|
||||
|
||||
let pause_file = base_dir.join("usb_media_remap.paused");
|
||||
|
||||
while !terminate.load(Ordering::Relaxed) {
|
||||
let events = match device.fetch_events() {
|
||||
Ok(events) => events,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
continue;
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(e) => return Err(format!("Failed to read events: {e}")),
|
||||
};
|
||||
let paused = pause_file.exists();
|
||||
for event in events {
|
||||
if terminate.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
if paused {
|
||||
// Pass through all KEY events without remapping.
|
||||
if event.event_type() == EventType::KEY {
|
||||
emit_key(&mut uinput, Key::new(event.code()), event.value())?;
|
||||
}
|
||||
} else {
|
||||
handle_event(&mut uinput, &args, event)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = device.ungrab();
|
||||
log_info("Stopping helper");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn log_error(message: &str) {
|
||||
eprintln!("USB-REMAP - ERROR: {}", message);
|
||||
log_line("ERROR", message);
|
||||
}
|
||||
|
||||
pub fn log_info(message: &str) {
|
||||
eprintln!("USB-REMAP - INFO: {}", message);
|
||||
log_line("INFO", message);
|
||||
}
|
||||
|
||||
fn log_line(level: &str, message: &str) {
|
||||
// Best-effort: derive the log location from --pid-file (or default).
|
||||
let pid_file = pid_file_from_env_args();
|
||||
let base_dir = base_dir_from_pid_file(&pid_file);
|
||||
let _ = ensure_dir(&base_dir);
|
||||
let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
let log_path = base_dir.join("duo.log");
|
||||
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(log_path) {
|
||||
let _ = writeln!(file, "{} - USB-REMAP - {}: {}", timestamp, level, message);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Args {
|
||||
pid_file: String,
|
||||
user: Option<String>,
|
||||
device: Option<PathBuf>,
|
||||
stop: bool,
|
||||
}
|
||||
|
||||
fn parse_args<I>(mut args: I) -> Args
|
||||
where
|
||||
I: Iterator<Item = String>,
|
||||
{
|
||||
let mut pid_file = default_pid_file();
|
||||
let mut user = None;
|
||||
let mut device = None;
|
||||
let mut stop = false;
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--pid-file" => {
|
||||
if let Some(value) = args.next() {
|
||||
pid_file = value;
|
||||
}
|
||||
}
|
||||
"--user" => {
|
||||
if let Some(value) = args.next() {
|
||||
user = Some(value);
|
||||
}
|
||||
}
|
||||
"--device" => {
|
||||
if let Some(value) = args.next() {
|
||||
device = Some(PathBuf::from(value));
|
||||
}
|
||||
}
|
||||
"--stop" => stop = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Args {
|
||||
pid_file,
|
||||
user,
|
||||
device,
|
||||
stop,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_event(
|
||||
uinput: &mut evdev::uinput::VirtualDevice,
|
||||
args: &Args,
|
||||
event: InputEvent,
|
||||
) -> Result<(), String> {
|
||||
if event.event_type() != EventType::KEY {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let key = Key::new(event.code());
|
||||
let value = event.value();
|
||||
|
||||
match key {
|
||||
Key::KEY_F4 => {
|
||||
if value == 1 {
|
||||
cycle_backlight();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Key::KEY_F11 => {
|
||||
if value == 1 {
|
||||
open_emoji_picker(args.user.as_deref());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if let Some((mapped, direction)) = brightness_key_mapping(key) {
|
||||
return handle_brightness_key(uinput, mapped, value, direction);
|
||||
}
|
||||
|
||||
let mapped = match key {
|
||||
Key::KEY_F1 => Key::KEY_MUTE,
|
||||
Key::KEY_F2 => Key::KEY_VOLUMEDOWN,
|
||||
Key::KEY_F3 => Key::KEY_VOLUMEUP,
|
||||
_ => key,
|
||||
};
|
||||
|
||||
emit_key(uinput, mapped, value)
|
||||
}
|
||||
|
||||
fn brightness_key_mapping(key: Key) -> Option<(Key, &'static str)> {
|
||||
match key {
|
||||
Key::KEY_F5 => Some((Key::KEY_BRIGHTNESSDOWN, "down")),
|
||||
Key::KEY_F6 => Some((Key::KEY_BRIGHTNESSUP, "up")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_brightness_key(
|
||||
uinput: &mut evdev::uinput::VirtualDevice,
|
||||
key: Key,
|
||||
value: i32,
|
||||
direction: &str,
|
||||
) -> Result<(), String> {
|
||||
let before = if value == 1 {
|
||||
read_primary_brightness().ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
emit_key(uinput, key, value)?;
|
||||
|
||||
if value == 1 {
|
||||
maybe_step_brightness_after_native_event(direction, before)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn maybe_step_brightness_after_native_event(
|
||||
direction: &str,
|
||||
before: Option<i32>,
|
||||
) -> Result<(), String> {
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
let after = read_primary_brightness().ok();
|
||||
if brightness_fallback_needed(before, after) {
|
||||
step_brightness(direction)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn brightness_fallback_needed(before: Option<i32>, after: Option<i32>) -> bool {
|
||||
matches!((before, after), (Some(before), Some(after)) if before == after)
|
||||
}
|
||||
|
||||
fn emit_key(uinput: &mut evdev::uinput::VirtualDevice, key: Key, value: i32) -> Result<(), String> {
|
||||
let events = [
|
||||
InputEvent::new(EventType::KEY, key.code(), value),
|
||||
InputEvent::new(EventType::SYNCHRONIZATION, 0, 0),
|
||||
];
|
||||
uinput
|
||||
.emit(&events)
|
||||
.map_err(|e| format!("Failed to emit key event: {e}"))
|
||||
}
|
||||
|
||||
fn write_pid(path: &str) -> Result<(), String> {
|
||||
if let Ok(existing) = fs::read_to_string(path) {
|
||||
if let Ok(pid) = existing.trim().parse::<i32>() {
|
||||
if unsafe { libc::kill(pid, 0) } == 0 {
|
||||
return Err(format!("Remapper already running (pid {})", pid));
|
||||
}
|
||||
}
|
||||
}
|
||||
fs::write(path, std::process::id().to_string())
|
||||
.map_err(|e| format!("Failed to write pid file: {e}"))
|
||||
}
|
||||
|
||||
fn configure_nonblocking(device: &Device) -> Result<(), nix::Error> {
|
||||
let fd = device.as_raw_fd();
|
||||
let current_flags = OFlag::from_bits_truncate(fcntl(fd, FcntlArg::F_GETFL)?);
|
||||
fcntl(fd, FcntlArg::F_SETFL(current_flags | OFlag::O_NONBLOCK))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct PidFileGuard {
|
||||
path: String,
|
||||
}
|
||||
|
||||
impl PidFileGuard {
|
||||
fn new(path: String) -> Self {
|
||||
Self { path }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PidFileGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_process(path: &str) -> Result<(), String> {
|
||||
let pid = fs::read_to_string(path)
|
||||
.map_err(|e| format!("Failed to read pid file: {e}"))?
|
||||
.trim()
|
||||
.parse::<i32>()
|
||||
.map_err(|e| format!("Invalid pid file contents: {e}"))?;
|
||||
|
||||
let pid = Pid::from_raw(pid);
|
||||
let _ = kill(pid, Signal::SIGTERM);
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
while start.elapsed() < Duration::from_secs(2) {
|
||||
let running = unsafe { libc::kill(pid.as_raw(), 0) == 0 };
|
||||
if !running {
|
||||
let _ = fs::remove_file(path);
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
let _ = kill(pid, Signal::SIGKILL);
|
||||
let _ = fs::remove_file(path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_keyboard_device() -> Option<PathBuf> {
|
||||
let by_id = Path::new("/dev/input/by-id");
|
||||
let entries = fs::read_dir(by_id).ok()?;
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.contains("Zenbook_Duo_Keyboard") && name.contains("event-kbd") {
|
||||
return Some(entry.path());
|
||||
}
|
||||
if name.contains("ASUS_Zenbook_Duo_Keyboard") && name.contains("event-kbd") {
|
||||
return Some(entry.path());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn cycle_backlight() {
|
||||
// Backlight state is shared with the main app via files next to the pid file.
|
||||
let pid_file = pid_file_from_env_args();
|
||||
let base_dir = base_dir_from_pid_file(&pid_file);
|
||||
if ensure_dir(&base_dir).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let kbl_lock_path = base_dir.join("kb_backlight_lock");
|
||||
let kbl_level_path = base_dir.join("kb_backlight_level");
|
||||
let kbl_last_cycle_path = base_dir.join("kb_backlight_last_cycle");
|
||||
|
||||
let lock = match OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.open(kbl_lock_path)
|
||||
{
|
||||
Ok(file) => file,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let _lock = match Flock::lock(lock, FlockArg::LockExclusiveNonblock) {
|
||||
Ok(l) => l,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let now = current_time_ms();
|
||||
if let Some(last) = fs::read_to_string(&kbl_last_cycle_path)
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u128>().ok())
|
||||
{
|
||||
if now.saturating_sub(last) < 600 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let _ = fs::write(&kbl_last_cycle_path, now.to_string());
|
||||
|
||||
let level = fs::read_to_string(&kbl_level_path)
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u8>().ok())
|
||||
.unwrap_or(0);
|
||||
let next = (level + 1) % 4;
|
||||
if crate::commands::backlight::set_backlight_daemon_first(next).is_ok() {
|
||||
let _ = fs::write(&kbl_level_path, next.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn open_emoji_picker(user: Option<&str>) {
|
||||
let user = match user {
|
||||
Some(u) => u,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let uid = match nix::unistd::User::from_name(user) {
|
||||
Ok(Some(u)) => u.uid.as_raw(),
|
||||
_ => return,
|
||||
};
|
||||
|
||||
let is_running = Command::new("pgrep")
|
||||
.arg("-u")
|
||||
.arg(uid.to_string())
|
||||
.arg("-x")
|
||||
.arg("gnome-characters")
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_running {
|
||||
return;
|
||||
}
|
||||
|
||||
let runtime_dir = format!("/run/user/{uid}");
|
||||
let bus_address = format!("unix:path={runtime_dir}/bus");
|
||||
|
||||
let mut cmd = if nix::unistd::Uid::current().is_root() {
|
||||
let mut cmd = Command::new("runuser");
|
||||
cmd.arg("-u").arg(user).arg("--").arg("env");
|
||||
cmd
|
||||
} else {
|
||||
Command::new("env")
|
||||
};
|
||||
|
||||
cmd.arg(format!("XDG_RUNTIME_DIR={runtime_dir}"))
|
||||
.arg(format!("DBUS_SESSION_BUS_ADDRESS={bus_address}"));
|
||||
|
||||
if Path::new(&format!("{runtime_dir}/wayland-0")).exists() {
|
||||
cmd.arg("WAYLAND_DISPLAY=wayland-0");
|
||||
} else if Path::new("/tmp/.X11-unix/X0").exists() {
|
||||
cmd.arg("DISPLAY=:0");
|
||||
}
|
||||
|
||||
let _ = cmd.arg("gnome-characters").spawn();
|
||||
}
|
||||
|
||||
fn current_time_ms() -> u128 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or(Duration::from_secs(0))
|
||||
.as_millis()
|
||||
}
|
||||
|
||||
fn read_primary_brightness() -> Result<i32, String> {
|
||||
let primary = crate::hardware::sysfs::primary_backlight_dir()
|
||||
.ok_or_else(|| "no primary backlight device found".to_string())?;
|
||||
read_backlight_value(&primary.join("brightness"))
|
||||
}
|
||||
|
||||
fn step_brightness(direction: &str) -> Result<(), String> {
|
||||
let primary = crate::hardware::sysfs::primary_backlight_dir()
|
||||
.ok_or_else(|| "no primary backlight device found".to_string())?;
|
||||
|
||||
let primary_max = read_backlight_value(&primary.join("max_brightness"))?;
|
||||
let current = read_backlight_value(&primary.join("brightness"))?;
|
||||
let next = next_brightness_value(current, primary_max, direction);
|
||||
|
||||
fs::write(primary.join("brightness"), next.to_string())
|
||||
.map_err(|e| format!("Failed to write primary brightness: {e}"))?;
|
||||
|
||||
if let Some(secondary) = crate::hardware::sysfs::secondary_backlight_dir() {
|
||||
let secondary_max = read_backlight_value(&secondary.join("max_brightness"))?;
|
||||
let mirrored = next.min(secondary_max);
|
||||
fs::write(secondary.join("brightness"), mirrored.to_string())
|
||||
.map_err(|e| format!("Failed to write secondary brightness: {e}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_backlight_value(path: &Path) -> Result<i32, String> {
|
||||
fs::read_to_string(path)
|
||||
.map_err(|e| format!("Failed to read {}: {e}", path.display()))?
|
||||
.trim()
|
||||
.parse::<i32>()
|
||||
.map_err(|e| format!("Invalid brightness value in {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
fn next_brightness_value(current: i32, max: i32, direction: &str) -> i32 {
|
||||
let step = (max / 20).max(1);
|
||||
if direction == "up" {
|
||||
(current + step).min(max)
|
||||
} else {
|
||||
(current - step).max(0)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_pid_file() -> String {
|
||||
paths::current_user_runtime_dir()
|
||||
.join("usb_media_remap.pid")
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
fn pid_file_from_env_args() -> String {
|
||||
let mut it = env::args().skip(1);
|
||||
while let Some(arg) = it.next() {
|
||||
if arg == "--pid-file" {
|
||||
if let Some(v) = it.next() {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
}
|
||||
default_pid_file()
|
||||
}
|
||||
|
||||
fn base_dir_from_pid_file(pid_file: &str) -> PathBuf {
|
||||
Path::new(pid_file)
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(paths::current_user_runtime_dir)
|
||||
}
|
||||
|
||||
fn ensure_dir(dir: &Path) -> Result<(), String> {
|
||||
crate::runtime::runtime_dir::ensure_dir_owned_like_parent(dir)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn brightness_step_uses_five_percent_chunks() {
|
||||
assert_eq!(next_brightness_value(200, 400, "up"), 220);
|
||||
assert_eq!(next_brightness_value(200, 400, "down"), 180);
|
||||
assert_eq!(next_brightness_value(395, 400, "up"), 400);
|
||||
assert_eq!(next_brightness_value(5, 400, "down"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brightness_keys_map_to_native_desktop_events() {
|
||||
assert_eq!(
|
||||
brightness_key_mapping(Key::KEY_F5),
|
||||
Some((Key::KEY_BRIGHTNESSDOWN, "down"))
|
||||
);
|
||||
assert_eq!(
|
||||
brightness_key_mapping(Key::KEY_F6),
|
||||
Some((Key::KEY_BRIGHTNESSUP, "up"))
|
||||
);
|
||||
assert_eq!(brightness_key_mapping(Key::KEY_F4), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brightness_fallback_only_runs_when_native_event_did_not_change_level() {
|
||||
assert!(brightness_fallback_needed(Some(100), Some(100)));
|
||||
assert!(!brightness_fallback_needed(Some(100), Some(110)));
|
||||
assert!(!brightness_fallback_needed(None, Some(100)));
|
||||
assert!(!brightness_fallback_needed(Some(100), None));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use crate::commands::events::{push_event, EventBuffer};
|
||||
use crate::models::{EventCategory, HardwareEvent};
|
||||
use crate::runtime::paths;
|
||||
|
||||
pub fn start(app: AppHandle, buffer: EventBuffer) {
|
||||
std::thread::spawn(move || {
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Result<Event, notify::Error>>();
|
||||
|
||||
let mut watcher = match RecommendedWatcher::new(tx, Config::default()) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
log::error!("Failed to create file watcher: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let runtime_dir = paths::system_runtime_dir();
|
||||
if runtime_dir.exists() {
|
||||
let _ = watcher.watch(runtime_dir.as_path(), RecursiveMode::NonRecursive);
|
||||
}
|
||||
|
||||
// Watch the primary sysfs backlight when one is available.
|
||||
if let Some(backlight_path) =
|
||||
crate::hardware::sysfs::primary_backlight_dir().map(|dir| dir.join("brightness"))
|
||||
{
|
||||
let _ = watcher.watch(backlight_path.as_path(), RecursiveMode::NonRecursive);
|
||||
}
|
||||
|
||||
for event in rx {
|
||||
match event {
|
||||
Ok(event) => {
|
||||
if !matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for path in &event.paths {
|
||||
let filename = path
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
match filename.as_str() {
|
||||
"state.json" => {
|
||||
let _ = app.emit("duo://status-changed", ());
|
||||
let hw_event = HardwareEvent::info(
|
||||
EventCategory::Service,
|
||||
"Runtime state updated",
|
||||
"file_watcher",
|
||||
);
|
||||
push_event(&buffer, hw_event);
|
||||
let _ = app.emit("duo://hardware-event", ());
|
||||
}
|
||||
"daemon.log" => {
|
||||
let _ = app.emit("duo://log-updated", ());
|
||||
}
|
||||
"brightness" => {
|
||||
let _ = app.emit("duo://status-changed", ());
|
||||
let hw_event = HardwareEvent::info(
|
||||
EventCategory::Display,
|
||||
"Display brightness changed",
|
||||
"file_watcher",
|
||||
);
|
||||
push_event(&buffer, hw_event);
|
||||
let _ = app.emit("duo://hardware-event", ());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("File watcher error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
pub mod file_watcher;
|
||||
|
||||
use crate::commands::events::EventBuffer;
|
||||
use tauri::AppHandle;
|
||||
|
||||
pub fn start_all_watchers(app: &AppHandle, buffer: EventBuffer) {
|
||||
file_watcher::start(app.clone(), buffer.clone());
|
||||
let _ = buffer;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/tauri/refs/heads/dev/crates/tauri-utils/schema.json",
|
||||
"productName": "Zenbook Duo Control",
|
||||
"version": "0.3.7",
|
||||
"identifier": "com.zenbook-duo.control",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeDevCommand": "npm run vite:dev",
|
||||
"beforeBuildCommand": "npm run vite:build"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": false,
|
||||
"windows": [
|
||||
{
|
||||
"title": "Zenbook Duo Control",
|
||||
"width": 960,
|
||||
"height": 640,
|
||||
"minWidth": 800,
|
||||
"minHeight": 500,
|
||||
"resizable": true,
|
||||
"decorations": false,
|
||||
"center": true,
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"trayIcon": {
|
||||
"iconPath": "icons/32x32.png",
|
||||
"iconAsTemplate": false,
|
||||
"tooltip": "Zenbook Duo Control"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["deb", "rpm"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"linux": {
|
||||
"deb": {
|
||||
"depends": ["libwebkit2gtk-4.1-0", "libappindicator3-1"]
|
||||
},
|
||||
"rpm": {
|
||||
"depends": ["webkit2gtk4.1", "libappindicator-gtk3"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useStore, useStoreInit } from "@/lib/store";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import TitleBar from "@/components/TitleBar";
|
||||
import Status from "@/pages/Status";
|
||||
import Controls from "@/pages/Controls";
|
||||
import Settings from "@/pages/Settings";
|
||||
import Logs from "@/pages/Logs";
|
||||
import DisplayLayout from "@/pages/DisplayLayout";
|
||||
import Profiles from "@/pages/Profiles";
|
||||
import EventMonitor from "@/pages/EventMonitor";
|
||||
import Diagnostics from "@/pages/Diagnostics";
|
||||
import Setup from "@/pages/Setup";
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
onNativeSystemThemeChanged,
|
||||
resolveThemeForPreference,
|
||||
} from "@/lib/theme";
|
||||
|
||||
export type Page =
|
||||
| "status"
|
||||
| "controls"
|
||||
| "settings"
|
||||
| "logs"
|
||||
| "display"
|
||||
| "profiles"
|
||||
| "events"
|
||||
| "diagnostics";
|
||||
|
||||
const pageComponents: Record<Page, React.ComponentType> = {
|
||||
status: Status,
|
||||
controls: Controls,
|
||||
settings: Settings,
|
||||
logs: Logs,
|
||||
display: DisplayLayout,
|
||||
profiles: Profiles,
|
||||
events: EventMonitor,
|
||||
diagnostics: Diagnostics,
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const [currentPage, setCurrentPage] = useState<Page>("status");
|
||||
|
||||
useStoreInit();
|
||||
const store = useStore();
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (store.loading) return;
|
||||
|
||||
let cancelled = false;
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
async function applyThemePreference() {
|
||||
const theme = await resolveThemeForPreference(store.settings.theme);
|
||||
if (!cancelled) {
|
||||
setTheme(theme);
|
||||
}
|
||||
}
|
||||
|
||||
void applyThemePreference();
|
||||
|
||||
if (store.settings.theme === "system") {
|
||||
void onNativeSystemThemeChanged((theme) => {
|
||||
if (!cancelled) {
|
||||
setTheme(theme);
|
||||
}
|
||||
}).then((cleanup) => {
|
||||
if (cancelled) {
|
||||
cleanup();
|
||||
} else {
|
||||
unlisten = cleanup;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, [setTheme, store.loading, store.settings.theme]);
|
||||
|
||||
if (!store.loading && !store.settings.setupCompleted) {
|
||||
return <Setup />;
|
||||
}
|
||||
|
||||
const PageComponent = pageComponents[currentPage];
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-background">
|
||||
<TitleBar />
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
<Sidebar currentPage={currentPage} onNavigate={setCurrentPage} />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Subtle top border accent */}
|
||||
<div className="h-px w-full bg-gradient-to-r from-transparent via-primary/20 to-transparent" />
|
||||
<main className="flex-1 overflow-y-auto px-8 py-7" key={currentPage}>
|
||||
<div className="animate-page-enter mx-auto max-w-4xl">
|
||||
<PageComponent />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,80 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { controlsApi } from "@/lib/tauri-adapters";
|
||||
import { useStore, useDispatch } from "@/lib/store";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const levels = [
|
||||
{ value: 0, label: "Off", desc: "Backlight off" },
|
||||
{ value: 1, label: "Low", desc: "Dim" },
|
||||
{ value: 2, label: "Mid", desc: "Medium" },
|
||||
{ value: 3, label: "Max", desc: "Full brightness" },
|
||||
];
|
||||
|
||||
export default function BacklightSlider() {
|
||||
const store = useStore();
|
||||
const dispatch = useDispatch();
|
||||
const [pending, setPending] = useState(false);
|
||||
const [localLevel, setLocalLevel] = useState<number | null>(null);
|
||||
const clearTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const displayLevel = localLevel ?? store.status.backlightLevel;
|
||||
|
||||
const handleChange = async (value: number[]) => {
|
||||
const level = value[0];
|
||||
if (level === undefined || level === displayLevel) return;
|
||||
|
||||
if (clearTimer.current) clearTimeout(clearTimer.current);
|
||||
|
||||
setLocalLevel(level);
|
||||
setPending(true);
|
||||
try {
|
||||
await controlsApi.setBacklight(level);
|
||||
dispatch({
|
||||
type: "SET_STATUS",
|
||||
payload: { ...store.status, backlightLevel: level },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to set backlight:", err);
|
||||
} finally {
|
||||
setPending(false);
|
||||
clearTimer.current = setTimeout(() => setLocalLevel(null), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="font-mono text-[11px] text-muted-foreground">0</span>
|
||||
<Slider
|
||||
min={0}
|
||||
max={3}
|
||||
step={1}
|
||||
value={[displayLevel]}
|
||||
onValueChange={handleChange}
|
||||
disabled={pending}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="font-mono text-[11px] text-muted-foreground">3</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{levels.map((l) => (
|
||||
<button
|
||||
key={l.value}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-0.5 rounded-lg border px-2 py-2 transition-all",
|
||||
displayLevel === l.value
|
||||
? "border-amber-500/30 bg-amber-500/8 text-amber-600 dark:text-amber-400"
|
||||
: "border-transparent text-muted-foreground hover:border-border hover:text-foreground"
|
||||
)}
|
||||
onClick={() => handleChange([l.value])}
|
||||
disabled={pending}
|
||||
>
|
||||
<span className="font-mono text-[12px] font-semibold">{l.label}</span>
|
||||
<span className="text-[10px] opacity-60">{l.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
type Node,
|
||||
type NodeProps,
|
||||
type ReactFlowInstance,
|
||||
useNodesState,
|
||||
} from "@xyflow/react";
|
||||
|
||||
import type { DisplayLayout } from "@/types/duo";
|
||||
import {
|
||||
isDuoDisplay,
|
||||
layoutToCanvasNodes,
|
||||
updateDisplayPosition,
|
||||
type DisplayCanvasNodeModel,
|
||||
} from "@/lib/display-layout";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DisplayCanvasProps {
|
||||
layout: DisplayLayout;
|
||||
onLayoutChange: (layout: DisplayLayout) => void;
|
||||
}
|
||||
|
||||
type DisplayNodeData = DisplayCanvasNodeModel["data"];
|
||||
|
||||
function DisplayNode({ data, selected }: NodeProps<Node<DisplayNodeData>>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-full w-full rounded-xl border-2 bg-card/85 backdrop-blur-sm",
|
||||
"shadow-sm shadow-black/5",
|
||||
data.primary
|
||||
? "border-primary/60 bg-primary/5 shadow-md shadow-primary/10"
|
||||
: selected
|
||||
? "border-primary/50"
|
||||
: "border-border/70"
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col justify-between p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-mono text-[13px] font-semibold">
|
||||
{data.connector}
|
||||
</div>
|
||||
<div className="mt-0.5 font-mono text-[11px] text-muted-foreground">
|
||||
{data.width}x{data.height} @ {data.refreshRate.toFixed(1)}Hz
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.primary && (
|
||||
<span className="shrink-0 rounded bg-primary/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
|
||||
Primary
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
Drag to reposition
|
||||
</div>
|
||||
<div className="font-mono text-[11px] text-muted-foreground">
|
||||
{data.scale.toFixed(2)}x
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeTypes = { displayNode: DisplayNode };
|
||||
|
||||
function layoutToNodes(layout: DisplayLayout): Node<DisplayNodeData>[] {
|
||||
return layoutToCanvasNodes(layout).map((node) => ({
|
||||
...node,
|
||||
type: "displayNode",
|
||||
selectable: true,
|
||||
}));
|
||||
}
|
||||
|
||||
export default function DisplayCanvas({ layout, onLayoutChange }: DisplayCanvasProps) {
|
||||
const rfRef = useRef<ReactFlowInstance<Node<DisplayNodeData>> | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const derivedNodes = useMemo(() => layoutToNodes(layout), [layout]);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node<DisplayNodeData>>([]);
|
||||
|
||||
// Keep nodes in sync with layout, but don't clobber while the user is dragging.
|
||||
useEffect(() => {
|
||||
if (isDragging) return;
|
||||
setNodes(derivedNodes);
|
||||
}, [derivedNodes, isDragging, setNodes]);
|
||||
|
||||
// Fit view when layout changes (refresh/apply), but avoid fighting during drag.
|
||||
useEffect(() => {
|
||||
if (isDragging) return;
|
||||
const inst = rfRef.current;
|
||||
if (!inst) return;
|
||||
if (derivedNodes.length === 0) return;
|
||||
// Small delay lets ReactFlow measure node sizes.
|
||||
const t = window.setTimeout(() => {
|
||||
try {
|
||||
inst.fitView({ padding: 0.2, duration: 250 });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 0);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [derivedNodes.length, isDragging]);
|
||||
|
||||
return (
|
||||
<div className="h-[420px] overflow-hidden rounded-xl border border-border/60 bg-muted/20">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={[]}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onInit={(inst: ReactFlowInstance<Node<DisplayNodeData>>) => {
|
||||
rfRef.current = inst;
|
||||
}}
|
||||
nodesConnectable={false}
|
||||
nodesDraggable
|
||||
elementsSelectable
|
||||
fitView
|
||||
snapToGrid
|
||||
snapGrid={[10, 10]}
|
||||
onNodeDragStart={(_, node) => {
|
||||
if (isDuoDisplay(node.id)) return;
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onNodeDrag={(_, node: Node<DisplayNodeData>) => {
|
||||
if (isDuoDisplay(node.id)) return;
|
||||
setNodes((nds) =>
|
||||
nds.map((n) => (n.id === node.id ? { ...n, position: node.position } : n))
|
||||
);
|
||||
}}
|
||||
onNodeDragStop={(_, node: Node<DisplayNodeData>) => {
|
||||
if (isDuoDisplay(node.id)) {
|
||||
setIsDragging(false);
|
||||
return;
|
||||
}
|
||||
setIsDragging(false);
|
||||
const next = updateDisplayPosition(layout, node.id, node.position);
|
||||
onLayoutChange(next);
|
||||
}}
|
||||
onPaneClick={() => {
|
||||
// ensure drag state resets if something weird happens
|
||||
setIsDragging(false);
|
||||
}}
|
||||
>
|
||||
<Background gap={20} size={1} color="oklch(0.5 0 0 / 10%)" />
|
||||
<Controls showInteractive={false} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { HardwareEvent } from "@/types/duo";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IconBolt } from "@tabler/icons-react";
|
||||
|
||||
interface EventStreamProps {
|
||||
events: HardwareEvent[];
|
||||
}
|
||||
|
||||
const severityColor: Record<string, string> = {
|
||||
info: "text-foreground/80",
|
||||
warning: "text-amber-500",
|
||||
error: "text-red-500",
|
||||
};
|
||||
|
||||
const severityDot: Record<string, string> = {
|
||||
info: "bg-blue-400",
|
||||
warning: "bg-amber-400",
|
||||
error: "bg-red-400",
|
||||
};
|
||||
|
||||
const categoryColor: Record<string, string> = {
|
||||
USB: "bg-violet-500/15 text-violet-500 border-violet-500/20",
|
||||
DISPLAY: "bg-blue-500/15 text-blue-500 border-blue-500/20",
|
||||
KEYBOARD: "bg-emerald-500/15 text-emerald-500 border-emerald-500/20",
|
||||
NETWORK: "bg-cyan-500/15 text-cyan-500 border-cyan-500/20",
|
||||
ROTATION: "bg-orange-500/15 text-orange-500 border-orange-500/20",
|
||||
BLUETOOTH: "bg-indigo-500/15 text-indigo-500 border-indigo-500/20",
|
||||
SERVICE: "bg-pink-500/15 text-pink-500 border-pink-500/20",
|
||||
};
|
||||
|
||||
function formatTime(timestamp: string) {
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString("en-US", { hour12: false });
|
||||
} catch {
|
||||
return "??:??:??";
|
||||
}
|
||||
}
|
||||
|
||||
export default function EventStream({ events }: EventStreamProps) {
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-center">
|
||||
<div className="mb-3 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<IconBolt className="size-4 text-muted-foreground" stroke={1.5} />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">No events recorded yet</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-h-[400px] space-y-0.5 overflow-y-auto">
|
||||
{events.map((event, i) => (
|
||||
<div
|
||||
key={`${event.timestamp}-${i}`}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-2.5 py-2 transition-colors hover:bg-muted/40",
|
||||
event.severity === "error" && "bg-red-500/5",
|
||||
event.severity === "warning" && "bg-amber-500/5"
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"inline-block size-1.5 shrink-0 rounded-full",
|
||||
severityDot[event.severity] ?? "bg-muted-foreground/30"
|
||||
)} />
|
||||
<span className="shrink-0 font-mono text-[11px] tabular-nums text-muted-foreground/60">
|
||||
{formatTime(event.timestamp)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-md border px-1.5 py-0.5 font-mono text-[10px] font-medium",
|
||||
categoryColor[event.category] ?? "border-border bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{event.category}
|
||||
</span>
|
||||
<span className={cn("min-w-0 truncate text-[12px]", severityColor[event.severity])}>
|
||||
{event.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState } from "react";
|
||||
import { displayApi } from "@/lib/tauri-adapters";
|
||||
import { useStore, useDispatch, refreshStatus } from "@/lib/store";
|
||||
import type { Orientation } from "@/types/duo";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
IconArrowUp,
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconArrowDown,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
const orientations: { id: Orientation; label: string; icon: React.ComponentType<{ className?: string; stroke?: number }> }[] = [
|
||||
{ id: "normal", label: "Normal", icon: IconArrowUp },
|
||||
{ id: "left", label: "Left", icon: IconArrowLeft },
|
||||
{ id: "right", label: "Right", icon: IconArrowRight },
|
||||
{ id: "inverted", label: "Inverted", icon: IconArrowDown },
|
||||
];
|
||||
|
||||
export default function OrientationButtons() {
|
||||
const store = useStore();
|
||||
const dispatch = useDispatch();
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const handleClick = async (orientation: Orientation) => {
|
||||
setPending(true);
|
||||
try {
|
||||
await displayApi.setOrientation(orientation);
|
||||
await refreshStatus(dispatch);
|
||||
} catch (err) {
|
||||
console.error("Failed to set orientation:", err);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{orientations.map((o) => {
|
||||
const Icon = o.icon;
|
||||
const isActive = store.status.orientation === o.id;
|
||||
return (
|
||||
<button
|
||||
key={o.id}
|
||||
className={cn(
|
||||
"group relative flex flex-col items-center gap-1.5 rounded-xl border px-3 py-3 transition-all",
|
||||
isActive
|
||||
? "border-blue-500/30 bg-blue-500/8 text-blue-600 dark:text-blue-400"
|
||||
: "border-border text-muted-foreground hover:border-blue-500/20 hover:text-foreground"
|
||||
)}
|
||||
onClick={() => handleClick(o.id)}
|
||||
disabled={pending}
|
||||
>
|
||||
<div className={cn(
|
||||
"flex size-8 items-center justify-center rounded-lg transition-colors",
|
||||
isActive
|
||||
? "bg-blue-500/15"
|
||||
: "bg-muted/50 group-hover:bg-muted"
|
||||
)}>
|
||||
<Icon className="size-4" stroke={isActive ? 2 : 1.5} />
|
||||
</div>
|
||||
<span className="text-[11px] font-medium">{o.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState } from "react";
|
||||
import type { Profile } from "@/types/duo";
|
||||
import { profilesApi } from "@/lib/tauri-adapters";
|
||||
import { refreshProfiles, refreshStatus, useDispatch } from "@/lib/store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
IconPlayerPlay,
|
||||
IconTrash,
|
||||
IconSun,
|
||||
IconArrowsMaximize,
|
||||
IconRotate,
|
||||
IconScreenShare,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
interface ProfileCardProps {
|
||||
profile: Profile;
|
||||
}
|
||||
|
||||
export default function ProfileCard({ profile }: ProfileCardProps) {
|
||||
const dispatch = useDispatch();
|
||||
const [activating, setActivating] = useState(false);
|
||||
|
||||
const handleActivate = async () => {
|
||||
setActivating(true);
|
||||
try {
|
||||
await profilesApi.activateProfile(profile.id);
|
||||
await refreshStatus(dispatch);
|
||||
} catch (err) {
|
||||
console.error("Failed to activate profile:", err);
|
||||
} finally {
|
||||
setActivating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await profilesApi.deleteProfile(profile.id);
|
||||
await refreshProfiles(dispatch);
|
||||
} catch (err) {
|
||||
console.error("Failed to delete profile:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass-card group rounded-xl p-5 transition-shadow hover:shadow-md hover:shadow-black/5">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<h4 className="text-[14px] font-semibold tracking-tight">{profile.name}</h4>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
className="size-7 p-0 text-muted-foreground/50 opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
|
||||
>
|
||||
<IconTrash className="size-3.5" stroke={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid grid-cols-2 gap-2">
|
||||
<ProfileDetail icon={IconSun} label="Backlight" value={`${profile.backlightLevel}/3`} />
|
||||
<ProfileDetail icon={IconArrowsMaximize} label="Scale" value={`${profile.scale}x`} />
|
||||
<ProfileDetail icon={IconRotate} label="Orientation" value={profile.orientation} capitalize />
|
||||
<ProfileDetail
|
||||
icon={IconScreenShare}
|
||||
label="Dual Screen"
|
||||
value={profile.dualScreenEnabled ? "On" : "Off"}
|
||||
highlight={profile.dualScreenEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleActivate}
|
||||
disabled={activating}
|
||||
className="w-full gap-1.5"
|
||||
>
|
||||
<IconPlayerPlay className="size-3.5" stroke={1.5} />
|
||||
{activating ? "Applying..." : "Activate"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileDetail({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
capitalize,
|
||||
highlight,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string; stroke?: number }>;
|
||||
label: string;
|
||||
value: string;
|
||||
capitalize?: boolean;
|
||||
highlight?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg bg-muted/40 px-2.5 py-2">
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<Icon className="size-3 text-muted-foreground/60" stroke={1.5} />
|
||||
<span className="text-[10px] text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-[12px] font-semibold",
|
||||
capitalize && "capitalize",
|
||||
highlight ? "text-primary" : "text-foreground"
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { Page } from "@/App";
|
||||
import { useStore } from "@/lib/store";
|
||||
import ThemeToggle from "@/components/ThemeToggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { APP_VERSION, formatVersion } from "@/lib/version";
|
||||
import {
|
||||
IconCircleDot,
|
||||
IconAdjustments,
|
||||
IconSettings,
|
||||
IconFileText,
|
||||
IconLayout,
|
||||
IconUsers,
|
||||
IconBolt,
|
||||
IconBug,
|
||||
IconDeviceDesktop,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
const navItems: { id: Page; label: string; icon: React.ComponentType<{ className?: string; stroke?: number }> }[] = [
|
||||
{ id: "status", label: "Status", icon: IconCircleDot },
|
||||
{ id: "display", label: "Displays", icon: IconDeviceDesktop },
|
||||
{ id: "controls", label: "Controls", icon: IconAdjustments },
|
||||
{ id: "profiles", label: "Profiles", icon: IconUsers },
|
||||
{ id: "settings", label: "Settings", icon: IconSettings },
|
||||
{ id: "logs", label: "Logs", icon: IconFileText },
|
||||
{ id: "events", label: "Events", icon: IconBolt },
|
||||
{ id: "diagnostics", label: "Diagnostics", icon: IconBug },
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
currentPage: Page;
|
||||
onNavigate: (page: Page) => void;
|
||||
}
|
||||
|
||||
export default function Sidebar({ currentPage, onNavigate }: SidebarProps) {
|
||||
const { versionInfo } = useStore();
|
||||
const appVersion = formatVersion(versionInfo.appVersion || APP_VERSION);
|
||||
const serviceVersion = versionInfo.daemonVersion
|
||||
? formatVersion(versionInfo.daemonVersion)
|
||||
: "Service unavailable";
|
||||
|
||||
return (
|
||||
<nav className="flex h-full w-[220px] shrink-0 flex-col bg-sidebar">
|
||||
{/* Brand */}
|
||||
<div className="px-5 pt-6 pb-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-primary/10">
|
||||
<IconLayout className="size-4 text-primary" stroke={1.75} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-[13px] font-semibold tracking-tight text-foreground">
|
||||
Zenbook Duo
|
||||
</h2>
|
||||
<span className="text-[10px] font-medium uppercase tracking-widest text-muted-foreground">
|
||||
Control Panel
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="mx-4 h-px bg-border/60" />
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex flex-1 flex-col gap-0.5 px-3 pt-4">
|
||||
<span className="mb-1.5 px-2 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/70">
|
||||
Navigation
|
||||
</span>
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = currentPage === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"group relative flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-[13px] font-medium transition-all duration-150",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
||||
)}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
>
|
||||
{/* Active indicator bar */}
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 h-5 w-[3px] -translate-y-1/2 rounded-r-full bg-primary" />
|
||||
)}
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-[18px] transition-colors",
|
||||
isActive ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
|
||||
)}
|
||||
stroke={isActive ? 2 : 1.5}
|
||||
/>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mx-4 h-px bg-border/60" />
|
||||
<div className="px-3 py-3">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<div className="space-y-1 px-5 pb-4 font-mono text-[10px] text-muted-foreground/50">
|
||||
<div>{appVersion}</div>
|
||||
<div>{serviceVersion}</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user