Skip to main content

nobble_addon_sdk/
protocol.rs

1//! What crosses the pipe between the daemon and an addon.
2//!
3//! [ADR-0016](../../../docs/decisions/0016-addon-process-boundary.md). One JSON
4//! object per line, in both directions. Line-delimited rather than
5//! length-prefixed or a binary codec because this is a **published** interface:
6//! it has to be readable in a log, reproducible with `echo`, and implementable
7//! by an addon author who has not installed a code generator.
8//!
9//! # Two shapes for one declaration, and why that is not two definitions
10//!
11//! The trait declares actions as `&'static [AddonAction]`, which is pleasant to
12//! write as a `const` and impossible to deserialise. So the types here are
13//! owned mirrors — [`ActionDecl`] beside [`AddonAction`](crate::AddonAction),
14//! and so on.
15//!
16//! They are not two things to keep in step. The author writes the borrowed one
17//! *once*; [`run`](crate::run) derives the owned one from it at the boundary.
18//! Nothing hand-maintains the second, so the failure Constitution VI is about —
19//! two definitions drifting — has nowhere to happen.
20//!
21//! # Both directions, and why the addon speaks first only about credentials
22//!
23//! Almost everything is the daemon asking and the addon answering. The one
24//! exception is [`Ask`], which an addon sends *while* answering, when it needs
25//! one of its own credentials. That makes the conversation strictly nested —
26//! request, optional asks and their answers, reply — which is what lets
27//! [`Credentials`](crate::Credentials) stay a plain synchronous call in the
28//! addon's code rather than infecting the trait with futures.
29
30use std::collections::BTreeMap;
31
32use serde::{Deserialize, Serialize};
33
34/// The protocol version this SDK speaks.
35///
36/// Independent of the daemon's version, which is the whole point of FR-049: an
37/// addon is built against an SDK, not against a daemon, and the two ship
38/// separately. `major` differing means incompatible, and the daemon refuses
39/// rather than loading — FR-049 again, with a message naming the fix, the same
40/// rule the UI–daemon handshake already follows.
41///
42/// # 1.1 — device-resolved actions
43///
44/// [`Description::device_actions`] was added ([ADR-0021]). Minor, because
45/// [`Version::compatible_with`] compares majors, the field is omitted when
46/// empty, and an addon that declares none puts **not one new byte** on the wire.
47/// A major bump would refuse every 1.1 addon on a 1.0 daemon — the shipped ones,
48/// and every third-party addon that declares nothing of the kind — to defend
49/// against one field.
50///
51/// **Additive on the wire is not additive in meaning**, and the version number
52/// cannot carry the difference. Two things carry it instead, and neither is
53/// optional:
54///
55/// - A daemon that reads this field must **refuse an action it cannot
56///   represent, naming it**, rather than reading it as something else. The
57///   refusal is per *action*: the addon stays in the registry with that one row
58///   reported unusable. Failing the handshake instead would leave the addon
59///   nowhere but a log line, which is a Principle IV collapse — and it is why
60///   [`KeystrokeDecl`] carries [`KeystrokeDecl::Unknown`] rather than failing
61///   the parse of the whole [`Description`].
62/// - A daemon built against 1.0 does not know the field exists. Serde drops what
63///   it does not recognise, so it will send [`Request::Perform`] for an action
64///   whose author never wrote a `perform` arm. [`run`](crate::run) answers that
65///   itself with a failure naming the cause, rather than letting it fall through
66///   to `NoSuchAction`, which would send the user looking for a missing action
67///   that is right there in the list.
68///
69/// [ADR-0021]: ../../../docs/decisions/0021-addon-device-resolved-actions.md
70pub const PROTOCOL: Version = Version { major: 1, minor: 2 };
71
72/// A protocol version.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74pub struct Version {
75    /// Incompatible when this differs.
76    pub major: u16,
77    /// Additive; a lower minor on either side is fine.
78    pub minor: u16,
79}
80
81impl Version {
82    /// Whether these two can talk.
83    #[must_use]
84    pub fn compatible_with(self, other: Self) -> bool {
85        self.major == other.major
86    }
87}
88
89impl std::fmt::Display for Version {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        write!(f, "{}.{}", self.major, self.minor)
92    }
93}
94
95/// What the daemon asks an addon to do.
96///
97/// Deliberately **not** `#[non_exhaustive]`, matching `DeviceEvent` and
98/// `Effect` in the daemon and for the same reason: a wildcard arm is how a
99/// newly added request gets silently ignored. Adding one should fail to compile
100/// in every implementation that has to answer it.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(tag = "ask", rename_all = "snake_case")]
103pub enum Request {
104    /// Version handshake. Always first.
105    Hello {
106        /// What the daemon speaks.
107        version: Version,
108    },
109    /// Everything static about the addon: what it is and what it offers.
110    ///
111    /// One request rather than six, because none of it changes while the addon
112    /// runs and six round trips at startup would be six chances to be half
113    /// described.
114    Describe,
115    /// Whether it could act right now.
116    Availability,
117    /// Whether one action is worth offering at the moment.
118    Applies {
119        /// Which one.
120        action: String,
121    },
122    /// A sentence for the interface, if it has one.
123    Status,
124    /// The current options for a live choice source (ADR-0022).
125    ///
126    /// Sent when somebody opens a picker, and at no other time — never on a
127    /// timer, which would cost idle CPU for a menu nobody has open.
128    LiveChoices {
129        /// Which named source.
130        id: String,
131    },
132    /// Do something.
133    Perform {
134        /// Which action.
135        action: String,
136        /// What the binding was configured with (ADR-0012).
137        ///
138        /// Omitted when empty, and `value` when absent. Most performs are a
139        /// bare press, and `"params":{},"value":null` on every one of them is
140        /// noise in a log somebody is reading to find out what happened.
141        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
142        params: BTreeMap<String, crate::addon::ParamValue>,
143        /// The fader position for a continuous action, absent for a press.
144        #[serde(default, skip_serializing_if = "Option::is_none")]
145        value: Option<u16>,
146    },
147    /// The inputs bound to one action, in device order (`006-FR-014a`).
148    ///
149    /// Sent unprompted whenever the set or its order changes, which is the one
150    /// place the daemon tells an addon something rather than asking it. It is
151    /// still a request on the wire — the addon answers `Done` — because a
152    /// second message shape would need a second reader at both ends.
153    BoundInputs {
154        /// Which of the addon's actions.
155        action: String,
156        /// Every input bound to it, already sorted.
157        #[serde(default, skip_serializing_if = "Vec::is_empty")]
158        inputs: Vec<String>,
159    },
160    /// Which of one action's inputs the user is holding right now.
161    ///
162    /// Sent when the set changes. Empty is the ordinary case and is omitted on
163    /// the wire, so an addon that never receives one has nothing held — which
164    /// is also what an older host that never sends one means.
165    HeldInputs {
166        /// Which of the addon's actions.
167        action: String,
168        /// The inputs currently held, in the same vocabulary as
169        /// [`Self::BoundInputs`].
170        #[serde(default, skip_serializing_if = "Vec::is_empty")]
171        held: Vec<String>,
172    },
173    /// Take these settings.
174    Configure {
175        /// By setting id.
176        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
177        values: BTreeMap<String, String>,
178    },
179    /// Read every signal it publishes, now.
180    ReadSignals,
181    /// Stop. The addon should exit; the daemon kills it if it does not.
182    Shutdown,
183}
184
185/// What an addon says back.
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187#[serde(tag = "say", rename_all = "snake_case")]
188pub enum Reply {
189    /// Answer to [`Request::Hello`].
190    Welcome {
191        /// What the addon's SDK speaks.
192        version: Version,
193    },
194    /// Answer to [`Request::Describe`].
195    Description(Description),
196    /// Answer to [`Request::Availability`].
197    Availability(AvailabilityDecl),
198    /// Answer to [`Request::Applies`].
199    Applies {
200        /// Whether it is worth offering.
201        applies: bool,
202    },
203    /// Answer to [`Request::Status`].
204    Status {
205        /// The sentence, if there is one.
206        status: Option<String>,
207    },
208    /// Answer to [`Request::LiveChoices`].
209    LiveChoices {
210        /// In the order the addon wants them shown — the ordering is a concept
211        /// only the addon has, so the interface renders rather than sorts.
212        choices: Vec<ChoiceDecl>,
213    },
214    /// Answer to [`Request::ReadSignals`].
215    Signals(ReadingDecl),
216    /// It worked, and there is nothing to say. Answers `Perform`, `Configure`
217    /// and `Shutdown`.
218    Done,
219    /// It did not work.
220    Failed {
221        /// Which kind, so the daemon can tell "no such action" from "the
222        /// target is not running" without parsing prose.
223        kind: FailureKind,
224        /// What to tell the user. Already a sentence.
225        detail: String,
226    },
227    /// The addon wants one of its own credentials, mid-request.
228    ///
229    /// Not really a reply: the daemon answers with [`Answer`] and the addon
230    /// then carries on with the request it was already handling. It shares this
231    /// channel because there is only one pipe.
232    Ask(Ask),
233}
234
235/// Why an action failed, in the categories the daemon distinguishes.
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(rename_all = "snake_case")]
238pub enum FailureKind {
239    /// The addon does not have that action. Usually a profile referring to
240    /// something a newer or older build had — US9 §4 says preserve and report,
241    /// never silently drop.
242    NoSuchAction,
243    /// It exists but cannot run now: the target application is closed, the
244    /// account is signed out.
245    Unavailable,
246    /// It tried and it failed.
247    Failed,
248}
249
250/// A credential request, from the addon to the daemon.
251///
252/// **There is no addon field, and that is the design.** See
253/// [`Credentials`](crate::Credentials) for the hole this shape closes: the
254/// daemon knows which addon is asking because it knows which child it is
255/// talking to, so an addon cannot name someone else's.
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
257#[serde(tag = "want", rename_all = "snake_case")]
258pub enum Ask {
259    /// Read one.
260    Get {
261        /// Which key.
262        key: String,
263    },
264    /// Store one.
265    Set {
266        /// Which key.
267        key: String,
268        /// What to store.
269        value: String,
270    },
271    /// Forget one.
272    Clear {
273        /// Which key.
274        key: String,
275    },
276    /// Read one from the addon's own store (ADR-0027).
277    ///
278    /// A different place from the three above, not a different key space. Those
279    /// reach the OS credential store, which is for values that are the whole of
280    /// an account's authority; this reaches a store the daemon encrypts at rest
281    /// and is for everything an addon wants to remember that is *not* a secret.
282    StoreGet {
283        /// Which key.
284        key: String,
285    },
286    /// Write one to the addon's own store.
287    ///
288    /// **What crosses this pipe is plaintext.** The encryption is the daemon's
289    /// and is not optional — an addon that had to remember to ask for it would
290    /// eventually produce a file indistinguishable from one that had reasoned
291    /// about it.
292    StoreSet {
293        /// Which key.
294        key: String,
295        /// What to store.
296        value: String,
297    },
298    /// Forget one from the addon's own store.
299    StoreClear {
300        /// Which key.
301        key: String,
302    },
303    /// Every key in the addon's own store.
304    StoreKeys,
305}
306
307/// The daemon's answer to an [`Ask`].
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309#[serde(tag = "answer", rename_all = "snake_case")]
310pub enum Answer {
311    /// What was stored, or nothing.
312    Value {
313        /// Absent means there is none, which is an ordinary state.
314        value: Option<String>,
315    },
316    /// The write or the clear succeeded.
317    Stored,
318    /// Every key there is, for [`Ask::StoreKeys`].
319    ///
320    /// Empty is the answer for a store that has never been written, a store
321    /// whose key material has been rotated away, and a platform with no
322    /// implementation. All three mean *there is nothing here*, and an addon
323    /// that treated them differently would be acting on a distinction it cannot
324    /// verify (ADR-0027).
325    Keys {
326        /// In whatever order the store keeps them.
327        keys: Vec<String>,
328    },
329    /// It did not.
330    Refused {
331        /// Why, as a sentence.
332        detail: String,
333    },
334}
335
336/// Everything static about an addon.
337#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
338pub struct Description {
339    /// Stable identifier, stored in bindings.
340    pub id: String,
341    /// What to call it.
342    pub name: String,
343    /// What it integrates, in a sentence.
344    pub description: String,
345    /// Everything it can be asked to do.
346    pub actions: Vec<ActionDecl>,
347    /// Everything it *names* that the device does on its own (ADR-0021).
348    ///
349    /// Omitted when empty, which is almost always, and the omission is
350    /// load-bearing in the same way `permissions`' is: an addon built against
351    /// SDK 1.0 sends no field, and *declares none* is exactly the right reading
352    /// of that. The reverse — a 1.1 addon talking to a 1.0 daemon — is the case
353    /// the version number cannot express, and is answered in
354    /// [`run`](crate::run) rather than here.
355    #[serde(default, skip_serializing_if = "Vec::is_empty")]
356    pub device_actions: Vec<DeviceActionDecl>,
357    /// Everything it publishes.
358    #[serde(default, skip_serializing_if = "Vec::is_empty")]
359    pub signals: Vec<SignalDecl>,
360    /// Everything it needs configuring.
361    #[serde(default, skip_serializing_if = "Vec::is_empty")]
362    pub settings: Vec<SettingDecl>,
363    /// Named lists its parameters draw options from (ADR-0022).
364    ///
365    /// Omitted when empty, which is every addon that has none — and an addon
366    /// built against an older SDK sends no field, which reads correctly as
367    /// "declares none".
368    #[serde(default, skip_serializing_if = "Vec::is_empty")]
369    pub choices: Vec<ChoicesDecl>,
370    /// Everything it needs to be allowed to do (FR-046).
371    ///
372    /// Omitted when empty, which is the ordinary case — and the omission is
373    /// load-bearing rather than tidy: an addon built against an older SDK sends
374    /// no field, and "declares nothing" is exactly the right reading of that.
375    #[serde(default, skip_serializing_if = "Vec::is_empty")]
376    pub permissions: Vec<PermissionDecl>,
377}
378
379/// One permission, owned. Mirrors [`Permission`](crate::Permission).
380///
381/// Tagged by kind with the target as a separate field, rather than one string
382/// per variant, so a consumer that does not recognise a future kind can still
383/// show the user *something* — the reason, at least — instead of dropping a
384/// permission silently. Dropping one is the failure that matters here: it
385/// would understate what the addon asked for.
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
387#[serde(tag = "kind", rename_all = "snake_case")]
388pub enum PermissionDecl {
389    /// Reach a host over the network.
390    Network {
391        /// The host.
392        host: String,
393        /// Why.
394        reason: String,
395    },
396    /// Read or write files under a path.
397    Files {
398        /// The path.
399        path: String,
400        /// Whether it writes as well as reads.
401        write: bool,
402        /// Why.
403        reason: String,
404    },
405    /// Start another program.
406    Launch {
407        /// What it starts.
408        program: String,
409        /// Why.
410        reason: String,
411    },
412    /// Keep credentials in the OS credential store. The enforced one.
413    Credentials {
414        /// Why.
415        reason: String,
416    },
417}
418
419/// One action, owned. Mirrors [`AddonAction`](crate::AddonAction).
420#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
421pub struct ActionDecl {
422    /// Stable identifier, stored in bindings.
423    pub id: String,
424    /// What to call it.
425    pub name: String,
426    /// What it does, in a sentence.
427    pub description: String,
428    /// What kind of input it expects: `momentary` or `continuous`.
429    pub trigger: String,
430    /// What it can be configured with.
431    #[serde(default, skip_serializing_if = "Vec::is_empty")]
432    pub params: Vec<ParamDecl>,
433    /// What a person must do elsewhere first, if anything. Mirrors
434    /// [`AddonAction::prerequisite`](crate::AddonAction::prerequisite).
435    ///
436    /// Present here because it was once not, and nobody noticed: the field
437    /// existed on [`AddonAction`](crate::AddonAction), the daemon's RPC carried
438    /// it, and the settings window rendered it — but this type sat in the middle
439    /// and had no place to put it, so every out-of-process addon's prerequisite
440    /// arrived as `None`. Unit tests on both sides passed throughout, because
441    /// neither side crosses the pipe. Additive and optional, so an addon built
442    /// against an older SDK still decodes.
443    #[serde(default, skip_serializing_if = "Option::is_none")]
444    pub prerequisite: Option<String>,
445}
446
447/// One device-resolved action, owned. Mirrors
448/// [`DeviceAction`](crate::DeviceAction).
449///
450/// Deliberately **not** an [`ActionDecl`] with extra fields. It carries no
451/// `trigger`, because a keystroke has no position to send, and no `params`,
452/// because a resolved binding stores none — so the two combinations ADR-0021
453/// calls impossible have nowhere to live rather than being checked for. A reader
454/// of a log can also tell which kind they are looking at without checking a flag.
455///
456/// Serde ignores fields it was not asked about, so a hand-written JSON addon can
457/// still *write* `"trigger"` beside one of these. What it cannot do is make it
458/// mean anything, because nothing reads it. That is the accurate claim;
459/// "unrepresentable" would be too strong.
460#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
461pub struct DeviceActionDecl {
462    /// Stable identifier, stored as the binding's provenance. Shares one
463    /// namespace with [`ActionDecl::id`]; the daemon refuses a collision by name
464    /// rather than resolving it to whichever list it looked in first.
465    pub id: String,
466    /// What to call it.
467    pub name: String,
468    /// What it does, in a sentence.
469    pub description: String,
470    /// The default the binding editor starts from, which the user may change.
471    pub keystroke: KeystrokeDecl,
472    /// The step outside Nobble the user must also take (FR-024), if any. Free
473    /// text, shown at the moment of binding, and never parsed.
474    #[serde(default, skip_serializing_if = "Option::is_none")]
475    pub prerequisite: Option<String>,
476}
477
478/// One keystroke, owned. Mirrors [`DeviceKeystroke`](crate::DeviceKeystroke).
479///
480/// # The tags are `nobble_rpc::ActionDto`'s tags, and that is the point
481///
482/// `hid_tap` and `hid_consumer`, with the same field names and the same
483/// omit-when-false on the modifiers, so this object and the one a binding is
484/// saved as are the **same JSON text**. The SDK cannot depend on `nobble-rpc`
485/// (FR-044) and generated bindings do not cross that boundary, so identical
486/// spelling plus a test pinning it is what stands in for Constitution VI's
487/// single definition. If the two ever drift, that test is what says so — nothing
488/// else will, because Cargo cannot see across the boundary.
489#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
490#[serde(tag = "type", rename_all = "snake_case")]
491pub enum KeystrokeDecl {
492    /// A keystroke.
493    HidTap {
494        /// HID usage code, not a character.
495        key: u8,
496        /// Held with it.
497        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
498        ctrl: bool,
499        /// Held with it.
500        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
501        shift: bool,
502        /// Held with it.
503        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
504        alt: bool,
505        /// Held with it.
506        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
507        gui: bool,
508    },
509    /// A Consumer Control usage — media keys.
510    HidConsumer {
511        /// The usage.
512        usage: u16,
513    },
514    /// A kind this build has no name for.
515    ///
516    /// **Not a fallback, and never sent by this SDK** — a refusal cut down to
517    /// the size ADR-0021 requires. Without it, an addon built against a later
518    /// SDK fails to deserialise its whole [`Description`], the handshake fails,
519    /// and the addon appears nowhere but a log line. With it, the cost is one
520    /// action the daemon reports as declared-but-unusable. The daemon must never
521    /// treat this as a keystroke; there is nothing here to send.
522    ///
523    /// **It covers an unrecognised *tag*, and only that.** A malformed payload —
524    /// `{"type":"hid_tap","key":999}`, a missing `key`, a `keystroke` that is a
525    /// string — still fails the parse of the whole [`Description`], because
526    /// serde has no way to localise the error to one element. That is left as it
527    /// is rather than papered over: this SDK cannot emit one, so producing one
528    /// means an addon written in something else is wrong about the format, and a
529    /// loud refusal is the right answer to that. The guarantee is about *future
530    /// versions*, which is what ADR-0021 asked for; it is not a general
531    /// tolerance of bad input, and claiming otherwise would be the kind of
532    /// promise somebody later relies on.
533    ///
534    /// The opposite decision from [`DeviceKeystroke`](crate::DeviceKeystroke),
535    /// which has no such variant on purpose: a *declaration* should fail to
536    /// compile when the vocabulary grows, and a *decode* should not fail at all.
537    #[serde(other)]
538    Unknown,
539}
540
541/// One parameter, owned. Mirrors [`AddonParam`](crate::AddonParam).
542#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
543pub struct ParamDecl {
544    /// Stable identifier.
545    pub id: String,
546    /// What to call it.
547    pub name: String,
548    /// What it is for.
549    pub description: String,
550    /// What it holds, so an interface can offer the right editor.
551    pub kind: String,
552    /// Whether the action fails without it.
553    pub required: bool,
554    /// Whether it holds several values rather than one (ADR-0022).
555    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
556    pub multiple: bool,
557    /// The id of a [`ChoicesDecl`] this draws its options from.
558    #[serde(default, skip_serializing_if = "Option::is_none")]
559    pub choices: Option<String>,
560}
561
562/// One named list of options, owned. Mirrors [`AddonChoices`](crate::AddonChoices).
563#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
564pub struct ChoicesDecl {
565    /// Stable id within the addon.
566    pub id: String,
567    /// What the list is.
568    pub name: String,
569    /// Whether the values must be asked for rather than read from below.
570    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
571    pub live: bool,
572    /// Every value, for a declared source. Empty when live.
573    #[serde(default, skip_serializing_if = "Vec::is_empty")]
574    pub values: Vec<ChoiceDecl>,
575}
576
577/// One option, owned. Mirrors [`AddonChoice`](crate::AddonChoice).
578#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
579pub struct ChoiceDecl {
580    /// What gets stored.
581    pub value: String,
582    /// What the user reads.
583    pub label: String,
584    /// A second line, where the label alone is ambiguous.
585    ///
586    /// **Defaulted, so this stayed additive.** An addon built against an
587    /// earlier SDK sends no such field and an older daemon ignores it, which is
588    /// what an API that is a promise to strangers has to manage.
589    ///
590    /// Exists because display names are not unique: two people called Alex in
591    /// one call is ordinary, and the identity underneath is a number nobody
592    /// recognises. A binding attached to the wrong Alex works perfectly and
593    /// passes every test.
594    #[serde(default, skip_serializing_if = "String::is_empty")]
595    pub detail: String,
596}
597
598/// One signal, owned. Mirrors [`AddonSignal`](crate::AddonSignal).
599#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
600pub struct SignalDecl {
601    /// Stable identifier, qualified by the addon id in a condition.
602    pub id: String,
603    /// What to call it.
604    pub name: String,
605    /// What it means.
606    pub description: String,
607}
608
609/// One setting, owned. Mirrors [`AddonSetting`](crate::AddonSetting).
610#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
611pub struct SettingDecl {
612    /// The parameter it is.
613    pub param: ParamDecl,
614    /// Whether it goes to the credential store rather than the settings file
615    /// (ADR-0013). A secret's value never crosses this pipe on the way *out*.
616    pub secret: bool,
617}
618
619/// Whether an addon can act, owned. Mirrors [`Availability`](crate::Availability).
620#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
621#[serde(tag = "state", rename_all = "snake_case")]
622pub enum AvailabilityDecl {
623    /// It could act right now.
624    Ready,
625    /// It could not, and this is why.
626    Unavailable {
627        /// A sentence for the user.
628        detail: String,
629    },
630}
631
632/// A signal reading, owned. Mirrors [`Reading`](crate::Reading).
633#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
634#[serde(tag = "read", rename_all = "snake_case")]
635pub enum ReadingDecl {
636    /// What each signal says.
637    Values {
638        /// By signal id.
639        values: Vec<(String, bool)>,
640    },
641    /// It could not answer, which FR-064 requires to be distinguishable from
642    /// every signal reading false.
643    Unavailable {
644        /// A sentence for the user.
645        detail: String,
646    },
647}
648
649#[cfg(test)]
650mod tests {
651    use super::*;
652
653    /// The wire is JSON, so the shape is part of the published interface and a
654    /// rename is a breaking change. Pinned as text rather than round-tripped,
655    /// because a round trip agrees with itself no matter what it renamed.
656    #[test]
657    fn a_perform_looks_like_this_on_the_wire() {
658        let json = serde_json::to_string(&Request::Perform {
659            action: "play_pause".to_owned(),
660            params: BTreeMap::from([("app".to_owned(), "exe:spotify.exe".into())]),
661            value: None,
662        })
663        .expect("serialise");
664        assert_eq!(
665            json,
666            r#"{"ask":"perform","action":"play_pause","params":{"app":"exe:spotify.exe"}}"#
667        );
668    }
669
670    #[test]
671    fn an_ask_carries_no_addon_name() {
672        // The whole of the fix. If this ever gains an `addon` field, an addon
673        // can name someone else's credentials again.
674        let json = serde_json::to_string(&Ask::Get {
675            key: "refresh_token".to_owned(),
676        })
677        .expect("serialise");
678        assert_eq!(json, r#"{"want":"get","key":"refresh_token"}"#);
679        assert!(!json.contains("addon"));
680    }
681
682    #[test]
683    fn every_message_round_trips() {
684        let requests = vec![
685            Request::Hello { version: PROTOCOL },
686            Request::Describe,
687            Request::Availability,
688            Request::Applies {
689                action: "sign_out".to_owned(),
690            },
691            Request::Status,
692            Request::Configure {
693                values: BTreeMap::new(),
694            },
695            Request::ReadSignals,
696            Request::Shutdown,
697        ];
698        for r in requests {
699            let text = serde_json::to_string(&r).expect("serialise");
700            let back: Request = serde_json::from_str(&text).expect("parse");
701            assert_eq!(back, r, "{text}");
702        }
703    }
704
705    /// The keystroke object has to be byte-identical to what
706    /// `nobble_rpc::ActionDto::HidTap` writes into the configuration file. This
707    /// pins one half; `nobble-service`, the only crate that can see both, pins
708    /// the equality. Two crates forbidden to see each other agree here or
709    /// nowhere.
710    #[test]
711    fn a_device_action_looks_like_this_on_the_wire() {
712        let json = serde_json::to_string(&DeviceActionDecl {
713            id: "discord_mute".to_owned(),
714            name: "Toggle mute in Discord".to_owned(),
715            description: "Mutes and unmutes your microphone in Discord.".to_owned(),
716            keystroke: KeystrokeDecl::HidTap {
717                key: 0x10,
718                ctrl: true,
719                shift: true,
720                alt: false,
721                gui: false,
722            },
723            prerequisite: Some("Set this keybind in Discord: User Settings > Keybinds.".to_owned()),
724        })
725        .expect("serialise");
726        assert_eq!(
727            json,
728            r#"{"id":"discord_mute","name":"Toggle mute in Discord","description":"Mutes and unmutes your microphone in Discord.","keystroke":{"type":"hid_tap","key":16,"ctrl":true,"shift":true},"prerequisite":"Set this keybind in Discord: User Settings > Keybinds."}"#
729        );
730    }
731
732    /// The claim that makes 1.1 a minor bump rather than a promise. An addon
733    /// that declares nothing new must put nothing new on the wire, or every
734    /// existing addon becomes a new conversation with a daemon that has not
735    /// changed.
736    #[test]
737    fn an_addon_declaring_none_puts_not_one_new_byte_on_the_wire() {
738        let json = serde_json::to_string(&Description {
739            id: "probe".to_owned(),
740            name: "Probe".to_owned(),
741            description: "For tests.".to_owned(),
742            actions: vec![],
743            device_actions: vec![],
744            choices: vec![],
745            signals: vec![],
746            settings: vec![],
747            permissions: vec![],
748        })
749        .expect("serialise");
750        assert_eq!(
751            json,
752            r#"{"id":"probe","name":"Probe","description":"For tests.","actions":[]}"#
753        );
754    }
755
756    /// ADR-0021's refusal, sized. An addon built against a later SDK costs the
757    /// daemon one unusable action, never the parse of the whole description —
758    /// which would fail the handshake and leave the addon nowhere but a log
759    /// line.
760    ///
761    /// Read inside a `Reply`, which is how it actually arrives: `Reply` is
762    /// internally tagged, and an internally tagged outer enum buffers its
763    /// content, which is exactly the situation where a `#[serde(other)]` inside
764    /// might have behaved differently from the bare struct. It does not.
765    #[test]
766    fn a_keystroke_kind_from_the_future_costs_one_action_not_the_description() {
767        let wire = r#"{"say":"description","id":"a","name":"A","description":"d","actions":[],"device_actions":[{"id":"x","name":"X","description":"d","keystroke":{"type":"hid_sequence","keys":[4,5]}}]}"#;
768        let Reply::Description(d) = serde_json::from_str(wire).expect("parse") else {
769            panic!("expected a description");
770        };
771        assert_eq!(d.device_actions.len(), 1);
772        assert_eq!(d.device_actions[0].keystroke, KeystrokeDecl::Unknown);
773        assert_eq!(d.device_actions[0].prerequisite, None);
774    }
775
776    /// The other half of the same guarantee, and the reason it is written down:
777    /// [`KeystrokeDecl::Unknown`] catches an unrecognised **tag** and nothing
778    /// else. A malformed payload fails the whole description, because serde
779    /// cannot localise the error to one element.
780    ///
781    /// Asserted rather than left as a surprise. This SDK cannot emit any of
782    /// these, so producing one means an addon written in something else is wrong
783    /// about the format, and a loud refusal is the right answer — but somebody
784    /// reading `Unknown`'s existence could reasonably assume more tolerance than
785    /// there is, and act on it.
786    #[test]
787    fn a_malformed_keystroke_still_fails_the_whole_description() {
788        for (case, wire) in [
789            (
790                "no keystroke at all",
791                r#"{"id":"a","name":"A","description":"d","actions":[],"device_actions":[{"id":"x","name":"X","description":"d"}]}"#,
792            ),
793            (
794                "a string where the object goes",
795                r#"{"id":"a","name":"A","description":"d","actions":[],"device_actions":[{"id":"x","name":"X","description":"d","keystroke":"ctrl+shift+m"}]}"#,
796            ),
797            (
798                "a tag with no usage",
799                r#"{"id":"a","name":"A","description":"d","actions":[],"device_actions":[{"id":"x","name":"X","description":"d","keystroke":{"type":"hid_tap"}}]}"#,
800            ),
801            (
802                "a usage that is not a byte",
803                r#"{"id":"a","name":"A","description":"d","actions":[],"device_actions":[{"id":"x","name":"X","description":"d","keystroke":{"type":"hid_tap","key":999}}]}"#,
804            ),
805        ] {
806            assert!(
807                serde_json::from_str::<Description>(wire).is_err(),
808                "{case} parsed, and it must not"
809            );
810        }
811    }
812
813    #[test]
814    fn a_major_mismatch_is_incompatible_and_a_minor_one_is_not() {
815        assert!(PROTOCOL.compatible_with(Version {
816            major: 1,
817            minor: 99
818        }));
819        assert!(!PROTOCOL.compatible_with(Version { major: 2, minor: 0 }));
820    }
821}