Added a plugin archive export for Linux

This commit is contained in:
Thorsten Sommer 2026-08-04 12:53:46 +02:00
parent 0b459801d4
commit b45fb83cb0
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
6 changed files with 95 additions and 20 deletions

View File

@ -118,8 +118,8 @@
@if (context is IAvailablePlugin shareablePlugin && CanSharePlugin(shareablePlugin))
{
<MudTooltip Text="@T("Share plugin archive")">
<MudIconButton Icon="@Icons.Material.Filled.IosShare" Size="Size.Medium" OnClick="@(() => this.SharePluginAsync(shareablePlugin))" Disabled="@(!CanSharePlugin(shareablePlugin))"/>
<MudTooltip Text="@this.SharePluginTooltip">
<MudIconButton Icon="@SharePluginIcon" Size="Size.Medium" OnClick="@(() => this.SharePluginAsync(shareablePlugin))" Disabled="@(!CanSharePlugin(shareablePlugin))"/>
</MudTooltip>
}

View File

@ -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;
/// <summary>
/// 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.
/// </summary>
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<AssistantPluginEditorDialog>
@ -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
{

View File

@ -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<PluginShareService> logger)
public sealed class PluginShareService(NativeShareService nativeShareService, RustService rustService, ILogger<PluginShareService> 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
/// <summary>
/// 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 <c>plugin.lua</c> is located at the archive root.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="plugin">The local plugin to archive and share.</param>
/// <param name="token">Cancellation token for archive creation.</param>
/// <returns>The share result, including the retained temporary archive path when successful.</returns>
/// <returns>The share result, including the archive path when successful.</returns>
public async Task<PluginShareResult> 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);
}
/// <summary>
/// Asks the user for a target location and writes the plugin archive to it.
/// </summary>
/// <param name="plugin">The local plugin to archive.</param>
/// <param name="pluginRoot">The validated plugin root directory.</param>
/// <param name="token">Cancellation token for archive creation.</param>
/// <returns>The share result, including the chosen archive path when successful.</returns>
private async Task<PluginShareResult> 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));
}
}
/// <summary>
/// Creates the plugin archive in a temporary directory and opens the native share sheet for it.
/// </summary>
/// <param name="plugin">The local plugin to archive and share.</param>
/// <param name="pluginRoot">The validated plugin root directory.</param>
/// <param name="token">Cancellation token for archive creation.</param>
/// <returns>The share result, including the retained temporary archive path when successful.</returns>
private async Task<PluginShareResult> 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}");

View File

@ -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.

View File

@ -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!(

View File

@ -49,9 +49,11 @@ 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> {
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)]