feat: add openrc cargo feature for OpenRC bluetooth service management

Coding-Agent: Claude Code
Model: claude-sonnet-4-5
This commit is contained in:
Anthony Lannutti 2026-06-04 13:34:57 -05:00 committed by GitHub
parent 989a56346a
commit 4ae20bc1db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 752 additions and 59 deletions

View file

@ -113,8 +113,10 @@ git = "https://github.com/AerynOS/locales-rs"
optional = true
[features]
default = ["a11y", "linux", "single-instance", "wgpu"]
default = ["a11y", "linux", "single-instance", "wgpu", "systemd"]
gettext = ["dep:gettext-rs"]
systemd = []
openrc = []
# Default features for Linux
linux = [

View file

@ -7,6 +7,12 @@
#![allow(clippy::cast_lossless)]
#![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;
use std::str::FromStr;
@ -16,6 +22,7 @@ pub mod config;
#[macro_use]
pub mod localize;
pub mod pages;
pub mod service_manager;
pub mod subscription;
pub mod theme;
pub mod utils;

View file

@ -16,6 +16,10 @@ use std::sync::Arc;
use std::time::Duration;
use zbus::zvariant::OwnedObjectPath;
#[cfg(test)]
use crate::service_manager::MockServiceManager;
use crate::service_manager::ServiceManagerHandle;
enum Dialog {
RequestConfirmation {
device: String,
@ -158,6 +162,8 @@ pub struct Page {
service_is_active: bool,
subscription: Option<tokio::sync::oneshot::Sender<()>>,
service_manager: ServiceManagerHandle,
}
impl page::Page<crate::pages::Message> for Page {
@ -524,7 +530,18 @@ impl Page {
}
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) => {
@ -668,8 +685,8 @@ impl Page {
}
Message::DBusConnect(connection) => {
self.service_is_active = systemd::is_bluetooth_active();
self.service_is_enabled = systemd::is_bluetooth_enabled();
self.service_is_active = self.service_manager.is_active();
self.service_is_enabled = self.service_manager.is_enabled();
self.connection = Some(connection.clone());
let get_adapters_fut = get_adapters(connection.clone());
@ -801,8 +818,9 @@ impl Page {
}
Message::ServiceActivate => {
return cosmic::task::future(async {
systemd::activate_bluetooth().await;
let activate_future = self.service_manager.activate();
return cosmic::task::future(async move {
activate_future.await;
tokio::time::sleep(Duration::from_secs(3)).await;
match zbus::Connection::system().await {
@ -813,8 +831,9 @@ impl Page {
}
Message::ServiceEnable => {
return cosmic::task::future(async {
systemd::enable_bluetooth().await;
let enable_future = self.service_manager.enable();
return cosmic::task::future(async move {
enable_future.await;
tokio::time::sleep(Duration::from_secs(3)).await;
match zbus::Connection::system().await {
@ -873,7 +892,7 @@ fn status() -> Section<crate::pages::Message> {
return bluetooth_service_issue(
fl!("bluetooth", "inactive"),
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 {}
mod systemd {
use futures::FutureExt;
pub fn activate_bluetooth() -> impl Future<Output = ()> + Send {
tokio::process::Command::new("pkexec")
.args(["systemctl", "start", "bluetooth"])
.status()
.map(|_| ())
}
pub fn enable_bluetooth() -> impl Future<Output = ()> + Send {
tokio::process::Command::new("pkexec")
.args(["systemctl", "enable", "--now", "bluetooth"])
.status()
.map(|_| ())
}
pub fn is_bluetooth_enabled() -> bool {
std::process::Command::new("systemctl")
.args(["is-enabled", "bluetooth"])
.status()
.map(|status| status.success())
.unwrap_or(true)
}
pub fn is_bluetooth_active() -> bool {
std::process::Command::new("systemctl")
.args(["is-active", "bluetooth"])
.status()
.map(|status| status.success())
.unwrap_or(true)
impl Page {
#[cfg(test)]
fn with_service_manager(service_manager: ServiceManagerHandle) -> Self {
Self {
service_manager,
..Page::default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dbus_service_unknown_with_installed_service_queries_manager() {
let bluetooth = MockServiceManager::new(true, false);
let mut page = Page::with_service_manager(ServiceManagerHandle::new(Box::new(bluetooth)));
let _task = page.update(Message::BluetoothEvent(Event::DBusServiceUnknown));
assert!(page.service_is_enabled);
assert!(!page.service_is_active);
assert!(!page.bluez_service_unknown);
}
#[test]
fn test_dbus_service_unknown_with_uninstalled_service_sets_bluez_unknown() {
let bluetooth = MockServiceManager::new(true, true).with_installed(false);
let mut page = Page::with_service_manager(ServiceManagerHandle::new(Box::new(bluetooth)));
let _task = page.update(Message::BluetoothEvent(Event::DBusServiceUnknown));
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);
}
}

View 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
View file

@ -9,7 +9,7 @@ override_dh_auto_clean:
ischroot || just vendor
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:
just rootdir=$(DESTDIR) install

View file

@ -53,26 +53,31 @@ heaptrack *args:
check-features:
#!/usr/bin/env bash
set -ex
cargo check --no-default-features
cargo check
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"
for service_manager in \
"systemd" \
"openrc"
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
# Bump cargo version, create git commit, and create tag