refactor: clean up daemon and CLI duplication
Reduce repeated adapter dispatch, CLI action rendering, and config save flows while keeping the current Roku behavior and docs aligned with the known secret-menu limitations.
This commit is contained in:
+402
-115
@@ -26,6 +26,20 @@ use crate::{
|
||||
const DAEMON_START_WAIT_ATTEMPTS: usize = 20;
|
||||
const DAEMON_START_WAIT_INTERVAL: Duration = Duration::from_millis(250);
|
||||
const DEFAULT_REMOTE_SEQUENCE_DELAY_MS: u64 = 200;
|
||||
const TVCTLD_SYSTEMD_UNIT: &str = "tvctld.service";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum DaemonMode {
|
||||
AdHoc,
|
||||
Systemd,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SystemdServiceStatus {
|
||||
installed: bool,
|
||||
active: bool,
|
||||
main_pid: Option<u32>,
|
||||
}
|
||||
|
||||
/// The tvctl command-line interface.
|
||||
#[derive(Debug, Parser)]
|
||||
@@ -91,13 +105,13 @@ pub enum Command {
|
||||
/// Manage the tvctld lifecycle.
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum DaemonCommand {
|
||||
/// Start the background daemon process.
|
||||
/// Start the background daemon or installed user service.
|
||||
Start,
|
||||
/// Stop the running daemon process.
|
||||
/// Stop the running daemon or installed user service.
|
||||
Stop,
|
||||
/// Restart the running daemon process.
|
||||
/// Restart the running daemon or installed user service.
|
||||
Restart,
|
||||
/// Show whether the daemon is running.
|
||||
/// Show daemon and user-service status.
|
||||
Status,
|
||||
/// Generate and enable a systemd user service.
|
||||
Install,
|
||||
@@ -174,13 +188,13 @@ pub enum AppCommand {
|
||||
/// Remote control commands.
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum RemoteCommand {
|
||||
/// Send a single normalized key.
|
||||
Key {
|
||||
/// Send one or more normalized keys.
|
||||
Send {
|
||||
/// One or more key names such as `home`, `down`, or `literal:abc`.
|
||||
keys: Vec<String>,
|
||||
/// Delay between keys in milliseconds when sending more than one key.
|
||||
/// Delay between keys when sending more than one key, in milliseconds.
|
||||
#[arg(long, default_value_t = DEFAULT_REMOTE_SEQUENCE_DELAY_MS)]
|
||||
delay_ms: u64,
|
||||
delay: u64,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -261,10 +275,23 @@ struct ConfigMutationResult {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct ServiceResult {
|
||||
struct ServiceInstallResult {
|
||||
unit_file: String,
|
||||
installed: bool,
|
||||
enabled: bool,
|
||||
running: bool,
|
||||
already_installed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct ServiceUninstallResult {
|
||||
unit_file: String,
|
||||
installed: bool,
|
||||
enabled: bool,
|
||||
running: bool,
|
||||
removed_unit: bool,
|
||||
stopped_service: bool,
|
||||
stopped_ad_hoc: bool,
|
||||
}
|
||||
|
||||
/// Parse the CLI and execute the selected command.
|
||||
@@ -300,12 +327,7 @@ async fn handle_daemon_command(cli: &Cli, command: DaemonCommand) -> Result<(),
|
||||
match command {
|
||||
DaemonCommand::Start => daemon_start(cli).await,
|
||||
DaemonCommand::Stop => daemon_stop(cli).await,
|
||||
DaemonCommand::Restart => {
|
||||
if daemon_status_payload().await.is_some() {
|
||||
daemon_stop(cli).await?;
|
||||
}
|
||||
daemon_start(cli).await
|
||||
}
|
||||
DaemonCommand::Restart => daemon_restart(cli).await,
|
||||
DaemonCommand::Status => daemon_status(cli).await,
|
||||
DaemonCommand::Install => daemon_install(cli).await,
|
||||
DaemonCommand::Uninstall => daemon_uninstall(cli).await,
|
||||
@@ -406,8 +428,7 @@ async fn handle_app_command(cli: &Cli, command: AppCommand) -> Result<(), CliErr
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let result: ActionResult = parse_response_data(response)?;
|
||||
render(cli, &result, || result.detail.clone())
|
||||
render_action_response(cli, response)
|
||||
}
|
||||
AppCommand::Stop => {
|
||||
let response = send_request(
|
||||
@@ -417,8 +438,7 @@ async fn handle_app_command(cli: &Cli, command: AppCommand) -> Result<(), CliErr
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let result: ActionResult = parse_response_data(response)?;
|
||||
render(cli, &result, || result.detail.clone())
|
||||
render_action_response(cli, response)
|
||||
}
|
||||
AppCommand::Refresh { clear } => {
|
||||
let response = send_request(
|
||||
@@ -437,10 +457,10 @@ async fn handle_app_command(cli: &Cli, command: AppCommand) -> Result<(), CliErr
|
||||
|
||||
async fn handle_remote_command(cli: &Cli, command: RemoteCommand) -> Result<(), CliError> {
|
||||
match command {
|
||||
RemoteCommand::Key { keys, delay_ms } => {
|
||||
RemoteCommand::Send { keys, delay } => {
|
||||
if keys.is_empty() {
|
||||
return Err(CliError::new(
|
||||
"At least one key is required for `tvctl remote key`.",
|
||||
"At least one key is required for `tvctl remote send`.",
|
||||
"Pass one or more keys such as `home` or `home down select`.",
|
||||
));
|
||||
}
|
||||
@@ -453,8 +473,7 @@ async fn handle_remote_command(cli: &Cli, command: RemoteCommand) -> Result<(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let result: ActionResult = parse_response_data(response)?;
|
||||
render(cli, &result, || result.detail.clone())
|
||||
render_action_response(cli, response)
|
||||
} else {
|
||||
let parsed = keys
|
||||
.iter()
|
||||
@@ -465,12 +484,11 @@ async fn handle_remote_command(cli: &Cli, command: RemoteCommand) -> Result<(),
|
||||
&DaemonRequest::SendSequence {
|
||||
device: cli.device.clone(),
|
||||
keys: parsed,
|
||||
delay_ms,
|
||||
delay_ms: delay,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let result: ActionResult = parse_response_data(response)?;
|
||||
render(cli, &result, || result.detail.clone())
|
||||
render_action_response(cli, response)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -499,8 +517,7 @@ async fn handle_dev_command(cli: &Cli, command: DevCommand) -> Result<(), CliErr
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let result: ActionResult = parse_response_data(response)?;
|
||||
render(cli, &result, || result.detail.clone())
|
||||
render_action_response(cli, response)
|
||||
}
|
||||
DevCommand::Reload => {
|
||||
let response = send_request(
|
||||
@@ -510,8 +527,7 @@ async fn handle_dev_command(cli: &Cli, command: DevCommand) -> Result<(), CliErr
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let result: ActionResult = parse_response_data(response)?;
|
||||
render(cli, &result, || result.detail.clone())
|
||||
render_action_response(cli, response)
|
||||
}
|
||||
DevCommand::Logs => {
|
||||
let response = send_request(
|
||||
@@ -555,23 +571,15 @@ async fn handle_config_command(cli: &Cli, command: ConfigCommand) -> Result<(),
|
||||
}
|
||||
ConfigCommand::Set { key, value } => {
|
||||
let path = default_config_path();
|
||||
let daemon_socket = daemon_status_payload()
|
||||
.await
|
||||
.map(|status| PathBuf::from(status.socket));
|
||||
let mut config = load_config().await?;
|
||||
config.set_value(&key, &value).map_err(|error| {
|
||||
CliError::new(
|
||||
format!("Failed to set config value: {error}"),
|
||||
"Run `tvctl config list` to confirm the key and expected value type.",
|
||||
)
|
||||
})?;
|
||||
config.save_to_path(&path).await.map_err(|error| {
|
||||
CliError::new(
|
||||
format!("Failed to save config: {error}"),
|
||||
"Check write permissions for ~/.config/tvctl/config.toml.",
|
||||
)
|
||||
})?;
|
||||
let reload = maybe_reload_daemon_config(daemon_socket).await?;
|
||||
let reload = save_config_with_reload(path.clone(), |config| {
|
||||
config.set_value(&key, &value).map_err(|error| {
|
||||
CliError::new(
|
||||
format!("Failed to set config value: {error}"),
|
||||
"Run `tvctl config list` to confirm the key and expected value type.",
|
||||
)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
let result = ConfigMutationResult {
|
||||
value: Some(redact_config_value(&key, value)),
|
||||
key: Some(key),
|
||||
@@ -587,17 +595,11 @@ async fn handle_config_command(cli: &Cli, command: ConfigCommand) -> Result<(),
|
||||
}
|
||||
ConfigCommand::Reset => {
|
||||
let path = default_config_path();
|
||||
let daemon_socket = daemon_status_payload()
|
||||
.await
|
||||
.map(|status| PathBuf::from(status.socket));
|
||||
let config = TvctlConfig::default();
|
||||
config.save_to_path(&path).await.map_err(|error| {
|
||||
CliError::new(
|
||||
format!("Failed to reset config: {error}"),
|
||||
"Check write permissions for ~/.config/tvctl/config.toml.",
|
||||
)
|
||||
})?;
|
||||
let reload = maybe_reload_daemon_config(daemon_socket).await?;
|
||||
let reload = save_config_with_reload(path.clone(), |config| {
|
||||
*config = TvctlConfig::default();
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
let result = ConfigMutationResult {
|
||||
key: None,
|
||||
value: None,
|
||||
@@ -619,6 +621,27 @@ async fn handle_config_command(cli: &Cli, command: ConfigCommand) -> Result<(),
|
||||
}
|
||||
|
||||
async fn daemon_start(cli: &Cli) -> Result<(), CliError> {
|
||||
let service = systemd_service_status().await?;
|
||||
if service.installed {
|
||||
if let Some(status) = daemon_status_payload().await {
|
||||
if service_owns_daemon(&service, &status) {
|
||||
return render(cli, &status, || {
|
||||
format!(
|
||||
"tvctld user service is already running on {}",
|
||||
status.socket
|
||||
)
|
||||
});
|
||||
}
|
||||
stop_ad_hoc_daemon(&status).await?;
|
||||
}
|
||||
|
||||
run_systemctl(&["--user", "start", TVCTLD_SYSTEMD_UNIT]).await?;
|
||||
let status = wait_for_daemon_ready().await?;
|
||||
return render(cli, &status, || {
|
||||
format!("Started tvctld user service on {}", status.socket)
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(status) = daemon_status_payload().await {
|
||||
return render(cli, &status, || {
|
||||
format!("tvctld is already running on {}", status.socket)
|
||||
@@ -645,63 +668,131 @@ async fn daemon_start(cli: &Cli) -> Result<(), CliError> {
|
||||
)
|
||||
})?;
|
||||
|
||||
for _ in 0..DAEMON_START_WAIT_ATTEMPTS {
|
||||
if let Some(status) = daemon_status_payload().await {
|
||||
return render(cli, &status, || {
|
||||
format!("tvctld started on {}", status.socket)
|
||||
});
|
||||
}
|
||||
sleep(DAEMON_START_WAIT_INTERVAL).await;
|
||||
}
|
||||
|
||||
Err(CliError::new(
|
||||
"tvctld did not become ready in time.",
|
||||
"Check whether the socket path is writable and retry `tvctl daemon start`.",
|
||||
))
|
||||
let status = wait_for_daemon_ready().await?;
|
||||
render(cli, &status, || {
|
||||
format!("tvctld started on {}", status.socket)
|
||||
})
|
||||
}
|
||||
|
||||
async fn daemon_stop(cli: &Cli) -> Result<(), CliError> {
|
||||
let socket = load_socket_path().await?;
|
||||
let response = send_request(socket, &DaemonRequest::Shutdown).await?;
|
||||
let data: serde_json::Value = parse_response_data(response)?;
|
||||
render(cli, &data, || "tvctld stopped.".to_string())
|
||||
let service = systemd_service_status().await?;
|
||||
if service.installed {
|
||||
let mut stopped_service = false;
|
||||
if service.active {
|
||||
run_systemctl(&["--user", "stop", TVCTLD_SYSTEMD_UNIT]).await?;
|
||||
wait_for_daemon_stopped().await?;
|
||||
stopped_service = true;
|
||||
}
|
||||
|
||||
let mut stopped_ad_hoc = false;
|
||||
if let Some(status) = daemon_status_payload().await {
|
||||
stop_ad_hoc_daemon(&status).await?;
|
||||
stopped_ad_hoc = true;
|
||||
}
|
||||
|
||||
if !stopped_service && !stopped_ad_hoc {
|
||||
return Err(CliError::new(
|
||||
"tvctld is not running.",
|
||||
"Start the daemon first or use `tvctl daemon status` to inspect the service.",
|
||||
));
|
||||
}
|
||||
|
||||
let detail = match (stopped_service, stopped_ad_hoc) {
|
||||
(true, true) => "Stopped tvctld user service and cleared a conflicting ad hoc daemon.",
|
||||
(true, false) => "Stopped tvctld user service.",
|
||||
(false, true) => {
|
||||
"Stopped a conflicting ad hoc tvctld while the user service remains installed."
|
||||
}
|
||||
(false, false) => unreachable!(),
|
||||
};
|
||||
return render(cli, &serde_json::json!({ "stopped": true }), || {
|
||||
detail.to_string()
|
||||
});
|
||||
}
|
||||
|
||||
let status = daemon_status_payload().await.ok_or_else(|| {
|
||||
CliError::new(
|
||||
"tvctld is not running.",
|
||||
"Start the daemon first or use `tvctl daemon status` to inspect it.",
|
||||
)
|
||||
})?;
|
||||
stop_ad_hoc_daemon(&status).await?;
|
||||
render(cli, &serde_json::json!({ "stopped": true }), || {
|
||||
"tvctld stopped.".to_string()
|
||||
})
|
||||
}
|
||||
|
||||
async fn daemon_restart(cli: &Cli) -> Result<(), CliError> {
|
||||
let service = systemd_service_status().await?;
|
||||
if service.installed {
|
||||
if let Some(status) = daemon_status_payload().await {
|
||||
if !service_owns_daemon(&service, &status) {
|
||||
stop_ad_hoc_daemon(&status).await?;
|
||||
}
|
||||
}
|
||||
run_systemctl(&["--user", "restart", TVCTLD_SYSTEMD_UNIT]).await?;
|
||||
let status = wait_for_daemon_ready().await?;
|
||||
return render(cli, &status, || {
|
||||
format!("Restarted tvctld user service on {}", status.socket)
|
||||
});
|
||||
}
|
||||
|
||||
if daemon_status_payload().await.is_some() {
|
||||
daemon_stop(cli).await?;
|
||||
}
|
||||
daemon_start(cli).await
|
||||
}
|
||||
|
||||
async fn daemon_status(cli: &Cli) -> Result<(), CliError> {
|
||||
let service = systemd_service_status().await?;
|
||||
if let Some(status) = daemon_status_payload().await {
|
||||
let mode = daemon_mode_for_status(&service, &status);
|
||||
return render(cli, &status, || {
|
||||
let http = if status.http_enabled {
|
||||
format!("{}:{}", status.http_host, status.http_port)
|
||||
} else {
|
||||
"disabled".to_string()
|
||||
};
|
||||
let default_device = status.default_device.as_deref().unwrap_or("none");
|
||||
format!(
|
||||
"tvctld is running.\nPID: {}\nSocket: {}\nHTTP: {}\nKnown Devices: {}\nDefault Device: {}",
|
||||
status.pid, status.socket, http, status.device_count, default_device
|
||||
)
|
||||
render_daemon_status(&service, &status, mode)
|
||||
});
|
||||
}
|
||||
|
||||
if cli.json {
|
||||
let status = serde_json::json!({
|
||||
"running": false,
|
||||
"service_installed": service.installed,
|
||||
"service_active": service.active,
|
||||
});
|
||||
return render(cli, &status, || "tvctld is not running.".to_string());
|
||||
}
|
||||
|
||||
println!("tvctld is not running.");
|
||||
if service.installed {
|
||||
println!("tvctld user service is installed but not running.");
|
||||
} else {
|
||||
println!("tvctld is not running.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn daemon_install(cli: &Cli) -> Result<(), CliError> {
|
||||
let unit_path = systemd_unit_path();
|
||||
let existing_service = systemd_service_status().await?;
|
||||
if existing_service.installed {
|
||||
let result = ServiceInstallResult {
|
||||
unit_file: unit_path.display().to_string(),
|
||||
installed: true,
|
||||
enabled: true,
|
||||
running: existing_service.active,
|
||||
already_installed: true,
|
||||
};
|
||||
return render(cli, &result, || render_service_install(&result));
|
||||
}
|
||||
|
||||
if let Some(status) = daemon_status_payload().await {
|
||||
stop_ad_hoc_daemon(&status).await?;
|
||||
}
|
||||
|
||||
let exe = std::env::current_exe().map_err(|error| {
|
||||
CliError::new(
|
||||
format!("Unable to locate the tvctl binary: {error}"),
|
||||
"Run the command from an installed or built tvctl executable.",
|
||||
)
|
||||
})?;
|
||||
let unit_path = systemd_unit_path();
|
||||
if let Some(parent) = unit_path.parent() {
|
||||
fs::create_dir_all(parent).await.map_err(|error| {
|
||||
CliError::new(
|
||||
@@ -721,29 +812,36 @@ async fn daemon_install(cli: &Cli) -> Result<(), CliError> {
|
||||
})?;
|
||||
|
||||
run_systemctl(&["--user", "daemon-reload"]).await?;
|
||||
run_systemctl(&["--user", "enable", "--now", "tvctld.service"]).await?;
|
||||
run_systemctl(&["--user", "enable", "--now", TVCTLD_SYSTEMD_UNIT]).await?;
|
||||
wait_for_daemon_ready().await?;
|
||||
|
||||
let result = ServiceResult {
|
||||
let result = ServiceInstallResult {
|
||||
unit_file: unit_path.display().to_string(),
|
||||
installed: true,
|
||||
enabled: true,
|
||||
running: true,
|
||||
already_installed: false,
|
||||
};
|
||||
render(cli, &result, || {
|
||||
format!(
|
||||
"Installed and started tvctld user service at {}.",
|
||||
result.unit_file
|
||||
)
|
||||
})
|
||||
render(cli, &result, || render_service_install(&result))
|
||||
}
|
||||
|
||||
async fn daemon_uninstall(cli: &Cli) -> Result<(), CliError> {
|
||||
if daemon_status_payload().await.is_some() {
|
||||
daemon_stop(cli).await?;
|
||||
}
|
||||
let unit_path = systemd_unit_path();
|
||||
let _ = run_systemctl(&["--user", "disable", "--now", "tvctld.service"]).await;
|
||||
let service = systemd_service_status().await?;
|
||||
let mut stopped_service = false;
|
||||
if service.installed || service.active {
|
||||
let _ = run_systemctl(&["--user", "disable", "--now", TVCTLD_SYSTEMD_UNIT]).await;
|
||||
let _ = wait_for_daemon_stopped().await;
|
||||
stopped_service = service.active;
|
||||
}
|
||||
let mut stopped_ad_hoc = false;
|
||||
if let Some(status) = daemon_status_payload().await {
|
||||
stop_ad_hoc_daemon(&status).await?;
|
||||
stopped_ad_hoc = true;
|
||||
}
|
||||
let mut removed_unit = false;
|
||||
match fs::remove_file(&unit_path).await {
|
||||
Ok(()) => {}
|
||||
Ok(()) => removed_unit = true,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
return Err(CliError::new(
|
||||
@@ -752,16 +850,18 @@ async fn daemon_uninstall(cli: &Cli) -> Result<(), CliError> {
|
||||
));
|
||||
}
|
||||
}
|
||||
run_systemctl(&["--user", "daemon-reload"]).await?;
|
||||
let _ = run_systemctl(&["--user", "daemon-reload"]).await;
|
||||
|
||||
let result = ServiceResult {
|
||||
let result = ServiceUninstallResult {
|
||||
unit_file: unit_path.display().to_string(),
|
||||
installed: false,
|
||||
enabled: false,
|
||||
running: false,
|
||||
removed_unit,
|
||||
stopped_service,
|
||||
stopped_ad_hoc,
|
||||
};
|
||||
render(cli, &result, || {
|
||||
format!("Removed tvctld user service from {}.", result.unit_file)
|
||||
})
|
||||
render(cli, &result, || render_service_uninstall(&result))
|
||||
}
|
||||
|
||||
async fn daemon_status_payload() -> Option<DaemonStatus> {
|
||||
@@ -770,6 +870,95 @@ async fn daemon_status_payload() -> Option<DaemonStatus> {
|
||||
parse_response_data(response).ok()
|
||||
}
|
||||
|
||||
async fn wait_for_daemon_ready() -> Result<DaemonStatus, CliError> {
|
||||
for _ in 0..DAEMON_START_WAIT_ATTEMPTS {
|
||||
if let Some(status) = daemon_status_payload().await {
|
||||
return Ok(status);
|
||||
}
|
||||
sleep(DAEMON_START_WAIT_INTERVAL).await;
|
||||
}
|
||||
|
||||
Err(CliError::new(
|
||||
"tvctld did not become ready in time.",
|
||||
"Check whether the socket path is writable and retry `tvctl daemon start`.",
|
||||
))
|
||||
}
|
||||
|
||||
async fn wait_for_daemon_stopped() -> Result<(), CliError> {
|
||||
for _ in 0..DAEMON_START_WAIT_ATTEMPTS {
|
||||
if daemon_status_payload().await.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
sleep(DAEMON_START_WAIT_INTERVAL).await;
|
||||
}
|
||||
|
||||
Err(CliError::new(
|
||||
"tvctld did not stop in time.",
|
||||
"Retry `tvctl daemon stop` or inspect the running process.",
|
||||
))
|
||||
}
|
||||
|
||||
async fn stop_ad_hoc_daemon(status: &DaemonStatus) -> Result<(), CliError> {
|
||||
let response = send_request(PathBuf::from(&status.socket), &DaemonRequest::Shutdown).await?;
|
||||
let _: serde_json::Value = parse_response_data(response)?;
|
||||
wait_for_daemon_stopped().await
|
||||
}
|
||||
|
||||
async fn systemd_service_status() -> Result<SystemdServiceStatus, CliError> {
|
||||
let installed = fs::metadata(systemd_unit_path()).await.is_ok();
|
||||
if !installed {
|
||||
return Ok(SystemdServiceStatus {
|
||||
installed: false,
|
||||
active: false,
|
||||
main_pid: None,
|
||||
});
|
||||
}
|
||||
|
||||
let active =
|
||||
systemctl_success(&["--user", "is-active", "--quiet", TVCTLD_SYSTEMD_UNIT]).await?;
|
||||
let main_pid = read_systemd_main_pid().await?;
|
||||
Ok(SystemdServiceStatus {
|
||||
installed,
|
||||
active,
|
||||
main_pid,
|
||||
})
|
||||
}
|
||||
|
||||
async fn systemctl_success(args: &[&str]) -> Result<bool, CliError> {
|
||||
let output = run_systemctl_output(args).await?;
|
||||
Ok(output.status.success())
|
||||
}
|
||||
|
||||
async fn read_systemd_main_pid() -> Result<Option<u32>, CliError> {
|
||||
let output = run_systemctl_output(&[
|
||||
"--user",
|
||||
"show",
|
||||
TVCTLD_SYSTEMD_UNIT,
|
||||
"--property=MainPID",
|
||||
"--value",
|
||||
])
|
||||
.await?;
|
||||
if !output.status.success() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let pid = stdout.trim().parse::<u32>().ok().filter(|pid| *pid > 0);
|
||||
Ok(pid)
|
||||
}
|
||||
|
||||
fn service_owns_daemon(service: &SystemdServiceStatus, status: &DaemonStatus) -> bool {
|
||||
service.active && service.main_pid == Some(status.pid)
|
||||
}
|
||||
|
||||
fn daemon_mode_for_status(service: &SystemdServiceStatus, status: &DaemonStatus) -> DaemonMode {
|
||||
if service_owns_daemon(service, status) {
|
||||
DaemonMode::Systemd
|
||||
} else {
|
||||
DaemonMode::AdHoc
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_config() -> Result<TvctlConfig, CliError> {
|
||||
TvctlConfig::load().await.map_err(|error| {
|
||||
CliError::new(
|
||||
@@ -803,6 +992,24 @@ async fn maybe_reload_daemon_config(
|
||||
parse_response_data(response).map(Some)
|
||||
}
|
||||
|
||||
async fn save_config_with_reload(
|
||||
path: PathBuf,
|
||||
mutate: impl FnOnce(&mut TvctlConfig) -> Result<(), CliError>,
|
||||
) -> Result<Option<ConfigReloadResult>, CliError> {
|
||||
let daemon_socket = daemon_status_payload()
|
||||
.await
|
||||
.map(|status| PathBuf::from(status.socket));
|
||||
let mut config = load_config().await?;
|
||||
mutate(&mut config)?;
|
||||
config.save_to_path(&path).await.map_err(|error| {
|
||||
CliError::new(
|
||||
format!("Failed to save config: {error}"),
|
||||
"Check write permissions for ~/.config/tvctl/config.toml.",
|
||||
)
|
||||
})?;
|
||||
maybe_reload_daemon_config(daemon_socket).await
|
||||
}
|
||||
|
||||
async fn load_socket_path() -> Result<PathBuf, CliError> {
|
||||
let config = load_config().await?;
|
||||
let runtime_paths = RuntimePaths::detect();
|
||||
@@ -822,16 +1029,7 @@ async fn load_socket_path() -> Result<PathBuf, CliError> {
|
||||
}
|
||||
|
||||
async fn run_systemctl(args: &[&str]) -> Result<(), CliError> {
|
||||
let output = TokioCommand::new("systemctl")
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
CliError::new(
|
||||
format!("Failed to run systemctl: {error}"),
|
||||
"Make sure systemd user services are available in this session.",
|
||||
)
|
||||
})?;
|
||||
let output = run_systemctl_output(args).await?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
@@ -849,6 +1047,19 @@ async fn run_systemctl(args: &[&str]) -> Result<(), CliError> {
|
||||
))
|
||||
}
|
||||
|
||||
async fn run_systemctl_output(args: &[&str]) -> Result<std::process::Output, CliError> {
|
||||
TokioCommand::new("systemctl")
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
CliError::new(
|
||||
format!("Failed to run systemctl: {error}"),
|
||||
"Make sure systemd user services are available in this session.",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn send_request(
|
||||
socket_path: PathBuf,
|
||||
request: &DaemonRequest,
|
||||
@@ -948,6 +1159,11 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_action_response(cli: &Cli, response: DaemonResponse) -> Result<(), CliError> {
|
||||
let result: ActionResult = parse_response_data(response)?;
|
||||
render(cli, &result, || result.detail.clone())
|
||||
}
|
||||
|
||||
fn render_device_list(devices: &[Device]) -> String {
|
||||
if devices.is_empty() {
|
||||
return "No devices are registered yet.".to_string();
|
||||
@@ -1052,6 +1268,77 @@ fn render_dev_logs(result: &DevLogsResult) -> String {
|
||||
result.lines.join("\n")
|
||||
}
|
||||
|
||||
fn render_daemon_status(
|
||||
service: &SystemdServiceStatus,
|
||||
status: &DaemonStatus,
|
||||
mode: DaemonMode,
|
||||
) -> String {
|
||||
let http = if status.http_enabled {
|
||||
format!("{}:{}", status.http_host, status.http_port)
|
||||
} else {
|
||||
"disabled".to_string()
|
||||
};
|
||||
let default_device = status.default_device.as_deref().unwrap_or("none");
|
||||
let mode_label = match mode {
|
||||
DaemonMode::AdHoc => "ad hoc",
|
||||
DaemonMode::Systemd => "systemd user service",
|
||||
};
|
||||
let mut lines = vec![
|
||||
format!("tvctld is running ({mode_label})."),
|
||||
format!("PID: {}", status.pid),
|
||||
format!("Socket: {}", status.socket),
|
||||
format!("HTTP: {http}"),
|
||||
format!("Known Devices: {}", status.device_count),
|
||||
format!("Default Device: {default_device}"),
|
||||
];
|
||||
if service.installed {
|
||||
lines.push(format!(
|
||||
"Service Installed: yes ({})",
|
||||
if service.active { "active" } else { "inactive" }
|
||||
));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn render_service_install(result: &ServiceInstallResult) -> String {
|
||||
if result.already_installed {
|
||||
let running = if result.running { "yes" } else { "no" };
|
||||
return [
|
||||
"tvctld user service is already installed.".to_string(),
|
||||
format!("Unit File: {}", result.unit_file),
|
||||
"Enabled: yes".to_string(),
|
||||
format!("Running: {running}"),
|
||||
]
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
[
|
||||
"Installed and started tvctld user service.".to_string(),
|
||||
format!("Unit File: {}", result.unit_file),
|
||||
"Enabled: yes".to_string(),
|
||||
"Running: yes".to_string(),
|
||||
"Use `tvctl daemon start|stop|restart` to manage the service.".to_string(),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn render_service_uninstall(result: &ServiceUninstallResult) -> String {
|
||||
let mut lines = Vec::new();
|
||||
if result.removed_unit {
|
||||
lines.push("Removed tvctld user service.".to_string());
|
||||
lines.push(format!("Unit File: {}", result.unit_file));
|
||||
} else {
|
||||
lines.push("tvctld user service was not installed.".to_string());
|
||||
}
|
||||
if result.stopped_service {
|
||||
lines.push("Stopped the running tvctld user service first.".to_string());
|
||||
}
|
||||
if result.stopped_ad_hoc {
|
||||
lines.push("Stopped a running ad hoc tvctld daemon.".to_string());
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn redact_config_value(key: &str, value: String) -> String {
|
||||
if is_secret_config_key(key) && !value.is_empty() {
|
||||
return "<redacted>".to_string();
|
||||
|
||||
Reference in New Issue
Block a user