diff --git a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs index a66d132c7..0e6918123 100644 --- a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs +++ b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs @@ -728,10 +728,10 @@ internal class PluginInstallerWindow : Window, IDisposable } else { - this.updatedPlugins = task.Result.Where(res => res.WasUpdated).ToList(); + this.updatedPlugins = task.Result.Where(res => res.Status == PluginUpdateStatus.StatusKind.Success).ToList(); this.updatePluginCount = this.updatedPlugins.Count; - var errorPlugins = task.Result.Where(res => !res.WasUpdated).ToList(); + var errorPlugins = task.Result.Where(res => res.Status != PluginUpdateStatus.StatusKind.Success).ToList(); var errorPluginCount = errorPlugins.Count; if (errorPluginCount > 0) @@ -739,9 +739,9 @@ internal class PluginInstallerWindow : Window, IDisposable var errorMessage = this.updatePluginCount > 0 ? Locs.ErrorModal_UpdaterFailPartial(this.updatePluginCount, errorPluginCount) : Locs.ErrorModal_UpdaterFail(errorPluginCount); - + var hintInsert = errorPlugins - .Aggregate(string.Empty, (current, pluginUpdateStatus) => $"{current}* {pluginUpdateStatus.InternalName}\n") + .Aggregate(string.Empty, (current, pluginUpdateStatus) => $"{current}* {pluginUpdateStatus.InternalName} ({PluginUpdateStatus.LocalizeUpdateStatusKind(pluginUpdateStatus.Status)})\n") .TrimEnd(); errorMessage += Locs.ErrorModal_HintBlame(hintInsert); @@ -2250,7 +2250,7 @@ internal class PluginInstallerWindow : Window, IDisposable var update = this.updatedPlugins.FirstOrDefault(update => update.InternalName == plugin.Manifest.InternalName); if (update != default) { - if (update.WasUpdated) + if (update.Status == PluginUpdateStatus.StatusKind.Success) { thisWasUpdated = true; label += Locs.PluginTitleMod_Updated; diff --git a/Dalamud/Plugin/Internal/PluginManager.cs b/Dalamud/Plugin/Internal/PluginManager.cs index 04698947e..c5fda414a 100644 --- a/Dalamud/Plugin/Internal/PluginManager.cs +++ b/Dalamud/Plugin/Internal/PluginManager.cs @@ -303,7 +303,7 @@ internal partial class PluginManager : IDisposable, IServiceType foreach (var metadata in updateMetadata) { - if (metadata.WasUpdated) + if (metadata.Status == PluginUpdateStatus.StatusKind.Success) { chatGui.Print(Locs.DalamudPluginUpdateSuccessful(metadata.Name, metadata.Version)); } @@ -311,7 +311,7 @@ internal partial class PluginManager : IDisposable, IServiceType { chatGui.Print(new XivChatEntry { - Message = Locs.DalamudPluginUpdateFailed(metadata.Name, metadata.Version), + Message = Locs.DalamudPluginUpdateFailed(metadata.Name, metadata.Version, PluginUpdateStatus.LocalizeUpdateStatusKind(metadata.Status)), Type = XivChatType.Urgent, }); } @@ -782,147 +782,14 @@ internal partial class PluginManager : IDisposable, IServiceType /// The reason this plugin was loaded. /// WorkingPluginId this plugin should inherit. /// A representing the asynchronous operation. - public async Task InstallPluginAsync(RemotePluginManifest repoManifest, bool useTesting, PluginLoadReason reason, Guid? inheritedWorkingPluginId = null) + public async Task InstallPluginAsync( + RemotePluginManifest repoManifest, bool useTesting, PluginLoadReason reason, + Guid? inheritedWorkingPluginId = null) { - Log.Debug($"Installing plugin {repoManifest.Name} (testing={useTesting})"); - - // If this plugin is in the default profile for whatever reason, delete the state - // If it was in multiple profiles and is still, the user uninstalled it and chose to keep it in there, - // or the user removed the plugin manually in which case we don't care - if (reason == PluginLoadReason.Installer) - { - try - { - // We don't need to apply, it doesn't matter - await this.profileManager.DefaultProfile.RemoveAsync(repoManifest.InternalName, false); - } - catch (ProfileOperationException) - { - // ignored - } - } - else - { - // If we are doing anything other than a fresh install, not having a workingPluginId is an error that must be fixed - Debug.Assert(inheritedWorkingPluginId != null, "inheritedWorkingPluginId != null"); - } - - // Ensure that we have a testing opt-in for this plugin if we are installing a testing version - if (useTesting && this.configuration.PluginTestingOptIns!.All(x => x.InternalName != repoManifest.InternalName)) - { - // TODO: this isn't safe - this.configuration.PluginTestingOptIns.Add(new PluginTestingOptIn(repoManifest.InternalName)); - this.configuration.QueueSave(); - } - - var downloadUrl = useTesting ? repoManifest.DownloadLinkTesting : repoManifest.DownloadLinkInstall; - var version = useTesting ? repoManifest.TestingAssemblyVersion : repoManifest.AssemblyVersion; - - var response = await this.happyHttpClient.SharedHttpClient.GetAsync(downloadUrl); - response.EnsureSuccessStatusCode(); - - var outputDir = new DirectoryInfo(Path.Combine(this.pluginDirectory.FullName, repoManifest.InternalName, version?.ToString() ?? string.Empty)); - - try - { - if (outputDir.Exists) - outputDir.Delete(true); - - outputDir.Create(); - } - catch - { - // ignored, since the plugin may be loaded already - } - - Log.Debug($"Extracting to {outputDir}"); - // This throws an error, even with overwrite=false - // ZipFile.ExtractToDirectory(tempZip.FullName, outputDir.FullName, false); - using (var archive = new ZipArchive(await response.Content.ReadAsStreamAsync())) - { - foreach (var zipFile in archive.Entries) - { - var outputFile = new FileInfo(Path.GetFullPath(Path.Combine(outputDir.FullName, zipFile.FullName))); - - if (!outputFile.FullName.StartsWith(outputDir.FullName, StringComparison.OrdinalIgnoreCase)) - { - throw new IOException("Trying to extract file outside of destination directory. See this link for more info: https://snyk.io/research/zip-slip-vulnerability"); - } - - if (outputFile.Directory == null) - { - throw new IOException("Output directory invalid."); - } - - if (zipFile.Name.IsNullOrEmpty()) - { - // Assuming Empty for Directory - Log.Verbose($"ZipFile name is null or empty, treating as a directory: {outputFile.Directory.FullName}"); - Directory.CreateDirectory(outputFile.Directory.FullName); - continue; - } - - // Ensure directory is created - Directory.CreateDirectory(outputFile.Directory.FullName); - - try - { - zipFile.ExtractToFile(outputFile.FullName, true); - } - catch (Exception ex) - { - if (outputFile.Extension.EndsWith("dll")) - { - throw new IOException($"Could not overwrite {zipFile.Name}: {ex.Message}"); - } - - Log.Error($"Could not overwrite {zipFile.Name}: {ex.Message}"); - } - } - } - - var dllFile = LocalPluginManifest.GetPluginFile(outputDir, repoManifest); - var manifestFile = LocalPluginManifest.GetManifestFile(dllFile); - - // We need to save the repoManifest due to how the repo fills in some fields that authors are not expected to use. - Util.WriteAllTextSafe(manifestFile.FullName, JsonConvert.SerializeObject(repoManifest, Formatting.Indented)); - - // Reload as a local manifest, add some attributes, and save again. - var manifest = LocalPluginManifest.Load(manifestFile); - - if (manifest == null) - throw new Exception("Plugin had no valid manifest"); - - if (manifest.InternalName != repoManifest.InternalName) - { - Directory.Delete(outputDir.FullName, true); - throw new Exception( - $"Distributed internal name does not match repo internal name: {manifest.InternalName} - {repoManifest.InternalName}"); - } - - if (manifest.WorkingPluginId != Guid.Empty) - throw new Exception("Plugin shall not specify a WorkingPluginId"); - - manifest.WorkingPluginId = inheritedWorkingPluginId ?? Guid.NewGuid(); - - if (useTesting) - { - manifest.Testing = true; - } - - // Document the url the plugin was installed from - manifest.InstalledFromUrl = repoManifest.SourceRepo.IsThirdParty ? repoManifest.SourceRepo.PluginMasterUrl : SpecialPluginSource.MainRepo; - - manifest.Save(manifestFile, "installation"); - - Log.Information($"Installed plugin {manifest.Name} (testing={useTesting})"); - - var plugin = await this.LoadPluginAsync(dllFile, manifest, reason); - - this.NotifyinstalledPluginsListChanged(); - return plugin; + var stream = await this.DownloadPluginAsync(repoManifest, useTesting); + return await this.InstallPluginInternalAsync(repoManifest, useTesting, reason, stream, inheritedWorkingPluginId); } - + /// /// Remove a plugin. /// @@ -1098,12 +965,25 @@ internal partial class PluginManager : IDisposable, IServiceType Version = (metadata.UseTesting ? metadata.UpdateManifest.TestingAssemblyVersion : metadata.UpdateManifest.AssemblyVersion)!, - WasUpdated = true, + Status = PluginUpdateStatus.StatusKind.Success, HasChangelog = !metadata.UpdateManifest.Changelog.IsNullOrWhitespace(), }; if (!dryRun) { + // Download the update before unloading + Stream updateStream; + try + { + updateStream = await this.DownloadPluginAsync(metadata.UpdateManifest, metadata.UseTesting); + } + catch (Exception ex) + { + Log.Error(ex, "Error during download (update)"); + updateStatus.Status = PluginUpdateStatus.StatusKind.FailedDownload; + return updateStatus; + } + // Unload if loaded if (plugin.State is PluginState.Loaded or PluginState.LoadError or PluginState.DependencyResolutionFailed) { @@ -1114,7 +994,7 @@ internal partial class PluginManager : IDisposable, IServiceType catch (Exception ex) { Log.Error(ex, "Error during unload (update)"); - updateStatus.WasUpdated = false; + updateStatus.Status = PluginUpdateStatus.StatusKind.FailedUnload; return updateStatus; } } @@ -1139,8 +1019,8 @@ internal partial class PluginManager : IDisposable, IServiceType } catch (Exception ex) { - Log.Error(ex, "Error during disable (update)"); - updateStatus.WasUpdated = false; + Log.Error(ex, "Error during remove from plugin list (update)"); + updateStatus.Status = PluginUpdateStatus.StatusKind.FailedUnload; return updateStatus; } @@ -1150,17 +1030,17 @@ internal partial class PluginManager : IDisposable, IServiceType try { - await this.InstallPluginAsync(metadata.UpdateManifest, metadata.UseTesting, PluginLoadReason.Update, workingPluginId); + await this.InstallPluginInternalAsync(metadata.UpdateManifest, metadata.UseTesting, PluginLoadReason.Update, updateStream, workingPluginId); } catch (Exception ex) { Log.Error(ex, "Error during install (update)"); - updateStatus.WasUpdated = false; + updateStatus.Status = PluginUpdateStatus.StatusKind.FailedLoad; return updateStatus; } } - if (notify && updateStatus.WasUpdated) + if (notify && updateStatus.Status == PluginUpdateStatus.StatusKind.Success) this.NotifyinstalledPluginsListChanged(); return updateStatus; @@ -1313,6 +1193,158 @@ internal partial class PluginManager : IDisposable, IServiceType /// The calling plugin, or null. public LocalPlugin? FindCallingPlugin() => this.FindCallingPlugin(new StackTrace()); + private async Task DownloadPluginAsync(RemotePluginManifest repoManifest, bool useTesting) + { + var downloadUrl = useTesting ? repoManifest.DownloadLinkTesting : repoManifest.DownloadLinkInstall; + var response = await this.happyHttpClient.SharedHttpClient.GetAsync(downloadUrl); + response.EnsureSuccessStatusCode(); + + return await response.Content.ReadAsStreamAsync(); + } + + /// + /// Install a plugin from a repository and load it. + /// + /// The plugin definition. + /// If the testing version should be used. + /// The reason this plugin was loaded. + /// WorkingPluginId this plugin should inherit. + /// A representing the asynchronous operation. + private async Task InstallPluginInternalAsync(RemotePluginManifest repoManifest, bool useTesting, PluginLoadReason reason, Stream zipStream, Guid? inheritedWorkingPluginId = null) + { + var version = useTesting ? repoManifest.TestingAssemblyVersion : repoManifest.AssemblyVersion; + Log.Debug($"Installing plugin {repoManifest.Name} (testing={useTesting}, version={version}, reason={reason})"); + + // If this plugin is in the default profile for whatever reason, delete the state + // If it was in multiple profiles and is still, the user uninstalled it and chose to keep it in there, + // or the user removed the plugin manually in which case we don't care + if (reason == PluginLoadReason.Installer) + { + try + { + // We don't need to apply, it doesn't matter + await this.profileManager.DefaultProfile.RemoveAsync(repoManifest.InternalName, false); + } + catch (ProfileOperationException) + { + // ignored + } + } + else + { + // If we are doing anything other than a fresh install, not having a workingPluginId is an error that must be fixed + Debug.Assert(inheritedWorkingPluginId != null, "inheritedWorkingPluginId != null"); + } + + // Ensure that we have a testing opt-in for this plugin if we are installing a testing version + if (useTesting && this.configuration.PluginTestingOptIns!.All(x => x.InternalName != repoManifest.InternalName)) + { + // TODO: this isn't safe + this.configuration.PluginTestingOptIns.Add(new PluginTestingOptIn(repoManifest.InternalName)); + this.configuration.QueueSave(); + } + + var outputDir = new DirectoryInfo(Path.Combine(this.pluginDirectory.FullName, repoManifest.InternalName, version?.ToString() ?? string.Empty)); + + try + { + if (outputDir.Exists) + outputDir.Delete(true); + + outputDir.Create(); + } + catch + { + // ignored, since the plugin may be loaded already + } + + Log.Debug($"Extracting to {outputDir}"); + + using (var archive = new ZipArchive(zipStream)) + { + foreach (var zipFile in archive.Entries) + { + var outputFile = new FileInfo(Path.GetFullPath(Path.Combine(outputDir.FullName, zipFile.FullName))); + + if (!outputFile.FullName.StartsWith(outputDir.FullName, StringComparison.OrdinalIgnoreCase)) + { + throw new IOException("Trying to extract file outside of destination directory. See this link for more info: https://snyk.io/research/zip-slip-vulnerability"); + } + + if (outputFile.Directory == null) + { + throw new IOException("Output directory invalid."); + } + + if (zipFile.Name.IsNullOrEmpty()) + { + // Assuming Empty for Directory + Log.Verbose($"ZipFile name is null or empty, treating as a directory: {outputFile.Directory.FullName}"); + Directory.CreateDirectory(outputFile.Directory.FullName); + continue; + } + + // Ensure directory is created + Directory.CreateDirectory(outputFile.Directory.FullName); + + try + { + zipFile.ExtractToFile(outputFile.FullName, true); + } + catch (Exception ex) + { + if (outputFile.Extension.EndsWith("dll")) + { + throw new IOException($"Could not overwrite {zipFile.Name}: {ex.Message}"); + } + + Log.Error($"Could not overwrite {zipFile.Name}: {ex.Message}"); + } + } + } + + var dllFile = LocalPluginManifest.GetPluginFile(outputDir, repoManifest); + var manifestFile = LocalPluginManifest.GetManifestFile(dllFile); + + // We need to save the repoManifest due to how the repo fills in some fields that authors are not expected to use. + Util.WriteAllTextSafe(manifestFile.FullName, JsonConvert.SerializeObject(repoManifest, Formatting.Indented)); + + // Reload as a local manifest, add some attributes, and save again. + var manifest = LocalPluginManifest.Load(manifestFile); + + if (manifest == null) + throw new Exception("Plugin had no valid manifest"); + + if (manifest.InternalName != repoManifest.InternalName) + { + Directory.Delete(outputDir.FullName, true); + throw new Exception( + $"Distributed internal name does not match repo internal name: {manifest.InternalName} - {repoManifest.InternalName}"); + } + + if (manifest.WorkingPluginId != Guid.Empty) + throw new Exception("Plugin shall not specify a WorkingPluginId"); + + manifest.WorkingPluginId = inheritedWorkingPluginId ?? Guid.NewGuid(); + + if (useTesting) + { + manifest.Testing = true; + } + + // Document the url the plugin was installed from + manifest.InstalledFromUrl = repoManifest.SourceRepo.IsThirdParty ? repoManifest.SourceRepo.PluginMasterUrl : SpecialPluginSource.MainRepo; + + manifest.Save(manifestFile, "installation"); + + Log.Information($"Installed plugin {manifest.Name} (testing={useTesting})"); + + var plugin = await this.LoadPluginAsync(dllFile, manifest, reason); + + this.NotifyinstalledPluginsListChanged(); + return plugin; + } + /// /// Load a plugin. /// @@ -1543,7 +1575,7 @@ internal partial class PluginManager : IDisposable, IServiceType { public static string DalamudPluginUpdateSuccessful(string name, Version version) => Loc.Localize("DalamudPluginUpdateSuccessful", " 》 {0} updated to v{1}.").Format(name, version); - public static string DalamudPluginUpdateFailed(string name, Version version) => Loc.Localize("DalamudPluginUpdateFailed", " 》 {0} update to v{1} failed.").Format(name, version); + public static string DalamudPluginUpdateFailed(string name, Version version, string why) => Loc.Localize("DalamudPluginUpdateFailed", " 》 {0} update to v{1} failed ({2}).").Format(name, version, why); } } diff --git a/Dalamud/Plugin/Internal/Types/PluginUpdateStatus.cs b/Dalamud/Plugin/Internal/Types/PluginUpdateStatus.cs index 24ca5fe0f..391107691 100644 --- a/Dalamud/Plugin/Internal/Types/PluginUpdateStatus.cs +++ b/Dalamud/Plugin/Internal/Types/PluginUpdateStatus.cs @@ -1,4 +1,5 @@ using System; +using CheapLoc; namespace Dalamud.Plugin.Internal.Types; @@ -7,6 +8,37 @@ namespace Dalamud.Plugin.Internal.Types; /// internal class PluginUpdateStatus { + /// + /// Enum containing possible statuses of a plugin update. + /// + public enum StatusKind + { + /// + /// The update is pending. + /// + Pending, + + /// + /// The update failed to download. + /// + FailedDownload, + + /// + /// The outdated plugin did not unload correctly. + /// + FailedUnload, + + /// + /// The updated plugin did not load correctly. + /// + FailedLoad, + + /// + /// The update succeeded. + /// + Success, + } + /// /// Gets the plugin internal name. /// @@ -23,12 +55,27 @@ internal class PluginUpdateStatus public Version Version { get; init; } = null!; /// - /// Gets or sets a value indicating whether the plugin was updated. + /// Gets or sets a value indicating the status of the update. /// - public bool WasUpdated { get; set; } + public StatusKind Status { get; set; } = StatusKind.Pending; /// /// Gets a value indicating whether the plugin has a changelog if it was updated. /// public bool HasChangelog { get; init; } + + /// + /// Get a localized version of the update status. + /// + /// Status to localize. + /// Localized text. + public static string LocalizeUpdateStatusKind(StatusKind status) => status switch + { + StatusKind.Pending => Loc.Localize("InstallerUpdateStatusPending", "Pending"), + StatusKind.FailedDownload => Loc.Localize("InstallerUpdateStatusFailedDownload", "Download failed"), + StatusKind.FailedUnload => Loc.Localize("InstallerUpdateStatusFailedUnload", "Unload failed"), + StatusKind.FailedLoad => Loc.Localize("InstallerUpdateStatusFailedLoad", "Load failed"), + StatusKind.Success => Loc.Localize("InstallerUpdateStatusSuccess", "Success"), + _ => "???", + }; }