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"),
+ _ => "???",
+ };
}