Skip to content
Merged
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,14 @@ Run wayscriber in the background and toggle with a keybind:
systemctl --user enable --now wayscriber.service
```

No-CLI setup path:
- Open `wayscriber-configurator`
- Go to the `Daemon` tab
- Click `Install/Update Service`, then `Enable + Start`
- Set a shortcut and click `Apply Shortcut`
- GNOME: writes a GNOME custom shortcut (`pkill -SIGUSR1 wayscriber`)
- KDE/Plasma: writes systemd drop-in env (`WAYSCRIBER_PORTAL_SHORTCUT`) for portal global shortcuts

Add keybinding:

Hyprland:
Expand Down
55 changes: 55 additions & 0 deletions configurator/src/app/daemon_setup/command.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
use std::env;
use std::path::PathBuf;
use std::process::Command;

#[derive(Debug)]
pub(super) struct CommandCapture {
pub(super) success: bool,
pub(super) stdout: String,
pub(super) stderr: String,
}

pub(super) fn command_available(program: &str) -> bool {
find_in_path(program).is_some()
}

pub(super) fn find_in_path(binary_name: &str) -> Option<PathBuf> {
let path_var = env::var_os("PATH")?;
env::split_paths(&path_var)
.map(|directory| directory.join(binary_name))
.find(|path| path.exists())
}

pub(super) fn run_command_checked(program: &str, args: &[&str]) -> Result<CommandCapture, String> {
let capture = run_command(program, args)?;
if capture.success {
return Ok(capture);
}
Err(format_command_failure(program, args, &capture))
}

pub(super) fn run_command(program: &str, args: &[&str]) -> Result<CommandCapture, String> {
let output = Command::new(program).args(args).output().map_err(|err| {
format!(
"Failed to execute `{}` with args [{}]: {}",
program,
args.join(" "),
err
)
})?;
Ok(CommandCapture {
success: output.status.success(),
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
})
}

fn format_command_failure(program: &str, args: &[&str], capture: &CommandCapture) -> String {
format!(
"`{}` failed with args [{}]\nstdout: {}\nstderr: {}",
program,
args.join(" "),
capture.stdout.trim(),
capture.stderr.trim()
)
}
93 changes: 93 additions & 0 deletions configurator/src/app/daemon_setup/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
mod command;
mod service;
mod shortcut;

use crate::models::{
DaemonAction, DaemonActionResult, DaemonRuntimeStatus, DesktopEnvironment, ShortcutBackend,
};

use command::command_available;
use service::{
SERVICE_NAME, detect_service_unit_path, install_or_update_user_service, query_service_active,
query_service_enabled, require_systemctl_available, run_systemctl_user,
};
use shortcut::{apply_shortcut, read_configured_shortcut};

pub(super) async fn load_daemon_runtime_status() -> Result<DaemonRuntimeStatus, String> {
load_daemon_runtime_status_sync()
}

pub(super) async fn perform_daemon_action(
action: DaemonAction,
shortcut_input: String,
) -> Result<DaemonActionResult, String> {
let message = perform_daemon_action_sync(action, shortcut_input.trim())?;
let status = load_daemon_runtime_status_sync()?;
Ok(DaemonActionResult { status, message })
}

fn perform_daemon_action_sync(
action: DaemonAction,
shortcut_input: &str,
) -> Result<String, String> {
match action {
DaemonAction::RefreshStatus => Ok("Daemon status refreshed.".to_string()),
DaemonAction::InstallOrUpdateService => {
let service_path = install_or_update_user_service()?;
Ok(format!(
"Installed/updated user service at {}",
service_path.display()
))
}
DaemonAction::EnableAndStartService => {
require_systemctl_available()?;
run_systemctl_user(&["daemon-reload"])?;
run_systemctl_user(&["enable", "--now", SERVICE_NAME])?;
Ok("Enabled and started wayscriber.service.".to_string())
}
DaemonAction::RestartService => {
require_systemctl_available()?;
run_systemctl_user(&["restart", SERVICE_NAME])?;
Ok("Restarted wayscriber.service.".to_string())
}
DaemonAction::StopAndDisableService => {
require_systemctl_available()?;
run_systemctl_user(&["disable", "--now", SERVICE_NAME])?;
Ok("Stopped and disabled wayscriber.service.".to_string())
}
DaemonAction::ApplyShortcut => apply_shortcut(shortcut_input),
}
}

fn load_daemon_runtime_status_sync() -> Result<DaemonRuntimeStatus, String> {
let desktop = DesktopEnvironment::detect_current();
let systemctl_available = command_available("systemctl");
let gsettings_available = command_available("gsettings");
let shortcut_backend =
ShortcutBackend::from_environment(desktop, gsettings_available, systemctl_available);
let service_unit_path = detect_service_unit_path(systemctl_available);
let service_installed = service_unit_path.is_some();
let service_enabled = if systemctl_available {
query_service_enabled()
} else {
false
};
let service_active = if systemctl_available {
query_service_active()
} else {
false
};
let configured_shortcut = read_configured_shortcut(shortcut_backend);

Ok(DaemonRuntimeStatus {
desktop,
shortcut_backend,
systemctl_available,
gsettings_available,
service_installed,
service_enabled,
service_active,
service_unit_path: service_unit_path.map(|path| path.display().to_string()),
configured_shortcut,
})
}
192 changes: 192 additions & 0 deletions configurator/src/app/daemon_setup/service.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
use std::env;
use std::fs;
use std::path::{Path, PathBuf};

use wayscriber::systemd_user_service::{
USER_SERVICE_NAME, escape_systemd_env_value as shared_escape_systemd_env_value,
portal_shortcut_dropin_path as shared_portal_shortcut_dropin_path, render_user_service_unit,
user_service_unit_path as shared_user_service_unit_path,
};

use super::command::{command_available, find_in_path, run_command, run_command_checked};

pub(super) const SERVICE_NAME: &str = USER_SERVICE_NAME;

pub(super) fn detect_service_unit_path(systemctl_available: bool) -> Option<PathBuf> {
if systemctl_available {
let capture = run_command(
"systemctl",
&[
"--user",
"show",
"--property=FragmentPath",
"--value",
SERVICE_NAME,
],
)
.ok()?;
if capture.success {
let trimmed = capture.stdout.trim();
if !trimmed.is_empty() && trimmed != "-" {
return Some(PathBuf::from(trimmed));
}
}
}

if let Some(path) = user_service_unit_path()
&& path.exists()
{
return Some(path);
}

package_service_paths()
.into_iter()
.find(|path| path.exists())
}

pub(super) fn query_service_enabled() -> bool {
let capture = match run_command("systemctl", &["--user", "is-enabled", SERVICE_NAME]) {
Ok(capture) => capture,
Err(_) => return false,
};
if !capture.success {
return false;
}
let value = capture.stdout.trim();
matches!(value, "enabled" | "enabled-runtime" | "linked")
}

pub(super) fn query_service_active() -> bool {
let capture = match run_command("systemctl", &["--user", "is-active", SERVICE_NAME]) {
Ok(capture) => capture,
Err(_) => return false,
};
capture.success && capture.stdout.trim() == "active"
}

pub(super) fn require_systemctl_available() -> Result<(), String> {
if command_available("systemctl") {
Ok(())
} else {
Err("systemctl is not available in PATH.".to_string())
}
}

pub(super) fn run_systemctl_user(args: &[&str]) -> Result<(), String> {
let mut full_args = Vec::with_capacity(args.len() + 1);
full_args.push("--user");
full_args.extend_from_slice(args);
let _ = run_command_checked("systemctl", &full_args)?;
Ok(())
}

pub(super) fn user_service_unit_path() -> Option<PathBuf> {
shared_user_service_unit_path()
}

pub(super) fn portal_shortcut_dropin_path() -> Option<PathBuf> {
shared_portal_shortcut_dropin_path()
}

pub(super) fn install_or_update_user_service() -> Result<PathBuf, String> {
let binary_path = resolve_wayscriber_binary_path()?;

let service_path = user_service_unit_path().ok_or_else(|| {
"Cannot resolve home directory; failed to determine user systemd service path.".to_string()
})?;
let service_dir = service_path
.parent()
.ok_or_else(|| "Invalid user service path".to_string())?;
fs::create_dir_all(service_dir).map_err(|err| {
format!(
"Failed to create user service directory {}: {}",
service_dir.display(),
err
)
})?;

let contents = render_user_service_file(&binary_path);
fs::write(&service_path, contents).map_err(|err| {
format!(
"Failed to write user service file {}: {}",
service_path.display(),
err
)
})?;

if command_available("systemctl") {
run_systemctl_user(&["daemon-reload"])?;
}

Ok(service_path)
}

fn package_service_paths() -> Vec<PathBuf> {
vec![
PathBuf::from("/usr/lib/systemd/user").join(SERVICE_NAME),
PathBuf::from("/etc/systemd/user").join(SERVICE_NAME),
PathBuf::from("/lib/systemd/user").join(SERVICE_NAME),
]
}

fn resolve_wayscriber_binary_path() -> Result<PathBuf, String> {
if let Some(path) = env::var_os("WAYSCRIBER_BIN").map(PathBuf::from)
&& path.exists()
{
return Ok(path);
}

if let Ok(current_exe) = env::current_exe()
&& let Some(exe_dir) = current_exe.parent()
{
let sibling = exe_dir.join("wayscriber");
if sibling.exists() {
return Ok(sibling);
}
}

if let Some(path) = find_in_path("wayscriber") {
return Ok(path);
}

Err(
"Unable to locate `wayscriber` binary. Set WAYSCRIBER_BIN or install `wayscriber` in PATH."
.to_string(),
)
}

fn render_user_service_file(binary_path: &Path) -> String {
render_user_service_unit(binary_path)
}

pub(super) fn escape_systemd_env_value(value: &str) -> String {
shared_escape_systemd_env_value(value)
}

#[cfg(test)]
mod tests {
use super::render_user_service_file;
use std::path::Path;
use wayscriber::systemd_user_service::{
portal_shortcut_dropin_path_from_config_root, user_service_unit_path_from_config_root,
};

#[test]
fn service_paths_are_derived_from_xdg_config_root() {
let root = Path::new("/tmp/xdg-config");
assert_eq!(
user_service_unit_path_from_config_root(root),
Path::new("/tmp/xdg-config/systemd/user/wayscriber.service")
);
assert_eq!(
portal_shortcut_dropin_path_from_config_root(root),
Path::new("/tmp/xdg-config/systemd/user/wayscriber.service.d/shortcut.conf")
);
}

#[test]
fn render_user_service_file_quotes_exec_path() {
let unit = render_user_service_file(Path::new("/tmp/My Apps/wayscriber"));
assert!(unit.contains("ExecStart=\"/tmp/My Apps/wayscriber\" --daemon"));
}
}
Loading