1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400
// Copyright 2023 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// This module contains ALL the Element Call related code (minus the FFI
// bindings for this file). Hence all other files in the rust sdk contain code
// that is relevant for all widgets. This makes it simple to rip out Element
// Call related pieces.
// TODO: The goal is to have not any Element Call specific code
// in the rust sdk. Find a better solution for this.
use serde::Serialize;
use url::Url;
use super::{url_params, WidgetSettings};
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ElementCallParams {
user_id: String,
room_id: String,
widget_id: String,
display_name: String,
lang: String,
theme: String,
client_id: String,
device_id: String,
base_url: String,
// Non template parameters
parent_url: String,
skip_lobby: bool,
confine_to_room: bool,
app_prompt: bool,
hide_header: bool,
preload: bool,
analytics_id: Option<String>,
font_scale: Option<f64>,
font: Option<String>,
#[serde(rename = "perParticipantE2EE")]
per_participant_e2ee: bool,
password: Option<String>,
}
/// Defines if a call is encrypted and which encryption system should be used.
///
/// This controls the url parameters: `perParticipantE2EE`, `password`.
#[derive(Debug, PartialEq)]
pub enum EncryptionSystem {
/// Equivalent to the element call url parameter: `perParticipantE2EE=false`
/// and no password.
Unencrypted,
/// Equivalent to the element call url parameters:
/// `perParticipantE2EE=true`
PerParticipantKeys,
/// Equivalent to the element call url parameters:
/// `password={secret}`
SharedSecret {
/// The secret/password which is used in the url.
secret: String,
},
}
/// Properties to create a new virtual Element Call widget.
#[derive(Debug)]
pub struct VirtualElementCallWidgetOptions {
/// The url to the app.
///
/// E.g. <https://call.element.io>, <https://call.element.dev>
pub element_call_url: String,
/// The widget id.
pub widget_id: String,
/// The url that is used as the target for the PostMessages sent
/// by the widget (to the client).
///
/// For a web app client this is the client url. In case of using other
/// platforms the client most likely is setup up to listen to
/// postmessages in the same webview the widget is hosted. In this case
/// the `parent_url` is set to the url of the webview with the widget. Be
/// aware that this means that the widget will receive its own postmessage
/// messages. The `matrix-widget-api` (js) ignores those so this works but
/// it might break custom implementations.
///
/// Defaults to `element_call_url` for the non-iframe (dedicated webview)
/// usecase.
pub parent_url: Option<String>,
/// Whether the branding header of Element call should be hidden.
///
/// Default: `true`
pub hide_header: Option<bool>,
/// If set, the lobby will be skipped and the widget will join the
/// call on the `io.element.join` action.
///
/// Default: `false`
pub preload: Option<bool>,
/// The font scale which will be used inside element call.
///
/// Default: `1`
pub font_scale: Option<f64>,
/// Whether element call should prompt the user to open in the browser or
/// the app.
///
/// Default: `false`
pub app_prompt: Option<bool>,
/// Don't show the lobby and join the call immediately.
///
/// Default: `false`
pub skip_lobby: Option<bool>,
/// Make it not possible to get to the calls list in the webview.
///
/// Default: `true`
pub confine_to_room: Option<bool>,
/// The font to use, to adapt to the system font.
pub font: Option<String>,
/// Can be used to pass a PostHog id to element call.
pub analytics_id: Option<String>,
/// The encryption system to use.
///
/// Use `EncryptionSystem::Unencrypted` to disable encryption.
pub encryption: EncryptionSystem,
}
impl WidgetSettings {
/// `WidgetSettings` are usually created from a state event.
/// (currently unimplemented)
///
/// In some cases the client wants to create custom `WidgetSettings`
/// for specific rooms based on other conditions.
/// This function returns a `WidgetSettings` object which can be used
/// to setup a widget using `run_client_widget_api`
/// and to generate the correct url for the widget.
///
/// # Arguments
///
/// * `props` - A struct containing the configuration parameters for a
/// element call widget.
pub fn new_virtual_element_call_widget(
props: VirtualElementCallWidgetOptions,
) -> Result<Self, url::ParseError> {
let mut raw_url: Url = Url::parse(&format!("{}/room", props.element_call_url))?;
let query_params = ElementCallParams {
user_id: url_params::USER_ID.to_owned(),
room_id: url_params::ROOM_ID.to_owned(),
widget_id: url_params::WIDGET_ID.to_owned(),
display_name: url_params::DISPLAY_NAME.to_owned(),
lang: url_params::LANGUAGE.to_owned(),
theme: url_params::CLIENT_THEME.to_owned(),
client_id: url_params::CLIENT_ID.to_owned(),
device_id: url_params::DEVICE_ID.to_owned(),
base_url: url_params::HOMESERVER_URL.to_owned(),
parent_url: props.parent_url.unwrap_or(props.element_call_url.clone()),
skip_lobby: props.skip_lobby.unwrap_or(false),
confine_to_room: props.confine_to_room.unwrap_or(true),
app_prompt: props.app_prompt.unwrap_or(false),
hide_header: props.hide_header.unwrap_or(true),
preload: props.preload.unwrap_or(false),
analytics_id: props.analytics_id,
font_scale: props.font_scale,
font: props.font,
per_participant_e2ee: props.encryption == EncryptionSystem::PerParticipantKeys,
password: match props.encryption {
EncryptionSystem::SharedSecret { secret } => Some(secret),
_ => None,
},
};
let query =
serde_html_form::to_string(query_params).map_err(|_| url::ParseError::Overflow)?;
// Revert the encoding for the template parameters. So we can have a unified
// replace logic.
let query = query.replace("%24", "$");
// All the params will be set inside the fragment (to keep the traffic to the
// server minimal and most importantly don't send the passwords).
raw_url.set_fragment(Some(&format!("?{}", query)));
// for EC we always want init on content load to be true.
Ok(Self { widget_id: props.widget_id, init_on_content_load: true, raw_url })
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use ruma::api::client::profile::get_profile;
use url::Url;
use crate::widget::{ClientProperties, WidgetSettings};
const WIDGET_ID: &str = "1/@#w23";
fn get_widget_settings(encryption: Option<EncryptionSystem>) -> WidgetSettings {
WidgetSettings::new_virtual_element_call_widget(VirtualElementCallWidgetOptions {
element_call_url: "https://call.element.io".to_owned(),
widget_id: WIDGET_ID.to_owned(),
parent_url: None,
hide_header: Some(true),
preload: Some(true),
font_scale: None,
app_prompt: Some(true),
skip_lobby: Some(false),
confine_to_room: Some(true),
font: None,
analytics_id: None,
encryption: encryption.unwrap_or(EncryptionSystem::PerParticipantKeys),
})
.expect("could not parse virtual element call widget")
}
trait FragmentQuery {
fn fragment_query(&self) -> Option<&str>;
}
impl FragmentQuery for Url {
fn fragment_query(&self) -> Option<&str> {
Some(self.fragment()?.split_once('?')?.1)
}
}
// Convert query strings to BTreeSet so that we can compare the urls independent
// of the order of the params.
type QuerySet = BTreeSet<(String, String)>;
use serde_html_form::from_str;
use super::{EncryptionSystem, VirtualElementCallWidgetOptions};
fn get_query_sets(url: &Url) -> Option<(QuerySet, QuerySet)> {
let fq = from_str::<QuerySet>(url.fragment_query().unwrap_or_default()).ok()?;
let q = from_str::<QuerySet>(url.query().unwrap_or_default()).ok()?;
Some((q, fq))
}
#[test]
fn new_virtual_element_call_widget_base_url() {
let widget_settings = get_widget_settings(None);
assert_eq!(widget_settings.base_url().unwrap().as_str(), "https://call.element.io/");
}
#[test]
fn new_virtual_element_call_widget_raw_url() {
const CONVERTED_URL: &str = "
https://call.element.io/room#\
?userId=$matrix_user_id\
&roomId=$matrix_room_id\
&widgetId=$matrix_widget_id\
&displayName=$matrix_display_name\
&lang=$org.matrix.msc2873.client_language\
&theme=$org.matrix.msc2873.client_theme\
&clientId=$org.matrix.msc2873.client_id\
&deviceId=$org.matrix.msc2873.matrix_device_id\
&baseUrl=$org.matrix.msc4039.matrix_base_url\
&parentUrl=https%3A%2F%2Fcall.element.io\
&skipLobby=false\
&confineToRoom=true\
&appPrompt=true\
&hideHeader=true\
&preload=true\
&perParticipantE2EE=true\
";
let mut url = get_widget_settings(None).raw_url().clone();
let mut gen = Url::parse(CONVERTED_URL).unwrap();
assert_eq!(get_query_sets(&url).unwrap(), get_query_sets(&gen).unwrap());
url.set_fragment(None);
url.set_query(None);
gen.set_fragment(None);
gen.set_query(None);
assert_eq!(url, gen);
}
#[test]
fn new_virtual_element_call_widget_id() {
assert_eq!(get_widget_settings(None).widget_id(), WIDGET_ID);
}
fn build_url_from_widget_settings(settings: WidgetSettings) -> String {
settings
._generate_webview_url(
get_profile::v3::Response::new(Some("some-url".into()), Some("hello".into())),
"@test:user.org".try_into().unwrap(),
"!room_id:room.org".try_into().unwrap(),
"ABCDEFG".into(),
"https://client-matrix.server.org".try_into().unwrap(),
ClientProperties::new(
"io.my_matrix.client",
Some(language_tags::LanguageTag::parse("en-us").unwrap()),
Some("light".into()),
),
)
.unwrap()
.to_string()
}
#[test]
fn new_virtual_element_call_widget_webview_url() {
const CONVERTED_URL: &str = "
https://call.element.io/room#\
?parentUrl=https%3A%2F%2Fcall.element.io\
&widgetId=1/@#w23\
&userId=%40test%3Auser.org&deviceId=ABCDEFG\
&roomId=%21room_id%3Aroom.org\
&lang=en-US&theme=light\
&baseUrl=https%3A%2F%2Fclient-matrix.server.org%2F\
&hideHeader=true\
&preload=true\
&skipLobby=false\
&confineToRoom=true\
&displayName=hello\
&appPrompt=true\
&clientId=io.my_matrix.client\
&perParticipantE2EE=true\
";
let gen = build_url_from_widget_settings(get_widget_settings(None));
let mut url = Url::parse(&gen).unwrap();
let mut gen = Url::parse(CONVERTED_URL).unwrap();
assert_eq!(get_query_sets(&url).unwrap(), get_query_sets(&gen).unwrap());
url.set_fragment(None);
url.set_query(None);
gen.set_fragment(None);
gen.set_query(None);
assert_eq!(url, gen);
}
#[test]
fn password_url_props_from_widget_settings() {
{
// PerParticipantKeys
let url = build_url_from_widget_settings(get_widget_settings(Some(
EncryptionSystem::PerParticipantKeys,
)));
let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
let expected_elements = [("perParticipantE2EE".to_owned(), "true".to_owned())];
for e in expected_elements {
assert!(
query_set.contains(&e),
"The query elements: \n{:?}\nDid not contain: \n{:?}",
query_set,
e
);
}
}
{
// Unencrypted
let url = build_url_from_widget_settings(get_widget_settings(Some(
EncryptionSystem::Unencrypted,
)));
let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
let expected_elements = ("perParticipantE2EE".to_owned(), "false".to_owned());
assert!(
query_set.contains(&expected_elements),
"The url query elements for an unencrypted call: \n{:?}\nDid not contain: \n{:?}",
query_set,
expected_elements
);
}
{
// SharedSecret
let url = build_url_from_widget_settings(get_widget_settings(Some(
EncryptionSystem::SharedSecret { secret: "this_surely_is_save".to_owned() },
)));
let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
let expected_elements = [("password".to_owned(), "this_surely_is_save".to_owned())];
for e in expected_elements {
assert!(
query_set.contains(&e),
"The query elements: \n{:?}\nDid not contain: \n{:?}",
query_set,
e
);
}
}
}
}