initial commit

This commit is contained in:
2026-09-26 17:37:13 +02:00
commit 839bebd8d4
226 changed files with 58701 additions and 0 deletions
@@ -0,0 +1,116 @@
# Rust Runtime Migration Plan
## Summary
Migrate the non-installer runtime from the current `duo.sh` + Python helper model to a Rust-first architecture with:
- a privileged system daemon as the single source of truth for hardware state and policy
- a small user-session agent for compositor/session-bound actions
- the existing Tauri app refactored into a client of the daemon over a Unix socket API
This is a one-shot cutover, not a staged parity rollout. Installer/setup scripts remain Bash, but they will install and wire up the new Rust binaries/services instead of `duo.sh` and Python helpers.
## Architecture and Key Changes
### 1. Replace `duo.sh` and Python helpers with Rust services
- Rebuild all runtime behavior currently owned by `duo.sh`: dock attach/detach handling, backlight persistence/control, brightness sync, Wi-Fi/Bluetooth state tracking, lock/resume handling, rotation handling, and USB remap lifecycle.
- Fold the three Python helpers into Rust:
- USB keyboard backlight control into the existing HID/rusb path
- Bluetooth hidraw backlight control into the existing ioctl HID path
- brightness key injection into a Rust uinput helper path
- Replace shell-managed state in `/tmp/duo*` with daemon-owned structured runtime state under a new Rust-controlled path; the shell-era temp-file contract is not preserved.
### 2. Introduce two Rust runtime processes
- **System daemon**: owns hardware watchers, state machine, policy, persistence, logging, and privileged operations.
- **User-session agent**: a minimal unprivileged process started per login session that performs GNOME/KDE/Niri display changes and other session-scoped actions on behalf of the daemon.
- The daemon and agent communicate over a local Unix socket contract; the daemon is authoritative and the agent is an executor for session-scoped operations only.
### 3. Refactor the Tauri app into a daemon client
- Move the UI off direct file reads, direct shell control, and direct runtime ownership.
- Replace `status`, control, logs, remap, and event flows with daemon-backed requests and event subscriptions over the Unix socket API.
- Keep the Tauri binary as the UI plus existing privileged helper modes only where still useful during migration; post-cutover, the daemon becomes the runtime authority.
- Profiles/settings remain user-facing concepts in the UI, but application of those settings goes through daemon APIs.
### 4. Preserve backend coverage in the Rust world
- Initial Rust cutover must fully support GNOME, KDE, and Niri.
- Backend-specific display logic currently split across shell helpers becomes Rust-managed backend adapters, with the session agent responsible for invoking the compositor-specific commands/tools inside the user session.
- The system daemon must not directly depend on shell-era display helper scripts after the cutover.
### 5. Installer/setup changes only at the integration boundary
- Keep installer/setup scripts in Bash, but switch them to installing/enabling:
- the Rust system daemon service
- the Rust user-session agent service
- the updated UI/client binary
- Remove installation of Python helpers and `duo.sh` from the target state.
- Preserve simple one-line install/update UX even though the runtime beneath it changes completely.
## Public Interfaces and Contracts
### Unix socket daemon API
Define a structured local API for:
- status snapshot
- control actions: backlight, orientation, display layout, profile activation, service reload/restart-style actions
- settings read/write needed by the UI
- remap control and status
- log/event streaming or polling
The API should be versioned from day one so the UI and daemon can detect mismatches cleanly.
### Session-agent contract
Define a narrow daemon-to-agent contract for:
- apply display layout
- set orientation / rotate modes
- launch session UI actions such as emoji picker
- report execution success/failure and session capability/backends
### Service model
Adopt:
- one systemd **system service** for the daemon
- one systemd **user service** for the session agent
- the Tauri app as an optional client, not a required runtime component
## Test Plan
### Functional parity scenarios
- Boot with keyboard attached and detached
- USB attach/detach transitions
- Bluetooth fallback behavior after detach
- Backlight set/cycle/persist across attach-detach and restart
- Brightness up/down behavior with compositor-native handling
- Wi-Fi/Bluetooth restore behavior across dock transitions
- Lock/unlock and suspend/resume transitions
- Rotation/orientation flows
- USB remap start/stop/pause behavior
- GNOME, KDE, and Niri display reconfiguration paths
### Integration scenarios
- UI connects to daemon, reads status, sends controls, and receives events
- Session agent registration and daemon-to-agent action execution
- Agent absent/unavailable behavior is surfaced clearly without daemon crash
- Service startup ordering and recovery when daemon or agent restarts independently
### Failure-mode coverage
- device disappearance during hotplug
- stale session agent socket
- daemon running without a logged-in agent
- permission/device-open failures
- backend command failures on GNOME/KDE/Niri
- UI/daemon API version mismatch
## Assumptions and Defaults
- Installer/setup scripts stay Bash and are explicitly out of scope for Rust migration except for changing what they install and enable.
- Migration includes replacing `duo.sh`, the three Python helpers, and shell-era runtime file/state ownership.
- This is a one-shot cutover, so the implementation must reach full GNOME/KDE/Niri runtime parity before switching installers/services to the Rust daemon by default.
- The new architecture is intentionally a clean break: no requirement to preserve `/usr/local/bin/duo`, `/tmp/duo*`, or the old CLI/state-file contract.
- The recommended execution order is: implement daemon core first, then session agent, then UI refactor to daemon API, then installer/service cutover, then parity validation across all three backends.
@@ -0,0 +1,619 @@
# Touchscreen Toggle Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add per-display touchscreen enable/disable to the Zenbook Duo Control Panel, persisted across reboots.
**Architecture:** sysfs unbind/bind via daemon IPC (daemon runs as root). Settings stored in existing DuoSettings model. UI toggles on Controls and Display pages.
**Tech Stack:** Rust (Tauri backend, daemon), React + TypeScript (frontend), serde JSON IPC
**Spec:** `docs/superpowers/specs/2026-03-22-touchscreen-toggle-design.md`
---
### Task 1: Hardware module — touchscreen detection and control
**Files:**
- Create: `ui-tauri-react/src-tauri/src/hardware/touchscreen.rs`
- Modify: `ui-tauri-react/src-tauri/src/hardware/mod.rs`
- [ ] **Step 1: Create `hardware/touchscreen.rs` with `TouchscreenDevice` struct**
```rust
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,
}
```
- [ ] **Step 2: Implement `list_touchscreens()`**
Scans `/sys/bus/i2c/drivers/i2c_hid_acpi/` for ELAN touchscreen devices. Checks if bound (enabled) by testing if the device symlink exists under the driver directory.
```rust
/// Maps ELAN model number to display connector.
fn elan_to_connector(name: &str) -> Option<&'static str> {
if name.contains("ELAN9008") {
Some("eDP-1")
} else if name.contains("ELAN9009") {
Some("eDP-2")
} else {
None
}
}
/// 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
}
```
- [ ] **Step 3: Implement `set_touchscreen_enabled()`**
```rust
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))
}
```
- [ ] **Step 4: Register module in `hardware/mod.rs`**
Add `pub mod touchscreen;` after existing declarations.
- [ ] **Step 5: Verify it compiles**
Run: `cd ui-tauri-react && cargo check --manifest-path src-tauri/Cargo.toml`
- [ ] **Step 6: Commit**
```bash
git add ui-tauri-react/src-tauri/src/hardware/touchscreen.rs ui-tauri-react/src-tauri/src/hardware/mod.rs
git commit -m "feat: add touchscreen hardware detection and sysfs control"
```
---
### Task 2: IPC protocol — add touchscreen request/response variants
**Files:**
- Modify: `ui-tauri-react/src-tauri/src/ipc/protocol.rs`
- [ ] **Step 1: Add `ListTouchscreens` and `SetTouchscreenEnabled` to `DaemonRequest`**
Add to the `DaemonRequest` enum:
```rust
ListTouchscreens,
SetTouchscreenEnabled { connector: String, enabled: bool },
```
- [ ] **Step 2: Add `Touchscreens` to `DaemonResponse`**
Add to the `DaemonResponse` enum:
```rust
Touchscreens { devices: Vec<crate::hardware::touchscreen::TouchscreenDevice> },
```
- [ ] **Step 3: Verify it compiles**
Run: `cd ui-tauri-react && cargo check --manifest-path src-tauri/Cargo.toml`
- [ ] **Step 4: Commit**
```bash
git add ui-tauri-react/src-tauri/src/ipc/protocol.rs
git commit -m "feat: add touchscreen IPC protocol variants"
```
---
### Task 3: Settings persistence — add `touchscreen_disabled` field
**Files:**
- Modify: `ui-tauri-react/src-tauri/src/models/settings.rs`
- [ ] **Step 1: Add field to `DuoSettings`**
Add to the `DuoSettings` struct:
```rust
#[serde(default)]
pub touchscreen_disabled: Vec<String>,
```
- [ ] **Step 2: Update `Default` implementation**
In the `Default` impl for `DuoSettings`, add:
```rust
touchscreen_disabled: Vec::new(),
```
- [ ] **Step 3: Verify it compiles**
Run: `cd ui-tauri-react && cargo check --manifest-path src-tauri/Cargo.toml`
- [ ] **Step 4: Commit**
```bash
git add ui-tauri-react/src-tauri/src/models/settings.rs
git commit -m "feat: add touchscreen_disabled to settings model"
```
---
### Task 4: Daemon handler — process touchscreen requests
**Files:**
- Modify: `ui-tauri-react/src-tauri/src/runtime/daemon.rs`
- [ ] **Step 1: Add match arms for touchscreen requests in `handle_client`**
In the `match envelope.payload { ... }` block, add:
```rust
DaemonRequest::ListTouchscreens => {
let devices = hardware::touchscreen::list_touchscreens();
DaemonResponse::Touchscreens { devices }
}
DaemonRequest::SetTouchscreenEnabled { connector, enabled } => {
let devices = hardware::touchscreen::list_touchscreens();
match devices.iter().find(|d| d.connector == connector) {
Some(dev) => {
match hardware::touchscreen::set_touchscreen_enabled(&dev.i2c_id, enabled) {
Ok(()) => DaemonResponse::Ack,
Err(message) => DaemonResponse::Error { message },
}
}
None => DaemonResponse::Error {
message: format!("No touchscreen found for connector {}", connector),
},
}
}
```
- [ ] **Step 2: Add touchscreen restore to `handle_lifecycle`**
In the `Post | Thaw | Boot` arm of `handle_lifecycle`, insert **between** the write-guard block (where `guard.touch()` / `persist_state()` are called) and the `forward_session_command` call. Use a new read-lock scope:
```rust
// Restore touchscreen disabled state
{
let guard = state.read().await;
let disabled = guard.settings.touchscreen_disabled.clone();
drop(guard);
for connector in &disabled {
let devices = hardware::touchscreen::list_touchscreens();
if let Some(dev) = devices.iter().find(|d| &d.connector == connector) {
if let Err(e) = hardware::touchscreen::set_touchscreen_enabled(&dev.i2c_id, false) {
eprintln!("rust-daemon: failed to restore touchscreen disabled for {}: {}", connector, e);
}
}
}
}
```
- [ ] **Step 3: Verify it compiles**
Run: `cd ui-tauri-react && cargo check --manifest-path src-tauri/Cargo.toml`
- [ ] **Step 4: Commit**
```bash
git add ui-tauri-react/src-tauri/src/runtime/daemon.rs
git commit -m "feat: handle touchscreen IPC requests and boot restore in daemon"
```
---
### Task 5: Tauri commands — expose touchscreen control to frontend
**Files:**
- Create: `ui-tauri-react/src-tauri/src/commands/touchscreen.rs`
- Modify: `ui-tauri-react/src-tauri/src/commands/mod.rs`
- Modify: `ui-tauri-react/src-tauri/src/lib.rs`
- [ ] **Step 1: Create `commands/touchscreen.rs`**
Follows the daemon-first-with-fallback pattern from `commands/display.rs`:
```rust
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(),
}
}
```
- [ ] **Step 2: Register module in `commands/mod.rs`**
Add `pub mod touchscreen;` after existing declarations.
- [ ] **Step 3: Register commands in `lib.rs` invoke_handler**
Add to the `generate_handler!` macro:
```rust
commands::touchscreen::list_touchscreens,
commands::touchscreen::set_touchscreen_enabled,
```
- [ ] **Step 4: Verify it compiles**
Run: `cd ui-tauri-react && cargo check --manifest-path src-tauri/Cargo.toml`
- [ ] **Step 5: Commit**
```bash
git add ui-tauri-react/src-tauri/src/commands/touchscreen.rs ui-tauri-react/src-tauri/src/commands/mod.rs ui-tauri-react/src-tauri/src/lib.rs
git commit -m "feat: add Tauri commands for touchscreen control"
```
---
### Task 6: Frontend types and API
**Files:**
- Modify: `ui-tauri-react/src/types/duo.ts`
- Modify: `ui-tauri-react/src/lib/tauri.ts`
- [ ] **Step 1: Add `TouchscreenDevice` interface to `duo.ts`**
```typescript
export interface TouchscreenDevice {
name: string;
i2cId: string;
connector: string;
enabled: boolean;
}
```
- [ ] **Step 2: Add `touchscreenDisabled` to `DuoSettings` interface**
```typescript
touchscreenDisabled: string[];
```
- [ ] **Step 3: Add invoke functions to `tauri.ts`**
```typescript
export const listTouchscreens = () =>
invoke<TouchscreenDevice[]>("list_touchscreens");
export const setTouchscreenEnabled = (connector: string, enabled: boolean) =>
invoke<void>("set_touchscreen_enabled", { connector, enabled });
```
- [ ] **Step 4: Verify frontend compiles**
Run: `cd ui-tauri-react && npm run check` (or `npx tsc --noEmit`)
- [ ] **Step 5: Commit**
```bash
git add ui-tauri-react/src/types/duo.ts ui-tauri-react/src/lib/tauri.ts
git commit -m "feat: add touchscreen types and API functions"
```
---
### Task 7: Controls page — touchscreen toggle section
**Files:**
- Modify: `ui-tauri-react/src/pages/Controls.tsx`
- [ ] **Step 1: Add imports and state**
Add `useEffect` to the React import (line 1 currently only imports `useState`). Add to existing imports:
```typescript
import { useState, useEffect } from "react";
import { listTouchscreens, setTouchscreenEnabled } from "@/lib/tauri";
import { TouchscreenDevice } from "@/types/duo";
import { Switch } from "@/components/ui/switch";
import { IconHandFinger } from "@tabler/icons-react";
```
Add state inside the component:
```typescript
const [touchscreens, setTouchscreens] = useState<TouchscreenDevice[]>([]);
useEffect(() => {
listTouchscreens().then(setTouchscreens).catch(console.error);
}, []);
const handleTouchToggle = async (connector: string, enabled: boolean) => {
try {
await setTouchscreenEnabled(connector, enabled);
setTouchscreens((prev) =>
prev.map((ts) => (ts.connector === connector ? { ...ts, enabled } : ts))
);
} catch (e) {
console.error("Failed to toggle touchscreen:", e);
}
};
```
- [ ] **Step 2: Add touchscreen card section**
Add after the existing card sections (Service Control card). Follow the same glass-card pattern:
```tsx
{touchscreens.length > 0 && (
<div className="glass-card animate-stagger-in stagger-4 rounded-xl p-5">
<div className="mb-5 flex items-center gap-2.5">
<div className="flex size-7 items-center justify-center rounded-lg bg-purple-500/12 text-purple-500 dark:bg-purple-400/10 dark:text-purple-400">
<IconHandFinger className="size-3.5" stroke={1.75} />
</div>
<div>
<h3 className="text-[13px] font-semibold text-foreground">
Touchscreen
</h3>
<p className="text-[11px] text-muted-foreground">
Enable or disable touch input per display
</p>
</div>
</div>
<div className="space-y-3">
{touchscreens.map((ts) => (
<div key={ts.connector} className="flex items-center justify-between">
<span className="text-[13px]">
{ts.connector}
<span className="text-muted-foreground ml-2 text-[11px]">
{ts.name}
</span>
</span>
<Switch
checked={ts.enabled}
onCheckedChange={(checked) =>
handleTouchToggle(ts.connector, checked)
}
/>
</div>
))}
</div>
</div>
)}
```
- [ ] **Step 3: Verify frontend compiles and renders**
Run: `cd ui-tauri-react && npm run check`
- [ ] **Step 4: Commit**
```bash
git add ui-tauri-react/src/pages/Controls.tsx
git commit -m "feat: add touchscreen toggle section to Controls page"
```
---
### Task 8: Display page — per-display touch toggle
**Files:**
- Modify: `ui-tauri-react/src/pages/DisplayLayout.tsx`
- [ ] **Step 1: Add imports and state**
Add to existing imports:
```typescript
import { listTouchscreens, setTouchscreenEnabled } from "@/lib/tauri";
import { TouchscreenDevice } from "@/types/duo";
import { Switch } from "@/components/ui/switch";
```
Add state inside the component:
```typescript
const [touchscreens, setTouchscreens] = useState<TouchscreenDevice[]>([]);
useEffect(() => {
listTouchscreens().then(setTouchscreens).catch(console.error);
}, []);
const handleTouchToggle = async (connector: string, enabled: boolean) => {
try {
await setTouchscreenEnabled(connector, enabled);
setTouchscreens((prev) =>
prev.map((ts) => (ts.connector === connector ? { ...ts, enabled } : ts))
);
} catch (e) {
console.error("Failed to toggle touchscreen:", e);
}
};
```
- [ ] **Step 2: Add touch toggle to per-display details**
In the "Connected Displays" section where each display's stats are shown, add a touch toggle for displays that have a mapped touchscreen. After the stats line (`{d.width}x{d.height} @ ...`), add:
```tsx
{(() => {
const ts = touchscreens.find((t) => t.connector === d.connector);
if (!ts) return null;
return (
<div className="flex items-center gap-2 mt-2">
<span className="text-xs text-muted-foreground">Touch</span>
<Switch
checked={ts.enabled}
onCheckedChange={(checked) =>
handleTouchToggle(d.connector, checked)
}
/>
</div>
);
})()}
```
- [ ] **Step 3: Verify frontend compiles**
Run: `cd ui-tauri-react && npm run check`
- [ ] **Step 4: Commit**
```bash
git add ui-tauri-react/src/pages/DisplayLayout.tsx
git commit -m "feat: add per-display touch toggle to Display page"
```
---
### Task 9: Settings sync — persist touchscreen state on toggle
**Files:**
- Modify: `ui-tauri-react/src/pages/Controls.tsx`
- Modify: `ui-tauri-react/src/pages/DisplayLayout.tsx`
- [ ] **Step 1: Update `handleTouchToggle` in Controls.tsx to persist settings**
After the `setTouchscreenEnabled` call succeeds, also update and save settings:
```typescript
const handleTouchToggle = async (connector: string, enabled: boolean) => {
try {
await setTouchscreenEnabled(connector, enabled);
setTouchscreens((prev) =>
prev.map((ts) => (ts.connector === connector ? { ...ts, enabled } : ts))
);
// Persist to settings
const settings = await loadSettings();
const disabled = settings.touchscreenDisabled ?? [];
settings.touchscreenDisabled = enabled
? disabled.filter((c) => c !== connector)
: [...disabled.filter((c) => c !== connector), connector];
await saveSettings(settings);
} catch (e) {
console.error("Failed to toggle touchscreen:", e);
}
};
```
Make sure `loadSettings` and `saveSettings` are imported from `@/lib/tauri`.
- [ ] **Step 2: Apply the same update to `handleTouchToggle` in DisplayLayout.tsx**
Same logic as step 1.
- [ ] **Step 3: Verify frontend compiles**
Run: `cd ui-tauri-react && npm run check`
- [ ] **Step 4: Commit**
```bash
git add ui-tauri-react/src/pages/Controls.tsx ui-tauri-react/src/pages/DisplayLayout.tsx
git commit -m "feat: persist touchscreen toggle state to settings"
```
---
### Task 10: Manual testing
- [ ] **Step 1: Build the project**
Run: `cd ui-tauri-react && cargo build --manifest-path src-tauri/Cargo.toml`
- [ ] **Step 2: Verify touchscreen detection**
With the daemon running, open the Control Panel. The Controls page should show a "Touchscreen" section with toggles for eDP-1 and eDP-2.
- [ ] **Step 3: Test toggle**
Toggle one touchscreen off. Verify:
- Touch input stops on that display
- The toggle shows the correct state after refresh
- The setting persists (check settings file)
- [ ] **Step 4: Test boot restore**
Restart the daemon. Verify the previously disabled touchscreen remains disabled.
- [ ] **Step 5: Test re-enable**
Toggle the touchscreen back on. Verify touch input works again.