Skip to main content

host_extensions/
executor_contract.rs

1//! Executor contract — canonical response types and error constants for
2//! `window.host.*` bridge calls.
3//!
4//! Host implementations (host-rs, dotli) use these definitions to ensure SPAs
5//! receive identical promise resolution values regardless of which host they
6//! run on.
7//!
8//! ## Response value conventions
9//!
10//! Most bridge calls resolve with **bare values** — a string, an integer, a
11//! boolean, or `null`.  Only `mediaGetUserMedia` resolves with a structured
12//! JSON object.  The types below document the exact JS-level value the
13//! promise resolves with.
14//!
15//! See also `docs/executor-contract.md` for behavioral rules that types cannot
16//! express (listener lifecycle, error semantics, identity rules).
17
18use serde::{Deserialize, Serialize};
19
20// ── Structured response types ───────────────────────────────────────────
21//
22// These are the only bridge calls that resolve with a JSON object.
23
24/// `mediaGetUserMedia({ audio, video })` — resolves with a JSON object
25/// containing the allocated local track IDs *before* the corresponding
26/// `mediaTrackReady` events fire.
27///
28/// A field is **absent** (not `null`) when the caller did not request that
29/// track kind.  Hosts MUST use `skip_serializing_if` (or equivalent) to
30/// omit unrequested fields rather than setting them to `null`.
31#[derive(Debug, Serialize)]
32#[serde(rename_all = "camelCase")]
33pub struct GetUserMediaResult {
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub audio_track_id: Option<u64>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub video_track_id: Option<u64>,
38}
39
40/// `mediaGetDisplayMedia()` — resolves with the allocated screen track ID.
41#[derive(Debug, Serialize)]
42#[serde(rename_all = "camelCase")]
43pub struct GetDisplayMediaResult {
44    pub screen_track_id: u64,
45}
46
47/// `meshStatus()` — resolves with the current mesh backend health and queue state.
48#[derive(Debug, Serialize, serde::Deserialize)]
49#[serde(rename_all = "camelCase")]
50pub struct MeshStatusResult {
51    pub health: String,
52    pub transport: String,
53    pub pending_publishes: u64,
54    pub pending_queries: u64,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub last_error: Option<String>,
57}
58
59/// `mesh.objects.put(path, dataBase64, policy?)` retention and preview policy.
60#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
61#[serde(rename_all = "camelCase")]
62pub struct MeshObjectPolicy {
63    /// Absolute expiry in host time. Hosts may internally derive this from TTL
64    /// before persistence, but app-facing behavior is always expressed as an
65    /// absolute host-clock deadline.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub expires_at_ms: Option<u64>,
68    /// Hint for host-managed previews, notifications, and restored plaintext
69    /// derivatives on compliant hosts.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub suppress_previews: Option<bool>,
72}
73
74#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
75#[serde(rename_all = "snake_case")]
76pub enum MeshObjectReadReason {
77    Expired,
78    NotFound,
79    PolicyDenied,
80}
81
82/// `mesh.objects.getResult(path)` and structured negative `meshReply` model.
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
84#[serde(rename_all = "camelCase")]
85pub struct MeshObjectResult {
86    pub data_base64: Option<String>,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub reason: Option<MeshObjectReadReason>,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub expires_at_ms: Option<u64>,
91}
92
93impl MeshObjectResult {
94    pub fn found(data_base64: String, expires_at_ms: Option<u64>) -> Self {
95        Self {
96            data_base64: Some(data_base64),
97            reason: None,
98            expires_at_ms,
99        }
100    }
101
102    pub fn unavailable(reason: MeshObjectReadReason, expires_at_ms: Option<u64>) -> Self {
103        Self {
104            data_base64: None,
105            reason: Some(reason),
106            expires_at_ms,
107        }
108    }
109
110    pub fn compat_data(self) -> Option<String> {
111        self.data_base64
112    }
113}
114
115#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
116#[serde(rename_all = "lowercase")]
117pub enum MeshControlMode {
118    Visible,
119    Encrypted,
120}
121
122/// Capability-scoped control envelope for mesh 1.2 private channels.
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
124#[serde(rename_all = "camelCase")]
125pub struct MeshControlEnvelope {
126    pub mode: MeshControlMode,
127    pub data_base64: String,
128}
129
130/// `mesh.private.objects.put(dataBase64, policy?)` opaque object reference.
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
132#[serde(rename_all = "camelCase")]
133pub struct MeshPrivateObjectRef {
134    pub handle: String,
135    pub capability: String,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub expires_at_ms: Option<u64>,
138}
139
140// ── Bare-value response documentation ───────────────────────────────────
141//
142// The following bridge calls resolve with bare (non-object) values.
143// No Rust struct is needed — the host serializes the value directly.
144//
145// | Method                  | Resolves with                | JS type   |
146// |-------------------------|------------------------------|-----------|
147// | `getAddress`            | SS58 address                 | `string`  |
148// | `sign`                  | `0x`-prefixed hex signature  | `string`  |
149// | `chainQuery`            | raw JSON-RPC result          | `any`     |
150// | `chainSubmit`           | transaction hash             | `string`  |
151// | `dataConnect`           | connection ID                | `number`  |
152// | `dataGetPeerId`         | wallet SS58 address          | `string`  |
153// | `mediaConnect`          | session ID                   | `number`  |
154// | `mediaAccept`           | session ID                   | `number`  |
155// |   (with `{ peer }`)    | session ID (group path)      | `number`  |
156// | `mediaGetPeerId`        | wallet SS58 address          | `string`  |
157// | `storageGet`            | stored value or `null`       | `string?` |
158// | `dataSend`              | `true`                       | `boolean` |
159// | `dataSendBytes`         | `true`                       | `boolean` |
160// | `dataClose`             | `true`                       | `boolean` |
161// | `dataStartListening`    | `true`                       | `boolean` |
162// | `storageSet`            | `true`                       | `boolean` |
163// | `storageRemove`         | `true`                       | `boolean` |
164// | `statementsWrite`       | `true`                       | `boolean` |
165// | `statementsSubscribe`   | `true`                       | `boolean` |
166// | `filesSave`             | `true`                       | `boolean` |
167// | `meshPublish`           | `true`                       | `boolean` |
168// | `meshSubscribe`         | `true`                       | `boolean` |
169// | `meshPutObject`         | `true`                       | `boolean` |
170// | `meshGetObject`         | stored object or `null`      | `string?` |
171// | `meshGetObjectResult`   | retention-aware read result  | `object`  |
172// | `meshRequest`           | request ID                   | `string`  |
173// | `meshRespond`           | `true`                       | `boolean` |
174// | `meshStatus`            | queue + health object        | `object`  |
175// | `meshPrivatePutObject`  | opaque ref + capability      | `object`  |
176// | `meshPrivateGetObject`  | capability-scoped read       | `object`  |
177// | `meshPrivateRequest`    | request ID                   | `string`  |
178// | `meshPrivateControlPublish` | `true`                  | `boolean` |
179// | `meshPrivateControlSubscribe` | `true`                | `boolean` |
180// | `meshPrivateReceiptPublish` | `true`                  | `boolean` |
181// | `meshPrivateReceiptSubscribe` | `true`                | `boolean` |
182// | `mediaClose`            | `true`                       | `boolean` |
183// | `mediaStartListening`   | `true`                       | `boolean` |
184// | `mediaAttachTrack`      | `true`                       | `boolean` |
185// | `mediaSetTrackEnabled`  | `true`                       | `boolean` |
186// | `mediaSignal`           | `true`                       | `boolean` |
187// | `requestWssPermission`  | `true`                       | `boolean` |
188// | `requestHttpPermission` | `true`                       | `boolean` |
189// | `mediaAddTrack`       | `true`                    | `boolean` |
190// | `mediaRemoveTrack`    | `true`                    | `boolean` |
191// | `mediaGroupConnect`   | group session ID (string) | `string`  |
192// | `mediaGroupAccept`    | session ID                | `number`  |
193// | `mediaGroupReject`    | `true`                    | `boolean` |
194
195// ── Error message constants ──────────────────────────────────────────────
196// Hosts MUST use these exact strings so SPAs can match on error text.
197
198pub const ERR_WALLET_NOT_CONNECTED: &str = "wallet not connected";
199pub const ERR_WALLET_NOT_ENABLED: &str = "wallet not enabled";
200pub const ERR_DATA_PERMISSION: &str = "data permission not granted";
201pub const ERR_MEDIA_PERMISSION: &str = "media permission not granted";
202pub const ERR_CAMERA_PERMISSION: &str = "camera permission not granted";
203pub const ERR_AUDIO_PERMISSION: &str = "audio permission not granted";
204pub const ERR_CHAIN_PERMISSION: &str = "chain permission not granted";
205pub const ERR_STATEMENTS_PERMISSION: &str = "statements permission not granted";
206pub const ERR_PENDING_SIGN: &str = "another signing request is pending";
207pub const ERR_PENDING_SUBMIT: &str = "another submit request is pending";
208pub const ERR_PENDING_CONNECT: &str = "another connect request is pending";
209pub const ERR_ADDRESS_REQUIRED: &str = "address is required";
210pub const ERR_PEER_ADDRESS_EMPTY: &str = "peer address cannot be empty";
211pub const ERR_FILENAME_REQUIRED: &str = "filename is required";
212pub const ERR_INVALID_ELEMENT_ID: &str = "invalid elementId";
213pub const ERR_SCREEN_PERMISSION: &str = "screen capture permission not granted";
214pub const ERR_SCREEN_DENIED_BY_OS: &str = "screen capture denied by operating system";
215pub const ERR_GROUP_TOO_LARGE: &str = "group exceeds maximum peer limit";
216pub const ERR_NO_PENDING_CALL_FOR_PEER: &str = "no pending incoming call from that peer";
217pub const ERR_GROUP_ID_REQUIRED: &str = "groupId is required for group calls";
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    fn json(v: &impl serde::Serialize) -> String {
224        serde_json::to_string(v).unwrap()
225    }
226
227    #[test]
228    fn get_user_media_result_both_tracks_json_shape() {
229        let r = GetUserMediaResult {
230            audio_track_id: Some(1),
231            video_track_id: Some(2),
232        };
233        assert_eq!(json(&r), r#"{"audioTrackId":1,"videoTrackId":2}"#);
234    }
235
236    #[test]
237    fn get_user_media_result_audio_only_omits_video() {
238        let r = GetUserMediaResult {
239            audio_track_id: Some(1),
240            video_track_id: None,
241        };
242        assert_eq!(json(&r), r#"{"audioTrackId":1}"#);
243    }
244
245    #[test]
246    fn get_user_media_result_video_only_omits_audio() {
247        let r = GetUserMediaResult {
248            audio_track_id: None,
249            video_track_id: Some(3),
250        };
251        assert_eq!(json(&r), r#"{"videoTrackId":3}"#);
252    }
253
254    #[test]
255    fn get_display_media_result_json_shape() {
256        let r = GetDisplayMediaResult {
257            screen_track_id: 42,
258        };
259        assert_eq!(json(&r), r#"{"screenTrackId":42}"#);
260    }
261
262    #[test]
263    fn mesh_status_result_json_shape() {
264        let result = MeshStatusResult {
265            health: "healthy".into(),
266            transport: "statement-store".into(),
267            pending_publishes: 1,
268            pending_queries: 2,
269            last_error: None,
270        };
271        assert_eq!(
272            json(&result),
273            r#"{"health":"healthy","transport":"statement-store","pendingPublishes":1,"pendingQueries":2}"#
274        );
275    }
276
277    #[test]
278    fn mesh_object_policy_json_shape() {
279        let policy = MeshObjectPolicy {
280            expires_at_ms: Some(1710000000000),
281            suppress_previews: Some(true),
282        };
283        assert_eq!(
284            json(&policy),
285            r#"{"expiresAtMs":1710000000000,"suppressPreviews":true}"#
286        );
287    }
288
289    #[test]
290    fn mesh_object_result_positive_json_shape() {
291        let result = MeshObjectResult::found("AQID".into(), Some(1710000000000));
292        assert_eq!(
293            json(&result),
294            r#"{"dataBase64":"AQID","expiresAtMs":1710000000000}"#
295        );
296    }
297
298    #[test]
299    fn mesh_object_result_negative_json_shape() {
300        let result =
301            MeshObjectResult::unavailable(MeshObjectReadReason::Expired, Some(1710000000000));
302        assert_eq!(
303            json(&result),
304            r#"{"dataBase64":null,"reason":"expired","expiresAtMs":1710000000000}"#
305        );
306    }
307
308    #[test]
309    fn mesh_control_envelope_json_shape() {
310        let envelope = MeshControlEnvelope {
311            mode: MeshControlMode::Encrypted,
312            data_base64: "AQID".into(),
313        };
314        assert_eq!(
315            json(&envelope),
316            r#"{"mode":"encrypted","dataBase64":"AQID"}"#
317        );
318    }
319
320    #[test]
321    fn mesh_private_object_ref_json_shape() {
322        let reference = MeshPrivateObjectRef {
323            handle: "mesh-private-handle-1".into(),
324            capability: "mesh-private-capability-1".into(),
325            expires_at_ms: Some(1710000000000),
326        };
327        assert_eq!(
328            json(&reference),
329            r#"{"handle":"mesh-private-handle-1","capability":"mesh-private-capability-1","expiresAtMs":1710000000000}"#
330        );
331    }
332
333    #[test]
334    fn error_constants_are_non_empty() {
335        let constants = [
336            ERR_WALLET_NOT_CONNECTED,
337            ERR_WALLET_NOT_ENABLED,
338            ERR_DATA_PERMISSION,
339            ERR_MEDIA_PERMISSION,
340            ERR_CAMERA_PERMISSION,
341            ERR_AUDIO_PERMISSION,
342            ERR_CHAIN_PERMISSION,
343            ERR_STATEMENTS_PERMISSION,
344            ERR_PENDING_SIGN,
345            ERR_PENDING_SUBMIT,
346            ERR_PENDING_CONNECT,
347            ERR_ADDRESS_REQUIRED,
348            ERR_PEER_ADDRESS_EMPTY,
349            ERR_FILENAME_REQUIRED,
350            ERR_INVALID_ELEMENT_ID,
351            ERR_SCREEN_PERMISSION,
352            ERR_SCREEN_DENIED_BY_OS,
353            ERR_GROUP_TOO_LARGE,
354            ERR_NO_PENDING_CALL_FOR_PEER,
355            ERR_GROUP_ID_REQUIRED,
356        ];
357        for c in &constants {
358            assert!(!c.is_empty());
359        }
360    }
361}