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}