using System.IO.Compression; using AIStudio.Settings; using AIStudio.Tools.PluginSystem; using AIStudio.Tools.Rust; namespace AIStudio.Tools.Services; public sealed class PluginShareService(NativeShareService nativeShareService, RustService rustService, SettingsManager settingsManager, ILogger logger) { 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"; /// /// Keep in sync with SHARE_DIRECTORY_NAME in runtime/src/share_sheet.rs: the runtime only hands /// archives from a directory with this name to the native share sheet. /// private const string TEMPORARY_ARCHIVE_DIRECTORY = "mindwork-ai-studio-plugin-shares"; private const int TEMPORARY_ARCHIVE_RETENTION_HOURS = 24; private const int FILE_NAME_PREFIX_MAX_LEN = 80; /// /// 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. /// public bool UsesExport => !nativeShareService.IsAvailable; /// /// 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 /// same happens on Windows and macOS once the share sheet failed or did not respond, until AI /// Studio restarts. /// /// The local plugin to archive and share. /// Cancellation token for archive creation. /// 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. public async Task ShareAsync(IAvailablePlugin plugin, CancellationToken token) { if (!this.TryGetShareablePluginRoot(plugin, out var pluginRoot, out var issue)) return ShareError(plugin, issue); if (this.UsesExport) return await this.ExportArchiveAsync(plugin, pluginRoot, token); return await this.ShareViaNativeSheetAsync(plugin, pluginRoot, token); } /// /// 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. /// /// The local plugin to archive and export. /// Cancellation token for archive creation. /// The export result, including the chosen archive path when successful. public async Task 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); } /// /// Checks whether a plugin may leave AI Studio as an archive at all, no matter whether it is /// shared or exported. /// /// The local plugin to check. /// The validated plugin root directory, when the plugin may leave. /// Why the plugin may not leave; empty otherwise. /// True when the plugin may be shared or exported. 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); } /// /// 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 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(PluginShareOutcome.CANCELLED, plugin.Name, string.Empty, string.Empty); var archivePath = saveResponse.SaveFilePath; try { token.ThrowIfCancellationRequested(); await Task.Run(async () => { token.ThrowIfCancellationRequested(); // // The save dialog already asked the user about overwriting an existing file, so we // write into the file the user picked instead of removing and recreating it. That // matters on Linux: inside a Flatpak, the file dialog hands out one single file // through the desktop portal. We may write that file, but we may not create a new // one next to it, which is what deleting and recreating would come down to. // await using var archiveStream = File.Create(archivePath); ZipFile.CreateFromDirectory(pluginRoot, archiveStream, CompressionLevel.Optimal, false); }, token); logger.LogInformation("Exported plugin '{PluginName}' ({PluginId}) to the archive '{ArchivePath}'.", plugin.Name, plugin.Id, archivePath); return new(PluginShareOutcome.EXPORTED, 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}{PluginArchive.PLUGIN_FILE_EXTENSION}"); try { token.ThrowIfCancellationRequested(); Directory.CreateDirectory(archiveDirectory); this.CleanUpExpiredArchives(archiveDirectory); await Task.Run(() => { token.ThrowIfCancellationRequested(); ZipFile.CreateFromDirectory(pluginRoot, archivePath, CompressionLevel.Optimal, false); }, token); token.ThrowIfCancellationRequested(); var shareResponse = await nativeShareService.Share(archivePath); switch (shareResponse.Outcome) { 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(PluginShareOutcome.SHARED, plugin.Name, archivePath, string.Empty); } catch (OperationCanceledException) { this.TryDeleteArchive(archivePath); throw; } catch (Exception exception) { this.TryDeleteArchive(archivePath); logger.LogError(exception, "Failed to create a share archive for plugin '{PluginName}' ({PluginId}).", plugin.Name, plugin.Id); return ShareError(plugin, string.Format(TB("Unexpected error: {0}"), exception.Message)); } } private static bool TryGetPluginRoot(IAvailablePlugin plugin, out string pluginRoot, out string issue) { pluginRoot = string.Empty; issue = string.Empty; if (string.IsNullOrWhiteSpace(plugin.LocalPath)) { issue = TB("The plugin has no local directory."); return false; } try { pluginRoot = Path.GetFullPath(plugin.LocalPath); } catch (Exception exception) { issue = string.Format(TB("The plugin directory is invalid: {0}"), exception.Message); return false; } if (!Directory.Exists(pluginRoot)) { issue = TB("The plugin directory does not exist."); return false; } var pluginFile = Path.Join(pluginRoot, PLUGIN_FILE_NAME); if (!IsPathInsideDirectory(pluginRoot, pluginFile) || !File.Exists(pluginFile)) { issue = TB("The plugin directory does not contain a plugin.lua file."); return false; } return true; } private void CleanUpExpiredArchives(string archiveDirectory) { var expiry = DateTime.UtcNow.AddHours(-TEMPORARY_ARCHIVE_RETENTION_HOURS); foreach (var archivePath in Directory.EnumerateFiles(archiveDirectory, $"*{PluginArchive.PLUGIN_FILE_EXTENSION}", SearchOption.TopDirectoryOnly)) { try { if (File.GetLastWriteTimeUtc(archivePath) < expiry) File.Delete(archivePath); } catch (Exception exception) { logger.LogWarning(exception, "Failed to delete expired plugin archive '{ArchivePath}'.", archivePath); } } } private static string CreateSafeFileNamePrefix(string pluginName) { var invalidCharacters = Path.GetInvalidFileNameChars().ToHashSet(); var fileName = new string(pluginName .Trim() .Select(character => char.IsLetterOrDigit(character) || ((character is '-' or '_' or '.') && !invalidCharacters.Contains(character)) ? character : '-') .ToArray()) .Trim('-', '.'); if (fileName.Length > FILE_NAME_PREFIX_MAX_LEN) fileName = fileName[..FILE_NAME_PREFIX_MAX_LEN].Trim('-', '.'); return string.IsNullOrWhiteSpace(fileName) ? "plugin" : fileName; } private static bool IsPathInsideDirectory(string parentDirectory, string path) { var parentPath = Path.GetFullPath(parentDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; var childPath = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; return childPath.StartsWith(parentPath, StringComparison.OrdinalIgnoreCase); } private void TryDeleteArchive(string archivePath) { if (!File.Exists(archivePath)) return; try { File.Delete(archivePath); } catch (Exception exception) { logger.LogWarning(exception, "Failed to delete temporary plugin archive '{ArchivePath}'.", archivePath); } } }