Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ curl --fail --location --proto '=https' --proto-redir '=https' \
--output decksmith-install.sh && sh decksmith-install.sh
```

On Arch Linux and Arch-based systems like Omarchy, the same command installs
missing packages with pacman, then builds the `main` branch from source
and installs it. There's no signed Arch download, so the build happens locally.

On Fedora 45, Debian 13, Ubuntu 26.04 or Pop!_OS 24.04, opt into the experimental package:

```sh
Expand Down
9 changes: 8 additions & 1 deletion apps/decksmith-daemon/src/audio_target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ impl Connection {
pub(crate) fn exchange(
&mut self,
request: &impl serde::Serialize,
) -> Result<Vec<u8>, &'static str> {
self.exchange_with_timeout(request, Duration::from_secs(3))
}
pub(crate) fn exchange_with_timeout(
&mut self,
request: &impl serde::Serialize,
timeout: Duration,
) -> Result<Vec<u8>, &'static str> {
use std::io::Write;
let bytes = serde_json::to_vec(request).map_err(|_| "audio_target_failed")?;
Expand All @@ -90,7 +97,7 @@ impl Connection {
.and_then(|()| self.input.flush())
.map_err(|_| "audio_target_failed")?;
self.output
.recv_timeout(Duration::from_secs(3))
.recv_timeout(timeout)
.map_err(|error| match error {
std::sync::mpsc::RecvTimeoutError::Timeout => "audio_target_timeout",
std::sync::mpsc::RecvTimeoutError::Disconnected => "audio_target_failed",
Expand Down
140 changes: 140 additions & 0 deletions apps/decksmith-daemon/src/connected_editor_lab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,143 @@ pub fn run(args: &[String]) -> Result<()> {
}
Ok(())
}

#[cfg(test)]
mod live_lock_tests {
use super::*;
use decksmith_core::device_model::ControlAddress;

/// Exercises the real guarded native helper through a VirtualDeck key.
/// The operator configures the disposable guest's locker before each run.
#[test]
#[ignore = "requires an explicitly prepared disposable Hyprland VM"]
fn virtual_key_runs_real_guarded_lock_action() {
assert_eq!(std::env::var("DECKSMITH_ISOLATED_TEST").as_deref(), Ok("1"));
assert!(
std::env::var("DBUS_SESSION_BUS_ADDRESS")
.is_ok_and(|value| value.starts_with("unix:path=/tmp/dbus-"))
);
assert!(std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE").is_some());
let mode = std::env::var("DECKSMITH_LOCK_CASE").unwrap();
assert!(matches!(mode.as_str(), "slow" | "unconfigured"));
let root = std::env::temp_dir().join(format!("decksmith-live-lock-{}", std::process::id()));
let store = Store::create(&root).unwrap();
let discovery = Discovery::new(vec![Candidate::new(
0,
DeviceModel::Mk2,
Some("lock-test-mk2"),
)])
.unwrap();
let id = random_device_id().unwrap();
store
.enroll(0, &discovery, 0, Enrollment::new(id, "Lock test", 50, None))
.unwrap();
let catalog = store.load().unwrap();
let mut value: serde_json::Value =
serde_json::from_slice(catalog.profile(id).unwrap().document().bytes()).unwrap();
value["pages"][0]["keys"][0] =
serde_json::json!({"label":"Lock", "action":{"type":"system", "command":"lock"}});
store
.save_profile(
id,
1,
&serde_json::to_vec(&value).unwrap(),
0,
"Lock test",
50,
)
.unwrap();
let backend = crate::shared_native_media::NativeBackend::connect_daily().unwrap();
let mut runtime =
Runtime::start(store.load().unwrap(), vec![Box::new(backend)], None).unwrap();
let revision = runtime
.discover(Discovery::new(discovery.candidates().to_vec()).unwrap())
.unwrap();
let deck = Arc::new(Mutex::new(VirtualDeck::new(DeviceModel::Mk2)));
runtime
.attach(revision, &discovery, 0, Box::new(Probe(deck.clone())))
.unwrap();
let ready = Instant::now() + Duration::from_secs(10);
while Instant::now() < ready
&& runtime
.statuses()
.iter()
.any(|s| !s.input_ready || !s.display_ready)
{
runtime.tick();
std::thread::sleep(Duration::from_millis(10));
}
assert!(
runtime
.statuses()
.iter()
.all(|s| s.input_ready && s.display_ready)
);
for (time, pressed) in [(1, true), (2, false)] {
deck.lock()
.unwrap()
.inject(InputEvent {
timestamp_ms: time,
event: RawEvent::Key { index: 0, pressed },
})
.unwrap();
}
let started = Instant::now();
let end = started + Duration::from_secs(27);
let result = loop {
let result = runtime
.tick()
.into_iter()
.find(|f| f.scope.device.control == ControlAddress::Key(0));
if let Some(feedback) = result {
break feedback.result;
}
assert!(Instant::now() < end, "no lock action feedback");
std::thread::sleep(Duration::from_millis(10));
};
let elapsed = started.elapsed();
if mode == "slow" {
assert_eq!(result, Ok(()));
assert!(
elapsed >= Duration::from_secs(3),
"fixture did not exceed the old timeout"
);
} else {
assert_eq!(result, Err("lock_unconfirmed"));
assert!(
elapsed >= Duration::from_secs(8),
"lock confirmation returned prematurely"
);
// A lock that was not confirmed might still happen later. Preserve
// the existing fail-closed builtin fence until controls restart.
for (time, pressed) in [(3, true), (4, false)] {
deck.lock()
.unwrap()
.inject(InputEvent {
timestamp_ms: time,
event: RawEvent::Key { index: 0, pressed },
})
.unwrap();
}
let end = Instant::now() + Duration::from_secs(5);
let retry = loop {
if let Some(feedback) = runtime
.tick()
.into_iter()
.find(|f| f.scope.device.control == ControlAddress::Key(0))
{
break feedback.result;
}
assert!(Instant::now() < end, "no blocked retry feedback");
std::thread::sleep(Duration::from_millis(10));
};
assert_eq!(retry, Err("native_builtin_effect_blocked"));
}
println!(
"PASS full VirtualDeck lock action: case={mode}, result={result:?}, elapsed_ms={}",
elapsed.as_millis()
);
assert!(runtime.stop().iter().all(|(_, result)| result.is_ok()));
std::fs::remove_dir_all(root).unwrap();
}
}
20 changes: 20 additions & 0 deletions apps/decksmith-daemon/src/feedback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,12 @@ pub fn failed(check: Check, name: String, code: &str) -> Notice {
"Not set",
"Set brightness",
),
"lock_unconfirmed" => (
"The desktop lock could not be confirmed",
"Check the desktop lock, fix the locker, then restart Background controls before retrying.",
"Lock unclear",
"Check lock",
),
code if code.ends_with("timeout") => (
"The action timed out; completion is unknown",
"Check the target before retrying; a partial change may have occurred.",
Expand Down Expand Up @@ -495,5 +501,19 @@ mod tests {
let timed_out = failed(c, "MPZ".into(), "launch_timeout");
assert!(timed_out.detail.contains("unknown"));
assert!(timed_out.hint.contains("before retrying"));
let lock = failed(
Check {
page: 0,
slot: 0,
label: "Lock".into(),
kind: "action".into(),
target: "Lock".into(),
command: String::new(),
},
"Lock".into(),
"lock_unconfirmed",
);
assert!(lock.detail.contains("could not be confirmed"));
assert!(lock.hint.contains("restart Background controls"));
}
}
Loading
Loading