matrix_sdk_ffi/
widget.rs

1use std::sync::{Arc, Mutex};
2
3use language_tags::LanguageTag;
4use matrix_sdk::{
5    async_trait,
6    widget::{MessageLikeEventFilter, StateEventFilter},
7};
8use ruma::events::MessageLikeEventType;
9use tracing::error;
10
11use crate::{room::Room, RUNTIME};
12
13#[derive(uniffi::Record)]
14pub struct WidgetDriverAndHandle {
15    pub driver: Arc<WidgetDriver>,
16    pub handle: Arc<WidgetDriverHandle>,
17}
18
19#[matrix_sdk_ffi_macros::export]
20pub fn make_widget_driver(settings: WidgetSettings) -> Result<WidgetDriverAndHandle, ParseError> {
21    let (driver, handle) = matrix_sdk::widget::WidgetDriver::new(settings.try_into()?);
22    Ok(WidgetDriverAndHandle {
23        driver: Arc::new(WidgetDriver(Mutex::new(Some(driver)))),
24        handle: Arc::new(WidgetDriverHandle(handle)),
25    })
26}
27
28/// An object that handles all interactions of a widget living inside a webview
29/// or IFrame with the Matrix world.
30#[derive(uniffi::Object)]
31pub struct WidgetDriver(Mutex<Option<matrix_sdk::widget::WidgetDriver>>);
32
33#[matrix_sdk_ffi_macros::export]
34impl WidgetDriver {
35    pub async fn run(
36        &self,
37        room: Arc<Room>,
38        capabilities_provider: Box<dyn WidgetCapabilitiesProvider>,
39    ) {
40        let Some(driver) = self.0.lock().unwrap().take() else {
41            error!("Can't call run multiple times on a WidgetDriver");
42            return;
43        };
44
45        let capabilities_provider = CapabilitiesProviderWrap(capabilities_provider.into());
46        if let Err(()) = driver.run(room.inner.clone(), capabilities_provider).await {
47            // TODO
48        }
49    }
50}
51
52/// Information about a widget.
53#[derive(uniffi::Record, Clone)]
54pub struct WidgetSettings {
55    /// Widget's unique identifier.
56    pub widget_id: String,
57    /// Whether or not the widget should be initialized on load message
58    /// (`ContentLoad` message), or upon creation/attaching of the widget to
59    /// the SDK's state machine that drives the API.
60    pub init_after_content_load: bool,
61    /// This contains the url from the widget state event.
62    /// In this url placeholders can be used to pass information from the client
63    /// to the widget. Possible values are: `$widgetId`, `$parentUrl`,
64    /// `$userId`, `$lang`, `$fontScale`, `$analyticsID`.
65    ///
66    /// # Examples
67    ///
68    /// e.g `http://widget.domain?username=$userId`
69    /// will become: `http://widget.domain?username=@user_matrix_id:server.domain`.
70    raw_url: String,
71}
72
73impl TryFrom<WidgetSettings> for matrix_sdk::widget::WidgetSettings {
74    type Error = ParseError;
75
76    fn try_from(value: WidgetSettings) -> Result<Self, Self::Error> {
77        let WidgetSettings { widget_id, init_after_content_load, raw_url } = value;
78        Ok(matrix_sdk::widget::WidgetSettings::new(widget_id, init_after_content_load, &raw_url)?)
79    }
80}
81
82impl From<matrix_sdk::widget::WidgetSettings> for WidgetSettings {
83    fn from(value: matrix_sdk::widget::WidgetSettings) -> Self {
84        WidgetSettings {
85            widget_id: value.widget_id().to_owned(),
86            init_after_content_load: value.init_on_content_load(),
87            raw_url: value.raw_url().to_string(),
88        }
89    }
90}
91
92/// Create the actual url that can be used to setup the WebView or IFrame
93/// that contains the widget.
94///
95/// # Arguments
96/// * `widget_settings` - The widget settings to generate the url for.
97/// * `room` - A matrix room which is used to query the logged in username
98/// * `props` - Properties from the client that can be used by a widget to adapt
99///   to the client. e.g. language, font-scale...
100#[matrix_sdk_ffi_macros::export]
101pub async fn generate_webview_url(
102    widget_settings: WidgetSettings,
103    room: Arc<Room>,
104    props: ClientProperties,
105) -> Result<String, ParseError> {
106    Ok(matrix_sdk::widget::WidgetSettings::generate_webview_url(
107        &widget_settings.clone().try_into()?,
108        &room.inner,
109        props.into(),
110    )
111    .await
112    .map(|url| url.to_string())?)
113}
114
115/// Defines if a call is encrypted and which encryption system should be used.
116///
117/// This controls the url parameters: `perParticipantE2EE`, `password`.
118#[derive(uniffi::Enum, Clone)]
119pub enum EncryptionSystem {
120    /// Equivalent to the element call url parameter: `enableE2EE=false`
121    Unencrypted,
122    /// Equivalent to the element call url parameter:
123    /// `perParticipantE2EE=true`
124    PerParticipantKeys,
125    /// Equivalent to the element call url parameter:
126    /// `password={secret}`
127    SharedSecret {
128        /// The secret/password which is used in the url.
129        secret: String,
130    },
131}
132
133impl From<EncryptionSystem> for matrix_sdk::widget::EncryptionSystem {
134    fn from(value: EncryptionSystem) -> Self {
135        match value {
136            EncryptionSystem::Unencrypted => Self::Unencrypted,
137            EncryptionSystem::PerParticipantKeys => Self::PerParticipantKeys,
138            EncryptionSystem::SharedSecret { secret } => Self::SharedSecret { secret },
139        }
140    }
141}
142
143/// Properties to create a new virtual Element Call widget.
144#[derive(uniffi::Record, Clone)]
145pub struct VirtualElementCallWidgetOptions {
146    /// The url to the app.
147    ///
148    /// E.g. <https://call.element.io>, <https://call.element.dev>
149    pub element_call_url: String,
150
151    /// The widget id.
152    pub widget_id: String,
153
154    /// The url that is used as the target for the PostMessages sent
155    /// by the widget (to the client).
156    ///
157    /// For a web app client this is the client url. In case of using other
158    /// platforms the client most likely is setup up to listen to
159    /// postmessages in the same webview the widget is hosted. In this case
160    /// the `parent_url` is set to the url of the webview with the widget. Be
161    /// aware that this means that the widget will receive its own postmessage
162    /// messages. The `matrix-widget-api` (js) ignores those so this works but
163    /// it might break custom implementations.
164    ///
165    /// Defaults to `element_call_url` for the non-iframe (dedicated webview)
166    /// usecase.
167    pub parent_url: Option<String>,
168
169    /// Whether the branding header of Element call should be hidden.
170    ///
171    /// Default: `true`
172    pub hide_header: Option<bool>,
173
174    /// If set, the lobby will be skipped and the widget will join the
175    /// call on the `io.element.join` action.
176    ///
177    /// Default: `false`
178    pub preload: Option<bool>,
179
180    /// The font scale which will be used inside element call.
181    ///
182    /// Default: `1`
183    pub font_scale: Option<f64>,
184
185    /// Whether element call should prompt the user to open in the browser or
186    /// the app.
187    ///
188    /// Default: `false`
189    pub app_prompt: Option<bool>,
190
191    /// Don't show the lobby and join the call immediately.
192    ///
193    /// Default: `false`
194    pub skip_lobby: Option<bool>,
195
196    /// Make it not possible to get to the calls list in the webview.
197    ///
198    /// Default: `true`
199    pub confine_to_room: Option<bool>,
200
201    /// The font to use, to adapt to the system font.
202    pub font: Option<String>,
203
204    /// Can be used to pass a PostHog id to element call.
205    pub analytics_id: Option<String>,
206
207    /// The encryption system to use.
208    ///
209    /// Use `EncryptionSystem::Unencrypted` to disable encryption.
210    pub encryption: EncryptionSystem,
211}
212
213impl From<VirtualElementCallWidgetOptions> for matrix_sdk::widget::VirtualElementCallWidgetOptions {
214    fn from(value: VirtualElementCallWidgetOptions) -> Self {
215        Self {
216            element_call_url: value.element_call_url,
217            widget_id: value.widget_id,
218            parent_url: value.parent_url,
219            hide_header: value.hide_header,
220            preload: value.preload,
221            font_scale: value.font_scale,
222            app_prompt: value.app_prompt,
223            skip_lobby: value.skip_lobby,
224            confine_to_room: value.confine_to_room,
225            font: value.font,
226            analytics_id: value.analytics_id,
227            encryption: value.encryption.into(),
228        }
229    }
230}
231
232/// `WidgetSettings` are usually created from a state event.
233/// (currently unimplemented)
234///
235/// In some cases the client wants to create custom `WidgetSettings`
236/// for specific rooms based on other conditions.
237/// This function returns a `WidgetSettings` object which can be used
238/// to setup a widget using `run_client_widget_api`
239/// and to generate the correct url for the widget.
240///
241/// # Arguments
242///
243/// * `props` - A struct containing the configuration parameters for a element
244///   call widget.
245#[matrix_sdk_ffi_macros::export]
246pub fn new_virtual_element_call_widget(
247    props: VirtualElementCallWidgetOptions,
248) -> Result<WidgetSettings, ParseError> {
249    Ok(matrix_sdk::widget::WidgetSettings::new_virtual_element_call_widget(props.into())
250        .map(|w| w.into())?)
251}
252
253/// The Capabilities required to run a element call widget.
254///
255/// This is intended to be used in combination with: `acquire_capabilities` of
256/// the `CapabilitiesProvider`.
257///
258/// `acquire_capabilities` can simply return the `WidgetCapabilities` from this
259/// function. Even if there are non intersecting permissions to what the widget
260/// requested.
261///
262/// Editing and extending the capabilities from this function is also possible,
263/// but should only be done as temporal workarounds until this function is
264/// adjusted
265#[matrix_sdk_ffi_macros::export]
266pub fn get_element_call_required_permissions(
267    own_user_id: String,
268    own_device_id: String,
269) -> WidgetCapabilities {
270    use ruma::events::StateEventType;
271
272    let read_send = vec![
273        // To read and send rageshake requests from other room members
274        WidgetEventFilter::MessageLikeWithType {
275            event_type: "org.matrix.rageshake_request".to_owned(),
276        },
277        // To read and send encryption keys
278        // TODO change this to the appropriate to-device version once ready
279        WidgetEventFilter::MessageLikeWithType {
280            event_type: "io.element.call.encryption_keys".to_owned(),
281        },
282        // To read and send custom EC reactions. They are different to normal `m.reaction`
283        // because they can be send multiple times to the same event.
284        WidgetEventFilter::MessageLikeWithType {
285            event_type: "io.element.call.reaction".to_owned(),
286        },
287        // This allows send raise hand reactions.
288        WidgetEventFilter::MessageLikeWithType {
289            event_type: MessageLikeEventType::Reaction.to_string(),
290        },
291        // This allows to detect if someone does not raise their hand anymore.
292        WidgetEventFilter::MessageLikeWithType {
293            event_type: MessageLikeEventType::RoomRedaction.to_string(),
294        },
295    ];
296
297    WidgetCapabilities {
298        read: vec![
299            // To compute the current state of the matrixRTC session.
300            WidgetEventFilter::StateWithType { event_type: StateEventType::CallMember.to_string() },
301            // To detect leaving/kicked room members during a call.
302            WidgetEventFilter::StateWithType { event_type: StateEventType::RoomMember.to_string() },
303            // To decide whether to encrypt the call streams based on the room encryption setting.
304            WidgetEventFilter::StateWithType {
305                event_type: StateEventType::RoomEncryption.to_string(),
306            },
307            // This allows the widget to check the room version, so it can know about
308            // version-specific auth rules (namely MSC3779).
309            WidgetEventFilter::StateWithType { event_type: StateEventType::RoomCreate.to_string() },
310        ]
311        .into_iter()
312        .chain(read_send.clone())
313        .collect(),
314        send: vec![
315            // To send the call participation state event (main MatrixRTC event).
316            // This is required for legacy state events (using only one event for all devices with
317            // a membership array). TODO: remove once legacy call member events are
318            // sunset.
319            WidgetEventFilter::StateWithTypeAndStateKey {
320                event_type: StateEventType::CallMember.to_string(),
321                state_key: own_user_id.clone(),
322            },
323            // `delayed_event`` version for session memberhips
324            // [MSC3779](https://github.com/matrix-org/matrix-spec-proposals/pull/3779), with no leading underscore.
325            WidgetEventFilter::StateWithTypeAndStateKey {
326                event_type: StateEventType::CallMember.to_string(),
327                state_key: format!("{own_user_id}_{own_device_id}"),
328            },
329            // The same as above but with an underscore.
330            // To work around the issue that state events starting with `@` have to be matrix id's
331            // but we use mxId+deviceId.
332            WidgetEventFilter::StateWithTypeAndStateKey {
333                event_type: StateEventType::CallMember.to_string(),
334                state_key: format!("_{own_user_id}_{own_device_id}"),
335            },
336        ]
337        .into_iter()
338        .chain(read_send)
339        .collect(),
340        requires_client: true,
341        update_delayed_event: true,
342        send_delayed_event: true,
343    }
344}
345
346#[derive(uniffi::Record)]
347pub struct ClientProperties {
348    /// The client_id provides the widget with the option to behave differently
349    /// for different clients. e.g org.example.ios.
350    client_id: String,
351    /// The language tag the client is set to e.g. en-us. (Undefined and invalid
352    /// becomes: `en-US`)
353    language_tag: Option<String>,
354    /// A string describing the theme (dark, light) or org.example.dark.
355    /// (default: `light`)
356    theme: Option<String>,
357}
358
359impl From<ClientProperties> for matrix_sdk::widget::ClientProperties {
360    fn from(value: ClientProperties) -> Self {
361        let ClientProperties { client_id, language_tag, theme } = value;
362        let language_tag = language_tag.and_then(|l| LanguageTag::parse(&l).ok());
363        Self::new(&client_id, language_tag, theme)
364    }
365}
366
367/// A handle that encapsulates the communication between a widget driver and the
368/// corresponding widget (inside a webview or IFrame).
369#[derive(uniffi::Object)]
370pub struct WidgetDriverHandle(matrix_sdk::widget::WidgetDriverHandle);
371
372#[matrix_sdk_ffi_macros::export]
373impl WidgetDriverHandle {
374    /// Receive a message from the widget driver.
375    ///
376    /// The message must be passed on to the widget.
377    ///
378    /// Returns `None` if the widget driver is no longer running.
379    pub async fn recv(&self) -> Option<String> {
380        self.0.recv().await
381    }
382
383    //// Send a message from the widget to the widget driver.
384    ///
385    /// Returns `false` if the widget driver is no longer running.
386    pub async fn send(&self, msg: String) -> bool {
387        self.0.send(msg).await
388    }
389}
390
391/// Capabilities that a widget can request from a client.
392#[derive(uniffi::Record)]
393pub struct WidgetCapabilities {
394    /// Types of the messages that a widget wants to be able to fetch.
395    pub read: Vec<WidgetEventFilter>,
396    /// Types of the messages that a widget wants to be able to send.
397    pub send: Vec<WidgetEventFilter>,
398    /// If this capability is requested by the widget, it can not operate
399    /// separately from the matrix client.
400    ///
401    /// This means clients should not offer to open the widget in a separate
402    /// browser/tab/webview that is not connected to the postmessage widget-api.
403    pub requires_client: bool,
404    /// This allows the widget to ask the client to update delayed events.
405    pub update_delayed_event: bool,
406    /// This allows the widget to send events with a delay.
407    pub send_delayed_event: bool,
408}
409
410impl From<WidgetCapabilities> for matrix_sdk::widget::Capabilities {
411    fn from(value: WidgetCapabilities) -> Self {
412        Self {
413            read: value.read.into_iter().map(Into::into).collect(),
414            send: value.send.into_iter().map(Into::into).collect(),
415            requires_client: value.requires_client,
416            update_delayed_event: value.update_delayed_event,
417            send_delayed_event: value.send_delayed_event,
418        }
419    }
420}
421
422impl From<matrix_sdk::widget::Capabilities> for WidgetCapabilities {
423    fn from(value: matrix_sdk::widget::Capabilities) -> Self {
424        Self {
425            read: value.read.into_iter().map(Into::into).collect(),
426            send: value.send.into_iter().map(Into::into).collect(),
427            requires_client: value.requires_client,
428            update_delayed_event: value.update_delayed_event,
429            send_delayed_event: value.send_delayed_event,
430        }
431    }
432}
433
434/// Different kinds of filters that could be applied to the timeline events.
435#[derive(uniffi::Enum, Clone)]
436pub enum WidgetEventFilter {
437    /// Matches message-like events with the given `type`.
438    MessageLikeWithType { event_type: String },
439    /// Matches `m.room.message` events with the given `msgtype`.
440    RoomMessageWithMsgtype { msgtype: String },
441    /// Matches state events with the given `type`, regardless of `state_key`.
442    StateWithType { event_type: String },
443    /// Matches state events with the given `type` and `state_key`.
444    StateWithTypeAndStateKey { event_type: String, state_key: String },
445}
446
447impl From<WidgetEventFilter> for matrix_sdk::widget::EventFilter {
448    fn from(value: WidgetEventFilter) -> Self {
449        match value {
450            WidgetEventFilter::MessageLikeWithType { event_type } => {
451                Self::MessageLike(MessageLikeEventFilter::WithType(event_type.into()))
452            }
453            WidgetEventFilter::RoomMessageWithMsgtype { msgtype } => {
454                Self::MessageLike(MessageLikeEventFilter::RoomMessageWithMsgtype(msgtype))
455            }
456            WidgetEventFilter::StateWithType { event_type } => {
457                Self::State(StateEventFilter::WithType(event_type.into()))
458            }
459            WidgetEventFilter::StateWithTypeAndStateKey { event_type, state_key } => {
460                Self::State(StateEventFilter::WithTypeAndStateKey(event_type.into(), state_key))
461            }
462        }
463    }
464}
465
466impl From<matrix_sdk::widget::EventFilter> for WidgetEventFilter {
467    fn from(value: matrix_sdk::widget::EventFilter) -> Self {
468        use matrix_sdk::widget::EventFilter as F;
469
470        match value {
471            F::MessageLike(MessageLikeEventFilter::WithType(event_type)) => {
472                Self::MessageLikeWithType { event_type: event_type.to_string() }
473            }
474            F::MessageLike(MessageLikeEventFilter::RoomMessageWithMsgtype(msgtype)) => {
475                Self::RoomMessageWithMsgtype { msgtype }
476            }
477            F::State(StateEventFilter::WithType(event_type)) => {
478                Self::StateWithType { event_type: event_type.to_string() }
479            }
480            F::State(StateEventFilter::WithTypeAndStateKey(event_type, state_key)) => {
481                Self::StateWithTypeAndStateKey { event_type: event_type.to_string(), state_key }
482            }
483        }
484    }
485}
486
487#[matrix_sdk_ffi_macros::export(callback_interface)]
488pub trait WidgetCapabilitiesProvider: Send + Sync {
489    fn acquire_capabilities(&self, capabilities: WidgetCapabilities) -> WidgetCapabilities;
490}
491
492struct CapabilitiesProviderWrap(Arc<dyn WidgetCapabilitiesProvider>);
493
494#[async_trait]
495impl matrix_sdk::widget::CapabilitiesProvider for CapabilitiesProviderWrap {
496    async fn acquire_capabilities(
497        &self,
498        capabilities: matrix_sdk::widget::Capabilities,
499    ) -> matrix_sdk::widget::Capabilities {
500        let this = self.0.clone();
501        // This could require a prompt to the user. Ideally the callback
502        // interface would just be async, but that's not supported yet so use
503        // one of tokio's blocking task threads instead.
504        RUNTIME
505            .spawn_blocking(move || this.acquire_capabilities(capabilities.into()).into())
506            .await
507            // propagate panics from the blocking task
508            .unwrap()
509    }
510}
511
512#[derive(Debug, thiserror::Error, uniffi::Error)]
513#[uniffi(flat_error)]
514pub enum ParseError {
515    #[error("empty host")]
516    EmptyHost,
517    #[error("invalid international domain name")]
518    IdnaError,
519    #[error("invalid port number")]
520    InvalidPort,
521    #[error("invalid IPv4 address")]
522    InvalidIpv4Address,
523    #[error("invalid IPv6 address")]
524    InvalidIpv6Address,
525    #[error("invalid domain character")]
526    InvalidDomainCharacter,
527    #[error("relative URL without a base")]
528    RelativeUrlWithoutBase,
529    #[error("relative URL with a cannot-be-a-base base")]
530    RelativeUrlWithCannotBeABaseBase,
531    #[error("a cannot-be-a-base URL doesn’t have a host to set")]
532    SetHostOnCannotBeABaseUrl,
533    #[error("URLs more than 4 GB are not supported")]
534    Overflow,
535    #[error("unknown URL parsing error")]
536    Other,
537}
538
539impl From<url::ParseError> for ParseError {
540    fn from(value: url::ParseError) -> Self {
541        match value {
542            url::ParseError::EmptyHost => Self::EmptyHost,
543            url::ParseError::IdnaError => Self::IdnaError,
544            url::ParseError::InvalidPort => Self::InvalidPort,
545            url::ParseError::InvalidIpv4Address => Self::InvalidIpv4Address,
546            url::ParseError::InvalidIpv6Address => Self::InvalidIpv6Address,
547            url::ParseError::InvalidDomainCharacter => Self::InvalidDomainCharacter,
548            url::ParseError::RelativeUrlWithoutBase => Self::RelativeUrlWithoutBase,
549            url::ParseError::RelativeUrlWithCannotBeABaseBase => {
550                Self::RelativeUrlWithCannotBeABaseBase
551            }
552            url::ParseError::SetHostOnCannotBeABaseUrl => Self::SetHostOnCannotBeABaseUrl,
553            url::ParseError::Overflow => Self::Overflow,
554            _ => Self::Other,
555        }
556    }
557}
558
559#[cfg(test)]
560mod tests {
561    use matrix_sdk::widget::Capabilities;
562
563    use super::get_element_call_required_permissions;
564
565    #[test]
566    fn element_call_permissions_are_correct() {
567        let widget_cap = get_element_call_required_permissions(
568            "@my_user:my_domain.org".to_owned(),
569            "ABCDEFGHI".to_owned(),
570        );
571
572        // We test two things:
573
574        // Converting the WidgetCapability (ffi struct) to Capabilities (rust sdk
575        // struct)
576        let cap = Into::<Capabilities>::into(widget_cap);
577        // Converting Capabilities (rust sdk struct) to a json list.
578        let cap_json_repr = serde_json::to_string(&cap).unwrap();
579
580        // Converting to a Vec<String> allows to check if the required elements exist
581        // without breaking the test each time the order of permissions might
582        // change.
583        let permission_array: Vec<String> = serde_json::from_str(&cap_json_repr).unwrap();
584
585        let cap_assert = |capability: &str| {
586            assert!(
587                permission_array.contains(&capability.to_owned()),
588                "The \"{}\" capability was missing from the element call capability list.",
589                capability
590            );
591        };
592
593        cap_assert("io.element.requires_client");
594        cap_assert("org.matrix.msc4157.update_delayed_event");
595        cap_assert("org.matrix.msc4157.send.delayed_event");
596        cap_assert("org.matrix.msc2762.receive.state_event:org.matrix.msc3401.call.member");
597        cap_assert("org.matrix.msc2762.receive.state_event:m.room.member");
598        cap_assert("org.matrix.msc2762.receive.state_event:m.room.encryption");
599        cap_assert("org.matrix.msc2762.receive.event:org.matrix.rageshake_request");
600        cap_assert("org.matrix.msc2762.receive.event:io.element.call.encryption_keys");
601        cap_assert("org.matrix.msc2762.receive.state_event:m.room.create");
602        cap_assert("org.matrix.msc2762.send.state_event:org.matrix.msc3401.call.member#@my_user:my_domain.org");
603        cap_assert("org.matrix.msc2762.send.state_event:org.matrix.msc3401.call.member#@my_user:my_domain.org_ABCDEFGHI");
604        cap_assert("org.matrix.msc2762.send.state_event:org.matrix.msc3401.call.member#_@my_user:my_domain.org_ABCDEFGHI");
605        cap_assert("org.matrix.msc2762.send.event:org.matrix.rageshake_request");
606        cap_assert("org.matrix.msc2762.send.event:io.element.call.encryption_keys");
607    }
608}