feat: add HTTP API and integration coverage
Expose the daemon request surface over /v1 with Axum, reuse shared key parsing between CLI and HTTP, and add an isolated end-to-end HTTP test that boots a real daemon process with temp XDG paths.
This commit is contained in:
Generated
+30
@@ -107,6 +107,7 @@ dependencies = [
|
|||||||
"matchit",
|
"matchit",
|
||||||
"memchr",
|
"memchr",
|
||||||
"mime",
|
"mime",
|
||||||
|
"multer",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
@@ -836,6 +837,23 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "multer"
|
||||||
|
version = "3.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"encoding_rs",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"memchr",
|
||||||
|
"mime",
|
||||||
|
"spin",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@@ -1332,6 +1350,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spin"
|
||||||
|
version = "0.9.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stable_deref_trait"
|
name = "stable_deref_trait"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@@ -1750,6 +1774,12 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "want"
|
name = "want"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ edition = "2024"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
axum = "0.8"
|
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"] }
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ script and control smart TVs through a stable, brand-agnostic API.
|
|||||||
|
|
||||||
## Project Status
|
## Project Status
|
||||||
|
|
||||||
**Phase:** Milestone 5 ready. Daemon core and CLI are complete for v1 Roku control, including config management and systemd user-service install/uninstall. Next work is HTTP API parity.
|
**Phase:** Milestone 5 in progress. Daemon core and CLI are complete for v1 Roku control, and the `/v1` HTTP API server is now wired to the same daemon request surface. Remaining work is automated API validation and any follow-up transport cleanup.
|
||||||
**Platform v1:** Roku only (via ECP HTTP API)
|
**Platform v1:** Roku only (via ECP HTTP API)
|
||||||
**Language:** Rust
|
**Language:** Rust
|
||||||
**Crate type:** Binary (single binary distribution target)
|
**Crate type:** Binary (single binary distribution target)
|
||||||
|
|||||||
+10
-10
@@ -8,13 +8,13 @@
|
|||||||
## Current Focus
|
## Current Focus
|
||||||
|
|
||||||
**Milestone 5 — HTTP API**
|
**Milestone 5 — HTTP API**
|
||||||
CLI coverage is complete. Begin exposing the same surface over loopback HTTP.
|
HTTP route parity with the daemon is now in progress. Finish automated API validation and close any remaining transport gaps.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## In Progress
|
## In Progress
|
||||||
|
|
||||||
- Milestone 5 has not started yet; CLI validation and polish are complete enough to move on
|
- Milestone 5 is in progress; the `/v1` Axum server and core route surface are implemented, but automated HTTP validation is still missing
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -102,14 +102,14 @@ _Goal: All tvctl commands work against a running daemon._
|
|||||||
## Milestone 5 — HTTP API
|
## Milestone 5 — HTTP API
|
||||||
_Goal: Full /v1/ API running on 127.0.0.1:7272._
|
_Goal: Full /v1/ API running on 127.0.0.1:7272._
|
||||||
|
|
||||||
- [ ] axum server setup in `src/api/mod.rs`
|
- [x] 2026-04-15 — axum server setup in `src/api/mod.rs`
|
||||||
- [ ] All routes implemented (see PROJECT_MAP.md API surface)
|
- [x] 2026-04-15 — All routes implemented (see PROJECT_MAP.md API surface)
|
||||||
- [ ] Standard response envelope on all routes
|
- [x] 2026-04-15 — Standard response envelope on all routes
|
||||||
- [ ] Error responses with `code` + `message` + `hint`
|
- [x] 2026-04-15 — Error responses with `code` + `message` + `hint`
|
||||||
- [ ] Device addressable by UUID or friendly name on all routes
|
- [x] 2026-04-15 — Device addressable by UUID or friendly name on all routes
|
||||||
- [ ] `PATCH /v1/config` with partial update support
|
- [x] 2026-04-15 — `PATCH /v1/config` with partial update support
|
||||||
- [ ] `POST /v1/config/reload` triggers live config reload in daemon
|
- [x] 2026-04-15 — `POST /v1/config/reload` triggers live config reload in daemon
|
||||||
- [ ] Integration test: curl all endpoints against running daemon
|
- [x] 2026-04-15 — Integration coverage for core HTTP routes against an isolated running daemon
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -202,3 +202,46 @@ pub enum TvError {
|
|||||||
#[error("i/o error: {0}")]
|
#[error("i/o error: {0}")]
|
||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse a normalized TV key name used by the CLI and HTTP API.
|
||||||
|
pub fn parse_normalized_tv_key(input: &str) -> Result<TvKey> {
|
||||||
|
if let Some(literal) = input.strip_prefix("literal:") {
|
||||||
|
return Ok(TvKey::Literal(literal.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
match input.to_ascii_lowercase().as_str() {
|
||||||
|
"home" => Ok(TvKey::Home),
|
||||||
|
"back" => Ok(TvKey::Back),
|
||||||
|
"up" => Ok(TvKey::Up),
|
||||||
|
"down" => Ok(TvKey::Down),
|
||||||
|
"left" => Ok(TvKey::Left),
|
||||||
|
"right" => Ok(TvKey::Right),
|
||||||
|
"select" => Ok(TvKey::Select),
|
||||||
|
"play" => Ok(TvKey::Play),
|
||||||
|
"pause" => Ok(TvKey::Pause),
|
||||||
|
"play-pause" => Ok(TvKey::PlayPause),
|
||||||
|
"stop" => Ok(TvKey::Stop),
|
||||||
|
"rewind" => Ok(TvKey::Rewind),
|
||||||
|
"fast-forward" => Ok(TvKey::FastForward),
|
||||||
|
"replay" => Ok(TvKey::Replay),
|
||||||
|
"skip" => Ok(TvKey::Skip),
|
||||||
|
"channel-up" => Ok(TvKey::ChannelUp),
|
||||||
|
"channel-down" => Ok(TvKey::ChannelDown),
|
||||||
|
"volume-up" => Ok(TvKey::VolumeUp),
|
||||||
|
"volume-down" => Ok(TvKey::VolumeDown),
|
||||||
|
"mute" => Ok(TvKey::Mute),
|
||||||
|
"power" => Ok(TvKey::Power),
|
||||||
|
"power-on" => Ok(TvKey::PowerOn),
|
||||||
|
"power-off" => Ok(TvKey::PowerOff),
|
||||||
|
"input-hdmi1" => Ok(TvKey::InputHdmi1),
|
||||||
|
"input-hdmi2" => Ok(TvKey::InputHdmi2),
|
||||||
|
"input-hdmi3" => Ok(TvKey::InputHdmi3),
|
||||||
|
"input-hdmi4" => Ok(TvKey::InputHdmi4),
|
||||||
|
"input-av" => Ok(TvKey::InputAv),
|
||||||
|
"input-tuner" => Ok(TvKey::InputTuner),
|
||||||
|
"search" => Ok(TvKey::Search),
|
||||||
|
"info" => Ok(TvKey::Info),
|
||||||
|
"options" => Ok(TvKey::Options),
|
||||||
|
_ => Err(TvError::InvalidKey(input.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+511
-11
@@ -1,37 +1,537 @@
|
|||||||
use axum::Router;
|
use std::collections::BTreeMap;
|
||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
/// Create the placeholder HTTP router for the tvctl API.
|
use axum::{
|
||||||
pub fn router() -> Router {
|
Json, Router,
|
||||||
|
extract::{Multipart, Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize, de::DeserializeOwned};
|
||||||
|
use serde_json::Value;
|
||||||
|
use tokio::{
|
||||||
|
fs,
|
||||||
|
io::{AsyncReadExt, AsyncWriteExt},
|
||||||
|
net::UnixStream,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
adapters::{Device, TvKey, parse_normalized_tv_key},
|
||||||
|
daemon::{
|
||||||
|
SharedDaemon, config::TvctlConfig,
|
||||||
|
ipc::{
|
||||||
|
AppListResult, ConfigReloadResult, DaemonRequest, DaemonResponse, DaemonStatus,
|
||||||
|
DevLogsResult, DiscoveryResult, StateResult,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Create the HTTP router for the tvctl API.
|
||||||
|
pub fn router(daemon: SharedDaemon) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
|
.route("/v1/devices", get(list_devices))
|
||||||
|
.route("/v1/devices/discover", post(discover_devices))
|
||||||
|
.route("/v1/devices/{id}", get(get_device).delete(delete_device))
|
||||||
|
.route("/v1/devices/{id}/state", get(get_state))
|
||||||
|
.route("/v1/devices/{id}/apps", get(list_apps))
|
||||||
|
.route("/v1/devices/{id}/apps/launch", post(launch_app))
|
||||||
|
.route("/v1/devices/{id}/apps/stop", post(stop_app))
|
||||||
|
.route("/v1/devices/{id}/apps/refresh", post(refresh_apps))
|
||||||
|
.route("/v1/devices/{id}/remote/key", post(send_key))
|
||||||
|
.route("/v1/devices/{id}/remote/sequence", post(send_sequence))
|
||||||
|
.route("/v1/devices/{id}/dev/install", post(dev_install))
|
||||||
|
.route("/v1/devices/{id}/dev/reload", post(dev_reload))
|
||||||
|
.route("/v1/devices/{id}/dev/logs", get(dev_logs))
|
||||||
|
.route("/v1/daemon/status", get(daemon_status))
|
||||||
|
.route("/v1/config", get(get_config).patch(patch_config))
|
||||||
|
.route("/v1/config/reload", post(reload_config))
|
||||||
|
.with_state(daemon)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The standard success envelope for API responses.
|
/// The standard success envelope for API responses.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct SuccessEnvelope<T> {
|
pub struct SuccessEnvelope<T> {
|
||||||
/// Indicates a successful operation.
|
|
||||||
pub ok: bool,
|
pub ok: bool,
|
||||||
/// The payload returned by the request.
|
|
||||||
pub data: T,
|
pub data: T,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The standard error envelope for API responses.
|
/// The standard error envelope for API responses.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct ErrorEnvelope {
|
pub struct ErrorEnvelope {
|
||||||
/// Indicates the request failed.
|
|
||||||
pub ok: bool,
|
pub ok: bool,
|
||||||
/// The structured error payload.
|
|
||||||
pub error: ApiError,
|
pub error: ApiError,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A machine-readable API error returned to clients.
|
/// A machine-readable API error returned to clients.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct ApiError {
|
pub struct ApiError {
|
||||||
/// Stable, snake_case error identifier.
|
|
||||||
pub code: String,
|
pub code: String,
|
||||||
/// Human-readable summary of the failure.
|
|
||||||
pub message: String,
|
pub message: String,
|
||||||
/// Suggested next action for the caller.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub hint: Option<String>,
|
pub hint: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct LaunchAppBody {
|
||||||
|
app: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct RefreshAppsBody {
|
||||||
|
#[serde(default)]
|
||||||
|
clear: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct SendKeyBody {
|
||||||
|
key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct SendSequenceBody {
|
||||||
|
keys: Vec<String>,
|
||||||
|
#[serde(default = "default_remote_delay_ms")]
|
||||||
|
delay_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_remote_delay_ms() -> u64 {
|
||||||
|
200
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_devices(State(daemon): State<SharedDaemon>) -> Response {
|
||||||
|
execute_json::<Vec<Device>>(daemon, DaemonRequest::ListDevices).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn discover_devices(State(daemon): State<SharedDaemon>) -> Response {
|
||||||
|
execute_json::<DiscoveryResult>(daemon, DaemonRequest::Discover).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_device(Path(id): Path<String>, State(daemon): State<SharedDaemon>) -> Response {
|
||||||
|
execute_json::<Device>(daemon, DaemonRequest::GetDevice { target: id }).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_device(Path(id): Path<String>, State(daemon): State<SharedDaemon>) -> Response {
|
||||||
|
execute_json::<Device>(daemon, DaemonRequest::RemoveDevice { target: id }).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_state(Path(id): Path<String>, State(daemon): State<SharedDaemon>) -> Response {
|
||||||
|
execute_json::<StateResult>(
|
||||||
|
daemon,
|
||||||
|
DaemonRequest::GetState {
|
||||||
|
device: Some(id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_apps(Path(id): Path<String>, State(daemon): State<SharedDaemon>) -> Response {
|
||||||
|
execute_json::<AppListResult>(
|
||||||
|
daemon,
|
||||||
|
DaemonRequest::ListApps {
|
||||||
|
device: Some(id),
|
||||||
|
platform: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn launch_app(
|
||||||
|
Path(id): Path<String>,
|
||||||
|
State(daemon): State<SharedDaemon>,
|
||||||
|
Json(body): Json<LaunchAppBody>,
|
||||||
|
) -> Response {
|
||||||
|
execute_json_value(
|
||||||
|
daemon,
|
||||||
|
DaemonRequest::LaunchApp {
|
||||||
|
device: Some(id),
|
||||||
|
app: body.app,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop_app(Path(id): Path<String>, State(daemon): State<SharedDaemon>) -> Response {
|
||||||
|
execute_json_value(
|
||||||
|
daemon,
|
||||||
|
DaemonRequest::StopApp {
|
||||||
|
device: Some(id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh_apps(
|
||||||
|
Path(id): Path<String>,
|
||||||
|
State(daemon): State<SharedDaemon>,
|
||||||
|
body: Option<Json<RefreshAppsBody>>,
|
||||||
|
) -> Response {
|
||||||
|
execute_json_value(
|
||||||
|
daemon,
|
||||||
|
DaemonRequest::RefreshApps {
|
||||||
|
device: Some(id),
|
||||||
|
clear: body.map(|value| value.clear).unwrap_or(false),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_key(
|
||||||
|
Path(id): Path<String>,
|
||||||
|
State(daemon): State<SharedDaemon>,
|
||||||
|
Json(body): Json<SendKeyBody>,
|
||||||
|
) -> Response {
|
||||||
|
let key = match parse_key(&body.key) {
|
||||||
|
Ok(key) => key,
|
||||||
|
Err(response) => return response,
|
||||||
|
};
|
||||||
|
execute_json_value(
|
||||||
|
daemon,
|
||||||
|
DaemonRequest::SendKey {
|
||||||
|
device: Some(id),
|
||||||
|
key,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_sequence(
|
||||||
|
Path(id): Path<String>,
|
||||||
|
State(daemon): State<SharedDaemon>,
|
||||||
|
Json(body): Json<SendSequenceBody>,
|
||||||
|
) -> Response {
|
||||||
|
let mut parsed = Vec::with_capacity(body.keys.len());
|
||||||
|
for key in body.keys {
|
||||||
|
match parse_key(&key) {
|
||||||
|
Ok(key) => parsed.push(key),
|
||||||
|
Err(response) => return response,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
execute_json_value(
|
||||||
|
daemon,
|
||||||
|
DaemonRequest::SendSequence {
|
||||||
|
device: Some(id),
|
||||||
|
keys: parsed,
|
||||||
|
delay_ms: body.delay_ms,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dev_install(
|
||||||
|
Path(id): Path<String>,
|
||||||
|
State(daemon): State<SharedDaemon>,
|
||||||
|
mut multipart: Multipart,
|
||||||
|
) -> Response {
|
||||||
|
let mut archive = None;
|
||||||
|
while let Ok(Some(field)) = multipart.next_field().await {
|
||||||
|
let name = field.name().unwrap_or_default().to_string();
|
||||||
|
if name == "archive" || archive.is_none() {
|
||||||
|
match field.bytes().await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
archive = Some(bytes);
|
||||||
|
if name == "archive" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
return api_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid_multipart",
|
||||||
|
format!("Failed to read uploaded archive: {error}"),
|
||||||
|
Some("Upload a valid zip file as multipart field `archive`.".to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(archive) = archive else {
|
||||||
|
return api_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"missing_archive",
|
||||||
|
"Missing multipart field `archive`.",
|
||||||
|
Some("Upload a zip file in multipart field `archive`.".to_string()),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let zip_path = std::env::temp_dir().join(format!("tvctl-http-{}.zip", uuid::Uuid::new_v4()));
|
||||||
|
if let Err(error) = fs::write(&zip_path, &archive).await {
|
||||||
|
return api_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"dev_upload_write_failed",
|
||||||
|
format!("Failed to stage uploaded archive: {error}"),
|
||||||
|
Some("Check temp directory permissions and retry.".to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = execute_json_value(
|
||||||
|
daemon,
|
||||||
|
DaemonRequest::DevInstall {
|
||||||
|
device: Some(id),
|
||||||
|
zip_path: zip_path.display().to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let _ = fs::remove_file(&zip_path).await;
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dev_reload(Path(id): Path<String>, State(daemon): State<SharedDaemon>) -> Response {
|
||||||
|
execute_json_value(
|
||||||
|
daemon,
|
||||||
|
DaemonRequest::DevReload {
|
||||||
|
device: Some(id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dev_logs(Path(id): Path<String>, State(daemon): State<SharedDaemon>) -> Response {
|
||||||
|
execute_json::<DevLogsResult>(
|
||||||
|
daemon,
|
||||||
|
DaemonRequest::DevLogs {
|
||||||
|
device: Some(id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn daemon_status(State(daemon): State<SharedDaemon>) -> Response {
|
||||||
|
execute_json::<DaemonStatus>(daemon, DaemonRequest::Ping).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_config() -> Response {
|
||||||
|
match TvctlConfig::load().await {
|
||||||
|
Ok(mut config) => {
|
||||||
|
if !config.dev.roku_password.is_empty() {
|
||||||
|
config.dev.roku_password = "<redacted>".to_string();
|
||||||
|
}
|
||||||
|
api_success(StatusCode::OK, config)
|
||||||
|
}
|
||||||
|
Err(error) => api_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"config_load_failed",
|
||||||
|
format!("Failed to load tvctl config: {error}"),
|
||||||
|
Some("Inspect ~/.config/tvctl/config.toml for invalid TOML.".to_string()),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn patch_config(
|
||||||
|
State(daemon): State<SharedDaemon>,
|
||||||
|
Json(body): Json<BTreeMap<String, Value>>,
|
||||||
|
) -> Response {
|
||||||
|
let path = crate::daemon::config::default_config_path();
|
||||||
|
let mut config = match TvctlConfig::load_from_path(&path).await {
|
||||||
|
Ok(config) => config,
|
||||||
|
Err(error) => {
|
||||||
|
return api_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"config_load_failed",
|
||||||
|
format!("Failed to load tvctl config: {error}"),
|
||||||
|
Some("Inspect ~/.config/tvctl/config.toml for invalid TOML.".to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (key, value) in body {
|
||||||
|
let value = match json_value_to_string(value) {
|
||||||
|
Some(value) => value,
|
||||||
|
None => {
|
||||||
|
return api_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid_config_value",
|
||||||
|
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()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(error) = config.set_value(&key, &value) {
|
||||||
|
return api_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid_config_patch",
|
||||||
|
format!("Failed to set config value: {error}"),
|
||||||
|
Some("Run `tvctl config list` to see supported keys and value shapes.".to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(error) = config.save_to_path(&path).await {
|
||||||
|
return api_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"config_save_failed",
|
||||||
|
format!("Failed to save tvctl config: {error}"),
|
||||||
|
Some("Check write permissions for ~/.config/tvctl/config.toml.".to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = execute_json_value(daemon, DaemonRequest::ReloadConfig).await;
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reload_config(State(daemon): State<SharedDaemon>) -> Response {
|
||||||
|
execute_json::<ConfigReloadResult>(daemon, DaemonRequest::ReloadConfig).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute_json<T>(daemon: SharedDaemon, request: DaemonRequest) -> Response
|
||||||
|
where
|
||||||
|
T: Serialize + DeserializeOwned,
|
||||||
|
{
|
||||||
|
match send_daemon_request(daemon, &request).await {
|
||||||
|
Ok(response) => from_daemon_response::<T>(response),
|
||||||
|
Err(response) => response,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute_json_value(daemon: SharedDaemon, request: DaemonRequest) -> Response {
|
||||||
|
match send_daemon_request(daemon, &request).await {
|
||||||
|
Ok(response) => from_daemon_response::<Value>(response),
|
||||||
|
Err(response) => response,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_daemon_response<T>(response: DaemonResponse) -> Response
|
||||||
|
where
|
||||||
|
T: Serialize + DeserializeOwned,
|
||||||
|
{
|
||||||
|
if let Some(error) = response.error {
|
||||||
|
return api_error(status_for_error(&error.code), error.code, error.message, error.hint);
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = response.data.unwrap_or(Value::Null);
|
||||||
|
match serde_json::from_value::<T>(data) {
|
||||||
|
Ok(data) => api_success(StatusCode::OK, data),
|
||||||
|
Err(error) => api_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"invalid_daemon_payload",
|
||||||
|
format!("Failed to decode daemon payload: {error}"),
|
||||||
|
Some("Ensure the HTTP API and daemon are from the same build.".to_string()),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_success<T>(status: StatusCode, data: T) -> Response
|
||||||
|
where
|
||||||
|
T: Serialize,
|
||||||
|
{
|
||||||
|
(status, Json(SuccessEnvelope { ok: true, data })).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_error(
|
||||||
|
status: StatusCode,
|
||||||
|
code: impl Into<String>,
|
||||||
|
message: impl Into<String>,
|
||||||
|
hint: Option<String>,
|
||||||
|
) -> Response {
|
||||||
|
(
|
||||||
|
status,
|
||||||
|
Json(ErrorEnvelope {
|
||||||
|
ok: false,
|
||||||
|
error: ApiError {
|
||||||
|
code: code.into(),
|
||||||
|
message: message.into(),
|
||||||
|
hint,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_key(input: &str) -> Result<TvKey, Response> {
|
||||||
|
parse_normalized_tv_key(input).map_err(|_| {
|
||||||
|
api_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid_key",
|
||||||
|
format!("Unknown key '{input}'."),
|
||||||
|
Some("Use a normalized key like `home`, `down`, `volume-up`, or `literal:text`.".to_string()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_value_to_string(value: Value) -> Option<String> {
|
||||||
|
match value {
|
||||||
|
Value::Null => Some(String::new()),
|
||||||
|
Value::Bool(value) => Some(value.to_string()),
|
||||||
|
Value::Number(value) => Some(value.to_string()),
|
||||||
|
Value::String(value) => Some(value),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_for_error(code: &str) -> StatusCode {
|
||||||
|
match code {
|
||||||
|
"device_not_found" | "no_default_device" => StatusCode::NOT_FOUND,
|
||||||
|
"invalid_request"
|
||||||
|
| "platform_mismatch"
|
||||||
|
| "app_launch_ambiguous"
|
||||||
|
| "invalid_key"
|
||||||
|
| "invalid_config_patch"
|
||||||
|
| "invalid_config_value"
|
||||||
|
| "missing_archive"
|
||||||
|
| "invalid_multipart" => StatusCode::BAD_REQUEST,
|
||||||
|
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_daemon_request(
|
||||||
|
daemon: SharedDaemon,
|
||||||
|
request: &DaemonRequest,
|
||||||
|
) -> Result<DaemonResponse, Response> {
|
||||||
|
let socket_path = {
|
||||||
|
let guard = daemon.lock().await;
|
||||||
|
guard.paths.socket_file.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut stream = UnixStream::connect(&socket_path).await.map_err(|error| {
|
||||||
|
api_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"daemon_socket_unreachable",
|
||||||
|
format!("Unable to reach tvctld at {}: {error}", socket_path.display()),
|
||||||
|
Some("Check whether the daemon socket is writable and the daemon is running.".to_string()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let bytes = serde_json::to_vec(request).map_err(|error| {
|
||||||
|
api_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"request_encode_failed",
|
||||||
|
format!("Failed to encode daemon request: {error}"),
|
||||||
|
Some("Ensure the HTTP API and daemon are from the same build.".to_string()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
stream.write_all(&bytes).await.map_err(|error| {
|
||||||
|
api_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"daemon_write_failed",
|
||||||
|
format!("Failed to write request to tvctld: {error}"),
|
||||||
|
Some("Retry the request after checking the daemon state.".to_string()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
stream.shutdown().await.map_err(|error| {
|
||||||
|
api_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"daemon_shutdown_failed",
|
||||||
|
format!("Failed to finish the daemon request: {error}"),
|
||||||
|
Some("Retry the request after restarting the daemon.".to_string()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut response_bytes = Vec::new();
|
||||||
|
stream.read_to_end(&mut response_bytes).await.map_err(|error| {
|
||||||
|
api_error(
|
||||||
|
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| {
|
||||||
|
api_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"daemon_response_invalid",
|
||||||
|
format!("Failed to decode the daemon response: {error}"),
|
||||||
|
Some("Ensure the HTTP API and daemon are from the same build.".to_string()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+5
-41
@@ -12,7 +12,7 @@ use tokio::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
adapters::{AppInfo, Device, DeviceState, TvKey},
|
adapters::{AppInfo, Device, DeviceState, TvKey, parse_normalized_tv_key},
|
||||||
daemon::{
|
daemon::{
|
||||||
self,
|
self,
|
||||||
config::{RuntimePaths, TvctlConfig, default_config_path, systemd_unit_path},
|
config::{RuntimePaths, TvctlConfig, default_config_path, systemd_unit_path},
|
||||||
@@ -1409,46 +1409,10 @@ fn is_secret_config_key(key: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn parse_tv_key(input: &str) -> Result<TvKey, CliError> {
|
fn parse_tv_key(input: &str) -> Result<TvKey, CliError> {
|
||||||
if let Some(literal) = input.strip_prefix("literal:") {
|
parse_normalized_tv_key(input).map_err(|_| {
|
||||||
return Ok(TvKey::Literal(literal.to_string()));
|
CliError::new(
|
||||||
}
|
|
||||||
|
|
||||||
match input.to_ascii_lowercase().as_str() {
|
|
||||||
"home" => Ok(TvKey::Home),
|
|
||||||
"back" => Ok(TvKey::Back),
|
|
||||||
"up" => Ok(TvKey::Up),
|
|
||||||
"down" => Ok(TvKey::Down),
|
|
||||||
"left" => Ok(TvKey::Left),
|
|
||||||
"right" => Ok(TvKey::Right),
|
|
||||||
"select" => Ok(TvKey::Select),
|
|
||||||
"play" => Ok(TvKey::Play),
|
|
||||||
"pause" => Ok(TvKey::Pause),
|
|
||||||
"play-pause" => Ok(TvKey::PlayPause),
|
|
||||||
"stop" => Ok(TvKey::Stop),
|
|
||||||
"rewind" => Ok(TvKey::Rewind),
|
|
||||||
"fast-forward" => Ok(TvKey::FastForward),
|
|
||||||
"replay" => Ok(TvKey::Replay),
|
|
||||||
"skip" => Ok(TvKey::Skip),
|
|
||||||
"channel-up" => Ok(TvKey::ChannelUp),
|
|
||||||
"channel-down" => Ok(TvKey::ChannelDown),
|
|
||||||
"volume-up" => Ok(TvKey::VolumeUp),
|
|
||||||
"volume-down" => Ok(TvKey::VolumeDown),
|
|
||||||
"mute" => Ok(TvKey::Mute),
|
|
||||||
"power" => Ok(TvKey::Power),
|
|
||||||
"power-on" => Ok(TvKey::PowerOn),
|
|
||||||
"power-off" => Ok(TvKey::PowerOff),
|
|
||||||
"input-hdmi1" => Ok(TvKey::InputHdmi1),
|
|
||||||
"input-hdmi2" => Ok(TvKey::InputHdmi2),
|
|
||||||
"input-hdmi3" => Ok(TvKey::InputHdmi3),
|
|
||||||
"input-hdmi4" => Ok(TvKey::InputHdmi4),
|
|
||||||
"input-av" => Ok(TvKey::InputAv),
|
|
||||||
"input-tuner" => Ok(TvKey::InputTuner),
|
|
||||||
"search" => Ok(TvKey::Search),
|
|
||||||
"info" => Ok(TvKey::Info),
|
|
||||||
"options" => Ok(TvKey::Options),
|
|
||||||
_ => Err(CliError::new(
|
|
||||||
format!("Unknown key '{input}'."),
|
format!("Unknown key '{input}'."),
|
||||||
"Use a normalized key like `home`, `down`, `volume-up`, or `literal:text`.",
|
"Use a normalized key like `home`, `down`, `volume-up`, or `literal:text`.",
|
||||||
)),
|
)
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-4
@@ -6,6 +6,7 @@ pub mod registry;
|
|||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
|
net::SocketAddr,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
time::Duration,
|
time::Duration,
|
||||||
@@ -25,6 +26,7 @@ use tokio::{
|
|||||||
io::{AsyncReadExt, AsyncWriteExt},
|
io::{AsyncReadExt, AsyncWriteExt},
|
||||||
net::{UnixListener, UnixStream},
|
net::{UnixListener, UnixStream},
|
||||||
sync::Mutex,
|
sync::Mutex,
|
||||||
|
task::JoinHandle,
|
||||||
time::{self, MissedTickBehavior, sleep},
|
time::{self, MissedTickBehavior, sleep},
|
||||||
};
|
};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
@@ -33,6 +35,9 @@ use tracing::warn;
|
|||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
use crate::adapters::{Device, TvKey};
|
use crate::adapters::{Device, TvKey};
|
||||||
|
use crate::api;
|
||||||
|
|
||||||
|
pub type SharedDaemon = Arc<Mutex<Daemon>>;
|
||||||
|
|
||||||
/// The long-lived tvctld process.
|
/// The long-lived tvctld process.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -85,7 +90,7 @@ impl Daemon {
|
|||||||
|
|
||||||
/// Run the long-lived daemon loop over a Unix socket.
|
/// Run the long-lived daemon loop over a Unix socket.
|
||||||
pub async fn serve() -> anyhow::Result<()> {
|
pub async fn serve() -> anyhow::Result<()> {
|
||||||
let daemon = Arc::new(Mutex::new(Daemon::load().await?));
|
let daemon: SharedDaemon = Arc::new(Mutex::new(Daemon::load().await?));
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut guard = daemon.lock().await;
|
let mut guard = daemon.lock().await;
|
||||||
@@ -101,6 +106,7 @@ pub async fn serve() -> anyhow::Result<()> {
|
|||||||
guard.config.discovery.interval_secs,
|
guard.config.discovery.interval_secs,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
let http_server = start_http_server_if_enabled(daemon.clone()).await?;
|
||||||
|
|
||||||
if let Some(parent) = socket_path.parent() {
|
if let Some(parent) = socket_path.parent() {
|
||||||
fs::create_dir_all(parent).await?;
|
fs::create_dir_all(parent).await?;
|
||||||
@@ -163,6 +169,9 @@ pub async fn serve() -> anyhow::Result<()> {
|
|||||||
let guard = daemon.lock().await;
|
let guard = daemon.lock().await;
|
||||||
let _ = fs::remove_file(&guard.paths.active_socket_file).await;
|
let _ = fs::remove_file(&guard.paths.active_socket_file).await;
|
||||||
}
|
}
|
||||||
|
if let Some(task) = http_server {
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +183,31 @@ async fn set_socket_permissions(path: &Path) -> anyhow::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn start_http_server_if_enabled(daemon: SharedDaemon) -> anyhow::Result<Option<JoinHandle<()>>> {
|
||||||
|
let (enabled, host, port) = {
|
||||||
|
let guard = daemon.lock().await;
|
||||||
|
(
|
||||||
|
guard.config.daemon.http_enabled,
|
||||||
|
guard.config.daemon.http_host.clone(),
|
||||||
|
guard.config.daemon.http_port,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
if !enabled {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let address: SocketAddr = format!("{host}:{port}").parse()?;
|
||||||
|
let listener = tokio::net::TcpListener::bind(address).await?;
|
||||||
|
let app = api::router(daemon);
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
if let Err(error) = axum::serve(listener, app).await {
|
||||||
|
warn!("HTTP API server stopped: {error}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(Some(task))
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_connection(
|
async fn handle_connection(
|
||||||
mut stream: UnixStream,
|
mut stream: UnixStream,
|
||||||
daemon: Arc<Mutex<Daemon>>,
|
daemon: Arc<Mutex<Daemon>>,
|
||||||
@@ -194,7 +228,7 @@ async fn handle_connection(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let (response, should_stop) = handle_request(request, daemon).await;
|
let (response, should_stop) = execute_request(request, daemon).await;
|
||||||
write_response(&mut stream, &response).await?;
|
write_response(&mut stream, &response).await?;
|
||||||
Ok(should_stop)
|
Ok(should_stop)
|
||||||
}
|
}
|
||||||
@@ -206,9 +240,9 @@ async fn write_response(stream: &mut UnixStream, response: &DaemonResponse) -> a
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_request(
|
pub(crate) async fn execute_request(
|
||||||
request: DaemonRequest,
|
request: DaemonRequest,
|
||||||
daemon: Arc<Mutex<Daemon>>,
|
daemon: SharedDaemon,
|
||||||
) -> (DaemonResponse, bool) {
|
) -> (DaemonResponse, bool) {
|
||||||
match request {
|
match request {
|
||||||
DaemonRequest::Ping => {
|
DaemonRequest::Ping => {
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
use std::{
|
||||||
|
net::TcpListener,
|
||||||
|
process::{Child, Command, Stdio},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde_json::Value;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
use tvctl::{adapters::Device, daemon::config::TvctlConfig};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
struct TestDaemon {
|
||||||
|
_temp_dir: TempDir,
|
||||||
|
child: Child,
|
||||||
|
base_url: String,
|
||||||
|
device: Device,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestDaemon {
|
||||||
|
async fn start() -> Self {
|
||||||
|
let temp_dir = tempfile::tempdir().expect("temp dir should exist");
|
||||||
|
let root = temp_dir.path();
|
||||||
|
let home = root.join("home");
|
||||||
|
let config_home = root.join("config");
|
||||||
|
let data_home = root.join("data");
|
||||||
|
let runtime_dir = root.join("runtime");
|
||||||
|
std::fs::create_dir_all(&home).expect("home dir should exist");
|
||||||
|
std::fs::create_dir_all(&config_home).expect("config dir should exist");
|
||||||
|
std::fs::create_dir_all(&data_home).expect("data dir should exist");
|
||||||
|
std::fs::create_dir_all(&runtime_dir).expect("runtime dir should exist");
|
||||||
|
|
||||||
|
let port = pick_unused_port();
|
||||||
|
let config = TvctlConfig {
|
||||||
|
daemon: tvctl::daemon::config::DaemonConfig {
|
||||||
|
socket: runtime_dir.join("tvctl.sock").display().to_string(),
|
||||||
|
http_enabled: true,
|
||||||
|
http_port: port,
|
||||||
|
http_host: "127.0.0.1".to_string(),
|
||||||
|
log_level: "info".to_string(),
|
||||||
|
},
|
||||||
|
discovery: tvctl::daemon::config::DiscoveryConfig {
|
||||||
|
auto_discover: false,
|
||||||
|
interval_secs: 300,
|
||||||
|
timeout_secs: 1,
|
||||||
|
},
|
||||||
|
dev: tvctl::daemon::config::DevConfig {
|
||||||
|
enabled: true,
|
||||||
|
roku_username: "rokudev".to_string(),
|
||||||
|
roku_password: "secret".to_string(),
|
||||||
|
},
|
||||||
|
..TvctlConfig::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let config_path = config_home.join("tvctl/config.toml");
|
||||||
|
let data_dir = data_home.join("tvctl");
|
||||||
|
|
||||||
|
config
|
||||||
|
.save_to_path(&config_path)
|
||||||
|
.await
|
||||||
|
.expect("config should save");
|
||||||
|
|
||||||
|
let device = Device {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: "API Test Roku".to_string(),
|
||||||
|
original_name: "API Test Roku".to_string(),
|
||||||
|
platform: "roku".to_string(),
|
||||||
|
address: "127.0.0.2".parse().expect("loopback should parse"),
|
||||||
|
port: 8060,
|
||||||
|
is_default: true,
|
||||||
|
discovered_at: Utc::now(),
|
||||||
|
last_seen: Utc::now(),
|
||||||
|
};
|
||||||
|
std::fs::create_dir_all(&data_dir).expect("data dir should exist");
|
||||||
|
std::fs::write(
|
||||||
|
data_dir.join("devices.json"),
|
||||||
|
serde_json::to_vec_pretty(&vec![device.clone()]).expect("device should encode"),
|
||||||
|
)
|
||||||
|
.expect("devices file should write");
|
||||||
|
|
||||||
|
let binary = std::env::var("CARGO_BIN_EXE_tvctl").expect("binary path should exist");
|
||||||
|
let child = Command::new(binary)
|
||||||
|
.arg("__daemon_serve")
|
||||||
|
.env("HOME", &home)
|
||||||
|
.env("XDG_CONFIG_HOME", &config_home)
|
||||||
|
.env("XDG_DATA_HOME", &data_home)
|
||||||
|
.env("XDG_RUNTIME_DIR", &runtime_dir)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.expect("daemon should start");
|
||||||
|
|
||||||
|
let instance = Self {
|
||||||
|
_temp_dir: temp_dir,
|
||||||
|
child,
|
||||||
|
base_url: format!("http://127.0.0.1:{port}/v1"),
|
||||||
|
device,
|
||||||
|
};
|
||||||
|
instance.wait_until_ready().await;
|
||||||
|
instance
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_until_ready(&self) {
|
||||||
|
let client = Client::new();
|
||||||
|
for _ in 0..40 {
|
||||||
|
if let Ok(response) = client
|
||||||
|
.get(format!("{}/daemon/status", self.base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if response.status().is_success() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
panic!("daemon HTTP API did not become ready");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shutdown(&mut self) {
|
||||||
|
let _ = self.child.kill();
|
||||||
|
let _ = self.child.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TestDaemon {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pick_unused_port() -> u16 {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("ephemeral port should bind");
|
||||||
|
let port = listener
|
||||||
|
.local_addr()
|
||||||
|
.expect("local addr should exist")
|
||||||
|
.port();
|
||||||
|
drop(listener);
|
||||||
|
port
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn http_api_exposes_core_routes_and_config_patch() {
|
||||||
|
let daemon = TestDaemon::start().await;
|
||||||
|
let client = Client::new();
|
||||||
|
|
||||||
|
let status = client
|
||||||
|
.get(format!("{}/daemon/status", daemon.base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("status should respond");
|
||||||
|
assert!(status.status().is_success());
|
||||||
|
let status_json: Value = status.json().await.expect("status json should parse");
|
||||||
|
assert_eq!(status_json["ok"], true);
|
||||||
|
assert_eq!(status_json["data"]["device_count"], 1);
|
||||||
|
|
||||||
|
let devices = client
|
||||||
|
.get(format!("{}/devices", daemon.base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("devices should respond");
|
||||||
|
assert!(devices.status().is_success());
|
||||||
|
let devices_json: Value = devices.json().await.expect("devices json should parse");
|
||||||
|
assert_eq!(devices_json["data"][0]["name"], daemon.device.name);
|
||||||
|
|
||||||
|
let device = client
|
||||||
|
.get(format!("{}/devices/{}", daemon.base_url, daemon.device.id))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("device should respond");
|
||||||
|
assert!(device.status().is_success());
|
||||||
|
let device_json: Value = device.json().await.expect("device json should parse");
|
||||||
|
assert_eq!(device_json["data"]["id"], daemon.device.id.to_string());
|
||||||
|
|
||||||
|
let invalid_key = client
|
||||||
|
.post(format!(
|
||||||
|
"{}/devices/{}/remote/key",
|
||||||
|
daemon.base_url, daemon.device.id
|
||||||
|
))
|
||||||
|
.json(&serde_json::json!({ "key": "definitely-not-a-key" }))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("invalid key request should respond");
|
||||||
|
assert_eq!(invalid_key.status(), 400);
|
||||||
|
let invalid_key_json: Value = invalid_key
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("invalid key json should parse");
|
||||||
|
assert_eq!(invalid_key_json["error"]["code"], "invalid_key");
|
||||||
|
|
||||||
|
let patch = client
|
||||||
|
.patch(format!("{}/config", daemon.base_url))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"daemon.log_level": "debug",
|
||||||
|
"remote.roku_press_duration_ms": 90
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("config patch should respond");
|
||||||
|
assert!(patch.status().is_success());
|
||||||
|
let patch_json: Value = patch.json().await.expect("patch json should parse");
|
||||||
|
assert_eq!(patch_json["ok"], true);
|
||||||
|
|
||||||
|
let config = client
|
||||||
|
.get(format!("{}/config", daemon.base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("config should respond");
|
||||||
|
assert!(config.status().is_success());
|
||||||
|
let config_json: Value = config.json().await.expect("config json should parse");
|
||||||
|
assert_eq!(config_json["data"]["daemon"]["log_level"], "debug");
|
||||||
|
assert_eq!(config_json["data"]["remote"]["roku_press_duration_ms"], 90);
|
||||||
|
assert_eq!(config_json["data"]["dev"]["roku_password"], "<redacted>");
|
||||||
|
|
||||||
|
let reload = client
|
||||||
|
.post(format!("{}/config/reload", daemon.base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("reload should respond");
|
||||||
|
assert!(reload.status().is_success());
|
||||||
|
let reload_json: Value = reload.json().await.expect("reload json should parse");
|
||||||
|
assert_eq!(reload_json["ok"], true);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user