From 3d4f9ba4cd732272daadda8bf790489d29f5666e Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Sat, 21 Feb 2026 03:35:29 +0100 Subject: [PATCH] Support ZIP extraction with `rootPath` attribute in update URLs --- AlwaysUpToDate/DownloadManager.cs | 77 +++++++++++++++++++++++++++---- AlwaysUpToDate/UpdateInfo.cs | 27 ++++++++++- README.md | 2 +- 3 files changed, 93 insertions(+), 13 deletions(-) diff --git a/AlwaysUpToDate/DownloadManager.cs b/AlwaysUpToDate/DownloadManager.cs index 851cb1b..8b920e5 100644 --- a/AlwaysUpToDate/DownloadManager.cs +++ b/AlwaysUpToDate/DownloadManager.cs @@ -206,7 +206,7 @@ namespace AlwaysUpToDate if (version > assemblyVersion && Interlocked.CompareExchange(ref updating, 0, 0) == 0) { - updateUrl = updateItem.DownloadUrl; + updateUrl = updateItem.DownloadUrl?.Value; pendingUpdateItem = updateItem; if (!updateItem.IsMandatory) { @@ -260,6 +260,46 @@ namespace AlwaysUpToDate }; } + private static string NormalizeZipRootPath(string zipRootPath) + { + if (string.IsNullOrWhiteSpace(zipRootPath)) + { + return null; + } + + string normalized = zipRootPath.Replace('\\', '/'); + if (!normalized.EndsWith("/")) + { + normalized += "/"; + } + + return normalized; + } + + private static string GetCommonRootFolder(ZipArchive archive) + { + ZipArchiveEntry firstFile = archive.Entries.FirstOrDefault(e => !string.IsNullOrEmpty(e.Name)); + if (firstFile == null) + { + return null; + } + + int slashIndex = firstFile.FullName.IndexOf('/'); + if (slashIndex < 0) + { + return null; + } + + string root = firstFile.FullName.Substring(0, slashIndex + 1); + + if (archive.Entries.All(e => e.FullName.StartsWith(root, StringComparison.OrdinalIgnoreCase))) + { + return root; + } + + return null; + } + private async Task DownloadFile() { try @@ -309,42 +349,59 @@ namespace AlwaysUpToDate using (ZipArchive zipArchive = ZipFile.OpenRead(zipPath)) { string fullInstallPath = Path.GetFullPath(installPath + Path.DirectorySeparatorChar); + string commonRoot = NormalizeZipRootPath(pendingUpdateItem?.DownloadUrl?.RootPath) ?? GetCommonRootFolder(zipArchive); long totalEntries = zipArchive.Entries.Count; long processedEntries = 0; foreach (ZipArchiveEntry entry in zipArchive.Entries) { - string destinationPath = Path.GetFullPath(Path.Join(installPath, entry.FullName)); + string entryRelativePath = commonRoot != null + ? entry.FullName.Substring(commonRoot.Length) + : entry.FullName; + + if (string.IsNullOrEmpty(entryRelativePath)) + { + processedEntries++; + continue; + } + + string destinationPath = Path.GetFullPath(Path.Join(installPath, entryRelativePath)); if (!destinationPath.StartsWith(fullInstallPath, StringComparison.OrdinalIgnoreCase)) { processedEntries++; continue; } - if (File.Exists(Path.Join(installPath, entry.FullName))) + if (File.Exists(Path.Join(installPath, entryRelativePath))) { - string moveName = Path.GetFileNameWithoutExtension(entry.FullName) + "_OLD_" + Path.GetExtension(entry.FullName); + string moveName = Path.GetFileNameWithoutExtension(entryRelativePath) + "_OLD_" + Path.GetExtension(entryRelativePath); int counter = 1; while (File.Exists(Path.Join(installPath, moveName))) { - moveName = Path.GetFileNameWithoutExtension(entry.FullName) + "_OLD_" + counter + Path.GetExtension(entry.FullName); + moveName = Path.GetFileNameWithoutExtension(entryRelativePath) + "_OLD_" + counter + Path.GetExtension(entryRelativePath); counter++; } - File.Move(Path.Join(installPath, entry.FullName), Path.Join(installPath, moveName)); + File.Move(Path.Join(installPath, entryRelativePath), Path.Join(installPath, moveName)); } - if (entry.FullName.EndsWith('/')) + if (entryRelativePath.EndsWith('/')) { - if (!Directory.Exists(Path.Join(installPath, entry.FullName))) + if (!Directory.Exists(Path.Join(installPath, entryRelativePath))) { - _ = Directory.CreateDirectory(Path.Join(installPath, entry.FullName)); + _ = Directory.CreateDirectory(Path.Join(installPath, entryRelativePath)); } } else { - entry.ExtractToFile(Path.Join(installPath, entry.FullName), true); + string entryDirectory = Path.GetDirectoryName(Path.Join(installPath, entryRelativePath)); + if (!string.IsNullOrEmpty(entryDirectory) && !Directory.Exists(entryDirectory)) + { + _ = Directory.CreateDirectory(entryDirectory); + } + + entry.ExtractToFile(Path.Join(installPath, entryRelativePath), true); } processedEntries++; diff --git a/AlwaysUpToDate/UpdateInfo.cs b/AlwaysUpToDate/UpdateInfo.cs index 7a59b1f..42e15d1 100644 --- a/AlwaysUpToDate/UpdateInfo.cs +++ b/AlwaysUpToDate/UpdateInfo.cs @@ -62,10 +62,10 @@ namespace AlwaysUpToDate public string Version { get; set; } /// - /// Gets or sets the URL from which the update ZIP file can be downloaded. + /// Gets or sets the download URL and optional ZIP root path for this update. /// [XmlElement("url")] - public string DownloadUrl { get; set; } + public DownloadUrl DownloadUrl { get; set; } /// /// Gets or sets an optional URL pointing to a changelog for this update. @@ -181,6 +181,29 @@ namespace AlwaysUpToDate public string Value { get; set; } } + /// + /// Represents a download URL with an optional root path inside the ZIP file. + /// + /// + /// <url rootPath="release/net8.0">https://example.com/app.zip</url> + /// + public class DownloadUrl + { + /// + /// Gets or sets an optional path prefix within the ZIP file to treat as the extraction root. + /// Only entries under this path are extracted, and the prefix is stripped from their destination paths. + /// When (default), the updater auto-detects a single common root folder. + /// + [XmlAttribute("rootPath")] + public string RootPath { get; set; } + + /// + /// Gets or sets the URL from which the update ZIP file can be downloaded. + /// + [XmlText] + public string Value { get; set; } + } + /// /// Specifies the target operating system for an update item. /// diff --git a/README.md b/README.md index 5ab9337..82acafa 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ The manifest is an XML file with one `` per target platform. | `os` | Yes | Target platform. Can be just an OS (`windows`, `linux`, `macos`/`osx`) or a combined OS-architecture value (`osx-arm64`, `windows-x64`, `linux-arm64`, etc.). | | `arch` | No | Target architecture (`x86`, `x64`, `arm`, `arm64`, `any`). Overrides any architecture embedded in ``. Only needed when using the separate-element format. | | `version` | Yes | Version in `X.X.X.X` format. | -| `url` | Yes | URL to the update ZIP file. | +| `url` | Yes | URL to the update ZIP file. Supports an optional `rootPath` attribute to specify a path prefix inside the ZIP to treat as the extraction root (e.g., `...`). When omitted, the updater auto-detects a single common root folder. | | `changelog` | No | URL pointing to a changelog. Passed to the `UpdateAvailable` event. | | `mandatory` | No | `true` or `false` (default `false`). Mandatory updates are installed immediately. | | `checksum` | No | Hex-encoded hash of the ZIP file. The `algorithm` attribute can be `SHA1`, `MD5`, `SHA256`, or `SHA512`. |