feat: add tauri-plugin-opener (#2019)

This commit is contained in:
Amr Bashir
2024-11-20 00:50:02 +02:00
committed by GitHub
parent 1051db406a
commit 383e636a8e
61 changed files with 2346 additions and 7 deletions
+75
View File
@@ -0,0 +1,75 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::path::{Path, PathBuf};
use tauri::{
ipc::{CommandScope, GlobalScope},
AppHandle, Runtime,
};
use crate::{scope::Scope, Error};
#[tauri::command]
pub async fn open_url<R: Runtime>(
app: AppHandle<R>,
command_scope: CommandScope<crate::scope::Entry>,
global_scope: GlobalScope<crate::scope::Entry>,
url: String,
with: Option<String>,
) -> crate::Result<()> {
let scope = Scope::new(
&app,
command_scope
.allows()
.iter()
.chain(global_scope.allows())
.collect(),
command_scope
.denies()
.iter()
.chain(global_scope.denies())
.collect(),
);
if scope.is_url_allowed(&url, with.as_deref()) {
crate::open_url(url, with)
} else {
Err(Error::ForbiddenUrl { url, with })
}
}
#[tauri::command]
pub async fn open_path<R: Runtime>(
app: AppHandle<R>,
command_scope: CommandScope<crate::scope::Entry>,
global_scope: GlobalScope<crate::scope::Entry>,
path: String,
with: Option<String>,
) -> crate::Result<()> {
let scope = Scope::new(
&app,
command_scope
.allows()
.iter()
.chain(global_scope.allows())
.collect(),
command_scope
.denies()
.iter()
.chain(global_scope.denies())
.collect(),
);
if scope.is_path_allowed(Path::new(&path), with.as_deref())? {
crate::open_path(path, with)
} else {
Err(Error::ForbiddenPath { path, with })
}
}
#[tauri::command]
pub async fn reveal_item_in_dir(path: PathBuf) -> crate::Result<()> {
crate::reveal_item_in_dir(path)
}
+54
View File
@@ -0,0 +1,54 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::path::PathBuf;
use serde::{Serialize, Serializer};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[cfg(mobile)]
#[error(transparent)]
PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError),
#[error(transparent)]
Tauri(#[from] tauri::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error("unknown program {0}")]
UnknownProgramName(String),
#[error("Not allowed to open path {}{}", .path, .with.as_ref().map(|w| format!(" with {w}")).unwrap_or_default())]
ForbiddenPath { path: String, with: Option<String> },
#[error("Not allowed to open url {}{}", .url, .with.as_ref().map(|w| format!(" with {w}")).unwrap_or_default())]
ForbiddenUrl { url: String, with: Option<String> },
#[error("API not supported on the current platform")]
UnsupportedPlatform,
#[error(transparent)]
#[cfg(windows)]
Win32Error(#[from] windows::core::Error),
#[error("Path doesn't have a parent: {0}")]
NoParent(PathBuf),
#[error("Failed to convert path to file:// url")]
FailedToConvertPathToFileUrl,
#[error(transparent)]
#[cfg(any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
))]
Zbus(#[from] zbus::Error),
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
+1
View File
@@ -0,0 +1 @@
!function(){"use strict";"function"==typeof SuppressedError&&SuppressedError,window.addEventListener("click",(function(e){if(e.defaultPrevented||0!==e.button||e.metaKey||e.altKey)return;const t=e.composedPath().find((e=>e instanceof Node&&"A"===e.nodeName.toUpperCase()));if(!t||!t.href||"_blank"!==t.target&&!e.ctrlKey&&!e.shiftKey)return;const n=new URL(t.href);n.origin===window.location.origin||["http:","https:","mailto:","tel:"].every((e=>n.protocol!==e))||(e.preventDefault(),async function(e,t={},n){window.__TAURI_INTERNALS__.invoke(e,t,n)}("plugin:opener|open_url",{url:n}))}))}();
+169
View File
@@ -0,0 +1,169 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::path::Path;
use tauri::{plugin::TauriPlugin, Manager, Runtime};
#[cfg(mobile)]
use tauri::plugin::PluginHandle;
#[cfg(target_os = "android")]
const PLUGIN_IDENTIFIER: &str = "app.tauri.opener";
#[cfg(target_os = "ios")]
tauri::ios_plugin_binding!(init_plugin_opener);
mod commands;
mod error;
mod open;
mod reveal_item_in_dir;
mod scope;
mod scope_entry;
pub use error::Error;
type Result<T> = std::result::Result<T, Error>;
pub use open::{open_path, open_url};
pub use reveal_item_in_dir::reveal_item_in_dir;
pub struct Opener<R: Runtime> {
// we use `fn() -> R` to slicence the unused generic error
// while keeping this struct `Send + Sync` without requiring `R` to be
#[cfg(not(mobile))]
_marker: std::marker::PhantomData<fn() -> R>,
#[cfg(mobile)]
mobile_plugin_handle: PluginHandle<R>,
}
impl<R: Runtime> Opener<R> {
/// Open a url with a default or specific program.
///
/// ## Platform-specific:
///
/// - **Android / iOS**: Always opens using default program.
#[cfg(desktop)]
pub fn open_url(&self, url: impl Into<String>, with: Option<impl Into<String>>) -> Result<()> {
crate::open::open(url.into(), with.map(Into::into)).map_err(Into::into)
}
/// Open a url with a default or specific program.
///
/// ## Platform-specific:
///
/// - **Android / iOS**: Always opens using default program.
#[cfg(mobile)]
pub fn open_url(&self, url: impl Into<String>, _with: Option<impl Into<String>>) -> Result<()> {
self.mobile_plugin_handle
.run_mobile_plugin("open", url.into())
.map_err(Into::into)
}
/// Open a path with a default or specific program.
///
/// ## Platform-specific:
///
/// - **Android / iOS**: Always opens using default program.
#[cfg(desktop)]
pub fn open_path(
&self,
path: impl Into<String>,
with: Option<impl Into<String>>,
) -> Result<()> {
crate::open::open(path.into(), with.map(Into::into)).map_err(Into::into)
}
/// Open a path with a default or specific program.
///
/// ## Platform-specific:
///
/// - **Android / iOS**: Always opens using default program.
#[cfg(mobile)]
pub fn open_path(
&self,
path: impl Into<String>,
_with: Option<impl Into<String>>,
) -> Result<()> {
self.mobile_plugin_handle
.run_mobile_plugin("open", path.into())
.map_err(Into::into)
}
pub fn reveal_item_in_dir<P: AsRef<Path>>(&self, p: P) -> Result<()> {
crate::reveal_item_in_dir::reveal_item_in_dir(p)
}
}
/// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the opener APIs.
pub trait OpenerExt<R: Runtime> {
fn opener(&self) -> &Opener<R>;
}
impl<R: Runtime, T: Manager<R>> crate::OpenerExt<R> for T {
fn opener(&self) -> &Opener<R> {
self.state::<Opener<R>>().inner()
}
}
/// The opener plugin Builder.
pub struct Builder {
open_js_links_on_click: bool,
}
impl Default for Builder {
fn default() -> Self {
Self {
open_js_links_on_click: true,
}
}
}
impl Builder {
/// Create a new opener plugin Builder.
pub fn new() -> Self {
Self::default()
}
/// Whether the plugin should inject a JS script to open URLs in default browser
/// when clicking on `<a>` elements that has `_blank` target, or when pressing `Ctrl` or `Shift` while clicking it.
///
/// Enabled by default for `http:`, `https:`, `mailto:`, `tel:` links.
pub fn open_js_links_on_click(mut self, open: bool) -> Self {
self.open_js_links_on_click = open;
self
}
/// Build and Initializes the plugin.
pub fn build<R: Runtime>(self) -> TauriPlugin<R> {
let mut builder = tauri::plugin::Builder::new("opener")
.setup(|app, _api| {
#[cfg(target_os = "android")]
let handle = _api.register_android_plugin(PLUGIN_IDENTIFIER, "OpenerPlugin")?;
#[cfg(target_os = "ios")]
let handle = _api.register_ios_plugin(init_plugin_opener)?;
app.manage(Opener {
#[cfg(not(mobile))]
_marker: std::marker::PhantomData::<fn() -> R>,
#[cfg(mobile)]
mobile_plugin_handle: handle,
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::open_url,
commands::open_path,
commands::reveal_item_in_dir
]);
if self.open_js_links_on_click {
builder = builder.js_init_script(include_str!("init-iife.js").to_string());
}
builder.build()
}
}
/// Initializes the plugin.
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::default().build()
}
+57
View File
@@ -0,0 +1,57 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
//! Types and functions related to shell.
use std::{ffi::OsStr, path::Path};
pub(crate) fn open<P: AsRef<OsStr>, S: AsRef<str>>(path: P, with: Option<S>) -> crate::Result<()> {
match with {
Some(program) => ::open::with_detached(path, program.as_ref()),
None => ::open::that_detached(path),
}
.map_err(Into::into)
}
/// Opens URL with the program specified in `with`, or system default if `None`.
///
/// ## Platform-specific:
///
/// - **Android / iOS**: Always opens using default program.
///
/// # Examples
///
/// ```rust,no_run
/// tauri::Builder::default()
/// .setup(|app| {
/// // open the given URL on the system default browser
/// tauri_plugin_opener::open_url("https://github.com/tauri-apps/tauri", None)?;
/// Ok(())
/// });
/// ```
pub fn open_url<P: AsRef<str>, S: AsRef<str>>(url: P, with: Option<S>) -> crate::Result<()> {
let url = url.as_ref();
open(url, with)
}
/// Opens path with the program specified in `with`, or system default if `None`.
///
/// ## Platform-specific:
///
/// - **Android / iOS**: Always opens using default program.
///
/// # Examples
///
/// ```rust,no_run
/// tauri::Builder::default()
/// .setup(|app| {
/// // open the given URL on the system default browser
/// tauri_plugin_opener::open_path("/path/to/file", None)?;
/// Ok(())
/// });
/// ```
pub fn open_path<P: AsRef<Path>, S: AsRef<str>>(path: P, with: Option<S>) -> crate::Result<()> {
let path = path.as_ref();
open(path, with)
}
+196
View File
@@ -0,0 +1,196 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::path::Path;
/// Reveal a path the system's default explorer.
///
/// ## Platform-specific:
///
/// - **Android / iOS:** Unsupported.
pub fn reveal_item_in_dir<P: AsRef<Path>>(path: P) -> crate::Result<()> {
let path = path.as_ref().canonicalize()?;
#[cfg(any(
windows,
target_os = "macos",
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
))]
return imp::reveal_item_in_dir(&path);
#[cfg(not(any(
windows,
target_os = "macos",
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
)))]
Err(crate::Error::UnsupportedPlatform)
}
#[cfg(windows)]
mod imp {
use super::*;
use windows::{
core::{w, HSTRING, PCWSTR},
Win32::{
Foundation::ERROR_FILE_NOT_FOUND,
System::Com::CoInitialize,
UI::{
Shell::{
ILCreateFromPathW, ILFree, SHOpenFolderAndSelectItems, ShellExecuteExW,
SHELLEXECUTEINFOW,
},
WindowsAndMessaging::SW_SHOWNORMAL,
},
},
};
pub fn reveal_item_in_dir(path: &Path) -> crate::Result<()> {
let file = dunce::simplified(path);
let _ = unsafe { CoInitialize(None) };
let dir = file
.parent()
.ok_or_else(|| crate::Error::NoParent(file.to_path_buf()))?;
let dir = HSTRING::from(dir);
let dir_item = unsafe { ILCreateFromPathW(&dir) };
let file_h = HSTRING::from(file);
let file_item = unsafe { ILCreateFromPathW(&file_h) };
unsafe {
if let Err(e) = SHOpenFolderAndSelectItems(dir_item, Some(&[file_item]), 0) {
// from https://github.com/electron/electron/blob/10d967028af2e72382d16b7e2025d243b9e204ae/shell/common/platform_util_win.cc#L302
// On some systems, the above call mysteriously fails with "file not
// found" even though the file is there. In these cases, ShellExecute()
// seems to work as a fallback (although it won't select the file).
if e.code().0 == ERROR_FILE_NOT_FOUND.0 as i32 {
let is_dir = file.is_dir();
let mut info = SHELLEXECUTEINFOW {
cbSize: std::mem::size_of::<SHELLEXECUTEINFOW>() as _,
nShow: SW_SHOWNORMAL.0,
lpFile: PCWSTR(dir.as_ptr()),
lpClass: if is_dir { w!("folder") } else { PCWSTR::null() },
lpVerb: if is_dir {
w!("explore")
} else {
PCWSTR::null()
},
..std::mem::zeroed()
};
ShellExecuteExW(&mut info).inspect_err(|_| {
ILFree(Some(dir_item));
ILFree(Some(file_item));
})?;
}
}
}
unsafe {
ILFree(Some(dir_item));
ILFree(Some(file_item));
}
Ok(())
}
}
#[cfg(any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
))]
mod imp {
use std::collections::HashMap;
use super::*;
pub fn reveal_item_in_dir(path: &Path) -> crate::Result<()> {
let connection = zbus::blocking::Connection::session()?;
reveal_with_filemanager1(path, &connection)
.or_else(|_| reveal_with_open_uri_portal(path, &connection))
}
fn reveal_with_filemanager1(
path: &Path,
connection: &zbus::blocking::Connection,
) -> crate::Result<()> {
let uri = url::Url::from_file_path(path)
.map_err(|_| crate::Error::FailedToConvertPathToFileUrl)?;
#[zbus::proxy(
interface = "org.freedesktop.FileManager1",
default_service = "org.freedesktop.FileManager1",
default_path = "/org/freedesktop/FileManager1"
)]
trait FileManager1 {
async fn ShowItems(&self, name: Vec<&str>, arg2: &str) -> crate::Result<()>;
}
let proxy = FileManager1ProxyBlocking::new(connection)?;
proxy.ShowItems(vec![uri.as_str()], "")
}
fn reveal_with_open_uri_portal(
path: &Path,
connection: &zbus::blocking::Connection,
) -> crate::Result<()> {
let uri = url::Url::from_file_path(path)
.map_err(|_| crate::Error::FailedToConvertPathToFileUrl)?;
#[zbus::proxy(
interface = "org.freedesktop.portal.Desktop",
default_service = "org.freedesktop.portal.OpenURI",
default_path = "/org/freedesktop/portal/desktop"
)]
trait PortalDesktop {
async fn OpenDirectory(
&self,
arg1: &str,
name: &str,
arg3: HashMap<&str, &str>,
) -> crate::Result<()>;
}
let proxy = PortalDesktopProxyBlocking::new(connection)?;
proxy.OpenDirectory("", uri.as_str(), HashMap::new())
}
}
#[cfg(target_os = "macos")]
mod imp {
use super::*;
use objc2_app_kit::NSWorkspace;
use objc2_foundation::{NSArray, NSString, NSURL};
pub fn reveal_item_in_dir(path: &Path) -> crate::Result<()> {
unsafe {
let path = path.to_string_lossy();
let path = NSString::from_str(&path);
let urls = vec![NSURL::fileURLWithPath(&path)];
let urls = NSArray::from_vec(urls);
let workspace = NSWorkspace::new();
workspace.activateFileViewerSelectingURLs(&urls);
}
Ok(())
}
}
+138
View File
@@ -0,0 +1,138 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::{
marker::PhantomData,
path::{Path, PathBuf},
sync::Arc,
};
use tauri::{ipc::ScopeObject, utils::acl::Value, AppHandle, Manager, Runtime};
use crate::{scope_entry::EntryRaw, Error};
pub use crate::scope_entry::Application;
#[derive(Debug)]
pub enum Entry {
Url {
url: glob::Pattern,
app: Application,
},
Path {
path: Option<PathBuf>,
app: Application,
},
}
impl ScopeObject for Entry {
type Error = Error;
fn deserialize<R: Runtime>(
app_handle: &AppHandle<R>,
raw: Value,
) -> std::result::Result<Self, Self::Error> {
serde_json::from_value(raw.into())
.and_then(|raw| {
let entry = match raw {
EntryRaw::Url { url, app } => Entry::Url {
url: glob::Pattern::new(&url)
.map_err(|e| serde::de::Error::custom(e.to_string()))?,
app,
},
EntryRaw::Path { path, app } => {
let path = match app_handle.path().parse(path) {
Ok(path) => Some(path),
#[cfg(not(target_os = "android"))]
Err(tauri::Error::UnknownPath) => None,
Err(err) => return Err(serde::de::Error::custom(err.to_string())),
};
Entry::Path { path, app }
}
};
Ok(entry)
})
.map_err(Into::into)
}
}
impl Application {
fn matches(&self, a: Option<&str>) -> bool {
match self {
Self::Default => a.is_none(),
Self::Enable(enable) => *enable,
Self::App(program) => Some(program.as_str()) == a,
}
}
}
impl Entry {
fn path(&self) -> Option<PathBuf> {
match self {
Self::Url { .. } => None,
Self::Path { path, .. } => path.clone(),
}
}
fn matches_url(&self, u: &str, a: Option<&str>) -> bool {
match self {
Self::Url { url, app } => url.matches(u) && app.matches(a),
Self::Path { .. } => false,
}
}
fn matches_path_program(&self, a: Option<&str>) -> bool {
match self {
Self::Url { .. } => false,
Self::Path { app, .. } => app.matches(a),
}
}
}
#[derive(Debug)]
pub struct Scope<'a, R: Runtime, M: Manager<R>> {
allowed: Vec<&'a Arc<Entry>>,
denied: Vec<&'a Arc<Entry>>,
manager: &'a M,
_marker: PhantomData<R>,
}
impl<'a, R: Runtime, M: Manager<R>> Scope<'a, R, M> {
pub(crate) fn new(
manager: &'a M,
allowed: Vec<&'a Arc<Entry>>,
denied: Vec<&'a Arc<Entry>>,
) -> Self {
Self {
manager,
allowed,
denied,
_marker: PhantomData,
}
}
pub fn is_url_allowed(&self, url: &str, with: Option<&str>) -> bool {
let denied = self.denied.iter().any(|e| e.matches_url(url, with));
if denied {
false
} else {
self.allowed.iter().any(|e| e.matches_url(url, with))
}
}
pub fn is_path_allowed(&self, path: &Path, with: Option<&str>) -> crate::Result<bool> {
let fs_scope = tauri::fs::Scope::new(
self.manager,
&tauri::utils::config::FsScope::Scope {
allow: self.allowed.iter().filter_map(|e| e.path()).collect(),
deny: self.denied.iter().filter_map(|e| e.path()).collect(),
require_literal_leading_dot: None,
},
)?;
Ok(fs_scope.is_allowed(path) && self.allowed.iter().any(|e| e.matches_path_program(with)))
}
}
+36
View File
@@ -0,0 +1,36 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::path::PathBuf;
use serde::Deserialize;
#[derive(Deserialize, Debug)]
#[serde(untagged)]
pub enum Application {
Default,
Enable(bool),
App(String),
}
impl Default for Application {
fn default() -> Self {
Self::Default
}
}
#[derive(Deserialize)]
#[serde(untagged, rename_all = "camelCase")]
pub(crate) enum EntryRaw {
Url {
url: String,
#[serde(default)]
app: Application,
},
Path {
path: PathBuf,
#[serde(default)]
app: Application,
},
}