diff --git a/app/MindWork AI Studio/Pages/Plugins.razor b/app/MindWork AI Studio/Pages/Plugins.razor index 5ae16ef0..81a15a2e 100644 --- a/app/MindWork AI Studio/Pages/Plugins.razor +++ b/app/MindWork AI Studio/Pages/Plugins.razor @@ -118,8 +118,8 @@ @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 9cd97c73..a28a8b21 100644 --- a/app/MindWork AI Studio/Pages/Plugins.razor.cs +++ b/app/MindWork AI Studio/Pages/Plugins.razor.cs @@ -213,7 +213,15 @@ public partial class Plugins : MSGComponentBase } private static bool CanSharePlugin(IAvailablePlugin plugin) => plugin is { IsInternal: false, IsManagedByConfigServer: false } && !string.IsNullOrWhiteSpace(plugin.LocalPath) && !IS_SHARING_PLUGIN; - + + /// + /// 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. + /// + private static string SharePluginIcon => OperatingSystem.IsLinux() ? Icons.Material.Filled.FileDownload : Icons.Material.Filled.IosShare; + + private string SharePluginTooltip => OperatingSystem.IsLinux() ? this.T("Export plugin archive") : this.T("Share plugin archive"); + private async Task OpenAssistantPluginEditorDialogAsync(IAvailablePlugin plugin) { var parameters = new DialogParameters @@ -265,12 +273,19 @@ public partial class Plugins : MSGComponentBase try { var shareResult = await this.PluginShareService.ShareAsync(plugin, CancellationToken.None); + if (shareResult.Cancelled) + return; + if (!shareResult.Success) { 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, T("An error occurred while sharing the plugin."))); + 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; } + + // 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))); } finally { diff --git a/app/MindWork AI Studio/Tools/Services/PluginShareService.cs b/app/MindWork AI Studio/Tools/Services/PluginShareService.cs index 5201bdb2..344709fc 100644 --- a/app/MindWork AI Studio/Tools/Services/PluginShareService.cs +++ b/app/MindWork AI Studio/Tools/Services/PluginShareService.cs @@ -1,11 +1,12 @@ using System.IO.Compression; using AIStudio.Tools.PluginSystem; +using AIStudio.Tools.Rust; namespace AIStudio.Tools.Services; -public sealed record PluginShareResult(bool Success, string PluginName, string ArchivePath, string Issue); +public sealed record PluginShareResult(bool Success, string PluginName, string ArchivePath, string Issue, bool Cancelled = false); -public sealed class PluginShareService(NativeShareService nativeShareService, ILogger logger) +public sealed class PluginShareService(NativeShareService nativeShareService, RustService rustService, ILogger logger) { private static PluginShareResult ShareError(IAvailablePlugin plugin, string issue) => new(false, plugin.Name, string.Empty, issue); private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PluginShareService).Namespace, nameof(PluginShareService)); @@ -19,12 +20,17 @@ public sealed class PluginShareService(NativeShareService nativeShareService, IL /// - /// Creates a shareable plugin archive from a local plugin and opens the native share sheet. + /// 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. /// + /// + /// 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. + /// /// The local plugin to archive and share. /// Cancellation token for archive creation. - /// The share result, including the retained temporary archive path when successful. + /// The share result, including the archive path when successful. public async Task ShareAsync(IAvailablePlugin plugin, CancellationToken token) { if (plugin.IsInternal) @@ -36,6 +42,67 @@ public sealed class PluginShareService(NativeShareService nativeShareService, IL if (!TryGetPluginRoot(plugin, out var pluginRoot, out var issue)) return ShareError(plugin, issue); + if (OperatingSystem.IsLinux()) + return await this.ExportAsync(plugin, pluginRoot, token); + + return await this.ShareViaNativeSheetAsync(plugin, pluginRoot, token); + } + + /// + /// Asks the user for a target location and writes the plugin archive to it. + /// + /// The local plugin to archive. + /// 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) + { + var suggestedFileName = $"{CreateSafeFileNamePrefix(plugin.Name)}{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); + + var archivePath = saveResponse.SaveFilePath; + try + { + token.ThrowIfCancellationRequested(); + await Task.Run(() => + { + token.ThrowIfCancellationRequested(); + + // The save dialog already asked the user about overwriting an existing file. + // ZipFile.CreateFromDirectory would fail on an existing file, though: + if (File.Exists(archivePath)) + File.Delete(archivePath); + + ZipFile.CreateFromDirectory(pluginRoot, archivePath, CompressionLevel.Optimal, false); + }, token); + + logger.LogInformation("Exported plugin '{PluginName}' ({PluginId}) to the archive '{ArchivePath}'.", plugin.Name, plugin.Id, archivePath); + return new(true, plugin.Name, archivePath, string.Empty); + } + catch (OperationCanceledException) + { + this.TryDeleteArchive(archivePath); + throw; + } + catch (Exception exception) + { + this.TryDeleteArchive(archivePath); + logger.LogError(exception, "Failed to export plugin '{PluginName}' ({PluginId}).", plugin.Name, plugin.Id); + return ShareError(plugin, string.Format(TB("Unexpected error: {0}"), exception.Message)); + } + } + + /// + /// Creates the plugin archive in a temporary directory and opens the native share sheet for it. + /// + /// The local plugin to archive and share. + /// The validated plugin root directory. + /// Cancellation token for archive creation. + /// The share result, including the retained temporary archive path when successful. + private async Task ShareViaNativeSheetAsync(IAvailablePlugin plugin, string pluginRoot, CancellationToken token) + { var archiveDirectory = Path.Join(Path.GetTempPath(), TEMPORARY_ARCHIVE_DIRECTORY); var archivePath = Path.Join(archiveDirectory, $"{CreateSafeFileNamePrefix(plugin.Name)}-{plugin.Id:N}-{Guid.NewGuid():N}{PLUGIN_FILE_EXTENSION}"); diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.8.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.8.1.md index e4b660b4..cc57690b 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.8.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.8.1.md @@ -1,7 +1,7 @@ # v26.8.1, build 251 (2026-08-xx xx:xx UTC) - Added a prototype Visual Briefing Assistant that turns documents, data, images, audio, and video into self-contained interactive HTML briefings. - Added organization-configurable defaults and visibility controls for the Visual Briefing Assistant. -- Added a share button for plugins, which uses the native share dialog on Windows and macOS. +- Added a share button for plugins, which uses the native share dialog on Windows and macOS. For Linux, we added an export option for plugins, which stores the plugin archive at a location of your choice. - Added an import button on the plugins page to install plugin archives directly from your disk. - Added the dedicated file extension `.mwplugin` for plugin archives. - Upgraded dependencies to their latest versions to improve security and stability. \ No newline at end of file diff --git a/runtime/src/file_actions.rs b/runtime/src/file_actions.rs index 5f725e04..8365b5e2 100644 --- a/runtime/src/file_actions.rs +++ b/runtime/src/file_actions.rs @@ -344,15 +344,6 @@ pub async fn open_path_in_file_manager( } } -#[cfg(target_os = "linux")] -pub(crate) async fn open_existing_file_in_file_manager(path: PathBuf) -> Result<(), String> { - if !path.is_file() { - return Err(format!("The requested path is not an existing file: {}", path.to_string_lossy())); - } - - open_file_manager_target(&path).await -} - async fn open_file_manager_target(requested_path: &Path) -> Result<(), String> { let Some(target) = resolve_file_manager_target(requested_path) else { let issue = format!( diff --git a/runtime/src/share_sheet.rs b/runtime/src/share_sheet.rs index 7f1fe85d..7cb2fdc2 100644 --- a/runtime/src/share_sheet.rs +++ b/runtime/src/share_sheet.rs @@ -49,9 +49,11 @@ 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> { - crate::file_actions::open_existing_file_in_file_manager(path).await +async fn share_file_on_platform(_path: PathBuf) -> Result<(), String> { + Err(String::from("The native share sheet is not available on Linux.")) } #[cfg(windows)]