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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ members = [
resolver = "2"

[workspace.package]
version = "1.3.0"
version = "1.3.1"
edition = "2021"
license = "MIT"

Expand Down
33 changes: 17 additions & 16 deletions crates/noticenterctl/src/app/local.rs
Original file line number Diff line number Diff line change
@@ -1,26 +1,27 @@
//! Local command dispatch that does not require a running daemon

use std::path::PathBuf;

use anyhow::{Context, Result};

use crate::cli::{Command, PresetCommand, ThemeCommand};
use crate::cli::{Command, DevCommand, DoctorCommand};

pub(super) fn handle_local_command(
command: Command,
mut run_css: impl FnMut(Option<PathBuf>) -> Result<()>,
mut run_preset: impl FnMut(PresetCommand) -> Result<()>,
mut sync_session: impl FnMut(crate::cli::DoctorServiceManagerArg) -> Result<()>,
mut run_theme: impl FnMut(ThemeCommand) -> Result<()>,
) -> Result<()> {
pub(super) fn handle_local_command(command: Command) -> Result<()> {
// Local commands remain available while the session bus or daemon is unavailable
match command {
Command::CssCheck { config } => run_css(config),
Command::Preset { command } => run_preset(command).context("preset command failed"),
Command::SyncSessionEnvironment { service_manager } => sync_session(service_manager),
Command::Theme { command } => run_theme(command).context("theme command failed"),
// The caller routes daemon-backed commands before reaching this helper
_ => Ok(()),
Command::CssCheck { config } => crate::css_check::run(config),
Command::Preset { command } => {
crate::preset::run_preset(command).context("preset command failed")
}
Command::Theme { command } => crate::theme::run(command).context("theme command failed"),
Command::Doctor {
command: Some(DoctorCommand::RepairSession),
service_manager,
..
} => crate::session_environment::sync(service_manager),
Command::Dev {
command: DevCommand::Logs,
} => crate::debug_logs::follow_debug_logs(),
// Incorrect routing must fail instead of reporting a successful no-op
other => anyhow::bail!("internal routing error: {other:?} is not a local command"),
}
}

Expand Down
68 changes: 33 additions & 35 deletions crates/noticenterctl/src/app/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,55 +5,53 @@ use clap::Parser;
use unixnotis_core::{ensure_control_api_version, log_session_bus_identity, ControlProxy};
use zbus::Connection;

use crate::cli::{Args, Command};
use crate::cli::{Args, Command, ExecutionKind};

use super::local::handle_local_command;

pub fn run() -> Result<()> {
// Parse CLI arguments before any daemon work starts
let args = Args::parse();
let command = args.command;
run_command(args.command)
}

pub(super) fn run_command(command: Command) -> Result<()> {
// Semantic checks happen before runtime and D-Bus setup
command.validate()?;

if command.is_synchronous() {
// Preset and CSS work should not pay for an unused asynchronous runtime
handle_local_command(
command,
crate::css_check::run,
crate::preset::run_preset,
crate::session_environment::sync,
crate::theme::run,
)?;
return Ok(());
match command.execution_kind() {
ExecutionKind::LocalSync => handle_local_command(command),
ExecutionKind::LocalAsync | ExecutionKind::Daemon => {
// A current-thread runtime avoids a worker pool for short control commands
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("build command runtime")?;
runtime.block_on(run_async(command))
}
}

// A current-thread runtime avoids a worker pool for short control commands
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("build command runtime")?;
runtime.block_on(run_async(command))
}

async fn run_async(command: Command) -> Result<()> {
if let Command::Doctor {
json,
verbose,
service_manager,
config,
} = command
{
// Doctor owns its D-Bus connection so one failed probe cannot stop later checks
return crate::doctor::run(json, verbose, service_manager, config).await;
pub(super) async fn run_async(command: Command) -> Result<()> {
match command {
Command::Doctor {
command: None,
json,
verbose,
service_manager,
config,
} => {
// Doctor owns its D-Bus connection so one failed probe cannot stop later checks
crate::doctor::run(json, verbose, service_manager, config).await
}
Command::Doctor {
command: Some(_), ..
} => anyhow::bail!("internal routing error: doctor repair reached async dispatcher"),
command => run_daemon(command).await,
}
}

// Every remaining command is backed by the running daemon
debug_assert!(
!command.is_local_only(),
"local-only commands must return before D-Bus dispatch"
);

async fn run_daemon(command: Command) -> Result<()> {
// Control commands need the session bus and the daemon proxy
let connection = Connection::session()
.await
Expand Down
46 changes: 5 additions & 41 deletions crates/noticenterctl/src/app/tests/local.rs
Original file line number Diff line number Diff line change
@@ -1,48 +1,12 @@
use anyhow::Result;

use super::handle_local_command;
use crate::cli::Command;

#[test]
fn daemon_command_is_not_dispatched_to_local_handlers() {
let mut css_called = false;
let mut preset_called = false;

handle_local_command(
Command::OpenPanel { debug: None },
|_| {
css_called = true;
Ok(())
},
|_| {
preset_called = true;
Ok(())
},
|_| Ok(()),
|_| Ok(()),
)
.expect("ignore daemon command in local dispatcher");

assert!(!css_called, "daemon command must not invoke CSS checks");
assert!(
!preset_called,
"daemon command must not invoke preset handling"
);
}

#[test]
fn local_handler_error_is_returned_to_the_caller() {
let result = handle_local_command(
Command::CssCheck { config: None },
|_| anyhow::bail!("CSS check failed"),
|_| -> Result<()> { Ok(()) },
|_| -> Result<()> { Ok(()) },
|_| -> Result<()> { Ok(()) },
);

let error = result.expect_err("local command failure should be returned");
fn daemon_command_fails_closed_in_local_dispatcher() {
let error = handle_local_command(Command::OpenPanel)
.expect_err("daemon command must fail in local dispatcher");
assert!(
error.to_string().contains("CSS check failed"),
"original local command error should remain visible"
error.to_string().contains("internal routing error"),
"routing failure should remain visible"
);
}
85 changes: 40 additions & 45 deletions crates/noticenterctl/src/app/tests/runner.rs
Original file line number Diff line number Diff line change
@@ -1,54 +1,49 @@
use std::cell::Cell;
use std::str::FromStr;

use anyhow::Result;

use crate::cli::{Command, PresetCommand};
use crate::cli::{Command, DndDuration, DndState, DoctorCommand, DoctorServiceManagerArg};

use super::local::handle_local_command;
use super::runner::{run_async, run_command};

#[test]
fn handle_local_command_runs_css_check_branch() {
let css_called = Cell::new(false);

handle_local_command(
Command::CssCheck { config: None },
|config| {
assert!(config.is_none());
css_called.set(true);
Ok(())
},
|_| -> Result<()> { panic!("preset runner should not be called for css check") },
|_| -> Result<()> { panic!("session runner should not be called for css check") },
|_| -> Result<()> { panic!("theme runner should not be called for css check") },
)
.expect("css check should dispatch");

assert!(css_called.get());
fn async_doctor_report_fails_closed_in_sync_local_dispatcher() {
let command = Command::Doctor {
command: None,
json: false,
verbose: false,
service_manager: DoctorServiceManagerArg::Auto,
config: None,
};
let error = handle_local_command(command)
.expect_err("async doctor report must fail in synchronous dispatcher");

assert!(error.to_string().contains("internal routing error"));
}

#[test]
fn handle_local_command_runs_preset_branch_with_command_payload() {
let preset_called = Cell::new(false);

handle_local_command(
Command::Preset {
command: PresetCommand::Inspect {
input: "theme.unixnotis".to_string(),
},
},
|_| -> Result<()> { panic!("css runner should not be called for preset command") },
|command| {
let PresetCommand::Inspect { input } = command else {
panic!("expected inspect preset command");
};
assert_eq!(input, "theme.unixnotis");
preset_called.set(true);
Ok(())
},
|_| -> Result<()> { panic!("session runner should not be called for preset command") },
|_| -> Result<()> { panic!("theme runner should not be called for preset command") },
)
.expect("preset should dispatch");

assert!(preset_called.get());
fn run_command_validates_semantics_before_starting_any_runtime_work() {
let command = Command::Dnd {
state: DndState::Off,
for_duration: Some(DndDuration::from_str("30m").expect("valid duration")),
until: None,
};
let error = run_command(command).expect_err("invalid DND command must fail before dispatch");

assert!(error.to_string().contains("valid only with `dnd on`"));
}

#[tokio::test]
async fn doctor_repair_fails_closed_in_async_dispatcher() {
let command = Command::Doctor {
command: Some(DoctorCommand::RepairSession),
json: false,
verbose: false,
service_manager: DoctorServiceManagerArg::Auto,
config: None,
};
let error = run_async(command)
.await
.expect_err("synchronous repair must fail in async dispatcher");

assert!(error.to_string().contains("internal routing error"));
}
27 changes: 27 additions & 0 deletions crates/noticenterctl/src/cli/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,33 @@ pub enum DoctorServiceManagerArg {
Manual,
}

#[derive(Subcommand, Debug)]
pub enum DoctorCommand {
// Repair the daemon service environment for the active graphical session
RepairSession,
}

#[derive(Subcommand, Debug)]
pub enum DevCommand {
// Open the panel with daemon diagnostic rendering enabled
OpenPanel {
#[arg(long, value_enum, default_value = "info")]
level: DebugLevelArg,
},
// Rebuild the daemon desktop application index
RefreshApplications,
// Explain attribution and popup decisions for one active notification
ExplainNotification {
id: u32,
},
// Print diagnostic detail for active notifications
DumpActive,
// Print diagnostic detail for saved notification history
DumpHistory,
// Follow daemon journal logs until interrupted
Logs,
}

impl InhibitScopeArg {
pub(crate) const fn as_scope(self) -> u32 {
// Map CLI scope to the daemon bitmask value
Expand Down
Loading