inset-embedder 0.19.0

dart:ui host traits and value types for Inset
Documentation
//! Opening, and being opened. Not a dart:ui file: Flutter reaches the first through its
//! `url_launcher` package, one call taking a URI, and the second through each platform's plugins.
//!
//! Opening is a call for each kind, as Electron's `shell` and Tauri's opener have them: a link in
//! the app that handles its scheme, a file in the app the system opens its kind with, and a file
//! shown in the system's file manager ([`Opener`]). A file is no `file://` link here: phones share
//! a file through the system (Android's content provider, iOS's interaction controller), and only
//! a file can be revealed. Whether some app opens a link is asked where the system answers it
//! ([`Opener::can_open_link`]); Apple and Google limit the question to the schemes an app
//! declared, since asking about many tells which apps the user has.
//!
//! Being opened: the system asks the app to open files and links, at launch or while it runs, as
//! one [`OpenRequest`] reaching the app through `EmbedderClient::open_requested`. A file is a
//! [`PickedFile`], as a file dialog answers one: a path where the system gives one, and the
//! system's handle where it gives none.
//!
//! A request is only what the system itself hands over as files and links: macOS's
//! `application:openURLs:`, iOS's URLs, Android's intents, the web's `launchQueue`. A command line
//! is the app's own, as Windows and Linux start an app with one: the system gives only strings,
//! and which of them are files the app decided when it registered its file types. So no host reads
//! it, and an app that opens what its command line names builds the request itself.

use std::fmt;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;

use crate::PickedFile;

/// The system's way to open links and files in other apps.
pub trait Opener {
    /// Opens `link` in the app that handles its scheme: a browser for `https`, the mail app for
    /// `mailto`.
    fn open_link(&self, link: &str) -> OpenFuture;

    /// Opens the file at `path` in the app the system opens its kind with.
    fn open_file(&self, path: &Path) -> OpenFuture;

    /// Shows the file at `path` in the system's file manager, selected in its folder.
    fn reveal_file(&self, path: &Path) -> OpenFuture;

    /// Whether some app on this system opens `link`; `None` where the system will not say, as
    /// for a scheme an app did not declare on iOS and Android, and anywhere on Linux and the web.
    fn can_open_link(&self, link: &str) -> Option<bool>;
}

/// What opening answers, once the system has handed the link or the file on.
pub type OpenFuture = Pin<Box<dyn Future<Output = Result<(), OpenError>>>>;

/// Why a link or a file did not open.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum OpenError {
    /// No app on this system opens it.
    NoApp,
    /// The system failed to open it.
    Failed(String),
}

impl fmt::Display for OpenError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            OpenError::NoApp => f.write_str("no app opens it"),
            OpenError::Failed(reason) => write!(f, "it did not open: {reason}"),
        }
    }
}

impl std::error::Error for OpenError {}

/// What the system asks the app to open, at launch or while it runs.
#[derive(Debug, Default)]
pub struct OpenRequest {
    pub files: Vec<PickedFile>,
    pub links: Vec<String>,
}

impl OpenRequest {
    pub fn is_empty(&self) -> bool {
        self.files.is_empty() && self.links.is_empty()
    }
}

/// `path` as a `file:` URI, which [`file_uri_path`] reads back: made absolute against the working
/// folder, its separators `/`, and every byte a URI's path may not hold as is percent-encoded. A Windows drive keeps its colon,
/// and a Windows share is the URL's host.
pub(crate) fn file_uri(path: &Path) -> String {
    let path = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
    let written = uri_path_of(&path);
    let encoded = percent_encoded(&written);
    if let Some(share) = encoded.strip_prefix("//") {
        format!("file://{share}")
    } else if encoded.starts_with('/') {
        format!("file://{encoded}")
    } else {
        format!("file:///{encoded}")
    }
}

/// The path's bytes, with `/` between its parts.
#[cfg(unix)]
fn uri_path_of(path: &Path) -> Vec<u8> {
    use std::os::unix::ffi::OsStrExt;
    path.as_os_str().as_bytes().to_vec()
}

/// The path's bytes, with `/` between its parts.
#[cfg(not(unix))]
fn uri_path_of(path: &Path) -> Vec<u8> {
    path.to_string_lossy().replace('\\', "/").into_bytes()
}

/// `bytes` with every byte outside a URI path's own characters (RFC 3986's `pchar` and `/`)
/// percent-encoded.
fn percent_encoded(bytes: &[u8]) -> String {
    let mut encoded = String::with_capacity(bytes.len());
    for &byte in bytes {
        if byte.is_ascii_alphanumeric() || b"-._~!$&'()*+,;=:@/".contains(&byte) {
            encoded.push(char::from(byte));
        } else {
            encoded.push_str(&format!("%{byte:02X}"));
        }
    }
    encoded
}

/// The path a `file:` URI names on this system, its percent-encoding decoded; `None` for another
/// URI, one on another host, or one that does not decode.
pub fn file_uri_path(uri: &str) -> Option<PathBuf> {
    let rest = uri.strip_prefix("file://")?;
    let path = match rest.find('/')? {
        0 => rest,
        host_end if &rest[..host_end] == "localhost" => &rest[host_end..],
        _ => return None,
    };
    let mut bytes = Vec::with_capacity(path.len());
    let mut encoded = path.bytes();
    while let Some(byte) = encoded.next() {
        if byte == b'%' {
            let high = encoded.next().and_then(hex_digit)?;
            let low = encoded.next().and_then(hex_digit)?;
            bytes.push(high << 4 | low);
        } else {
            bytes.push(byte);
        }
    }
    path_from_bytes(bytes)
}

fn hex_digit(digit: u8) -> Option<u8> {
    char::from(digit).to_digit(16).map(|value| value as u8)
}

/// A decoded URI path as this system's path: its bytes on Unix; on Windows UTF-8, with the slash
/// before a drive dropped and the slashes turned around.
#[cfg(unix)]
fn path_from_bytes(bytes: Vec<u8>) -> Option<PathBuf> {
    use std::ffi::OsString;
    use std::os::unix::ffi::OsStringExt;
    Some(PathBuf::from(OsString::from_vec(bytes)))
}

#[cfg(not(unix))]
fn path_from_bytes(bytes: Vec<u8>) -> Option<PathBuf> {
    let text = String::from_utf8(bytes).ok()?;
    let has_drive = text.len() > 2
        && text.as_bytes()[0] == b'/'
        && text.as_bytes()[1].is_ascii_alphabetic()
        && text.as_bytes()[2] == b':';
    let text = if has_drive { &text[1..] } else { &text[..] };
    Some(PathBuf::from(text.replace('/', "\\")))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[cfg(unix)]
    #[cfg(unix)]
    #[test]
    fn a_path_is_a_file_uri_with_what_a_uri_cannot_hold_encoded_and_reads_back() {
        let path = Path::new("/Movies/a b/été #1?.mp4");
        let uri = file_uri(path);
        assert_eq!(uri, "file:///Movies/a%20b/%C3%A9t%C3%A9%20%231%3F.mp4");
        assert_eq!(file_uri_path(&uri).as_deref(), Some(path));
        let relative = file_uri(Path::new("clip.mov"));
        assert!(relative.starts_with("file:///") && relative.ends_with("/clip.mov"));
    }

    #[cfg(windows)]
    #[test]
    fn a_windows_path_is_a_file_uri_with_its_drive_or_its_share() {
        assert_eq!(
            file_uri(Path::new(r"C:\Movies\a b.mp4")),
            "file:///C:/Movies/a%20b.mp4"
        );
        assert_eq!(
            file_uri(Path::new(r"\\server\share\a.mp4")),
            "file://server/share/a.mp4"
        );
    }

    #[cfg(unix)]
    #[test]
    fn a_file_uri_names_its_path_decoded() {
        assert_eq!(
            file_uri_path("file:///home/me/My%20Notes/caf%C3%A9.txt"),
            Some(PathBuf::from("/home/me/My Notes/café.txt"))
        );
        assert_eq!(
            file_uri_path("file://localhost/tmp/a"),
            Some(PathBuf::from("/tmp/a"))
        );
        assert_eq!(file_uri_path("file://elsewhere/tmp/a"), None);
        assert_eq!(file_uri_path("https://example.org/a"), None);
        assert_eq!(file_uri_path("file:///bad%2"), None);
    }
}