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

@@ -7,8 +7,10 @@ namespace AIStudio.Tools.Services;
public sealed class PluginShareService(NativeShareService nativeShareService, RustService rustService, SettingsManager settingsManager, ILogger<PluginShareService> 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;
/// <summary>
/// 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.
/// </summary>
public bool UsesExport => !nativeShareService.IsAvailable;
/// <summary>
/// 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.
@@ -29,31 +37,73 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru
/// <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.
/// 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.
/// </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 archive path when successful.</returns>
/// <returns>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.</returns>
public async Task<PluginShareResult> 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);
}
/// <summary>
/// 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.
/// </summary>
/// <param name="plugin">The local plugin to archive and export.</param>
/// <param name="token">Cancellation token for archive creation.</param>
/// <returns>The export result, including the chosen archive path when successful.</returns>
public async Task<PluginShareResult> 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);
}
/// <summary>
/// Checks whether a plugin may leave AI Studio as an archive at all, no matter whether it is
/// shared or exported.
/// </summary>
/// <param name="plugin">The local plugin to check.</param>
/// <param name="pluginRoot">The validated plugin root directory, when the plugin may leave.</param>
/// <param name="issue">Why the plugin may not leave; empty otherwise.</param>
/// <returns>True when the plugin may be shared or exported.</returns>
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);
}
/// <summary>
/// Asks the user for a target location and writes the plugin archive to it.
/// </summary>
@@ -61,12 +111,12 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru
/// <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)
private async Task<PluginShareResult> 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)
{