diff --git a/.github/scripts/setup-system.ps1 b/.github/scripts/setup-system.ps1 index 655d5a686..f4c5f4129 100644 --- a/.github/scripts/setup-system.ps1 +++ b/.github/scripts/setup-system.ps1 @@ -319,7 +319,7 @@ if (-not ($filename -and $downloadUri)) { } Write-Host "Dowloading protobuf zip from ${downloadUri}..." -ForegroundColor Yellow -Start-BitsTransfer -TransferType Download -Source $downloadUri -Destination "$temp\protobuf.zip" +Invoke-RestMethodGithub -Uri $downloadUri -OutFile "$temp\protobuf.zip" Write-Host 'Expanding protobuf zip...' -ForegroundColor Yellow Expand-Archive "$temp\protobuf.zip" "$projectRoot\target\Frameworks" -Force diff --git a/Cargo.lock b/Cargo.lock index 8b02b0c48..252ce3401 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -156,9 +156,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67fc08ce920c31afb70f013dcce1bfc3a3195de6a228474e45e1f145b36f8d04" +checksum = "43f6cb1bf222025340178f382c426f13757b2960e89779dfcb319c32542a5a41" dependencies = [ "memchr", ] @@ -271,6 +271,12 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b4930d2cb77ce62f89ee5d5289b4ac049559b1c45539271f5ed4fdc7db34545" +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + [[package]] name = "arrayvec" version = "0.7.2" @@ -292,7 +298,7 @@ dependencies = [ "asn1-rs-derive 0.1.0", "asn1-rs-impl", "displaydoc", - "nom", + "nom 7.1.3", "num-traits", "rusticata-macros", "thiserror", @@ -308,7 +314,7 @@ dependencies = [ "asn1-rs-derive 0.4.0", "asn1-rs-impl", "displaydoc", - "nom", + "nom 7.1.3", "num-traits", "rusticata-macros", "thiserror", @@ -733,7 +739,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ae2468a89544a466886840aa467a25b766499f4f04bf7d9fcd10ecee9fccef" dependencies = [ "arrayref", - "arrayvec", + "arrayvec 0.7.2", "cc", "cfg-if 1.0.0", "constant_time_eq", @@ -988,7 +994,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -1786,7 +1792,7 @@ checksum = "fe398ac75057914d7d07307bf67dc7f3f574a26783b4fc7805a20ffa9f506e82" dependencies = [ "asn1-rs 0.3.1", "displaydoc", - "nom", + "nom 7.1.3", "num-bigint", "num-traits", "rusticata-macros", @@ -1800,7 +1806,7 @@ checksum = "dbd676fbbab537128ef0278adb5576cf363cff6aa22a7b24effe97347cfab61e" dependencies = [ "asn1-rs 0.5.2", "displaydoc", - "nom", + "nom 7.1.3", "num-bigint", "num-traits", "rusticata-macros", @@ -2388,6 +2394,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d73a076bdabd78c2f9045dba1b90664a655fa8372581c238596e1eb3a5e1b7" +[[package]] +name = "freedesktop_entry_parser" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db9c27b72f19a99a895f8ca89e2d26e4ef31013376e56fdafef697627306c3e4" +dependencies = [ + "nom 7.1.3", + "thiserror", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -3075,6 +3091,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "home" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5444c27eef6923071f7ebcc33e3444508466a76f7a2b93da00ed6e19f30c1ddb" +dependencies = [ + "windows-sys 0.48.0", +] + [[package]] name = "hostname" version = "0.3.1" @@ -3724,6 +3749,19 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" +[[package]] +name = "lexical-core" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6607c62aa161d23d17a9072cc5da0be67cdfc89d3afb1e8d9c842bebc2525ffe" +dependencies = [ + "arrayvec 0.5.2", + "bitflags", + "cfg-if 1.0.0", + "ryu", + "static_assertions", +] + [[package]] name = "libc" version = "0.2.144" @@ -3925,7 +3963,7 @@ version = "0.43.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39d5ef876a2b2323d63c258e63c2f8e36f205fe5a11f0b3095d59635650790ff" dependencies = [ - "arrayvec", + "arrayvec 0.7.2", "asynchronous-codec", "bytes", "either", @@ -4786,6 +4824,17 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nom" +version = "5.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08959a387a676302eebf4ddbcbc611da04285579f76f88ee0506c63b1a61dd4b" +dependencies = [ + "lexical-core", + "memchr", + "version_check", +] + [[package]] name = "nom" version = "7.1.3" @@ -5690,7 +5739,7 @@ dependencies = [ [[package]] name = "prisma-client-rust" version = "0.6.8" -source = "git+https://github.com/Brendonovich/prisma-client-rust?rev=7e67b4550dd5323d479c96008678032f396b9060#7e67b4550dd5323d479c96008678032f396b9060" +source = "git+https://github.com/Brendonovich/prisma-client-rust?rev=687a00812130454613eee2c8e804bc615e755180#687a00812130454613eee2c8e804bc615e755180" dependencies = [ "base64 0.13.1", "bigdecimal", @@ -5723,7 +5772,7 @@ dependencies = [ [[package]] name = "prisma-client-rust-cli" version = "0.6.8" -source = "git+https://github.com/Brendonovich/prisma-client-rust?rev=7e67b4550dd5323d479c96008678032f396b9060#7e67b4550dd5323d479c96008678032f396b9060" +source = "git+https://github.com/Brendonovich/prisma-client-rust?rev=687a00812130454613eee2c8e804bc615e755180#687a00812130454613eee2c8e804bc615e755180" dependencies = [ "directories", "flate2", @@ -5743,7 +5792,7 @@ dependencies = [ [[package]] name = "prisma-client-rust-macros" version = "0.6.8" -source = "git+https://github.com/Brendonovich/prisma-client-rust?rev=7e67b4550dd5323d479c96008678032f396b9060#7e67b4550dd5323d479c96008678032f396b9060" +source = "git+https://github.com/Brendonovich/prisma-client-rust?rev=687a00812130454613eee2c8e804bc615e755180#687a00812130454613eee2c8e804bc615e755180" dependencies = [ "convert_case 0.6.0", "proc-macro2", @@ -5755,7 +5804,7 @@ dependencies = [ [[package]] name = "prisma-client-rust-sdk" version = "0.6.8" -source = "git+https://github.com/Brendonovich/prisma-client-rust?rev=7e67b4550dd5323d479c96008678032f396b9060#7e67b4550dd5323d479c96008678032f396b9060" +source = "git+https://github.com/Brendonovich/prisma-client-rust?rev=687a00812130454613eee2c8e804bc615e755180#687a00812130454613eee2c8e804bc615e755180" dependencies = [ "convert_case 0.5.0", "dmmf", @@ -6373,7 +6422,7 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81ca098a9821bd52d6b24fd8b10bd081f47d39c22778cafaa75a2857a62c6390" dependencies = [ - "aho-corasick 1.0.1", + "aho-corasick 1.0.2", "memchr", "regex-syntax 0.7.2", ] @@ -6654,7 +6703,7 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -6934,6 +6983,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "sd-desktop-linux" +version = "0.1.0" +dependencies = [ + "aho-corasick 1.0.2", + "atty", + "freedesktop_entry_parser", + "mime", + "shlex", + "thiserror", + "xdg", + "xdg-mime", +] + [[package]] name = "sd-desktop-macos" version = "0.1.0" @@ -7065,7 +7128,7 @@ dependencies = [ name = "sd-sync-generator" version = "0.1.0" dependencies = [ - "nom", + "nom 7.1.3", "once_cell", "prisma-client-rust-sdk", "proc-macro2", @@ -7576,6 +7639,7 @@ dependencies = [ "rand 0.8.5", "rspc", "sd-core", + "sd-desktop-linux", "sd-desktop-macos", "serde", "specta", @@ -7743,7 +7807,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c12bc9199d1db8234678b7051747c07f517cdcf019262d1847b94ec8b1aee3e" dependencies = [ "itertools", - "nom", + "nom 7.1.3", "unicode_categories", ] @@ -10086,7 +10150,7 @@ dependencies = [ "data-encoding", "der-parser 7.0.0", "lazy_static", - "nom", + "nom 7.1.3", "oid-registry 0.4.0", "ring", "rusticata-macros", @@ -10105,7 +10169,7 @@ dependencies = [ "data-encoding", "der-parser 8.2.0", "lazy_static", - "nom", + "nom 7.1.3", "oid-registry 0.6.1", "rusticata-macros", "thiserror", @@ -10121,6 +10185,28 @@ dependencies = [ "libc", ] +[[package]] +name = "xdg" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688597db5a750e9cad4511cb94729a078e274308099a0382b5b8203bbc767fee" +dependencies = [ + "home", +] + +[[package]] +name = "xdg-mime" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87bf7b69bb50588d70a36e467be29d3df3e8c32580276d62eded9738c1a797aa" +dependencies = [ + "dirs-next", + "glob", + "mime", + "nom 5.1.3", + "unicase", +] + [[package]] name = "yasna" version = "0.5.2" diff --git a/Cargo.toml b/Cargo.toml index 5baea3b60..21e129ef2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,19 +18,19 @@ edition = "2021" repository = "https://github.com/spacedriveapp/spacedrive" [workspace.dependencies] -prisma-client-rust = { git = "https://github.com/Brendonovich/prisma-client-rust", rev = "7e67b4550dd5323d479c96008678032f396b9060", features = [ +prisma-client-rust = { git = "https://github.com/Brendonovich/prisma-client-rust", rev = "687a00812130454613eee2c8e804bc615e755180", features = [ "rspc", "sqlite-create-many", "migrations", "sqlite", ] } -prisma-client-rust-cli = { git = "https://github.com/Brendonovich/prisma-client-rust", rev = "7e67b4550dd5323d479c96008678032f396b9060", features = [ +prisma-client-rust-cli = { git = "https://github.com/Brendonovich/prisma-client-rust", rev = "687a00812130454613eee2c8e804bc615e755180", features = [ "rspc", "sqlite-create-many", "migrations", "sqlite", ] } -prisma-client-rust-sdk = { git = "https://github.com/Brendonovich/prisma-client-rust", rev = "7e67b4550dd5323d479c96008678032f396b9060", features = [ +prisma-client-rust-sdk = { git = "https://github.com/Brendonovich/prisma-client-rust", rev = "687a00812130454613eee2c8e804bc615e755180", features = [ "sqlite", ] } diff --git a/apps/desktop/crates/linux/Cargo.toml b/apps/desktop/crates/linux/Cargo.toml new file mode 100644 index 000000000..febbc8a91 --- /dev/null +++ b/apps/desktop/crates/linux/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "sd-desktop-linux" +version = "0.1.0" +license = { workspace = true } +repository = { workspace = true } +edition = { workspace = true } + +[dependencies] +aho-corasick = "1.0.2" +atty = "0.2.14" +freedesktop_entry_parser = "1.3.0" +mime = "0.3.17" +shlex = "1.1.0" +thiserror = "1.0.40" +xdg = "2.5.0" +xdg-mime = "0.3.3" + diff --git a/apps/desktop/crates/linux/README.md b/apps/desktop/crates/linux/README.md new file mode 100644 index 000000000..b46586b2a --- /dev/null +++ b/apps/desktop/crates/linux/README.md @@ -0,0 +1,9 @@ +# Linux crate + +For some OS specific operations + +> The code for parsing Desktop Entries and finding which programs handle a certain mime-type is based on: +> +> https://github.com/chmln/handlr (MIT) +> +> thanks @chmln diff --git a/apps/desktop/crates/linux/src/desktop_entry.rs b/apps/desktop/crates/linux/src/desktop_entry.rs new file mode 100644 index 000000000..3da1789fe --- /dev/null +++ b/apps/desktop/crates/linux/src/desktop_entry.rs @@ -0,0 +1,178 @@ +use std::{ + collections::HashMap, + convert::TryFrom, + ffi::OsString, + path::{Path, PathBuf}, + process::{Command, Stdio}, + str::FromStr, +}; + +use aho_corasick::AhoCorasick; +use mime::Mime; + +use crate::{Error, Result, SystemApps}; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DesktopEntry { + pub name: String, + pub exec: String, + pub file_name: OsString, + pub terminal: bool, + pub mimes: Vec, + pub categories: HashMap, +} + +#[derive(PartialEq, Eq, Copy, Clone)] +pub enum Mode { + Launch, + Open, +} + +fn terminal() -> Result { + SystemApps::get_entries() + .ok() + .and_then(|mut entries| { + entries.find(|(_handler, entry)| entry.categories.contains_key("TerminalEmulator")) + }) + .map(|e| e.1.exec) + .ok_or(Error::NoTerminal) +} + +impl DesktopEntry { + pub fn exec(&self, mode: Mode, arguments: &[&str]) -> Result<()> { + let supports_multiple = self.exec.contains("%F") || self.exec.contains("%U"); + if arguments.is_empty() { + self.exec_inner(&[])? + } else if supports_multiple || mode == Mode::Launch { + self.exec_inner(arguments)?; + } else { + for arg in arguments { + self.exec_inner(&[*arg])?; + } + }; + + Ok(()) + } + + fn exec_inner(&self, args: &[&str]) -> Result<()> { + let mut cmd = { + let (cmd, args) = self.get_cmd(args)?; + let mut cmd = Command::new(cmd); + cmd.args(args); + cmd + }; + + if self.terminal && atty::is(atty::Stream::Stdout) { + cmd.spawn()?.wait()?; + } else { + cmd.stdout(Stdio::null()).stderr(Stdio::null()).spawn()?; + } + + Ok(()) + } + + pub fn get_cmd(&self, args: &[&str]) -> Result<(String, Vec)> { + let special = AhoCorasick::new(["%f", "%F", "%u", "%U"]).expect("Failed to build pattern"); + + let mut exec = shlex::split(&self.exec).ok_or(Error::InvalidExec(self.exec.clone()))?; + + // The desktop entry doesn't contain arguments - we make best effort and append them at + // the end + if special.is_match(&self.exec) { + exec = exec + .into_iter() + .flat_map(|s| match s.as_str() { + "%f" | "%F" | "%u" | "%U" => { + args.iter().map(|arg| str::to_string(arg)).collect() + } + s if special.is_match(s) => vec![{ + let mut replaced = String::with_capacity(s.len() + args.len() * 2); + special.replace_all_with(s, &mut replaced, |_, _, dst| { + dst.push_str(args.join(" ").as_str()); + false + }); + replaced + }], + _ => vec![s], + }) + .collect() + } else { + exec.extend(args.iter().map(|arg| str::to_string(arg))); + } + + // If the entry expects a terminal (emulator), but this process is not running in one, we + // launch a new one. + if self.terminal && !atty::is(atty::Stream::Stdout) { + exec = shlex::split(&terminal()?) + .ok_or(Error::InvalidExec(self.exec.clone()))? + .into_iter() + .chain(["-e".to_owned()]) + .chain(exec) + .collect(); + } + + Ok((exec.remove(0), exec)) + } +} + +fn parse_file(path: &Path) -> Option { + let raw_entry = freedesktop_entry_parser::parse_entry(path).ok()?; + let section = raw_entry.section("Desktop Entry"); + + let mut entry = DesktopEntry { + file_name: path.file_name()?.to_owned(), + ..Default::default() + }; + + for attr in section.attrs().filter(|a| a.has_value()) { + match attr.name { + "Name" if entry.name.is_empty() => { + entry.name = attr.value?.into(); + } + "Exec" => entry.exec = attr.value?.into(), + "MimeType" => { + entry.mimes = attr + .value? + .split(';') + .filter_map(|m| Mime::from_str(m).ok()) + .collect::>(); + } + "Terminal" => entry.terminal = attr.value? == "true", + "Categories" => { + entry.categories = attr + .value? + .split(';') + .filter(|s| !s.is_empty()) + .map(|cat| (cat.to_owned(), ())) + .collect(); + } + _ => {} + } + } + + if !entry.name.is_empty() && !entry.exec.is_empty() { + Some(entry) + } else { + None + } +} + +impl TryFrom for DesktopEntry { + type Error = Error; + fn try_from(path: PathBuf) -> Result { + parse_file(&path).ok_or(Error::BadEntry(path)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn complex_exec() { + let entry = parse_file(Path::new("tests/cmus.desktop")).unwrap(); + assert_eq!(entry.mimes.len(), 2); + assert_eq!(entry.mimes[0].essence_str(), "audio/mp3"); + assert_eq!(entry.mimes[1].essence_str(), "audio/ogg"); + } +} diff --git a/apps/desktop/crates/linux/src/handler.rs b/apps/desktop/crates/linux/src/handler.rs new file mode 100644 index 000000000..5ca3624ab --- /dev/null +++ b/apps/desktop/crates/linux/src/handler.rs @@ -0,0 +1,54 @@ +use std::{convert::TryFrom, ffi::OsString, fmt::Display, path::PathBuf, str::FromStr}; + +use mime::Mime; + +use crate::{DesktopEntry, Error, ExecMode, Result}; + +pub enum HandlerType { + Mime(Mime), + Ext(String), +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] +pub struct Handler(OsString); + +impl Display for Handler { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0.to_string_lossy()) + } +} + +impl FromStr for Handler { + type Err = Error; + fn from_str(s: &str) -> Result { + let handler = Self::assume_valid(s.into()); + handler.get_entry()?; + Ok(handler) + } +} + +impl Handler { + pub fn assume_valid(name: OsString) -> Self { + Self(name) + } + + pub fn get_path(&self) -> Result { + let mut path = PathBuf::from("applications"); + path.push(&self.0); + xdg::BaseDirectories::new()? + .find_data_file(path) + .ok_or(Error::BadPath(self.0.to_string_lossy().to_string())) + } + + pub fn get_entry(&self) -> Result { + DesktopEntry::try_from(self.get_path()?) + } + + pub fn launch(&self, args: &[&str]) -> Result<()> { + self.get_entry()?.exec(ExecMode::Launch, args) + } + + pub fn open(&self, args: &[&str]) -> Result<()> { + self.get_entry()?.exec(ExecMode::Open, args) + } +} diff --git a/apps/desktop/crates/linux/src/lib.rs b/apps/desktop/crates/linux/src/lib.rs new file mode 100644 index 000000000..617d0fc5a --- /dev/null +++ b/apps/desktop/crates/linux/src/lib.rs @@ -0,0 +1,29 @@ +#![cfg(target_os = "linux")] + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Xdg(#[from] xdg::BaseDirectoriesError), + #[error("no handlers found for '{0}'")] + NotFound(String), + #[error("bad Desktop Entry exec line: {0}")] + InvalidExec(String), + #[error("malformed desktop entry at {0}")] + BadEntry(std::path::PathBuf), + #[error("Please specify the default terminal with handlr set x-scheme-handler/terminal")] + NoTerminal, + #[error("Bad path: {0}")] + BadPath(String), +} + +pub type Result = std::result::Result; + +mod desktop_entry; +mod handler; +mod system; + +pub use desktop_entry::{DesktopEntry, Mode as ExecMode}; +pub use handler::{Handler, HandlerType}; +pub use system::SystemApps; diff --git a/apps/desktop/crates/linux/src/system.rs b/apps/desktop/crates/linux/src/system.rs new file mode 100644 index 000000000..6305486c8 --- /dev/null +++ b/apps/desktop/crates/linux/src/system.rs @@ -0,0 +1,65 @@ +use std::{ + collections::{HashMap, HashSet, VecDeque}, + convert::TryFrom, + ffi::OsString, +}; + +use mime::Mime; +use xdg_mime::SharedMimeInfo; + +use crate::{DesktopEntry, Handler, HandlerType, Result}; + +#[derive(Debug, Default, Clone)] +pub struct SystemApps(pub HashMap>); + +impl SystemApps { + pub fn get_handlers(&self, handler_type: HandlerType) -> VecDeque { + let mime_db = SharedMimeInfo::new(); + match handler_type { + HandlerType::Ext(ext) => { + let mut handlers: HashSet = HashSet::new(); + for mime in mime_db.get_mime_types_from_file_name(ext.as_str()) { + if let Some(mime_handlers) = self.0.get(&mime) { + for handler in mime_handlers { + handlers.insert(handler.clone()); + } + } + } + handlers.into_iter().collect() + } + HandlerType::Mime(mime) => self.0.get(&mime).unwrap_or(&VecDeque::new()).clone(), + } + } + + pub fn get_handler(&self, handler_type: HandlerType) -> Option { + Some(self.get_handlers(handler_type).get(0)?.clone()) + } + + pub fn get_entries() -> Result> { + Ok(xdg::BaseDirectories::new()? + .list_data_files_once("applications") + .into_iter() + .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("desktop")) + .filter_map(|p| { + Some(( + p.file_name()?.to_owned(), + DesktopEntry::try_from(p.clone()).ok()?, + )) + })) + } + + pub fn populate() -> Result { + let mut map = HashMap::>::with_capacity(50); + + Self::get_entries()?.for_each(|(_, entry)| { + let (file_name, mimes) = (entry.file_name, entry.mimes); + mimes.into_iter().for_each(|mime| { + map.entry(mime) + .or_default() + .push_back(Handler::assume_valid(file_name.clone())); + }); + }); + + Ok(Self(map)) + } +} diff --git a/apps/desktop/crates/linux/tests/cmus.desktop b/apps/desktop/crates/linux/tests/cmus.desktop new file mode 100644 index 000000000..a26121d85 --- /dev/null +++ b/apps/desktop/crates/linux/tests/cmus.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Encoding=UTF-8 +Version=1.0 +Type=Application +Display=true +Exec=bash -c "(! pgrep cmus && tilix -e cmus && tilix -a session-add-down -e cava); sleep 0.1 && cmus-remote -q %f" +Terminal=false +Name=cmus-remote +Comment=Music player cmus-remote control +NoDisplay=true +Icon=cmus +MimeType=audio/mp3;audio/ogg; diff --git a/apps/desktop/crates/macos/Cargo.toml b/apps/desktop/crates/macos/Cargo.toml index 6f375b9a3..dcff8a575 100644 --- a/apps/desktop/crates/macos/Cargo.toml +++ b/apps/desktop/crates/macos/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "sd-desktop-macos" version = "0.1.0" -license.workspace = true -repository.workspace = true -edition.workspace = true +license = { workspace = true } +repository = { workspace = true } +edition = { workspace = true } [dependencies] swift-rs = { workspace = true, features = ["serde"] } diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 9be31d6e3..dace63a3b 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -4,9 +4,9 @@ version = "0.1.0" description = "The universal file manager." authors = ["Spacedrive Technology Inc."] default-run = "spacedrive" -license.workspace = true -repository.workspace = true -edition.workspace = true +license = { workspace = true } +repository = { workspace = true } +edition = { workspace = true } [dependencies] tauri = { version = "1.3.0", features = ["dialog-all", "linux-protocol-headers", "macos-private-api", "os-all", "path-all", "protocol-all", "shell-all", "window-all"] } @@ -27,16 +27,17 @@ serde = "1.0.163" percent-encoding = "2.2.0" http = "0.2.9" opener = "0.6.1" -specta.workspace = true +specta = { workspace = true } tauri-specta = { workspace = true, features = ["typescript"] } uuid = { version = "1.3.3", features = ["serde"] } [target.'cfg(target_os = "linux")'.dependencies] axum = { version = "0.6.18", features = ["headers", "query"] } rand = "0.8.5" +sd-desktop-linux = { path = "../crates/linux" } [target.'cfg(target_os = "macos")'.dependencies] -sd-desktop-macos.path = "../crates/macos" +sd-desktop-macos = { path = "../crates/macos" } [build-dependencies] tauri-build = { version = "1.3.0", features = [] } diff --git a/apps/desktop/src-tauri/src/file.rs b/apps/desktop/src-tauri/src/file.rs index ed2984453..3ee7146f9 100644 --- a/apps/desktop/src-tauri/src/file.rs +++ b/apps/desktop/src-tauri/src/file.rs @@ -38,9 +38,12 @@ pub async fn open_file_path( Ok(res) } -#[derive(Type, serde::Serialize)] +#[derive(Type, Debug, serde::Serialize)] pub struct OpenWithApplication { name: String, + #[cfg(target_os = "linux")] + url: std::path::PathBuf, + #[cfg(not(target_os = "linux"))] url: String, } @@ -55,7 +58,7 @@ pub async fn get_file_path_open_with_apps( return Err(()) }; - let Ok(Some(_path)) = library + let Ok(Some(path)) = library .get_file_path(id) .await else { @@ -64,7 +67,7 @@ pub async fn get_file_path_open_with_apps( #[cfg(target_os = "macos")] return Ok(unsafe { - sd_desktop_macos::get_open_with_applications(&_path.to_str().unwrap().into()) + sd_desktop_macos::get_open_with_applications(&path.to_str().unwrap().into()) } .as_slice() .iter() @@ -74,7 +77,42 @@ pub async fn get_file_path_open_with_apps( }) .collect()); - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "linux")] + { + use sd_desktop_linux::{DesktopEntry, HandlerType, SystemApps}; + + // TODO: cache this, and only update when the underlying XDG desktop apps changes + let system_apps = SystemApps::populate().map_err(|_| ())?; + + let handlers = system_apps.get_handlers(HandlerType::Ext( + path.file_name() + .and_then(|name| name.to_str()) + .map(|name| name.to_string()) + .ok_or( + // io::Error::new( + // io::ErrorKind::Other, + // "Missing file name from path", + // ) + (), + )?, + )); + + let data = handlers + .iter() + .map(|handler| { + let path = handler.get_path().map_err(|_| ())?; + let entry = DesktopEntry::try_from(path.clone()).map_err(|_| ())?; + Ok(OpenWithApplication { + name: entry.name, + url: path, + }) + }) + .collect::, _>>()?; + + return Ok(data); + } + + #[allow(unreachable_code)] Err(()) } @@ -83,14 +121,14 @@ pub async fn get_file_path_open_with_apps( pub async fn open_file_path_with( library: uuid::Uuid, id: i32, - _with_url: String, + url: String, node: tauri::State<'_, Arc>, ) -> Result<(), ()> { let Some(library) = node.library_manager.get_library(library).await else { return Err(()) }; - let Ok(Some(_path)) = library + let Ok(Some(path)) = library .get_file_path(id) .await else { @@ -100,10 +138,17 @@ pub async fn open_file_path_with( #[cfg(target_os = "macos")] unsafe { sd_desktop_macos::open_file_path_with( - &_path.to_str().unwrap().into(), - &_with_url.as_str().into(), + &path.to_str().ok_or(())?.into(), + &url.as_str().into(), ) }; + #[cfg(target_os = "linux")] + { + sd_desktop_linux::Handler::assume_valid(url.into()) + .open(&[path.to_str().ok_or(())?]) + .map_err(|_| ())?; + } + Ok(()) } diff --git a/apps/desktop/src/commands.ts b/apps/desktop/src/commands.ts index 9184d74cf..f5b1fc6cc 100644 --- a/apps/desktop/src/commands.ts +++ b/apps/desktop/src/commands.ts @@ -30,8 +30,8 @@ export function getFilePathOpenWithApps(library: string, id: number) { return invoke()("get_file_path_open_with_apps", { library,id }) } -export function openFilePathWith(library: string, id: number, withUrl: string) { - return invoke()("open_file_path_with", { library,id,withUrl }) +export function openFilePathWith(library: string, id: number, url: string) { + return invoke()("open_file_path_with", { library,id,url }) } export function lockAppTheme(themeType: AppThemeType) { diff --git a/interface/app/$libraryId/Explorer/File/ContextMenu/OpenWith.tsx b/interface/app/$libraryId/Explorer/File/ContextMenu/OpenWith.tsx index d4f9f34b1..b1fa2002d 100644 --- a/interface/app/$libraryId/Explorer/File/ContextMenu/OpenWith.tsx +++ b/interface/app/$libraryId/Explorer/File/ContextMenu/OpenWith.tsx @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { Suspense } from 'react'; import { FilePath, useLibraryContext } from '@sd/client'; import { ContextMenu } from '@sd/ui'; +import { showAlertDialog } from '~/components'; import { Platform, usePlatform } from '~/util/Platform'; export default (props: { filePath: FilePath }) => { @@ -33,7 +34,7 @@ const Items = ({ }) => { const { library } = useLibraryContext(); - const items = useQuery( + const items = useQuery( ['openWith', filePath.id], () => actions.getFilePathOpenWithApps(library.uuid, filePath.id), { suspense: true } @@ -44,11 +45,20 @@ const Items = ({ {items.data?.map((d) => ( actions.openFilePathWith(library.uuid, filePath.id, d.url)} + onClick={async () => { + try { + await actions.openFilePathWith(library.uuid, filePath.id, d.url); + } catch { + showAlertDialog({ + title: 'Error', + value: `Failed to open file, with: ${d.url}` + }); + } + }} > {d.name} - ))} + )) ??

No apps available

} ); }; diff --git a/interface/util/Platform.tsx b/interface/util/Platform.tsx index 13cfc444e..c6436b459 100644 --- a/interface/util/Platform.tsx +++ b/interface/util/Platform.tsx @@ -1,4 +1,4 @@ -import { PropsWithChildren, createContext, useContext, useState } from 'react'; +import { PropsWithChildren, createContext, useContext } from 'react'; export type OperatingSystem = 'browser' | 'linux' | 'macOS' | 'windows' | 'unknown'; @@ -24,8 +24,8 @@ export type Platform = { openLogsDir?(): void; // Opens a file path with a given ID openFilePath?(library: string, id: number): any; - getFilePathOpenWithApps?(library: string, id: number): any; - openFilePathWith?(library: string, id: number, appUrl: string): any; + getFilePathOpenWithApps?(library: string, id: number): Promise<{ name: string; url: string }[]>; + openFilePathWith?(library: string, id: number, appUrl: string): Promise; lockAppTheme?(themeType: 'Auto' | 'Light' | 'Dark'): any; };