Skip to main content

nobble_addon_sdk/
run.rs

1//! The loop an addon does not have to write.
2//!
3//! [`run`] takes your [`Addon`] and serves the protocol on stdin and stdout
4//! until the daemon says stop. You never see a frame.
5
6use std::collections::BTreeMap;
7use std::io::{BufRead, BufReader, Write};
8use std::sync::{Arc, Mutex};
9
10use crate::addon::{
11    Addon, AddonAction, AddonChoices, AddonError, AddonParam, AddonSetting, AddonSignal,
12    Availability, CredentialHandle, Credentials, DeviceAction, DeviceKeystroke, Invocation,
13    Permission, Reading, Store, StoreHandle,
14};
15use crate::protocol::{
16    ActionDecl, Answer, Ask, AvailabilityDecl, ChoiceDecl, ChoicesDecl, Description,
17    DeviceActionDecl, FailureKind, KeystrokeDecl, PROTOCOL, ParamDecl, PermissionDecl, ReadingDecl,
18    Reply, Request, SettingDecl, SignalDecl,
19};
20
21/// Serve an addon on stdin and stdout, until told to stop.
22///
23/// Returns when the daemon sends `Shutdown` or closes the pipe. An addon's
24/// `main` is normally nothing but this.
25pub fn run<A: Addon>(addon: A) {
26    let stdin = BufReader::new(std::io::stdin());
27    let stdout = std::io::stdout();
28    // A broken pipe means the daemon went away, which is its business and not
29    // an error to shout about: exit quietly and let it restart us.
30    let _ = serve(addon, stdin, stdout);
31}
32
33/// [`run`], with the pipe supplied. For tests, and for anything hosting an
34/// addon over something other than stdio.
35///
36/// # Errors
37/// If the pipe fails. A malformed line is answered with a failure rather than
38/// returned — one bad frame should not end the conversation.
39pub fn serve<A: Addon, R: BufRead + Send + 'static, W: Write + Send + 'static>(
40    mut addon: A,
41    reader: R,
42    writer: W,
43) -> std::io::Result<()> {
44    let pipe = Arc::new(Mutex::new(Pipe {
45        reader,
46        writer,
47        line: String::new(),
48    }));
49    let credentials: CredentialHandle = Arc::new(WireCredentials {
50        pipe: Arc::clone(&pipe),
51    });
52
53    // Handed over before the first request, so an addon that remembers things
54    // has them loaded before anybody asks it anything (ADR-0027).
55    addon.attach_store(Arc::new(WireStore {
56        pipe: Arc::clone(&pipe),
57    }) as StoreHandle);
58
59    loop {
60        let Some(text) = read_line(&pipe)? else {
61            return Ok(()); // the daemon closed the pipe
62        };
63        let request: Request = match serde_json::from_str(&text) {
64            Ok(r) => r,
65            Err(e) => {
66                // Answered rather than fatal. A frame this end cannot parse is
67                // a bug in one message, and killing the conversation over it
68                // would turn a bad request into a dead addon.
69                send(
70                    &pipe,
71                    &Reply::Failed {
72                        kind: FailureKind::Failed,
73                        detail: format!("could not understand that: {e}"),
74                    },
75                )?;
76                continue;
77            }
78        };
79
80        let reply = match request {
81            // Version checking is the daemon's call, not ours: it is the one that
82            // can report a mismatch to a user and name the fix (FR-049). Answering
83            // honestly and letting it decide is the whole of our part.
84            Request::Hello { .. } => Reply::Welcome { version: PROTOCOL },
85            Request::Describe => Reply::Description(describe(&addon)),
86            Request::Availability => Reply::Availability(match addon.availability() {
87                Availability::Ready => AvailabilityDecl::Ready,
88                Availability::Unavailable(detail) => AvailabilityDecl::Unavailable { detail },
89            }),
90            Request::Applies { action } => Reply::Applies {
91                applies: addon.applies(&action),
92            },
93            Request::Status => Reply::Status {
94                status: addon.status(),
95            },
96            Request::LiveChoices { id } => Reply::LiveChoices {
97                choices: addon
98                    .live_choices(&id)
99                    .into_iter()
100                    .map(|c| ChoiceDecl {
101                        value: c.value,
102                        label: c.label,
103                        detail: c.detail,
104                    })
105                    .collect(),
106            },
107            Request::HeldInputs { action, held } => {
108                addon.held_inputs(&action, &held);
109                Reply::Done
110            }
111            Request::BoundInputs { action, inputs } => {
112                addon.bound_inputs(&action, &inputs);
113                Reply::Done
114            }
115            Request::Configure { values } => {
116                addon.configure(&values, Arc::clone(&credentials));
117                Reply::Done
118            }
119            Request::ReadSignals => Reply::Signals(match addon.read_signals() {
120                Reading::Values(values) => ReadingDecl::Values {
121                    values: values
122                        .into_iter()
123                        .map(|(id, on)| (id.to_owned(), on))
124                        .collect(),
125                },
126                Reading::Unavailable(detail) => ReadingDecl::Unavailable { detail },
127            }),
128            Request::Perform {
129                action,
130                params,
131                value,
132            } => perform(&mut addon, &action, &params, value),
133            Request::Shutdown => {
134                send(&pipe, &Reply::Done)?;
135                return Ok(());
136            }
137        };
138        send(&pipe, &reply)?;
139    }
140}
141
142fn perform<A: Addon>(
143    addon: &mut A,
144    action: &str,
145    params: &BTreeMap<String, crate::addon::ParamValue>,
146    value: Option<u16>,
147) -> Reply {
148    // ADR-0021's backward-compatibility half. A daemon that understands
149    // `device_actions` resolved this to a keystroke when the key was bound and
150    // never sends it here at all; a daemon built against SDK 1.0 dropped the
151    // field on the way in, because serde discards what it does not recognise,
152    // and will ask for a `perform` the author was told not to write.
153    //
154    // Answered here rather than left to fall through, because the fall-through
155    // is `AddonError::NoSuchAction` and that sentence is a lie: the action is
156    // right there in the list the user bound it from, and they would go looking
157    // for a missing action instead of an old Nobble.
158    //
159    // `Failed` rather than `Unavailable`: unavailable means try again later, and
160    // this never will be until the daemon is replaced.
161    if addon.device_actions().iter().any(|d| d.id == action) {
162        return Reply::Failed {
163            kind: FailureKind::Failed,
164            detail: format!(
165                "{action:?} is sent by the device, not by this addon. \
166                 This version of Nobble is older than the addon and cannot bind \
167                 it as a keystroke; update Nobble."
168            ),
169        };
170    }
171
172    let invocation = match value {
173        Some(v) => Invocation::moved(params, v),
174        None => Invocation::press(params),
175    };
176    match addon.perform(action, &invocation) {
177        Ok(()) => Reply::Done,
178        Err(e) => {
179            let (kind, detail) = match e {
180                AddonError::NoSuchAction(w) => (FailureKind::NoSuchAction, w),
181                AddonError::Unavailable(w) => (FailureKind::Unavailable, w),
182                AddonError::Failed(w) => (FailureKind::Failed, w),
183            };
184            Reply::Failed { kind, detail }
185        }
186    }
187}
188
189/// Derive the owned declaration from the borrowed one the author wrote.
190///
191/// This function is why `protocol`'s mirrors are not a second definition to
192/// keep in step: there is one authored source, and this is the only thing that
193/// produces the other.
194fn describe<A: Addon>(addon: &A) -> Description {
195    Description {
196        id: addon.id().to_owned(),
197        name: addon.name().to_owned(),
198        description: addon.description().to_owned(),
199        actions: addon.actions().iter().map(action_decl).collect(),
200        device_actions: addon
201            .device_actions()
202            .iter()
203            .map(device_action_decl)
204            .collect(),
205        choices: addon.choices().iter().map(choices_decl).collect(),
206        signals: addon.signals().iter().map(signal_decl).collect(),
207        settings: addon.settings().iter().map(setting_decl).collect(),
208        permissions: addon.permissions().iter().map(permission_decl).collect(),
209    }
210}
211
212fn permission_decl(p: &Permission) -> PermissionDecl {
213    match *p {
214        Permission::Network { host, reason } => PermissionDecl::Network {
215            host: host.to_owned(),
216            reason: reason.to_owned(),
217        },
218        Permission::Files {
219            path,
220            write,
221            reason,
222        } => PermissionDecl::Files {
223            path: path.to_owned(),
224            write,
225            reason: reason.to_owned(),
226        },
227        Permission::Launch { program, reason } => PermissionDecl::Launch {
228            program: program.to_owned(),
229            reason: reason.to_owned(),
230        },
231        Permission::Credentials { reason } => PermissionDecl::Credentials {
232            reason: reason.to_owned(),
233        },
234    }
235}
236
237fn action_decl(a: &AddonAction) -> ActionDecl {
238    ActionDecl {
239        id: a.id.to_owned(),
240        name: a.name.to_owned(),
241        description: a.description.to_owned(),
242        trigger: a.trigger.as_wire().to_owned(),
243        params: a.params.iter().map(param_decl).collect(),
244        prerequisite: a.prerequisite.map(ToOwned::to_owned),
245    }
246}
247
248/// The device-resolved half of the same one-way derivation.
249///
250/// A second function beside [`action_decl`] rather than a branch inside it,
251/// because the two produce different shapes — this one has no trigger and no
252/// parameters to convert. It is still the *only* thing that produces a
253/// [`DeviceActionDecl`], which is the property that matters: one authored
254/// source, one derivation per owned type, nothing hand-maintaining the mirror.
255fn device_action_decl(d: &DeviceAction) -> DeviceActionDecl {
256    DeviceActionDecl {
257        id: d.id.to_owned(),
258        name: d.name.to_owned(),
259        description: d.description.to_owned(),
260        keystroke: keystroke_decl(d.keystroke),
261        prerequisite: d.prerequisite.map(ToOwned::to_owned),
262    }
263}
264
265/// Exhaustive on purpose: [`KeystrokeDecl::Unknown`] is a decode state and has
266/// no arm here, so a new [`DeviceKeystroke`] variant fails to compile until
267/// somebody decides what crosses the pipe for it.
268fn keystroke_decl(k: DeviceKeystroke) -> KeystrokeDecl {
269    match k {
270        DeviceKeystroke::Tap {
271            key,
272            ctrl,
273            shift,
274            alt,
275            gui,
276        } => KeystrokeDecl::HidTap {
277            key,
278            ctrl,
279            shift,
280            alt,
281            gui,
282        },
283        DeviceKeystroke::Consumer { usage } => KeystrokeDecl::HidConsumer { usage },
284    }
285}
286
287fn param_decl(p: &AddonParam) -> ParamDecl {
288    ParamDecl {
289        id: p.id.to_owned(),
290        name: p.name.to_owned(),
291        description: p.description.to_owned(),
292        kind: p.kind.as_wire().to_owned(),
293        required: p.required,
294        multiple: p.multiple,
295        choices: p.choices.map(ToOwned::to_owned),
296    }
297}
298
299fn choices_decl(c: &AddonChoices) -> ChoicesDecl {
300    ChoicesDecl {
301        id: c.id.to_owned(),
302        name: c.name.to_owned(),
303        live: c.live,
304        values: c
305            .values
306            .iter()
307            .map(|v| ChoiceDecl {
308                value: v.value.to_owned(),
309                label: v.label.to_owned(),
310                // A declared source has nothing to disambiguate: its values are
311                // fixed at build time, so an author who needs two of them told
312                // apart writes it into the label.
313                detail: String::new(),
314            })
315            .collect(),
316    }
317}
318
319fn signal_decl(s: &AddonSignal) -> SignalDecl {
320    SignalDecl {
321        id: s.id.to_owned(),
322        name: s.name.to_owned(),
323        description: s.description.to_owned(),
324    }
325}
326
327fn setting_decl(s: &AddonSetting) -> SettingDecl {
328    SettingDecl {
329        param: param_decl(&s.param),
330        secret: s.secret,
331    }
332}
333
334/// The pipe, and the buffer reads reuse.
335struct Pipe<R, W> {
336    reader: R,
337    writer: W,
338    line: String,
339}
340
341fn read_line<R: BufRead, W: Write>(
342    pipe: &Arc<Mutex<Pipe<R, W>>>,
343) -> std::io::Result<Option<String>> {
344    let mut p = pipe
345        .lock()
346        .unwrap_or_else(std::sync::PoisonError::into_inner);
347    p.line.clear();
348    let Pipe { reader, line, .. } = &mut *p;
349    if reader.read_line(line)? == 0 {
350        return Ok(None);
351    }
352    Ok(Some(p.line.trim_end().to_owned()))
353}
354
355fn send<R: BufRead, W: Write, T: serde::Serialize>(
356    pipe: &Arc<Mutex<Pipe<R, W>>>,
357    message: &T,
358) -> std::io::Result<()> {
359    let mut p = pipe
360        .lock()
361        .unwrap_or_else(std::sync::PoisonError::into_inner);
362    let text = serde_json::to_string(message)?;
363    writeln!(p.writer, "{text}")?;
364    p.writer.flush()
365}
366
367/// [`Credentials`] over the pipe.
368///
369/// Every call is an [`Ask`] out and an [`Answer`] back, taken while the addon
370/// is part-way through a request. That works because the conversation is
371/// strictly nested — the daemon is waiting for our reply, so the next thing it
372/// sends is the answer to this — and it is what keeps `Credentials` a plain
373/// synchronous call in the addon's own code.
374struct WireCredentials<R, W> {
375    pipe: Arc<Mutex<Pipe<R, W>>>,
376}
377
378impl<R: BufRead + Send, W: Write + Send> WireCredentials<R, W> {
379    fn exchange(&self, ask: &Ask) -> Option<Answer> {
380        send(&self.pipe, &Reply::Ask(ask.clone())).ok()?;
381        let text = read_line(&self.pipe).ok()??;
382        serde_json::from_str(&text).ok()
383    }
384}
385
386impl<R: BufRead + Send, W: Write + Send> Credentials for WireCredentials<R, W> {
387    fn get(&self, key: &str) -> Option<String> {
388        match self.exchange(&Ask::Get {
389            key: key.to_owned(),
390        })? {
391            Answer::Value { value } => value,
392            // A refusal and an absence are the same to the caller: there is no
393            // credential to use. The daemon has already told the user why.
394            Answer::Stored | Answer::Keys { .. } | Answer::Refused { .. } => None,
395        }
396    }
397
398    fn set(&self, key: &str, value: &str) -> Result<(), String> {
399        match self.exchange(&Ask::Set {
400            key: key.to_owned(),
401            value: value.to_owned(),
402        }) {
403            Some(Answer::Stored) => Ok(()),
404            Some(Answer::Refused { detail }) => Err(detail),
405            Some(Answer::Value { .. } | Answer::Keys { .. }) | None => {
406                Err("the daemon did not answer".to_owned())
407            }
408        }
409    }
410
411    fn clear(&self, key: &str) {
412        let _ = self.exchange(&Ask::Clear {
413            key: key.to_owned(),
414        });
415    }
416}
417
418/// [`Store`] over the same pipe, by the same nesting argument as
419/// [`WireCredentials`].
420///
421/// A second type rather than a second set of methods on the first, because they
422/// are different places and an addon holding one handle that could reach both
423/// would make *which store did this go to* a question somebody has to answer at
424/// every call site (ADR-0027).
425struct WireStore<R, W> {
426    pipe: Arc<Mutex<Pipe<R, W>>>,
427}
428
429impl<R: BufRead + Send, W: Write + Send> WireStore<R, W> {
430    fn exchange(&self, ask: &Ask) -> Option<Answer> {
431        send(&self.pipe, &Reply::Ask(ask.clone())).ok()?;
432        let text = read_line(&self.pipe).ok()??;
433        serde_json::from_str(&text).ok()
434    }
435}
436
437impl<R: BufRead + Send, W: Write + Send> Store for WireStore<R, W> {
438    fn get(&self, key: &str) -> Option<String> {
439        match self.exchange(&Ask::StoreGet {
440            key: key.to_owned(),
441        })? {
442            Answer::Value { value } => value,
443            // Absent, refused and unreadable are one answer on purpose: an
444            // addon cannot act differently on them, and distinguishing them
445            // would leak that a value once existed.
446            Answer::Stored | Answer::Keys { .. } | Answer::Refused { .. } => None,
447        }
448    }
449
450    fn set(&self, key: &str, value: &str) -> Result<(), String> {
451        match self.exchange(&Ask::StoreSet {
452            key: key.to_owned(),
453            value: value.to_owned(),
454        }) {
455            Some(Answer::Stored) => Ok(()),
456            Some(Answer::Refused { detail }) => Err(detail),
457            // No fallback to storing it unencrypted. Remembering less than you
458            // wanted is recoverable; the other thing is not.
459            Some(Answer::Value { .. } | Answer::Keys { .. }) | None => {
460                Err("the daemon did not answer".to_owned())
461            }
462        }
463    }
464
465    fn clear(&self, key: &str) {
466        let _ = self.exchange(&Ask::StoreClear {
467            key: key.to_owned(),
468        });
469    }
470
471    fn keys(&self) -> Vec<String> {
472        match self.exchange(&Ask::StoreKeys) {
473            Some(Answer::Keys { keys }) => keys,
474            _ => Vec::new(),
475        }
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use crate::addon::{AddonAction, Availability, Trigger};
483    use std::io::Cursor;
484
485    /// A writer the test can still read after `serve` has taken it.
486    #[derive(Clone, Default)]
487    struct Recorder(Arc<Mutex<Vec<u8>>>);
488
489    impl Write for Recorder {
490        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
491            self.0
492                .lock()
493                .unwrap_or_else(std::sync::PoisonError::into_inner)
494                .extend_from_slice(buf);
495            Ok(buf.len())
496        }
497        fn flush(&mut self) -> std::io::Result<()> {
498            Ok(())
499        }
500    }
501
502    impl Recorder {
503        fn lines(&self) -> Vec<String> {
504            let bytes = self
505                .0
506                .lock()
507                .unwrap_or_else(std::sync::PoisonError::into_inner)
508                .clone();
509            String::from_utf8(bytes)
510                .expect("utf-8")
511                .lines()
512                .map(str::to_owned)
513                .collect()
514        }
515    }
516
517    const ACTIONS: &[AddonAction] = &[AddonAction {
518        id: "wave",
519        name: "Wave",
520        description: "Says hello.",
521        trigger: Trigger::Momentary,
522        params: &[],
523        ..AddonAction::BASE
524    }];
525
526    const DEVICE_ACTIONS: &[DeviceAction] = &[DeviceAction {
527        id: "salute",
528        name: "Salute",
529        description: "The device sends this one.",
530        keystroke: DeviceKeystroke::Tap {
531            key: 0x10,
532            ctrl: true,
533            shift: true,
534            alt: false,
535            gui: false,
536        },
537        prerequisite: Some("Set this keybind in the other application."),
538    }];
539
540    #[derive(Default)]
541    struct Probe;
542
543    impl Addon for Probe {
544        fn id(&self) -> &'static str {
545            "probe"
546        }
547        fn name(&self) -> &'static str {
548            "Probe"
549        }
550        fn description(&self) -> &'static str {
551            "For tests."
552        }
553        fn actions(&self) -> &'static [AddonAction] {
554            ACTIONS
555        }
556        fn device_actions(&self) -> &'static [DeviceAction] {
557            DEVICE_ACTIONS
558        }
559        fn availability(&self) -> Availability {
560            Availability::Ready
561        }
562        fn perform(&mut self, action: &str, _i: &Invocation<'_>) -> Result<(), AddonError> {
563            match action {
564                "wave" => Ok(()),
565                other => Err(AddonError::NoSuchAction(other.to_owned())),
566            }
567        }
568        fn configure(&mut self, _values: &BTreeMap<String, String>, c: CredentialHandle) {
569            // Asks mid-request on purpose: that is the nested exchange under
570            // test. What it reads is asserted on the wire rather than kept in a
571            // field, because the wire is the thing this crate publishes.
572            let _ = c.get("token");
573        }
574    }
575
576    fn script(lines: &[&str]) -> Cursor<Vec<u8>> {
577        Cursor::new(lines.join("\n").into_bytes())
578    }
579
580    #[test]
581    fn it_answers_a_handshake_and_describes_itself() {
582        let out = Recorder::default();
583        serve(
584            Probe,
585            script(&[
586                r#"{"ask":"hello","version":{"major":1,"minor":0}}"#,
587                r#"{"ask":"describe"}"#,
588            ]),
589            out.clone(),
590        )
591        .expect("served");
592
593        let lines = out.lines();
594        assert_eq!(
595            lines[0],
596            r#"{"say":"welcome","version":{"major":1,"minor":2}}"#
597        );
598        let described: Reply = serde_json::from_str(&lines[1]).expect("parse");
599        let Reply::Description(d) = described else {
600            panic!("expected a description, got {:?}", lines[1]);
601        };
602        assert_eq!(d.id, "probe");
603        assert_eq!(d.actions.len(), 1);
604        assert_eq!(d.actions[0].trigger, "momentary");
605        // Derived from the borrowed declaration, never hand-written.
606        assert_eq!(d.actions[0].id, "wave");
607    }
608
609    /// This one is about the derivation losing a field, which is invisible from
610    /// either end. `AddonAction::prerequisite` existed, the daemon's own types
611    /// had it and the settings window rendered it — and `action_decl` had
612    /// nothing to copy it into, so no addon out of process ever shipped one.
613    /// Asserted on the encoded text rather than the struct, because the struct
614    /// is the half that was already right.
615    #[test]
616    fn an_actions_prerequisite_is_carried_into_the_declaration() {
617        const NEEDY: AddonAction = AddonAction {
618            id: "push_to_talk",
619            name: "Push to talk",
620            description: "Holds the key down.",
621            trigger: Trigger::Momentary,
622            prerequisite: Some("Set this keybind in the other application."),
623            ..AddonAction::BASE
624        };
625
626        let decl = action_decl(&NEEDY);
627        assert_eq!(decl.prerequisite.as_deref(), NEEDY.prerequisite);
628        let encoded = serde_json::to_string(&decl).expect("encode");
629        assert!(encoded.contains("Set this keybind"), "{encoded}");
630
631        // And absent rather than empty when there is nothing to say, so a
632        // reader cannot mistake "no prerequisite" for "a blank one".
633        let plain = serde_json::to_string(&action_decl(&ACTIONS[0])).expect("encode");
634        assert!(!plain.contains("prerequisite"), "{plain}");
635    }
636
637    #[test]
638    fn a_failure_keeps_its_kind() {
639        // The daemon distinguishes "no such action" from "not right now"
640        // without parsing prose, because US9 §4 treats them differently: one is
641        // a binding to preserve and report, the other is a temporary state.
642        let out = Recorder::default();
643        serve(
644            Probe,
645            script(&[r#"{"ask":"perform","action":"nope"}"#]),
646            out.clone(),
647        )
648        .expect("served");
649
650        let reply: Reply = serde_json::from_str(&out.lines()[0]).expect("parse");
651        assert_eq!(
652            reply,
653            Reply::Failed {
654                kind: FailureKind::NoSuchAction,
655                detail: "nope".to_owned(),
656            }
657        );
658    }
659
660    #[test]
661    fn a_credential_is_fetched_mid_request_and_names_no_addon() {
662        // The nested exchange: the daemon asks us to configure, we ask it for a
663        // credential while doing so, it answers, and only then do we reply. It
664        // works because the conversation is strictly nested -- the daemon is
665        // already waiting on us, so the next line it sends is our answer.
666        let out = Recorder::default();
667        serve(
668            Probe,
669            script(&[
670                r#"{"ask":"configure"}"#,
671                r#"{"answer":"value","value":"sekrit"}"#,
672            ]),
673            out.clone(),
674        )
675        .expect("served");
676
677        let lines = out.lines();
678        assert_eq!(
679            lines[0], r#"{"say":"ask","want":"get","key":"token"}"#,
680            "the ask goes out first, and carries no addon name"
681        );
682        assert!(
683            !lines[0].contains("probe"),
684            "an addon must not be able to name whose credential it wants"
685        );
686        assert_eq!(lines[1], r#"{"say":"done"}"#, "then the reply");
687    }
688
689    /// ADR-0021's backward-compatibility half, which nothing else can test:
690    /// only an *old* daemon sends this, and there is no old daemon to run
691    /// against. The failure has to say why, because the honest-looking answer —
692    /// `NoSuchAction` — would send the user hunting for a missing action that
693    /// is right there in the list they bound it from.
694    #[test]
695    fn an_old_daemon_asking_us_to_perform_a_device_action_is_told_why_not() {
696        let out = Recorder::default();
697        serve(
698            Probe,
699            script(&[r#"{"ask":"perform","action":"salute"}"#]),
700            out.clone(),
701        )
702        .expect("served");
703
704        let Reply::Failed { kind, detail } = serde_json::from_str(&out.lines()[0]).expect("parse")
705        else {
706            panic!("expected a failure, got {:?}", out.lines()[0]);
707        };
708        // Not `Unavailable`: unavailable means try again later, and this will
709        // not change until the daemon is replaced.
710        assert_eq!(kind, FailureKind::Failed);
711        assert!(detail.contains("sent by the device"), "{detail}");
712        assert!(detail.contains("update Nobble"), "{detail}");
713    }
714
715    /// The addon author writes no `perform` arm for a device-resolved action,
716    /// so the id must never reach one. `Probe::perform` answers `NoSuchAction`
717    /// for anything but `wave`; if the guard above ever moves below the call,
718    /// this is what notices.
719    #[test]
720    fn a_device_action_never_reaches_the_addons_own_perform() {
721        let out = Recorder::default();
722        serve(
723            Probe,
724            script(&[r#"{"ask":"perform","action":"salute"}"#]),
725            out.clone(),
726        )
727        .expect("served");
728        assert!(
729            !out.lines()[0].contains("no_such_action"),
730            "the guard let it through: {}",
731            out.lines()[0]
732        );
733    }
734
735    #[test]
736    fn a_frame_it_cannot_parse_is_answered_rather_than_fatal() {
737        // One bad message should not end the conversation, or a daemon bug
738        // becomes a dead addon.
739        let out = Recorder::default();
740        serve(
741            Probe,
742            script(&["not json at all", r#"{"ask":"status"}"#]),
743            out.clone(),
744        )
745        .expect("served");
746
747        let lines = out.lines();
748        assert!(lines[0].contains(r#""say":"failed""#), "{}", lines[0]);
749        assert_eq!(
750            lines[1], r#"{"say":"status","status":null}"#,
751            "and it carries on"
752        );
753    }
754
755    #[test]
756    fn shutdown_ends_it() {
757        let out = Recorder::default();
758        serve(
759            Probe,
760            script(&[r#"{"ask":"shutdown"}"#, r#"{"ask":"status"}"#]),
761            out.clone(),
762        )
763        .expect("served");
764        assert_eq!(
765            out.lines().len(),
766            1,
767            "nothing after shutdown is answered: {:?}",
768            out.lines()
769        );
770    }
771}