1use crate::{json::Value, schema::Schema};
5use alloc::{collections::BTreeSet, format, string::String, vec::Vec};
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9#[non_exhaustive]
10pub enum ErrorCode {
11 Disabled,
13 NotFound,
15 InvalidInput,
17 CapabilityUnavailable,
19 Unavailable,
21 RevisionConflict,
23 Unsupported,
25 LimitExceeded,
27 Cancelled,
29 Internal,
31 Forbidden,
33 ConfirmationRequired,
35}
36
37impl ErrorCode {
38 pub fn as_str(self) -> &'static str {
40 match self {
41 Self::Disabled => "control_disabled",
42 Self::NotFound => "not_found",
43 Self::InvalidInput => "invalid_input",
44 Self::CapabilityUnavailable => "capability_unavailable",
45 Self::Unavailable => "unavailable",
46 Self::RevisionConflict => "revision_conflict",
47 Self::Unsupported => "unsupported",
48 Self::LimitExceeded => "limit_exceeded",
49 Self::Cancelled => "cancelled",
50 Self::Internal => "internal_error",
51 Self::Forbidden => "forbidden",
52 Self::ConfirmationRequired => "confirmation_required",
53 }
54 }
55}
56
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub enum Access {
60 Open,
62 Confirm,
64 Local,
66}
67
68impl Access {
69 pub fn as_str(self) -> &'static str {
71 match self {
72 Self::Open => "open",
73 Self::Confirm => "confirm",
74 Self::Local => "local",
75 }
76 }
77}
78
79#[derive(Clone, Debug, PartialEq, Eq)]
81pub struct ActionError {
82 pub code: ErrorCode,
84 pub message: String,
86 pub details: Value,
88}
89
90impl ActionError {
91 pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
93 Self {
94 code,
95 message: message.into(),
96 details: Value::Null,
97 }
98 }
99 pub fn with_details(mut self, details: Value) -> Self {
101 self.details = details;
102 self
103 }
104 pub fn to_json(&self) -> Value {
106 Value::object([
107 ("code", Value::string(self.code.as_str())),
108 ("message", Value::string(&self.message)),
109 ("details", self.details.clone()),
110 ])
111 }
112}
113
114#[derive(Clone, Copy, Debug)]
116pub struct ActionSpec {
117 pub name: &'static str,
119 pub description: &'static str,
121 pub input: &'static Schema,
123 pub output: &'static Schema,
125 pub capabilities: u64,
127 pub mutation: bool,
129 pub revision_required: bool,
131 pub undo: bool,
133 pub transaction: Option<&'static str>,
135 pub cancellable: bool,
137 pub command_id: Option<u32>,
139 pub access: Access,
141}
142
143impl ActionSpec {
144 pub const fn read(name: &'static str, description: &'static str) -> Self {
146 Self {
147 name,
148 description,
149 input: &crate::schema::EMPTY_OBJECT,
150 output: &crate::schema::OBJECT,
151 capabilities: 0,
152 mutation: false,
153 revision_required: false,
154 undo: false,
155 transaction: None,
156 cancellable: false,
157 command_id: None,
158 access: Access::Open,
159 }
160 }
161 pub const fn confirm(mut self) -> Self {
163 self.access = Access::Confirm;
164 self
165 }
166 pub const fn local_only(mut self) -> Self {
168 self.access = Access::Local;
169 self
170 }
171 pub const fn write(name: &'static str, description: &'static str) -> Self {
173 Self {
174 mutation: true,
175 revision_required: true,
176 ..Self::read(name, description)
177 }
178 }
179 pub const fn schemas(mut self, input: &'static Schema, output: &'static Schema) -> Self {
181 self.input = input;
182 self.output = output;
183 self
184 }
185}
186
187#[derive(Clone, Copy, Debug, Default)]
189pub struct ActionContext {
190 pub expected_revision: Option<u64>,
192 pub atomic: bool,
194 pub local: bool,
196}
197
198#[derive(Clone, Debug, PartialEq, Eq)]
200pub struct Resource {
201 pub uri: String,
203 pub name: String,
205 pub mime_type: String,
207 pub size: Option<u64>,
209}
210
211impl Resource {
212 pub fn to_json(&self) -> Value {
214 Value::object([
215 ("uri", Value::string(&self.uri)),
216 ("name", Value::string(&self.name)),
217 ("mimeType", Value::string(&self.mime_type)),
218 ("size", self.size.map(Value::u64).unwrap_or(Value::Null)),
219 ])
220 }
221}
222
223#[derive(Clone, Debug, PartialEq, Eq)]
225pub struct ActionResult {
226 pub value: Value,
228 pub revision: u64,
230 pub task: Option<u64>,
232 pub resources: Vec<Resource>,
234}
235
236impl ActionResult {
237 pub fn completed(value: Value, revision: u64) -> Self {
239 Self {
240 value,
241 revision,
242 task: None,
243 resources: Vec::new(),
244 }
245 }
246 pub fn deferred(task: u64, revision: u64) -> Self {
248 Self {
249 value: Value::Null,
250 revision,
251 task: Some(task),
252 resources: Vec::new(),
253 }
254 }
255 pub fn to_json(&self) -> Value {
257 Value::object([
258 (
259 "status",
260 Value::string(if self.task.is_some() {
261 "pending"
262 } else {
263 "complete"
264 }),
265 ),
266 ("revision", Value::u64(self.revision)),
267 ("value", self.value.clone()),
268 ("task", self.task.map(Value::u64).unwrap_or(Value::Null)),
269 (
270 "resources",
271 Value::Array(self.resources.iter().map(Resource::to_json).collect()),
272 ),
273 ])
274 }
275}
276
277pub trait Host {
279 fn actions(&self) -> &[ActionSpec];
281 fn revision(&self) -> u64;
283 fn capabilities(&self) -> u64;
285 fn availability(&self, _action: &ActionSpec) -> Result<(), ActionError> {
287 Ok(())
288 }
289 fn access(&self, action: &ActionSpec) -> Access {
292 action.access
293 }
294 fn confirm(&mut self, _action: &ActionSpec, _input: &Value) -> Result<(), ActionError> {
297 Err(ActionError::new(
298 ErrorCode::Forbidden,
299 "This action needs the user's confirmation, which this host cannot ask for",
300 )
301 .with_details(Value::object([("reason", Value::string("unattended"))])))
302 }
303 fn refused(&mut self, _action: &ActionSpec) {}
305 fn execute(
307 &mut self,
308 action: &ActionSpec,
309 input: &Value,
310 context: &ActionContext,
311 ) -> Result<ActionResult, ActionError>;
312 fn supports_transaction(&self, _domain: &str) -> bool {
314 false
315 }
316 fn begin_transaction(&mut self, _domain: &str) -> Result<(), ActionError> {
318 Err(ActionError::new(
319 ErrorCode::Unsupported,
320 "Transactions are unavailable",
321 ))
322 }
323 fn commit_transaction(&mut self) -> Result<(), ActionError> {
325 Err(ActionError::new(
326 ErrorCode::Unsupported,
327 "Transactions are unavailable",
328 ))
329 }
330 fn rollback_transaction(&mut self) -> Result<(), ActionError> {
332 Err(ActionError::new(
333 ErrorCode::Unsupported,
334 "Transactions are unavailable",
335 ))
336 }
337 fn inspect(&self, _query: &Value) -> Result<Value, ActionError> {
341 Err(ActionError::new(
342 ErrorCode::Unsupported,
343 "Inspection is unavailable",
344 ))
345 }
346 fn task_status(&self, _id: u64) -> Result<Value, ActionError> {
348 Err(ActionError::new(ErrorCode::NotFound, "Task not found"))
349 }
350 fn task_cancel(&mut self, _id: u64) -> Result<Value, ActionError> {
352 Err(ActionError::new(ErrorCode::NotFound, "Task not found"))
353 }
354 fn resource_info(&self, _uri: &str) -> Result<Resource, ActionError> {
356 Err(ActionError::new(ErrorCode::NotFound, "Resource not found"))
357 }
358 fn resource_read(
360 &self,
361 _uri: &str,
362 _offset: u64,
363 _limit: usize,
364 ) -> Result<Vec<u8>, ActionError> {
365 Err(ActionError::new(ErrorCode::NotFound, "Resource not found"))
366 }
367}
368
369pub struct Invocation<'a> {
371 pub action: &'a str,
373 pub input: &'a Value,
375 pub expected_revision: Option<u64>,
377}
378
379#[derive(Clone, Debug)]
381pub struct BatchCall {
382 pub action: String,
384 pub input: Value,
386 pub expected_revision: Option<u64>,
388}
389
390#[derive(Clone, Debug)]
392pub struct BatchResult {
393 pub results: Vec<Result<ActionResult, ActionError>>,
395 pub rolled_back: bool,
397 pub revision: u64,
399}
400
401impl BatchResult {
402 pub fn to_json(&self) -> Value {
404 Value::object([
405 (
406 "results",
407 Value::Array(
408 self.results
409 .iter()
410 .map(|r| match r {
411 Ok(v) => {
412 Value::object([("ok", Value::Bool(true)), ("result", v.to_json())])
413 }
414 Err(e) => {
415 Value::object([("ok", Value::Bool(false)), ("error", e.to_json())])
416 }
417 })
418 .collect(),
419 ),
420 ),
421 ("rolledBack", Value::Bool(self.rolled_back)),
422 ("revision", Value::u64(self.revision)),
423 ])
424 }
425}
426
427#[derive(Clone, Debug)]
429pub struct Page {
430 pub actions: Vec<Value>,
432 pub next_cursor: Option<String>,
434 pub revision: u64,
436}
437
438impl Page {
439 pub fn to_json(&self) -> Value {
441 Value::object([
442 ("actions", Value::Array(self.actions.clone())),
443 (
444 "nextCursor",
445 self.next_cursor
446 .as_ref()
447 .map(|s| Value::string(s))
448 .unwrap_or(Value::Null),
449 ),
450 ("revision", Value::u64(self.revision)),
451 ])
452 }
453}
454
455#[derive(Default)]
457pub struct Dispatcher {
458 enabled: bool,
459}
460
461impl Dispatcher {
462 pub const fn new() -> Self {
464 Self { enabled: false }
465 }
466 pub fn set_enabled(&mut self, enabled: bool) {
468 self.enabled = enabled;
469 }
470 pub fn enabled(&self) -> bool {
472 self.enabled
473 }
474 pub fn check_enabled(&self) -> Result<(), ActionError> {
476 if self.enabled {
477 Ok(())
478 } else {
479 Err(ActionError::new(
480 ErrorCode::Disabled,
481 "External control is disabled",
482 ))
483 }
484 }
485 fn find(&self, host: &impl Host, name: &str) -> Result<ActionSpec, ActionError> {
486 validate_registry(host.actions())?;
487 host.actions()
488 .iter()
489 .find(|action| action.name == name)
490 .copied()
491 .ok_or_else(|| {
492 ActionError::new(ErrorCode::NotFound, "Action not found")
493 .with_details(Value::object([("action", Value::string(name))]))
494 })
495 }
496 fn ready(&self, host: &impl Host, action: &ActionSpec) -> Result<(), ActionError> {
497 let missing = action.capabilities & !host.capabilities();
498 if missing != 0 {
499 return Err(ActionError::new(
500 ErrorCode::CapabilityUnavailable,
501 "Required platform services are unavailable",
502 )
503 .with_details(Value::object([(
504 "missingCapabilities",
505 Value::u64(missing),
506 )])));
507 }
508 host.availability(action)
509 }
510 fn validate(
511 &self,
512 host: &impl Host,
513 action: &ActionSpec,
514 input: &Value,
515 context: &ActionContext,
516 revision: u64,
517 ) -> Result<(), ActionError> {
518 if let Err(e) = action.input.validate(input) {
519 return Err(ActionError::new(
520 ErrorCode::InvalidInput,
521 "Action input does not match its schema",
522 )
523 .with_details(Value::object([
524 ("path", Value::string(e.path)),
525 ("reason", Value::string(e.reason)),
526 ])));
527 }
528 self.ready(host, action)?;
529 if action.mutation
530 && action.revision_required
531 && !context.local
532 && context.expected_revision.is_none()
533 {
534 return Err(ActionError::new(
535 ErrorCode::InvalidInput,
536 "This mutation requires expectedRevision",
537 ));
538 }
539 if action.mutation
540 && context
541 .expected_revision
542 .is_some_and(|expected| expected != revision)
543 {
544 return Err(ActionError::new(
545 ErrorCode::RevisionConflict,
546 "The application state changed after it was inspected",
547 )
548 .with_details(Value::object([
549 (
550 "expectedRevision",
551 context
552 .expected_revision
553 .map(Value::u64)
554 .unwrap_or(Value::Null),
555 ),
556 ("currentRevision", Value::u64(revision)),
557 ])));
558 }
559 Ok(())
560 }
561 fn invoke(
562 &self,
563 host: &mut impl Host,
564 action: &ActionSpec,
565 input: &Value,
566 context: &ActionContext,
567 ) -> Result<ActionResult, ActionError> {
568 let mut result = host.execute(action, input, context)?;
569 result.revision = host.revision();
570 if context.atomic && result.task.is_some() {
571 return Err(ActionError::new(
572 ErrorCode::Internal,
573 "An atomic action returned deferred work",
574 ));
575 }
576 if result.task.is_none() {
577 if let Err(e) = action.output.validate(&result.value) {
578 return Err(ActionError::new(
579 ErrorCode::Internal,
580 "The action returned an invalid result",
581 )
582 .with_details(Value::object([
583 ("path", Value::string(e.path)),
584 ("reason", Value::string(e.reason)),
585 ])));
586 }
587 }
588 Ok(result)
589 }
590 pub fn execute(
592 &self,
593 host: &mut impl Host,
594 call: Invocation<'_>,
595 ) -> Result<ActionResult, ActionError> {
596 self.check_enabled()?;
597 let action = self.find(host, call.action)?;
598 let context = ActionContext {
599 expected_revision: call.expected_revision,
600 atomic: false,
601 local: false,
602 };
603 let access = host.access(&action);
604 if matches!(access, Access::Local) {
606 self.consent(host, &action, call.input)?;
607 }
608 self.validate(host, &action, call.input, &context, host.revision())?;
609 if !matches!(access, Access::Local) {
610 self.consent(host, &action, call.input)?;
611 if matches!(access, Access::Confirm) {
612 self.validate(host, &action, call.input, &context, host.revision())?;
614 }
615 }
616 self.invoke(host, &action, call.input, &context)
617 }
618 fn consent(
620 &self,
621 host: &mut impl Host,
622 action: &ActionSpec,
623 input: &Value,
624 ) -> Result<(), ActionError> {
625 match host.access(action) {
626 Access::Open => Ok(()),
627 Access::Local => {
628 host.refused(action);
629 Err(ActionError::new(
630 ErrorCode::Forbidden,
631 "This action is not available to external control",
632 )
633 .with_details(Value::object([
634 ("action", Value::string(action.name)),
635 ("reason", Value::string("app")),
636 ])))
637 }
638 Access::Confirm => host.confirm(action, input),
639 }
640 }
641 pub fn execute_local(
643 &self,
644 host: &mut impl Host,
645 name: &str,
646 input: &Value,
647 ) -> Result<ActionResult, ActionError> {
648 let action = self.find(host, name)?;
649 let context = ActionContext {
650 local: true,
651 ..ActionContext::default()
652 };
653 self.validate(host, &action, input, &context, host.revision())?;
654 self.invoke(host, &action, input, &context)
655 }
656 pub fn execute_command(
658 &self,
659 host: &mut impl Host,
660 id: u32,
661 input: &Value,
662 ) -> Result<ActionResult, ActionError> {
663 validate_registry(host.actions())?;
664 let name = host
665 .actions()
666 .iter()
667 .find(|a| a.command_id == Some(id))
668 .map(|a| a.name)
669 .ok_or_else(|| {
670 ActionError::new(ErrorCode::NotFound, "GUI command is not registered")
671 })?;
672 self.execute_local(host, name, input)
673 }
674 pub fn batch(
676 &self,
677 host: &mut impl Host,
678 calls: &[BatchCall],
679 atomic: bool,
680 stop_on_error: bool,
681 ) -> Result<BatchResult, ActionError> {
682 self.check_enabled()?;
683 if calls.is_empty() || calls.len() > 128 {
684 return Err(ActionError::new(
685 ErrorCode::InvalidInput,
686 "A batch must contain 1 to 128 calls",
687 ));
688 }
689 let start_revision = host.revision();
690 let mut prepared = Vec::new();
691 if atomic {
692 let mut domain = None;
693 for call in calls {
694 let action = self.find(host, &call.action)?;
695 let Some(next) = action.transaction else {
696 return Err(ActionError::new(
697 ErrorCode::Unsupported,
698 "An action does not support atomic batches",
699 )
700 .with_details(Value::object([("action", Value::string(&call.action))])));
701 };
702 if domain.is_some_and(|d| d != next) || !host.supports_transaction(next) {
703 return Err(ActionError::new(
704 ErrorCode::Unsupported,
705 "The batch has no shared supported transaction domain",
706 ));
707 }
708 domain = Some(next);
709 let context = ActionContext {
710 expected_revision: call.expected_revision,
711 atomic: true,
712 local: false,
713 };
714 if matches!(host.access(&action), Access::Local) {
715 self.consent(host, &action, &call.input)?;
716 }
717 self.validate(host, &action, &call.input, &context, start_revision)?;
718 self.consent(host, &action, &call.input)?;
719 prepared.push(action);
720 }
721 host.begin_transaction(domain.unwrap_or(""))?;
722 }
723 let mut results = Vec::with_capacity(calls.len());
724 for (index, call) in calls.iter().enumerate() {
725 let result = if atomic {
726 let action = prepared[index];
727 let context = ActionContext {
728 expected_revision: call.expected_revision,
729 atomic: true,
730 local: false,
731 };
732 self.ready(host, &action)
733 .and_then(|()| self.invoke(host, &action, &call.input, &context))
734 } else {
735 self.execute(
736 host,
737 Invocation {
738 action: &call.action,
739 input: &call.input,
740 expected_revision: call.expected_revision,
741 },
742 )
743 };
744 let failed = result.is_err();
745 results.push(result);
746 if failed && atomic {
747 host.rollback_transaction().map_err(|e| {
748 ActionError::new(ErrorCode::Internal, "Atomic rollback failed")
749 .with_details(e.to_json())
750 })?;
751 return Ok(BatchResult {
752 results,
753 rolled_back: true,
754 revision: host.revision(),
755 });
756 }
757 if failed && stop_on_error {
758 break;
759 }
760 }
761 if atomic {
762 if let Err(error) = host.commit_transaction() {
763 host.rollback_transaction().map_err(|e| {
764 ActionError::new(ErrorCode::Internal, "Rollback after commit failure failed")
765 .with_details(e.to_json())
766 })?;
767 return Err(error);
768 }
769 }
770 Ok(BatchResult {
771 results,
772 rolled_back: false,
773 revision: host.revision(),
774 })
775 }
776 pub fn discover(
778 &self,
779 host: &impl Host,
780 query: &str,
781 after: Option<&str>,
782 limit: usize,
783 ) -> Result<Page, ActionError> {
784 self.check_enabled()?;
785 validate_registry(host.actions())?;
786 if limit == 0 || limit > 100 || query.len() > 256 {
787 return Err(ActionError::new(
788 ErrorCode::InvalidInput,
789 "Discovery limit is 1 to 100 and query is at most 256 bytes",
790 ));
791 }
792 let query = query.to_lowercase();
793 let mut actions: Vec<&ActionSpec> = host
794 .actions()
795 .iter()
796 .filter(|a| {
797 (query.is_empty()
798 || a.name.to_lowercase().contains(&query)
799 || a.description.to_lowercase().contains(&query))
800 && after.is_none_or(|cursor| a.name > cursor)
801 })
802 .collect();
803 actions.sort_by_key(|a| a.name);
804 let next_cursor = if actions.len() > limit {
805 Some(actions[limit - 1].name.into())
806 } else {
807 None
808 };
809 actions.truncate(limit);
810 Ok(Page {
811 actions: actions
812 .into_iter()
813 .map(|a| self.metadata(host, a, false))
814 .collect(),
815 next_cursor,
816 revision: host.revision(),
817 })
818 }
819 pub fn describe(&self, host: &impl Host, name: &str) -> Result<Value, ActionError> {
821 self.check_enabled()?;
822 let action = self.find(host, name)?;
823 Ok(self.metadata(host, &action, true))
824 }
825 fn metadata(&self, host: &impl Host, action: &ActionSpec, schemas: bool) -> Value {
826 let availability = self.ready(host, action);
827 let mut value = Value::object([
828 ("name", Value::string(action.name)),
829 ("description", Value::string(action.description)),
830 ("available", Value::Bool(availability.is_ok())),
831 (
832 "unavailableReason",
833 availability
834 .err()
835 .map(|e| e.to_json())
836 .unwrap_or(Value::Null),
837 ),
838 ("capabilities", Value::u64(action.capabilities)),
839 ("mutation", Value::Bool(action.mutation)),
840 ("revisionRequired", Value::Bool(action.revision_required)),
841 ("undo", Value::Bool(action.undo)),
842 (
843 "transaction",
844 action.transaction.map(Value::string).unwrap_or(Value::Null),
845 ),
846 ("cancellable", Value::Bool(action.cancellable)),
847 ("access", Value::string(host.access(action).as_str())),
848 (
849 "commandId",
850 action
851 .command_id
852 .map(|id| Value::u64(id as u64))
853 .unwrap_or(Value::Null),
854 ),
855 ]);
856 if schemas {
857 value.insert("inputSchema", action.input.to_json());
858 value.insert("outputSchema", action.output.to_json());
859 }
860 value
861 }
862}
863
864pub fn validate_registry(actions: &[ActionSpec]) -> Result<(), ActionError> {
866 if actions.len() > 4096 {
867 return Err(ActionError::new(
868 ErrorCode::LimitExceeded,
869 "Too many registered actions",
870 ));
871 }
872 let mut names = BTreeSet::new();
873 let mut commands = BTreeSet::new();
874 for action in actions.iter() {
875 if action.name.is_empty()
876 || action.name.len() > 128
877 || !action
878 .name
879 .bytes()
880 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
881 {
882 return Err(ActionError::new(
883 ErrorCode::Internal,
884 "Invalid registered action name",
885 ));
886 }
887 if !names.insert(action.name)
888 || action.command_id.is_some_and(|id| !commands.insert(id))
889 {
890 return Err(ActionError::new(
891 ErrorCode::Internal,
892 format!("Duplicate action or command: {}", action.name),
893 ));
894 }
895 if action.revision_required && !action.mutation {
896 return Err(ActionError::new(
897 ErrorCode::Internal,
898 "A read action cannot require a mutation revision",
899 ));
900 }
901 }
902 Ok(())
903}