Skip to main content

trunk_recorder_plugin/
sdk.rs

1//! Writing a plugin: implement [`Plugin`] and call [`run`] from `main`.
2
3use std::io::{self, BufRead, IsTerminal, Write};
4use std::path::PathBuf;
5use std::sync::{Arc, Mutex};
6
7use schemars::JsonSchema;
8use serde::de::DeserializeOwned;
9use serde_json::Value;
10
11use crate::protocol::*;
12use crate::schema;
13
14/// A plugin. Every method runs on the one thread that reads the recorder's
15/// messages, one message at a time: return quickly, and hand slow work
16/// (network, disk, other processes) to a thread of your own.
17pub trait Plugin: Sized {
18    /// The plugin's settings. `#[serde(default)]` on the struct lets a user
19    /// leave any of them empty. The settings form is drawn from its schema
20    /// (see [`crate::schema`]). [`NoConfig`] for none.
21    type Config: DeserializeOwned + JsonSchema;
22    /// Settings for each system, when the plugin needs some (an API key per
23    /// system, say). [`NoConfig`] for none.
24    type SystemConfig: DeserializeOwned + JsonSchema;
25
26    /// Who the plugin is and what it subscribes to — start from [`crate::manifest!`].
27    /// `config` and `system_config` are filled in from the types above.
28    fn manifest() -> Manifest;
29
30    /// Start with the user's settings. An `Err` is shown to the user, and the
31    /// plugin isn't started again until recording next starts.
32    fn start(host: Host, setup: Setup<Self::Config, Self::SystemConfig>) -> Result<Self, String>;
33
34    fn call_start(&mut self, _call: CallInfo) {}
35    fn call_end(&mut self, _call: CallInfo) {}
36    fn call_concluded(&mut self, _call: ConcludedCall) {}
37    fn unit(&mut self, _event: UnitEvent) {}
38    fn audio(&mut self, _chunk: AudioChunk) {}
39    fn status(&mut self, _status: Status) {}
40
41    /// The recorder is stopping (or the plugin's stdin closed): finish or save
42    /// what's in flight within `grace` — the process exits when this returns.
43    fn shutdown(&mut self, _grace: std::time::Duration) {}
44}
45
46pub use crate::schema::NoConfig;
47
48/// What [`Plugin::start`] gets.
49#[derive(Clone, Debug)]
50pub struct Setup<C, S> {
51    pub config: C,
52    /// Every system, with the plugin's settings for it (`None`: left empty).
53    pub systems: Vec<System<S>>,
54    pub host: HostInfo,
55    pub capture_dir: PathBuf,
56    /// The plugin's own folder, kept across restarts and upgrades.
57    pub data_dir: PathBuf,
58    /// Audio formats `call.concluded` will carry (always "wav").
59    pub audio_formats: Vec<String>,
60}
61
62impl<C, S> Setup<C, S> {
63    pub fn system(&self, index: u16) -> Option<&System<S>> {
64        self.systems.iter().find(|s| s.index == index)
65    }
66    pub fn system_named(&self, short_name: &str) -> Option<&System<S>> {
67        self.systems.iter().find(|s| s.short_name == short_name)
68    }
69    /// Whether `call.concluded` will carry `format` ([`format`](mod@format)).
70    pub fn has_format(&self, format: &str) -> bool {
71        self.audio_formats.iter().any(|f| f == format)
72    }
73}
74
75#[derive(Clone, Debug)]
76pub struct System<S> {
77    pub index: u16,
78    pub short_name: String,
79    /// "p25" | "smartnet" | "dmr" | "conventional"
80    pub kind: String,
81    pub config: Option<S>,
82}
83
84/// The way back to the recorder: logs, health, results. Cheap to clone and
85/// usable from any thread. (A plugin's stdout belongs to the protocol — never
86/// `println!` in a plugin; use this, or `eprintln!` for the raw log.)
87#[derive(Clone)]
88pub struct Host {
89    out: Arc<Mutex<Box<dyn Write + Send>>>,
90}
91
92impl Host {
93    /// A host writing to `w` instead of stdout (see [`crate::testing`]).
94    pub fn to_writer(w: impl Write + Send + 'static) -> Host {
95        Host { out: Arc::new(Mutex::new(Box::new(w))) }
96    }
97
98    pub fn send(&self, m: &PluginMessage) {
99        let mut line = serde_json::to_string(m).unwrap_or_default();
100        line.push('\n');
101        let mut o = self.out.lock().unwrap_or_else(|e| e.into_inner());
102        // The recorder has gone when this fails; stdin's end will stop us.
103        let _ = o.write_all(line.as_bytes()).and_then(|_| o.flush());
104    }
105
106    pub fn log(&self, level: Level, message: impl Into<String>) {
107        self.send(&PluginMessage::Log { level, message: message.into() });
108    }
109    pub fn error(&self, message: impl Into<String>) {
110        self.log(Level::Error, message)
111    }
112    pub fn warn(&self, message: impl Into<String>) {
113        self.log(Level::Warn, message)
114    }
115    pub fn info(&self, message: impl Into<String>) {
116        self.log(Level::Info, message)
117    }
118    pub fn debug(&self, message: impl Into<String>) {
119        self.log(Level::Debug, message)
120    }
121
122    /// The plugin's health, shown in the interface until the next.
123    pub fn status(&self, state: State, message: impl Into<String>) {
124        self.send(&PluginMessage::Status { state, message: message.into() });
125    }
126
127    /// What became of a concluded call.
128    pub fn call_result(&self, path: &str, outcome: Outcome, message: impl Into<String>, url: impl Into<String>) {
129        self.send(&PluginMessage::CallResult { path: path.to_string(), outcome, message: message.into(), url: url.into() });
130    }
131}
132
133/// The manifest with the settings schemas filled in.
134pub fn describe<P: Plugin>() -> Manifest {
135    let mut m = P::manifest();
136    m.api = API_VERSION;
137    let c = schema::schema_for::<P::Config>();
138    m.config = (!schema::is_empty(&c)).then_some(c);
139    let s = schema::schema_for::<P::SystemConfig>();
140    m.system_config = (!schema::is_empty(&s)).then_some(s);
141    m
142}
143
144/// Run the plugin: `--describe` prints its manifest; otherwise it talks to the
145/// recorder on stdin/stdout until told to stop. Call from `main`.
146pub fn run<P: Plugin>() {
147    let arg = std::env::args().nth(1);
148    match arg.as_deref() {
149        Some("--describe") => {
150            println!("{}", serde_json::to_string_pretty(&describe::<P>()).unwrap_or_default());
151            return;
152        }
153        Some("--version" | "-V") => {
154            println!("{}", P::manifest().version);
155            return;
156        }
157        Some(a) => {
158            eprintln!("unknown argument {a} (a plugin takes --describe or --version; the recorder runs it with none)");
159            std::process::exit(2);
160        }
161        None => {}
162    }
163    let stdin = io::stdin();
164    if stdin.is_terminal() {
165        let m = P::manifest();
166        eprintln!(
167            "{} {} is a Trunk Recorder Pro plugin: the recorder runs it and talks to it over stdin/stdout.\n\
168             `--describe` prints its manifest. Try it with `trunk-pro plugin run <this binary>`,\n\
169             or paste protocol lines here (a hello first).",
170            m.name, m.version
171        );
172    }
173    let code = serve::<P>(stdin.lock(), Host { out: Arc::new(Mutex::new(Box::new(io::stdout()))) });
174    std::process::exit(code);
175}
176
177/// The message loop over `input`; the process's exit status.
178pub fn serve<P: Plugin>(input: impl BufRead, host: Host) -> i32 {
179    let mut plugin: Option<P> = None;
180    let mut grace = std::time::Duration::from_secs(10);
181    for line in input.lines() {
182        let Ok(line) = line else { break };
183        if line.trim().is_empty() {
184            continue;
185        }
186        let msg: HostMessage = match serde_json::from_str(&line) {
187            Ok(m) => m,
188            Err(e) => {
189                host.warn(format!("unreadable message from the recorder ({e}): {line}"));
190                continue;
191            }
192        };
193        let Some(p) = plugin.as_mut() else {
194            let HostMessage::Hello(hello) = msg else {
195                host.warn("a message before hello, ignored");
196                continue;
197            };
198            match start::<P>(host.clone(), hello) {
199                Ok(p) => {
200                    plugin = Some(p);
201                    host.send(&PluginMessage::Ready);
202                }
203                Err(e) => {
204                    host.status(State::Error, e.clone());
205                    host.error(e);
206                    return EXIT_CONFIG;
207                }
208            }
209            continue;
210        };
211        match msg {
212            HostMessage::Hello(_) => host.warn("a second hello, ignored"),
213            HostMessage::CallStart(c) => p.call_start(c),
214            HostMessage::CallEnd(c) => p.call_end(c),
215            HostMessage::CallConcluded(c) => p.call_concluded(c),
216            HostMessage::Unit(u) => p.unit(u),
217            HostMessage::Audio(a) => p.audio(a),
218            HostMessage::Status(s) => p.status(s),
219            HostMessage::Shutdown(s) => {
220                grace = std::time::Duration::from_secs_f64(s.grace_s.max(0.0));
221                break;
222            }
223            HostMessage::Unknown => {}
224        }
225    }
226    if let Some(p) = plugin.as_mut() {
227        p.shutdown(grace);
228    }
229    0
230}
231
232fn start<P: Plugin>(host: Host, hello: Hello) -> Result<P, String> {
233    if hello.api != 0 && hello.api < API_VERSION {
234        return Err(format!("this plugin needs a newer recorder (plugin API {API_VERSION}, the recorder has {})", hello.api));
235    }
236    let config: P::Config = parse(hello.config, "settings")?;
237    let mut systems = Vec::new();
238    for s in hello.systems {
239        let config = match s.config {
240            Value::Null => None,
241            v => Some(parse::<P::SystemConfig>(v, &format!("settings for {}", s.short_name))?),
242        };
243        systems.push(System { index: s.index, short_name: s.short_name, kind: s.kind, config });
244    }
245    let setup = Setup { config, systems, host: hello.host, capture_dir: hello.capture_dir, data_dir: hello.data_dir, audio_formats: hello.audio_formats };
246    P::start(host, setup)
247}
248
249/// Settings left out entirely count as `{}`, so `#[serde(default)]` applies.
250fn parse<T: DeserializeOwned>(v: Value, what: &str) -> Result<T, String> {
251    let v = if v.is_null() { Value::Object(Default::default()) } else { v };
252    serde_json::from_value(v).map_err(|e| format!("bad {what}: {e}"))
253}
254
255/// A manifest with the id, version, description, homepage, repository,
256/// authors and license from Cargo.toml; the name is the id until you set one:
257///
258/// ```ignore
259/// fn manifest() -> Manifest {
260///     Manifest { name: "OpenMHz".into(), subscribe: vec![topic::CALL_CONCLUDED.into()], ..trunk_recorder_plugin::manifest!() }
261/// }
262/// ```
263#[macro_export]
264macro_rules! manifest {
265    () => {{
266        let authors: Vec<String> = env!("CARGO_PKG_AUTHORS").split(':').filter(|a| !a.is_empty()).map(String::from).collect();
267        $crate::Manifest {
268            id: env!("CARGO_PKG_NAME").to_string(),
269            name: env!("CARGO_PKG_NAME").to_string(),
270            version: env!("CARGO_PKG_VERSION").to_string(),
271            description: env!("CARGO_PKG_DESCRIPTION").to_string(),
272            api: $crate::API_VERSION,
273            homepage: env!("CARGO_PKG_HOMEPAGE").to_string(),
274            repository: env!("CARGO_PKG_REPOSITORY").to_string(),
275            authors,
276            license: env!("CARGO_PKG_LICENSE").to_string(),
277            ..Default::default()
278        }
279    }};
280}