[ENG-594] Open With Windows + fixes (#945)

* Windows `Open With` WIP
 - Listing applications capable of hanling a file type is working
 - Openning a file with a selected application is failing with unspecified error HRESULT(0x80004005) for some reason

* Fix file not opening due to COM not being initialized
 - Fix `no apps available` style

* Remove unwrap

* Fix `Open With` due to changes in main

* Fix macOS `Open With`

* Fix Windows `Open With` due to changes in main
 - Sort linux `Open With` entries, to ensure consistent app order

* Fix macOS again

* Update core.ts

* Fix windows CI being rate limited

* Clippy

* Fix CoUninitialize not being called

* minor formatting

* Implement feedback
 - Improve performance of listing apps that can handle a certain file type in Linux

* Fix broken feedback change
 - Small perf improvement to windows crate

* Some improvements to windows crate
This commit is contained in:
Vítor Vasconcellos 2023-06-17 02:23:45 -03:00 committed by GitHub
parent 88166cdfdd
commit 4078c360b4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 382 additions and 184 deletions

View file

@ -178,6 +178,8 @@ https://learn.microsoft.com/windows/package-manager/winget/
Write-Host
Write-Host 'Installing Visual Studio Build Tools...' -ForegroundColor Yellow
Write-Host 'This will take some time as it involves downloading several gigabytes of data....' -ForegroundColor Cyan
winget install -e --accept-source-agreements --force --disable-interactivity --id Microsoft.VisualStudio.2022.BuildTools `
--override 'updateall --quiet --wait'
# Force install because BuildTools is itself a package manager, so let it decide if something needs to be installed or not
winget install -e --accept-source-agreements --force --disable-interactivity --id Microsoft.VisualStudio.2022.BuildTools `
--override '--wait --quiet --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended'
@ -206,6 +208,8 @@ https://learn.microsoft.com/windows/package-manager/winget/
$LASTEXITCODE = 0
}
# TODO: Install Strawberry perl, required by debug build of openssl-sys
Write-Host
Write-Host 'Installing NodeJS...' -ForegroundColor Yellow
# Check if Node.JS is already installed and if it's compatible with the project
@ -341,7 +345,7 @@ while ($page -gt 0) {
$_.workflow_runs | ForEach-Object {
$artifactPath = (
(Invoke-RestMethod -Uri ($_.artifacts_url | Out-String) -Method Get).artifacts `
(Invoke-RestMethodGithub -Uri ($_.artifacts_url | Out-String) -Method Get).artifacts `
| Where-Object {
$_.name -eq "ffmpeg-${ffmpegVersion}-x86_64"
} | ForEach-Object {

15
Cargo.lock generated
View file

@ -3756,9 +3756,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.144"
version = "0.2.146"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b00cc1c228a6782d0f076e7b232802e0c5689d41bb5df366f2a6b6621cfdfe1"
checksum = "f92be4933c13fd498862a9e02a3055f8a8d9c039ce33db97306fd5a6caa7f29b"
[[package]]
name = "libheif-rs"
@ -6998,6 +6998,16 @@ dependencies = [
"swift-rs",
]
[[package]]
name = "sd-desktop-windows"
version = "0.1.0"
dependencies = [
"libc",
"normpath",
"thiserror",
"windows 0.48.0",
]
[[package]]
name = "sd-ffmpeg"
version = "0.1.0"
@ -7634,6 +7644,7 @@ dependencies = [
"sd-core",
"sd-desktop-linux",
"sd-desktop-macos",
"sd-desktop-windows",
"serde",
"specta",
"tauri",

View file

@ -29,12 +29,14 @@ pub enum Mode {
}
fn terminal() -> Result<String> {
// TODO: Attemtp to read x-terminal-emulator bin (Debian/Ubuntu spec for setting default terminal)
SystemApps::get_entries()
.ok()
.and_then(|mut entries| {
entries.find(|(_handler, entry)| entry.categories.contains_key("TerminalEmulator"))
entries
.find(|DesktopEntry { categories, .. }| categories.contains_key("TerminalEmulator"))
})
.map(|e| e.1.exec)
.map(|e| e.exec)
.ok_or(Error::NoTerminal)
}

View file

@ -1,7 +1,7 @@
use std::{
collections::{HashMap, HashSet, VecDeque},
collections::{BTreeSet, HashMap},
convert::TryFrom,
ffi::OsString,
ffi::OsStr,
};
use mime::Mime;
@ -10,49 +10,53 @@ use xdg_mime::SharedMimeInfo;
use crate::{DesktopEntry, Handler, HandlerType, Result};
#[derive(Debug, Default, Clone)]
pub struct SystemApps(pub HashMap<Mime, VecDeque<Handler>>);
pub struct SystemApps(pub HashMap<Mime, BTreeSet<Handler>>);
impl SystemApps {
pub fn get_handlers(&self, handler_type: HandlerType) -> VecDeque<Handler> {
match handler_type {
pub fn get_handlers(&self, handler_type: HandlerType) -> impl Iterator<Item = &Handler> {
let mimes = match handler_type {
HandlerType::Ext(ext) => {
let mut handlers: HashSet<Handler> = HashSet::new();
for mime in SharedMimeInfo::new().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()
SharedMimeInfo::new().get_mime_types_from_file_name(ext.as_str())
}
HandlerType::Mime(mime) => vec![mime],
};
let mut handlers: BTreeSet<&Handler> = BTreeSet::new();
for mime in mimes {
if let Some(mime_handlers) = self.0.get(&mime) {
handlers.extend(mime_handlers.iter());
}
HandlerType::Mime(mime) => self.0.get(&mime).unwrap_or(&VecDeque::new()).clone(),
}
handlers.into_iter()
}
pub fn get_handler(&self, handler_type: HandlerType) -> Option<Handler> {
Some(self.get_handlers(handler_type).get(0)?.clone())
pub fn get_handler(&self, handler_type: HandlerType) -> Option<&Handler> {
self.get_handlers(handler_type).next()
}
pub fn get_entries() -> Result<impl Iterator<Item = (OsString, DesktopEntry)>> {
pub fn get_entries() -> Result<impl Iterator<Item = DesktopEntry>> {
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).ok()?))))
.filter(|p| p.extension().map_or(false, |x| x == OsStr::new("desktop")))
.filter_map(|p| DesktopEntry::try_from(&p).ok()))
}
pub fn populate() -> Result<Self> {
let mut map = HashMap::<Mime, VecDeque<Handler>>::with_capacity(50);
let mut map = HashMap::<Mime, BTreeSet<Handler>>::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()));
});
});
Self::get_entries()?.for_each(
|DesktopEntry {
mimes, file_name, ..
}| {
mimes.into_iter().for_each(|mime| {
map.entry(mime)
.or_default()
.insert(Handler::assume_valid(file_name.clone()));
});
},
);
Ok(Self(map))
}

View file

@ -0,0 +1,15 @@
[package]
name = "sd-desktop-windows"
version = "0.1.0"
license = { workspace = true }
repository = { workspace = true }
edition = { workspace = true }
[dependencies]
thiserror = "1.0.40"
normpath = "1.1.1"
libc = "0.2.146"
[dependencies.windows]
version = "0.48"
features = ["Win32_UI_Shell", "Win32_System_Com"]

View file

@ -0,0 +1,119 @@
#![cfg(target_os = "windows")]
use std::{
ffi::{OsStr, OsString},
os::windows::ffi::OsStrExt,
path::Path,
};
use normpath::PathExt;
use windows::{
core::{HSTRING, PCWSTR},
Win32::{
System::Com::{
CoInitializeEx, CoUninitialize, IDataObject, COINIT_APARTMENTTHREADED,
COINIT_DISABLE_OLE1DDE,
},
UI::Shell::{
BHID_DataObject, IAssocHandler, IShellItem, SHAssocEnumHandlers,
SHCreateItemFromParsingName, ASSOC_FILTER_RECOMMENDED,
},
},
};
pub use windows::core::{Error, Result};
// Based on: https://github.com/Byron/trash-rs/blob/841bc1388959ab3be4f05ad1a90b03aa6bcaea67/src/windows.rs#L212-L258
struct CoInitializer {}
impl CoInitializer {
fn new() -> CoInitializer {
let hr = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE) };
if hr.is_err() {
panic!("Call to CoInitializeEx failed. HRESULT: {:?}.", hr);
}
CoInitializer {}
}
}
thread_local! {
static CO_INITIALIZER: CoInitializer = {
unsafe { libc::atexit(atexit_handler) };
CoInitializer::new()
};
}
extern "C" fn atexit_handler() {
unsafe {
CoUninitialize();
}
}
fn ensure_com_initialized() {
CO_INITIALIZER.with(|_| {});
}
// Use SHAssocEnumHandlers to get the list of apps associated with a file extension.
// https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-shassocenumhandlers
pub fn list_apps_associated_with_ext(ext: &OsStr) -> Result<Vec<IAssocHandler>> {
if ext.is_empty() {
return Ok(Vec::new());
}
// SHAssocEnumHandlers requires the extension to be prefixed with a dot
let ext = {
// Get first charact from ext
let ext_bytes = ext.encode_wide().collect::<Vec<_>>();
if ext_bytes[0] != '.' as u16 {
let mut prefixed_ext = OsString::from(".");
prefixed_ext.push(ext);
prefixed_ext
} else {
ext.to_os_string()
}
};
let assoc_handlers =
unsafe { SHAssocEnumHandlers(&HSTRING::from(ext), ASSOC_FILTER_RECOMMENDED) }?;
let mut vec = Vec::new();
loop {
let mut rgelt = [None; 1];
let mut pceltfetched = 0;
unsafe { assoc_handlers.Next(&mut rgelt, Some(&mut pceltfetched)) }?;
if pceltfetched == 0 {
break;
}
if let [Some(handler)] = rgelt {
vec.push(handler);
}
}
Ok(vec)
}
pub fn open_file_path_with(path: &Path, url: &str) -> Result<()> {
ensure_com_initialized();
let ext = path.extension().ok_or(Error::OK)?;
for handler in list_apps_associated_with_ext(ext)?.iter() {
let name = unsafe { handler.GetName()?.to_string()? };
if name == url {
let path = path.normalize_virtually().map_err(|_| Error::OK)?;
let wide_path = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect::<Vec<_>>();
let factory: IShellItem =
unsafe { SHCreateItemFromParsingName(PCWSTR(wide_path.as_ptr()), None) }?;
let data: IDataObject = unsafe { factory.BindToHandler(None, &BHID_DataObject) }?;
unsafe { handler.Invoke(&data) }?;
return Ok(());
}
}
Err(Error::OK)
}

View file

@ -39,6 +39,9 @@ sd-desktop-linux = { path = "../crates/linux" }
[target.'cfg(target_os = "macos")'.dependencies]
sd-desktop-macos = { path = "../crates/macos" }
[target.'cfg(target_os = "windows")'.dependencies]
sd-desktop-windows = { path = "../crates/windows" }
[build-dependencies]
tauri-build = { version = "1.3.0", features = [] }

View file

@ -50,59 +50,53 @@ pub async fn open_file_path(
}
#[derive(Serialize, Type)]
#[serde(tag = "t", content = "c")]
#[allow(dead_code)]
pub enum OpenWithApplication {
File {
id: i32,
name: String,
#[cfg(target_os = "linux")]
url: std::path::PathBuf,
#[cfg(not(target_os = "linux"))]
url: String,
},
Error(i32, String),
pub struct OpenWithApplication {
id: i32,
name: String,
#[cfg(target_os = "linux")]
url: std::path::PathBuf,
#[cfg(not(target_os = "linux"))]
url: String,
}
#[tauri::command(async)]
#[specta::specta]
#[allow(unused_variables)]
pub async fn get_file_path_open_with_apps(
library: uuid::Uuid,
ids: Vec<i32>,
node: tauri::State<'_, Arc<Node>>,
) -> Result<Vec<OpenWithApplication>, ()> {
let Some(library) = node.library_manager.get_library(library).await else {
return Err(())
};
let Some(library) = node.library_manager.get_library(library).await
else {
return Ok(vec![]);
};
let Ok(paths) = library.get_file_paths(ids).await.map_err(|e| {error!("{e:#?}");})
else {
return Err(());
};
let Ok(paths) = library
.get_file_paths(ids).await
.map_err(|e| {error!("{e:#?}");})
else {
return Ok(vec![]);
};
#[cfg(target_os = "macos")]
return Ok(paths
.into_iter()
.flat_map(|(id, path)| {
if let Some(path) = path {
unsafe {
sd_desktop_macos::get_open_with_applications(&path.to_str().unwrap().into())
}
let Some(path) = path
else {
error!("File not found in database");
return vec![];
};
unsafe { sd_desktop_macos::get_open_with_applications(&path.to_str().unwrap().into()) }
.as_slice()
.iter()
.map(|app| OpenWithApplication::File {
.map(|app| OpenWithApplication {
id,
name: app.name.to_string(),
url: app.url.to_string(),
})
.collect::<Vec<_>>()
} else {
vec![OpenWithApplication::Error(
id,
"File not found in database".into(),
)]
}
})
.collect());
@ -111,133 +105,170 @@ pub async fn get_file_path_open_with_apps(
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 Ok(system_apps) = SystemApps::populate()
.map_err(|e| { error!("{e:#?}"); })
else {
return Ok(vec![]);
};
return Ok(paths
.into_iter()
.flat_map(|(id, path)| {
if let Some(path) = path {
let Some(name) = path.file_name()
.and_then(|name| name.to_str())
.map(|name| name.to_string())
let Some(path) = path
else {
return vec![OpenWithApplication::Error(
id,
"Failed to extract file name".into(),
)]
error!("File not found in database");
return vec![];
};
system_apps
.get_handlers(HandlerType::Ext(name))
.iter()
.map(|handler| {
handler
.get_path()
.map(|path| {
DesktopEntry::try_from(&path)
.map(|entry| OpenWithApplication::File {
id,
name: entry.name,
url: path,
})
.unwrap_or_else(|e| {
error!("{e:#?}");
OpenWithApplication::Error(
id,
"Failed to parse desktop entry".into(),
)
})
})
.unwrap_or_else(|e| {
error!("{e:#?}");
OpenWithApplication::Error(
let Some(name) = path.file_name()
.and_then(|name| name.to_str())
.map(|name| name.to_string())
else {
error!("Failed to extract file name");
return vec![];
};
system_apps
.get_handlers(HandlerType::Ext(name))
.map(|handler| {
handler
.get_path()
.map_err(|e| {
error!("{e:#?}");
})
.and_then(|path| {
DesktopEntry::try_from(&path)
// TODO: Ignore desktop entries that have commands that don't exist/aren't available in path
.map(|entry| OpenWithApplication {
id,
"Failed to get path from desktop entry".into(),
)
})
})
.collect::<Vec<_>>()
} else {
vec![OpenWithApplication::Error(
id,
"File not found in database".into(),
)]
}
name: entry.name,
url: path,
})
.map_err(|e| {
error!("{e:#?}");
})
})
})
.collect::<Result<Vec<_>, _>>()
.unwrap_or(vec![])
})
.collect());
}
#[cfg(windows)]
return Ok(paths
.into_iter()
.flat_map(|(id, path)| {
let Some(path) = path
else {
error!("File not found in database");
return vec![];
};
let Some(ext) = path.extension()
else {
error!("Failed to extract file extension");
return vec![];
};
sd_desktop_windows::list_apps_associated_with_ext(ext)
.map_err(|e| {
error!("{e:#?}");
})
.map(|handlers| {
handlers
.iter()
.filter_map(|handler| {
let (Ok(name), Ok(url)) = (
unsafe { handler.GetUIName() }.map_err(|e| { error!("{e:#?}");})
.and_then(|name| unsafe { name.to_string() }
.map_err(|e| { error!("{e:#?}");})),
unsafe { handler.GetName() }.map_err(|e| { error!("{e:#?}");})
.and_then(|name| unsafe { name.to_string() }
.map_err(|e| { error!("{e:#?}");})),
) else {
error!("Failed to get handler info");
return None
};
Some(OpenWithApplication { id, name, url })
})
.collect::<Vec<_>>()
})
.unwrap_or(vec![])
})
.collect());
#[allow(unreachable_code)]
Err(())
Ok(vec![])
}
type FileIdAndUrl = (i32, String);
#[tauri::command(async)]
#[specta::specta]
#[allow(unused_variables)]
pub async fn open_file_path_with(
library: uuid::Uuid,
file_ids_and_urls: Vec<FileIdAndUrl>,
node: tauri::State<'_, Arc<Node>>,
) -> Result<(), ()> {
let Some(library) = node.library_manager.get_library(library).await else {
return Err(())
};
let Some(library) = node.library_manager.get_library(library).await
else {
return Err(())
};
let url_by_id = file_ids_and_urls.into_iter().collect::<HashMap<_, _>>();
let ids = url_by_id.keys().copied().collect::<Vec<_>>();
#[cfg(target_os = "macos")]
{
library
.get_file_paths(ids)
.await
.map(|paths| {
paths.iter().for_each(|(id, path)| {
if let Some(path) = path {
library
.get_file_paths(ids)
.await
.map_err(|e| {
error!("{e:#?}");
})
.and_then(|paths| {
paths
.iter()
.map(|(id, path)| {
let (Some(path), Some(url)) = (
#[cfg(windows)]
path.as_ref(),
#[cfg(not(windows))]
path.as_ref().and_then(|path| path.to_str()),
url_by_id.get(id)
)
else {
error!("File not found in database");
return Err(());
};
#[cfg(target_os = "macos")]
return {
unsafe {
sd_desktop_macos::open_file_path_with(
&path.to_str().unwrap().into(),
&url_by_id
.get(id)
.expect("we just created this hashmap")
.as_str()
.into(),
&path.into(),
&url.as_str().into(),
)
}
}
})
})
.map_err(|e| {
error!("{e:#?}");
})
}
};
Ok(())
};
#[cfg(target_os = "linux")]
{
library
.get_file_paths(ids)
.await
.map(|paths| {
paths.iter().for_each(|(id, path)| {
if let Some(path) = path.as_ref().and_then(|path| path.to_str()) {
if let Err(e) = sd_desktop_linux::Handler::assume_valid(
url_by_id
.get(id)
.expect("we just created this hashmap")
.as_str()
.into(),
)
#[cfg(target_os = "linux")]
return sd_desktop_linux::Handler::assume_valid(url.into())
.open(&[path])
{
.map_err(|e| {
error!("{e:#?}");
}
}
});
#[cfg(windows)]
return sd_desktop_windows::open_file_path_with(path, url).map_err(|e| {
error!("{e:#?}");
});
#[allow(unreachable_code)]
Err(())
})
})
.map_err(|e| {
error!("{e:#?}");
})
}
.collect::<Result<Vec<_>, _>>()
.map(|_| ())
})
}

View file

@ -38,6 +38,6 @@ export function lockAppTheme(themeType: AppThemeType) {
return invoke()<null>("lock_app_theme", { themeType })
}
export type OpenWithApplication = { t: "File"; c: { id: number; name: string; url: string } } | { t: "Error"; c: [number, string] }
export type OpenWithApplication = { id: number; name: string; url: string }
export type AppThemeType = "Auto" | "Light" | "Dark"
export type OpenFilePathResult = { t: "NoLibrary" } | { t: "NoFile"; c: number } | { t: "OpenError"; c: [number, string] } | { t: "AllGood"; c: number } | { t: "Internal"; c: string }

View file

@ -34,7 +34,7 @@ const Items = ({
}) => {
const { library } = useLibraryContext();
const items = useQuery<any[]>(
const items = useQuery<unknown>(
['openWith', filePath.id],
() => actions.getFilePathOpenWithApps(library.uuid, [filePath.id]),
{ suspense: true }
@ -42,25 +42,30 @@ const Items = ({
return (
<>
{items.data?.map((data) => (
<ContextMenu.Item
key={data.name}
onClick={async () => {
try {
await actions.openFilePathWith(library.uuid, [
(filePath.id, data.c.url)
]);
} catch {
showAlertDialog({
title: 'Error',
value: `Failed to open file, with: ${data.url}`
});
}
}}
>
{data.name}
</ContextMenu.Item>
)) ?? <p> No apps available </p>}
{Array.isArray(items.data) && items.data.length > 0 ? (
items.data.map((data, id) => (
<ContextMenu.Item
key={id}
onClick={async () => {
try {
await actions.openFilePathWith(library.uuid, [
[filePath.id, data.url]
]);
} catch (e) {
console.error(e);
showAlertDialog({
title: 'Error',
value: `Failed to open file, with: ${data.url}`
});
}
}}
>
{data.name}
</ContextMenu.Item>
))
) : (
<p className="w-full text-center text-sm text-gray-400"> No apps available </p>
)}
</>
);
};

View file

@ -90,7 +90,11 @@ export const Categories = (props: { selected: Category; onSelectedChanged(c: Cat
<motion.div
onViewportEnter={() => lastCategoryVisibleHandler(index)}
onViewportLeave={() => lastCategoryVisibleHandler(index)}
viewport={{ root: ref, margin: '0% -120px 0% 0%' }}
viewport={{
root: ref,
// WARNING: Edge breaks if the values are not postfixed with px or %
margin: '0% -120px 0% 0%'
}}
className="min-w-fit"
key={category}
>

View file

@ -25,8 +25,8 @@ export type Platform = {
openLogsDir?(): void;
// Opens a file path with a given ID
openFilePath?(library: string, ids: number[]): any;
getFilePathOpenWithApps?(library: string, ids: number[]): any;
openFilePathWith?(library: string, fileIdsAndAppUrls: [number, string][]): any;
getFilePathOpenWithApps?(library: string, ids: number[]): Promise<unknown>;
openFilePathWith?(library: string, fileIdsAndAppUrls: [number, string][]): Promise<unknown>;
lockAppTheme?(themeType: 'Auto' | 'Light' | 'Dark'): any;
};

View file

@ -184,7 +184,7 @@ export type MaybeNot<T> = T | { not: T }
export type MediaData = { id: number; pixel_width: number | null; pixel_height: number | null; longitude: number | null; latitude: number | null; fps: number | null; capture_device_make: string | null; capture_device_model: string | null; capture_device_software: string | null; duration_seconds: number | null; codecs: string | null; streams: number | null }
export type Node = { id: number; pub_id: number[]; name: string; platform: number; version: string | null; last_seen: string; timezone: string | null; date_created: string }
export type Node = { id: number; pub_id: number[]; name: string; platform: number; date_created: string }
/**
* NodeConfig is the configuration for a node. This is shared between all libraries and is stored in a JSON file on disk.