584da2d825
Set up the Rust crate, baseline module layout, and project docs so the repository matches the design bundle and builds cleanly as a starting point.
182 lines
6.1 KiB
Markdown
182 lines
6.1 KiB
Markdown
# DESIGN.md
|
|
# tvctl — Design Decisions and Rationale
|
|
# This document explains the why behind decisions in PROJECT_MAP.md.
|
|
# Read this if you want to understand the reasoning, not just the choices.
|
|
|
|
---
|
|
|
|
## Vision
|
|
|
|
tvctl exists because the Roku ECP API (and similar TV control APIs) are fully capable
|
|
but completely inaccessible to technical users without writing custom code. There is no
|
|
good CLI tool, no stable local API, and no abstraction layer that works across TV brands.
|
|
|
|
The goal is to be that abstraction layer — the foundation that other tools are built on,
|
|
and a useful tool in its own right.
|
|
|
|
---
|
|
|
|
## Why a Daemon?
|
|
|
|
A stateless CLI that talks directly to TVs on every invocation would be simpler to build
|
|
but has fundamental limitations:
|
|
|
|
- Device discovery (SSDP) takes time — you don't want it on every command
|
|
- The app cache needs to live somewhere persistent and shared
|
|
- An HTTP API for tool builders needs a persistent process
|
|
- State caching requires something running continuously
|
|
|
|
The daemon owns all of this. The CLI is a thin client. This is the correct architecture
|
|
for a tool with these requirements.
|
|
|
|
---
|
|
|
|
## Why Unix Socket for CLI, HTTP for API?
|
|
|
|
Two different audiences, two different needs:
|
|
|
|
**CLI users** are local, trusted, and interactive. A Unix socket is:
|
|
- Faster than HTTP for local IPC
|
|
- Inherently secure (filesystem permissions, user-only access)
|
|
- No port to conflict with other software
|
|
- The right tool for local process communication
|
|
|
|
**Tool builders** need HTTP because:
|
|
- Every language has an HTTP client
|
|
- REST is a universal interface
|
|
- It works from scripts, Home Assistant, stream decks, anything
|
|
- Versioning (`/v1/`) is natural in HTTP
|
|
|
|
Keeping these separate means the HTTP API port is optional — you can disable it entirely
|
|
and still use the full CLI. Security-conscious users appreciate this.
|
|
|
|
---
|
|
|
|
## Why Resource-Verb CLI Pattern?
|
|
|
|
```
|
|
tvctl device list ← resource-verb
|
|
tvctl list-devices ← verb-resource (rejected)
|
|
```
|
|
|
|
Resource-verb was chosen because:
|
|
|
|
1. It mirrors REST naturally — `GET /devices` → `tvctl device list`
|
|
2. It scales — as commands grow, the namespace stays organized
|
|
3. Tab completion is more useful — type `tvctl device <tab>` to see all device commands
|
|
4. It matches tools users already know (`git remote add`, `kubectl pod get`)
|
|
|
|
The 1:1 mapping between CLI and API is intentional and valuable. Learn one, know both.
|
|
|
|
---
|
|
|
|
## Why Organic App Cache?
|
|
|
|
The temptation is to ship a pre-populated database of app IDs. This was explicitly rejected:
|
|
|
|
- It requires ongoing maintenance as apps are added/removed
|
|
- It would contain apps the user doesn't have installed (bloat, confusion)
|
|
- App IDs are stable — once discovered, they never need to be re-fetched
|
|
- The first-fetch delay is tiny and happens once per app, ever
|
|
|
|
The organic approach means the cache is always perfectly accurate for this user's TV.
|
|
It grows automatically through normal use. It requires zero maintenance.
|
|
|
|
---
|
|
|
|
## Why Loopback-Only HTTP by Default?
|
|
|
|
The HTTP API exposes control over your TV. Binding to `0.0.0.0` by default would mean
|
|
anyone on your network could control your TV without authentication.
|
|
|
|
Loopback-only (`127.0.0.1`) is the secure default. Users who want network exposure can
|
|
change `http_host` in config — but they make that choice consciously.
|
|
|
|
There is no authentication system in v1. The security model is network isolation.
|
|
This is appropriate for a local tool used by one person.
|
|
|
|
---
|
|
|
|
## Why User-Level Systemd Service?
|
|
|
|
`tvctl daemon install` installs a systemd **user** service, not a system service. This means:
|
|
|
|
- No `sudo` required at any point
|
|
- The daemon runs as the installing user
|
|
- The Unix socket is owned by that user
|
|
- Uninstalling is clean and doesn't require root
|
|
|
|
System-level services are for system daemons. tvctld is a user tool.
|
|
|
|
---
|
|
|
|
## Why TOML for Config?
|
|
|
|
- It's the de facto standard in the Rust ecosystem
|
|
- Human-readable and editable without special knowledge
|
|
- Better than YAML (no indentation pitfalls, no Norway problem)
|
|
- Better than JSON for config (supports comments, more readable)
|
|
- `serde` + `toml` crate support is first-class in Rust
|
|
|
|
---
|
|
|
|
## Why kebab-case for Key Names?
|
|
|
|
```
|
|
volume-up ← chosen
|
|
VolumeUp ← Roku's internal name (rejected at surface)
|
|
volume_up ← snake_case (rejected)
|
|
```
|
|
|
|
- kebab-case is the Unix CLI convention for multi-word values
|
|
- It's consistent with how flags are named (`--json`, `--device`)
|
|
- The adapter layer handles translation to platform-specific names
|
|
- Users never need to know Roku uses `VolumeUp` internally
|
|
|
|
---
|
|
|
|
## The Adapter Abstraction
|
|
|
|
This is the most important architectural decision in the project.
|
|
|
|
The adapter trait is a wall between the platform world and the application world.
|
|
Everything above the adapter layer speaks tvctld's language. Everything below speaks
|
|
the platform's language.
|
|
|
|
Consequences:
|
|
- Adding Google TV support means writing one new file (`src/adapters/googletv/`)
|
|
- The CLI, API, registry, and cache don't change at all
|
|
- Platform bugs are isolated to their adapter
|
|
- Testing adapters is straightforward — mock the trait
|
|
|
|
The dev methods (`dev_install`, `dev_reload`, `dev_logs`) have default implementations
|
|
that return `NotSupported`. This means the trait can be implemented by platforms that
|
|
don't support developer mode without those platforms needing to handle it explicitly.
|
|
|
|
---
|
|
|
|
## State Cache Design
|
|
|
|
State is cached in memory only and cleared on daemon restart. This is intentional:
|
|
|
|
- TVs don't push state — you have to poll
|
|
- Cached state can be stale by definition
|
|
- Pretending to have live state you don't have is dishonest and confusing
|
|
- Every state response includes a `timestamp` so callers know how fresh it is
|
|
|
|
In a future version, WebSocket streaming or polling could make state more live.
|
|
For v1, honest polling with clear timestamps is the right approach.
|
|
|
|
---
|
|
|
|
## App Resolution Priority
|
|
|
|
When resolving an app name to a platform ID:
|
|
|
|
1. Per-device installed app list (in memory, freshest)
|
|
2. Platform cache (on disk, still accurate)
|
|
3. Live fetch from TV (fallback, always works)
|
|
|
|
This order minimizes network calls while always being able to resolve.
|
|
The user can also bypass resolution entirely with a raw `--id` flag.
|