Fixed sharing plugins when the share dialog of the operating system does not respond (#1088)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-19 14:39:12 +02:00
1 parent 7d35536c87
commit f26d132d56
16 files changed
+543 -76

No files matched your search

+185 -21
View File
@@ -16,12 +16,44 @@ pub struct ShareFileRequest {
file_path: String,
}
/// What became of a share request. Keep in sync with ShareSheetOutcome on the .NET side.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ShareOutcome {
/// The operating system opened its share UI for the file.
Opened,
/// The share UI could not be opened, or the request was invalid.
Failed,
/// Windows accepted the share request, but its share UI never asked for the file. Windows
/// reports no error then, and the share UI simply does not appear.
#[cfg(windows)]
Unresponsive,
}
#[derive(Serialize)]
pub struct ShareFileResponse {
success: bool,
outcome: ShareOutcome,
issue: String,
}
/// Why a share request did not open the share UI.
struct ShareFailure {
outcome: ShareOutcome,
issue: String,
}
/// Most failures are plain errors of the platform APIs, hence a text alone counts as a failure.
impl From<String> for ShareFailure {
fn from(issue: String) -> Self {
Self {
outcome: ShareOutcome::Failed,
issue,
}
}
}
pub async fn share_file(Json(request): Json<ShareFileRequest>) -> Json<ShareFileResponse> {
let path = PathBuf::from(request.file_path.trim());
if path.as_os_str().is_empty() {
@@ -49,14 +81,17 @@ pub async fn share_file(Json(request): Json<ShareFileRequest>) -> Json<ShareFile
Ok(()) => {
info!(Source = "Share sheet"; "Opened the native share UI.");
Json(ShareFileResponse {
success: true,
outcome: ShareOutcome::Opened,
issue: String::new(),
})
}
Err(issue) => {
error!(Source = "Share sheet"; "{issue}");
failure(issue)
Err(share_failure) => {
error!(Source = "Share sheet"; "{}", share_failure.issue);
Json(ShareFileResponse {
outcome: share_failure.outcome,
issue: share_failure.issue,
})
}
}
}
@@ -79,7 +114,7 @@ fn is_shareable_archive(path: &Path) -> bool {
fn failure(issue: impl Into<String>) -> Json<ShareFileResponse> {
Json(ShareFileResponse {
success: false,
outcome: ShareOutcome::Failed,
issue: issue.into(),
})
}
@@ -87,20 +122,81 @@ fn failure(issue: impl Into<String>) -> Json<ShareFileResponse> {
// 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."))
async fn share_file_on_platform(_path: PathBuf) -> Result<(), ShareFailure> {
Err(String::from("The native share sheet is not available on Linux.").into())
}
#[cfg(windows)]
async fn share_file_on_platform(path: PathBuf) -> Result<(), String> {
async fn share_file_on_platform(path: PathBuf) -> Result<(), ShareFailure> {
use std::cell::RefCell;
use windows::ApplicationModel::DataTransfer::{DataRequestedEventArgs, DataTransferManager};
use std::time::{Duration, Instant};
use log::{debug, warn};
use windows::ApplicationModel::DataTransfer::{DataPackage, DataRequestedEventArgs, DataTransferManager, ShareCompletedEventArgs};
use windows::Foundation::TypedEventHandler;
use windows::Storage::{IStorageItem, StorageFile};
use windows::Win32::UI::Shell::IDataTransferManagerInterop;
use windows::core::{factory, HSTRING, Interface};
use windows::core::{factory, IInspectable, HSTRING, Interface};
use windows_collections::IIterable;
/// How long Windows waits for the shared data. The documentation of DataRequest.GetDeferral
/// names 200 ms, after which the share operation times out.
const DATA_PACKAGE_DEADLINE: Duration = Duration::from_millis(200);
/// How long the share UI may take to ask for the shared file after Windows accepted the share
/// request. A working share UI asks right away, before it lists any target. The limit is
/// generous on purpose: we adjust it once the log shows how long slow computers take.
const DATA_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
/// Hands the shared file to Windows, once its share UI asks for it.
fn provide_shared_file(arguments: &DataRequestedEventArgs, path: &str) -> windows::core::Result<()> {
let request = arguments.Request()?;
let lookup_started = Instant::now();
let file = StorageFile::GetFileFromPathAsync(&HSTRING::from(path))?.get()?;
let lookup_duration = lookup_started.elapsed();
if lookup_duration > DATA_PACKAGE_DEADLINE {
warn!(Source = "Share sheet"; "Looking up the shared file took {} ms, but Windows allows only {} ms to provide the data.", lookup_duration.as_millis(), DATA_PACKAGE_DEADLINE.as_millis());
}
let file: IStorageItem = file.cast()?;
let items = IIterable::<IStorageItem>::from(vec![Some(file)]);
let data = request.Data()?;
data.Properties()?.SetTitle(&HSTRING::from("MindWork AI Studio"))?;
data.SetStorageItemsReadOnly(&items)?;
observe_share_outcome(&data);
Ok(())
}
/// Logs how the share ends. Windows reports this for each data package, and each share gets a
/// package of its own, so these handlers never stack up. Nothing depends on these events: they
/// only tell us in the log what the user saw.
fn observe_share_outcome(data: &DataPackage) {
let completed = TypedEventHandler::<DataPackage, ShareCompletedEventArgs>::new(|_, arguments| {
let target_app = arguments.as_ref()
.and_then(|arguments| arguments.ShareTarget().ok())
.and_then(|target| target.AppUserModelId().ok())
.map(|app_id| app_id.to_string())
.unwrap_or_default();
info!(Source = "Share sheet"; "The Windows share sheet shared the file with '{target_app}'.");
Ok(())
});
// Windows offers this event since version 1703:
if let Err(error) = data.ShareCompleted(&completed) {
debug!(Source = "Share sheet"; "Cannot observe whether the share completes: {error}");
}
let canceled = TypedEventHandler::<DataPackage, IInspectable>::new(|_, _| {
info!(Source = "Share sheet"; "The Windows share sheet closed without sharing the file.");
Ok(())
});
// Windows offers this event since version 2004:
if let Err(error) = data.ShareCanceled(&canceled) {
debug!(Source = "Share sheet"; "Cannot observe whether the share gets canceled: {error}");
}
}
// 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
@@ -115,6 +211,7 @@ async fn share_file_on_platform(path: PathBuf) -> Result<(), String> {
let ui_window = window.clone();
let path = path.to_string_lossy().to_string();
let (sender, receiver) = tokio::sync::oneshot::channel();
let (data_requested_sender, data_requested_receiver) = tokio::sync::oneshot::channel::<Result<(), String>>();
window.run_on_main_thread(move || {
let result = (|| -> Result<(), String> {
@@ -123,18 +220,39 @@ async fn share_file_on_platform(path: PathBuf) -> Result<(), String> {
.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}"))?;
//
// When its share UI really opens, Windows first asks for the data and lists the targets
// only afterward. How long that takes shows in the log whether the share UI responded at
// all. Registering the handler below takes no measurable time, so we start measuring here.
//
// The handler runs on the main thread. Like the window events in start_tauri, it must
// therefore never lock MAIN_WINDOW: it would deadlock as soon as anybody else holds
// that lock.
//
let share_started = Instant::now();
let mut data_requested = Some(data_requested_sender);
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(())
info!(Source = "Share sheet"; "Windows requested the shared file {} ms after AI Studio opened its share UI.", share_started.elapsed().as_millis());
let result = provide_shared_file(arguments, &path);
match data_requested.take() {
// The first request answers the share call, which waits for it:
Some(sender) => {
let _ = sender.send(result.clone().map_err(|error| format!("Failed to provide the shared file to Windows: {error}")));
},
// Windows asked again, or only after the share call stopped waiting. Windows
// shows a failure in its share UI, but would not log it anywhere:
None => if let Err(error) = &result {
error!(Source = "Share sheet"; "Failed to provide the shared file to Windows: {error}");
},
}
result
});
if let Some(previous_token) = DATA_REQUESTED_TOKEN.with(|token| token.borrow_mut().take()) {
let _ = manager.RemoveDataRequested(previous_token);
@@ -150,11 +268,25 @@ async fn share_file_on_platform(path: PathBuf) -> Result<(), String> {
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."))?
receiver.await.map_err(|_| String::from("The Windows share sheet did not return a result."))??;
//
// Windows accepts a share request even when its share UI hangs and never appears. Only when
// the share UI asks for the file do we know that it opened. We wait for that without blocking
// the worker, so that the other calls of the app go on meanwhile:
//
match tokio::time::timeout(DATA_REQUEST_TIMEOUT, data_requested_receiver).await {
Ok(Ok(result)) => result.map_err(ShareFailure::from),
Ok(Err(_)) => Err(String::from("The Windows share sheet discarded the share request before it asked for the file.").into()),
Err(_) => Err(ShareFailure {
outcome: ShareOutcome::Unresponsive,
issue: format!("Windows accepted the share request, but its share UI did not ask for the file within {} seconds.", DATA_REQUEST_TIMEOUT.as_secs()),
}),
}
}
#[cfg(target_os = "macos")]
async fn share_file_on_platform(path: PathBuf) -> Result<(), String> {
async fn share_file_on_platform(path: PathBuf) -> Result<(), ShareFailure> {
use std::cell::RefCell;
use objc2::rc::Retained;
use objc2::runtime::AnyObject;
@@ -196,5 +328,37 @@ async fn share_file_on_platform(path: PathBuf) -> Result<(), String> {
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."))?
receiver.await.map_err(|_| String::from("The macOS share sheet did not return a result."))?.map_err(ShareFailure::from)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_plain_error_text_counts_as_a_failure() {
let share_failure = ShareFailure::from(String::from("The main window is not available."));
assert_eq!(share_failure.outcome, ShareOutcome::Failed);
assert_eq!(share_failure.issue, "The main window is not available.");
}
/// The app reads these values as ShareSheetOutcome. A renamed value would turn every answer into
/// one the app does not know.
#[test]
fn the_response_names_the_outcome_as_the_app_reads_it() {
let opened = serde_json::to_value(ShareFileResponse { outcome: ShareOutcome::Opened, issue: String::new() }).unwrap();
let failed = serde_json::to_value(failure("The file path is empty.").0).unwrap();
assert_eq!(opened, serde_json::json!({ "outcome": "opened", "issue": "" }));
assert_eq!(failed, serde_json::json!({ "outcome": "failed", "issue": "The file path is empty." }));
}
#[cfg(windows)]
#[test]
fn an_unresponsive_share_ui_is_named_as_the_app_reads_it() {
let unresponsive = serde_json::to_value(ShareFileResponse { outcome: ShareOutcome::Unresponsive, issue: String::new() }).unwrap();
assert_eq!(unresponsive["outcome"], "unresponsive");
}
}