Skip to main content

inset_embedder/
opener.rs

1//! Opening, and being opened. Not a dart:ui file: Flutter reaches the first through its
2//! `url_launcher` package, one call taking a URI, and the second through each platform's plugins.
3//!
4//! Opening is a call for each kind, as Electron's `shell` and Tauri's opener have them: a link in
5//! the app that handles its scheme, a file in the app the system opens its kind with, and a file
6//! shown in the system's file manager ([`Opener`]). A file is no `file://` link here: phones share
7//! a file through the system (Android's content provider, iOS's interaction controller), and only
8//! a file can be revealed. Whether some app opens a link is asked where the system answers it
9//! ([`Opener::can_open_link`]); Apple and Google limit the question to the schemes an app
10//! declared, since asking about many tells which apps the user has.
11//!
12//! Being opened: the system asks the app to open files and links, at launch or while it runs, as
13//! one [`OpenRequest`] reaching the app through `EmbedderClient::open_requested`. A file is a
14//! [`PickedFile`], as a file dialog answers one: a path where the system gives one, and the
15//! system's handle where it gives none.
16//!
17//! A request is only what the system itself hands over as files and links: macOS's
18//! `application:openURLs:`, iOS's URLs, Android's intents, the web's `launchQueue`. A command line
19//! is the app's own, as Windows and Linux start an app with one: the system gives only strings,
20//! and which of them are files the app decided when it registered its file types. So no host reads
21//! it, and an app that opens what its command line names builds the request itself.
22
23use std::fmt;
24use std::future::Future;
25use std::path::{Path, PathBuf};
26use std::pin::Pin;
27
28use crate::PickedFile;
29
30/// The system's way to open links and files in other apps.
31pub trait Opener {
32    /// Opens `link` in the app that handles its scheme: a browser for `https`, the mail app for
33    /// `mailto`.
34    fn open_link(&self, link: &str) -> OpenFuture;
35
36    /// Opens the file at `path` in the app the system opens its kind with.
37    fn open_file(&self, path: &Path) -> OpenFuture;
38
39    /// Shows the file at `path` in the system's file manager, selected in its folder.
40    fn reveal_file(&self, path: &Path) -> OpenFuture;
41
42    /// Whether some app on this system opens `link`; `None` where the system will not say, as
43    /// for a scheme an app did not declare on iOS and Android, and anywhere on Linux and the web.
44    fn can_open_link(&self, link: &str) -> Option<bool>;
45}
46
47/// What opening answers, once the system has handed the link or the file on.
48pub type OpenFuture = Pin<Box<dyn Future<Output = Result<(), OpenError>>>>;
49
50/// Why a link or a file did not open.
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub enum OpenError {
53    /// No app on this system opens it.
54    NoApp,
55    /// The system failed to open it.
56    Failed(String),
57}
58
59impl fmt::Display for OpenError {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        match self {
62            OpenError::NoApp => f.write_str("no app opens it"),
63            OpenError::Failed(reason) => write!(f, "it did not open: {reason}"),
64        }
65    }
66}
67
68impl std::error::Error for OpenError {}
69
70/// What the system asks the app to open, at launch or while it runs.
71#[derive(Debug, Default)]
72pub struct OpenRequest {
73    pub files: Vec<PickedFile>,
74    pub links: Vec<String>,
75}
76
77impl OpenRequest {
78    pub fn is_empty(&self) -> bool {
79        self.files.is_empty() && self.links.is_empty()
80    }
81}
82
83/// `path` as a `file:` URI, which [`file_uri_path`] reads back: made absolute against the working
84/// folder, its separators `/`, and every byte a URI's path may not hold as is percent-encoded. A Windows drive keeps its colon,
85/// and a Windows share is the URL's host.
86pub(crate) fn file_uri(path: &Path) -> String {
87    let path = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
88    let written = uri_path_of(&path);
89    let encoded = percent_encoded(&written);
90    if let Some(share) = encoded.strip_prefix("//") {
91        format!("file://{share}")
92    } else if encoded.starts_with('/') {
93        format!("file://{encoded}")
94    } else {
95        format!("file:///{encoded}")
96    }
97}
98
99/// The path's bytes, with `/` between its parts.
100#[cfg(unix)]
101fn uri_path_of(path: &Path) -> Vec<u8> {
102    use std::os::unix::ffi::OsStrExt;
103    path.as_os_str().as_bytes().to_vec()
104}
105
106/// The path's bytes, with `/` between its parts.
107#[cfg(not(unix))]
108fn uri_path_of(path: &Path) -> Vec<u8> {
109    path.to_string_lossy().replace('\\', "/").into_bytes()
110}
111
112/// `bytes` with every byte outside a URI path's own characters (RFC 3986's `pchar` and `/`)
113/// percent-encoded.
114fn percent_encoded(bytes: &[u8]) -> String {
115    let mut encoded = String::with_capacity(bytes.len());
116    for &byte in bytes {
117        if byte.is_ascii_alphanumeric() || b"-._~!$&'()*+,;=:@/".contains(&byte) {
118            encoded.push(char::from(byte));
119        } else {
120            encoded.push_str(&format!("%{byte:02X}"));
121        }
122    }
123    encoded
124}
125
126/// The path a `file:` URI names on this system, its percent-encoding decoded; `None` for another
127/// URI, one on another host, or one that does not decode.
128pub fn file_uri_path(uri: &str) -> Option<PathBuf> {
129    let rest = uri.strip_prefix("file://")?;
130    let path = match rest.find('/')? {
131        0 => rest,
132        host_end if &rest[..host_end] == "localhost" => &rest[host_end..],
133        _ => return None,
134    };
135    let mut bytes = Vec::with_capacity(path.len());
136    let mut encoded = path.bytes();
137    while let Some(byte) = encoded.next() {
138        if byte == b'%' {
139            let high = encoded.next().and_then(hex_digit)?;
140            let low = encoded.next().and_then(hex_digit)?;
141            bytes.push(high << 4 | low);
142        } else {
143            bytes.push(byte);
144        }
145    }
146    path_from_bytes(bytes)
147}
148
149fn hex_digit(digit: u8) -> Option<u8> {
150    char::from(digit).to_digit(16).map(|value| value as u8)
151}
152
153/// A decoded URI path as this system's path: its bytes on Unix; on Windows UTF-8, with the slash
154/// before a drive dropped and the slashes turned around.
155#[cfg(unix)]
156fn path_from_bytes(bytes: Vec<u8>) -> Option<PathBuf> {
157    use std::ffi::OsString;
158    use std::os::unix::ffi::OsStringExt;
159    Some(PathBuf::from(OsString::from_vec(bytes)))
160}
161
162#[cfg(not(unix))]
163fn path_from_bytes(bytes: Vec<u8>) -> Option<PathBuf> {
164    let text = String::from_utf8(bytes).ok()?;
165    let has_drive = text.len() > 2
166        && text.as_bytes()[0] == b'/'
167        && text.as_bytes()[1].is_ascii_alphabetic()
168        && text.as_bytes()[2] == b':';
169    let text = if has_drive { &text[1..] } else { &text[..] };
170    Some(PathBuf::from(text.replace('/', "\\")))
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[cfg(unix)]
178    #[cfg(unix)]
179    #[test]
180    fn a_path_is_a_file_uri_with_what_a_uri_cannot_hold_encoded_and_reads_back() {
181        let path = Path::new("/Movies/a b/été #1?.mp4");
182        let uri = file_uri(path);
183        assert_eq!(uri, "file:///Movies/a%20b/%C3%A9t%C3%A9%20%231%3F.mp4");
184        assert_eq!(file_uri_path(&uri).as_deref(), Some(path));
185        let relative = file_uri(Path::new("clip.mov"));
186        assert!(relative.starts_with("file:///") && relative.ends_with("/clip.mov"));
187    }
188
189    #[cfg(windows)]
190    #[test]
191    fn a_windows_path_is_a_file_uri_with_its_drive_or_its_share() {
192        assert_eq!(
193            file_uri(Path::new(r"C:\Movies\a b.mp4")),
194            "file:///C:/Movies/a%20b.mp4"
195        );
196        assert_eq!(
197            file_uri(Path::new(r"\\server\share\a.mp4")),
198            "file://server/share/a.mp4"
199        );
200    }
201
202    #[cfg(unix)]
203    #[test]
204    fn a_file_uri_names_its_path_decoded() {
205        assert_eq!(
206            file_uri_path("file:///home/me/My%20Notes/caf%C3%A9.txt"),
207            Some(PathBuf::from("/home/me/My Notes/café.txt"))
208        );
209        assert_eq!(
210            file_uri_path("file://localhost/tmp/a"),
211            Some(PathBuf::from("/tmp/a"))
212        );
213        assert_eq!(file_uri_path("file://elsewhere/tmp/a"), None);
214        assert_eq!(file_uri_path("https://example.org/a"), None);
215        assert_eq!(file_uri_path("file:///bad%2"), None);
216    }
217}