mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-04 22:49:40 +00:00
Added export and import of plugin archives (#885)
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
1 parent
e7407ce60a
commit
8a9e6aeb87
43 files changed
+2041
-256
No files matched your search
Generated
+54
-15
@@ -4259,6 +4259,9 @@ dependencies = [
|
||||
"image",
|
||||
"keyring-core",
|
||||
"log",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation 0.3.2",
|
||||
"once_cell",
|
||||
"pbkdf2",
|
||||
"pdfium-render",
|
||||
@@ -4292,6 +4295,8 @@ dependencies = [
|
||||
"webkit2gtk",
|
||||
"webm-iterable",
|
||||
"whoami",
|
||||
"windows 0.61.3",
|
||||
"windows-collections 0.2.0",
|
||||
"windows-native-keyring-store",
|
||||
"windows-registry",
|
||||
]
|
||||
@@ -4646,23 +4651,30 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "objc2-app-kit"
|
||||
version = "0.3.0"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5906f93257178e2f7ae069efb89fbd6ee94f0592740b5f8a1512ca498814d0fb"
|
||||
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"block2 0.6.2",
|
||||
"libc",
|
||||
"objc2 0.6.4",
|
||||
"objc2-cloud-kit",
|
||||
"objc2-core-data",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-image",
|
||||
"objc2-core-text",
|
||||
"objc2-core-video",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-quartz-core 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-cloud-kit"
|
||||
version = "0.3.0"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c1948a9be5f469deadbd6bcb86ad7ff9e47b4f632380139722f7d9840c0d42c"
|
||||
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"objc2 0.6.4",
|
||||
@@ -4671,10 +4683,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-data"
|
||||
version = "0.3.0"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f860f8e841f6d32f754836f51e6bc7777cd7e7053cf18528233f6811d3eceb4"
|
||||
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"objc2 0.6.4",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
@@ -4692,11 +4705,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-graphics"
|
||||
version = "0.3.0"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dca602628b65356b6513290a21a6405b4d4027b8b250f0b98dddbb28b7de02"
|
||||
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"dispatch2",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
"objc2-io-surface",
|
||||
@@ -4704,9 +4718,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-image"
|
||||
version = "0.3.0"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ffa6bea72bf42c78b0b34e89c0bafac877d5f80bf91e159a5d96ea7f693ca56"
|
||||
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
|
||||
dependencies = [
|
||||
"objc2 0.6.4",
|
||||
"objc2-foundation 0.3.2",
|
||||
@@ -4722,6 +4736,31 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-text"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-video"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-io-surface",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
@@ -4774,9 +4813,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "objc2-io-surface"
|
||||
version = "0.3.0"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "161a8b87e32610086e1a7a9e9ec39f84459db7b3a0881c1f16ca5a2605581c19"
|
||||
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"objc2 0.6.4",
|
||||
@@ -4833,9 +4872,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.0"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fb3794501bb1bee12f08dcad8c61f2a5875791ad1c6f47faa71a0f033f20071"
|
||||
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"objc2 0.6.4",
|
||||
@@ -4868,7 +4907,7 @@ dependencies = [
|
||||
"objc2-core-image",
|
||||
"objc2-core-location",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-quartz-core 0.3.0",
|
||||
"objc2-quartz-core 0.3.2",
|
||||
"objc2-user-notifications",
|
||||
]
|
||||
|
||||
|
||||
@@ -69,9 +69,14 @@ permutation_iterator = { git = "https://github.com/SommerEngineering/permutation
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-registry = "0.6.1"
|
||||
windows-native-keyring-store = "1.1.0"
|
||||
windows = { version = "=0.61.3", features = ["ApplicationModel_DataTransfer", "Foundation", "Foundation_Collections", "Storage", "Storage_Streams", "Win32_Foundation", "Win32_System_WinRT", "Win32_UI_Shell"] }
|
||||
windows-collections = "=0.2.0"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
apple-native-keyring-store = { version = "1.0.0", features = ["keychain"] }
|
||||
objc2 = "0.6.3"
|
||||
objc2-app-kit = { version = "0.3.2", features = ["NSResponder", "NSSharingService", "NSView"] }
|
||||
objc2-foundation = { version = "0.3.2", features = ["NSArray", "NSGeometry", "NSString", "NSURL"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
ashpd = { version = "0.13.12", default-features = false, features = ["tokio", "open_uri", "global_shortcuts"] }
|
||||
|
||||
+23
-26
@@ -328,16 +328,29 @@ pub async fn open_path_in_file_manager(
|
||||
});
|
||||
}
|
||||
|
||||
let Some(target) = resolve_file_manager_target(&requested_path) else {
|
||||
match open_file_manager_target(&requested_path).await {
|
||||
Ok(()) => Json(OpenPathResponse {
|
||||
success: true,
|
||||
issue: String::new(),
|
||||
}),
|
||||
|
||||
Err(issue) => {
|
||||
error!(Source = "Tauri"; "{issue}");
|
||||
Json(OpenPathResponse {
|
||||
success: false,
|
||||
issue,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn open_file_manager_target(requested_path: &Path) -> Result<(), String> {
|
||||
let Some(target) = resolve_file_manager_target(requested_path) else {
|
||||
let issue = format!(
|
||||
"The path does not exist and its parent folder could not be found: {}",
|
||||
requested_path.to_string_lossy(),
|
||||
);
|
||||
error!(Source = "Tauri"; "{issue}");
|
||||
return Json(OpenPathResponse {
|
||||
success: false,
|
||||
issue,
|
||||
});
|
||||
return Err(issue);
|
||||
};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -345,19 +358,10 @@ pub async fn open_path_in_file_manager(
|
||||
return match open_path_in_linux_file_manager(&target).await {
|
||||
Ok(()) => {
|
||||
info!("Opened file manager for path: {:?}", target.path);
|
||||
Json(OpenPathResponse {
|
||||
success: true,
|
||||
issue: String::new(),
|
||||
})
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Err(issue) => {
|
||||
error!(Source = "Tauri"; "{issue}");
|
||||
Json(OpenPathResponse {
|
||||
success: false,
|
||||
issue,
|
||||
})
|
||||
}
|
||||
Err(issue) => Err(issue),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -371,19 +375,12 @@ pub async fn open_path_in_file_manager(
|
||||
match command.spawn() {
|
||||
Ok(_) => {
|
||||
info!("Opened file manager for path: {:?}", target.path);
|
||||
Json(OpenPathResponse {
|
||||
success: true,
|
||||
issue: String::new(),
|
||||
})
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Err(error) => {
|
||||
let issue = format!("Failed to open the file manager: {error}");
|
||||
error!(Source = "Tauri"; "{issue}");
|
||||
Json(OpenPathResponse {
|
||||
success: false,
|
||||
issue,
|
||||
})
|
||||
Err(issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -19,6 +19,7 @@ pub mod qdrant_edge_database;
|
||||
pub mod certificate_factory;
|
||||
pub mod runtime_api_token;
|
||||
pub mod stale_process_cleanup;
|
||||
pub mod share_sheet;
|
||||
mod sidecar_types;
|
||||
mod file_actions;
|
||||
pub mod global_shortcuts;
|
||||
pub mod global_shortcuts;
|
||||
@@ -38,6 +38,7 @@ pub fn start_runtime_api() {
|
||||
.route("/system/qdrant-edge/delete-file", post(crate::qdrant_edge_database::delete_qdrant_edge_embedding_by_file))
|
||||
.route("/system/qdrant-edge/delete-store", post(crate::qdrant_edge_database::delete_qdrant_edge_store))
|
||||
.route("/clipboard/set", post(crate::clipboard::set_clipboard))
|
||||
.route("/share/file", post(crate::share_sheet::share_file))
|
||||
.route("/events", get(crate::app_window::get_event_stream))
|
||||
.route("/updates/check", get(crate::app_window::check_for_update))
|
||||
.route("/updates/install", get(crate::app_window::install_update))
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
use axum::Json;
|
||||
use log::{error, info};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use crate::api_token::APIToken;
|
||||
|
||||
/// The directory the app creates its shareable plugin archives in. Keep in sync with
|
||||
/// PluginShareService.TEMPORARY_ARCHIVE_DIRECTORY on the .NET side.
|
||||
const SHARE_DIRECTORY_NAME: &str = "mindwork-ai-studio-plugin-shares";
|
||||
|
||||
/// The file extension of plugin archives, without the leading dot. Keep in sync with
|
||||
/// PluginShareService.PLUGIN_FILE_EXTENSION on the .NET side.
|
||||
const SHARE_FILE_EXTENSION: &str = "mwplugin";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ShareFileRequest {
|
||||
file_path: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ShareFileResponse {
|
||||
success: bool,
|
||||
issue: String,
|
||||
}
|
||||
|
||||
pub async fn share_file(_token: APIToken, Json(request): Json<ShareFileRequest>) -> Json<ShareFileResponse> {
|
||||
let path = PathBuf::from(request.file_path.trim());
|
||||
if path.as_os_str().is_empty() {
|
||||
return failure("The file path is empty.");
|
||||
}
|
||||
|
||||
if !path.is_file() {
|
||||
return failure(format!("The requested path is not an existing file: {}", path.to_string_lossy()));
|
||||
}
|
||||
|
||||
// Resolve the path before validating it, so a symlink with a matching name cannot point at an
|
||||
// arbitrary file. We share the original path afterwards, though: on Windows, canonicalize
|
||||
// returns a \\?\ path, which the WinRT storage APIs do not accept.
|
||||
let resolved_path = match std::fs::canonicalize(&path) {
|
||||
Ok(resolved_path) => resolved_path,
|
||||
Err(error) => return failure(format!("The requested path could not be resolved: {error}")),
|
||||
};
|
||||
|
||||
if !is_shareable_archive(&resolved_path) {
|
||||
return failure(format!("The requested path is not a plugin archive created by AI Studio: {}", path.to_string_lossy()));
|
||||
}
|
||||
|
||||
let result = share_file_on_platform(path).await;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
info!(Source = "Share sheet"; "Opened the native share UI.");
|
||||
Json(ShareFileResponse {
|
||||
success: true,
|
||||
issue: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
Err(issue) => {
|
||||
error!(Source = "Share sheet"; "{issue}");
|
||||
failure(issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks that a path points to a plugin archive the app itself created for sharing. This keeps the
|
||||
/// endpoint from handing arbitrary readable files to the operating system's share UI.
|
||||
///
|
||||
/// We match the directory by name instead of comparing it against the temporary directory: Rust and
|
||||
/// .NET do not have to agree on where that is, and a mismatch would break sharing entirely.
|
||||
fn is_shareable_archive(path: &Path) -> bool {
|
||||
let has_archive_extension = path.extension().is_some_and(|extension| extension.eq_ignore_ascii_case(SHARE_FILE_EXTENSION));
|
||||
if !has_archive_extension {
|
||||
return false;
|
||||
}
|
||||
|
||||
path.parent()
|
||||
.and_then(|parent| parent.file_name())
|
||||
.is_some_and(|directory_name| directory_name == SHARE_DIRECTORY_NAME)
|
||||
}
|
||||
|
||||
fn failure(issue: impl Into<String>) -> Json<ShareFileResponse> {
|
||||
Json(ShareFileResponse {
|
||||
success: false,
|
||||
issue: issue.into(),
|
||||
})
|
||||
}
|
||||
|
||||
// Linux has no native share sheet: the XDG desktop portals do not provide a share interface. The
|
||||
// app exports the file through the save dialog instead, hence this endpoint is not used on Linux:
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn share_file_on_platform(_path: PathBuf) -> Result<(), String> {
|
||||
Err(String::from("The native share sheet is not available on Linux."))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
async fn share_file_on_platform(path: PathBuf) -> Result<(), String> {
|
||||
use std::cell::RefCell;
|
||||
use windows::ApplicationModel::DataTransfer::{DataRequestedEventArgs, DataTransferManager};
|
||||
use windows::Foundation::{EventRegistrationToken, TypedEventHandler};
|
||||
use windows::Storage::{IStorageItem, StorageFile};
|
||||
use windows::Win32::UI::Shell::IDataTransferManagerInterop;
|
||||
use windows::core::{factory, HSTRING, Interface};
|
||||
use windows_collections::IIterable;
|
||||
|
||||
// The DataTransferManager belongs to the window, not to a single share. Registering a handler
|
||||
// for every share would stack them up, and each stale handler keeps pointing at the archive of
|
||||
// its own share, which gets cleaned up after a while. We therefore remember the registration
|
||||
// and remove the previous handler before adding a new one. We only ever register on the main
|
||||
// thread, hence a thread-local reference is sufficient:
|
||||
thread_local! {
|
||||
static DATA_REQUESTED_TOKEN: RefCell<Option<EventRegistrationToken>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
let window = crate::app_window::MAIN_WINDOW.lock().unwrap().clone()
|
||||
.ok_or_else(|| String::from("The main window is not available."))?;
|
||||
let ui_window = window.clone();
|
||||
let path = path.to_string_lossy().to_string();
|
||||
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||||
|
||||
window.run_on_main_thread(move || {
|
||||
let result = (|| -> Result<(), String> {
|
||||
let hwnd = ui_window.hwnd().map_err(|error| format!("Failed to get the native window handle: {error}"))?;
|
||||
let interop: IDataTransferManagerInterop = factory::<DataTransferManager, IDataTransferManagerInterop>()
|
||||
.map_err(|error| format!("Failed to access the Windows share service: {error}"))?;
|
||||
let manager: DataTransferManager = unsafe { interop.GetForWindow(hwnd) }
|
||||
.map_err(|error| format!("Failed to create the Windows share request: {error}"))?;
|
||||
let handler = TypedEventHandler::<DataTransferManager, DataRequestedEventArgs>::new(move |_, arguments| {
|
||||
let Some(arguments) = arguments.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let request = arguments.Request()?;
|
||||
let file = StorageFile::GetFileFromPathAsync(&HSTRING::from(&path))?.get()?;
|
||||
let file: IStorageItem = file.cast()?;
|
||||
let items = IIterable::<IStorageItem>::from(vec![Some(file)]);
|
||||
request.Data()?.Properties()?.SetTitle(&HSTRING::from("MindWork AI Studio"))?;
|
||||
request.Data()?.SetStorageItemsReadOnly(&items)?;
|
||||
Ok(())
|
||||
});
|
||||
if let Some(previous_token) = DATA_REQUESTED_TOKEN.with(|token| token.borrow_mut().take()) {
|
||||
let _ = manager.RemoveDataRequested(previous_token);
|
||||
}
|
||||
|
||||
let token = manager.DataRequested(&handler)
|
||||
.map_err(|error| format!("Failed to provide the shared file: {error}"))?;
|
||||
DATA_REQUESTED_TOKEN.with(|current| current.replace(Some(token)));
|
||||
unsafe { interop.ShowShareUIForWindow(hwnd) }
|
||||
.map_err(|error| format!("Failed to open the Windows share sheet: {error}"))?;
|
||||
Ok(())
|
||||
})();
|
||||
let _ = sender.send(result);
|
||||
}).map_err(|error| format!("Failed to schedule the Windows share sheet: {error}"))?;
|
||||
|
||||
receiver.await.map_err(|_| String::from("The Windows share sheet did not return a result."))?
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
async fn share_file_on_platform(path: PathBuf) -> Result<(), String> {
|
||||
use std::cell::RefCell;
|
||||
use objc2::rc::Retained;
|
||||
use objc2::runtime::AnyObject;
|
||||
use objc2::{AnyThread, MainThreadMarker};
|
||||
use objc2_app_kit::{NSSharingServicePicker, NSView};
|
||||
use objc2_foundation::{NSArray, NSRect, NSRectEdge, NSString, NSURL};
|
||||
|
||||
// AppKit does not retain the picker while its UI is shown. Without a strong reference of our
|
||||
// own, the picker would be deallocated right after showRelativeToRect and the share sheet
|
||||
// would close immediately. We create and replace the picker on the main thread only, hence a
|
||||
// thread-local reference is sufficient:
|
||||
thread_local! {
|
||||
static CURRENT_PICKER: RefCell<Option<Retained<NSSharingServicePicker>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
let window = crate::app_window::MAIN_WINDOW.lock().unwrap().clone()
|
||||
.ok_or_else(|| String::from("The main window is not available."))?;
|
||||
let ui_window = window.clone();
|
||||
let path = path.to_string_lossy().to_string();
|
||||
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||||
|
||||
window.run_on_main_thread(move || {
|
||||
let result = (|| -> Result<(), String> {
|
||||
// We create the NSView reference from a raw pointer below, which bypasses the
|
||||
// main-thread guarantee of objc2. Thus, we assert the main thread ourselves:
|
||||
let _mtm = MainThreadMarker::new().ok_or_else(|| String::from("The macOS share sheet must run on the main thread."))?;
|
||||
let path = NSString::from_str(&path);
|
||||
let url = NSURL::fileURLWithPath(&path);
|
||||
let item: Retained<AnyObject> = Retained::into_super(Retained::into_super(url));
|
||||
let items = NSArray::from_retained_slice(&[item]);
|
||||
|
||||
// Safety: the items are NSURL instances, which conform to NSPasteboardWriting.
|
||||
let picker = unsafe { NSSharingServicePicker::initWithItems(NSSharingServicePicker::alloc(), &items) };
|
||||
let view = unsafe { &*ui_window.ns_view().map_err(|error| format!("Failed to get the native view: {error}"))?.cast::<NSView>() };
|
||||
picker.showRelativeToRect_ofView_preferredEdge(NSRect::ZERO, view, NSRectEdge::MinY);
|
||||
CURRENT_PICKER.with(|current| current.replace(Some(picker)));
|
||||
Ok(())
|
||||
})();
|
||||
let _ = sender.send(result);
|
||||
}).map_err(|error| format!("Failed to schedule the macOS share sheet: {error}"))?;
|
||||
|
||||
receiver.await.map_err(|_| String::from("The macOS share sheet did not return a result."))?
|
||||
}
|
||||
Reference in new issue
Block a user