Support ZIP extraction with rootPath attribute in update URLs

This commit is contained in:
Stone_Red
2026-02-21 03:35:29 +01:00
parent eae786b680
commit 3d4f9ba4cd
3 changed files with 93 additions and 13 deletions
+67 -10
View File
@@ -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++;
+25 -2
View File
@@ -62,10 +62,10 @@ namespace AlwaysUpToDate
public string Version { get; set; }
/// <summary>
/// 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.
/// </summary>
[XmlElement("url")]
public string DownloadUrl { get; set; }
public DownloadUrl DownloadUrl { get; set; }
/// <summary>
/// Gets or sets an optional URL pointing to a changelog for this update.
@@ -181,6 +181,29 @@ namespace AlwaysUpToDate
public string Value { get; set; }
}
/// <summary>
/// Represents a download URL with an optional root path inside the ZIP file.
/// </summary>
/// <example>
/// <c>&lt;url rootPath="release/net8.0"&gt;https://example.com/app.zip&lt;/url&gt;</c>
/// </example>
public class DownloadUrl
{
/// <summary>
/// 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 <see langword="null"/> (default), the updater auto-detects a single common root folder.
/// </summary>
[XmlAttribute("rootPath")]
public string RootPath { get; set; }
/// <summary>
/// Gets or sets the URL from which the update ZIP file can be downloaded.
/// </summary>
[XmlText]
public string Value { get; set; }
}
/// <summary>
/// Specifies the target operating system for an update item.
/// </summary>
+1 -1
View File
@@ -32,7 +32,7 @@ The manifest is an XML file with one `<item>` 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 `<os>`. 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., `<url rootPath="release/net8.0">...</url>`). 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`. |