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

@@ -0,0 +1,9 @@
namespace AIStudio.Tools.Rust;
/// <summary>
/// The answer of the runtime to a request to share a file through the share sheet of the
/// operating system.
/// </summary>
/// <param name="Outcome">What became of the request.</param>
/// <param name="Issue">Why the share sheet did not open; empty when it opened.</param>
public sealed record ShareFileResponse(ShareSheetOutcome Outcome, string Issue);
@@ -0,0 +1,30 @@
namespace AIStudio.Tools.Rust;
/// <summary>
/// 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.
/// </summary>
public enum ShareSheetOutcome
{
/// <summary>
/// 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.
/// </summary>
UNKNOWN,
/// <summary>
/// The operating system opened its share sheet for the file.
/// </summary>
OPENED,
/// <summary>
/// The share sheet could not be opened, or the request was invalid.
/// </summary>
FAILED,
/// <summary>
/// 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.
/// </summary>
UNRESPONSIVE,
}
@@ -1,6 +1,40 @@
using AIStudio.Tools.Rust;
namespace AIStudio.Tools.Services;
public sealed class NativeShareService(RustService rustService)
/// <summary>
/// Hands files to the share sheet of the operating system, and remembers when that share sheet
/// failed.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class NativeShareService(RustService rustService, ILogger<NativeShareService> logger)
{
public Task<bool> Share(string filePath) => rustService.ShareFile(filePath);
}
private volatile bool isUnavailable;
/// <summary>
/// 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.
/// </summary>
public bool IsAvailable => !OperatingSystem.IsLinux() && !this.isUnavailable;
/// <summary>
/// Opens the share sheet of the operating system for a file.
/// </summary>
/// <param name="filePath">The plugin archive to share.</param>
/// <returns>What became of the request.</returns>
public async Task<ShareFileResponse> 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;
}
}
@@ -0,0 +1,33 @@
namespace AIStudio.Tools.Services;
/// <summary>
/// What became of sharing or exporting a plugin archive.
/// </summary>
public enum PluginShareOutcome
{
/// <summary>
/// The share sheet of the operating system opened for the plugin archive.
/// </summary>
SHARED,
/// <summary>
/// The plugin archive was written to the location the user chose.
/// </summary>
EXPORTED,
/// <summary>
/// The user canceled choosing a location for the export.
/// </summary>
CANCELLED,
/// <summary>
/// The plugin could not be shared or exported.
/// </summary>
FAILED,
/// <summary>
/// The share sheet of the operating system failed or did not respond. The plugin archive can
/// still be exported instead.
/// </summary>
SHARE_SHEET_UNAVAILABLE,
}
@@ -1,3 +1,10 @@
namespace AIStudio.Tools.Services;
public sealed record PluginShareResult(bool Success, string PluginName, string ArchivePath, string Issue, bool Cancelled = false);
/// <summary>
/// The result of sharing or exporting a plugin archive.
/// </summary>
/// <param name="Outcome">What became of the request.</param>
/// <param name="PluginName">The name of the plugin.</param>
/// <param name="ArchivePath">Where the archive was written; empty when there is none.</param>
/// <param name="Issue">Why the plugin could not be shared or exported; empty otherwise.</param>
public sealed record PluginShareResult(PluginShareOutcome Outcome, string PluginName, string ArchivePath, string Issue);
@@ -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)
{
@@ -1,9 +1,16 @@
// ReSharper disable NotAccessedPositionalProperty.Local
using AIStudio.Tools.Rust;
namespace AIStudio.Tools.Services;
public sealed partial class RustService
{
public async Task<bool> ShareFile(string filePath)
/// <summary>
/// Asks the runtime to hand a file to the share sheet of the operating system.
/// </summary>
/// <param name="filePath">The plugin archive to share.</param>
/// <returns>What became of the request. Any failure to reach the runtime counts as FAILED.</returns>
public async Task<ShareFileResponse> 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<ShareFileResponse>(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);
}