nimbus_cli/output/
deeplink.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5use anyhow::{anyhow, Result};
6use percent_encoding::{AsciiSet, CONTROLS};
7
8use crate::protocol::StartAppProtocol;
9use crate::{AppOpenArgs, LaunchableApp};
10
11impl LaunchableApp {
12    pub(crate) fn copy_to_clipboard(
13        &self,
14        app_protocol: &StartAppProtocol,
15        open: &AppOpenArgs,
16    ) -> Result<usize> {
17        let url = self.longform_url(app_protocol, open)?;
18        let len = url.len();
19        if let Err(e) = set_clipboard(url) {
20            anyhow::bail!("Can't copy URL to clipboard: {}", e)
21        };
22
23        Ok(len)
24    }
25
26    pub(crate) fn longform_url(
27        &self,
28        app_protocol: &StartAppProtocol,
29        open: &AppOpenArgs,
30    ) -> Result<String> {
31        let deeplink = match (&open.deeplink, self.app_opening_deeplink()) {
32            (Some(deeplink), _) => deeplink.to_owned(),
33            (_, Some(deeplink)) => join_query(deeplink, "--nimbus-cli&--is-launcher"),
34            _ => anyhow::bail!("A deeplink must be provided"),
35        };
36
37        let url = longform_deeplink_url(deeplink.as_str(), app_protocol)?;
38
39        self.prepend_scheme(url.as_str())
40    }
41
42    fn app_opening_deeplink(&self) -> Option<&str> {
43        match self {
44            Self::Android { open_deeplink, .. } => open_deeplink.as_deref(),
45            Self::Ios { .. } => Some("noop"),
46        }
47    }
48
49    pub(crate) fn deeplink(&self, open: &AppOpenArgs) -> Result<Option<String>> {
50        let deeplink = &open.deeplink;
51        if deeplink.is_none() {
52            return Ok(None);
53        }
54        let deeplink = self.prepend_scheme(deeplink.as_ref().unwrap())?;
55        Ok(Some(deeplink))
56    }
57
58    fn prepend_scheme(&self, deeplink: &str) -> Result<String> {
59        Ok(if deeplink.contains("://") {
60            deeplink.to_string()
61        } else {
62            let scheme = self.mandatory_scheme()?;
63            format!("{scheme}://{deeplink}")
64        })
65    }
66
67    fn mandatory_scheme(&self) -> Result<&str> {
68        match self {
69            Self::Android { scheme, .. } | Self::Ios { scheme, .. } => scheme
70                .as_deref()
71                .ok_or_else(|| anyhow!("A scheme is not defined for this app")),
72        }
73    }
74}
75
76// The following are the special query percent encode set.
77// https://url.spec.whatwg.org/#query-percent-encode-set
78const QUERY: &AsciiSet = &CONTROLS
79    .add(b' ')
80    .add(b'"')
81    .add(b'<')
82    .add(b'>')
83    .add(b'#')
84    .add(b'\'')
85    // Additionally, we've added '{' and '}' to make  sure iOS simctl works with it.
86    .add(b'{')
87    .add(b'}')
88    // Then some belt and braces: we're quoting a single query attribute value.
89    .add(b':')
90    .add(b'/')
91    .add(b'?')
92    .add(b'&');
93
94/// Construct a URL from the deeplink and the protocol object.
95pub(crate) fn longform_deeplink_url(
96    deeplink: &str,
97    app_protocol: &StartAppProtocol,
98) -> Result<String> {
99    let StartAppProtocol {
100        reset_db,
101        experiments,
102        log_state,
103    } = app_protocol;
104    if !reset_db && experiments.is_none() && !log_state {
105        return Ok(deeplink.to_string());
106    }
107
108    let mut parts: Vec<_> = Default::default();
109    if !deeplink.contains("--nimbus-cli") {
110        parts.push("--nimbus-cli".to_string());
111    }
112    if let Some(v) = experiments {
113        let json = serde_json::to_string(v)?;
114        let string = percent_encoding::utf8_percent_encode(&json, QUERY).to_string();
115        parts.push(format!("--experiments={string}"));
116    }
117
118    if *reset_db {
119        parts.push("--reset-db".to_string());
120    }
121    if *log_state {
122        parts.push("--log-state".to_string());
123    }
124
125    Ok(join_query(deeplink, &parts.join("&")))
126}
127
128fn join_query(url: &str, item: &str) -> String {
129    let suffix = if url.contains('?') { '&' } else { '?' };
130    format!("{url}{suffix}{item}")
131}
132
133fn set_clipboard(contents: String) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
134    use copypasta::{ClipboardContext, ClipboardProvider};
135    let mut ctx = ClipboardContext::new()?;
136    ctx.set_contents(contents)?;
137    Ok(())
138}
139
140#[cfg(test)]
141mod unit_tests {
142
143    use super::*;
144    use serde_json::json;
145
146    #[test]
147    fn test_url_noop() -> Result<()> {
148        let p = StartAppProtocol {
149            reset_db: false,
150            experiments: None,
151            log_state: false,
152        };
153        assert_eq!("host".to_string(), longform_deeplink_url("host", &p)?);
154        assert_eq!(
155            "host?query=1".to_string(),
156            longform_deeplink_url("host?query=1", &p)?
157        );
158        Ok(())
159    }
160
161    #[test]
162    fn test_url_reset_db() -> Result<()> {
163        let p = StartAppProtocol {
164            reset_db: true,
165            experiments: None,
166            log_state: false,
167        };
168        assert_eq!(
169            "host?--nimbus-cli&--reset-db".to_string(),
170            longform_deeplink_url("host", &p)?
171        );
172        assert_eq!(
173            "host?query=1&--nimbus-cli&--reset-db".to_string(),
174            longform_deeplink_url("host?query=1", &p)?
175        );
176
177        Ok(())
178    }
179
180    #[test]
181    fn test_url_log_state() -> Result<()> {
182        let p = StartAppProtocol {
183            reset_db: false,
184            experiments: None,
185            log_state: true,
186        };
187        assert_eq!(
188            "host?--nimbus-cli&--log-state".to_string(),
189            longform_deeplink_url("host", &p)?
190        );
191        assert_eq!(
192            "host?query=1&--nimbus-cli&--log-state".to_string(),
193            longform_deeplink_url("host?query=1", &p)?
194        );
195
196        Ok(())
197    }
198
199    #[test]
200    fn test_url_experiments() -> Result<()> {
201        let v = json!({"data": []});
202        let p = StartAppProtocol {
203            reset_db: false,
204            experiments: Some(&v),
205            log_state: false,
206        };
207        assert_eq!(
208            "host?--nimbus-cli&--experiments=%7B%22data%22%3A[]%7D".to_string(),
209            longform_deeplink_url("host", &p)?
210        );
211        assert_eq!(
212            "host?query=1&--nimbus-cli&--experiments=%7B%22data%22%3A[]%7D".to_string(),
213            longform_deeplink_url("host?query=1", &p)?
214        );
215
216        Ok(())
217    }
218
219    #[test]
220    fn test_deeplink_has_is_launcher_param_if_no_deeplink_is_specified() -> Result<()> {
221        let app =
222            LaunchableApp::try_from_app_channel_device(Some("fenix"), Some("developer"), None)?;
223
224        // No payload, or command line param for deeplink.
225        let payload: StartAppProtocol = Default::default();
226        let open: AppOpenArgs = Default::default();
227        assert_eq!(
228            "fenix-dev://open?--nimbus-cli&--is-launcher".to_string(),
229            app.longform_url(&payload, &open)?
230        );
231
232        // A command line param for deeplink.
233        let open = AppOpenArgs {
234            deeplink: Some("deeplink".to_string()),
235            ..Default::default()
236        };
237        assert_eq!(
238            "fenix-dev://deeplink".to_string(),
239            app.longform_url(&payload, &open)?
240        );
241
242        // A parameter from the payload, but no deeplink.
243        let payload = StartAppProtocol {
244            log_state: true,
245            ..Default::default()
246        };
247        assert_eq!(
248            "fenix-dev://open?--nimbus-cli&--is-launcher&--log-state".to_string(),
249            app.longform_url(&payload, &Default::default())?
250        );
251
252        // A deeplink from the command line, and an extra param from the payload.
253        let open = AppOpenArgs {
254            deeplink: Some("deeplink".to_string()),
255            ..Default::default()
256        };
257        assert_eq!(
258            "fenix-dev://deeplink?--nimbus-cli&--log-state".to_string(),
259            app.longform_url(&payload, &open)?
260        );
261
262        Ok(())
263    }
264}