feat: add openrc cargo feature for OpenRC bluetooth service management
Coding-Agent: Claude Code Model: claude-sonnet-4-5
This commit is contained in:
parent
989a56346a
commit
4ae20bc1db
6 changed files with 752 additions and 59 deletions
|
|
@ -113,8 +113,10 @@ git = "https://github.com/AerynOS/locales-rs"
|
||||||
optional = true
|
optional = true
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["a11y", "linux", "single-instance", "wgpu"]
|
default = ["a11y", "linux", "single-instance", "wgpu", "systemd"]
|
||||||
gettext = ["dep:gettext-rs"]
|
gettext = ["dep:gettext-rs"]
|
||||||
|
systemd = []
|
||||||
|
openrc = []
|
||||||
|
|
||||||
# Default features for Linux
|
# Default features for Linux
|
||||||
linux = [
|
linux = [
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,12 @@
|
||||||
#![allow(clippy::cast_lossless)]
|
#![allow(clippy::cast_lossless)]
|
||||||
#![allow(clippy::too_many_lines)]
|
#![allow(clippy::too_many_lines)]
|
||||||
|
|
||||||
|
#[cfg(not(any(feature = "systemd", feature = "openrc")))]
|
||||||
|
compile_error!(
|
||||||
|
"At least one service manager feature must be enabled. \
|
||||||
|
Enable 'systemd' or 'openrc' for bluetooth service management."
|
||||||
|
);
|
||||||
|
|
||||||
pub mod app;
|
pub mod app;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
|
@ -16,6 +22,7 @@ pub mod config;
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
pub mod localize;
|
pub mod localize;
|
||||||
pub mod pages;
|
pub mod pages;
|
||||||
|
pub mod service_manager;
|
||||||
pub mod subscription;
|
pub mod subscription;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,10 @@ use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use zbus::zvariant::OwnedObjectPath;
|
use zbus::zvariant::OwnedObjectPath;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use crate::service_manager::MockServiceManager;
|
||||||
|
use crate::service_manager::ServiceManagerHandle;
|
||||||
|
|
||||||
enum Dialog {
|
enum Dialog {
|
||||||
RequestConfirmation {
|
RequestConfirmation {
|
||||||
device: String,
|
device: String,
|
||||||
|
|
@ -158,6 +162,8 @@ pub struct Page {
|
||||||
service_is_active: bool,
|
service_is_active: bool,
|
||||||
|
|
||||||
subscription: Option<tokio::sync::oneshot::Sender<()>>,
|
subscription: Option<tokio::sync::oneshot::Sender<()>>,
|
||||||
|
|
||||||
|
service_manager: ServiceManagerHandle,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl page::Page<crate::pages::Message> for Page {
|
impl page::Page<crate::pages::Message> for Page {
|
||||||
|
|
@ -524,7 +530,18 @@ impl Page {
|
||||||
}
|
}
|
||||||
|
|
||||||
Event::DBusServiceUnknown => {
|
Event::DBusServiceUnknown => {
|
||||||
self.bluez_service_unknown = true;
|
// D-Bus activation for org.bluez is absent. On systemd this usually
|
||||||
|
// means bluez isn't installed; on OpenRC it may be installed but lack
|
||||||
|
// the D-Bus service file. Check the service manager to disambiguate.
|
||||||
|
if self.service_manager.is_installed() {
|
||||||
|
// Service manager confirms the service exists — query its real state.
|
||||||
|
self.bluez_service_unknown = false;
|
||||||
|
self.service_is_active = self.service_manager.is_active();
|
||||||
|
self.service_is_enabled = self.service_manager.is_enabled();
|
||||||
|
} else {
|
||||||
|
// Genuinely not installed — let status() show the unknown message.
|
||||||
|
self.bluez_service_unknown = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Event::Agent(message) => {
|
Event::Agent(message) => {
|
||||||
|
|
@ -668,8 +685,8 @@ impl Page {
|
||||||
}
|
}
|
||||||
|
|
||||||
Message::DBusConnect(connection) => {
|
Message::DBusConnect(connection) => {
|
||||||
self.service_is_active = systemd::is_bluetooth_active();
|
self.service_is_active = self.service_manager.is_active();
|
||||||
self.service_is_enabled = systemd::is_bluetooth_enabled();
|
self.service_is_enabled = self.service_manager.is_enabled();
|
||||||
self.connection = Some(connection.clone());
|
self.connection = Some(connection.clone());
|
||||||
|
|
||||||
let get_adapters_fut = get_adapters(connection.clone());
|
let get_adapters_fut = get_adapters(connection.clone());
|
||||||
|
|
@ -801,8 +818,9 @@ impl Page {
|
||||||
}
|
}
|
||||||
|
|
||||||
Message::ServiceActivate => {
|
Message::ServiceActivate => {
|
||||||
return cosmic::task::future(async {
|
let activate_future = self.service_manager.activate();
|
||||||
systemd::activate_bluetooth().await;
|
return cosmic::task::future(async move {
|
||||||
|
activate_future.await;
|
||||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||||
|
|
||||||
match zbus::Connection::system().await {
|
match zbus::Connection::system().await {
|
||||||
|
|
@ -813,8 +831,9 @@ impl Page {
|
||||||
}
|
}
|
||||||
|
|
||||||
Message::ServiceEnable => {
|
Message::ServiceEnable => {
|
||||||
return cosmic::task::future(async {
|
let enable_future = self.service_manager.enable();
|
||||||
systemd::enable_bluetooth().await;
|
return cosmic::task::future(async move {
|
||||||
|
enable_future.await;
|
||||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||||
|
|
||||||
match zbus::Connection::system().await {
|
match zbus::Connection::system().await {
|
||||||
|
|
@ -873,7 +892,7 @@ fn status() -> Section<crate::pages::Message> {
|
||||||
return bluetooth_service_issue(
|
return bluetooth_service_issue(
|
||||||
fl!("bluetooth", "inactive"),
|
fl!("bluetooth", "inactive"),
|
||||||
fl!("activate"),
|
fl!("activate"),
|
||||||
Message::ServiceEnable,
|
Message::ServiceActivate,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1126,36 +1145,65 @@ fn multiple_adapter() -> Section<crate::pages::Message> {
|
||||||
|
|
||||||
impl page::AutoBind<crate::pages::Message> for Page {}
|
impl page::AutoBind<crate::pages::Message> for Page {}
|
||||||
|
|
||||||
mod systemd {
|
impl Page {
|
||||||
use futures::FutureExt;
|
#[cfg(test)]
|
||||||
|
fn with_service_manager(service_manager: ServiceManagerHandle) -> Self {
|
||||||
pub fn activate_bluetooth() -> impl Future<Output = ()> + Send {
|
Self {
|
||||||
tokio::process::Command::new("pkexec")
|
service_manager,
|
||||||
.args(["systemctl", "start", "bluetooth"])
|
..Page::default()
|
||||||
.status()
|
}
|
||||||
.map(|_| ())
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn enable_bluetooth() -> impl Future<Output = ()> + Send {
|
#[cfg(test)]
|
||||||
tokio::process::Command::new("pkexec")
|
mod tests {
|
||||||
.args(["systemctl", "enable", "--now", "bluetooth"])
|
use super::*;
|
||||||
.status()
|
|
||||||
.map(|_| ())
|
#[test]
|
||||||
}
|
fn test_dbus_service_unknown_with_installed_service_queries_manager() {
|
||||||
|
let bluetooth = MockServiceManager::new(true, false);
|
||||||
pub fn is_bluetooth_enabled() -> bool {
|
let mut page = Page::with_service_manager(ServiceManagerHandle::new(Box::new(bluetooth)));
|
||||||
std::process::Command::new("systemctl")
|
let _task = page.update(Message::BluetoothEvent(Event::DBusServiceUnknown));
|
||||||
.args(["is-enabled", "bluetooth"])
|
|
||||||
.status()
|
assert!(page.service_is_enabled);
|
||||||
.map(|status| status.success())
|
assert!(!page.service_is_active);
|
||||||
.unwrap_or(true)
|
assert!(!page.bluez_service_unknown);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_bluetooth_active() -> bool {
|
#[test]
|
||||||
std::process::Command::new("systemctl")
|
fn test_dbus_service_unknown_with_uninstalled_service_sets_bluez_unknown() {
|
||||||
.args(["is-active", "bluetooth"])
|
let bluetooth = MockServiceManager::new(true, true).with_installed(false);
|
||||||
.status()
|
let mut page = Page::with_service_manager(ServiceManagerHandle::new(Box::new(bluetooth)));
|
||||||
.map(|status| status.success())
|
let _task = page.update(Message::BluetoothEvent(Event::DBusServiceUnknown));
|
||||||
.unwrap_or(true)
|
|
||||||
|
assert!(page.bluez_service_unknown);
|
||||||
|
assert!(!page.service_is_enabled);
|
||||||
|
assert!(!page.service_is_active);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dbus_service_unknown_clears_previously_set_bluez_service_unknown() {
|
||||||
|
// Simulate a stale state and verify the handler re-checks via is_installed().
|
||||||
|
let bluetooth = MockServiceManager::new(true, true);
|
||||||
|
let mut page = Page::with_service_manager(ServiceManagerHandle::new(Box::new(bluetooth)));
|
||||||
|
page.bluez_service_unknown = true;
|
||||||
|
|
||||||
|
let _task = page.update(Message::BluetoothEvent(Event::DBusServiceUnknown));
|
||||||
|
|
||||||
|
assert!(!page.bluez_service_unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_service_activate_calls_through_to_service_manager() {
|
||||||
|
let bluetooth = MockServiceManager::new(false, false);
|
||||||
|
let mut page = Page::with_service_manager(ServiceManagerHandle::new(Box::new(bluetooth)));
|
||||||
|
let _task = page.update(Message::ServiceActivate);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_service_enable_calls_through_to_service_manager() {
|
||||||
|
let bluetooth = MockServiceManager::new(false, false);
|
||||||
|
let mut page = Page::with_service_manager(ServiceManagerHandle::new(Box::new(bluetooth)));
|
||||||
|
let _task = page.update(Message::ServiceEnable);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
631
cosmic-settings/src/service_manager.rs
Normal file
631
cosmic-settings/src/service_manager.rs
Normal file
|
|
@ -0,0 +1,631 @@
|
||||||
|
// Copyright 2023 System76 <info@system76.com>
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
|
||||||
|
//! Service manager abstraction for managing system services.
|
||||||
|
//!
|
||||||
|
//! This module provides a trait-based interface for interacting with system
|
||||||
|
//! service managers (like systemd, OpenRC, etc.), allowing for testability
|
||||||
|
//! and flexibility across different init systems.
|
||||||
|
|
||||||
|
use std::future::Future;
|
||||||
|
use std::pin::Pin;
|
||||||
|
|
||||||
|
/// Trait for managing system services.
|
||||||
|
///
|
||||||
|
/// Implementations are bound to a specific service name and provide
|
||||||
|
/// methods to query service state and perform lifecycle operations.
|
||||||
|
pub trait ServiceManager {
|
||||||
|
/// Whether the service is configured to start on boot.
|
||||||
|
fn is_enabled(&self) -> bool;
|
||||||
|
|
||||||
|
/// Whether the service is currently running.
|
||||||
|
fn is_active(&self) -> bool;
|
||||||
|
|
||||||
|
/// Start the service.
|
||||||
|
fn activate(&self) -> Pin<Box<dyn Future<Output = ()> + Send>>;
|
||||||
|
|
||||||
|
/// Enable the service to start on boot and start it now.
|
||||||
|
fn enable(&self) -> Pin<Box<dyn Future<Output = ()> + Send>>;
|
||||||
|
|
||||||
|
/// Whether the service is installed on this system.
|
||||||
|
fn is_installed(&self) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A newtype around `Box<dyn ServiceManager>` that enables `#[derive(Default)]`
|
||||||
|
/// on containing structs. Methods on `ServiceManager` are accessible via `Deref`.
|
||||||
|
///
|
||||||
|
/// The `Default` impl creates a manager for the `"bluetooth"` service, which is
|
||||||
|
/// currently the only consumer. If other services need this in the future the
|
||||||
|
/// default can be made configurable.
|
||||||
|
pub struct ServiceManagerHandle(Box<dyn ServiceManager>);
|
||||||
|
|
||||||
|
impl ServiceManagerHandle {
|
||||||
|
pub fn new(manager: Box<dyn ServiceManager>) -> Self {
|
||||||
|
Self(manager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Deref for ServiceManagerHandle {
|
||||||
|
type Target = dyn ServiceManager;
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&*self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ServiceManagerHandle {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self(create_default_service_manager("bluetooth"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log a warning or error after a privileged command completes.
|
||||||
|
fn log_command_result(
|
||||||
|
result: &Result<std::process::ExitStatus, std::io::Error>,
|
||||||
|
description: &str,
|
||||||
|
service: &str,
|
||||||
|
) {
|
||||||
|
match result {
|
||||||
|
Ok(status) if status.success() => {}
|
||||||
|
Ok(status) => tracing::warn!(
|
||||||
|
"{} for '{}' failed with exit code: {:?}",
|
||||||
|
description,
|
||||||
|
service,
|
||||||
|
status.code(),
|
||||||
|
),
|
||||||
|
Err(e) => tracing::error!("Failed to execute {} for '{}': {}", description, service, e,),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a privileged command via `pkexec`.
|
||||||
|
///
|
||||||
|
/// This is the common async entry point for service management actions
|
||||||
|
/// that require elevated privileges.
|
||||||
|
async fn run_pkexec_command(args: &[&str], description: &str, service: &str) {
|
||||||
|
let result = tokio::process::Command::new("pkexec")
|
||||||
|
.args(args)
|
||||||
|
.status()
|
||||||
|
.await;
|
||||||
|
log_command_result(&result, description, service);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mock ServiceManager that returns configurable boolean values.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub struct MockServiceManager {
|
||||||
|
enabled: bool,
|
||||||
|
active: bool,
|
||||||
|
installed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl MockServiceManager {
|
||||||
|
pub fn new(enabled: bool, active: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
enabled,
|
||||||
|
active,
|
||||||
|
installed: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_installed(mut self, installed: bool) -> Self {
|
||||||
|
self.installed = installed;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl ServiceManager for MockServiceManager {
|
||||||
|
fn is_enabled(&self) -> bool {
|
||||||
|
self.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_active(&self) -> bool {
|
||||||
|
self.active
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activate(&self) -> Pin<Box<dyn Future<Output = ()> + Send>> {
|
||||||
|
Box::pin(async {})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enable(&self) -> Pin<Box<dyn Future<Output = ()> + Send>> {
|
||||||
|
Box::pin(async {})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_installed(&self) -> bool {
|
||||||
|
self.installed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SystemD implementation of ServiceManager.
|
||||||
|
#[cfg(feature = "systemd")]
|
||||||
|
pub struct SystemDServiceManager {
|
||||||
|
service_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "systemd")]
|
||||||
|
impl SystemDServiceManager {
|
||||||
|
pub fn new(service_name: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
service_name: service_name.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "systemd")]
|
||||||
|
impl ServiceManager for SystemDServiceManager {
|
||||||
|
fn is_enabled(&self) -> bool {
|
||||||
|
std::process::Command::new("systemctl")
|
||||||
|
.args(["is-enabled", &self.service_name])
|
||||||
|
.status()
|
||||||
|
.map(|status| status.success())
|
||||||
|
.unwrap_or(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_active(&self) -> bool {
|
||||||
|
std::process::Command::new("systemctl")
|
||||||
|
.args(["is-active", &self.service_name])
|
||||||
|
.status()
|
||||||
|
.map(|status| status.success())
|
||||||
|
.unwrap_or(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activate(&self) -> Pin<Box<dyn Future<Output = ()> + Send>> {
|
||||||
|
let service = self.service_name.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
run_pkexec_command(
|
||||||
|
&["systemctl", "start", &service],
|
||||||
|
"systemctl start",
|
||||||
|
&service,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enable(&self) -> Pin<Box<dyn Future<Output = ()> + Send>> {
|
||||||
|
let service = self.service_name.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
run_pkexec_command(
|
||||||
|
&["systemctl", "enable", "--now", &service],
|
||||||
|
"systemctl enable --now",
|
||||||
|
&service,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_installed(&self) -> bool {
|
||||||
|
std::process::Command::new("systemctl")
|
||||||
|
.args(["cat", &self.service_name])
|
||||||
|
.status()
|
||||||
|
.map(|status| status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenRC implementation of ServiceManager.
|
||||||
|
#[cfg(feature = "openrc")]
|
||||||
|
pub struct OpenRcServiceManager {
|
||||||
|
service_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "openrc")]
|
||||||
|
impl OpenRcServiceManager {
|
||||||
|
pub fn new(service_name: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
service_name: service_name.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses `rc-update show` output to check if a service is in any runlevel.
|
||||||
|
///
|
||||||
|
/// Uses exact service name matching to avoid false positives from prefix matches
|
||||||
|
/// (e.g., "bluetoothd" should not match a lookup for "bluetooth").
|
||||||
|
#[cfg(any(feature = "openrc", test))]
|
||||||
|
fn is_service_in_runlevel_output(output: &str, service_name: &str) -> bool {
|
||||||
|
output.lines().any(|line| {
|
||||||
|
line.split('|')
|
||||||
|
.next()
|
||||||
|
.map(|name| name.trim() == service_name)
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "openrc")]
|
||||||
|
impl ServiceManager for OpenRcServiceManager {
|
||||||
|
fn is_enabled(&self) -> bool {
|
||||||
|
std::process::Command::new("rc-update")
|
||||||
|
.args(["show"])
|
||||||
|
.output()
|
||||||
|
.map(|output| {
|
||||||
|
is_service_in_runlevel_output(
|
||||||
|
&String::from_utf8_lossy(&output.stdout),
|
||||||
|
&self.service_name,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_active(&self) -> bool {
|
||||||
|
std::process::Command::new("rc-service")
|
||||||
|
.args([&self.service_name, "status"])
|
||||||
|
.status()
|
||||||
|
.map(|status| status.success())
|
||||||
|
.unwrap_or(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activate(&self) -> Pin<Box<dyn Future<Output = ()> + Send>> {
|
||||||
|
let service = self.service_name.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
run_pkexec_command(
|
||||||
|
&["rc-service", &service, "start"],
|
||||||
|
"rc-service start",
|
||||||
|
&service,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enable(&self) -> Pin<Box<dyn Future<Output = ()> + Send>> {
|
||||||
|
let service = self.service_name.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
run_pkexec_command(
|
||||||
|
&["rc-update", "add", &service, "default"],
|
||||||
|
"rc-update add",
|
||||||
|
&service,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
run_pkexec_command(
|
||||||
|
&["rc-service", &service, "start"],
|
||||||
|
"rc-service start",
|
||||||
|
&service,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_installed(&self) -> bool {
|
||||||
|
std::path::Path::new("/etc/init.d")
|
||||||
|
.join(&self.service_name)
|
||||||
|
.exists()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect the running service manager and create a manager for the named service.
|
||||||
|
///
|
||||||
|
/// Checks runtime directories (`/run/systemd/system`, `/run/openrc`) to determine
|
||||||
|
/// which service manager is actively running, rather than relying on which binaries
|
||||||
|
/// are installed. This avoids false detections when multiple service managers are
|
||||||
|
/// installed but only one is actively managing services.
|
||||||
|
pub fn detect_service_manager(
|
||||||
|
service_name: impl Into<String>,
|
||||||
|
) -> Result<Box<dyn ServiceManager>, String> {
|
||||||
|
let service_name = service_name.into();
|
||||||
|
|
||||||
|
#[cfg(feature = "systemd")]
|
||||||
|
{
|
||||||
|
if std::path::Path::new("/run/systemd/system").exists() {
|
||||||
|
tracing::debug!("Detected systemd service manager via /run/systemd/system");
|
||||||
|
return Ok(Box::new(SystemDServiceManager::new(service_name)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "openrc")]
|
||||||
|
{
|
||||||
|
if std::path::Path::new("/run/openrc").exists() {
|
||||||
|
tracing::debug!("Detected OpenRC service manager via /run/openrc");
|
||||||
|
return Ok(Box::new(OpenRcServiceManager::new(service_name)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut checked = Vec::new();
|
||||||
|
#[cfg(feature = "systemd")]
|
||||||
|
checked.push("systemd (/run/systemd/system)");
|
||||||
|
#[cfg(feature = "openrc")]
|
||||||
|
checked.push("openrc (/run/openrc)");
|
||||||
|
|
||||||
|
Err(format!(
|
||||||
|
"Could not detect a running service manager. Checked for: {}. \
|
||||||
|
None of these service managers appear to be running on this system.",
|
||||||
|
checked.join(", ")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Factory: returns `MockServiceManager` in tests, detects the running service manager in production.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn create_default_service_manager(_service_name: impl Into<String>) -> Box<dyn ServiceManager> {
|
||||||
|
Box::new(MockServiceManager::new(false, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
pub fn create_default_service_manager(service_name: impl Into<String>) -> Box<dyn ServiceManager> {
|
||||||
|
let service_name = service_name.into();
|
||||||
|
detect_service_manager(&service_name).unwrap_or_else(|e| {
|
||||||
|
tracing::warn!(
|
||||||
|
"Failed to detect service manager: {}. Service management features will not be available.",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
tracing::warn!(
|
||||||
|
"Using no-op service manager for '{}'. Service status will always report as enabled/active, but operations will not actually execute.",
|
||||||
|
service_name
|
||||||
|
);
|
||||||
|
// Graceful degradation — app continues but service management won't work
|
||||||
|
Box::new(NoOpServiceManager)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fallback when no service manager is detected — reports everything as active but does nothing.
|
||||||
|
struct NoOpServiceManager;
|
||||||
|
|
||||||
|
impl ServiceManager for NoOpServiceManager {
|
||||||
|
fn is_enabled(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_active(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activate(&self) -> Pin<Box<dyn Future<Output = ()> + Send>> {
|
||||||
|
Box::pin(async {})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enable(&self) -> Pin<Box<dyn Future<Output = ()> + Send>> {
|
||||||
|
Box::pin(async {})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_installed(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tracing_subscriber::util::SubscriberInitExt;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_service_manager_trait_is_enabled() {
|
||||||
|
struct TestServiceManager {
|
||||||
|
enabled: bool,
|
||||||
|
installed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServiceManager for TestServiceManager {
|
||||||
|
fn is_enabled(&self) -> bool {
|
||||||
|
self.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_active(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activate(&self) -> Pin<Box<dyn Future<Output = ()> + Send>> {
|
||||||
|
Box::pin(async {})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enable(&self) -> Pin<Box<dyn Future<Output = ()> + Send>> {
|
||||||
|
Box::pin(async {})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_installed(&self) -> bool {
|
||||||
|
self.installed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let test_service = TestServiceManager {
|
||||||
|
enabled: true,
|
||||||
|
installed: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(test_service.is_enabled());
|
||||||
|
|
||||||
|
let disabled_service = TestServiceManager {
|
||||||
|
enabled: false,
|
||||||
|
installed: true,
|
||||||
|
};
|
||||||
|
assert!(!disabled_service.is_enabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_service_manager_trait_is_active() {
|
||||||
|
struct TestServiceManager {
|
||||||
|
active: bool,
|
||||||
|
installed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServiceManager for TestServiceManager {
|
||||||
|
fn is_enabled(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_active(&self) -> bool {
|
||||||
|
self.active
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activate(&self) -> Pin<Box<dyn Future<Output = ()> + Send>> {
|
||||||
|
Box::pin(async {})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enable(&self) -> Pin<Box<dyn Future<Output = ()> + Send>> {
|
||||||
|
Box::pin(async {})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_installed(&self) -> bool {
|
||||||
|
self.installed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let test_service = TestServiceManager {
|
||||||
|
active: true,
|
||||||
|
installed: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(test_service.is_active());
|
||||||
|
|
||||||
|
let inactive_service = TestServiceManager {
|
||||||
|
active: false,
|
||||||
|
installed: true,
|
||||||
|
};
|
||||||
|
assert!(!inactive_service.is_active());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mock_service_manager_returns_configured_values() {
|
||||||
|
let bluetooth = MockServiceManager::new(true, true);
|
||||||
|
assert!(bluetooth.is_enabled());
|
||||||
|
assert!(bluetooth.is_active());
|
||||||
|
|
||||||
|
let disabled_bluetooth = MockServiceManager::new(false, false);
|
||||||
|
assert!(!disabled_bluetooth.is_enabled());
|
||||||
|
assert!(!disabled_bluetooth.is_active());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(feature = "systemd")]
|
||||||
|
fn test_systemd_service_manager_implements_trait() {
|
||||||
|
let bluetooth = SystemDServiceManager::new("bluetooth");
|
||||||
|
|
||||||
|
// Can't test actual systemd calls in unit tests, but verify the struct exists
|
||||||
|
let _enabled: bool = bluetooth.is_enabled();
|
||||||
|
let _active: bool = bluetooth.is_active();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_service_manager_trait_has_activate_method() {
|
||||||
|
let bluetooth = MockServiceManager::new(false, false);
|
||||||
|
bluetooth.activate().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_service_manager_trait_has_enable_method() {
|
||||||
|
let bluetooth = MockServiceManager::new(false, false);
|
||||||
|
bluetooth.enable().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_service_manager_activate_returns_future() {
|
||||||
|
let bluetooth = MockServiceManager::new(false, false);
|
||||||
|
let future = bluetooth.activate();
|
||||||
|
future.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_service_manager_enable_returns_future() {
|
||||||
|
let bluetooth = MockServiceManager::new(false, false);
|
||||||
|
let future = bluetooth.enable();
|
||||||
|
future.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_service_manager_uses_mock_in_tests() {
|
||||||
|
let bluetooth = create_default_service_manager("bluetooth");
|
||||||
|
|
||||||
|
assert!(!bluetooth.is_enabled());
|
||||||
|
assert!(!bluetooth.is_active());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(feature = "openrc")]
|
||||||
|
fn test_openrc_service_manager_implements_trait() {
|
||||||
|
let bluetooth = OpenRcServiceManager::new("bluetooth");
|
||||||
|
|
||||||
|
// Can't test actual OpenRC calls in unit tests, but verify the struct exists
|
||||||
|
let _enabled: bool = bluetooth.is_enabled();
|
||||||
|
let _active: bool = bluetooth.is_active();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_service_manager_returns_result() {
|
||||||
|
let result = detect_service_manager("test-service");
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
assert!(!e.is_empty());
|
||||||
|
assert!(
|
||||||
|
e.contains("service manager"),
|
||||||
|
"Error message should mention service manager: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mock_service_manager_accepts_service_name() {
|
||||||
|
let bluetooth = MockServiceManager::new(true, true);
|
||||||
|
assert!(bluetooth.is_enabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mock_service_manager_is_installed_defaults_to_true() {
|
||||||
|
let bluetooth = MockServiceManager::new(true, true);
|
||||||
|
assert!(bluetooth.is_installed());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mock_service_manager_with_installed_configures_state() {
|
||||||
|
let not_installed = MockServiceManager::new(true, true).with_installed(false);
|
||||||
|
assert!(!not_installed.is_installed());
|
||||||
|
|
||||||
|
let installed = MockServiceManager::new(true, true).with_installed(true);
|
||||||
|
assert!(installed.is_installed());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_op_service_manager_reports_not_installed() {
|
||||||
|
let no_op = NoOpServiceManager;
|
||||||
|
|
||||||
|
// When no service manager is detected at runtime, the fallback must report
|
||||||
|
// not-installed so DBusServiceUnknown falls through to the unknown-service path
|
||||||
|
// instead of showing a non-functional toggle.
|
||||||
|
assert!(!no_op.is_installed());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_rc_update_output_exact_service_name() {
|
||||||
|
// Must use exact matching, not prefix matching — "bluetoothd" must not match "bluetooth".
|
||||||
|
let output = " bluetoothd | default\n";
|
||||||
|
let found = is_service_in_runlevel_output(output, "bluetooth");
|
||||||
|
assert!(!found);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_rc_update_output_matches_exact_name() {
|
||||||
|
let output = " bluetooth | default\n";
|
||||||
|
let found = is_service_in_runlevel_output(output, "bluetooth");
|
||||||
|
assert!(found);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_rc_update_output_not_found() {
|
||||||
|
let output = " dbus | default\n network | sysinit\n";
|
||||||
|
let found = is_service_in_runlevel_output(output, "bluetooth");
|
||||||
|
assert!(!found);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_log_command_result_logs_all_branches() {
|
||||||
|
let _guard = tracing_subscriber::fmt().with_test_writer().set_default();
|
||||||
|
|
||||||
|
let err_result: Result<std::process::ExitStatus, std::io::Error> = Err(
|
||||||
|
std::io::Error::new(std::io::ErrorKind::NotFound, "command not found"),
|
||||||
|
);
|
||||||
|
log_command_result(&err_result, "test command", "test-service");
|
||||||
|
|
||||||
|
let fail_result = std::process::Command::new("false").status();
|
||||||
|
log_command_result(&fail_result, "test false", "test-service");
|
||||||
|
|
||||||
|
let ok_result = std::process::Command::new("true").status();
|
||||||
|
log_command_result(&ok_result, "test true", "test-service");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_rc_update_output_multi_line() {
|
||||||
|
let output = " dbus | default\n bluetooth | default\n network | sysinit\n";
|
||||||
|
let found = is_service_in_runlevel_output(output, "bluetooth");
|
||||||
|
assert!(found);
|
||||||
|
}
|
||||||
|
}
|
||||||
2
debian/rules
vendored
2
debian/rules
vendored
|
|
@ -9,7 +9,7 @@ override_dh_auto_clean:
|
||||||
ischroot || just vendor
|
ischroot || just vendor
|
||||||
|
|
||||||
override_dh_auto_build:
|
override_dh_auto_build:
|
||||||
test -e vendor.tar && just build-vendored || just
|
test -e vendor.tar && just build-vendored --features systemd || just build-release --features systemd
|
||||||
|
|
||||||
override_dh_auto_install:
|
override_dh_auto_install:
|
||||||
just rootdir=$(DESTDIR) install
|
just rootdir=$(DESTDIR) install
|
||||||
|
|
|
||||||
41
justfile
41
justfile
|
|
@ -53,26 +53,31 @@ heaptrack *args:
|
||||||
check-features:
|
check-features:
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -ex
|
set -ex
|
||||||
cargo check --no-default-features
|
|
||||||
cargo check
|
cargo check
|
||||||
for feature in \
|
for service_manager in \
|
||||||
"page-accessibility" \
|
"systemd" \
|
||||||
"page-about" \
|
"openrc"
|
||||||
"page-bluetooth" \
|
|
||||||
"page-date" \
|
|
||||||
"page-default-apps" \
|
|
||||||
"page-display" \
|
|
||||||
"page-input" \
|
|
||||||
"page-legacy-applications" \
|
|
||||||
"page-networking" \
|
|
||||||
"page-power" \
|
|
||||||
"page-region" \
|
|
||||||
"page-sound" \
|
|
||||||
"page-users" \
|
|
||||||
"page-window-management" \
|
|
||||||
"page-workspaces"
|
|
||||||
do
|
do
|
||||||
cargo check --no-default-features --features "${feature}"
|
cargo check --no-default-features --features "${service_manager}"
|
||||||
|
for feature in \
|
||||||
|
"page-accessibility" \
|
||||||
|
"page-about" \
|
||||||
|
"page-bluetooth" \
|
||||||
|
"page-date" \
|
||||||
|
"page-default-apps" \
|
||||||
|
"page-display" \
|
||||||
|
"page-input" \
|
||||||
|
"page-legacy-applications" \
|
||||||
|
"page-networking" \
|
||||||
|
"page-power" \
|
||||||
|
"page-region" \
|
||||||
|
"page-sound" \
|
||||||
|
"page-users" \
|
||||||
|
"page-window-management" \
|
||||||
|
"page-workspaces"
|
||||||
|
do
|
||||||
|
cargo check --no-default-features --features "${feature},${service_manager}"
|
||||||
|
done
|
||||||
done
|
done
|
||||||
|
|
||||||
# Bump cargo version, create git commit, and create tag
|
# Bump cargo version, create git commit, and create tag
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue