diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
index 17db92a21..613ebaa83 100644
--- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
+++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua
@@ -11086,9 +11086,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2057806005"] = "Enable plugin"
-- Plugins
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2222816203"] = "Plugins"
+-- {0} Do you want to save the plugin archive to a location of your choice instead? Until you restart AI Studio, the plugin page offers this export instead of sharing.
+UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2430906247"] = "{0} Do you want to save the plugin archive to a location of your choice instead? Until you restart AI Studio, the plugin page offers this export instead of sharing."
+
-- The tile '{0}' has been updated.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2443911707"] = "The tile '{0}' has been updated."
+-- Share dialog not available
+UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2445896850"] = "Share dialog not available"
+
-- Edit Assistant Plugin
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2477579768"] = "Edit Assistant Plugin"
@@ -14017,6 +14023,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T2221093487"] =
-- Unexpected error: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T2350673880"] = "Unexpected error: {0}"
+-- The share dialog of your operating system did not respond.
+UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T254952756"] = "The share dialog of your operating system did not respond."
+
-- The plugin has no local directory.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T3284289028"] = "The plugin has no local directory."
diff --git a/app/MindWork AI Studio/Pages/Plugins.razor b/app/MindWork AI Studio/Pages/Plugins.razor
index dfb91ddaa..6f92e9309 100644
--- a/app/MindWork AI Studio/Pages/Plugins.razor
+++ b/app/MindWork AI Studio/Pages/Plugins.razor
@@ -131,7 +131,7 @@
@if (context is IAvailablePlugin shareablePlugin && CanSharePlugin(shareablePlugin))
{
-
+
}
diff --git a/app/MindWork AI Studio/Pages/Plugins.razor.cs b/app/MindWork AI Studio/Pages/Plugins.razor.cs
index cde1857ab..81d707398 100644
--- a/app/MindWork AI Studio/Pages/Plugins.razor.cs
+++ b/app/MindWork AI Studio/Pages/Plugins.razor.cs
@@ -274,14 +274,15 @@ public partial class Plugins : MSGComponentBase
private bool AllowPluginSharing => this.SettingsManager.ConfigurationData.App.AllowUserToSharePlugins;
///
- /// Linux has no native share sheet, hence the plugin archive is exported to a location of the
- /// user's choice there. The action must be labeled accordingly.
+ /// Where no native share sheet can be used, the plugin archive is exported to a location of the
+ /// user's choice instead: on Linux, which has none, and once the share sheet failed or did not
+ /// respond, until AI Studio restarts. The action must be labeled accordingly.
///
- private static string SharePluginIcon => OperatingSystem.IsLinux() ? Icons.Material.Filled.FileDownload : Icons.Material.Filled.IosShare;
+ private string SharePluginIcon => this.PluginShareService.UsesExport ? Icons.Material.Filled.FileDownload : Icons.Material.Filled.IosShare;
- private string SharePluginTooltip => OperatingSystem.IsLinux() ? this.T("Export plugin archive") : this.T("Share plugin archive");
+ private string SharePluginTooltip => this.PluginShareService.UsesExport ? this.T("Export plugin archive") : this.T("Share plugin archive");
- private string SharePluginLockText => OperatingSystem.IsLinux() ? this.T("Your organization has disabled exporting plugins.") : this.T("Your organization has disabled sharing plugins.");
+ private string SharePluginLockText => this.PluginShareService.UsesExport ? this.T("Your organization has disabled exporting plugins.") : this.T("Your organization has disabled sharing plugins.");
private async Task OpenAssistantPluginEditorDialogAsync(IAvailablePlugin plugin)
{
@@ -370,20 +371,38 @@ public partial class Plugins : MSGComponentBase
try
{
+ // A failed share sheet switches to the export, so we remember what this click does:
+ var isExport = this.PluginShareService.UsesExport;
var shareResult = await this.PluginShareService.ShareAsync(plugin, CancellationToken.None);
- if (shareResult.Cancelled)
- return;
-
- if (!shareResult.Success)
+ if (shareResult.Outcome is PluginShareOutcome.SHARE_SHEET_UNAVAILABLE)
{
- LOG.LogError($"Sharing the plugin '{shareResult.PluginName}' from archive '{shareResult.ArchivePath}' failed with Issue: '{shareResult.Issue}'.");
- await this.MessageBus.SendError(new(Icons.Material.Filled.ReportProblem, OperatingSystem.IsLinux() ? T("An error occurred while exporting the plugin.") : T("An error occurred while sharing the plugin.")));
- return;
+ // The runtime and NativeShareService already logged why the share sheet did not open:
+ if (!await this.ConfirmExportInsteadOfSharingAsync(shareResult.Issue))
+ return;
+
+ isExport = true;
+ shareResult = await this.PluginShareService.ExportAsync(plugin, CancellationToken.None);
}
- // On Linux, the user chose the target location, so we confirm where the archive was stored:
- if (OperatingSystem.IsLinux())
- await this.MessageBus.SendSuccess(new(Icons.Material.Filled.FileDownload, string.Format(T("The plugin archive was exported to '{0}'."), shareResult.ArchivePath)));
+ switch (shareResult.Outcome)
+ {
+ // On export, the user chose the target location, so we confirm where the archive was stored:
+ case PluginShareOutcome.EXPORTED:
+ await this.MessageBus.SendSuccess(new(Icons.Material.Filled.FileDownload, string.Format(T("The plugin archive was exported to '{0}'."), shareResult.ArchivePath)));
+ break;
+
+ case PluginShareOutcome.FAILED:
+ LOG.LogError($"Sharing the plugin '{shareResult.PluginName}' from archive '{shareResult.ArchivePath}' failed with Issue: '{shareResult.Issue}'.");
+ await this.MessageBus.SendError(new(Icons.Material.Filled.ReportProblem, isExport ? T("An error occurred while exporting the plugin.") : T("An error occurred while sharing the plugin.")));
+ break;
+
+ // The share sheet takes it from here, and a canceled export needs no message. The
+ // export never reports an unavailable share sheet:
+ case PluginShareOutcome.SHARED:
+ case PluginShareOutcome.CANCELLED:
+ case PluginShareOutcome.SHARE_SHEET_UNAVAILABLE:
+ break;
+ }
}
finally
{
@@ -392,6 +411,24 @@ public partial class Plugins : MSGComponentBase
}
}
+ ///
+ /// Offers to export the plugin archive after the share sheet of the operating system failed or
+ /// did not respond.
+ ///
+ /// Why the share sheet did not open.
+ /// True when the user wants to export the archive instead.
+ private async Task ConfirmExportInsteadOfSharingAsync(string issue)
+ {
+ var dialogParameters = new DialogParameters
+ {
+ { x => x.Message, string.Format(this.T("{0} Do you want to save the plugin archive to a location of your choice instead? Until you restart AI Studio, the plugin page offers this export instead of sharing."), issue) },
+ };
+
+ var dialogReference = await this.DialogService.ShowAsync(this.T("Share dialog not available"), dialogParameters, DialogOptions.FULLSCREEN);
+ var dialogResult = await dialogReference.Result;
+ return dialogResult is not null && !dialogResult.Canceled;
+ }
+
private async Task ImportAssistantPluginAsync()
{
if (this.isImportingAssistantPlugin)
diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua
index 1c19cac12..0ae66abe0 100644
--- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua
+++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua
@@ -11088,9 +11088,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2057806005"] = "Plugin aktivieren"
-- Plugins
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2222816203"] = "Plugins"
+-- {0} Do you want to save the plugin archive to a location of your choice instead? Until you restart AI Studio, the plugin page offers this export instead of sharing.
+UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2430906247"] = "{0} Möchten Sie das Plugin-Archiv stattdessen an einem Ort Ihrer Wahl speichern? Bis zum Neustart von AI Studio bietet die Plugin-Seite diesen Export anstelle des Teilens an."
+
-- The tile '{0}' has been updated.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2443911707"] = "Die Kachel „{0}“ wurde aktualisiert."
+-- Share dialog not available
+UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2445896850"] = "Dialog zum Teilen nicht verfügbar"
+
-- Edit Assistant Plugin
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2477579768"] = "Plugin für „Assistent bearbeiten“"
@@ -14019,6 +14025,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T2221093487"] =
-- Unexpected error: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T2350673880"] = "Unerwarteter Fehler: {0}"
+-- The share dialog of your operating system did not respond.
+UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T254952756"] = "Der systemeigene Dialog zum Teilen hat nicht reagiert."
+
-- The plugin has no local directory.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T3284289028"] = "Das Plugin hat kein lokales Verzeichnis."
diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua
index d85a59ec5..499c9fe92 100644
--- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua
+++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua
@@ -11088,9 +11088,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2057806005"] = "Enable plugin"
-- Plugins
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2222816203"] = "Plugins"
+-- {0} Do you want to save the plugin archive to a location of your choice instead? Until you restart AI Studio, the plugin page offers this export instead of sharing.
+UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2430906247"] = "{0} Do you want to save the plugin archive to a location of your choice instead? Until you restart AI Studio, the plugin page offers this export instead of sharing."
+
-- The tile '{0}' has been updated.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2443911707"] = "The tile '{0}' has been updated."
+-- Share dialog not available
+UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2445896850"] = "Share dialog not available"
+
-- Edit Assistant Plugin
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2477579768"] = "Edit Assistant Plugin"
@@ -14019,6 +14025,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T2221093487"] =
-- Unexpected error: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T2350673880"] = "Unexpected error: {0}"
+-- The share dialog of your operating system did not respond.
+UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T254952756"] = "The share dialog of your operating system did not respond."
+
-- The plugin has no local directory.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T3284289028"] = "The plugin has no local directory."
diff --git a/app/MindWork AI Studio/Program.cs b/app/MindWork AI Studio/Program.cs
index 686b8c18b..39700b223 100644
--- a/app/MindWork AI Studio/Program.cs
+++ b/app/MindWork AI Studio/Program.cs
@@ -239,7 +239,7 @@ internal sealed class Program
builder.Services.AddSingleton();
builder.Services.AddHostedService(serviceProvider => serviceProvider.GetRequiredService());
builder.Services.AddHostedService();
- builder.Services.AddScoped();
+ builder.Services.AddSingleton();
builder.Services.AddScoped();
//
diff --git a/app/MindWork AI Studio/Tools/Rust/ShareFileResponse.cs b/app/MindWork AI Studio/Tools/Rust/ShareFileResponse.cs
new file mode 100644
index 000000000..300807b26
--- /dev/null
+++ b/app/MindWork AI Studio/Tools/Rust/ShareFileResponse.cs
@@ -0,0 +1,9 @@
+namespace AIStudio.Tools.Rust;
+
+///
+/// The answer of the runtime to a request to share a file through the share sheet of the
+/// operating system.
+///
+/// What became of the request.
+/// Why the share sheet did not open; empty when it opened.
+public sealed record ShareFileResponse(ShareSheetOutcome Outcome, string Issue);
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Tools/Rust/ShareSheetOutcome.cs b/app/MindWork AI Studio/Tools/Rust/ShareSheetOutcome.cs
new file mode 100644
index 000000000..b7546f732
--- /dev/null
+++ b/app/MindWork AI Studio/Tools/Rust/ShareSheetOutcome.cs
@@ -0,0 +1,30 @@
+namespace AIStudio.Tools.Rust;
+
+///
+/// What became of a request to open the share sheet of the operating system. Keep in sync with
+/// ShareOutcome in runtime/src/share_sheet.rs.
+///
+public enum ShareSheetOutcome
+{
+ ///
+ /// The runtime sent an outcome the app does not know, or none at all. The app reads this as a
+ /// failure, never as an opened share sheet.
+ ///
+ UNKNOWN,
+
+ ///
+ /// The operating system opened its share sheet for the file.
+ ///
+ OPENED,
+
+ ///
+ /// The share sheet could not be opened, or the request was invalid.
+ ///
+ FAILED,
+
+ ///
+ /// Windows accepted the share request, but its share sheet never asked for the file. Windows
+ /// reports no error then, and the share sheet simply does not appear.
+ ///
+ UNRESPONSIVE,
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Tools/Services/NativeShareService.cs b/app/MindWork AI Studio/Tools/Services/NativeShareService.cs
index aa42f5ee6..4db5cd980 100644
--- a/app/MindWork AI Studio/Tools/Services/NativeShareService.cs
+++ b/app/MindWork AI Studio/Tools/Services/NativeShareService.cs
@@ -1,6 +1,40 @@
+using AIStudio.Tools.Rust;
+
namespace AIStudio.Tools.Services;
-public sealed class NativeShareService(RustService rustService)
+///
+/// Hands files to the share sheet of the operating system, and remembers when that share sheet
+/// failed.
+///
+///
+/// A share sheet which hung once tends to stay broken: on Windows, it then accepts every further
+/// request without ever appearing, so the share button seems to do nothing at all. Once the share
+/// sheet failed or did not respond, AI Studio therefore exports plugin archives instead, until it
+/// restarts. This service is a singleton, so that the decision holds for every window.
+///
+public sealed class NativeShareService(RustService rustService, ILogger logger)
{
- public Task Share(string filePath) => rustService.ShareFile(filePath);
-}
+ private volatile bool isUnavailable;
+
+ ///
+ /// Whether the share sheet of the operating system can be used. Linux has none, and on Windows
+ /// and macOS it counts as unavailable once it failed or did not respond.
+ ///
+ public bool IsAvailable => !OperatingSystem.IsLinux() && !this.isUnavailable;
+
+ ///
+ /// Opens the share sheet of the operating system for a file.
+ ///
+ /// The plugin archive to share.
+ /// What became of the request.
+ public async Task Share(string filePath)
+ {
+ var response = await rustService.ShareFile(filePath);
+ if (response.Outcome is ShareSheetOutcome.OPENED || this.isUnavailable)
+ return response;
+
+ this.isUnavailable = true;
+ logger.LogWarning("The native share sheet did not open ({Outcome}). Until AI Studio restarts, plugin archives are exported instead.", response.Outcome);
+ return response;
+ }
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Tools/Services/PluginShareOutcome.cs b/app/MindWork AI Studio/Tools/Services/PluginShareOutcome.cs
new file mode 100644
index 000000000..243e2fb5c
--- /dev/null
+++ b/app/MindWork AI Studio/Tools/Services/PluginShareOutcome.cs
@@ -0,0 +1,33 @@
+namespace AIStudio.Tools.Services;
+
+///
+/// What became of sharing or exporting a plugin archive.
+///
+public enum PluginShareOutcome
+{
+ ///
+ /// The share sheet of the operating system opened for the plugin archive.
+ ///
+ SHARED,
+
+ ///
+ /// The plugin archive was written to the location the user chose.
+ ///
+ EXPORTED,
+
+ ///
+ /// The user canceled choosing a location for the export.
+ ///
+ CANCELLED,
+
+ ///
+ /// The plugin could not be shared or exported.
+ ///
+ FAILED,
+
+ ///
+ /// The share sheet of the operating system failed or did not respond. The plugin archive can
+ /// still be exported instead.
+ ///
+ SHARE_SHEET_UNAVAILABLE,
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Tools/Services/PluginShareResult.cs b/app/MindWork AI Studio/Tools/Services/PluginShareResult.cs
index dcf1ada81..a6a881849 100644
--- a/app/MindWork AI Studio/Tools/Services/PluginShareResult.cs
+++ b/app/MindWork AI Studio/Tools/Services/PluginShareResult.cs
@@ -1,3 +1,10 @@
namespace AIStudio.Tools.Services;
-public sealed record PluginShareResult(bool Success, string PluginName, string ArchivePath, string Issue, bool Cancelled = false);
\ No newline at end of file
+///
+/// The result of sharing or exporting a plugin archive.
+///
+/// What became of the request.
+/// The name of the plugin.
+/// Where the archive was written; empty when there is none.
+/// Why the plugin could not be shared or exported; empty otherwise.
+public sealed record PluginShareResult(PluginShareOutcome Outcome, string PluginName, string ArchivePath, string Issue);
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Tools/Services/PluginShareService.cs b/app/MindWork AI Studio/Tools/Services/PluginShareService.cs
index 1c5f2c05e..cd0025de8 100644
--- a/app/MindWork AI Studio/Tools/Services/PluginShareService.cs
+++ b/app/MindWork AI Studio/Tools/Services/PluginShareService.cs
@@ -7,8 +7,10 @@ namespace AIStudio.Tools.Services;
public sealed class PluginShareService(NativeShareService nativeShareService, RustService rustService, SettingsManager settingsManager, ILogger logger)
{
- private static PluginShareResult ShareError(IAvailablePlugin plugin, string issue) => new(false, plugin.Name, string.Empty, issue);
-
+ private static PluginShareResult ShareError(IAvailablePlugin plugin, string issue) => new(PluginShareOutcome.FAILED, plugin.Name, string.Empty, issue);
+
+ private static PluginShareResult ShareSheetUnavailable(IAvailablePlugin plugin, string issue) => new(PluginShareOutcome.SHARE_SHEET_UNAVAILABLE, plugin.Name, string.Empty, issue);
+
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PluginShareService).Namespace, nameof(PluginShareService));
private const string PLUGIN_FILE_NAME = "plugin.lua";
@@ -22,6 +24,12 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru
private const int TEMPORARY_ARCHIVE_RETENTION_HOURS = 24;
private const int FILE_NAME_PREFIX_MAX_LEN = 80;
+ ///
+ /// Whether sharing a plugin exports its archive to a location of the user's choice instead of
+ /// handing it to the share sheet of the operating system.
+ ///
+ public bool UsesExport => !nativeShareService.IsAvailable;
+
///
/// Creates a shareable plugin archive from a local plugin and hands it over to the user.
/// The archive contains the plugin root contents, so plugin.lua is located at the archive root.
@@ -29,31 +37,73 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru
///
/// On Windows and macOS, the archive is created in a temporary directory and handed over to the
/// native share sheet. Linux has no such share sheet, since the XDG desktop portals do not provide
- /// a share interface. Thus, the archive is exported to a location of the user's choice there.
+ /// a share interface. Thus, the archive is exported to a location of the user's choice there. The
+ /// same happens on Windows and macOS once the share sheet failed or did not respond, until AI
+ /// Studio restarts.
///
/// The local plugin to archive and share.
/// Cancellation token for archive creation.
- /// The share result, including the archive path when successful.
+ /// The share result, including the archive path when successful. When the share sheet
+ /// failed or did not respond, the outcome is SHARE_SHEET_UNAVAILABLE, and ExportAsync can still
+ /// export the plugin.
public async Task ShareAsync(IAvailablePlugin plugin, CancellationToken token)
{
- if (plugin.IsInternal)
- return ShareError(plugin, TB("Internal plugins cannot be shared."));
-
- if (plugin.IsManagedByConfigServer)
- return ShareError(plugin, TB("Config Server managed plugins cannot be shared."));
-
- if (!settingsManager.ConfigurationData.App.AllowUserToSharePlugins)
- return ShareError(plugin, TB("Your organization has disabled sharing plugins."));
-
- if (!TryGetPluginRoot(plugin, out var pluginRoot, out var issue))
+ if (!this.TryGetShareablePluginRoot(plugin, out var pluginRoot, out var issue))
return ShareError(plugin, issue);
- if (OperatingSystem.IsLinux())
- return await this.ExportAsync(plugin, pluginRoot, token);
+ if (this.UsesExport)
+ return await this.ExportArchiveAsync(plugin, pluginRoot, token);
return await this.ShareViaNativeSheetAsync(plugin, pluginRoot, token);
}
+ ///
+ /// Exports a plugin archive to a location of the user's choice, even where a share sheet would
+ /// be available. This is the way out after the share sheet failed or did not respond.
+ ///
+ /// The local plugin to archive and export.
+ /// Cancellation token for archive creation.
+ /// The export result, including the chosen archive path when successful.
+ public async Task ExportAsync(IAvailablePlugin plugin, CancellationToken token)
+ {
+ if (!this.TryGetShareablePluginRoot(plugin, out var pluginRoot, out var issue))
+ return ShareError(plugin, issue);
+
+ return await this.ExportArchiveAsync(plugin, pluginRoot, token);
+ }
+
+ ///
+ /// Checks whether a plugin may leave AI Studio as an archive at all, no matter whether it is
+ /// shared or exported.
+ ///
+ /// The local plugin to check.
+ /// The validated plugin root directory, when the plugin may leave.
+ /// Why the plugin may not leave; empty otherwise.
+ /// True when the plugin may be shared or exported.
+ private bool TryGetShareablePluginRoot(IAvailablePlugin plugin, out string pluginRoot, out string issue)
+ {
+ pluginRoot = string.Empty;
+ if (plugin.IsInternal)
+ {
+ issue = TB("Internal plugins cannot be shared.");
+ return false;
+ }
+
+ if (plugin.IsManagedByConfigServer)
+ {
+ issue = TB("Config Server managed plugins cannot be shared.");
+ return false;
+ }
+
+ if (!settingsManager.ConfigurationData.App.AllowUserToSharePlugins)
+ {
+ issue = TB("Your organization has disabled sharing plugins.");
+ return false;
+ }
+
+ return TryGetPluginRoot(plugin, out pluginRoot, out issue);
+ }
+
///
/// Asks the user for a target location and writes the plugin archive to it.
///
@@ -61,12 +111,12 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru
/// The validated plugin root directory.
/// Cancellation token for archive creation.
/// The share result, including the chosen archive path when successful.
- private async Task ExportAsync(IAvailablePlugin plugin, string pluginRoot, CancellationToken token)
+ private async Task ExportArchiveAsync(IAvailablePlugin plugin, string pluginRoot, CancellationToken token)
{
var suggestedFileName = $"{CreateSafeFileNamePrefix(plugin.Name)}{PluginArchive.PLUGIN_FILE_EXTENSION}";
var saveResponse = await rustService.SaveFile(TB("Export plugin archive"), [FileTypes.PLUGIN_ARCHIVE], suggestedFileName);
if (saveResponse.UserCancelled)
- return new(false, plugin.Name, string.Empty, string.Empty, true);
+ return new(PluginShareOutcome.CANCELLED, plugin.Name, string.Empty, string.Empty);
var archivePath = saveResponse.SaveFilePath;
try
@@ -88,7 +138,7 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru
}, token);
logger.LogInformation("Exported plugin '{PluginName}' ({PluginId}) to the archive '{ArchivePath}'.", plugin.Name, plugin.Id, archivePath);
- return new(true, plugin.Name, archivePath, string.Empty);
+ return new(PluginShareOutcome.EXPORTED, plugin.Name, archivePath, string.Empty);
}
catch (OperationCanceledException)
{
@@ -128,14 +178,28 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru
}, token);
token.ThrowIfCancellationRequested();
- if (!await nativeShareService.Share(archivePath))
+ var shareResponse = await nativeShareService.Share(archivePath);
+ switch (shareResponse.Outcome)
{
- this.TryDeleteArchive(archivePath);
- return ShareError(plugin, TB("The native share dialog could not be opened."));
+ case ShareSheetOutcome.OPENED:
+ break;
+
+ //
+ // A share sheet which did not respond may still appear later and ask for the archive.
+ // We keep it for that case; the clean-up removes it once it expired:
+ //
+ case ShareSheetOutcome.UNRESPONSIVE:
+ return ShareSheetUnavailable(plugin, TB("The share dialog of your operating system did not respond."));
+
+ case ShareSheetOutcome.FAILED:
+ case ShareSheetOutcome.UNKNOWN:
+ default:
+ this.TryDeleteArchive(archivePath);
+ return ShareSheetUnavailable(plugin, TB("The native share dialog could not be opened."));
}
logger.LogInformation("Created plugin archive '{ArchivePath}' for plugin '{PluginName}' ({PluginId}).", archivePath, plugin.Name, plugin.Id);
- return new(true, plugin.Name, archivePath, string.Empty);
+ return new(PluginShareOutcome.SHARED, plugin.Name, archivePath, string.Empty);
}
catch (OperationCanceledException)
{
diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Share.cs b/app/MindWork AI Studio/Tools/Services/RustService.Share.cs
index 6234fe105..a59dfdb43 100644
--- a/app/MindWork AI Studio/Tools/Services/RustService.Share.cs
+++ b/app/MindWork AI Studio/Tools/Services/RustService.Share.cs
@@ -1,9 +1,16 @@
// ReSharper disable NotAccessedPositionalProperty.Local
+using AIStudio.Tools.Rust;
+
namespace AIStudio.Tools.Services;
public sealed partial class RustService
{
- public async Task ShareFile(string filePath)
+ ///
+ /// Asks the runtime to hand a file to the share sheet of the operating system.
+ ///
+ /// The plugin archive to share.
+ /// What became of the request. Any failure to reach the runtime counts as FAILED.
+ public async Task ShareFile(string filePath)
{
try
{
@@ -11,34 +18,37 @@ public sealed partial class RustService
if (!response.IsSuccessStatusCode)
{
this.logger?.LogError($"The Rust runtime rejected the share request: {response.StatusCode}.");
- return false;
+ return new(ShareSheetOutcome.FAILED, $"The Rust runtime rejected the share request: {response.StatusCode}.");
}
var result = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions);
- if (result?.Success == true)
- return true;
+ if (result is null)
+ {
+ this.logger?.LogError("The Rust runtime answered the share request without a result.");
+ return new(ShareSheetOutcome.FAILED, "The Rust runtime answered the share request without a result.");
+ }
- this.logger?.LogError($"The native share sheet could not be opened: {result?.Issue ?? "Unknown error"}");
- return false;
+ if (result.Outcome is not ShareSheetOutcome.OPENED)
+ this.logger?.LogError($"The native share sheet did not open ({result.Outcome}): {result.Issue}");
+
+ return result;
}
catch (HttpRequestException exception)
{
this.logger?.LogWarning(exception, "Failed to reach the Rust runtime share endpoint.");
- return false;
+ return new(ShareSheetOutcome.FAILED, exception.Message);
}
catch (TaskCanceledException exception)
{
this.logger?.LogWarning(exception, "Timed out while reaching the Rust runtime share endpoint.");
- return false;
+ return new(ShareSheetOutcome.FAILED, exception.Message);
}
catch (Exception exception)
{
this.logger?.LogError(exception, "Failed to process the Rust runtime share response.");
- return false;
+ return new(ShareSheetOutcome.FAILED, exception.Message);
}
}
private sealed record ShareFileRequest(string FilePath);
-
- private sealed record ShareFileResponse(bool Success, string Issue);
}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md
index 27d384570..bf504e5f9 100644
--- a/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md
+++ b/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md
@@ -94,6 +94,7 @@
- Fixed AI Studio removing harmless lines from files such as text or Markdown files, mistaking them for suspicious instructions (prompt injection).
- Fixed AI Studio not checking the transcripts of audio and video files for suspicious instructions (prompt injection) in the Batch Processing assistant and when you load such a file into a field of an assistant. AI Studio now checks these transcripts like all other documents before the AI sees them, even very long ones.
- Fixed AI Studio deleting the API keys you entered yourself for the providers of your organization when your organization changed the secret that protects its configuration. These keys now stay, so you no longer need to enter them again.
+- Fixed sharing plugins when the share dialog of Windows or macOS does not open or stops responding. AI Studio now notices this and offers to save the plugin archive to a location of your choice instead. Until you restart AI Studio, the plugin page then offers this export instead of sharing.
- Updated the code contributions on the supporters page, which now thank everyone who has contributed code to AI Studio so far.
- Upgraded several libraries to improve security.
- Upgraded to Rust v1.99.0
diff --git a/app/Tests/Tools/ShareFileResponseTests.cs b/app/Tests/Tools/ShareFileResponseTests.cs
new file mode 100644
index 000000000..83bb70ce9
--- /dev/null
+++ b/app/Tests/Tools/ShareFileResponseTests.cs
@@ -0,0 +1,51 @@
+using System.Text.Json;
+
+using AIStudio.Tools.Rust;
+using AIStudio.Tools.Services;
+
+namespace AIStudio.Tests.Tools;
+
+///
+/// Checks that the app reads every outcome which the runtime sends after a share request.
+///
+///
+/// ShareFileResponse in runtime/src/share_sheet.rs names the outcome in snake case. A name which the
+/// app does not know would not fail anywhere: the enum converter falls back to the first member, and
+/// a share sheet which did not respond would go unnoticed again. The options mirror what RustService
+/// deserializes with.
+///
+[TestFixture]
+public sealed class ShareFileResponseTests
+{
+ private static readonly JsonSerializerOptions AS_THE_RUST_SERVICE_READS_IT = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
+ Converters =
+ {
+ new RustEnumConverter(),
+ },
+ };
+
+ [TestCase("opened", ShareSheetOutcome.OPENED)]
+ [TestCase("failed", ShareSheetOutcome.FAILED)]
+ [TestCase("unresponsive", ShareSheetOutcome.UNRESPONSIVE)]
+ public void TheAppReadsEveryOutcomeOfTheRuntime(string outcome, ShareSheetOutcome expectedOutcome)
+ {
+ var response = JsonSerializer.Deserialize($$"""{"outcome":"{{outcome}}","issue":"The main window is not available."}""", AS_THE_RUST_SERVICE_READS_IT);
+
+ Assert.That(response, Is.Not.Null);
+ Assert.Multiple(() =>
+ {
+ Assert.That(response!.Outcome, Is.EqualTo(expectedOutcome));
+ Assert.That(response.Issue, Is.EqualTo("The main window is not available."));
+ });
+ }
+
+ [Test]
+ public void AnUnknownOutcomeNeverCountsAsAnOpenedShareSheet()
+ {
+ var response = JsonSerializer.Deserialize("""{"outcome":"vanished","issue":""}""", AS_THE_RUST_SERVICE_READS_IT);
+
+ Assert.That(response?.Outcome, Is.EqualTo(ShareSheetOutcome.UNKNOWN));
+ }
+}
\ No newline at end of file
diff --git a/runtime/src/share_sheet.rs b/runtime/src/share_sheet.rs
index 649d92081..92c3aa874 100644
--- a/runtime/src/share_sheet.rs
+++ b/runtime/src/share_sheet.rs
@@ -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 for ShareFailure {
+ fn from(issue: String) -> Self {
+ Self {
+ outcome: ShareOutcome::Failed,
+ issue,
+ }
+ }
+}
+
pub async fn share_file(Json(request): Json) -> Json {
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) -> Json {
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) -> Json {
Json(ShareFileResponse {
- success: false,
+ outcome: ShareOutcome::Failed,
issue: issue.into(),
})
}
@@ -87,20 +122,81 @@ fn failure(issue: impl Into) -> Json {
// 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::::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::::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::::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::>();
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::::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::::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");
+ }
}