mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-04 15:27:21 +00:00
Fixed sharing plugins when the share dialog of the operating system does not respond (#1088)
This commit is contained in:
1 parent
7d35536c87
commit
f26d132d56
16 files changed
+543
-76
No files matched your search
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user