feat: add milestone 6 CLI polish

Add shell completion generation, improve first-run device messaging,
and include HTTP endpoint details in daemon startup output.
This commit is contained in:
44r0n7
2026-04-15 15:56:21 -04:00
parent b8a0a0ff16
commit 26cc0c973a
10 changed files with 164 additions and 111 deletions
Generated
+10
View File
@@ -223,6 +223,15 @@ dependencies = [
"strsim", "strsim",
] ]
[[package]]
name = "clap_complete"
version = "4.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff7a1dccbdd8b078c2bdebff47e404615151534d5043da397ec50286816f9cb"
dependencies = [
"clap",
]
[[package]] [[package]]
name = "clap_derive" name = "clap_derive"
version = "4.6.0" version = "4.6.0"
@@ -1686,6 +1695,7 @@ dependencies = [
"axum", "axum",
"chrono", "chrono",
"clap", "clap",
"clap_complete",
"libc", "libc",
"md5", "md5",
"reqwest", "reqwest",
+1
View File
@@ -8,6 +8,7 @@ anyhow = "1.0"
axum = { version = "0.8", features = ["multipart"] } axum = { version = "0.8", features = ["multipart"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
clap_complete = "4.5"
libc = "0.2" libc = "0.2"
md5 = "0.7" md5 = "0.7"
reqwest = { version = "0.12", default-features = false, features = ["charset", "http2", "json", "multipart", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["charset", "http2", "json", "multipart", "rustls-tls"] }
+13
View File
@@ -229,6 +229,16 @@ tvctl config reset Reset to defaults
tvctl config reload Hot-reload config into running daemon tvctl config reload Hot-reload config into running daemon
``` ```
### completion
Generate shell completions to stdout.
```bash
tvctl completion bash > ~/.local/share/bash-completion/completions/tvctl
tvctl completion zsh > ~/.zfunc/_tvctl
tvctl completion fish > ~/.config/fish/completions/tvctl.fish
```
--- ---
## HTTP API Reference ## HTTP API Reference
@@ -462,6 +472,9 @@ tvctl state
# Scripting example # Scripting example
tvctl state --json | jq '.data.active_app.name' tvctl state --json | jq '.data.active_app.name'
# Optional shell completions
tvctl completion zsh > ~/.zfunc/_tvctl
``` ```
--- ---
+12 -10
View File
@@ -1,20 +1,20 @@
# ROADMAP.md # ROADMAP.md
# tvctl — Feature Roadmap and Milestone Tracker # tvctl — Feature Roadmap and Milestone Tracker
# Agents: update this file as work is completed. See AGENT.md for instructions. # Agents: update this file as work is completed. See AGENT.md for instructions.
# Last updated: 2026-04-14 # Last updated: 2026-04-15
--- ---
## Current Focus ## Current Focus
**Milestone 5HTTP API** **Milestone 6Polish and Release Prep**
HTTP route parity with the daemon is now in progress. Finish automated API validation and close any remaining transport gaps. Close the low-risk polish items that make the CLI easier to install, discover, and use day to day.
--- ---
## In Progress ## In Progress
- Milestone 5 is in progress; the `/v1` Axum server and core route surface are implemented, but automated HTTP validation is still missing - Milestone 6 is in progress; shell completions and first-run CLI polish are landing first while heavier release work stays pending
--- ---
@@ -116,14 +116,14 @@ _Goal: Full /v1/ API running on 127.0.0.1:7272._
## Milestone 6 — Polish and Release Prep ## Milestone 6 — Polish and Release Prep
_Goal: Ready for real use._ _Goal: Ready for real use._
- [ ] Shell completions (bash, zsh, fish) via clap - [x] 2026-04-15 — Shell completions (bash, zsh, fish) via clap
- [ ] `tvctl daemon install` generates correct systemd unit file - [ ] `tvctl daemon install` generates correct systemd unit file
- [ ] First-run experience: helpful output when no devices discovered yet - [x] 2026-04-15 — First-run experience: helpful output when no devices discovered yet
- [ ] Daemon startup message with socket path and HTTP port - [x] 2026-04-15 — Daemon startup message with socket path and HTTP port
- [ ] Log output via `tracing` (respects `log_level` config) - [ ] Log output via `tracing` (respects `log_level` config)
- [ ] README accuracy pass (verify all examples work) - [x] 2026-04-15 — README accuracy pass (verify all examples work)
- [ ] `cargo clippy` clean - [x] 2026-04-15 — `cargo clippy` clean
- [ ] `cargo test` passing - [x] 2026-04-15 — `cargo test` passing
- [ ] Cross-compile test (x86_64 + aarch64) - [ ] Cross-compile test (x86_64 + aarch64)
- [ ] GitHub Actions CI (build + clippy + test) - [ ] GitHub Actions CI (build + clippy + test)
- [ ] First binary release - [ ] First binary release
@@ -164,6 +164,8 @@ out of scope until Milestone 6 is complete and stable.
- [x] 2026-04-14 — Add daemon-backed app control, remote input, state queries, and dev workflows with live Roku CLI validation - [x] 2026-04-14 — Add daemon-backed app control, remote input, state queries, and dev workflows with live Roku CLI validation
- [x] 2026-04-14 — Add config list/get/set/reset/reload plus systemd user-service install/uninstall commands - [x] 2026-04-14 — Add config list/get/set/reset/reload plus systemd user-service install/uninstall commands
- [x] 2026-04-14 — Finish Milestone 4 with help, JSON-mode, config socket-reload, and secret-redaction polish - [x] 2026-04-14 — Finish Milestone 4 with help, JSON-mode, config socket-reload, and secret-redaction polish
- [x] 2026-04-15 — Add HTTP API route parity and integration coverage against an isolated running daemon
- [x] 2026-04-15 — Add shell completions and first-run CLI polish for Milestone 6
--- ---
+44 -49
View File
@@ -18,7 +18,8 @@ use tokio::{
use crate::{ use crate::{
adapters::{Device, TvKey, parse_normalized_tv_key}, adapters::{Device, TvKey, parse_normalized_tv_key},
daemon::{ daemon::{
SharedDaemon, config::TvctlConfig, SharedDaemon,
config::TvctlConfig,
ipc::{ ipc::{
AppListResult, ConfigReloadResult, DaemonRequest, DaemonResponse, DaemonStatus, AppListResult, ConfigReloadResult, DaemonRequest, DaemonResponse, DaemonStatus,
DevLogsResult, DiscoveryResult, StateResult, DevLogsResult, DiscoveryResult, StateResult,
@@ -115,13 +116,7 @@ async fn delete_device(Path(id): Path<String>, State(daemon): State<SharedDaemon
} }
async fn get_state(Path(id): Path<String>, State(daemon): State<SharedDaemon>) -> Response { async fn get_state(Path(id): Path<String>, State(daemon): State<SharedDaemon>) -> Response {
execute_json::<StateResult>( execute_json::<StateResult>(daemon, DaemonRequest::GetState { device: Some(id) }).await
daemon,
DaemonRequest::GetState {
device: Some(id),
},
)
.await
} }
async fn list_apps(Path(id): Path<String>, State(daemon): State<SharedDaemon>) -> Response { async fn list_apps(Path(id): Path<String>, State(daemon): State<SharedDaemon>) -> Response {
@@ -151,13 +146,7 @@ async fn launch_app(
} }
async fn stop_app(Path(id): Path<String>, State(daemon): State<SharedDaemon>) -> Response { async fn stop_app(Path(id): Path<String>, State(daemon): State<SharedDaemon>) -> Response {
execute_json_value( execute_json_value(daemon, DaemonRequest::StopApp { device: Some(id) }).await
daemon,
DaemonRequest::StopApp {
device: Some(id),
},
)
.await
} }
async fn refresh_apps( async fn refresh_apps(
@@ -182,7 +171,7 @@ async fn send_key(
) -> Response { ) -> Response {
let key = match parse_key(&body.key) { let key = match parse_key(&body.key) {
Ok(key) => key, Ok(key) => key,
Err(response) => return response, Err(response) => return *response,
}; };
execute_json_value( execute_json_value(
daemon, daemon,
@@ -203,7 +192,7 @@ async fn send_sequence(
for key in body.keys { for key in body.keys {
match parse_key(&key) { match parse_key(&key) {
Ok(key) => parsed.push(key), Ok(key) => parsed.push(key),
Err(response) => return response, Err(response) => return *response,
} }
} }
execute_json_value( execute_json_value(
@@ -277,23 +266,11 @@ async fn dev_install(
} }
async fn dev_reload(Path(id): Path<String>, State(daemon): State<SharedDaemon>) -> Response { async fn dev_reload(Path(id): Path<String>, State(daemon): State<SharedDaemon>) -> Response {
execute_json_value( execute_json_value(daemon, DaemonRequest::DevReload { device: Some(id) }).await
daemon,
DaemonRequest::DevReload {
device: Some(id),
},
)
.await
} }
async fn dev_logs(Path(id): Path<String>, State(daemon): State<SharedDaemon>) -> Response { async fn dev_logs(Path(id): Path<String>, State(daemon): State<SharedDaemon>) -> Response {
execute_json::<DevLogsResult>( execute_json::<DevLogsResult>(daemon, DaemonRequest::DevLogs { device: Some(id) }).await
daemon,
DaemonRequest::DevLogs {
device: Some(id),
},
)
.await
} }
async fn daemon_status(State(daemon): State<SharedDaemon>) -> Response { async fn daemon_status(State(daemon): State<SharedDaemon>) -> Response {
@@ -342,7 +319,9 @@ async fn patch_config(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
"invalid_config_value", "invalid_config_value",
format!("Config value for '{key}' must be a string, number, boolean, or null."), format!("Config value for '{key}' must be a string, number, boolean, or null."),
Some("Use flat key/value JSON such as {\"daemon.http_port\": 7272}.".to_string()), Some(
"Use flat key/value JSON such as {\"daemon.http_port\": 7272}.".to_string(),
),
); );
} }
}; };
@@ -365,8 +344,7 @@ async fn patch_config(
); );
} }
let response = execute_json_value(daemon, DaemonRequest::ReloadConfig).await; execute_json_value(daemon, DaemonRequest::ReloadConfig).await
response
} }
async fn reload_config(State(daemon): State<SharedDaemon>) -> Response { async fn reload_config(State(daemon): State<SharedDaemon>) -> Response {
@@ -395,7 +373,12 @@ where
T: Serialize + DeserializeOwned, T: Serialize + DeserializeOwned,
{ {
if let Some(error) = response.error { if let Some(error) = response.error {
return api_error(status_for_error(&error.code), error.code, error.message, error.hint); return api_error(
status_for_error(&error.code),
error.code,
error.message,
error.hint,
);
} }
let data = response.data.unwrap_or(Value::Null); let data = response.data.unwrap_or(Value::Null);
@@ -437,14 +420,17 @@ fn api_error(
.into_response() .into_response()
} }
fn parse_key(input: &str) -> Result<TvKey, Response> { fn parse_key(input: &str) -> Result<TvKey, Box<Response>> {
parse_normalized_tv_key(input).map_err(|_| { parse_normalized_tv_key(input).map_err(|_| {
api_error( Box::new(api_error(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
"invalid_key", "invalid_key",
format!("Unknown key '{input}'."), format!("Unknown key '{input}'."),
Some("Use a normalized key like `home`, `down`, `volume-up`, or `literal:text`.".to_string()), Some(
) "Use a normalized key like `home`, `down`, `volume-up`, or `literal:text`."
.to_string(),
),
))
}) })
} }
@@ -486,8 +472,14 @@ async fn send_daemon_request(
api_error( api_error(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
"daemon_socket_unreachable", "daemon_socket_unreachable",
format!("Unable to reach tvctld at {}: {error}", socket_path.display()), format!(
Some("Check whether the daemon socket is writable and the daemon is running.".to_string()), "Unable to reach tvctld at {}: {error}",
socket_path.display()
),
Some(
"Check whether the daemon socket is writable and the daemon is running."
.to_string(),
),
) )
})?; })?;
@@ -517,14 +509,17 @@ async fn send_daemon_request(
})?; })?;
let mut response_bytes = Vec::new(); let mut response_bytes = Vec::new();
stream.read_to_end(&mut response_bytes).await.map_err(|error| { stream
api_error( .read_to_end(&mut response_bytes)
StatusCode::INTERNAL_SERVER_ERROR, .await
"daemon_read_failed", .map_err(|error| {
format!("Failed to read the daemon response: {error}"), api_error(
Some("Retry the request after restarting the daemon.".to_string()), StatusCode::INTERNAL_SERVER_ERROR,
) "daemon_read_failed",
})?; format!("Failed to read the daemon response: {error}"),
Some("Retry the request after restarting the daemon.".to_string()),
)
})?;
serde_json::from_slice::<DaemonResponse>(&response_bytes).map_err(|error| { serde_json::from_slice::<DaemonResponse>(&response_bytes).map_err(|error| {
api_error( api_error(
+68 -15
View File
@@ -1,6 +1,7 @@
use std::{collections::BTreeMap, net::IpAddr, path::PathBuf, process::Stdio, time::Duration}; use std::{collections::BTreeMap, net::IpAddr, path::PathBuf, process::Stdio, time::Duration};
use clap::{Args, CommandFactory, Parser, Subcommand}; use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
use clap_complete::{Generator, Shell, generate};
use serde::Serialize; use serde::Serialize;
use thiserror::Error; use thiserror::Error;
use tokio::{ use tokio::{
@@ -97,11 +98,23 @@ pub enum Command {
#[command(subcommand)] #[command(subcommand)]
command: ConfigCommand, command: ConfigCommand,
}, },
/// Generate shell completion scripts.
Completion {
#[arg(value_enum)]
shell: CompletionShell,
},
/// Internal daemon entry point used by `tvctl daemon start`. /// Internal daemon entry point used by `tvctl daemon start`.
#[command(hide = true, name = "__daemon_serve")] #[command(hide = true, name = "__daemon_serve")]
InternalDaemonServe, InternalDaemonServe,
} }
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum CompletionShell {
Bash,
Zsh,
Fish,
}
/// Manage the tvctld lifecycle. /// Manage the tvctld lifecycle.
#[derive(Debug, Clone, Subcommand)] #[derive(Debug, Clone, Subcommand)]
pub enum DaemonCommand { pub enum DaemonCommand {
@@ -320,9 +333,29 @@ pub async fn run() -> Result<(), CliError> {
Command::State => handle_state_command(&cli).await, Command::State => handle_state_command(&cli).await,
Command::Dev { command } => handle_dev_command(&cli, command).await, Command::Dev { command } => handle_dev_command(&cli, command).await,
Command::Config { command } => handle_config_command(&cli, command).await, Command::Config { command } => handle_config_command(&cli, command).await,
Command::Completion { shell } => handle_completion_command(shell),
} }
} }
fn handle_completion_command(shell: CompletionShell) -> Result<(), CliError> {
let mut command = Cli::command();
match shell {
CompletionShell::Bash => print_completions(Shell::Bash, &mut command),
CompletionShell::Zsh => print_completions(Shell::Zsh, &mut command),
CompletionShell::Fish => print_completions(Shell::Fish, &mut command),
}
Ok(())
}
fn print_completions<G: Generator>(generator: G, command: &mut clap::Command) {
generate(
generator,
command,
command.get_name().to_string(),
&mut std::io::stdout(),
);
}
async fn handle_daemon_command(cli: &Cli, command: DaemonCommand) -> Result<(), CliError> { async fn handle_daemon_command(cli: &Cli, command: DaemonCommand) -> Result<(), CliError> {
match command { match command {
DaemonCommand::Start => daemon_start(cli).await, DaemonCommand::Start => daemon_start(cli).await,
@@ -626,10 +659,7 @@ async fn daemon_start(cli: &Cli) -> Result<(), CliError> {
if let Some(status) = daemon_status_payload().await { if let Some(status) = daemon_status_payload().await {
if service_owns_daemon(&service, &status) { if service_owns_daemon(&service, &status) {
return render(cli, &status, || { return render(cli, &status, || {
format!( render_daemon_started("tvctld user service is already running.", &status)
"tvctld user service is already running on {}",
status.socket
)
}); });
} }
stop_ad_hoc_daemon(&status).await?; stop_ad_hoc_daemon(&status).await?;
@@ -638,13 +668,13 @@ async fn daemon_start(cli: &Cli) -> Result<(), CliError> {
run_systemctl(&["--user", "start", TVCTLD_SYSTEMD_UNIT]).await?; run_systemctl(&["--user", "start", TVCTLD_SYSTEMD_UNIT]).await?;
let status = wait_for_daemon_ready().await?; let status = wait_for_daemon_ready().await?;
return render(cli, &status, || { return render(cli, &status, || {
format!("Started tvctld user service on {}", status.socket) render_daemon_started("Started tvctld user service.", &status)
}); });
} }
if let Some(status) = daemon_status_payload().await { if let Some(status) = daemon_status_payload().await {
return render(cli, &status, || { return render(cli, &status, || {
format!("tvctld is already running on {}", status.socket) render_daemon_started("tvctld is already running.", &status)
}); });
} }
@@ -670,7 +700,7 @@ async fn daemon_start(cli: &Cli) -> Result<(), CliError> {
let status = wait_for_daemon_ready().await?; let status = wait_for_daemon_ready().await?;
render(cli, &status, || { render(cli, &status, || {
format!("tvctld started on {}", status.socket) render_daemon_started("tvctld started.", &status)
}) })
} }
@@ -725,15 +755,15 @@ async fn daemon_stop(cli: &Cli) -> Result<(), CliError> {
async fn daemon_restart(cli: &Cli) -> Result<(), CliError> { async fn daemon_restart(cli: &Cli) -> Result<(), CliError> {
let service = systemd_service_status().await?; let service = systemd_service_status().await?;
if service.installed { if service.installed {
if let Some(status) = daemon_status_payload().await { if let Some(status) = daemon_status_payload().await
if !service_owns_daemon(&service, &status) { && !service_owns_daemon(&service, &status)
stop_ad_hoc_daemon(&status).await?; {
} stop_ad_hoc_daemon(&status).await?;
} }
run_systemctl(&["--user", "restart", TVCTLD_SYSTEMD_UNIT]).await?; run_systemctl(&["--user", "restart", TVCTLD_SYSTEMD_UNIT]).await?;
let status = wait_for_daemon_ready().await?; let status = wait_for_daemon_ready().await?;
return render(cli, &status, || { return render(cli, &status, || {
format!("Restarted tvctld user service on {}", status.socket) render_daemon_started("Restarted tvctld user service.", &status)
}); });
} }
@@ -1166,7 +1196,11 @@ fn render_action_response(cli: &Cli, response: DaemonResponse) -> Result<(), Cli
fn render_device_list(devices: &[Device]) -> String { fn render_device_list(devices: &[Device]) -> String {
if devices.is_empty() { if devices.is_empty() {
return "No devices are registered yet.".to_string(); return [
"No devices are registered yet.".to_string(),
"Run `tvctl device discover` to scan the network or `tvctl device add --platform roku --address <ip>` to add one manually.".to_string(),
]
.join("\n");
} }
devices devices
@@ -1187,7 +1221,11 @@ fn render_device_list(devices: &[Device]) -> String {
fn render_discovery_result(devices: &[Device]) -> String { fn render_discovery_result(devices: &[Device]) -> String {
if devices.is_empty() { if devices.is_empty() {
return "Discovery completed, but no devices were found.".to_string(); return [
"Discovery completed, but no devices were found.".to_string(),
"Make sure the TV is powered on and reachable, then retry or add it manually with `tvctl device add --platform roku --address <ip>`.".to_string(),
]
.join("\n");
} }
format!( format!(
"Discovered {} device(s).\n{}", "Discovered {} device(s).\n{}",
@@ -1300,6 +1338,21 @@ fn render_daemon_status(
lines.join("\n") lines.join("\n")
} }
fn render_daemon_started(prefix: &str, status: &DaemonStatus) -> String {
let http = if status.http_enabled {
format!("http://{}:{}/v1", status.http_host, status.http_port)
} else {
"disabled".to_string()
};
[
prefix.to_string(),
format!("Socket: {}", status.socket),
format!("HTTP: {http}"),
]
.join("\n")
}
fn render_service_install(result: &ServiceInstallResult) -> String { fn render_service_install(result: &ServiceInstallResult) -> String {
if result.already_installed { if result.already_installed {
let running = if result.running { "yes" } else { "no" }; let running = if result.running { "yes" } else { "no" };
+1 -13
View File
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
use tokio::fs; use tokio::fs;
/// The complete daemon configuration loaded from TOML. /// The complete daemon configuration loaded from TOML.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)] #[serde(default)]
pub struct TvctlConfig { pub struct TvctlConfig {
/// Runtime daemon settings. /// Runtime daemon settings.
@@ -24,18 +24,6 @@ pub struct TvctlConfig {
pub dev: DevConfig, pub dev: DevConfig,
} }
impl Default for TvctlConfig {
fn default() -> Self {
Self {
daemon: DaemonConfig::default(),
discovery: DiscoveryConfig::default(),
devices: DeviceConfig::default(),
remote: RemoteConfig::default(),
dev: DevConfig::default(),
}
}
}
impl TvctlConfig { impl TvctlConfig {
/// Load configuration from the default XDG path or return defaults when absent. /// Load configuration from the default XDG path or return defaults when absent.
pub async fn load() -> anyhow::Result<Self> { pub async fn load() -> anyhow::Result<Self> {
+12 -12
View File
@@ -183,7 +183,9 @@ async fn set_socket_permissions(path: &Path) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
async fn start_http_server_if_enabled(daemon: SharedDaemon) -> anyhow::Result<Option<JoinHandle<()>>> { async fn start_http_server_if_enabled(
daemon: SharedDaemon,
) -> anyhow::Result<Option<JoinHandle<()>>> {
let (enabled, host, port) = { let (enabled, host, port) = {
let guard = daemon.lock().await; let guard = daemon.lock().await;
( (
@@ -469,17 +471,15 @@ pub(crate) async fn execute_request(
Ok(device) => device, Ok(device) => device,
Err(response) => return (response, false), Err(response) => return (response, false),
}; };
if clear { if clear && let Err(error) = guard.app_cache.clear_platform(&device.platform).await {
if let Err(error) = guard.app_cache.clear_platform(&device.platform).await { return (
return ( DaemonResponse::error(
DaemonResponse::error( "app_cache_clear_failed",
"app_cache_clear_failed", format!("Failed to clear the cached app list: {error}"),
format!("Failed to clear the cached app list: {error}"), Some("Check permissions for ~/.local/share/tvctl/cache.".to_string()),
Some("Check permissions for ~/.local/share/tvctl/cache.".to_string()), ),
), false,
false, );
);
}
} }
match guard.adapters.list_apps(&device).await { match guard.adapters.list_apps(&device).await {
Ok(apps) => match guard Ok(apps) => match guard
+1 -9
View File
@@ -179,19 +179,11 @@ impl Default for DeviceRegistry {
} }
/// A registry of platform adapters available to the daemon. /// A registry of platform adapters available to the daemon.
#[derive(Debug, Clone)] #[derive(Debug, Clone, Default)]
pub struct AdapterRegistry { pub struct AdapterRegistry {
roku: RokuAdapter, roku: RokuAdapter,
} }
impl Default for AdapterRegistry {
fn default() -> Self {
Self {
roku: RokuAdapter::new(),
}
}
}
impl AdapterRegistry { impl AdapterRegistry {
/// Build the adapter registry from the loaded daemon config. /// Build the adapter registry from the loaded daemon config.
pub fn from_config(config: &TvctlConfig) -> Self { pub fn from_config(config: &TvctlConfig) -> Self {
+2 -3
View File
@@ -110,10 +110,9 @@ impl TestDaemon {
.get(format!("{}/daemon/status", self.base_url)) .get(format!("{}/daemon/status", self.base_url))
.send() .send()
.await .await
&& response.status().is_success()
{ {
if response.status().is_success() { return;
return;
}
} }
sleep(Duration::from_millis(100)).await; sleep(Duration::from_millis(100)).await;
} }