[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
Write-Host 'Installing Visual Studio Build Tools...' -ForegroundColor Yellow 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 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 # 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 ` winget install -e --accept-source-agreements --force --disable-interactivity --id Microsoft.VisualStudio.2022.BuildTools `
--override '--wait --quiet --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended' --override '--wait --quiet --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended'
@ -206,6 +208,8 @@ https://learn.microsoft.com/windows/package-manager/winget/
$LASTEXITCODE = 0 $LASTEXITCODE = 0
} }
# TODO: Install Strawberry perl, required by debug build of openssl-sys
Write-Host Write-Host
Write-Host 'Installing NodeJS...' -ForegroundColor Yellow Write-Host 'Installing NodeJS...' -ForegroundColor Yellow
# Check if Node.JS is already installed and if it's compatible with the project # 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 { $_.workflow_runs | ForEach-Object {
$artifactPath = ( $artifactPath = (
(Invoke-RestMethod -Uri ($_.artifacts_url | Out-String) -Method Get).artifacts ` (Invoke-RestMethodGithub -Uri ($_.artifacts_url | Out-String) -Method Get).artifacts `
| Where-Object { | Where-Object {
$_.name -eq "ffmpeg-${ffmpegVersion}-x86_64" $_.name -eq "ffmpeg-${ffmpegVersion}-x86_64"
} | ForEach-Object { } | ForEach-Object {

15
Cargo.lock generated
View file

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

View file

@ -29,12 +29,14 @@ pub enum Mode {
} }
fn terminal() -> Result<String> { fn terminal() -> Result<String> {
// TODO: Attemtp to read x-terminal-emulator bin (Debian/Ubuntu spec for setting default terminal)
SystemApps::get_entries() SystemApps::get_entries()
.ok() .ok()
.and_then(|mut entries| { .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) .ok_or(Error::NoTerminal)
} }

View file

@ -1,7 +1,7 @@
use std::{ use std::{
collections::{HashMap, HashSet, VecDeque}, collections::{BTreeSet, HashMap},
convert::TryFrom, convert::TryFrom,
ffi::OsString, ffi::OsStr,
}; };
use mime::Mime; use mime::Mime;
@ -10,49 +10,53 @@ use xdg_mime::SharedMimeInfo;
use crate::{DesktopEntry, Handler, HandlerType, Result}; use crate::{DesktopEntry, Handler, HandlerType, Result};
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone)]
pub struct SystemApps(pub HashMap<Mime, VecDeque<Handler>>); pub struct SystemApps(pub HashMap<Mime, BTreeSet<Handler>>);
impl SystemApps { impl SystemApps {
pub fn get_handlers(&self, handler_type: HandlerType) -> VecDeque<Handler> { pub fn get_handlers(&self, handler_type: HandlerType) -> impl Iterator<Item = &Handler> {
match handler_type { let mimes = match handler_type {
HandlerType::Ext(ext) => { HandlerType::Ext(ext) => {
let mut handlers: HashSet<Handler> = HashSet::new(); SharedMimeInfo::new().get_mime_types_from_file_name(ext.as_str())
for mime in SharedMimeInfo::new().get_mime_types_from_file_name(ext.as_str()) { }
if let Some(mime_handlers) = self.0.get(&mime) { HandlerType::Mime(mime) => vec![mime],
for handler in mime_handlers { };
handlers.insert(handler.clone());
} let mut handlers: BTreeSet<&Handler> = BTreeSet::new();
} for mime in mimes {
} if let Some(mime_handlers) = self.0.get(&mime) {
handlers.into_iter().collect() 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> { pub fn get_handler(&self, handler_type: HandlerType) -> Option<&Handler> {
Some(self.get_handlers(handler_type).get(0)?.clone()) 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()? Ok(xdg::BaseDirectories::new()?
.list_data_files_once("applications") .list_data_files_once("applications")
.into_iter() .into_iter()
.filter(|p| p.extension().and_then(|x| x.to_str()) == Some("desktop")) .filter(|p| p.extension().map_or(false, |x| x == OsStr::new("desktop")))
.filter_map(|p| Some((p.file_name()?.to_owned(), DesktopEntry::try_from(&p).ok()?)))) .filter_map(|p| DesktopEntry::try_from(&p).ok()))
} }
pub fn populate() -> Result<Self> { 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)| { Self::get_entries()?.for_each(
let (file_name, mimes) = (entry.file_name, entry.mimes); |DesktopEntry {
mimes.into_iter().for_each(|mime| { mimes, file_name, ..
map.entry(mime) }| {
.or_default() mimes.into_iter().for_each(|mime| {
.push_back(Handler::assume_valid(file_name.clone())); map.entry(mime)
}); .or_default()
}); .insert(Handler::assume_valid(file_name.clone()));
});
},
);
Ok(Self(map)) 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] [target.'cfg(target_os = "macos")'.dependencies]
sd-desktop-macos = { path = "../crates/macos" } sd-desktop-macos = { path = "../crates/macos" }
[target.'cfg(target_os = "windows")'.dependencies]
sd-desktop-windows = { path = "../crates/windows" }
[build-dependencies] [build-dependencies]
tauri-build = { version = "1.3.0", features = [] } tauri-build = { version = "1.3.0", features = [] }

View file

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

View file

@ -38,6 +38,6 @@ export function lockAppTheme(themeType: AppThemeType) {
return invoke()<null>("lock_app_theme", { themeType }) 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 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 } 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 { library } = useLibraryContext();
const items = useQuery<any[]>( const items = useQuery<unknown>(
['openWith', filePath.id], ['openWith', filePath.id],
() => actions.getFilePathOpenWithApps(library.uuid, [filePath.id]), () => actions.getFilePathOpenWithApps(library.uuid, [filePath.id]),
{ suspense: true } { suspense: true }
@ -42,25 +42,30 @@ const Items = ({
return ( return (
<> <>
{items.data?.map((data) => ( {Array.isArray(items.data) && items.data.length > 0 ? (
<ContextMenu.Item items.data.map((data, id) => (
key={data.name} <ContextMenu.Item
onClick={async () => { key={id}
try { onClick={async () => {
await actions.openFilePathWith(library.uuid, [ try {
(filePath.id, data.c.url) await actions.openFilePathWith(library.uuid, [
]); [filePath.id, data.url]
} catch { ]);
showAlertDialog({ } catch (e) {
title: 'Error', console.error(e);
value: `Failed to open file, with: ${data.url}` showAlertDialog({
}); title: 'Error',
} value: `Failed to open file, with: ${data.url}`
}} });
> }
{data.name} }}
</ContextMenu.Item> >
)) ?? <p> No apps available </p>} {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 <motion.div
onViewportEnter={() => lastCategoryVisibleHandler(index)} onViewportEnter={() => lastCategoryVisibleHandler(index)}
onViewportLeave={() => 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" className="min-w-fit"
key={category} key={category}
> >

View file

@ -25,8 +25,8 @@ export type Platform = {
openLogsDir?(): void; openLogsDir?(): void;
// Opens a file path with a given ID // Opens a file path with a given ID
openFilePath?(library: string, ids: number[]): any; openFilePath?(library: string, ids: number[]): any;
getFilePathOpenWithApps?(library: string, ids: number[]): any; getFilePathOpenWithApps?(library: string, ids: number[]): Promise<unknown>;
openFilePathWith?(library: string, fileIdsAndAppUrls: [number, string][]): any; openFilePathWith?(library: string, fileIdsAndAppUrls: [number, string][]): Promise<unknown>;
lockAppTheme?(themeType: 'Auto' | 'Light' | 'Dark'): any; 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 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. * NodeConfig is the configuration for a node. This is shared between all libraries and is stored in a JSON file on disk.