diff --git a/README.md b/README.md index 41558240..ee0bf8b4 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/configurator/src/app/daemon_setup/command.rs b/configurator/src/app/daemon_setup/command.rs new file mode 100644 index 00000000..8efe9f2d --- /dev/null +++ b/configurator/src/app/daemon_setup/command.rs @@ -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 { + 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 { + 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 { + 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() + ) +} diff --git a/configurator/src/app/daemon_setup/mod.rs b/configurator/src/app/daemon_setup/mod.rs new file mode 100644 index 00000000..74ae9f99 --- /dev/null +++ b/configurator/src/app/daemon_setup/mod.rs @@ -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 { + load_daemon_runtime_status_sync() +} + +pub(super) async fn perform_daemon_action( + action: DaemonAction, + shortcut_input: String, +) -> Result { + 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 { + 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 { + 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, + }) +} diff --git a/configurator/src/app/daemon_setup/service.rs b/configurator/src/app/daemon_setup/service.rs new file mode 100644 index 00000000..d3d92462 --- /dev/null +++ b/configurator/src/app/daemon_setup/service.rs @@ -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 { + 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 { + shared_user_service_unit_path() +} + +pub(super) fn portal_shortcut_dropin_path() -> Option { + shared_portal_shortcut_dropin_path() +} + +pub(super) fn install_or_update_user_service() -> Result { + 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 { + 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 { + 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")); + } +} diff --git a/configurator/src/app/daemon_setup/shortcut.rs b/configurator/src/app/daemon_setup/shortcut.rs new file mode 100644 index 00000000..f8daf4ea --- /dev/null +++ b/configurator/src/app/daemon_setup/shortcut.rs @@ -0,0 +1,392 @@ +use std::fs; + +use crate::models::{DesktopEnvironment, ShortcutBackend}; + +use super::command::{command_available, run_command, run_command_checked}; +use super::service::{ + escape_systemd_env_value, portal_shortcut_dropin_path, query_service_active, + require_systemctl_available, run_systemctl_user, +}; + +const PORTAL_APP_ID: &str = "wayscriber"; +const TOGGLE_COMMAND: &str = "pkill -SIGUSR1 wayscriber"; + +const GNOME_MEDIA_KEYS_SCHEMA: &str = "org.gnome.settings-daemon.plugins.media-keys"; +const GNOME_MEDIA_KEYS_KEY: &str = "custom-keybindings"; +const GNOME_CUSTOM_KEYBINDING_SCHEMA: &str = + "org.gnome.settings-daemon.plugins.media-keys.custom-keybinding"; +const GNOME_WAYSCRIBER_KEYBINDING_PATH: &str = + "/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/wayscriber-toggle/"; +const GNOME_SHORTCUT_NAME: &str = "Wayscriber Toggle"; + +pub(super) fn read_configured_shortcut(backend: ShortcutBackend) -> Option { + match backend { + ShortcutBackend::GnomeCustomShortcut => read_gnome_shortcut_binding(), + ShortcutBackend::PortalServiceDropIn => read_portal_shortcut_from_dropin(), + ShortcutBackend::Manual => None, + } +} + +pub(super) fn apply_shortcut(shortcut_input: &str) -> Result { + let desktop = DesktopEnvironment::detect_current(); + let backend = ShortcutBackend::from_environment( + desktop, + command_available("gsettings"), + command_available("systemctl"), + ); + + match backend { + ShortcutBackend::GnomeCustomShortcut => { + let normalized = normalize_shortcut_for_gnome(shortcut_input)?; + apply_gnome_custom_shortcut(&normalized)?; + Ok(format!("Configured GNOME shortcut: {normalized}")) + } + ShortcutBackend::PortalServiceDropIn => { + require_systemctl_available()?; + let normalized = normalize_shortcut_for_portal(shortcut_input)?; + let dropin_path = write_portal_shortcut_dropin(&normalized)?; + run_systemctl_user(&["daemon-reload"])?; + if query_service_active() { + run_systemctl_user(&["restart", "wayscriber.service"])?; + } + Ok(format!( + "Configured portal shortcut: {normalized} (drop-in: {})", + dropin_path.display() + )) + } + ShortcutBackend::Manual => Err( + "Automatic shortcut setup is not available in this desktop session; bind `pkill -SIGUSR1 wayscriber` manually." + .to_string(), + ), + } +} + +fn apply_gnome_custom_shortcut(binding: &str) -> Result<(), String> { + require_gsettings_available()?; + + let mut bindings = read_gnome_custom_keybinding_paths()?; + if !bindings + .iter() + .any(|path| path == GNOME_WAYSCRIBER_KEYBINDING_PATH) + { + bindings.push(GNOME_WAYSCRIBER_KEYBINDING_PATH.to_string()); + } + let rendered_bindings = serialize_gsettings_path_list(&bindings); + + run_gsettings_command(&[ + "set", + GNOME_MEDIA_KEYS_SCHEMA, + GNOME_MEDIA_KEYS_KEY, + &rendered_bindings, + ])?; + + let schema_with_path = + format!("{GNOME_CUSTOM_KEYBINDING_SCHEMA}:{GNOME_WAYSCRIBER_KEYBINDING_PATH}"); + run_gsettings_command(&[ + "set", + &schema_with_path, + "name", + &gvariant_string_literal(GNOME_SHORTCUT_NAME), + ])?; + run_gsettings_command(&[ + "set", + &schema_with_path, + "command", + &gvariant_string_literal(TOGGLE_COMMAND), + ])?; + run_gsettings_command(&[ + "set", + &schema_with_path, + "binding", + &gvariant_string_literal(binding), + ])?; + + Ok(()) +} + +fn read_gnome_custom_keybinding_paths() -> Result, String> { + let capture = run_command_checked( + "gsettings", + &["get", GNOME_MEDIA_KEYS_SCHEMA, GNOME_MEDIA_KEYS_KEY], + )?; + parse_gsettings_path_list(&capture.stdout) +} + +fn read_gnome_shortcut_binding() -> Option { + if !command_available("gsettings") { + return None; + } + let schema_with_path = + format!("{GNOME_CUSTOM_KEYBINDING_SCHEMA}:{GNOME_WAYSCRIBER_KEYBINDING_PATH}"); + let capture = run_command("gsettings", &["get", &schema_with_path, "binding"]).ok()?; + if !capture.success { + return None; + } + parse_gsettings_string_value(capture.stdout.trim()) +} + +fn require_gsettings_available() -> Result<(), String> { + if command_available("gsettings") { + Ok(()) + } else { + Err("gsettings is not available in PATH.".to_string()) + } +} + +fn run_gsettings_command(args: &[&str]) -> Result<(), String> { + let _ = run_command_checked("gsettings", args)?; + Ok(()) +} + +fn write_portal_shortcut_dropin(shortcut: &str) -> Result { + let dropin_path = portal_shortcut_dropin_path().ok_or_else(|| { + "Cannot resolve home directory; failed to determine systemd drop-in path.".to_string() + })?; + let dropin_dir = dropin_path + .parent() + .ok_or_else(|| "Invalid drop-in path".to_string())?; + fs::create_dir_all(dropin_dir).map_err(|err| { + format!( + "Failed to create service drop-in directory {}: {}", + dropin_dir.display(), + err + ) + })?; + + let escaped_shortcut = escape_systemd_env_value(shortcut); + let escaped_app_id = escape_systemd_env_value(PORTAL_APP_ID); + let contents = format!( + "[Service]\nEnvironment=\"WAYSCRIBER_PORTAL_SHORTCUT={escaped_shortcut}\"\nEnvironment=\"WAYSCRIBER_PORTAL_APP_ID={escaped_app_id}\"\n" + ); + fs::write(&dropin_path, contents).map_err(|err| { + format!( + "Failed to write portal shortcut drop-in {}: {}", + dropin_path.display(), + err + ) + })?; + Ok(dropin_path) +} + +fn read_portal_shortcut_from_dropin() -> Option { + let path = portal_shortcut_dropin_path()?; + let content = fs::read_to_string(path).ok()?; + parse_portal_shortcut_from_dropin(&content) +} + +fn parse_portal_shortcut_from_dropin(content: &str) -> Option { + content.lines().find_map(|line| { + let trimmed = line.trim(); + let prefix = "Environment=\"WAYSCRIBER_PORTAL_SHORTCUT="; + if !trimmed.starts_with(prefix) || !trimmed.ends_with('"') { + return None; + } + let inner = &trimmed[prefix.len()..trimmed.len() - 1]; + if inner.is_empty() { + return None; + } + Some(inner.replace("\\\"", "\"").replace("\\\\", "\\")) + }) +} + +fn parse_gsettings_path_list(raw: &str) -> Result, String> { + let trimmed = raw.trim(); + let list_literal = trimmed.strip_prefix("@as ").map_or(trimmed, str::trim); + if !list_literal.starts_with('[') || !list_literal.ends_with(']') { + return Err(format!( + "Unexpected gsettings list format: `{}`", + raw.trim() + )); + } + let inner = list_literal[1..list_literal.len() - 1].trim(); + if inner.is_empty() { + return Ok(Vec::new()); + } + + let mut values = Vec::new(); + for chunk in inner.split(',') { + let value = chunk.trim().trim_matches('\'').trim_matches('"').trim(); + if value.is_empty() { + continue; + } + values.push(value.to_string()); + } + Ok(values) +} + +fn serialize_gsettings_path_list(paths: &[String]) -> String { + let mut rendered = String::from("["); + for (index, path) in paths.iter().enumerate() { + if index > 0 { + rendered.push_str(", "); + } + rendered.push('\''); + rendered.push_str(path); + rendered.push('\''); + } + rendered.push(']'); + rendered +} + +fn gvariant_string_literal(value: &str) -> String { + let escaped = value.replace('\\', "\\\\").replace('\'', "\\'"); + format!("'{escaped}'") +} + +fn parse_gsettings_string_value(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed == "''" { + return None; + } + let unquoted = trimmed + .strip_prefix('\'') + .and_then(|value| value.strip_suffix('\'')) + .unwrap_or(trimmed); + if unquoted.is_empty() { + return None; + } + Some(unquoted.replace("\\'", "'").replace("\\\\", "\\")) +} + +fn normalize_shortcut_for_gnome(input: &str) -> Result { + normalize_shortcut(input, true) +} + +fn normalize_shortcut_for_portal(input: &str) -> Result { + normalize_shortcut(input, false) +} + +fn normalize_shortcut(input: &str, gnome_style: bool) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Ok(if gnome_style { + "g".to_string() + } else { + "g".to_string() + }); + } + if trimmed.contains('<') && trimmed.contains('>') { + return Ok(trimmed.to_string()); + } + + let parts: Vec<&str> = trimmed + .split('+') + .map(str::trim) + .filter(|segment| !segment.is_empty()) + .collect(); + if parts.is_empty() { + return Err("Shortcut cannot be empty.".to_string()); + } + let key = normalize_key_name(parts[parts.len() - 1])?; + let modifiers = &parts[..parts.len() - 1]; + + let mut rendered_modifiers: Vec<&'static str> = Vec::new(); + for modifier in modifiers { + let normalized = normalize_modifier(modifier, gnome_style).ok_or_else(|| { + format!( + "Unsupported modifier `{}`. Supported: Ctrl, Shift, Alt, Super/Meta.", + modifier + ) + })?; + if !rendered_modifiers.contains(&normalized) { + rendered_modifiers.push(normalized); + } + } + + let mut normalized = String::new(); + for modifier in rendered_modifiers { + normalized.push_str(modifier); + } + normalized.push_str(&key); + Ok(normalized) +} + +fn normalize_modifier(modifier: &str, gnome_style: bool) -> Option<&'static str> { + match modifier.to_ascii_lowercase().as_str() { + "ctrl" | "control" | "primary" => Some(if gnome_style { "" } else { "" }), + "shift" => Some(""), + "alt" | "option" => Some(""), + "super" | "meta" | "win" | "windows" => Some(""), + _ => None, + } +} + +fn normalize_key_name(key: &str) -> Result { + let trimmed = key.trim(); + if trimmed.is_empty() { + return Err("Shortcut key is empty.".to_string()); + } + let upper = trimmed.to_ascii_uppercase(); + if upper.starts_with('F') + && upper.len() > 1 + && upper[1..] + .chars() + .all(|character| character.is_ascii_digit()) + { + return Ok(upper); + } + if trimmed.chars().count() == 1 { + return Ok(trimmed.to_ascii_lowercase()); + } + Ok(trimmed.to_ascii_lowercase()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_gsettings_paths_handles_empty_variants() { + assert_eq!( + parse_gsettings_path_list("@as []").unwrap(), + Vec::::new() + ); + assert_eq!( + parse_gsettings_path_list("[]").unwrap(), + Vec::::new() + ); + } + + #[test] + fn parse_and_serialize_gsettings_paths_round_trip() { + let raw = "['/org/one/', '/org/two/']"; + let parsed = parse_gsettings_path_list(raw).expect("parse gsettings path list"); + assert_eq!( + parsed, + vec!["/org/one/".to_string(), "/org/two/".to_string()] + ); + assert_eq!(serialize_gsettings_path_list(&parsed), raw); + } + + #[test] + fn parse_portal_shortcut_reads_dropin_value() { + let content = "[Service]\nEnvironment=\"WAYSCRIBER_PORTAL_SHORTCUT=g\"\n"; + assert_eq!( + parse_portal_shortcut_from_dropin(content), + Some("g".to_string()) + ); + } + + #[test] + fn normalize_shortcut_supports_human_readable_input() { + assert_eq!( + normalize_shortcut_for_gnome("Super+G").unwrap(), + "g".to_string() + ); + assert_eq!( + normalize_shortcut_for_portal("Ctrl+Shift+G").unwrap(), + "g".to_string() + ); + assert_eq!( + normalize_shortcut_for_portal("g").unwrap(), + "g".to_string() + ); + } + + #[test] + fn normalize_shortcut_rejects_unknown_modifier() { + let error = + normalize_shortcut_for_portal("Hyper+G").expect_err("expected invalid shortcut"); + assert!(error.contains("Unsupported modifier")); + } +} diff --git a/configurator/src/app/mod.rs b/configurator/src/app/mod.rs index 0728f9f5..ce2d19fe 100644 --- a/configurator/src/app/mod.rs +++ b/configurator/src/app/mod.rs @@ -1,3 +1,4 @@ +mod daemon_setup; mod entry; mod io; mod state; diff --git a/configurator/src/app/state.rs b/configurator/src/app/state.rs index 7f5716da..18f902b3 100644 --- a/configurator/src/app/state.rs +++ b/configurator/src/app/state.rs @@ -6,9 +6,11 @@ use wayscriber::config::{Config, PRESET_SLOTS_MAX}; use crate::messages::Message; use crate::models::{ - ColorPickerId, ConfigDraft, KeybindingsTabId, TabId, ToolbarLayoutModeOption, UiTabId, + ColorPickerId, ConfigDraft, DaemonRuntimeStatus, DesktopEnvironment, KeybindingsTabId, TabId, + ToolbarLayoutModeOption, UiTabId, }; +use super::daemon_setup::load_daemon_runtime_status; use super::io::load_config_from_disk; #[derive(Debug)] @@ -34,6 +36,13 @@ pub(crate) struct ConfiguratorApp { pub(crate) config_path: Option, pub(crate) config_mtime: Option, pub(crate) last_backup_path: Option, + pub(crate) daemon_status: Option, + pub(crate) daemon_shortcut_input: String, + pub(crate) daemon_feedback: Option, + pub(crate) daemon_busy: bool, + pub(crate) daemon_next_status_request_id: u64, + pub(crate) daemon_latest_status_request_id: u64, + pub(crate) daemon_preserve_feedback_status_request_id: Option, } #[derive(Debug, Clone)] @@ -71,6 +80,7 @@ impl ConfiguratorApp { let boards_len = defaults.boards.items.len(); let config_path = Config::get_config_path().ok(); let base_config = Arc::new(default_config.clone()); + let desktop = DesktopEnvironment::detect_current(); let mut app = Self { draft: baseline.clone(), @@ -78,7 +88,7 @@ impl ConfiguratorApp { defaults, base_config, status: StatusMessage::info("Loading configuration..."), - active_tab: TabId::Drawing, + active_tab: TabId::Daemon, active_ui_tab: UiTabId::Toolbar, active_keybindings_tab: KeybindingsTabId::General, preset_collapsed: vec![false; PRESET_SLOTS_MAX], @@ -93,13 +103,23 @@ impl ConfiguratorApp { config_path, config_mtime: None, last_backup_path: None, + daemon_status: None, + daemon_shortcut_input: desktop.default_shortcut_input().to_string(), + daemon_feedback: Some("Detecting background mode setup status...".to_string()), + daemon_busy: false, + daemon_next_status_request_id: 2, + daemon_latest_status_request_id: 1, + daemon_preserve_feedback_status_request_id: None, }; app.sync_all_color_picker_hex(); - let command = Command::batch(vec![Command::perform( - load_config_from_disk(), - Message::ConfigLoaded, - )]); + let initial_status_request_id = app.daemon_latest_status_request_id; + let command = Command::batch(vec![ + Command::perform(load_config_from_disk(), Message::ConfigLoaded), + Command::perform(load_daemon_runtime_status(), move |result| { + Message::DaemonStatusLoaded(initial_status_request_id, result) + }), + ]); (app, command) } diff --git a/configurator/src/app/update/daemon.rs b/configurator/src/app/update/daemon.rs new file mode 100644 index 00000000..a54a89e0 --- /dev/null +++ b/configurator/src/app/update/daemon.rs @@ -0,0 +1,348 @@ +use iced::Command; + +use crate::messages::Message; +use crate::models::{DaemonAction, DaemonActionResult, DaemonRuntimeStatus}; + +use super::super::daemon_setup::{load_daemon_runtime_status, perform_daemon_action}; +use super::super::state::ConfiguratorApp; + +impl ConfiguratorApp { + pub(super) fn handle_daemon_status_loaded( + &mut self, + request_id: u64, + result: Result, + ) -> Command { + if request_id != self.daemon_latest_status_request_id { + return Command::none(); + } + let preserve_feedback = self.daemon_preserve_feedback_status_request_id == Some(request_id); + if preserve_feedback { + self.daemon_preserve_feedback_status_request_id = None; + } + match result { + Ok(status) => { + self.apply_daemon_status(status); + if should_update_feedback_after_status_load( + preserve_feedback, + self.daemon_busy, + self.daemon_feedback.as_deref(), + ) { + self.daemon_feedback = Some("Background mode status loaded.".to_string()); + } + } + Err(err) => { + if preserve_feedback && !self.daemon_busy { + let previous_feedback = self + .daemon_feedback + .as_deref() + .unwrap_or("Background setup action failed."); + self.daemon_feedback = + Some(format!("{previous_feedback}\nStatus refresh failed: {err}")); + } else if !self.daemon_busy { + self.daemon_feedback = + Some(format!("Failed to load background setup status: {err}")); + } + } + } + Command::none() + } + + pub(super) fn handle_daemon_shortcut_input_changed( + &mut self, + value: String, + ) -> Command { + self.daemon_shortcut_input = value; + Command::none() + } + + pub(super) fn handle_daemon_action_requested( + &mut self, + action: DaemonAction, + ) -> Command { + if self.daemon_busy { + return Command::none(); + } + self.invalidate_pending_daemon_status_requests(); + self.daemon_busy = true; + self.daemon_feedback = Some(action_pending_message(action)); + let shortcut_input = self.daemon_shortcut_input.clone(); + Command::perform( + perform_daemon_action(action, shortcut_input), + Message::DaemonActionCompleted, + ) + } + + pub(super) fn handle_daemon_action_completed( + &mut self, + result: Result, + ) -> Command { + self.daemon_busy = false; + match result { + Ok(output) => { + self.apply_daemon_status(output.status); + self.daemon_feedback = Some(output.message); + Command::none() + } + Err(err) => { + self.daemon_feedback = Some(format!("Background setup action failed: {err}")); + self.schedule_daemon_status_reload(true) + } + } + } + + fn apply_daemon_status(&mut self, status: DaemonRuntimeStatus) { + if let Some(configured_shortcut) = status.configured_shortcut.clone() { + self.daemon_shortcut_input = configured_shortcut; + } else if self.daemon_shortcut_input.trim().is_empty() { + self.daemon_shortcut_input = status.desktop.default_shortcut_input().to_string(); + } + self.daemon_status = Some(status); + } + + fn schedule_daemon_status_reload(&mut self, preserve_feedback: bool) -> Command { + let request_id = self.daemon_next_status_request_id; + self.daemon_next_status_request_id = self.daemon_next_status_request_id.saturating_add(1); + self.daemon_latest_status_request_id = request_id; + if preserve_feedback { + self.daemon_preserve_feedback_status_request_id = Some(request_id); + } + Command::perform(load_daemon_runtime_status(), move |result| { + Message::DaemonStatusLoaded(request_id, result) + }) + } + + fn invalidate_pending_daemon_status_requests(&mut self) { + let invalidation_id = self.daemon_next_status_request_id; + self.daemon_next_status_request_id = self.daemon_next_status_request_id.saturating_add(1); + self.daemon_latest_status_request_id = invalidation_id; + self.daemon_preserve_feedback_status_request_id = None; + } +} + +fn should_update_feedback_after_status_load( + preserve_feedback: bool, + daemon_busy: bool, + current_feedback: Option<&str>, +) -> bool { + if preserve_feedback || daemon_busy { + return false; + } + let Some(feedback) = current_feedback else { + return true; + }; + let normalized = feedback.to_ascii_lowercase(); + normalized.contains("detecting background mode setup status") + || normalized.contains("refreshing background setup status") + || normalized.contains("detecting daemon setup status") + || normalized.contains("refreshing daemon status") + || normalized == "background mode status loaded." + || normalized == "daemon status loaded." +} + +fn action_pending_message(action: DaemonAction) -> String { + match action { + DaemonAction::RefreshStatus => "Refreshing background setup status...".to_string(), + DaemonAction::InstallOrUpdateService => { + "Installing/updating background service...".to_string() + } + DaemonAction::EnableAndStartService => { + "Enabling and starting background mode...".to_string() + } + DaemonAction::RestartService => "Restarting background service...".to_string(), + DaemonAction::StopAndDisableService => { + "Stopping and disabling background mode...".to_string() + } + DaemonAction::ApplyShortcut => "Applying desktop shortcut setup...".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{DesktopEnvironment, ShortcutBackend}; + + #[test] + fn daemon_status_loaded_sets_default_shortcut_when_missing() { + let (mut app, _command) = ConfiguratorApp::new_app(); + app.daemon_shortcut_input.clear(); + let status = DaemonRuntimeStatus { + desktop: DesktopEnvironment::Kde, + shortcut_backend: ShortcutBackend::PortalServiceDropIn, + systemctl_available: true, + gsettings_available: false, + service_installed: false, + service_enabled: false, + service_active: false, + service_unit_path: None, + configured_shortcut: None, + }; + + app.daemon_latest_status_request_id = 7; + let _ = app.handle_daemon_status_loaded(7, Ok(status)); + + assert_eq!(app.daemon_shortcut_input, "Ctrl+Shift+G"); + assert!(app.daemon_status.is_some()); + } + + #[test] + fn daemon_action_completion_error_sets_feedback() { + let (mut app, _command) = ConfiguratorApp::new_app(); + let _ = app.handle_daemon_action_completed(Err("boom".to_string())); + assert!( + app.daemon_feedback + .as_deref() + .unwrap_or_default() + .contains("Background setup action failed") + ); + assert_eq!( + app.daemon_preserve_feedback_status_request_id, + Some(app.daemon_latest_status_request_id) + ); + } + + #[test] + fn status_loaded_does_not_clear_daemon_busy() { + let (mut app, _command) = ConfiguratorApp::new_app(); + app.daemon_busy = true; + app.daemon_feedback = Some("Installing/updating background service...".to_string()); + let status = DaemonRuntimeStatus { + desktop: DesktopEnvironment::Kde, + shortcut_backend: ShortcutBackend::PortalServiceDropIn, + systemctl_available: true, + gsettings_available: false, + service_installed: false, + service_enabled: false, + service_active: false, + service_unit_path: None, + configured_shortcut: None, + }; + + app.daemon_latest_status_request_id = 9; + let _ = app.handle_daemon_status_loaded(9, Ok(status)); + + assert!(app.daemon_busy); + assert_eq!( + app.daemon_feedback.as_deref(), + Some("Installing/updating background service...") + ); + } + + #[test] + fn failed_action_feedback_is_preserved_after_status_reload() { + let (mut app, _command) = ConfiguratorApp::new_app(); + let _ = app.handle_daemon_action_completed(Err("boom".to_string())); + let preserved_request_id = app.daemon_latest_status_request_id; + let status = DaemonRuntimeStatus { + desktop: DesktopEnvironment::Kde, + shortcut_backend: ShortcutBackend::PortalServiceDropIn, + systemctl_available: true, + gsettings_available: false, + service_installed: false, + service_enabled: false, + service_active: false, + service_unit_path: None, + configured_shortcut: None, + }; + + let _ = app.handle_daemon_status_loaded(preserved_request_id, Ok(status)); + + assert!( + app.daemon_feedback + .as_deref() + .unwrap_or_default() + .contains("Background setup action failed: boom") + ); + assert!(app.daemon_preserve_feedback_status_request_id.is_none()); + } + + #[test] + fn stale_status_callback_does_not_consume_preserve_flag() { + let (mut app, _command) = ConfiguratorApp::new_app(); + let _ = app.handle_daemon_action_completed(Err("boom".to_string())); + let preserved_request_id = app.daemon_latest_status_request_id; + let stale_request_id = preserved_request_id.saturating_sub(1); + let stale_status = DaemonRuntimeStatus { + desktop: DesktopEnvironment::Kde, + shortcut_backend: ShortcutBackend::PortalServiceDropIn, + systemctl_available: true, + gsettings_available: false, + service_installed: false, + service_enabled: false, + service_active: false, + service_unit_path: None, + configured_shortcut: None, + }; + let _ = app.handle_daemon_status_loaded(stale_request_id, Ok(stale_status)); + + assert_eq!( + app.daemon_preserve_feedback_status_request_id, + Some(preserved_request_id) + ); + } + + #[test] + fn preserved_error_is_not_applied_while_new_action_is_busy() { + let (mut app, _command) = ConfiguratorApp::new_app(); + let _ = app.handle_daemon_action_completed(Err("boom".to_string())); + let preserved_request_id = app.daemon_latest_status_request_id; + app.daemon_busy = true; + app.daemon_feedback = Some("Restarting background service...".to_string()); + + let _ = app.handle_daemon_status_loaded( + preserved_request_id, + Err("portal temporarily unavailable".to_string()), + ); + + assert_eq!( + app.daemon_feedback.as_deref(), + Some("Restarting background service...") + ); + } + + #[test] + fn old_status_callback_after_newer_action_success_is_ignored() { + let (mut app, _command) = ConfiguratorApp::new_app(); + let old_status = DaemonRuntimeStatus { + desktop: DesktopEnvironment::Kde, + shortcut_backend: ShortcutBackend::PortalServiceDropIn, + systemctl_available: true, + gsettings_available: false, + service_installed: false, + service_enabled: false, + service_active: false, + service_unit_path: None, + configured_shortcut: Some("old".to_string()), + }; + let new_status = DaemonRuntimeStatus { + desktop: DesktopEnvironment::Kde, + shortcut_backend: ShortcutBackend::PortalServiceDropIn, + systemctl_available: true, + gsettings_available: false, + service_installed: true, + service_enabled: true, + service_active: true, + service_unit_path: Some("/tmp/wayscriber.service".to_string()), + configured_shortcut: Some("new".to_string()), + }; + + let _ = app.handle_daemon_action_completed(Err("old failure".to_string())); + let old_request_id = app.daemon_latest_status_request_id; + + let _ = app.handle_daemon_action_requested(DaemonAction::RefreshStatus); + let _ = app.handle_daemon_action_completed(Ok(DaemonActionResult { + status: new_status.clone(), + message: "refresh complete".to_string(), + })); + + let _ = app.handle_daemon_status_loaded(old_request_id, Ok(old_status)); + + assert_eq!(app.daemon_shortcut_input.as_str(), "new"); + assert_eq!( + app.daemon_status + .as_ref() + .and_then(|status| status.configured_shortcut.as_deref()), + Some("new") + ); + assert_eq!(app.daemon_feedback.as_deref(), Some("refresh complete")); + } +} diff --git a/configurator/src/app/update/mod.rs b/configurator/src/app/update/mod.rs index 7dee1074..3e43d07d 100644 --- a/configurator/src/app/update/mod.rs +++ b/configurator/src/app/update/mod.rs @@ -1,6 +1,7 @@ mod boards; mod color_picker; mod config; +mod daemon; mod fields; mod presets; mod tabs; @@ -19,6 +20,14 @@ impl ConfiguratorApp { Message::ResetToDefaults => self.handle_reset_to_defaults(), Message::SaveRequested => self.handle_save_requested(), Message::ConfigSaved(result) => self.handle_config_saved(result), + Message::DaemonStatusLoaded(request_id, result) => { + self.handle_daemon_status_loaded(request_id, result) + } + Message::DaemonShortcutInputChanged(value) => { + self.handle_daemon_shortcut_input_changed(value) + } + Message::DaemonActionRequested(action) => self.handle_daemon_action_requested(action), + Message::DaemonActionCompleted(result) => self.handle_daemon_action_completed(result), Message::TabSelected(tab) => self.handle_tab_selected(tab), Message::UiTabSelected(tab) => self.handle_ui_tab_selected(tab), Message::KeybindingsTabSelected(tab) => self.handle_keybindings_tab_selected(tab), diff --git a/configurator/src/app/view/daemon.rs b/configurator/src/app/view/daemon.rs new file mode 100644 index 00000000..3a9d642f --- /dev/null +++ b/configurator/src/app/view/daemon.rs @@ -0,0 +1,337 @@ +use iced::Element; +use iced::theme; +use iced::widget::{button, column, horizontal_rule, row, scrollable, text, text_input}; + +use crate::messages::Message; +use crate::models::{DaemonAction, ShortcutBackend}; + +use super::super::state::ConfiguratorApp; + +impl ConfiguratorApp { + pub(super) fn daemon_tab(&self) -> Element<'_, Message> { + let busy = self.daemon_busy; + let status_loading = self.daemon_status.is_none(); + let service_installed = self + .daemon_status + .as_ref() + .is_some_and(|status| status.service_installed); + + let mut content = column![].spacing(16); + + // ── Title and explanation ── + content = content.push(text("Background Mode").size(20)).push(text( + "Run wayscriber in the background and toggle it with a keyboard shortcut.", + )); + + // ── Overall status summary ── + content = content.push(self.daemon_overall_status(busy)); + + // ── Feedback banner ── + if let Some(feedback) = self.daemon_feedback.as_deref() { + let styled = if feedback.to_ascii_lowercase().contains("failed") + || feedback.to_ascii_lowercase().contains("error") + { + text(feedback).style(theme::Text::Color(iced::Color::from_rgb(1.0, 0.5, 0.5))) + } else { + text(feedback).style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.9, 0.6))) + }; + content = content.push(styled); + } + + if busy { + content = content.push(text("Working...").size(12)); + } + + if status_loading { + content = content.push( + text("Checking your system and background service status...") + .size(14) + .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), + ); + content = content.push(self.daemon_technical_details(busy)); + return scrollable(content).into(); + } + + content = content.push(horizontal_rule(1)); + + // ── Step 1: Install the service ── + content = content.push(self.daemon_step_install(busy)); + content = content.push(horizontal_rule(1)); + + // ── Step 2: Set your shortcut ── + content = content.push(self.daemon_step_shortcut(busy, service_installed)); + content = content.push(horizontal_rule(1)); + + // ── Step 3: Start the service ── + content = content.push(self.daemon_step_start(busy, service_installed)); + content = content.push(horizontal_rule(1)); + + // ── Technical details (bottom) ── + content = content.push(self.daemon_technical_details(busy)); + + scrollable(content).into() + } + + fn daemon_overall_status(&self, busy: bool) -> Element<'_, Message> { + let (label, color) = match self.daemon_status.as_ref() { + None => ("Status: Detecting...", iced::Color::from_rgb(0.6, 0.6, 0.6)), + Some(status) => { + if status.service_active { + ("Status: Running", iced::Color::from_rgb(0.5, 0.9, 0.5)) + } else if status.service_installed { + ( + "Status: Installed, not running", + iced::Color::from_rgb(0.95, 0.8, 0.3), + ) + } else { + ( + "Status: Not installed", + iced::Color::from_rgb(0.6, 0.6, 0.6), + ) + } + } + }; + + let mut refresh_button = button("Refresh").style(theme::Button::Secondary); + if !busy { + refresh_button = refresh_button + .on_press(Message::DaemonActionRequested(DaemonAction::RefreshStatus)); + } + + row![ + text(label).size(16).style(theme::Text::Color(color)), + refresh_button + ] + .spacing(12) + .into() + } + + fn daemon_step_install(&self, busy: bool) -> Element<'_, Message> { + let installed = self + .daemon_status + .as_ref() + .is_some_and(|s| s.service_installed); + + let status_indicator = if installed { + text("Installed \u{2713}") + .size(14) + .style(theme::Text::Color(iced::Color::from_rgb(0.5, 0.9, 0.5))) + } else { + text("Not installed") + .size(14) + .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))) + }; + + let button_label = if installed { + "Update Service" + } else { + "Install Service" + }; + let mut install_button = button(button_label).style(theme::Button::Secondary); + if !busy { + install_button = install_button.on_press(Message::DaemonActionRequested( + DaemonAction::InstallOrUpdateService, + )); + } + + column![ + text("Step 1 — Install the service").size(16), + text("Install wayscriber as a background service.").size(14), + row![status_indicator, install_button].spacing(12), + ] + .spacing(8) + .into() + } + + fn daemon_step_shortcut(&self, busy: bool, service_installed: bool) -> Element<'_, Message> { + if !service_installed { + return column![ + text("Step 2 — Set your shortcut") + .size(16) + .style(theme::Text::Color(iced::Color::from_rgb(0.55, 0.55, 0.55))), + text("Install the background service first, then set your shortcut.") + .size(14) + .style(theme::Text::Color(iced::Color::from_rgb(0.55, 0.55, 0.55))), + ] + .spacing(8) + .into(); + } + + let placeholder = match self.daemon_status.as_ref().map(|s| s.shortcut_backend) { + Some(ShortcutBackend::GnomeCustomShortcut) => "e.g. Super+G or g", + Some(ShortcutBackend::PortalServiceDropIn) => "e.g. Ctrl+Shift+G or g", + _ => "e.g. Ctrl+Shift+G", + }; + + let mut shortcut_button = button("Apply Shortcut").style(theme::Button::Primary); + if !busy { + shortcut_button = shortcut_button + .on_press(Message::DaemonActionRequested(DaemonAction::ApplyShortcut)); + } + + let mut step = column![ + text("Step 2 — Set your shortcut").size(16), + text("Choose a keyboard shortcut to toggle drawing on/off.").size(14), + text( + "The shortcut takes effect after the background service is installed and running." + ) + .size(12) + .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), + ] + .spacing(8); + + if let Some(configured) = self + .daemon_status + .as_ref() + .and_then(|s| s.configured_shortcut.as_deref()) + { + step = step.push( + text(format!("Current shortcut: {configured}")) + .size(12) + .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), + ); + } + + step = step.push( + text_input(placeholder, &self.daemon_shortcut_input) + .on_input(Message::DaemonShortcutInputChanged) + .padding(8), + ); + step = step.push(shortcut_button); + + step.into() + } + + fn daemon_step_start(&self, busy: bool, service_installed: bool) -> Element<'_, Message> { + if !service_installed { + return column![ + text("Step 3 — Start the service") + .size(16) + .style(theme::Text::Color(iced::Color::from_rgb(0.55, 0.55, 0.55))), + text("Install the background service first.") + .size(14) + .style(theme::Text::Color(iced::Color::from_rgb(0.55, 0.55, 0.55))), + ] + .spacing(8) + .into(); + } + + let enabled = self + .daemon_status + .as_ref() + .is_some_and(|status| status.service_enabled); + let running = self + .daemon_status + .as_ref() + .is_some_and(|s| s.service_active); + + let status_indicator = if running && enabled { + text("Running \u{2713}") + .size(14) + .style(theme::Text::Color(iced::Color::from_rgb(0.5, 0.9, 0.5))) + } else if running { + text("Running (not enabled)") + .size(14) + .style(theme::Text::Color(iced::Color::from_rgb(0.95, 0.8, 0.3))) + } else if enabled { + text("Enabled, not running") + .size(14) + .style(theme::Text::Color(iced::Color::from_rgb(0.95, 0.8, 0.3))) + } else { + text("Stopped and disabled") + .size(14) + .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))) + }; + + let mut step = column![ + text("Step 3 — Start the service").size(16), + text("Enable and start the background service.").size(14), + status_indicator, + ] + .spacing(8); + + if running { + // Show Restart and Stop when running + let mut restart_button = button("Restart").style(theme::Button::Secondary); + if !busy { + restart_button = restart_button + .on_press(Message::DaemonActionRequested(DaemonAction::RestartService)); + } + let mut stop_button = button("Stop & Disable").style(theme::Button::Secondary); + if !busy { + stop_button = stop_button.on_press(Message::DaemonActionRequested( + DaemonAction::StopAndDisableService, + )); + } + step = step.push(row![restart_button, stop_button].spacing(12)); + } else { + // Show Start when not running + let mut start_button = button("Start").style(theme::Button::Primary); + if !busy { + start_button = start_button.on_press(Message::DaemonActionRequested( + DaemonAction::EnableAndStartService, + )); + } + step = step.push(start_button); + } + + step.into() + } + + fn daemon_technical_details(&self, busy: bool) -> Element<'_, Message> { + let mut details = column![text("Details").size(14)].spacing(4); + + if let Some(status) = self.daemon_status.as_ref() { + details = details.push( + text(format!("Desktop: {}", status.desktop.label())) + .size(12) + .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), + ); + + details = details.push( + text(status.shortcut_backend.friendly_label()) + .size(12) + .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), + ); + + if let Some(path) = status.service_unit_path.as_deref() { + details = details.push( + text(format!("Service file: {path}")) + .size(12) + .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), + ); + } + + // Only show tool availability when something is missing + if !status.systemctl_available || !status.gsettings_available { + let mut missing = Vec::new(); + if !status.systemctl_available { + missing.push("systemctl"); + } + if !status.gsettings_available { + missing.push("gsettings"); + } + details = details.push( + text(format!("Missing tools: {}", missing.join(", "))) + .size(12) + .style(theme::Text::Color(iced::Color::from_rgb(0.95, 0.8, 0.3))), + ); + } + } else { + details = details.push( + text("Detecting environment...") + .size(12) + .style(theme::Text::Color(iced::Color::from_rgb(0.6, 0.6, 0.6))), + ); + } + + let mut refresh_button = button("Refresh").style(theme::Button::Secondary); + if !busy { + refresh_button = refresh_button + .on_press(Message::DaemonActionRequested(DaemonAction::RefreshStatus)); + } + details = details.push(refresh_button); + + details.into() + } +} diff --git a/configurator/src/app/view/mod.rs b/configurator/src/app/view/mod.rs index a45c9ad8..4ca761a8 100644 --- a/configurator/src/app/view/mod.rs +++ b/configurator/src/app/view/mod.rs @@ -1,6 +1,7 @@ mod arrow; mod boards; mod capture; +mod daemon; mod drawing; mod history; mod keybindings; @@ -119,6 +120,7 @@ impl ConfiguratorApp { TabId::Ui => self.ui_tab(), TabId::Boards => self.boards_tab(), TabId::Capture => self.capture_tab(), + TabId::Daemon => self.daemon_tab(), TabId::Session => self.session_tab(), TabId::Keybindings => self.keybindings_tab(), #[cfg(feature = "tablet-input")] diff --git a/configurator/src/messages.rs b/configurator/src/messages.rs index a7c4ff8d..7600df7a 100644 --- a/configurator/src/messages.rs +++ b/configurator/src/messages.rs @@ -5,12 +5,12 @@ use wayscriber::config::Config; use crate::models::{ BoardBackgroundOption, BoardItemTextField, BoardItemToggleField, ColorMode, ColorPickerId, - ColorPickerValue, DragToolField, EraserModeOption, FontStyleOption, FontWeightOption, - KeybindingField, KeybindingsTabId, NamedColorOption, OverrideOption, - PresenterToolBehaviorOption, PresetEraserKindOption, PresetEraserModeOption, PresetTextField, - PresetToggleField, QuadField, SessionCompressionOption, SessionStorageModeOption, - StatusPositionOption, TabId, TextField, ToggleField, ToolOption, ToolbarLayoutModeOption, - ToolbarOverrideField, TripletField, UiTabId, + ColorPickerValue, DaemonAction, DaemonActionResult, DaemonRuntimeStatus, DragToolField, + EraserModeOption, FontStyleOption, FontWeightOption, KeybindingField, KeybindingsTabId, + NamedColorOption, OverrideOption, PresenterToolBehaviorOption, PresetEraserKindOption, + PresetEraserModeOption, PresetTextField, PresetToggleField, QuadField, + SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, TabId, TextField, + ToggleField, ToolOption, ToolbarLayoutModeOption, ToolbarOverrideField, TripletField, UiTabId, }; #[cfg(feature = "tablet-input")] use crate::models::{PressureThicknessEditModeOption, PressureThicknessEntryModeOption}; @@ -22,6 +22,10 @@ pub enum Message { ResetToDefaults, SaveRequested, ConfigSaved(Result<(Option, Arc), String>), + DaemonStatusLoaded(u64, Result), + DaemonShortcutInputChanged(String), + DaemonActionRequested(DaemonAction), + DaemonActionCompleted(Result), TabSelected(TabId), UiTabSelected(UiTabId), KeybindingsTabSelected(KeybindingsTabId), diff --git a/configurator/src/models/daemon.rs b/configurator/src/models/daemon.rs new file mode 100644 index 00000000..2a7bdd42 --- /dev/null +++ b/configurator/src/models/daemon.rs @@ -0,0 +1,155 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DesktopEnvironment { + Gnome, + Kde, + Unknown, +} + +impl DesktopEnvironment { + pub fn detect_current() -> Self { + let current = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default(); + let session = std::env::var("XDG_SESSION_DESKTOP").unwrap_or_default(); + Self::from_desktop_strings(¤t, &session) + } + + pub fn from_desktop_strings(current: &str, session: &str) -> Self { + let combined = format!("{current};{session}").to_lowercase(); + if combined.contains("gnome") { + return Self::Gnome; + } + if combined.contains("kde") || combined.contains("plasma") { + return Self::Kde; + } + Self::Unknown + } + + pub fn label(self) -> &'static str { + match self { + Self::Gnome => "GNOME", + Self::Kde => "KDE Plasma", + Self::Unknown => "Unknown/Other", + } + } + + pub fn default_shortcut_input(self) -> &'static str { + match self { + Self::Gnome => "Super+G", + Self::Kde | Self::Unknown => "Ctrl+Shift+G", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShortcutBackend { + GnomeCustomShortcut, + PortalServiceDropIn, + Manual, +} + +impl ShortcutBackend { + pub fn from_environment( + desktop: DesktopEnvironment, + gsettings_available: bool, + systemctl_available: bool, + ) -> Self { + if desktop == DesktopEnvironment::Gnome && gsettings_available { + return Self::GnomeCustomShortcut; + } + if systemctl_available { + return Self::PortalServiceDropIn; + } + Self::Manual + } + + pub fn friendly_label(self) -> &'static str { + match self { + Self::GnomeCustomShortcut => "Shortcut will be configured via GNOME Settings", + Self::PortalServiceDropIn => "Shortcut will be configured via your desktop portal", + Self::Manual => { + "Automatic shortcut setup is not available — you'll need to add a keybind manually" + } + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DaemonAction { + RefreshStatus, + InstallOrUpdateService, + EnableAndStartService, + RestartService, + StopAndDisableService, + ApplyShortcut, +} + +#[derive(Debug, Clone)] +pub struct DaemonRuntimeStatus { + pub desktop: DesktopEnvironment, + pub shortcut_backend: ShortcutBackend, + pub systemctl_available: bool, + pub gsettings_available: bool, + pub service_installed: bool, + pub service_enabled: bool, + pub service_active: bool, + pub service_unit_path: Option, + pub configured_shortcut: Option, +} + +#[derive(Debug, Clone)] +pub struct DaemonActionResult { + pub status: DaemonRuntimeStatus, + pub message: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detect_desktop_prefers_explicit_gnome_marker() { + assert_eq!( + DesktopEnvironment::from_desktop_strings("GNOME", ""), + DesktopEnvironment::Gnome + ); + assert_eq!( + DesktopEnvironment::from_desktop_strings("ubuntu:GNOME", "ubuntu"), + DesktopEnvironment::Gnome + ); + } + + #[test] + fn detect_desktop_avoids_assuming_bare_ubuntu_is_gnome() { + assert_eq!( + DesktopEnvironment::from_desktop_strings("", "ubuntu"), + DesktopEnvironment::Unknown + ); + } + + #[test] + fn detect_desktop_kde_variants() { + assert_eq!( + DesktopEnvironment::from_desktop_strings("KDE", ""), + DesktopEnvironment::Kde + ); + assert_eq!( + DesktopEnvironment::from_desktop_strings("plasma", ""), + DesktopEnvironment::Kde + ); + } + + #[test] + fn shortcut_backend_selection_prefers_gnome_when_available() { + assert_eq!( + ShortcutBackend::from_environment(DesktopEnvironment::Gnome, true, true), + ShortcutBackend::GnomeCustomShortcut + ); + assert_eq!( + ShortcutBackend::from_environment(DesktopEnvironment::Kde, false, true), + ShortcutBackend::PortalServiceDropIn + ); + assert_eq!( + ShortcutBackend::from_environment(DesktopEnvironment::Unknown, false, false), + ShortcutBackend::Manual + ); + } +} diff --git a/configurator/src/models/mod.rs b/configurator/src/models/mod.rs index acc5caf3..9f240e47 100644 --- a/configurator/src/models/mod.rs +++ b/configurator/src/models/mod.rs @@ -1,6 +1,7 @@ pub mod color; pub mod color_picker; pub mod config; +pub mod daemon; pub mod error; pub mod fields; pub mod keybindings; @@ -10,6 +11,9 @@ pub mod util; pub use color::{ColorMode, ColorQuadInput, ColorTripletInput, NamedColorOption}; pub use color_picker::{ColorPickerId, ColorPickerValue}; pub use config::{BoardBackgroundOption, BoardItemTextField, BoardItemToggleField, ConfigDraft}; +pub use daemon::{ + DaemonAction, DaemonActionResult, DaemonRuntimeStatus, DesktopEnvironment, ShortcutBackend, +}; pub use fields::{ DragToolField, EraserModeOption, FontStyleOption, FontWeightOption, OverrideOption, PresenterToolBehaviorOption, PresetEraserKindOption, PresetEraserModeOption, PresetTextField, diff --git a/configurator/src/models/tab.rs b/configurator/src/models/tab.rs index a8ad42db..108cfd72 100644 --- a/configurator/src/models/tab.rs +++ b/configurator/src/models/tab.rs @@ -8,6 +8,7 @@ pub enum TabId { Ui, Boards, Capture, + Daemon, Session, Keybindings, #[cfg(feature = "tablet-input")] @@ -16,7 +17,8 @@ pub enum TabId { impl TabId { #[cfg(feature = "tablet-input")] - pub const ALL: [TabId; 11] = [ + pub const ALL: [TabId; 12] = [ + TabId::Daemon, TabId::Drawing, TabId::Presets, TabId::Ui, @@ -31,7 +33,8 @@ impl TabId { ]; #[cfg(not(feature = "tablet-input"))] - pub const ALL: [TabId; 10] = [ + pub const ALL: [TabId; 11] = [ + TabId::Daemon, TabId::Drawing, TabId::Presets, TabId::Ui, @@ -54,6 +57,7 @@ impl TabId { TabId::Ui => "UI", TabId::Boards => "Boards", TabId::Capture => "Capture", + TabId::Daemon => "Background Mode", TabId::Session => "Session", TabId::Keybindings => "Keybindings", #[cfg(feature = "tablet-input")] diff --git a/docs/SETUP.md b/docs/SETUP.md index 76e21a1c..a217e6d4 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -12,6 +12,19 @@ systemctl --user enable --now wayscriber.service The service keeps the daemon running in the background; you only need a keybind to toggle the overlay. +### Configurator setup (no CLI) + +If you installed `wayscriber-configurator`, you can set this up entirely in GUI: +1. Open `wayscriber-configurator`. +2. Go to the `Daemon` tab. +3. Click `Install/Update Service`. +4. Click `Enable + Start`. +5. Set your shortcut and click `Apply Shortcut`. + +Desktop-specific shortcut handling: +- GNOME: creates/updates a GNOME custom shortcut that runs `pkill -SIGUSR1 wayscriber`. +- KDE/Plasma: writes a systemd user drop-in with `WAYSCRIBER_PORTAL_SHORTCUT` for portal global shortcut handling. + ### Quick Install Run the install script: diff --git a/src/backend/wayland/backend/state_init/mod.rs b/src/backend/wayland/backend/state_init/mod.rs index 2b53da7e..96f7c2c1 100644 --- a/src/backend/wayland/backend/state_init/mod.rs +++ b/src/backend/wayland/backend/state_init/mod.rs @@ -74,7 +74,7 @@ pub(super) fn init_state(backend: &WaylandBackend, setup: WaylandSetup) -> Resul if !state.first_run_completed && !state.first_run_skipped { state .active_step - .get_or_insert(crate::onboarding::FirstRunStep::WaitDraw); + .get_or_insert(crate::onboarding::FirstRunStep::BackgroundModeSetup); } else { state.active_step = None; state.quick_access_requires_toolbar = false; diff --git a/src/backend/wayland/handlers/keyboard/mod.rs b/src/backend/wayland/handlers/keyboard/mod.rs index cbda40be..7481977f 100644 --- a/src/backend/wayland/handlers/keyboard/mod.rs +++ b/src/backend/wayland/handlers/keyboard/mod.rs @@ -109,6 +109,9 @@ impl KeyboardHandler for WaylandState { { return; } + if self.try_handle_first_run_background_mode_choice(key) { + return; + } if self.zoom.is_engaged() { match key { Key::Escape => { diff --git a/src/backend/wayland/state/onboarding.rs b/src/backend/wayland/state/onboarding.rs index 9ccf46e5..8c3e161e 100644 --- a/src/backend/wayland/state/onboarding.rs +++ b/src/backend/wayland/state/onboarding.rs @@ -1,5 +1,5 @@ use crate::config::{RadialMenuMouseBinding, keybindings::Action}; -use crate::input::state::UiToastKind; +use crate::input::{Key, state::UiToastKind}; use crate::onboarding::{DEFERRED_HINT_REPEAT_MAX, FirstRunStep, OnboardingState}; use crate::ui::{OnboardingCard, OnboardingChecklistItem}; @@ -14,6 +14,57 @@ impl WaylandState { self.apply_toolbar_visibility_hint(); } + pub(in crate::backend::wayland) fn try_handle_first_run_background_mode_choice( + &mut self, + key: Key, + ) -> bool { + if !background_mode_prompt_active( + self.onboarding.state(), + self.first_run_onboarding_card_visible(), + ) { + return false; + } + + let Some(enable_background_mode) = background_mode_prompt_choice(key) else { + return false; + }; + + if enable_background_mode { + match crate::daemon::setup::setup_background_mode() { + Ok(summary) => { + mark_background_mode_prompt(self.onboarding.state_mut(), true); + self.onboarding.save(); + self.input_state.set_ui_toast( + UiToastKind::Info, + format!( + "Background mode enabled. Service file: {}", + summary.service_path.display() + ), + ); + } + Err(err) => { + mark_background_mode_prompt(self.onboarding.state_mut(), false); + self.onboarding.save(); + self.input_state.set_ui_toast( + UiToastKind::Error, + format!( + "Background mode setup failed: {err}. You can set this up later in Background Mode settings." + ), + ); + } + } + } else { + mark_background_mode_prompt(self.onboarding.state_mut(), false); + self.onboarding.save(); + self.input_state + .set_ui_toast(UiToastKind::Info, "Skipped background mode setup for now."); + } + + self.input_state.dirty_tracker.mark_full(); + self.input_state.needs_redraw = true; + true + } + pub(in crate::backend::wayland) fn try_skip_first_run_onboarding(&mut self) -> bool { if !first_run_skip_allowed( self.onboarding.state().first_run_active(), @@ -46,6 +97,15 @@ impl WaylandState { let footer = "Shift+Escape to skip".to_string(); let card = match step { + FirstRunStep::BackgroundModeSetup => OnboardingCard { + eyebrow: eyebrow.to_string(), + title: "Enable background mode?".to_string(), + body: "Keeps Wayscriber ready in the background so you can toggle overlay quickly." + .to_string(), + items: Vec::new(), + footer: "Y = set up now • N = skip • Shift+Escape = skip onboarding" + .to_string(), + }, FirstRunStep::WaitDraw => OnboardingCard { eyebrow: eyebrow.to_string(), title: "Draw one mark".to_string(), @@ -179,7 +239,7 @@ impl WaylandState { changed = true; } } else if state.active_step.is_none() { - state.active_step = Some(FirstRunStep::WaitDraw); + state.active_step = Some(FirstRunStep::BackgroundModeSetup); changed = true; } @@ -188,6 +248,13 @@ impl WaylandState { break; }; match step { + FirstRunStep::BackgroundModeSetup => { + if !state.first_run_background_mode_prompted { + break; + } + state.active_step = Some(FirstRunStep::WaitDraw); + changed = true; + } FirstRunStep::WaitDraw => { if !state.first_stroke_done { break; @@ -525,12 +592,36 @@ fn quick_access_completed( done } +fn background_mode_prompt_active(state: &OnboardingState, card_visible: bool) -> bool { + state.first_run_active() + && card_visible + && state.active_step == Some(FirstRunStep::BackgroundModeSetup) +} + +fn background_mode_prompt_choice(key: Key) -> Option { + let Key::Char(ch) = key else { + return None; + }; + + match ch.to_ascii_lowercase() { + 'y' => Some(true), + 'n' => Some(false), + _ => None, + } +} + +fn mark_background_mode_prompt(state: &mut OnboardingState, enabled: bool) { + state.first_run_background_mode_prompted = true; + state.first_run_background_mode_enabled = enabled; +} + fn first_run_step_eyebrow(step: FirstRunStep) -> &'static str { match step { - FirstRunStep::WaitDraw => "Step 1 / 4", - FirstRunStep::DrawUndo => "Step 2 / 4", - FirstRunStep::QuickAccess => "Step 3 / 4", - FirstRunStep::Reference => "Step 4 / 4", + FirstRunStep::BackgroundModeSetup => "Step 1 / 5", + FirstRunStep::WaitDraw => "Step 2 / 5", + FirstRunStep::DrawUndo => "Step 3 / 5", + FirstRunStep::QuickAccess => "Step 4 / 5", + FirstRunStep::Reference => "Step 5 / 5", } } @@ -559,10 +650,12 @@ fn first_run_card_hidden_by_ui_state( #[cfg(test)] mod tests { use super::{ - FirstRunStep, OnboardingState, first_run_card_hidden_by_ui_state, first_run_skip_allowed, + FirstRunStep, OnboardingState, background_mode_prompt_active, + background_mode_prompt_choice, first_run_card_hidden_by_ui_state, first_run_skip_allowed, first_run_step_eyebrow, quick_access_completed, }; use crate::config::RadialMenuMouseBinding; + use crate::input::Key; #[test] fn first_run_skip_requires_active_onboarding_and_visible_card() { @@ -603,18 +696,49 @@ mod tests { #[test] fn first_run_eyebrow_shows_progress() { - assert_eq!(first_run_step_eyebrow(FirstRunStep::WaitDraw), "Step 1 / 4"); - assert_eq!(first_run_step_eyebrow(FirstRunStep::DrawUndo), "Step 2 / 4"); + assert_eq!( + first_run_step_eyebrow(FirstRunStep::BackgroundModeSetup), + "Step 1 / 5" + ); + assert_eq!(first_run_step_eyebrow(FirstRunStep::WaitDraw), "Step 2 / 5"); + assert_eq!(first_run_step_eyebrow(FirstRunStep::DrawUndo), "Step 3 / 5"); assert_eq!( first_run_step_eyebrow(FirstRunStep::QuickAccess), - "Step 3 / 4" + "Step 4 / 5" ); assert_eq!( first_run_step_eyebrow(FirstRunStep::Reference), - "Step 4 / 4" + "Step 5 / 5" ); } + #[test] + fn background_mode_prompt_choice_accepts_yes_and_no_keys() { + assert_eq!(background_mode_prompt_choice(Key::Char('y')), Some(true)); + assert_eq!(background_mode_prompt_choice(Key::Char('Y')), Some(true)); + assert_eq!(background_mode_prompt_choice(Key::Char('n')), Some(false)); + assert_eq!(background_mode_prompt_choice(Key::Char('N')), Some(false)); + assert_eq!(background_mode_prompt_choice(Key::Char('x')), None); + assert_eq!(background_mode_prompt_choice(Key::Escape), None); + } + + #[test] + fn background_mode_prompt_active_requires_step_and_visible_card() { + let mut state = OnboardingState { + active_step: Some(FirstRunStep::BackgroundModeSetup), + ..OnboardingState::default() + }; + assert!(background_mode_prompt_active(&state, true)); + assert!(!background_mode_prompt_active(&state, false)); + + state.active_step = Some(FirstRunStep::WaitDraw); + assert!(!background_mode_prompt_active(&state, true)); + + state.active_step = Some(FirstRunStep::BackgroundModeSetup); + state.first_run_completed = true; + assert!(!background_mode_prompt_active(&state, true)); + } + #[test] fn quick_access_completes_when_radial_unavailable_and_context_disabled() { let state = OnboardingState::default(); diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index d1d9fc3d..e4485166 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -4,6 +4,7 @@ mod core; mod global_shortcuts; mod icons; mod overlay; +pub(crate) mod setup; mod tray; mod types; diff --git a/src/daemon/setup.rs b/src/daemon/setup.rs new file mode 100644 index 00000000..c647e2bd --- /dev/null +++ b/src/daemon/setup.rs @@ -0,0 +1,85 @@ +use anyhow::{Context, Result, bail}; +use std::fs; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use wayscriber::systemd_user_service::{ + USER_SERVICE_NAME, render_user_service_unit, user_service_unit_path, +}; + +#[derive(Debug, Clone)] +pub(crate) struct BackgroundModeSetupSummary { + pub(crate) service_path: PathBuf, +} + +pub(crate) fn setup_background_mode() -> Result { + let service_path = ensure_user_service_file()?; + run_systemctl_user(&["daemon-reload"])?; + run_systemctl_user(&["enable", "--now", USER_SERVICE_NAME])?; + Ok(BackgroundModeSetupSummary { service_path }) +} + +fn ensure_user_service_file() -> Result { + let service_path = + user_service_unit_path().context("unable to resolve XDG config directory")?; + if let Some(parent) = service_path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!( + "failed to create systemd user directory {}", + parent.display() + ) + })?; + } + + let executable = std::env::current_exe().context("failed to resolve wayscriber executable")?; + let service_contents = render_user_service_unit(&executable); + write_if_changed(&service_path, &service_contents)?; + Ok(service_path) +} + +fn write_if_changed(path: &Path, content: &str) -> Result<()> { + match fs::read_to_string(path) { + Ok(existing) if existing == content => return Ok(()), + Ok(_) => {} + Err(err) if err.kind() == ErrorKind::NotFound => {} + Err(err) => { + return Err(err).with_context(|| format!("failed to read {}", path.display())); + } + } + + fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))?; + Ok(()) +} + +fn run_systemctl_user(args: &[&str]) -> Result<()> { + let output = Command::new("systemctl") + .arg("--user") + .args(args) + .output() + .with_context(|| format!("failed to execute systemctl --user {}", args.join(" ")))?; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let detail = systemctl_error_detail(&stdout, &stderr); + + bail!( + "systemctl --user {} failed (status {}): {}", + args.join(" "), + output.status, + detail + ); +} + +fn systemctl_error_detail(stdout: &str, stderr: &str) -> String { + match (stdout.is_empty(), stderr.is_empty()) { + (true, true) => "no output from systemctl".to_string(), + (true, false) => stderr.to_string(), + (false, true) => stdout.to_string(), + (false, false) => format!("{stderr} | {stdout}"), + } +} diff --git a/src/daemon/tray/ksni.rs b/src/daemon/tray/ksni.rs index 1240b14e..61cfc524 100644 --- a/src/daemon/tray/ksni.rs +++ b/src/daemon/tray/ksni.rs @@ -15,6 +15,8 @@ use log::{info, warn}; #[cfg(feature = "tray")] use std::env; #[cfg(feature = "tray")] +use std::process::Command; +#[cfg(feature = "tray")] use std::sync::atomic::Ordering; #[cfg(feature = "tray")] use std::time::{Duration, Instant}; @@ -43,6 +45,7 @@ impl ksni::Tray for WayscriberTray { fn tool_tip(&self) -> ksni::ToolTip { let status = self.tray_status.snapshot(); + let overlay_active = self.overlay_pid.load(Ordering::Acquire) > 0; let TrayStatus { overlay_error, watcher_offline, @@ -50,6 +53,8 @@ impl ksni::Tray for WayscriberTray { } = status; let mut description = "Toggle overlay, open configurator, or quit from the tray".to_string(); + description.push_str("\nOverlay: "); + description.push_str(if overlay_active { "active" } else { "hidden" }); if watcher_offline { description.push_str("\nTray watcher offline"); @@ -97,6 +102,8 @@ impl ksni::Tray for WayscriberTray { fn menu(&self) -> Vec> { use ksni::menu::*; let use_theme_icons = tray_theme_icons_enabled(); + let overlay_active = self.overlay_pid.load(Ordering::Acquire) > 0; + let toggle_label = toggle_overlay_menu_label(); vec![ StandardItem { @@ -110,7 +117,7 @@ impl ksni::Tray for WayscriberTray { .into(), MenuItem::Separator, StandardItem { - label: "Toggle Overlay".to_string(), + label: toggle_label, icon_name: menu_icon_name("tool-pointer", use_theme_icons), activate: Box::new(|this: &mut Self| { this.toggle_flag.store(true, Ordering::Release); @@ -118,6 +125,15 @@ impl ksni::Tray for WayscriberTray { ..Default::default() } .into(), + StandardItem { + label: format!( + "Overlay: {}", + if overlay_active { "active" } else { "hidden" } + ), + enabled: false, + ..Default::default() + } + .into(), StandardItem { label: format_binding_label(action_label(Action::BoardPicker), None), icon_name: menu_icon_name("view-grid", use_theme_icons), @@ -291,3 +307,51 @@ fn resolve_icon_theme_path() -> String { } String::new() } + +#[cfg(feature = "tray")] +fn toggle_overlay_menu_label() -> String { + let base = "Toggle Overlay"; + match configured_toggle_shortcut_hint() { + Some(shortcut) => format!("{base} ({shortcut})"), + None => base.to_string(), + } +} + +#[cfg(feature = "tray")] +fn configured_toggle_shortcut_hint() -> Option { + if let Ok(shortcut) = env::var("WAYSCRIBER_PORTAL_SHORTCUT") + && !shortcut.trim().is_empty() + { + return Some(shortcut.trim().to_string()); + } + read_gnome_toggle_shortcut_binding() +} + +#[cfg(feature = "tray")] +fn read_gnome_toggle_shortcut_binding() -> Option { + let schema = "org.gnome.settings-daemon.plugins.media-keys.custom-keybinding:/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/wayscriber-toggle/"; + let output = Command::new("gsettings") + .args(["get", schema, "binding"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + parse_gsettings_string_value(&String::from_utf8_lossy(&output.stdout)) +} + +#[cfg(feature = "tray")] +fn parse_gsettings_string_value(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed == "''" { + return None; + } + let unquoted = trimmed + .strip_prefix('\'') + .and_then(|value| value.strip_suffix('\'')) + .unwrap_or(trimmed); + if unquoted.is_empty() { + return None; + } + Some(unquoted.replace("\\'", "'").replace("\\\\", "\\")) +} diff --git a/src/lib.rs b/src/lib.rs index 224c26e9..d68c53a5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ pub mod input; mod label_format; pub mod paths; pub mod session; +pub mod systemd_user_service; pub mod time_utils; pub mod toolbar_icons; pub mod ui; diff --git a/src/onboarding.rs b/src/onboarding.rs index 2566ae01..779766ae 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -15,6 +15,7 @@ const ONBOARDING_DIR: &str = "wayscriber"; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum FirstRunStep { + BackgroundModeSetup, WaitDraw, DrawUndo, QuickAccess, @@ -50,6 +51,12 @@ pub struct OnboardingState { /// Active first-run onboarding step (if any) #[serde(default)] pub active_step: Option, + /// Whether the first-run background mode prompt was answered + #[serde(default)] + pub first_run_background_mode_prompted: bool, + /// Whether background mode setup was completed from first-run prompt + #[serde(default)] + pub first_run_background_mode_enabled: bool, /// Whether quick-access step requires revealing hidden toolbars #[serde(default)] pub quick_access_requires_toolbar: bool, @@ -122,6 +129,8 @@ impl Default for OnboardingState { first_run_completed: false, first_run_skipped: false, active_step: None, + first_run_background_mode_prompted: false, + first_run_background_mode_enabled: false, quick_access_requires_toolbar: false, quick_access_radial_preview_shown: false, quick_access_context_preview_shown: false, @@ -296,6 +305,14 @@ fn migrate_onboarding_state(state: &mut OnboardingState) -> bool { state.active_step = None; needs_save = true; } + if state.first_run_background_mode_enabled && !state.first_run_background_mode_prompted { + state.first_run_background_mode_prompted = true; + needs_save = true; + } + if state.first_run_completed && !state.first_run_background_mode_prompted { + state.first_run_background_mode_prompted = true; + needs_save = true; + } if state.quick_access_requires_toolbar && state.active_step != Some(FirstRunStep::QuickAccess) { state.quick_access_requires_toolbar = false; needs_save = true; @@ -342,6 +359,8 @@ fn recover_onboarding_file(path: &Path, _raw: Option<&str>) -> OnboardingState { first_run_completed: true, first_run_skipped: false, active_step: None, + first_run_background_mode_prompted: true, + first_run_background_mode_enabled: false, quick_access_requires_toolbar: false, quick_access_radial_preview_shown: false, quick_access_context_preview_shown: false, diff --git a/src/systemd_user_service.rs b/src/systemd_user_service.rs new file mode 100644 index 00000000..e08f3d94 --- /dev/null +++ b/src/systemd_user_service.rs @@ -0,0 +1,90 @@ +use std::path::{Path, PathBuf}; + +use crate::paths::config_dir; + +pub const USER_SERVICE_NAME: &str = "wayscriber.service"; + +pub fn user_service_unit_path() -> Option { + config_dir().map(|root| user_service_unit_path_from_config_root(&root)) +} + +pub fn portal_shortcut_dropin_path() -> Option { + config_dir().map(|root| portal_shortcut_dropin_path_from_config_root(&root)) +} + +pub fn user_service_unit_path_from_config_root(config_root: &Path) -> PathBuf { + config_root + .join("systemd") + .join("user") + .join(USER_SERVICE_NAME) +} + +pub fn portal_shortcut_dropin_path_from_config_root(config_root: &Path) -> PathBuf { + config_root + .join("systemd") + .join("user") + .join(format!("{USER_SERVICE_NAME}.d")) + .join("shortcut.conf") +} + +pub fn quote_systemd_exec(path: &Path) -> String { + let escaped = path + .to_string_lossy() + .replace('\\', "\\\\") + .replace('"', "\\\""); + format!("\"{escaped}\"") +} + +pub fn escape_systemd_env_value(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + +pub fn render_user_service_unit(binary_path: &Path) -> String { + let quoted_exec = quote_systemd_exec(binary_path); + let binary_dir = binary_path + .parent() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "/usr/bin".to_string()); + let escaped_path_env = + escape_systemd_env_value(&format!("{binary_dir}:/usr/local/bin:/usr/bin:/bin")); + format!( + "[Unit]\nDescription=Wayscriber - Screen annotation tool for Wayland\nDocumentation=https://wayscriber.com\nPartOf=graphical-session.target\nAfter=graphical-session.target\n\n[Service]\nType=simple\nExecStartPre=/bin/sh -c '[ -n \"$WAYLAND_DISPLAY\" ] && [ -S \"$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY\" ]'\nExecStart={} --daemon\nRestart=on-failure\nRestartSec=5\nRestartPreventExitStatus=75\nSuccessExitStatus=75\nEnvironment=\"PATH={}\"\n\n[Install]\nWantedBy=graphical-session.target\n", + quoted_exec, escaped_path_env + ) +} + +#[cfg(test)] +mod tests { + use super::{ + portal_shortcut_dropin_path_from_config_root, quote_systemd_exec, render_user_service_unit, + user_service_unit_path_from_config_root, + }; + use std::path::Path; + + #[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 quote_systemd_exec_supports_whitespace() { + assert_eq!( + quote_systemd_exec(Path::new("/tmp/My Apps/wayscriber")), + "\"/tmp/My Apps/wayscriber\"" + ); + } + + #[test] + fn render_user_service_unit_quotes_exec_path() { + let unit = render_user_service_unit(Path::new("/tmp/My Apps/wayscriber")); + assert!(unit.contains("ExecStart=\"/tmp/My Apps/wayscriber\" --daemon")); + } +} diff --git a/src/ui/onboarding_card.rs b/src/ui/onboarding_card.rs index fc17e685..1635bdab 100644 --- a/src/ui/onboarding_card.rs +++ b/src/ui/onboarding_card.rs @@ -31,10 +31,6 @@ pub fn render_onboarding_card( height: u32, card: &OnboardingCard, ) { - if card.items.is_empty() { - return; - } - let margin = CARD_MARGIN * CARD_SCALE; let card_max_width = CARD_MAX_WIDTH * CARD_SCALE; let card_min_width = CARD_MIN_WIDTH * CARD_SCALE; diff --git a/tools/install-configurator.sh b/tools/install-configurator.sh index 706cadee..503d17e7 100755 --- a/tools/install-configurator.sh +++ b/tools/install-configurator.sh @@ -22,9 +22,19 @@ die() { echo "Building configurator (release)..." (cd "$PROJECT_ROOT" && cargo build --release --bins --manifest-path configurator/Cargo.toml) -BIN_PATH="$PROJECT_ROOT/configurator/target/release/$BINARY_NAME" -if [ ! -f "$BIN_PATH" ]; then - die "Configurator binary not found at $BIN_PATH" +BIN_PATH="" +for CANDIDATE in \ + "$PROJECT_ROOT/target/release/$BINARY_NAME" \ + "$PROJECT_ROOT/configurator/target/release/$BINARY_NAME" +do + if [ -f "$CANDIDATE" ]; then + BIN_PATH="$CANDIDATE" + break + fi +done + +if [ -z "$BIN_PATH" ]; then + die "Configurator binary not found at expected paths under $PROJECT_ROOT/target/release or $PROJECT_ROOT/configurator/target/release" fi if [ ! -d "$INSTALL_DIR" ] || [ ! -w "$INSTALL_DIR" ]; then