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`. |