diff --git a/README.md b/README.md index cbac6d57..73ead3c6 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,12 @@ In your project file, make sure you set up a few things so that the library can ``` +IMPORTANT NOTE: In .NET 8+, a change was made that causes your git/source code commit hash to be included in your app's `` number. This behavior cannot be avoided by NetSparkleUpdater at this time as we rely on `AssemblyInformationalVersionAttribute`, and this attribute's behavior was changed. Your users may be told that they are currently running `1.0.0+commitHashHere` by NetSparkleUpdater (and your native app itself!). We recommend adding the following lines to your project file (in a new `` or an existing one): + +```xml +false +``` + ### Code ```csharp @@ -426,7 +432,7 @@ Having just the latest version of your software in the app cast has the added si 3. If you don't want to generate signatures because you trust your AppCenter builds, use `SecurityMode.Unsafe` or the following `IAppCastHandler` override: ```csharp -public bool DownloadAndParse() +public override bool DownloadAndParse() { try { @@ -435,7 +441,8 @@ public bool DownloadAndParse() var appCast = _dataDownloader.DownloadAndGetAppCastData(_castUrl); if (!string.IsNullOrWhiteSpace(appCast)) { - ParseAppCast(appCast); + Items.Clear(); + Items.AddRange(ParseAppCast(appcast)); return true; } } @@ -504,7 +511,7 @@ Contributions are ALWAYS welcome! If you see a new feature you'd like to add, pl * Unit tests for all parts of the project * Extensive testing on macOS/Linux -* More built-in app cast parsers (e.g. natively support using/creating JSON feeds) -- possible via interfaces but not built-in yet +* More built-in app cast parsers * More options in the app cast generator * See the [issues list](https://github.com/NetSparkleUpdater/NetSparkle/issues) for more diff --git a/UPGRADING.md b/UPGRADING.md index 8c550f91..65dfa917 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -34,7 +34,25 @@ * By default, timestamps are now output along with the `Tag` and actual log item * `RegistryConfiguration` has changed its default final path to `NetSparkleUpdater` instead of `AutoUpdate`. Please migrate saved configuration data yourself if you need to do so for your users (probably not necessary). * `ShowUpdateNeededUI` no longer shows an update window if the number of update items is 0. (Arguably a bug fix, but technically a breaking change.) - +* Major refactoring for app cast handling/parsing to clean up logic / make new formats easier + * New `AppCast` model class that holds info on the actual app cast and its items + * `AppCastItem` no longer contains information on serializing or parsing to/from XML + * App cast parsing/serializing is now handled by an `IAppCastGenerator` implementation + * `XMLAppCast` renamed to `AppCastHelper` + * `SparkleUpdater` now has an `AppCastGenerator` member that handles deserializing the app cast rather than `AppCastHelper` + * `AppCastItem` serialization now expects the full download link to already be known (serialization will not consider the overall app cast URL) + * `IAppCastHandler` is no longer available/used. + * App cast downloading and item filtering is now handled by `AppCastHelper`. + * If you want to manage downloads, implement `IAppCastDataDownloader` and set `SparkleUpdater.AppCastDataDownloader` + * If you want to manage deserializing/serializing the app cast, implement `IAppCastGenerator` and set `SparkleUpdater.AppCastGenerator` + * If you want to manage filtering on your own, implement `IAppCastFilter` and set `SparkleUpdater.AppCastHelper.AppCastFilter` + * `AppCastHelper` also has two new properties: `FilterOutItemsWithNoVersion` and `FilterOutItemsWithNoDownloadLink`, which both default to `true`. + * If you need absolute control more than the above, you can subclass `AppCastHelper` and override methods: `SetupAppCastHelper`, `DownloadAppCast`, and `FilterUpdates`. This probably is not necessary, however, and you can do what you want through the interfaces, most likely. + * `AppCastHelper.SetupAppCastHelper` signature is now `SetupAppCastHelper(IAppCastDataDownloader dataDownloader, string castUrl, string? installedVersion, ISignatureVerifier? signatureVerifier, ILogger? logWriter = null)` (note: no longer takes a `Configuration` object) +* Renamed `AppCastItem.OperatingSystemString` to `OperatingSystem` +* XML app casts write `version`, `shortVersion`, and `criticalUpdate` to the `` tag and the `` (both for backwards/Sparkle compat; we'd rather not write to `` but we don't want to break anyone that updates their app cast gen without updating the main library). + * If both the overall `` and the `` have this data, the info from the `` is prioritized. + * JSON app casts are not affected. **Changes/Fixes** @@ -64,6 +82,13 @@ * Added `nullability` compatibility to core and UI libraries (#595) * Base language version is now 8.0 (9.0 for Avalonia), but this is only used for nullability compatibility (compile-time), so this shouldn't affect older projects (`.NET 4.6.2`, `netstandard2.0`) and is thus a non-breaking change * Fixed initialization issue in DownloadProgressWindow (WinForms) icon use +* Added `JsonAppCastGenerator` to read/write app casts from/to JSON (use with app cast generator option `--output-type`) +* Added `ChannelAppCastFilter` (implements `IAppCastFilter`) for easy way to filter your app cast items by a channel, e.g. `beta` or `alpha`. Use by setting `AppCastHelper.AppCastFilter`. Uses simple `string.Contains` invariant lowercase string check to search for channels in the `AppCastItem`'s version information. + * If you want to allow versions like `2.0.0-beta.1`, set `ChannelSearchNames` to `new List() {"beta"}` + * Set `RemoveOlderItems` to `false` if you want to keep old versions when filtering, e.g. for rolling back to an old version + * Set `KeepItemsWithNoSuffix` to `false` if you want to remove all items that don't match the given channel (doing this will not let people on a beta version update to a non-beta version!) +* `AppCast? SparkleUpdater.AppCastCache` holds the most recently deserialized app cast information. +* `AppCastItem` has a new `Channel` property. Use it along with `ChannelAppCastFilter` if you want to use channels that way instead of via your `` property. In the app cast generator, use the `--channel` option to set this. ## Updating from 0.X or 1.X to 2.X diff --git a/src/NetSparkle.Samples.Avalonia.MacOS/NetSparkle.Samples.Avalonia.MacOS.csproj b/src/NetSparkle.Samples.Avalonia.MacOS/NetSparkle.Samples.Avalonia.MacOS.csproj index 324bf98d..1af3c655 100644 --- a/src/NetSparkle.Samples.Avalonia.MacOS/NetSparkle.Samples.Avalonia.MacOS.csproj +++ b/src/NetSparkle.Samples.Avalonia.MacOS/NetSparkle.Samples.Avalonia.MacOS.csproj @@ -5,6 +5,8 @@ software-update-available.ico NetSparkleUpdater.Samples.Avalonia NetSparkleUpdater.Samples.Avalonia + 1.0.0-beta1 + false diff --git a/src/NetSparkle.Samples.Avalonia.MacOSZip/NetSparkle.Samples.Avalonia.MacOSZip.csproj b/src/NetSparkle.Samples.Avalonia.MacOSZip/NetSparkle.Samples.Avalonia.MacOSZip.csproj index 1da36faa..2d617580 100644 --- a/src/NetSparkle.Samples.Avalonia.MacOSZip/NetSparkle.Samples.Avalonia.MacOSZip.csproj +++ b/src/NetSparkle.Samples.Avalonia.MacOSZip/NetSparkle.Samples.Avalonia.MacOSZip.csproj @@ -5,6 +5,7 @@ software-update-available.ico NetSparkleUpdater.Samples.Avalonia NetSparkleUpdater.Samples.Avalonia + false diff --git a/src/NetSparkle.Samples.Avalonia/NetSparkle.Samples.Avalonia.csproj b/src/NetSparkle.Samples.Avalonia/NetSparkle.Samples.Avalonia.csproj index bfcd86a4..d152db72 100644 --- a/src/NetSparkle.Samples.Avalonia/NetSparkle.Samples.Avalonia.csproj +++ b/src/NetSparkle.Samples.Avalonia/NetSparkle.Samples.Avalonia.csproj @@ -5,8 +5,9 @@ software-update-available.ico NetSparkleUpdater.Samples.Avalonia NetSparkleUpdater.Samples.Avalonia - true - true + true + true + false diff --git a/src/NetSparkle.Samples.DownloadedExe/NetSparkle.Samples.UpdateExe.csproj b/src/NetSparkle.Samples.DownloadedExe/NetSparkle.Samples.UpdateExe.csproj index d09e1db9..de0964a2 100644 --- a/src/NetSparkle.Samples.DownloadedExe/NetSparkle.Samples.UpdateExe.csproj +++ b/src/NetSparkle.Samples.DownloadedExe/NetSparkle.Samples.UpdateExe.csproj @@ -12,6 +12,7 @@ v4.6.2 512 true + false AnyCPU diff --git a/src/NetSparkle.Samples.HandleEventsYourself/NetSparkle.Samples.HandleEventsYourself.csproj b/src/NetSparkle.Samples.HandleEventsYourself/NetSparkle.Samples.HandleEventsYourself.csproj index 5371b572..aafa4dec 100644 --- a/src/NetSparkle.Samples.HandleEventsYourself/NetSparkle.Samples.HandleEventsYourself.csproj +++ b/src/NetSparkle.Samples.HandleEventsYourself/NetSparkle.Samples.HandleEventsYourself.csproj @@ -4,6 +4,7 @@ WinExe net8.0-windows true + false diff --git a/src/NetSparkle.Samples.NetCore.WPF/NetSparkle.Samples.NetCore.WPF.csproj b/src/NetSparkle.Samples.NetCore.WPF/NetSparkle.Samples.NetCore.WPF.csproj index a145814a..e712b1b5 100644 --- a/src/NetSparkle.Samples.NetCore.WPF/NetSparkle.Samples.NetCore.WPF.csproj +++ b/src/NetSparkle.Samples.NetCore.WPF/NetSparkle.Samples.NetCore.WPF.csproj @@ -5,6 +5,7 @@ net7.0-windows true software-update-available.ico + false diff --git a/src/NetSparkle.Samples.NetCore.WinForms/NetSparkle.Samples.NetCore.WinForms.csproj b/src/NetSparkle.Samples.NetCore.WinForms/NetSparkle.Samples.NetCore.WinForms.csproj index ee0d42a3..0b1c2ad0 100644 --- a/src/NetSparkle.Samples.NetCore.WinForms/NetSparkle.Samples.NetCore.WinForms.csproj +++ b/src/NetSparkle.Samples.NetCore.WinForms/NetSparkle.Samples.NetCore.WinForms.csproj @@ -5,6 +5,7 @@ net7.0-windows true software-update-available.ico + false diff --git a/src/NetSparkle.Tests.AppCastGenerator/AppCastMakerTests.cs b/src/NetSparkle.Tests.AppCastGenerator/AppCastMakerTests.cs index a45fc3da..94864a14 100644 --- a/src/NetSparkle.Tests.AppCastGenerator/AppCastMakerTests.cs +++ b/src/NetSparkle.Tests.AppCastGenerator/AppCastMakerTests.cs @@ -7,12 +7,20 @@ using Xunit; using System.Runtime.InteropServices; using System.Diagnostics; +using NetSparkleUpdater.Interfaces; +using NetSparkleUpdater.AppCastHandlers; namespace NetSparkle.Tests.AppCastGenerator { [Collection(SignatureManagerFixture.CollectionName)] public class AppCastMakerTests { + public enum AppCastMakerType + { + Xml = 0, + Json = 1 + } + SignatureManagerFixture _fixture; public AppCastMakerTests(SignatureManagerFixture fixture) @@ -196,8 +204,10 @@ public void CanGetVersionFromFolderPathWithInitialBinaryDir() Assert.Null(AppCastMaker.GetVersionFromName(Path.Combine("output", "1.0", "file.ext"), Path.Combine("output", "1.0", "file.ext"))); } - [Fact] - public void CanGetVersionFromFullPathOnDisk() + [Theory] + [InlineData(AppCastMakerType.Xml)] + [InlineData(AppCastMakerType.Json)] + public void CanGetVersionFromFullPathOnDisk(AppCastMakerType appCastMakerType) { // test a full file path by using the tmp dir var tempDir = GetCleanTempDir(); @@ -227,7 +237,9 @@ public void CanGetVersionFromFullPathOnDisk() var signatureManager = _fixture.GetSignatureManager(); Assert.True(signatureManager.KeysExist()); - var maker = new XMLAppCastMaker(signatureManager, opts); + AppCastMaker maker = appCastMakerType == AppCastMakerType.Xml + ? new XMLAppCastMaker(signatureManager, opts) + : new JsonAppCastMaker(signatureManager, opts); var appCastFileName = maker.GetPathToAppCastOutput(opts.OutputDirectory, opts.SourceBinaryDirectory); var (items, productName) = maker.LoadAppCastItemsAndProductName(opts.SourceBinaryDirectory, opts.ReparseExistingAppCast, appCastFileName); Assert.Equal("100.0.302", items[0].Version); @@ -318,11 +330,21 @@ public void XMLAppCastHasProperExtension() Assert.Equal("xml", maker.GetAppCastExtension()); } - [Fact] - public void CanGetItemsAndProductNameFromExistingAppCast() + public void JsonAppCastHasProperExtension() { - var maker = new XMLAppCastMaker(_fixture.GetSignatureManager(), new Options()); + var maker = new JsonAppCastMaker(_fixture.GetSignatureManager(), new Options()); + Assert.Equal("json", maker.GetAppCastExtension()); + } + + [Theory] + [InlineData(AppCastMakerType.Xml)] + [InlineData(AppCastMakerType.Json)] + public void CanGetItemsAndProductNameFromExistingAppCast(AppCastMakerType appCastMakerType) + { + AppCastMaker maker = appCastMakerType == AppCastMakerType.Xml + ? new XMLAppCastMaker(_fixture.GetSignatureManager(), new Options()) + : new JsonAppCastMaker(_fixture.GetSignatureManager(), new Options()); // create fake app cast file var appCastData = @""; var fakeAppCastFilePath = Path.GetTempFileName(); @@ -331,7 +353,9 @@ public void CanGetItemsAndProductNameFromExistingAppCast() Assert.Empty(items); Assert.Null(productName); // now create something with some actual data! - appCastData = @" + if (appCastMakerType == AppCastMakerType.Xml) + { + appCastData = @" @@ -368,8 +392,44 @@ public void CanGetItemsAndProductNameFromExistingAppCast() ".Trim(); + } + else + { + appCastData = @" + { + ""title"": ""NetSparkle Test App"", + ""langauge"": ""en"", + ""description"": ""Most recent changes with links to updates."", + ""link"": ""https://netsparkleupdater.github.io/NetSparkle/files/sample-app/appcast.json"", + ""items"": [ + { + ""title"": ""Version 2.0"", + ""release_notes_link"": ""https://netsparkleupdater.github.io/NetSparkle/files/sample-app/2.0-release-notes.md"", + ""publication_date"": ""2016-10-28T10:30:00"", + ""url"": ""https://netsparkleupdater.github.io/NetSparkle/files/sample-app/NetSparkleUpdate.exe"", + ""version"": ""2.0"", + ""os"": ""windows"", + ""size"": 12288, + ""type"": ""application/octet-stream"", + ""signature"": ""foo"" + }, + { + ""title"": ""Version 1.3"", + ""release_notes_link"": ""https://netsparkleupdater.github.io/NetSparkle/files/sample-app/1.3-release-notes.md"", + ""publication_date"": ""2016-10-27T10:30:00"", + ""url"": ""https://netsparkleupdater.github.io/NetSparkle/files/sample-app/NetSparkleUpdate13.exe"", + ""version"": ""1.3"", + ""os"": ""linux"", + ""size"": 11555, + ""type"": ""application/octet-stream"", + ""signature"": ""bar"" + } + ] + }".Trim(); + } fakeAppCastFilePath = Path.GetTempFileName(); File.WriteAllText(fakeAppCastFilePath, appCastData); + Console.WriteLine(appCastData); (items, productName) = maker.GetItemsAndProductNameFromExistingAppCast(fakeAppCastFilePath, false); Assert.Equal("NetSparkle Test App", productName); Assert.Equal(2, items.Count); @@ -377,7 +437,7 @@ public void CanGetItemsAndProductNameFromExistingAppCast() Assert.Equal("https://netsparkleupdater.github.io/NetSparkle/files/sample-app/2.0-release-notes.md", items[0].ReleaseNotesLink); Assert.Equal(28, items[0].PublicationDate.Day); Assert.Equal("https://netsparkleupdater.github.io/NetSparkle/files/sample-app/NetSparkleUpdate.exe", items[0].DownloadLink); - Assert.Equal("windows", items[0].OperatingSystemString); + Assert.Equal("windows", items[0].OperatingSystem); Assert.Equal("2.0", items[0].Version); Assert.Equal(12288, items[0].UpdateSize); Assert.Equal("foo", items[0].DownloadSignature); @@ -387,13 +447,16 @@ public void CanGetItemsAndProductNameFromExistingAppCast() Assert.Equal(27, items[1].PublicationDate.Day); Assert.Equal(30, items[1].PublicationDate.Minute); Assert.Equal("https://netsparkleupdater.github.io/NetSparkle/files/sample-app/NetSparkleUpdate13.exe", items[1].DownloadLink); - Assert.Equal("linux", items[1].OperatingSystemString); + Assert.Equal("linux", items[1].OperatingSystem); Assert.Equal("1.3", items[1].Version); Assert.Equal(11555, items[1].UpdateSize); Assert.Equal("bar", items[1].DownloadSignature); - // test duplicate items - appCastData = @" + // test duplicate items -- items found earlier in the app cast parsing should be + // overwritten by later items if they have the same version + if (appCastMakerType == AppCastMakerType.Xml) + { + appCastData = @" @@ -443,6 +506,52 @@ public void CanGetItemsAndProductNameFromExistingAppCast() ".Trim(); + } + else + { + appCastData = @" + { + ""title"": ""NetSparkle Test App"", + ""langauge"": ""en"", + ""description"": ""Most recent changes with links to updates."", + ""link"": ""https://netsparkleupdater.github.io/NetSparkle/files/sample-app/appcast.json"", + ""items"": [ + { + ""title"": ""Version 2.0"", + ""release_notes_link"": ""https://netsparkleupdater.github.io/NetSparkle/files/sample-app/2.0-release-notes.md"", + ""publication_date"": ""2016-10-28T10:30:00"", + ""url"": ""https://netsparkleupdater.github.io/NetSparkle/files/sample-app/NetSparkleUpdate.exe"", + ""version"": ""2.0"", + ""os"": ""windows"", + ""size"": 12288, + ""type"": ""application/octet-stream"", + ""signature"": ""foo"" + }, + { + ""title"": ""Version 1.3"", + ""release_notes_link"": ""https://netsparkleupdater.github.io/NetSparkle/files/sample-app/1.3-release-notes.md"", + ""publication_date"": ""2016-10-27T10:30:00"", + ""url"": ""https://netsparkleupdater.github.io/NetSparkle/files/sample-app/NetSparkleUpdate13.exe"", + ""version"": ""1.3"", + ""os"": ""linux"", + ""size"": 11555, + ""type"": ""application/octet-stream"", + ""signature"": ""bar"" + }, + { + ""title"": ""Version 1.3 - The Real Deal"", + ""release_notes_link"": ""https://netsparkleupdater.github.io/NetSparkle/files/sample-app/1.3-real-release-notes.md"", + ""publication_date"": ""2016-10-27T12:44:00"", + ""url"": ""https://netsparkleupdater.github.io/NetSparkle/files/sample-app/NetSparkleUpdate13-real.exe"", + ""version"": ""1.3"", + ""os"": ""macOS"", + ""size"": 22222, + ""type"": ""application/octet-stream"", + ""signature"": ""moo"" + } + ] + }".Trim(); + } fakeAppCastFilePath = Path.GetTempFileName(); File.WriteAllText(fakeAppCastFilePath, appCastData); (items, productName) = maker.GetItemsAndProductNameFromExistingAppCast(fakeAppCastFilePath, true); @@ -452,7 +561,7 @@ public void CanGetItemsAndProductNameFromExistingAppCast() Assert.Equal("https://netsparkleupdater.github.io/NetSparkle/files/sample-app/2.0-release-notes.md", items[0].ReleaseNotesLink); Assert.Equal(28, items[0].PublicationDate.Day); Assert.Equal("https://netsparkleupdater.github.io/NetSparkle/files/sample-app/NetSparkleUpdate.exe", items[0].DownloadLink); - Assert.Equal("windows", items[0].OperatingSystemString); + Assert.Equal("windows", items[0].OperatingSystem); Assert.Equal("2.0", items[0].Version); Assert.Equal(12288, items[0].UpdateSize); Assert.Equal("foo", items[0].DownloadSignature); @@ -462,7 +571,7 @@ public void CanGetItemsAndProductNameFromExistingAppCast() Assert.Equal(27, items[1].PublicationDate.Day); Assert.Equal(44, items[1].PublicationDate.Minute); Assert.Equal("https://netsparkleupdater.github.io/NetSparkle/files/sample-app/NetSparkleUpdate13-real.exe", items[1].DownloadLink); - Assert.Equal("macOS", items[1].OperatingSystemString); + Assert.Equal("macOS", items[1].OperatingSystem); Assert.Equal("1.3", items[1].Version); Assert.Equal(22222, items[1].UpdateSize); Assert.Equal("moo", items[1].DownloadSignature); @@ -477,8 +586,10 @@ private static string RandomString(int length) .Select(s => s[random.Next(s.Length)]).ToArray()); } - [Fact] - public void CanCreateSimpleAppCast() + [Theory] + [InlineData(AppCastMakerType.Xml)] + [InlineData(AppCastMakerType.Json)] + public void CanCreateSimpleAppCast(AppCastMakerType appCastMakerType) { // setup test dir var tempDir = GetCleanTempDir(); @@ -505,7 +616,9 @@ public void CanCreateSimpleAppCast() var signatureManager = _fixture.GetSignatureManager(); Assert.True(signatureManager.KeysExist()); - var maker = new XMLAppCastMaker(signatureManager, opts); + AppCastMaker maker = appCastMakerType == AppCastMakerType.Xml + ? new XMLAppCastMaker(signatureManager, opts) + : new JsonAppCastMaker(signatureManager, opts); var appCastFileName = maker.GetPathToAppCastOutput(opts.OutputDirectory, opts.SourceBinaryDirectory); var (items, productName) = maker.LoadAppCastItemsAndProductName(opts.SourceBinaryDirectory, opts.ReparseExistingAppCast, appCastFileName); if (items != null) @@ -550,8 +663,10 @@ public void CanCreateSimpleAppCast() } } - [Fact] - public void SingleMajorMinorDigitVersionDoesNotFail() + [Theory] + [InlineData(AppCastMakerType.Xml)] + [InlineData(AppCastMakerType.Json)] + public void SingleMajorMinorDigitVersionDoesNotFail(AppCastMakerType appCastMakerType) { // setup test dir var tempDir = GetCleanTempDir(); @@ -578,7 +693,9 @@ public void SingleMajorMinorDigitVersionDoesNotFail() var signatureManager = _fixture.GetSignatureManager(); Assert.True(signatureManager.KeysExist()); - var maker = new XMLAppCastMaker(signatureManager, opts); + AppCastMaker maker = appCastMakerType == AppCastMakerType.Xml + ? new XMLAppCastMaker(signatureManager, opts) + : new JsonAppCastMaker(signatureManager, opts); var appCastFileName = maker.GetPathToAppCastOutput(opts.OutputDirectory, opts.SourceBinaryDirectory); var (items, productName) = maker.LoadAppCastItemsAndProductName(opts.SourceBinaryDirectory, opts.ReparseExistingAppCast, appCastFileName); if (items != null) @@ -601,8 +718,10 @@ public void SingleMajorMinorDigitVersionDoesNotFail() } } - [Fact] - public void NoVersionCausesEmptyAppCast() + [Theory] + [InlineData(AppCastMakerType.Xml)] + [InlineData(AppCastMakerType.Json)] + public void NoVersionCausesEmptyAppCast(AppCastMakerType appCastMakerType) { // setup test dir var tempDir = GetCleanTempDir(); @@ -629,7 +748,9 @@ public void NoVersionCausesEmptyAppCast() var signatureManager = _fixture.GetSignatureManager(); Assert.True(signatureManager.KeysExist()); - var maker = new XMLAppCastMaker(signatureManager, opts); + AppCastMaker maker = appCastMakerType == AppCastMakerType.Xml + ? new XMLAppCastMaker(signatureManager, opts) + : new JsonAppCastMaker(signatureManager, opts); var appCastFileName = maker.GetPathToAppCastOutput(opts.OutputDirectory, opts.SourceBinaryDirectory); var (items, productName) = maker.LoadAppCastItemsAndProductName(opts.SourceBinaryDirectory, opts.ReparseExistingAppCast, appCastFileName); // shouldn't have any items @@ -645,8 +766,10 @@ public void NoVersionCausesEmptyAppCast() // https://github.com/NetSparkleUpdater/NetSparkle/discussions/426 // attempts to reproduce bug but they are not using the --file-extract-version param // so we went ahead and kept the test case but couldn't repro bug - [Fact] - public void CheckReleaseNotesLink() + [Theory] + [InlineData(AppCastMakerType.Xml)] + [InlineData(AppCastMakerType.Json)] + public void CheckReleaseNotesLink(AppCastMakerType appCastMakerType) { // setup test dir var tempDir = GetCleanTempDir(); @@ -685,7 +808,9 @@ public void CheckReleaseNotesLink() var signatureManager = _fixture.GetSignatureManager(); Assert.True(signatureManager.KeysExist()); - var maker = new XMLAppCastMaker(signatureManager, opts); + AppCastMaker maker = appCastMakerType == AppCastMakerType.Xml + ? new XMLAppCastMaker(signatureManager, opts) + : new JsonAppCastMaker(signatureManager, opts); var appCastFileName = maker.GetPathToAppCastOutput(opts.OutputDirectory, opts.SourceBinaryDirectory); var (items, productName) = maker.LoadAppCastItemsAndProductName(opts.SourceBinaryDirectory, opts.ReparseExistingAppCast, appCastFileName); // shouldn't have any items @@ -694,7 +819,7 @@ public void CheckReleaseNotesLink() maker.SerializeItemsToFile(items, productName, appCastFileName); maker.CreateSignatureFile(appCastFileName, opts.SignatureFileExtension ?? "signature"); } - Console.Write(File.ReadAllText(Path.Combine(innerAppcastOutputPath, "appcast.xml"))); + Console.Write(File.ReadAllText(Path.Combine(innerAppcastOutputPath, "appcast." + maker.GetAppCastExtension()))); Assert.Single(items); Assert.Equal("1.0", items[0].Version); Assert.Equal("http://baseURL/appname/changelogs/1.0.md", items[0].ReleaseNotesLink); @@ -709,8 +834,10 @@ public void CheckReleaseNotesLink() } } - [Fact] - public void NetSparkleCanParseHumanReadableAppCast() + [Theory] + [InlineData(AppCastMakerType.Xml)] + [InlineData(AppCastMakerType.Json)] + public async void NetSparkleCanParseHumanReadableAppCast(AppCastMakerType appCastMakerType) { var tempDir = GetCleanTempDir(); // create dummy file @@ -737,7 +864,9 @@ public void NetSparkleCanParseHumanReadableAppCast() { var signatureManager = _fixture.GetSignatureManager(); Assert.True(signatureManager.KeysExist()); - var maker = new XMLAppCastMaker(signatureManager, opts); + AppCastMaker maker = appCastMakerType == AppCastMakerType.Xml + ? new XMLAppCastMaker(signatureManager, opts) + : new JsonAppCastMaker(signatureManager, opts); var appCastFileName = maker.GetPathToAppCastOutput(opts.OutputDirectory, opts.SourceBinaryDirectory); var (items, productName) = maker.LoadAppCastItemsAndProductName(opts.SourceBinaryDirectory, opts.ReparseExistingAppCast, appCastFileName); // should have one item @@ -753,24 +882,27 @@ public void NetSparkleCanParseHumanReadableAppCast() // for debugging print out app cast // Console.WriteLine(File.ReadAllText(appCastFileName)); // test NetSparkle reading file - var appCastHandler = new NetSparkleUpdater.AppCastHandlers.XMLAppCast(); + var appCastHelper = new NetSparkleUpdater.AppCastHandlers.AppCastHelper(); var publicKey = signatureManager.GetPublicKey(); var publicKeyString = Convert.ToBase64String(publicKey); - appCastHandler.SetupAppCastHandler( + var logWriter = new NetSparkleUpdater.LogWriter(LogWriterOutputMode.Console); + IAppCastGenerator appCastGenerator = appCastMakerType == AppCastMakerType.Xml + ? new NetSparkleUpdater.AppCastHandlers.XMLAppCastGenerator(logWriter) + : new NetSparkleUpdater.AppCastHandlers.JsonAppCastGenerator(logWriter); + appCastHelper.SetupAppCastHelper( new NetSparkleUpdater.Downloaders.LocalFileAppCastDownloader(), appCastFileName, - new EmptyTestDataConfguration( - new FakeTestDataAssemblyAccessor() - { - AssemblyVersion = "1.0" - }), + "1.0", new NetSparkleUpdater.SignatureVerifiers.Ed25519Checker( NetSparkleUpdater.Enums.SecurityMode.Strict, publicKeyString), - new NetSparkleUpdater.LogWriter(LogWriterOutputMode.Console)); - var didSucceed = appCastHandler.DownloadAndParse(); - Assert.True(didSucceed); - var updates = appCastHandler.GetAvailableUpdates(); + logWriter); + var appCast = await appCastHelper.DownloadAppCast(); + Assert.False(string.IsNullOrWhiteSpace(appCast)); + var appCastObj = appCastGenerator.DeserializeAppCast(appCast); + Assert.NotNull(appCastObj); + Assert.NotEmpty(appCastObj.Items); + var updates = appCastHelper.FilterUpdates(appCastObj.Items); Assert.Single(updates); Assert.Equal("2.0", updates[0].Version); Assert.Equal("https://example.com/downloads/hello%202.0.exe", updates[0].DownloadLink); @@ -826,8 +958,10 @@ public void CanSetVersionViaCLI() } } - [Fact] - public void CannotSetVersionViaCLIWithTwoItemsHavingNoVersion() + [Theory] + [InlineData(AppCastMakerType.Xml)] + [InlineData(AppCastMakerType.Json)] + public void CannotSetVersionViaCLIWithTwoItemsHavingNoVersion(AppCastMakerType appCastMakerType) { // setup test dir var tempDir = GetCleanTempDir(); @@ -858,7 +992,9 @@ public void CannotSetVersionViaCLIWithTwoItemsHavingNoVersion() var signatureManager = _fixture.GetSignatureManager(); Assert.True(signatureManager.KeysExist()); - var maker = new XMLAppCastMaker(signatureManager, opts); + AppCastMaker maker = appCastMakerType == AppCastMakerType.Xml + ? new XMLAppCastMaker(signatureManager, opts) + : new JsonAppCastMaker(signatureManager, opts); var appCastFileName = maker.GetPathToAppCastOutput(opts.OutputDirectory, opts.SourceBinaryDirectory); var (items, productName) = maker.LoadAppCastItemsAndProductName(opts.SourceBinaryDirectory, opts.ReparseExistingAppCast, appCastFileName); // items should be null since this is a failure case @@ -871,8 +1007,10 @@ public void CannotSetVersionViaCLIWithTwoItemsHavingNoVersion() } } - [Fact] - public void CanSetCriticalVersion() + [Theory] + [InlineData(AppCastMakerType.Xml)] + [InlineData(AppCastMakerType.Json)] + public async void CanSetCriticalVersion(AppCastMakerType appCastMakerType) { // setup test dir var tempDir = GetCleanTempDir(); @@ -903,7 +1041,9 @@ public void CanSetCriticalVersion() var signatureManager = _fixture.GetSignatureManager(); Assert.True(signatureManager.KeysExist()); - var maker = new XMLAppCastMaker(signatureManager, opts); + AppCastMaker maker = appCastMakerType == AppCastMakerType.Xml + ? new XMLAppCastMaker(signatureManager, opts) + : new JsonAppCastMaker(signatureManager, opts); var appCastFileName = maker.GetPathToAppCastOutput(opts.OutputDirectory, opts.SourceBinaryDirectory); var (items, productName) = maker.LoadAppCastItemsAndProductName(opts.SourceBinaryDirectory, opts.ReparseExistingAppCast, appCastFileName); Assert.Equal(2, items.Count()); @@ -919,25 +1059,29 @@ public void CanSetCriticalVersion() maker.CreateSignatureFile(appCastFileName, opts.SignatureFileExtension ?? "signature"); } // DEBUG: Console.WriteLine(File.ReadAllText(Path.Combine(tempDir, "appcast.xml"))); + Console.WriteLine(File.ReadAllText(Path.Combine(tempDir, "appcast." + maker.GetAppCastExtension()))); // test NetSparkle reading file - var appCastHandler = new NetSparkleUpdater.AppCastHandlers.XMLAppCast(); + var appCastHelper = new NetSparkleUpdater.AppCastHandlers.AppCastHelper(); var publicKey = signatureManager.GetPublicKey(); var publicKeyString = Convert.ToBase64String(publicKey); - appCastHandler.SetupAppCastHandler( + var logWriter = new NetSparkleUpdater.LogWriter(LogWriterOutputMode.Console); + IAppCastGenerator appCastGenerator = appCastMakerType == AppCastMakerType.Xml + ? new NetSparkleUpdater.AppCastHandlers.XMLAppCastGenerator(logWriter) + : new NetSparkleUpdater.AppCastHandlers.JsonAppCastGenerator(logWriter); + appCastHelper.SetupAppCastHelper( new NetSparkleUpdater.Downloaders.LocalFileAppCastDownloader(), appCastFileName, - new EmptyTestDataConfguration( - new FakeTestDataAssemblyAccessor() - { - AssemblyVersion = "1.0" - }), + "1.0", new NetSparkleUpdater.SignatureVerifiers.Ed25519Checker( NetSparkleUpdater.Enums.SecurityMode.Strict, publicKeyString), - new NetSparkleUpdater.LogWriter(LogWriterOutputMode.Console)); - var didSucceed = appCastHandler.DownloadAndParse(); - Assert.True(didSucceed); - var updates = appCastHandler.GetAvailableUpdates(); + logWriter); + var appCast = await appCastHelper.DownloadAppCast(); + Assert.False(string.IsNullOrWhiteSpace(appCast)); + var appCastObj = appCastGenerator.DeserializeAppCast(appCast); + Assert.NotNull(appCastObj); + Assert.NotEmpty(appCastObj.Items); + var updates = appCastHelper.FilterUpdates(appCastObj.Items); Assert.Equal(2, updates.Count()); // 1.4 should not be marked critical; 1.3 should be Assert.Equal("1.4", updates[0].Version); @@ -952,8 +1096,222 @@ public void CanSetCriticalVersion() } } - [Fact] - public void ChangelogNameInAppcastMatchesFilesystem() + [Theory] + [InlineData(AppCastMakerType.Xml, true)] + [InlineData(AppCastMakerType.Xml, false)] + [InlineData(AppCastMakerType.Json, true)] + public async void CanChangeXmlSignatureOutput(AppCastMakerType appCastMakerType, bool useEdSignatureAttr) + { + // setup test dir + var tempDir = GetCleanTempDir(); + // create dummy files + var myApp13FilePath = Path.Combine(tempDir, "hello myapp 1.3.txt"); + var myApp14FilePath = Path.Combine(tempDir, "hello myapp 1.4.txt"); + const int fileSizeBytes = 57; + var tempData = RandomString(fileSizeBytes); + File.WriteAllText(myApp13FilePath, tempData); + tempData = RandomString(fileSizeBytes); + File.WriteAllText(myApp14FilePath, tempData); + var opts = new Options() + { + FileExtractVersion = true, + SearchBinarySubDirectories = true, + SourceBinaryDirectory = tempDir, + Extensions = "txt", + OutputDirectory = tempDir, + OperatingSystem = GetOperatingSystemForAppCastString(), + BaseUrl = "https://example.com/downloads", + OverwriteOldItemsInAppcast = false, + ReparseExistingAppCast = false, + UseEd25519SignatureAttributeForXml = useEdSignatureAttr, + }; + + try + { + var signatureManager = _fixture.GetSignatureManager(); + Assert.True(signatureManager.KeysExist()); + var myApp13Signature = signatureManager.GetSignatureForFile(myApp13FilePath); + var myApp14Signature = signatureManager.GetSignatureForFile(myApp14FilePath); + + AppCastMaker maker = appCastMakerType == AppCastMakerType.Xml + ? new XMLAppCastMaker(signatureManager, opts) + : new JsonAppCastMaker(signatureManager, opts); + var appCastFileName = maker.GetPathToAppCastOutput(opts.OutputDirectory, opts.SourceBinaryDirectory); + var (items, productName) = maker.LoadAppCastItemsAndProductName(opts.SourceBinaryDirectory, opts.ReparseExistingAppCast, appCastFileName); + Assert.Equal(2, items.Count()); + // 1.4 should not be marked critical; 1.3 should be + Assert.Equal("1.4", items[0].Version); + Assert.Equal(myApp14Signature, items[0].DownloadSignature); + Assert.True(signatureManager.VerifySignature(myApp14FilePath, items[0].DownloadSignature)); + Assert.Equal("1.3", items[1].Version); + Assert.Equal(myApp13Signature, items[1].DownloadSignature); + Assert.True(signatureManager.VerifySignature(myApp13FilePath, items[1].DownloadSignature)); + // make sure data ends up in file, too + if (items != null) + { + maker.SerializeItemsToFile(items, productName, appCastFileName); + maker.CreateSignatureFile(appCastFileName, opts.SignatureFileExtension ?? "signature"); + } + // DEBUG: Console.WriteLine(File.ReadAllText(Path.Combine(tempDir, "appcast.xml"))); + var rawFile = File.ReadAllText(Path.Combine(tempDir, "appcast." + maker.GetAppCastExtension())); + // Console.WriteLine(rawFile); + if (appCastMakerType == AppCastMakerType.Xml) + { + if (useEdSignatureAttr) + { + // make sure output file got the ed25519 signature attribute + Assert.Contains(XMLAppCastGenerator.Ed25519SignatureAttribute, rawFile); + // won't contain sparkle:signature + Assert.DoesNotContain("sparkle:" + XMLAppCastGenerator.SignatureAttribute, rawFile); + } + else + { + Assert.DoesNotContain(XMLAppCastGenerator.Ed25519SignatureAttribute, rawFile); + Assert.Contains("sparkle:" + XMLAppCastGenerator.SignatureAttribute, rawFile); + } + } + if (appCastMakerType == AppCastMakerType.Json) + { + // does not affect JSON at all + Assert.Contains("signature", rawFile); + Assert.DoesNotContain(XMLAppCastGenerator.Ed25519SignatureAttribute, rawFile); + } + // test NetSparkle reading file + var appCastHelper = new NetSparkleUpdater.AppCastHandlers.AppCastHelper(); + var publicKey = signatureManager.GetPublicKey(); + var publicKeyString = Convert.ToBase64String(publicKey); + var logWriter = new NetSparkleUpdater.LogWriter(LogWriterOutputMode.Console); + IAppCastGenerator appCastGenerator = appCastMakerType == AppCastMakerType.Xml + ? new NetSparkleUpdater.AppCastHandlers.XMLAppCastGenerator(logWriter) + : new NetSparkleUpdater.AppCastHandlers.JsonAppCastGenerator(logWriter); + appCastHelper.SetupAppCastHelper( + new NetSparkleUpdater.Downloaders.LocalFileAppCastDownloader(), + appCastFileName, + "1.0", + new NetSparkleUpdater.SignatureVerifiers.Ed25519Checker( + NetSparkleUpdater.Enums.SecurityMode.Strict, + publicKeyString), + logWriter); + var appCast = await appCastHelper.DownloadAppCast(); + Assert.False(string.IsNullOrWhiteSpace(appCast)); + var appCastObj = appCastGenerator.DeserializeAppCast(appCast); + Assert.NotNull(appCastObj); + Assert.NotEmpty(appCastObj.Items); + var updates = appCastHelper.FilterUpdates(appCastObj.Items); + Assert.Equal(2, updates.Count()); + // 1.4 should not be marked critical; 1.3 should be + Assert.Equal("1.4", updates[0].Version); + Assert.Equal("1.3", updates[1].Version); + Assert.Equal(myApp14Signature, updates[0].DownloadSignature); + Assert.True(signatureManager.VerifySignature(myApp14FilePath, updates[0].DownloadSignature)); + Assert.Equal(myApp13Signature, updates[1].DownloadSignature); + Assert.True(signatureManager.VerifySignature(myApp13FilePath, updates[1].DownloadSignature)); + } + finally + { + // make sure tempDir always cleaned up + CleanUpDir(tempDir); + } + } + + [Theory] + [InlineData(AppCastMakerType.Xml)] + [InlineData(AppCastMakerType.Json)] + public async void CanSetChannel(AppCastMakerType appCastMakerType) + { + // setup test dir + var tempDir = GetCleanTempDir(); + // create dummy files + var dummyFilePath = Path.Combine(tempDir, "hello myapp 1.3.txt"); + var dummyFilePath2 = Path.Combine(tempDir, "hello myapp 1.4.txt"); + const int fileSizeBytes = 57; + var tempData = RandomString(fileSizeBytes); + File.WriteAllText(dummyFilePath, tempData); + tempData = RandomString(fileSizeBytes); + File.WriteAllText(dummyFilePath2, tempData); + var opts = new Options() + { + FileExtractVersion = true, + SearchBinarySubDirectories = true, + SourceBinaryDirectory = tempDir, + Extensions = "txt", + OutputDirectory = tempDir, + OperatingSystem = GetOperatingSystemForAppCastString(), + BaseUrl = "https://example.com/downloads", + OverwriteOldItemsInAppcast = false, + ReparseExistingAppCast = false, + CriticalVersions = "1.3", + Channel = "preview" + }; + + try + { + var signatureManager = _fixture.GetSignatureManager(); + Assert.True(signatureManager.KeysExist()); + + AppCastMaker maker = appCastMakerType == AppCastMakerType.Xml + ? new XMLAppCastMaker(signatureManager, opts) + : new JsonAppCastMaker(signatureManager, opts); + var appCastFileName = maker.GetPathToAppCastOutput(opts.OutputDirectory, opts.SourceBinaryDirectory); + var (items, productName) = maker.LoadAppCastItemsAndProductName(opts.SourceBinaryDirectory, opts.ReparseExistingAppCast, appCastFileName); + Assert.Equal(2, items.Count()); + // 1.4 should not be marked critical; 1.3 should be + Assert.Equal("1.4", items[0].Version); + Assert.False(items[0].IsCriticalUpdate); + Assert.Equal("preview", items[0].Channel); + Assert.Equal("1.3", items[1].Version); + Assert.True(items[1].IsCriticalUpdate); + Assert.Equal("preview", items[1].Channel); + // make sure data ends up in file, too + if (items != null) + { + maker.SerializeItemsToFile(items, productName, appCastFileName); + maker.CreateSignatureFile(appCastFileName, opts.SignatureFileExtension ?? "signature"); + } + // DEBUG: Console.WriteLine(File.ReadAllText(Path.Combine(tempDir, "appcast.xml"))); + Console.WriteLine(File.ReadAllText(Path.Combine(tempDir, "appcast." + maker.GetAppCastExtension()))); + // test NetSparkle reading file + var appCastHelper = new NetSparkleUpdater.AppCastHandlers.AppCastHelper(); + var publicKey = signatureManager.GetPublicKey(); + var publicKeyString = Convert.ToBase64String(publicKey); + var logWriter = new NetSparkleUpdater.LogWriter(LogWriterOutputMode.Console); + IAppCastGenerator appCastGenerator = appCastMakerType == AppCastMakerType.Xml + ? new NetSparkleUpdater.AppCastHandlers.XMLAppCastGenerator(logWriter) + : new NetSparkleUpdater.AppCastHandlers.JsonAppCastGenerator(logWriter); + appCastHelper.SetupAppCastHelper( + new NetSparkleUpdater.Downloaders.LocalFileAppCastDownloader(), + appCastFileName, + "1.0", + new NetSparkleUpdater.SignatureVerifiers.Ed25519Checker( + NetSparkleUpdater.Enums.SecurityMode.Strict, + publicKeyString), + logWriter); + var appCast = await appCastHelper.DownloadAppCast(); + Assert.False(string.IsNullOrWhiteSpace(appCast)); + var appCastObj = appCastGenerator.DeserializeAppCast(appCast); + Assert.NotNull(appCastObj); + Assert.NotEmpty(appCastObj.Items); + var updates = appCastHelper.FilterUpdates(appCastObj.Items); + Assert.Equal(2, updates.Count()); + // 1.4 should not be marked critical; 1.3 should be + Assert.Equal("1.4", updates[0].Version); + Assert.False(updates[0].IsCriticalUpdate); + Assert.Equal("preview", updates[0].Channel); + Assert.Equal("1.3", updates[1].Version); + Assert.True(updates[1].IsCriticalUpdate); + Assert.Equal("preview", updates[1].Channel); + } + finally + { + // make sure tempDir always cleaned up + CleanUpDir(tempDir); + } + } + + [Theory] + [InlineData(AppCastMakerType.Xml)] + [InlineData(AppCastMakerType.Json)] + public void ChangelogNameInAppcastMatchesFilesystem(AppCastMakerType appCastMakerType) { // setup test dir var tempDir = GetCleanTempDir(); @@ -989,7 +1347,9 @@ public void ChangelogNameInAppcastMatchesFilesystem() var signatureManager = _fixture.GetSignatureManager(); Assert.True(signatureManager.KeysExist()); - var maker = new XMLAppCastMaker(signatureManager, opts); + AppCastMaker maker = appCastMakerType == AppCastMakerType.Xml + ? new XMLAppCastMaker(signatureManager, opts) + : new JsonAppCastMaker(signatureManager, opts); var appCastFileName = maker.GetPathToAppCastOutput(opts.OutputDirectory, opts.SourceBinaryDirectory); var (items, productName) = maker.LoadAppCastItemsAndProductName(opts.SourceBinaryDirectory, opts.ReparseExistingAppCast, appCastFileName); Assert.Single(items); @@ -1002,10 +1362,14 @@ public void ChangelogNameInAppcastMatchesFilesystem() } } - [Fact] - public void CanGetSemVerLikeVersionsFromExistingAppCast() + [Theory] + [InlineData(AppCastMakerType.Xml)] + [InlineData(AppCastMakerType.Json)] + public void CanGetSemVerLikeVersionsFromExistingAppCast(AppCastMakerType appCastMakerType) { - var maker = new XMLAppCastMaker(_fixture.GetSignatureManager(), new Options()); + AppCastMaker maker = appCastMakerType == AppCastMakerType.Xml + ? new XMLAppCastMaker(_fixture.GetSignatureManager(), new Options()) + : new JsonAppCastMaker(_fixture.GetSignatureManager(), new Options()); // create fake app cast file var appCastData = @""; var fakeAppCastFilePath = Path.GetTempFileName(); @@ -1014,7 +1378,9 @@ public void CanGetSemVerLikeVersionsFromExistingAppCast() Assert.Empty(items); Assert.Null(productName); // now create something with some actual data! - appCastData = @" + if (appCastMakerType == AppCastMakerType.Xml) + { + appCastData = @" @@ -1053,6 +1419,43 @@ public void CanGetSemVerLikeVersionsFromExistingAppCast() ".Trim(); + } + else + { + appCastData = @" + { + ""title"": ""NetSparkle Test App"", + ""langauge"": ""en"", + ""description"": ""Most recent changes with links to updates."", + ""link"": ""https://netsparkleupdater.github.io/NetSparkle/files/sample-app/appcast.json"", + ""items"": [ + { + ""title"": ""Version 2.0 Beta 1"", + ""release_notes_link"": ""https://netsparkleupdater.github.io/NetSparkle/files/sample-app/2.0-release-notes.md"", + ""publication_date"": ""2016-10-28T10:30:00"", + ""url"": ""https://netsparkleupdater.github.io/NetSparkle/files/sample-app/NetSparkleUpdate.exe"", + ""version"": ""2.0-beta1"", + ""short_version"": ""2.0"", + ""os"": ""windows"", + ""size"": 1337, + ""type"": ""application/octet-stream"", + ""signature"": ""foo"" + }, + { + ""title"": ""Version 2.0 Alpha 1"", + ""release_notes_link"": ""https://netsparkleupdater.github.io/NetSparkle/files/sample-app/2.0-release-notes.md"", + ""publication_date"": ""2016-10-28T10:30:00"", + ""url"": ""https://netsparkleupdater.github.io/NetSparkle/files/sample-app/NetSparkleUpdate.exe"", + ""version"": ""2.0-alpha.1"", + ""short_version"": ""2.0"", + ""os"": ""windows"", + ""size"": 2337, + ""type"": ""application/octet-stream"", + ""signature"": ""bar"" + } + ] + }".Trim(); + } fakeAppCastFilePath = Path.GetTempFileName(); File.WriteAllText(fakeAppCastFilePath, appCastData); (items, productName) = maker.GetItemsAndProductNameFromExistingAppCast(fakeAppCastFilePath, false); @@ -1090,8 +1493,10 @@ private static string GetDotnetProcessName() return "dotnet"; } - [Fact] - public void CanMakeAppCastWithAssemblyData() + [Theory] + [InlineData(AppCastMakerType.Xml)] + [InlineData(AppCastMakerType.Json)] + public void CanMakeAppCastWithAssemblyData(AppCastMakerType appCastMakerType) { var envVersion = Environment.Version; var dotnetVersion = "net" + envVersion.Major + ".0"; @@ -1182,7 +1587,9 @@ public void CanMakeAppCastWithAssemblyData() var signatureManager = _fixture.GetSignatureManager(); Assert.True(signatureManager.KeysExist()); - var maker = new XMLAppCastMaker(signatureManager, opts); + AppCastMaker maker = appCastMakerType == AppCastMakerType.Xml + ? new XMLAppCastMaker(signatureManager, opts) + : new JsonAppCastMaker(signatureManager, opts); var appCastFileName = maker.GetPathToAppCastOutput(opts.OutputDirectory, opts.SourceBinaryDirectory); var (items, productName) = maker.LoadAppCastItemsAndProductName(opts.SourceBinaryDirectory, opts.ReparseExistingAppCast, appCastFileName); if (items != null) diff --git a/src/NetSparkle.Tests.AppCastGenerator/EmptyTestDataConfguration.cs b/src/NetSparkle.Tests.AppCastGenerator/EmptyTestDataConfiguration.cs similarity index 59% rename from src/NetSparkle.Tests.AppCastGenerator/EmptyTestDataConfguration.cs rename to src/NetSparkle.Tests.AppCastGenerator/EmptyTestDataConfiguration.cs index 73d9000c..76d4615f 100644 --- a/src/NetSparkle.Tests.AppCastGenerator/EmptyTestDataConfguration.cs +++ b/src/NetSparkle.Tests.AppCastGenerator/EmptyTestDataConfiguration.cs @@ -3,9 +3,9 @@ namespace NetSparkle.Tests.AppCastGenerator { - public class EmptyTestDataConfguration : Configuration + public class EmptyTestDataConfiguration : Configuration { - public EmptyTestDataConfguration(IAssemblyAccessor accessor) : base(accessor) + public EmptyTestDataConfiguration(IAssemblyAccessor accessor) : base(accessor) { } diff --git a/src/NetSparkle.Tests/AppCastGeneratorTests.cs b/src/NetSparkle.Tests/AppCastGeneratorTests.cs new file mode 100644 index 00000000..c2fefe4a --- /dev/null +++ b/src/NetSparkle.Tests/AppCastGeneratorTests.cs @@ -0,0 +1,372 @@ +using NetSparkleUpdater.AppCastHandlers; +using NetSparkleUpdater.Interfaces; +using NetSparkleUpdater; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Xunit; +using System.IO; +using Xunit.Sdk; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Xml.Linq; +using System.Xml; + +namespace NetSparkleUnitTests +{ + public class AppCastGeneratorTests + { + public enum AppCastMakerType + { + Xml = 0, + Json = 1 + } + + private IAppCastGenerator GetGeneratorForType(AppCastMakerType appCastMakerType) + { + var logger = new LogWriter() + { + OutputMode = NetSparkleUpdater.Enums.LogWriterOutputMode.Console + }; + if (appCastMakerType == AppCastMakerType.Xml) + { + return new XMLAppCastGenerator(logger); + } + else + { + return new JsonAppCastGenerator(logger); + } + } + + [Theory] + [InlineData(AppCastMakerType.Xml)] + [InlineData(AppCastMakerType.Json)] + public async void GeneratorOutputIsSorted(AppCastMakerType appCastMakerType) + { + var appCast = new AppCast() + { + Title = "My App", + Items = new List() + { + // intentionally put them out of order in the AppCast object + new AppCastItem() { Version = "0.9", DownloadLink = "https://mysite.com/update.exe" }, + new AppCastItem() { Version = "1.3", DownloadLink = "https://mysite.com/update.exe" }, + new AppCastItem() { Version = "1.1", DownloadLink = "https://mysite.com/update.exe" }, + } + }; + + IAppCastGenerator maker = GetGeneratorForType(appCastMakerType); + var serialized = maker.SerializeAppCast(appCast); // .Serialize() makes no promises about sorting + var deserialized = maker.DeserializeAppCast(serialized); + Assert.Equal(3, deserialized.Items.Count); + Assert.Equal("1.3", deserialized.Items[0].Version); + Assert.Equal("1.1", deserialized.Items[1].Version); + Assert.Equal("0.9", deserialized.Items[2].Version); + // test with other methods + deserialized = await maker.DeserializeAppCastAsync(serialized); + Assert.Equal(3, deserialized.Items.Count); + Assert.Equal("1.3", deserialized.Items[0].Version); + Assert.Equal("1.1", deserialized.Items[1].Version); + Assert.Equal("0.9", deserialized.Items[2].Version); + // write to file + var path = System.IO.Path.GetTempFileName(); + try + { + maker.SerializeAppCastToFile(appCast, path); + deserialized = maker.DeserializeAppCastFromFile(path); + Assert.Equal(3, deserialized.Items.Count); + Assert.Equal("1.3", deserialized.Items[0].Version); + Assert.Equal("1.1", deserialized.Items[1].Version); + Assert.Equal("0.9", deserialized.Items[2].Version); + deserialized = await maker.DeserializeAppCastFromFileAsync(path); + Assert.Equal(3, deserialized.Items.Count); + Assert.Equal("1.3", deserialized.Items[0].Version); + Assert.Equal("1.1", deserialized.Items[1].Version); + Assert.Equal("0.9", deserialized.Items[2].Version); + } + finally + { + System.IO.File.Delete(path); + } + } + + [Theory] + [InlineData(AppCastMakerType.Xml, nameof(IAppCastGenerator.SerializeAppCast))] + [InlineData(AppCastMakerType.Xml, nameof(IAppCastGenerator.SerializeAppCastAsync))] + [InlineData(AppCastMakerType.Xml, nameof(IAppCastGenerator.SerializeAppCastToFile))] + [InlineData(AppCastMakerType.Xml, nameof(IAppCastGenerator.SerializeAppCastToFileAsync))] + [InlineData(AppCastMakerType.Json, nameof(IAppCastGenerator.SerializeAppCast))] + [InlineData(AppCastMakerType.Json, nameof(IAppCastGenerator.SerializeAppCastAsync))] + [InlineData(AppCastMakerType.Json, nameof(IAppCastGenerator.SerializeAppCastToFile))] + [InlineData(AppCastMakerType.Json, nameof(IAppCastGenerator.SerializeAppCastToFileAsync))] + public async void TestCanSerializeAppCast(AppCastMakerType appCastMakerType, string serializeFuncName) + { + var appCast = new AppCast() + { + Title = "My App", + Description = "My App Updates", + Link = "https://mysite.com/updates", + Language = "en_US", + Items = new List() + { + new AppCastItem() + { + Version = "1.3", + DownloadLink = "https://mysite.com/update.deb", + DownloadSignature = "seru3112", + IsCriticalUpdate = true, + OperatingSystem = "linux", + PublicationDate = new DateTime(2023, 12, 09, 12, 12, 12, DateTimeKind.Utc), + Channel = "", + }, + new AppCastItem() + { + Title = "Super Beta", + Version = "0.9-beta1", + ShortVersion = "0.9", + DownloadLink = "https://mysite.com/update09beta.exe", + DownloadSignature = "seru311b2", + ReleaseNotesLink = "https://mysite.com/update09beta.md", + ReleaseNotesSignature = "srjlwj", + PublicationDate = new DateTime(1999, 12, 09, 11, 11, 11, DateTimeKind.Utc), + Channel = "beta", + }, + } + }; + IAppCastGenerator maker = GetGeneratorForType(appCastMakerType); + var serialized = ""; + if (serializeFuncName == nameof(IAppCastGenerator.SerializeAppCast)) + { + serialized = maker.SerializeAppCast(appCast); + } + else if (serializeFuncName == nameof(IAppCastGenerator.SerializeAppCastAsync)) + { + serialized = await maker.SerializeAppCastAsync(appCast); + } + else if (serializeFuncName == nameof(IAppCastGenerator.SerializeAppCastToFile)) + { + var path = System.IO.Path.GetTempFileName(); + maker.SerializeAppCastToFile(appCast, path); + serialized = File.ReadAllText(path); + File.Delete(path); + } + else if (serializeFuncName == nameof(IAppCastGenerator.SerializeAppCastToFileAsync)) + { + var path = System.IO.Path.GetTempFileName(); + await maker.SerializeAppCastToFileAsync(appCast, path); + serialized = File.ReadAllText(path); + File.Delete(path); + } + // manually parse things + if (appCastMakerType == AppCastMakerType.Xml) + { + XDocument doc = XDocument.Parse(serialized); + var rss = doc.Element("rss"); + var channel = rss.Element("channel"); + Assert.Equal("My App", channel.Element("title").Value); + Assert.Equal("My App Updates", channel.Element("description").Value); + Assert.Equal("https://mysite.com/updates", channel.Element("link").Value); + Assert.Equal("en_US", channel.Element("language").Value); + var items = channel?.Descendants("item"); + Assert.Equal(2, items.Count()); + var element = items.ElementAt(0); + var nspace = XMLAppCastGenerator.SparkleNamespace; + var enclosureElement = element.Element("enclosure"); + Assert.NotNull(enclosureElement); + Assert.Equal("1.3", element.Element(nspace + "version").Value); + Assert.Equal("https://mysite.com/update.deb", enclosureElement.Attribute("url").Value); + Assert.Equal("seru3112", enclosureElement.Attribute(nspace + "signature").Value); + Assert.Equal("", element.Element(nspace + "criticalUpdate").Value); + Assert.Equal("linux", enclosureElement.Attribute(nspace + "os").Value); + Assert.Contains("Sat, 09 Dec 2023 12:12:12", element.Element("pubDate").Value); + Assert.Null(element.Element(nspace + "channel")); + // test other item + element = items.ElementAt(1); + enclosureElement = element.Element("enclosure"); + Assert.NotNull(enclosureElement); + Assert.Equal("Super Beta", element.Element("title").Value); + Assert.Equal("0.9-beta1", element.Element(nspace + "version").Value); + Assert.Equal("0.9", element.Element(nspace + "shortVersionString").Value); + Assert.Equal("https://mysite.com/update09beta.exe", enclosureElement.Attribute("url").Value); + Assert.Equal("seru311b2", enclosureElement.Attribute(nspace + "signature").Value); + Assert.Equal("https://mysite.com/update09beta.md", element.Element(nspace + "releaseNotesLink").Value); + Assert.Equal("srjlwj", element.Element(nspace + "releaseNotesLink").Attribute(nspace + "signature").Value); + Assert.Null(element.Element(nspace + "criticalUpdate")); + Assert.Null(enclosureElement.Element(nspace + "os")); + Assert.Contains("Thu, 09 Dec 1999 11:11:11", element.Element("pubDate").Value); + Assert.Equal("beta", element.Element(nspace + "channel").Value); + } + else + { + JsonNode mainNode = JsonNode.Parse(serialized); + Assert.Equal("My App", mainNode["title"].ToString()); + Assert.Equal("My App Updates", mainNode["description"].ToString()); + Assert.Equal("https://mysite.com/updates", mainNode["link"].ToString()); + Assert.Equal("en_US", mainNode["language"].ToString()); + var items = mainNode["items"].AsArray(); + var element = items.ElementAt(0); + Assert.Equal("1.3", element["version"].ToString()); + Assert.Equal("https://mysite.com/update.deb", element["url"].ToString()); + Assert.Equal("seru3112", element["signature"].ToString()); + Assert.Equal("true", element["is_critical"].ToString()); + Assert.Equal("linux", element["os"].ToString()); + Assert.Contains("2023-12-09T12:12:12Z", element["publication_date"].ToString()); + Assert.Equal("", element["channel"].ToString()); + element = items.ElementAt(1); + Assert.Equal("Super Beta", element["title"].ToString()); + Assert.Equal("0.9-beta1", element["version"].ToString()); + Assert.Equal("0.9", element["short_version"].ToString()); + Assert.Equal("https://mysite.com/update09beta.exe", element["url"].ToString()); + Assert.Equal("seru311b2", element["signature"].ToString()); + Assert.Equal("https://mysite.com/update09beta.md", element["release_notes_link"].ToString()); + Assert.Equal("srjlwj", element["release_notes_signature"].ToString()); + Assert.Equal("false", element["is_critical"].ToString()); + Assert.Equal(AppCastItem.DefaultOperatingSystem, element["os"].ToString()); + Assert.Contains("1999-12-09T11:11:11Z", element["publication_date"].ToString()); + Assert.Equal("beta", element["channel"].ToString()); + } + } + + [Theory] + [InlineData(AppCastMakerType.Xml, nameof(IAppCastGenerator.DeserializeAppCast))] + [InlineData(AppCastMakerType.Xml, nameof(IAppCastGenerator.DeserializeAppCastAsync))] + [InlineData(AppCastMakerType.Xml, nameof(IAppCastGenerator.DeserializeAppCastFromFile))] + [InlineData(AppCastMakerType.Xml, nameof(IAppCastGenerator.DeserializeAppCastFromFileAsync))] + [InlineData(AppCastMakerType.Json, nameof(IAppCastGenerator.DeserializeAppCast))] + [InlineData(AppCastMakerType.Json, nameof(IAppCastGenerator.DeserializeAppCastAsync))] + [InlineData(AppCastMakerType.Json, nameof(IAppCastGenerator.DeserializeAppCastFromFile))] + [InlineData(AppCastMakerType.Json, nameof(IAppCastGenerator.DeserializeAppCastFromFileAsync))] + public async void TestCanDeserializeAppCast(AppCastMakerType appCastMakerType, string deserializeFuncName) + { + var appCastToDeserialize = ""; + if (appCastMakerType == AppCastMakerType.Xml) + { + appCastToDeserialize = @" + + + My App + https://mysite.com/updates + My App Updates + en_US + + + Sat, 09 Dec 2023 12:12:12 +09:00 + 1.3 + + + + + + Super Beta + https://mysite.com/update09beta.md + Thu, 09 Dec 1999 11:11:11 +09:00 + 0.9-beta1 + 0.9 + beta + + + +".Trim(); + } + else + { + appCastToDeserialize = @"{ + ""title"": ""My App"", + ""language"": ""en_US"", + ""description"": ""My App Updates"", + ""link"": ""https://mysite.com/updates"", + ""items"": [ + { + ""version"": ""1.3"", + ""url"": ""https://mysite.com/update.deb"", + ""signature"": ""seru3112"", + ""publication_date"": ""2023-12-09T03:12:12Z"", + ""is_critical"": true, + ""size"": 0, + ""os"": ""linux"", + ""channel"": """", + ""type"": ""application/octet-stream"" + }, + { + ""title"": ""Super Beta"", + ""version"": ""0.9-beta1"", + ""short_version"": ""0.9"", + ""release_notes_link"": ""https://mysite.com/update09beta.md"", + ""release_notes_signature"": ""srjlwj"", + ""url"": ""https://mysite.com/update09beta.exe"", + ""signature"": ""seru311b2"", + ""publication_date"": ""1999-12-09T02:11:11Z"", + ""is_critical"": false, + ""size"": 0, + ""os"": ""windows"", + ""channel"": ""beta"", + ""type"": ""application/octet-stream"" + } + ] +}"; + } + IAppCastGenerator maker = GetGeneratorForType(appCastMakerType); + AppCast deserialized = null; + if (deserializeFuncName == nameof(IAppCastGenerator.DeserializeAppCast)) + { + deserialized = maker.DeserializeAppCast(appCastToDeserialize); + } + else if (deserializeFuncName == nameof(IAppCastGenerator.DeserializeAppCastAsync)) + { + deserialized = await maker.DeserializeAppCastAsync(appCastToDeserialize); + } + else if (deserializeFuncName == nameof(IAppCastGenerator.DeserializeAppCastFromFile)) + { + var path = System.IO.Path.GetTempFileName(); + File.WriteAllText(path, appCastToDeserialize); + deserialized = maker.DeserializeAppCastFromFile(path); + File.Delete(path); + } + else if (deserializeFuncName == nameof(IAppCastGenerator.DeserializeAppCastFromFileAsync)) + { + var path = System.IO.Path.GetTempFileName(); + File.WriteAllText(path, appCastToDeserialize); + deserialized = await maker.DeserializeAppCastFromFileAsync(path); + File.Delete(path); + } + // now that we have the app cast, test the data in it + Assert.Equal("My App", deserialized.Title); + Assert.Equal("My App Updates", deserialized.Description); + Assert.Equal("https://mysite.com/updates", deserialized.Link); + Assert.Equal("en_US", deserialized.Language); + Assert.Equal(2, deserialized.Items.Count); + + Assert.Equal("1.3", deserialized.Items[0].Version); + Assert.Equal("https://mysite.com/update.deb", deserialized.Items[0].DownloadLink); + Assert.Equal("seru3112", deserialized.Items[0].DownloadSignature); + Assert.True(deserialized.Items[0].IsCriticalUpdate); + Assert.Equal("linux", deserialized.Items[0].OperatingSystem); + Assert.Equal(2023, deserialized.Items[0].PublicationDate.ToUniversalTime().Year); + Assert.Equal(12, deserialized.Items[0].PublicationDate.ToUniversalTime().Month); + Assert.Equal(9, deserialized.Items[0].PublicationDate.ToUniversalTime().Day); + Assert.Equal(3, deserialized.Items[0].PublicationDate.ToUniversalTime().Hour); + Assert.Equal(12, deserialized.Items[0].PublicationDate.ToUniversalTime().Minute); + Assert.Equal(12, deserialized.Items[0].PublicationDate.ToUniversalTime().Second); + Assert.True(string.IsNullOrWhiteSpace(deserialized.Items[0].Channel)); + + Assert.Equal("Super Beta", deserialized.Items[1].Title); + Assert.Equal("0.9-beta1", deserialized.Items[1].Version); + Assert.Equal("0.9", deserialized.Items[1].ShortVersion); + Assert.Equal("https://mysite.com/update09beta.exe", deserialized.Items[1].DownloadLink); + Assert.Equal("seru311b2", deserialized.Items[1].DownloadSignature); + Assert.Equal("https://mysite.com/update09beta.md", deserialized.Items[1].ReleaseNotesLink); + Assert.Equal("srjlwj", deserialized.Items[1].ReleaseNotesSignature); + Assert.Equal(1999, deserialized.Items[1].PublicationDate.ToUniversalTime().Year); + Assert.Equal(12, deserialized.Items[1].PublicationDate.ToUniversalTime().Month); + Assert.Equal(9, deserialized.Items[1].PublicationDate.ToUniversalTime().Day); + Assert.Equal(2, deserialized.Items[1].PublicationDate.ToUniversalTime().Hour); + Assert.Equal(11, deserialized.Items[1].PublicationDate.ToUniversalTime().Minute); + Assert.Equal(11, deserialized.Items[1].PublicationDate.ToUniversalTime().Second); + Assert.Equal("beta", deserialized.Items[1].Channel); + Assert.False(deserialized.Items[1].IsCriticalUpdate); + Assert.Equal(AppCastItem.DefaultOperatingSystem, deserialized.Items[1].OperatingSystem); + } + } +} \ No newline at end of file diff --git a/src/NetSparkle.Tests/AssemblyAccessorTests.cs b/src/NetSparkle.Tests/AssemblyAccessorTests.cs index 337d3c0e..caba1910 100644 --- a/src/NetSparkle.Tests/AssemblyAccessorTests.cs +++ b/src/NetSparkle.Tests/AssemblyAccessorTests.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Reflection; @@ -224,6 +225,7 @@ public void TestReflectionAccessor() { Assert.True(false, "Failed to build DLL"); } + #pragma warning disable CS0618 // don't worry about deprecation notice var accessor = new AssemblyReflectionAccessor(_fixture.DllPath); Assert.Equal(AssemblyAccessorTestsFixture.Company, accessor.AssemblyCompany); Assert.Equal(AssemblyAccessorTestsFixture.Copyright, accessor.AssemblyCopyright); @@ -231,6 +233,7 @@ public void TestReflectionAccessor() Assert.Equal(AssemblyAccessorTestsFixture.Title, accessor.AssemblyTitle); Assert.Equal(AssemblyAccessorTestsFixture.Product, accessor.AssemblyProduct); Assert.Equal(AssemblyAccessorTestsFixture.AssemblyVersion, accessor.AssemblyVersion); + #pragma warning restore CS0618 } #endif } diff --git a/src/NetSparkle.Tests/ChannelAppCastFilterTests.cs b/src/NetSparkle.Tests/ChannelAppCastFilterTests.cs new file mode 100644 index 00000000..2c49edc6 --- /dev/null +++ b/src/NetSparkle.Tests/ChannelAppCastFilterTests.cs @@ -0,0 +1,266 @@ +using NetSparkleUpdater; +using NetSparkleUpdater.AppCastHandlers; +using NetSparkleUpdater.Enums; +using NetSparkleUpdater.SignatureVerifiers; +using System; +using System.Linq; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using Xunit; + +namespace NetSparkleUnitTests +{ + public class ChannelAppCastFilterTests + { + [Fact] + public void CanFilterItemsByVersionChannel() + { + var logWriter = new LogWriter(LogWriterOutputMode.Console); + var filter = new ChannelAppCastFilter(logWriter); + var currentVersion = new SemVerLike("1.0.0", ""); + var items = new List() + { + new AppCastItem() { Version = "2.0-beta1" }, + new AppCastItem() { Version = "1.1-beta1" }, + new AppCastItem() { Version = "1.1-alpha1" }, + new AppCastItem() { Version = "1.0.0" }, + }; + filter.ChannelSearchNames = new List() { "alpha" }; + var filtered = filter.GetFilteredAppCastItems(currentVersion, items).ToList(); + Assert.Single(filtered); + Assert.Equal("1.1-alpha1", filtered[0].Version); + filter.ChannelSearchNames = new List() { "beta" }; + filtered = filter.GetFilteredAppCastItems(currentVersion, items).ToList(); + Assert.Equal(2, filtered.Count); + Assert.Equal("2.0-beta1", filtered[0].Version); + } + + [Fact] + public void CanFilterItemsByVersionChannelStrangeCasing() + { + var logWriter = new LogWriter(LogWriterOutputMode.Console); + var filter = new ChannelAppCastFilter(logWriter); + var currentVersion = new SemVerLike("1.0.0", ""); + var items = new List() + { + new AppCastItem() { Version = "2.0-BEta1" }, + new AppCastItem() { Version = "1.1-beta1" }, + new AppCastItem() { Version = "1.1-AlPha1" }, + new AppCastItem() { Version = "1.0.0" }, + }; + filter.ChannelSearchNames = new List() { "alpha" }; + var filtered = filter.GetFilteredAppCastItems(currentVersion, items).ToList(); + Assert.Single(filtered); + Assert.Equal("1.1-AlPha1", filtered[0].Version); + filter.ChannelSearchNames = new List() { "beta" }; + filtered = filter.GetFilteredAppCastItems(currentVersion, items).ToList(); + Assert.Equal(2, filtered.Count); + Assert.Equal("2.0-BEta1", filtered[0].Version); + Assert.Equal("1.1-beta1", filtered[1].Version); + } + + [Fact] + public void CanFilterItemsByAppCastItemChannel() + { + var logWriter = new LogWriter(LogWriterOutputMode.Console); + var filter = new ChannelAppCastFilter(logWriter); + var currentVersion = new SemVerLike("1.0.0", ""); + var items = new List() + { + new AppCastItem() { Version = "2.0", Channel = "gamma" }, + new AppCastItem() { Version = "1.2-preview", Channel = "beta" }, + new AppCastItem() { Version = "1.1-beta1" }, + new AppCastItem() { Version = "1.0.0" }, + }; + filter.ChannelSearchNames = new List() { "gamma" }; + var filtered = filter.GetFilteredAppCastItems(currentVersion, items).ToList(); + Assert.Single(filtered); + Assert.Equal("2.0", filtered[0].Version); + filter.ChannelSearchNames = new List() { "beta" }; + filtered = filter.GetFilteredAppCastItems(currentVersion, items).ToList(); + Assert.Equal(2, filtered.Count); + Assert.Equal("1.2-preview", filtered[0].Version); + Assert.Equal("beta", filtered[0].Channel); + Assert.Equal("1.1-beta1", filtered[1].Version); + Assert.Null(filtered[1].Channel); + filter.ChannelSearchNames = new List() { "beta", "preview" }; + filtered = filter.GetFilteredAppCastItems(currentVersion, items).ToList(); + Assert.Equal(2, filtered.Count); + Assert.Equal("1.2-preview", filtered[0].Version); + Assert.Equal("beta", filtered[0].Channel); + Assert.Equal("1.1-beta1", filtered[1].Version); + Assert.Null(filtered[1].Channel); + } + + [Fact] + public void CanUpgradeToNewStableVersionWhileOnBeta() + { + var logWriter = new LogWriter(LogWriterOutputMode.Console); + var filter = new ChannelAppCastFilter(logWriter); + var currentVersion = new SemVerLike("1.1-alpha1", ""); + var items = new List() + { + new AppCastItem() { Version = "2.0" }, + new AppCastItem() { Version = "1.1-beta1" }, + new AppCastItem() { Version = "1.1-alpha1" }, + new AppCastItem() { Version = "1.0.0" }, + }; + filter.ChannelSearchNames = new List() { "beta" }; + var filtered = filter.GetFilteredAppCastItems(currentVersion, items).ToList(); + Assert.Single(filtered); + Assert.Equal("2.0", filtered[0].Version); + } + + [Fact] + public void CanFilterByMultipleChannels() + { + var logWriter = new LogWriter(LogWriterOutputMode.Console); + var filter = new ChannelAppCastFilter(logWriter); + var currentVersion = new SemVerLike("1.0.0", ""); + var items = new List() + { + new AppCastItem() { Version = "1.1-beta1" }, + new AppCastItem() { Version = "1.1-alpha1" }, + new AppCastItem() { Version = "1.0.0" }, + }; + filter.ChannelSearchNames = new List() { "alpha", "beta" }; + var filtered = filter.GetFilteredAppCastItems(currentVersion, items).ToList(); + Assert.Equal(2, filtered.Count); + Assert.Equal("1.1-beta1", filtered[0].Version); + Assert.Equal("1.1-alpha1", filtered[1].Version); + } + + [Fact] + public void FirstItemIsLatestInSeriesOfChannel() + { + var logWriter = new LogWriter(LogWriterOutputMode.Console); + var filter = new ChannelAppCastFilter(logWriter); + var currentVersion = new SemVerLike("1.0.0", ""); + var items = new List() + { + new AppCastItem() { Version = "1.2-beta1" }, + new AppCastItem() { Version = "1.1-beta4" }, + new AppCastItem() { Version = "1.0.0" }, + }; + filter.ChannelSearchNames = new List() { "beta" }; + var filtered = filter.GetFilteredAppCastItems(currentVersion, items).ToList(); + Assert.Equal(2, filtered.Count); + Assert.Equal("1.2-beta1", filtered[0].Version); + Assert.Equal("1.1-beta4", filtered[1].Version); + } + + [Fact] + public void NonStableIgnoredIfNoChannels() + { + var logWriter = new LogWriter(LogWriterOutputMode.Console); + var filter = new ChannelAppCastFilter(logWriter); + var currentVersion = new SemVerLike("1.0.0", ""); + var items = new List() + { + new AppCastItem() { Version = "2.0-beta1" }, + new AppCastItem() { Version = "1.9" }, + new AppCastItem() { Version = "1.3-beta1" }, + new AppCastItem() { Version = "1.2-beta1" }, + new AppCastItem() { Version = "1.2" }, + new AppCastItem() { Version = "1.1-beta4" }, + new AppCastItem() { Version = "1.0.0" }, + }; + filter.ChannelSearchNames = new List() { }; + var filtered = filter.GetFilteredAppCastItems(currentVersion, items).ToList(); + Assert.Equal(2, filtered.Count); + Assert.Equal("1.9", filtered[0].Version); + Assert.Equal("1.2", filtered[1].Version); + } + + [Fact] + public void GetsLatestIfBothStableAndChannelAvailable() + { + var logWriter = new LogWriter(LogWriterOutputMode.Console); + var filter = new ChannelAppCastFilter(logWriter); + var currentVersion = new SemVerLike("1.0.0", ""); + var items = new List() + { + new AppCastItem() { Version = "1.1" }, + new AppCastItem() { Version = "1.1-rc1" }, + new AppCastItem() { Version = "1.0.0" }, + }; + filter.ChannelSearchNames = new List() { "rc" }; + var filtered = filter.GetFilteredAppCastItems(currentVersion, items).ToList(); + Assert.Equal(2, filtered.Count); + Assert.Equal("1.1", filtered[0].Version); + Assert.Equal("1.1-rc1", filtered[1].Version); + + items = new List() + { + new AppCastItem() { Version = "1.2-beta1" }, + new AppCastItem() { Version = "1.1" }, + new AppCastItem() { Version = "1.1-rc1" }, + new AppCastItem() { Version = "1.0.0" }, + }; + filter.ChannelSearchNames = new List() { "rc", "beta" }; + filtered = filter.GetFilteredAppCastItems(currentVersion, items).ToList(); + Assert.Equal(3, filtered.Count); + Assert.Equal("1.2-beta1", filtered[0].Version); + Assert.Equal("1.1", filtered[1].Version); + Assert.Equal("1.1-rc1", filtered[2].Version); + } + + [Fact] + public void TestRemoveOlderItems() + { + var logWriter = new LogWriter(LogWriterOutputMode.Console); + var filter = new ChannelAppCastFilter(logWriter); + filter.RemoveOlderItems = false; + var currentVersion = new SemVerLike("1.0.0", ""); + var items = new List() + { + new AppCastItem() { Version = "2.0-beta1" }, + new AppCastItem() { Version = "1.1-beta4" }, + new AppCastItem() { Version = "1.0.0" }, + new AppCastItem() { Version = "0.9.0" }, + new AppCastItem() { Version = "0.4.0" }, + }; + filter.ChannelSearchNames = new List() { "beta" }; + var filtered = filter.GetFilteredAppCastItems(currentVersion, items).ToList(); + Assert.Equal(5, filtered.Count); + Assert.Equal("2.0-beta1", filtered[0].Version); + Assert.Equal("1.1-beta4", filtered[1].Version); + Assert.Equal("1.0.0", filtered[2].Version); + Assert.Equal("0.9.0", filtered[3].Version); + Assert.Equal("0.4.0", filtered[4].Version); + // now remove older items + filter.RemoveOlderItems = true; + filtered = filter.GetFilteredAppCastItems(currentVersion, items).ToList(); + Assert.Equal(2, filtered.Count); + Assert.Equal("2.0-beta1", filtered[0].Version); + Assert.Equal("1.1-beta4", filtered[1].Version); + } + + [Fact] + public void TestKeepItemsWithSuffix() + { + var logWriter = new LogWriter(LogWriterOutputMode.Console); + var filter = new ChannelAppCastFilter(logWriter); + filter.KeepItemsWithNoChannelInfo = true; + var currentVersion = new SemVerLike("1.0.0", ""); + var items = new List() + { + new AppCastItem() { Version = "2.0", Channel = "" }, + new AppCastItem() { Version = "2.0", Channel = "gamma" }, // will be discarded! + new AppCastItem() { Version = "2.0-beta1" }, + new AppCastItem() { Version = "1.0.0" }, + }; + filter.ChannelSearchNames = new List() { "beta" }; + var filtered = filter.GetFilteredAppCastItems(currentVersion, items).ToList(); + Assert.Equal(2, filtered.Count); + Assert.Equal("2.0", filtered[0].Version); + Assert.Equal("2.0-beta1", filtered[1].Version); + // now don't keep items with no channel names + filter.KeepItemsWithNoChannelInfo = false; + filtered = filter.GetFilteredAppCastItems(currentVersion, items).ToList(); + Assert.Single(filtered); + Assert.Equal("2.0-beta1", filtered[0].Version); + } + } +} diff --git a/src/NetSparkle.Tests/EmptyTestDataConfguration.cs b/src/NetSparkle.Tests/EmptyTestDataConfiguration.cs similarity index 57% rename from src/NetSparkle.Tests/EmptyTestDataConfguration.cs rename to src/NetSparkle.Tests/EmptyTestDataConfiguration.cs index 00905f83..cd25664f 100644 --- a/src/NetSparkle.Tests/EmptyTestDataConfguration.cs +++ b/src/NetSparkle.Tests/EmptyTestDataConfiguration.cs @@ -3,9 +3,9 @@ namespace NetSparkleUnitTests { - public class EmptyTestDataConfguration : Configuration + public class EmptyTestDataConfiguration : Configuration { - public EmptyTestDataConfguration(IAssemblyAccessor accessor) : base(accessor) + public EmptyTestDataConfiguration(IAssemblyAccessor accessor) : base(accessor) { } diff --git a/src/NetSparkle.Tests/SemVerLikeTests.cs b/src/NetSparkle.Tests/SemVerLikeTests.cs index 5ad29843..733227b3 100644 --- a/src/NetSparkle.Tests/SemVerLikeTests.cs +++ b/src/NetSparkle.Tests/SemVerLikeTests.cs @@ -35,6 +35,9 @@ public void ParseTests(string input, string version, string allSuffixes) [InlineData("1.0-alpha.2", "1.0-alpha.1", 1)] [InlineData("1.0-alpha.1", "1.0-beta.2", -1)] [InlineData("1.0-rc.1", "1.0-beta.2", 1)] + [InlineData("1", "1", 0)] + [InlineData("1", "2", -1)] + [InlineData("2", "1", 1)] public void CompareTest(string left, string right, int result) { Assert.Equal( diff --git a/src/NetSparkle.Tests/SparkleUpdaterFixture.cs b/src/NetSparkle.Tests/SparkleUpdaterFixture.cs index c01ad6fe..ce3ed654 100644 --- a/src/NetSparkle.Tests/SparkleUpdaterFixture.cs +++ b/src/NetSparkle.Tests/SparkleUpdaterFixture.cs @@ -17,8 +17,9 @@ public class SparkleUpdaterFixture : IDisposable public SparkleUpdater CreateUpdater(string xmlData, string installedVersion, IAppCastFilter filter = null) { SparkleUpdater updater = new SparkleUpdater("test-url", new AlwaysSucceedSignatureChecker()); + (updater.LogWriter as LogWriter).OutputMode = NetSparkleUpdater.Enums.LogWriterOutputMode.Console; updater.AppCastDataDownloader = new StringCastDataDownloader(xmlData); - updater.Configuration = new EmptyTestDataConfguration(new FakeTestDataAssemblyAccessor() + updater.Configuration = new EmptyTestDataConfiguration(new FakeTestDataAssemblyAccessor() { AssemblyCompany = "NetSparkle Test App", AssemblyCopyright = "@ (C) Thinking", @@ -26,7 +27,7 @@ public SparkleUpdater CreateUpdater(string xmlData, string installedVersion, IAp AssemblyVersion = installedVersion }); - XMLAppCast cast = updater.AppCastHandler as XMLAppCast; + var cast = updater.AppCastHelper as AppCastHelper; if (cast != null) { if (filter != null) diff --git a/src/NetSparkle.Tests/StringCastDataDownloader.cs b/src/NetSparkle.Tests/StringCastDataDownloader.cs index 12be7423..23ac91bb 100644 --- a/src/NetSparkle.Tests/StringCastDataDownloader.cs +++ b/src/NetSparkle.Tests/StringCastDataDownloader.cs @@ -1,4 +1,6 @@ +using System; using System.Text; +using System.Threading.Tasks; using NetSparkleUpdater.Interfaces; namespace NetSparkleUnitTests @@ -6,6 +8,7 @@ namespace NetSparkleUnitTests public class StringCastDataDownloader : IAppCastDataDownloader { private string _data = null; + public StringCastDataDownloader(string data) { _data = data; @@ -16,9 +19,14 @@ public string DownloadAndGetAppCastData(string url) return _data; } + public Task DownloadAndGetAppCastDataAsync(string url) + { + return Task.FromResult(_data); + } + public Encoding GetAppCastEncoding() { - return Encoding.UTF8; + return Encoding.UTF8; } } } \ No newline at end of file diff --git a/src/NetSparkle.Tools.AppCastGenerator/AppCastMaker.cs b/src/NetSparkle.Tools.AppCastGenerator/AppCastMaker.cs index d791b6a5..7bd9ff7e 100644 --- a/src/NetSparkle.Tools.AppCastGenerator/AppCastMaker.cs +++ b/src/NetSparkle.Tools.AppCastGenerator/AppCastMaker.cs @@ -43,7 +43,10 @@ public AppCastMaker(SignatureManager signatureManager, Options options) /// /// Loads an existing app cast file and loads its AppCastItem items and any product name that is in the file. /// Should not return duplicate versions. - /// The items list should always be non-null and sorted by AppCastItem.Version descending. + /// Sorting should be done after reading in the file is done and items are parsed, as duplicates + /// will be overwritten in the order they are read. + /// The items list should always be non-null and sorted by AppCastItem.Version descending when the function + /// is complete. /// /// File name/path for app cast file to read /// If true and an item is loaded with a version that has already been found, @@ -317,7 +320,7 @@ public IEnumerable FindBinaries(string binaryDirectory, IEnumerable + /// True if output should be human readable (indents, newslines). + /// False by default. + /// + public bool HumanReadableOutput { get; set; } + + /// + public override string GetAppCastExtension() + { + return "json"; + } + + /// + public override (List, string?) GetItemsAndProductNameFromExistingAppCast(string appCastFileName, bool overwriteOldItemsInAppcast) + { + Console.WriteLine("Parsing existing app cast at {0}...", appCastFileName); + var items = new List(); + string? productName = null; + try + { + if (!File.Exists(appCastFileName)) + { + Console.WriteLine("App cast does not exist at {0}, so creating it anew...", appCastFileName, Color.Red); + } + else + { + var logWriter = new LogWriter(LogWriterOutputMode.Console); + var generator = new JsonAppCastGenerator(logWriter); + var appCast = generator.DeserializeAppCastFromFileWithoutSorting(appCastFileName); + Console.WriteLine("Deserializing app cast from JSON file...{0} = title", appCast.Title); + productName = appCast.Title; + + foreach (var currentItem in appCast.Items) + { + Console.WriteLine("Found an item in the app cast: version {0} (short version = {1}; title = {3}) -- os = {2}", + currentItem.Version, currentItem.ShortVersion, currentItem.OperatingSystem, currentItem.Title); + var itemFound = items.Where(x => x.Version != null && x.Version == currentItem.Version?.Trim()).FirstOrDefault(); + if (itemFound == null) + { + items.Add(currentItem); + } + else + { + Console.WriteLine($"Duplicate item with version {currentItem.Version} found in app cast. This is likely an invalid state" + + $" and you should fix your app cast so that it does not have duplicate items.", Color.Yellow); + if (overwriteOldItemsInAppcast) + { + items.Remove(itemFound); // remove old item. + items.Add(currentItem); + Console.WriteLine("Overwriting old item (title: {0}, version: {1}) with newly found one with title {2} and version {3}...", itemFound.Title, itemFound.Version, currentItem.Title, currentItem.Version, Color.Yellow); + } + } + } + } + } + catch (Exception e) + { + Console.WriteLine($"Error reading previous app cast: {e.Message}. Not using it for any items...JSON was {appCastFileName}", Color.Red); + return (new List(), null); + } + items.Sort((a, b) => { + if (a.Version == null && b.Version == null) + { + return 0; + } + if (a.Version != null && b.Version == null) + { + return -1; + } + if (a.Version == null && b.Version != null) + { + return 1; + } + return b.Version?.CompareTo(a.Version) ?? 0; + }); + return (items, productName); + } + + /// + public override void SerializeItemsToFile(List items, string applicationTitle, string path) + { + var jsonGenerator = new JsonAppCastGenerator() + { + HumanReadableOutput = HumanReadableOutput + }; + Console.WriteLine("Writing json app cast to {0}", path); + var appCast = new AppCast() + { + Items = items, + Title = applicationTitle, + Link = _opts.AppCastLink, + Description = _opts.AppCastDescription, + Language = "en" + }; + jsonGenerator.SerializeAppCastToFile(appCast, path); + } + } +} diff --git a/src/NetSparkle.Tools.AppCastGenerator/NetSparkle.Tools.AppCastGenerator.csproj b/src/NetSparkle.Tools.AppCastGenerator/NetSparkle.Tools.AppCastGenerator.csproj index df7c1f0a..71c1f00e 100644 --- a/src/NetSparkle.Tools.AppCastGenerator/NetSparkle.Tools.AppCastGenerator.csproj +++ b/src/NetSparkle.Tools.AppCastGenerator/NetSparkle.Tools.AppCastGenerator.csproj @@ -73,7 +73,6 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - diff --git a/src/NetSparkle.Tools.AppCastGenerator/Options.cs b/src/NetSparkle.Tools.AppCastGenerator/Options.cs index 75f062c0..14356ed3 100644 --- a/src/NetSparkle.Tools.AppCastGenerator/Options.cs +++ b/src/NetSparkle.Tools.AppCastGenerator/Options.cs @@ -1,7 +1,9 @@ using CommandLine; using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Linq; +using System.Runtime.ExceptionServices; using System.Text; using System.Threading.Tasks; @@ -62,10 +64,15 @@ public class Options public string? PathToKeyFiles { get; set; } [Option("signature-file-extension", SetName = "local", Required = false, - HelpText = "Suffix (without '.') to append to appcast.xml for signature file", + HelpText = "Suffix (without '.') to append to appcast.xml for signature file. If you change this, make sure to also set AppCastHelper.SignatureFileExtension in the core NetSparkleUpdater lib", Default = "signature")] public string? SignatureFileExtension { get; set; } + [Option("use-ed25519-signature-attribute", SetName = "local", Required = false, + HelpText = "If true and doing XML output, the output signature attribute in the XML will be 'edSignature' rather than 'signature' to match the original Sparkle library.", + Default = "signature")] + public bool UseEd25519SignatureAttributeForXml { get; set; } + [Option("public-key-override", SetName = "local", Required = false, HelpText = "Public key override (ignores whatever is in the public key file) for signing binaries. This" + " overrides ALL other public keys set when verifying binaries, INCLUDING public key set via environment variables! " + "If not set, uses --key-path (if set) or the default SignatureManager location. Not used in --generate-keys or --export.", Default = "")] @@ -98,6 +105,12 @@ public class Options [Option("critical-versions", SetName = "local", Required = false, HelpText = "Comma-separated list of versions to mark as critical in the app cast. Must match version text exactly. E.g., \"1.0.2,1.2.3.1\"", Default = "")] public string? CriticalVersions { get; set; } + [Option("channel", SetName = "local", Required = false, HelpText = "Name of release channel for any items added into the app cast. Should be a single channel; does not support things like \"beta,gamma\". Do not set if you want to use your release channel - if you set this to \"release\" or \"stable\", those will be treated as special channels and not as the stable channel. (Unless you want all your items to be in a specific channel, of course.)", Default = "")] + public string? Channel { get; set; } + + [Option("output-type", SetName = "local", Required = false, HelpText = "Output type for app cast file ('xml' or 'json' without the ' marks); defaults to 'xml'", Default = "xml")] + public string? OutputType { get; set; } + #region Key Generation [Option("generate-keys", SetName = "keys", Required = false, HelpText = "Generate keys", Default = false)] diff --git a/src/NetSparkle.Tools.AppCastGenerator/Program.cs b/src/NetSparkle.Tools.AppCastGenerator/Program.cs index 7c8b1448..11af9b14 100644 --- a/src/NetSparkle.Tools.AppCastGenerator/Program.cs +++ b/src/NetSparkle.Tools.AppCastGenerator/Program.cs @@ -111,7 +111,9 @@ static void Run(Options opts) } // actually create the app cast - var generator = new XMLAppCastMaker(signatureManager, opts); + AppCastMaker generator = opts.OutputType?.ToLower() != "json" + ? new XMLAppCastMaker(signatureManager, opts) + : new JsonAppCastMaker(signatureManager, opts); var appCastFileName = generator.GetPathToAppCastOutput(opts.OutputDirectory ?? ".", opts.SourceBinaryDirectory ?? "."); var outputDirName = Path.GetDirectoryName(appCastFileName); if (outputDirName == null || string.IsNullOrWhiteSpace(outputDirName)) diff --git a/src/NetSparkle.Tools.AppCastGenerator/SignatureManager.cs b/src/NetSparkle.Tools.AppCastGenerator/SignatureManager.cs index 29de7ec5..61cdbf2f 100644 --- a/src/NetSparkle.Tools.AppCastGenerator/SignatureManager.cs +++ b/src/NetSparkle.Tools.AppCastGenerator/SignatureManager.cs @@ -20,12 +20,12 @@ public class SignatureManager public SignatureManager() { - SetStorageDirectory(GetDefaultStorageDirectory()); _privateKeyOverride = ""; _publicKeyOverride = ""; _storagePath = ""; _privateKeyFilePath = ""; _publicKeyFilePath = ""; + SetStorageDirectory(GetDefaultStorageDirectory()); } public static string GetDefaultStorageDirectory() diff --git a/src/NetSparkle.Tools.AppCastGenerator/XMLAppCastMaker.cs b/src/NetSparkle.Tools.AppCastGenerator/XMLAppCastMaker.cs index eca5b33f..a479842f 100644 --- a/src/NetSparkle.Tools.AppCastGenerator/XMLAppCastMaker.cs +++ b/src/NetSparkle.Tools.AppCastGenerator/XMLAppCastMaker.cs @@ -2,9 +2,11 @@ using NetSparkleUpdater.Enums; using System; using System.Collections.Generic; +using System.ComponentModel; using System.Drawing; using System.IO; using System.Linq; +using System.Reflection; using System.Text; using System.Threading.Tasks; using System.Xml; @@ -16,6 +18,11 @@ namespace NetSparkleUpdater.AppCastGenerator { public class XMLAppCastMaker : AppCastMaker { + /// + /// Sparkle XML namespace + /// + public static readonly XNamespace SparkleNamespace = "http://www.andymatuschak.org/xml-namespaces/sparkle"; + public XMLAppCastMaker(SignatureManager signatureManager, Options options) : base(signatureManager, options) { HumanReadableOutput = options.HumanReadableOutput; @@ -58,11 +65,12 @@ public override (List, string?) GetItemsAndProductNameFromExistingA var docDescendants = doc.Descendants("item"); var logWriter = new LogWriter(LogWriterOutputMode.Console); + var xmlGenerator = new XMLAppCastGenerator(logWriter); foreach (var item in docDescendants) { - var currentItem = AppCastItem.Parse("", "", "/", item, logWriter); + var currentItem = xmlGenerator.ReadAppCastItem(item); Console.WriteLine("Found an item in the app cast: version {0} ({1}) -- os = {2}", - currentItem.Version, currentItem.ShortVersion, currentItem.OperatingSystemString); + currentItem.Version, currentItem.ShortVersion, currentItem.OperatingSystem); var itemFound = items.Where(x => x.Version != null && x.Version == currentItem.Version?.Trim()).FirstOrDefault(); if (itemFound == null) { @@ -108,17 +116,23 @@ public override (List, string?) GetItemsAndProductNameFromExistingA /// public override void SerializeItemsToFile(List items, string applicationTitle, string path) { - var appcastXmlDocument = XMLAppCast.GenerateAppCastXml(items, applicationTitle, _opts.AppCastLink ?? "", _opts.AppCastDescription ?? ""); - Console.WriteLine("Writing app cast to {0}", path); - using (var xmlWriter = XmlWriter.Create(path, new XmlWriterSettings - { - NewLineChars = Environment.NewLine, - Encoding = new UTF8Encoding(false), - Indent = HumanReadableOutput - })) + var xmlGenerator = new XMLAppCastGenerator() { - appcastXmlDocument.Save(xmlWriter); - } + HumanReadableOutput = HumanReadableOutput, + OutputSignatureAttribute = _opts.UseEd25519SignatureAttributeForXml + ? XMLAppCastGenerator.Ed25519SignatureAttribute + : XMLAppCastGenerator.SignatureAttribute + }; + Console.WriteLine("Writing xml app cast to {0}", path); + var appCast = new AppCast() + { + Items = items, + Title = applicationTitle, + Link = _opts.AppCastLink, + Description = _opts.AppCastDescription, + Language = "en" + }; + xmlGenerator.SerializeAppCastToFile(appCast, path); } } } diff --git a/src/NetSparkle.UI.Avalonia/UIFactory.cs b/src/NetSparkle.UI.Avalonia/UIFactory.cs index 70741bb6..2edf5458 100644 --- a/src/NetSparkle.UI.Avalonia/UIFactory.cs +++ b/src/NetSparkle.UI.Avalonia/UIFactory.cs @@ -142,7 +142,7 @@ public virtual IUpdateAvailable CreateUpdateAvailableWindow(SparkleUpdater spark { viewModel.ReleaseNotesGrabber = ReleaseNotesGrabberOverride; } - viewModel.Initialize(sparkle, updates, isUpdateAlreadyDownloaded, ReleaseNotesHTMLTemplate ?? "", AdditionalReleaseNotesHeaderHTML ?? "", ReleaseNotesDateTimeFormat); + viewModel.Initialize(sparkle, updates, isUpdateAlreadyDownloaded, ReleaseNotesHTMLTemplate ?? "", AdditionalReleaseNotesHeaderHTML ?? "", ReleaseNotesDateTimeFormat, sparkle.AppCastCache?.Title ?? "the application", sparkle.Configuration.AssemblyAccessor.AssemblyVersion); ProcessWindowAfterInit?.Invoke(window, this); return window; } @@ -153,7 +153,8 @@ public virtual IDownloadProgress CreateProgressWindow(SparkleUpdater sparkle, Ap var viewModel = new DownloadProgressWindowViewModel() { ItemToDownload = item, - SoftwareWillRelaunchAfterUpdateInstalled = sparkle.RelaunchAfterUpdate + SoftwareWillRelaunchAfterUpdateInstalled = sparkle.RelaunchAfterUpdate, + DownloadTitle = sparkle.AppCastCache?.Title ?? "application" }; var window = new DownloadProgressWindow(viewModel, _iconBitmap) { diff --git a/src/NetSparkle.UI.Avalonia/ViewModels/DownloadProgressWindowViewModel.cs b/src/NetSparkle.UI.Avalonia/ViewModels/DownloadProgressWindowViewModel.cs index aefe1105..e61050a1 100644 --- a/src/NetSparkle.UI.Avalonia/ViewModels/DownloadProgressWindowViewModel.cs +++ b/src/NetSparkle.UI.Avalonia/ViewModels/DownloadProgressWindowViewModel.cs @@ -23,7 +23,6 @@ public class DownloadProgressWindowViewModel : ChangeNotifier private string _errorMessageText; private bool _isErrorMessageVisible; - private string _downloadingTitle; private double _downloadProgressValue; private string _userReadableDownloadProgress; @@ -40,17 +39,19 @@ public DownloadProgressWindowViewModel() DidDownloadAnything = false; _errorMessageText = ""; IsErrorMessageVisible = false; - _downloadingTitle = ""; _userReadableDownloadProgress = ""; _actionButtonTitle = "Install"; _downloadProgressValue = 0.0; IsActionButtonVisible = false; } + /// + /// Title for software download. + /// + public string? DownloadTitle { get; set; } = ""; + /// /// that is going to be downloaded. - /// Setting this property changes the - /// property /// public AppCastItem? ItemToDownload { @@ -59,15 +60,6 @@ public AppCastItem? ItemToDownload { _itemToDownload = value; NotifyPropertyChanged(); - - if (_itemToDownload != null) - { - DownloadingTitle = string.Format("Downloading {0}", _itemToDownload.AppName + " " + _itemToDownload.Version); - } - else - { - DownloadingTitle = "Downloading..."; - } } } @@ -117,8 +109,14 @@ public bool IsErrorMessageVisible /// public string DownloadingTitle { - get => _downloadingTitle; - set { _downloadingTitle = value; NotifyPropertyChanged(); } + get + { + if (_itemToDownload != null) + { + return string.Format("Downloading {0}", DownloadTitle + " " + _itemToDownload.Version); + } + return "Downloading..."; + } } /// diff --git a/src/NetSparkle.UI.Avalonia/ViewModels/UpdateAvailableWindowViewModel.cs b/src/NetSparkle.UI.Avalonia/ViewModels/UpdateAvailableWindowViewModel.cs index 6c2acda9..03028c05 100644 --- a/src/NetSparkle.UI.Avalonia/ViewModels/UpdateAvailableWindowViewModel.cs +++ b/src/NetSparkle.UI.Avalonia/ViewModels/UpdateAvailableWindowViewModel.cs @@ -229,8 +229,10 @@ private void SendResponse(UpdateAvailableResult response) /// The HTML string template to show in the release notes /// The HTML string to add into the head element of the HTML for the release notes /// Date format for release notes + /// Title for application + /// Currently installed version of application public void Initialize(SparkleUpdater sparkle, List items, bool isUpdateAlreadyDownloaded = false, - string releaseNotesHTMLTemplate = "", string additionalReleaseNotesHeaderHTML = "", string releaseNotesDateFormat = "D") + string releaseNotesHTMLTemplate = "", string additionalReleaseNotesHeaderHTML = "", string releaseNotesDateFormat = "D", string appNameTitle = "the application", string installedVersion = "") { _updates = items; if (ReleaseNotesGrabber == null) @@ -253,24 +255,11 @@ public void Initialize(SparkleUpdater sparkle, List items, bool isU AppCastItem? item = items.FirstOrDefault(); // TODO: string translations - TitleHeaderText = string.Format("A new version of {0} is available.", item?.AppName ?? "the application"); + TitleHeaderText = string.Format("A new version of {0} is available.", appNameTitle); var downloadInstallText = isUpdateAlreadyDownloaded ? "install" : "download"; if (item != null) { - var versionString = ""; - try - { - // Use try/catch since Version constructor can throw an exception and we don't want to - // die just because the user has a malformed version string - var versionObj = SemVerLike.Parse(item.AppVersionInstalled); - versionString = versionObj.ToString(); - } - catch - { - versionString = "?"; - } - InfoText = string.Format("{0} {3} is now available (you have {1}). Would you like to {2} it now?", item.AppName, versionString, - downloadInstallText, item.Version); + InfoText = string.Format("{0} {1} is now available (you have {2}). Would you like to {3} it now?", appNameTitle, item.Version, installedVersion, downloadInstallText); } else { diff --git a/src/NetSparkle.UI.WPF/UIFactory.cs b/src/NetSparkle.UI.WPF/UIFactory.cs index bc4c5c2b..d0413540 100644 --- a/src/NetSparkle.UI.WPF/UIFactory.cs +++ b/src/NetSparkle.UI.WPF/UIFactory.cs @@ -134,7 +134,7 @@ public virtual IUpdateAvailable CreateUpdateAvailableWindow(SparkleUpdater spark viewModel.ReleaseNotesGrabber = ReleaseNotesGrabberOverride; } viewModel.Initialize(sparkle, updates, isUpdateAlreadyDownloaded, ReleaseNotesHTMLTemplate ?? "", - AdditionalReleaseNotesHeaderHTML ?? "", ReleaseNotesDateTimeFormat); + AdditionalReleaseNotesHeaderHTML ?? "", ReleaseNotesDateTimeFormat, sparkle.AppCastCache?.Title ?? "the application", sparkle.Configuration.AssemblyAccessor.AssemblyVersion); ProcessWindowAfterInit?.Invoke(window, this); return window; } @@ -145,7 +145,8 @@ public virtual IDownloadProgress CreateProgressWindow(SparkleUpdater sparkle, Ap var viewModel = new DownloadProgressWindowViewModel() { ItemToDownload = item, - SoftwareWillRelaunchAfterUpdateInstalled = sparkle.RelaunchAfterUpdate + SoftwareWillRelaunchAfterUpdateInstalled = sparkle.RelaunchAfterUpdate, + DownloadTitle = sparkle.AppCastCache?.Title ?? "application" }; var window = new DownloadProgressWindow(viewModel) { diff --git a/src/NetSparkle.UI.WPF/ViewModels/DownloadProgressWindowViewModel.cs b/src/NetSparkle.UI.WPF/ViewModels/DownloadProgressWindowViewModel.cs index 02d9498a..18b69d4d 100644 --- a/src/NetSparkle.UI.WPF/ViewModels/DownloadProgressWindowViewModel.cs +++ b/src/NetSparkle.UI.WPF/ViewModels/DownloadProgressWindowViewModel.cs @@ -19,7 +19,6 @@ public class DownloadProgressWindowViewModel : ChangeNotifier private string _errorMessageText; private bool _isErrorMessageVisible; - private string _downloadingTitle; private double _downloadProgressValue; private string _userReadableDownloadProgress; @@ -36,7 +35,6 @@ public DownloadProgressWindowViewModel() DidDownloadAnything = false; _errorMessageText = ""; IsErrorMessageVisible = false; - _downloadingTitle = ""; _userReadableDownloadProgress = ""; _actionButtonTitle = "Install"; _downloadProgressValue = 0.0; @@ -45,8 +43,6 @@ public DownloadProgressWindowViewModel() /// /// that is going to be downloaded. - /// Setting this property changes the - /// property /// public AppCastItem? ItemToDownload { @@ -55,15 +51,6 @@ public AppCastItem? ItemToDownload { _itemToDownload = value; NotifyPropertyChanged(); - - if (_itemToDownload != null) - { - DownloadingTitle = string.Format("Downloading {0}", _itemToDownload.AppName + " " + _itemToDownload.Version); - } - else - { - DownloadingTitle = "Downloading..."; - } } } @@ -106,6 +93,11 @@ public bool IsErrorMessageVisible set { _isErrorMessageVisible = value; NotifyPropertyChanged(); } } + /// + /// Title for software download. + /// + public string? DownloadTitle { get; set; } = ""; + /// /// Title to show to the user (e.g. "Downloading..."). /// This property is automatically set when @@ -113,8 +105,14 @@ public bool IsErrorMessageVisible /// public string DownloadingTitle { - get => _downloadingTitle; - set { _downloadingTitle = value; NotifyPropertyChanged(); } + get + { + if (_itemToDownload != null) + { + return string.Format("Downloading {0}", DownloadTitle + " " + _itemToDownload.Version); + } + return "Downloading..."; + } } /// diff --git a/src/NetSparkle.UI.WPF/ViewModels/UpdateAvailableWindowViewModel.cs b/src/NetSparkle.UI.WPF/ViewModels/UpdateAvailableWindowViewModel.cs index d1826b07..5720fe12 100644 --- a/src/NetSparkle.UI.WPF/ViewModels/UpdateAvailableWindowViewModel.cs +++ b/src/NetSparkle.UI.WPF/ViewModels/UpdateAvailableWindowViewModel.cs @@ -195,8 +195,10 @@ private void SendResponse(UpdateAvailableResult response) /// The HTML string template to show for the release notes /// The HTML string to add into the head element of the HTML for the release notes /// Date format for release notes + /// Title for application + /// Currently installed version of application public void Initialize(SparkleUpdater sparkle, List items, bool isUpdateAlreadyDownloaded = false, - string releaseNotesHTMLTemplate = "", string additionalReleaseNotesHeaderHTML = "", string releaseNotesDateFormat = "D") + string releaseNotesHTMLTemplate = "", string additionalReleaseNotesHeaderHTML = "", string releaseNotesDateFormat = "D", string appNameTitle = "the application", string installedVersion = "") { _updates = items; if (ReleaseNotesGrabber == null) @@ -210,24 +212,11 @@ public void Initialize(SparkleUpdater sparkle, List items, bool isU AppCastItem? item = items.FirstOrDefault(); // TODO: string translations - TitleHeaderText = string.Format("A new version of {0} is available.", item?.AppName ?? "the application"); + TitleHeaderText = string.Format("A new version of {0} is available.", appNameTitle); var downloadInstallText = isUpdateAlreadyDownloaded ? "install" : "download"; if (item != null) { - var versionString = ""; - try - { - // Use try/catch since Version constructor can throw an exception and we don't want to - // die just because the user has a malformed version string - var versionObj = SemVerLike.Parse(item.AppVersionInstalled); - versionString = versionObj.ToString(); - } - catch - { - versionString = "?"; - } - InfoText = string.Format("{0} {3} is now available (you have {1}). Would you like to {2} it now?", item.AppName, versionString, downloadInstallText, - item.Version); + InfoText = string.Format("{0} {1} is now available (you have {2}). Would you like to {3} it now?", appNameTitle, item.Version, installedVersion, downloadInstallText); } else { diff --git a/src/NetSparkle.UI.WinForms.NetCore/DownloadProgressWindow.cs b/src/NetSparkle.UI.WinForms.NetCore/DownloadProgressWindow.cs index 68039899..80358bf8 100644 --- a/src/NetSparkle.UI.WinForms.NetCore/DownloadProgressWindow.cs +++ b/src/NetSparkle.UI.WinForms.NetCore/DownloadProgressWindow.cs @@ -37,7 +37,8 @@ public bool SoftwareWillRelaunchAfterUpdateInstalled /// /// The appcast item to use /// Your application Icon - public DownloadProgressWindow(AppCastItem item, Icon? applicationIcon) + /// Name of application that is being downloaded + public DownloadProgressWindow(AppCastItem item, Icon? applicationIcon, string downloadTitle = "") { InitializeComponent(); @@ -50,7 +51,7 @@ public DownloadProgressWindow(AppCastItem item, Icon? applicationIcon) // init ui btnInstallAndReLaunch.Visible = false; btnInstallAndReLaunch.Text = "Install and Relaunch"; - lblHeader.Text = lblHeader.Text.Replace("APP", item.AppName + " " + item.Version); + lblHeader.Text = lblHeader.Text.Replace("APP", downloadTitle + " " + item.Version); downloadProgressLbl.Text = ""; progressDownload.Maximum = 100; progressDownload.Minimum = 0; diff --git a/src/NetSparkle.UI.WinForms.NetCore/UIFactory.cs b/src/NetSparkle.UI.WinForms.NetCore/UIFactory.cs index f7c3796d..99eacd47 100644 --- a/src/NetSparkle.UI.WinForms.NetCore/UIFactory.cs +++ b/src/NetSparkle.UI.WinForms.NetCore/UIFactory.cs @@ -85,7 +85,7 @@ public UIFactory(Icon? applicationIcon) : this() public virtual IUpdateAvailable CreateUpdateAvailableWindow(SparkleUpdater sparkle, List updates, bool isUpdateAlreadyDownloaded = false) { var window = new UpdateAvailableWindow(sparkle, updates, _applicationIcon, isUpdateAlreadyDownloaded, - ReleaseNotesHTMLTemplate ?? "", AdditionalReleaseNotesHeaderHTML ?? "", ReleaseNotesDateTimeFormat ?? "D"); + ReleaseNotesHTMLTemplate ?? "", AdditionalReleaseNotesHeaderHTML ?? "", ReleaseNotesDateTimeFormat ?? "D", sparkle.AppCastCache?.Title ?? "the application", sparkle.Configuration.AssemblyAccessor.AssemblyVersion); if (HideReleaseNotes) { (window as IUpdateAvailable).HideReleaseNotes(); @@ -110,7 +110,8 @@ public virtual IUpdateAvailable CreateUpdateAvailableWindow(SparkleUpdater spark /// public virtual IDownloadProgress CreateProgressWindow(SparkleUpdater sparkle, AppCastItem item) { - var window = new DownloadProgressWindow(item, _applicationIcon) + var window = new DownloadProgressWindow(item, _applicationIcon, + sparkle.AppCastCache?.Title ?? "application") { SoftwareWillRelaunchAfterUpdateInstalled = sparkle.RelaunchAfterUpdate }; diff --git a/src/NetSparkle.UI.WinForms.NetCore/UpdateAvailableWindow.cs b/src/NetSparkle.UI.WinForms.NetCore/UpdateAvailableWindow.cs index 81010e16..56b02369 100644 --- a/src/NetSparkle.UI.WinForms.NetCore/UpdateAvailableWindow.cs +++ b/src/NetSparkle.UI.WinForms.NetCore/UpdateAvailableWindow.cs @@ -53,8 +53,10 @@ public partial class UpdateAvailableWindow : Form, IUpdateAvailable /// HTML template for every single note. Use {0} = Version. {1} = Date. {2} = Note Body /// Additional text they will inserted into HTML Head. For Stylesheets. /// Date format for release notes + /// Title for application + /// Currently installed version of application public UpdateAvailableWindow(SparkleUpdater sparkle, List items, Icon? applicationIcon = null, bool isUpdateAlreadyDownloaded = false, - string releaseNotesHTMLTemplate = "", string additionalReleaseNotesHeaderHTML = "", string releaseNotesDateFormat = "D") + string releaseNotesHTMLTemplate = "", string additionalReleaseNotesHeaderHTML = "", string releaseNotesDateFormat = "D", string appNameTitle = "the application", string installedVersion = "") { _sparkle = sparkle; _updates = items; @@ -78,23 +80,10 @@ public UpdateAvailableWindow(SparkleUpdater sparkle, List items, Ic AppCastItem? item = items.FirstOrDefault(); var downloadInstallText = isUpdateAlreadyDownloaded ? "install" : "download"; - lblHeader.Text = lblHeader.Text.Replace("APP", item != null ? item.AppName : "the application"); + lblHeader.Text = lblHeader.Text.Replace("APP", appNameTitle); if (item != null) { - var versionString = ""; - try - { - // Use try/catch since Version constructor can throw an exception and we don't want to - // die just because the user has a malformed version string - var versionObj = SemVerLike.Parse(item.AppVersionInstalled); - versionString = versionObj.ToString(); - } - catch - { - versionString = "?"; - } - lblInfoText.Text = string.Format("{0} {3} is now available (you have {1}). Would you like to {2} it now?", item.AppName, versionString, - downloadInstallText, item.Version); + lblInfoText.Text = string.Format("{0} {1} is now available (you have {2}). Would you like to {3} it now?", appNameTitle, item.Version, installedVersion, downloadInstallText); } else { diff --git a/src/NetSparkle.UI.WinForms.NetFramework/DownloadProgressWindow.cs b/src/NetSparkle.UI.WinForms.NetFramework/DownloadProgressWindow.cs index 68039899..80358bf8 100644 --- a/src/NetSparkle.UI.WinForms.NetFramework/DownloadProgressWindow.cs +++ b/src/NetSparkle.UI.WinForms.NetFramework/DownloadProgressWindow.cs @@ -37,7 +37,8 @@ public bool SoftwareWillRelaunchAfterUpdateInstalled /// /// The appcast item to use /// Your application Icon - public DownloadProgressWindow(AppCastItem item, Icon? applicationIcon) + /// Name of application that is being downloaded + public DownloadProgressWindow(AppCastItem item, Icon? applicationIcon, string downloadTitle = "") { InitializeComponent(); @@ -50,7 +51,7 @@ public DownloadProgressWindow(AppCastItem item, Icon? applicationIcon) // init ui btnInstallAndReLaunch.Visible = false; btnInstallAndReLaunch.Text = "Install and Relaunch"; - lblHeader.Text = lblHeader.Text.Replace("APP", item.AppName + " " + item.Version); + lblHeader.Text = lblHeader.Text.Replace("APP", downloadTitle + " " + item.Version); downloadProgressLbl.Text = ""; progressDownload.Maximum = 100; progressDownload.Minimum = 0; diff --git a/src/NetSparkle.UI.WinForms.NetFramework/UIFactory.cs b/src/NetSparkle.UI.WinForms.NetFramework/UIFactory.cs index faa2971c..ca3e9c77 100644 --- a/src/NetSparkle.UI.WinForms.NetFramework/UIFactory.cs +++ b/src/NetSparkle.UI.WinForms.NetFramework/UIFactory.cs @@ -85,7 +85,7 @@ public UIFactory(Icon applicationIcon) : this() public virtual IUpdateAvailable CreateUpdateAvailableWindow(SparkleUpdater sparkle, List updates, bool isUpdateAlreadyDownloaded = false) { var window = new UpdateAvailableWindow(sparkle, updates, _applicationIcon, isUpdateAlreadyDownloaded, - ReleaseNotesHTMLTemplate ?? "", AdditionalReleaseNotesHeaderHTML ?? "", ReleaseNotesDateTimeFormat); + ReleaseNotesHTMLTemplate ?? "", AdditionalReleaseNotesHeaderHTML ?? "", ReleaseNotesDateTimeFormat, sparkle.AppCastCache?.Title ?? "the application", sparkle.Configuration.AssemblyAccessor.AssemblyVersion); if (HideReleaseNotes) { (window as IUpdateAvailable).HideReleaseNotes(); @@ -110,7 +110,8 @@ public virtual IUpdateAvailable CreateUpdateAvailableWindow(SparkleUpdater spark /// public virtual IDownloadProgress CreateProgressWindow(SparkleUpdater sparkle, AppCastItem item) { - var window = new DownloadProgressWindow(item, _applicationIcon) + var window = new DownloadProgressWindow(item, _applicationIcon, + sparkle.AppCastCache?.Title ?? "application") { SoftwareWillRelaunchAfterUpdateInstalled = sparkle.RelaunchAfterUpdate }; diff --git a/src/NetSparkle.UI.WinForms.NetFramework/UpdateAvailableWindow.cs b/src/NetSparkle.UI.WinForms.NetFramework/UpdateAvailableWindow.cs index 5e19e71d..e521ab8d 100644 --- a/src/NetSparkle.UI.WinForms.NetFramework/UpdateAvailableWindow.cs +++ b/src/NetSparkle.UI.WinForms.NetFramework/UpdateAvailableWindow.cs @@ -53,8 +53,10 @@ public partial class UpdateAvailableWindow : Form, IUpdateAvailable /// HTML template for every single note. Use {0} = Version. {1} = Date. {2} = Note Body /// Additional text they will inserted into HTML Head. For Stylesheets. /// Date format for release notes + /// Title for application + /// Currently installed version of application public UpdateAvailableWindow(SparkleUpdater sparkle, List items, Icon? applicationIcon = null, bool isUpdateAlreadyDownloaded = false, - string releaseNotesHTMLTemplate = "", string additionalReleaseNotesHeaderHTML = "", string releaseNotesDateFormat = "D") + string releaseNotesHTMLTemplate = "", string additionalReleaseNotesHeaderHTML = "", string releaseNotesDateFormat = "D", string appNameTitle = "the application", string installedVersion = "") { _sparkle = sparkle; _updates = items; @@ -78,23 +80,9 @@ public UpdateAvailableWindow(SparkleUpdater sparkle, List items, Ic AppCastItem item = items.FirstOrDefault(); var downloadInstallText = isUpdateAlreadyDownloaded ? "install" : "download"; - lblHeader.Text = lblHeader.Text.Replace("APP", item != null ? item.AppName : "the application"); if (item != null) { - var versionString = ""; - try - { - // Use try/catch since Version constructor can throw an exception and we don't want to - // die just because the user has a malformed version string - var versionObj = SemVerLike.Parse(item.AppVersionInstalled); - versionString = versionObj.ToString(); - } - catch - { - versionString = "?"; - } - lblInfoText.Text = string.Format("{0} {3} is now available (you have {1}). Would you like to {2} it now?", item.AppName, versionString, - downloadInstallText, item.Version); + lblInfoText.Text = string.Format("{0} {1} is now available (you have {2}). Would you like to {3} it now?", appNameTitle, item.Version, installedVersion, downloadInstallText); } else { diff --git a/src/NetSparkle/AppCast.cs b/src/NetSparkle/AppCast.cs new file mode 100644 index 00000000..c8ca4e3c --- /dev/null +++ b/src/NetSparkle/AppCast.cs @@ -0,0 +1,57 @@ +using NetSparkleUpdater; +using NetSparkleUpdater.Configurations; +using NetSparkleUpdater.Interfaces; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace NetSparkleUpdater +{ + /// + /// An XML-based app cast document downloader and handler + /// + public class AppCast + { + /// + /// App cast title (usually the name of the application) + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// App cast language (e.g. "en") + /// + [JsonPropertyName("language")] + public string? Language { get; set; } + + /// + /// App cast description (e.g. "updates for my application"). + /// Defaults to "Most recent changes with links to updates" + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// + /// Link to location to download this app cast + /// + [JsonPropertyName("link")] + public string? Link { get; set; } + + /// + /// List of that were parsed in the app cast + /// + [JsonPropertyName("items")] + public List Items { get; set; } + + /// + /// Default constructor for objects. + /// Sets ` to an empty list and sets + /// to "Most recent changes with links to updates". + /// + public AppCast() + { + Items = new List(); + Description = "Most recent changes with links to updates"; + } + } +} \ No newline at end of file diff --git a/src/NetSparkle/AppCastHandlers/XMLAppCast.cs b/src/NetSparkle/AppCastHandlers/AppCastHelper.cs similarity index 50% rename from src/NetSparkle/AppCastHandlers/XMLAppCast.cs rename to src/NetSparkle/AppCastHandlers/AppCastHelper.cs index cfaf3fc2..912609c1 100644 --- a/src/NetSparkle/AppCastHandlers/XMLAppCast.cs +++ b/src/NetSparkle/AppCastHandlers/AppCastHelper.cs @@ -5,38 +5,24 @@ using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices; +using System.Threading.Tasks; using System.Xml.Linq; namespace NetSparkleUpdater.AppCastHandlers { /// - /// An XML-based app cast document downloader and handler + /// The AppCastHelper class is responsible for downloading + /// app cast data, downloading and checking app cast signature data, + /// and filtering app cast items when looking for available updates. /// - public class XMLAppCast : IAppCastHandler + public class AppCastHelper { - private Configuration? _config; + private string? _installedVersion; private string? _castUrl; - private ISignatureVerifier? _signatureVerifier; private ILogger? _logWriter; - private IAppCastDataDownloader? _dataDownloader; - /// - /// Sparkle XML namespace - /// - public static readonly XNamespace SparkleNamespace = "http://www.andymatuschak.org/xml-namespaces/sparkle"; - - /// - /// App cast title (usually the name of the application) - /// - public string? Title { get; set; } - - /// - /// App cast language (e.g. "en") - /// - public string? Language { get; set; } - /// /// Extension (WITHOUT the "." at the start) for the signature /// file. Defaults to "signature". @@ -47,21 +33,36 @@ public class XMLAppCast : IAppCastHandler /// An optional filtering component. Use this to manually filter /// items for custom channels (e.g. beta or alpha) or run your own /// logic on getting rid of older versions. + /// NOTE: When you use this interface + /// with , you must filter out old versions of + /// yourself if you want that to happen! In other words, skips this step + /// when there is an implementation available. + /// + /// Note that items will still be filtered based on OS and signature information as necessary. You don't + /// need to handle that. /// public IAppCastFilter? AppCastFilter { get; set; } /// - /// List of that were parsed in the app cast + /// When filtering for available updates, remove items with + /// no version information set. /// - public readonly List Items; + public bool FilterOutItemsWithNoVersion { get; set; } + + /// + /// When filtering for available updates, remove items with + /// no download link set. + /// + public bool FilterOutItemsWithNoDownloadLink { get; set; } /// /// Create a new object with an empty list of items /// - public XMLAppCast() + public AppCastHelper() { - Items = new List(); SignatureFileExtension = "signature"; + FilterOutItemsWithNoVersion = true; + FilterOutItemsWithNoDownloadLink = true; } /// @@ -71,14 +72,13 @@ public XMLAppCast() /// (provided by via the /// property. /// full URL to the app cast file - /// configuration for handling update intervals/checks - /// (user skipped versions, etc.) + /// installed version of the software /// Object to check signatures of app cast information /// object that you can utilize to do any necessary logging - public void SetupAppCastHandler(IAppCastDataDownloader dataDownloader, string castUrl, Configuration config, ISignatureVerifier? signatureVerifier, ILogger? logWriter = null) + public virtual void SetupAppCastHelper(IAppCastDataDownloader dataDownloader, string castUrl, string? installedVersion, ISignatureVerifier? signatureVerifier, ILogger? logWriter = null) { _dataDownloader = dataDownloader; - _config = config; + _installedVersion = installedVersion; _castUrl = castUrl; _signatureVerifier = signatureVerifier; @@ -89,86 +89,110 @@ private void CheckSetupCalled() { if (_dataDownloader == null) { - _logWriter?.PrintMessage("Warning: XMLAppCast has no IAppCastDataDownloader; did you forget to call SetupAppCastHandler()?"); - } - if (_config == null) - { - _logWriter?.PrintMessage("Warning: XMLAppCast has no Configuration; did you forget to call SetupAppCastHandler()?"); + _logWriter?.PrintMessage("Warning: AppCastHandler has no IAppCastDataDownloader; did you forget to call SetupAppCastHandler()?"); } if (string.IsNullOrWhiteSpace(_castUrl)) { - _logWriter?.PrintMessage("Warning: XMLAppCast has no app cast URL; did you forget to call SetupAppCastHandler()?"); + _logWriter?.PrintMessage("Warning: AppCastHandler has no app cast URL; did you forget to call SetupAppCastHandler()?"); } if (_signatureVerifier == null) { - _logWriter?.PrintMessage("Warning: XMLAppCast has no ISignatureVerifier; did you forget to call SetupAppCastHandler()?"); + _logWriter?.PrintMessage("Warning: AppCastHandler has no ISignatureVerifier; did you forget to call SetupAppCastHandler()?"); } } + private bool IsSignatureNeeded() + { + return Utilities.IsSignatureNeeded( + _signatureVerifier?.SecurityMode ?? SecurityMode.UseIfPossible, + _signatureVerifier?.HasValidKeyInformation() ?? false, + false); + } + + private async Task DownloadSignatureData(string appCastUrl, string signatureExtension) + { + try + { + if (_dataDownloader != null) + { + var signatureData = await _dataDownloader.DownloadAndGetAppCastDataAsync(appCastUrl + "." + signatureExtension.Trim().TrimStart('.')); + return signatureData; + } + } + catch (Exception e) + { + _logWriter?.PrintMessage("Error grabbing signature {0}.{2}: {1} ", appCastUrl, e.Message, signatureExtension); + } + return null; + } + /// - /// Download castUrl resource and parse it + /// Downloads an app cast from the app cast URL sent to + /// and verifies its signature (if signatures are required for app casts). + /// Configure functionality by implementing your own + /// or by overriding this method in a subclass. /// - public virtual bool DownloadAndParse() + /// Downloaded app cast raw string if download and verification succeeded. Null if + /// the operation failed somehow. + public virtual async Task DownloadAppCast() { CheckSetupCalled(); if (_castUrl == null || string.IsNullOrWhiteSpace(_castUrl)) { _logWriter?.PrintMessage("Warning: DownloadAndParse called with no app cast URL set; did you forget to call SetupAppCastHandler()?"); - return false; + return null; + } + if (_dataDownloader == null) + { + _logWriter?.PrintMessage("Warning: DownloadAndParse called with no data downloader set; did you forget to call SetupAppCastHandler()?"); + return null; } try { _logWriter?.PrintMessage("Downloading app cast data..."); - var appcast = _dataDownloader?.DownloadAndGetAppCastData(_castUrl); - var signatureNeeded = Utilities.IsSignatureNeeded( - _signatureVerifier?.SecurityMode ?? SecurityMode.UseIfPossible, - _signatureVerifier?.HasValidKeyInformation() ?? false, - false); - bool isValidAppcast = true; - if (signatureNeeded) + var appCast = await _dataDownloader.DownloadAndGetAppCastDataAsync(_castUrl) ?? ""; + if (string.IsNullOrWhiteSpace(appCast)) + { + _logWriter?.PrintMessage("Failed to download app cast from URL {0}", _castUrl ?? ""); + return null; + } + bool isValidAppCast = true; + if (IsSignatureNeeded()) { _logWriter?.PrintMessage("Downloading app cast signature data..."); var signature = ""; - var extension = SignatureFileExtension?.Trim().TrimStart('.') ?? "signature"; - try - { - signature = _dataDownloader?.DownloadAndGetAppCastData(_castUrl + "." + extension); - } - catch (Exception e) - { - _logWriter?.PrintMessage("Error reading app cast {0}.{2}: {1} ", _castUrl, e.Message, extension); - } + var extension = SignatureFileExtension?.Trim() ?? "signature"; + signature = await DownloadSignatureData(_castUrl, extension); if (string.IsNullOrWhiteSpace(signature)) { - // legacy: check for .dsa file - try - { - _logWriter?.PrintMessage("Attempting to check for legacy .dsa signature data..."); - signature = _dataDownloader?.DownloadAndGetAppCastData(_castUrl + ".dsa"); - } - catch (Exception e) - { - _logWriter?.PrintMessage("Error reading app cast {0}.dsa: {1} ", _castUrl, e.Message); - } + _logWriter?.PrintMessage("Attempting to check for legacy .dsa signature data..."); + signature = await DownloadSignatureData(_castUrl, ".dsa"); } - isValidAppcast = VerifyAppCast(appcast, signature); + isValidAppCast = VerifyAppCast(appCast, signature); } - if (isValidAppcast) + if (isValidAppCast) { - _logWriter?.PrintMessage("Appcast is valid! Parsing..."); - ParseAppCast(appcast); - return true; + _logWriter?.PrintMessage("Appcast is valid!"); + return appCast; } } catch (Exception e) { - _logWriter?.PrintMessage("Error reading app cast {0}: {1} ", _castUrl, e.Message); + _logWriter?.PrintMessage("Error downloading app cast {0}: {1} ", _castUrl, e.Message); } _logWriter?.PrintMessage("Appcast is not valid"); - return false; + return null; } - private bool VerifyAppCast(string? appCast, string? signature) + /// + /// Verify a given app cast string based on a given cryptographic signature. + /// Verified via the sent to + /// . + /// + /// App cast string to verify + /// Signature of app cast + /// True if signature is not required OR required and valid; false otherwise + protected bool VerifyAppCast(string? appCast, string? signature) { if (string.IsNullOrWhiteSpace(appCast)) { @@ -177,124 +201,87 @@ private bool VerifyAppCast(string? appCast, string? signature) } // checking signature - var signatureNeeded = Utilities.IsSignatureNeeded( - _signatureVerifier?.SecurityMode ?? SecurityMode.UseIfPossible, - _signatureVerifier?.HasValidKeyInformation() ?? false, - false); var appcastBytes = _dataDownloader?.GetAppCastEncoding().GetBytes(appCast); - if (signatureNeeded && - (_signatureVerifier?.VerifySignature(signature ?? "", appcastBytes ?? Array.Empty()) ?? ValidationResult.Invalid) + if (IsSignatureNeeded() && + (_signatureVerifier?.VerifySignature( + signature ?? "", appcastBytes ?? Array.Empty()) ?? ValidationResult.Invalid) == ValidationResult.Invalid) { - _logWriter?.PrintMessage("Signature check of appcast failed"); + _logWriter?.PrintMessage("Signature check of appcast failed (Security mode is {0})", + _signatureVerifier?.SecurityMode ?? SecurityMode.UseIfPossible); return false; } return true; } /// - /// Parse the app cast XML string into a list of objects. - /// When complete, the Items list should contain the parsed information - /// as objects. + /// Returns filtered list of updates between current installed version and latest version. + /// By default, checks operating system (always does this no matter what unless this function is overriden), + /// whether or not signatures are available (if signatures are required), and + /// version of the software (filters out anything that is the same version or older). + /// Adjust behavior via the and + /// bool properties, + /// (NOTE: if you use this, you will need to handle filtering + /// out items that are older than the user's current version), or override this method in a subclass. /// - /// the non-null string XML app cast - protected virtual void ParseAppCast(string? appCast) + /// List of items to filter + /// A list of filtered updates that could be installed + public virtual List FilterUpdates(List items) { - Items.Clear(); - if (!string.IsNullOrWhiteSpace(appCast)) + CheckSetupCalled(); + var installedVersion = SemVerLike.Parse(_installedVersion ?? ""); + bool shouldFilterOutSmallerVersions = true; + + if (AppCastFilter != null) { - XDocument doc = XDocument.Parse(appCast); - var rss = doc?.Element("rss"); - var channel = rss?.Element("channel"); + _logWriter?.PrintMessage("Using custom AppCastFilter to filter out items..."); + items = AppCastFilter.GetFilteredAppCastItems(installedVersion, items)?.ToList() ?? new List(); - Title = channel?.Element("title")?.Value ?? string.Empty; - Language = channel?.Element("language")?.Value ?? "en"; + // AppCastFilter user has responsibility to filter out both older and not needed versions, + // so the AppCastHandler object no longer needs to handle filtering out old versions. + // Also this allows to easily switch between pre-release and retail versions, on demand. + // The AppCastHandler will still filter out items that don't match the current OS. + shouldFilterOutSmallerVersions = false; + } - var items = doc?.Descendants("item"); - if (items != null) + var signatureNeeded = IsSignatureNeeded(); + _logWriter?.PrintMessage("Looking for available updates; our installed version is {0}; do we need a signature? {1}; are we filtering out smaller versions than our current version? {2}", installedVersion, signatureNeeded, shouldFilterOutSmallerVersions); + return items.Where((item) => + { + var filterResult = FilterAppCastItem(installedVersion, shouldFilterOutSmallerVersions, signatureNeeded, item); + if (filterResult == FilterItemResult.Valid) { - foreach (var item in items) + if (FilterOutItemsWithNoVersion && + (item.Version == null || string.IsNullOrWhiteSpace(item.Version))) + { + return false; + } + if (FilterOutItemsWithNoDownloadLink && + (item.DownloadLink == null || string.IsNullOrWhiteSpace(item.DownloadLink))) { - var currentItem = AppCastItem.Parse(_config?.InstalledVersion, _config?.ApplicationName, _castUrl, item, _logWriter); - _logWriter?.PrintMessage("Found an item in the app cast: version {0} ({1}) -- os = {2}", - currentItem.Version ?? "[Unknown version]", currentItem.ShortVersion ?? "[Unknown short version]", currentItem.OperatingSystemString ?? "[Unknown operating system]"); - Items.Add(currentItem); + return false; } + // accept all valid items + _logWriter?.PrintMessage("Item with version {0} ({1}) is a valid update! It can be downloaded at {2}", item.Version ?? "[Unknown version]", item.ShortVersion ?? "[Unknown short version]", item.DownloadLink ?? "[Unknown download link]"); + return true; } - - // sort versions in reverse order - Items.Sort((item1, item2) => -1 * item1.CompareTo(item2)); - } - } - - - /// - /// Check if an AppCastItem update is valid based on operating system. The user's current operating system - /// needs to match the operating system of the AppCastItem for the AppCastItem to be valid. - /// - /// the AppCastItem under consideration - /// FilterItemResult.Valid if the AppCastItem should be considered as a valid target for installation; - /// FilterItemResult.NotThisPlatform otherwise. - protected FilterItemResult FilterAppCastItemByOS(AppCastItem item) - { -#if NETFRAMEWORK - // don't allow non-windows updates - if (!item.IsWindowsUpdate) - { - _logWriter?.PrintMessage("Rejecting update for {0} ({1}, {2}) because it isn't a Windows update and we're on Windows", item.Version ?? "[Unknown version]", - item.ShortVersion ?? "[Unknown short version]", item.DownloadLink ?? "[Unknown download link]"); - return FilterItemResult.NotThisPlatform; - } -#else - // check operating system and filter out ones that don't match the current - // operating system - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !item.IsWindowsUpdate) - { - _logWriter?.PrintMessage("Rejecting update for {0} ({1}, {2}) because it isn't a Windows update and we're on Windows", item.Version ?? "[Unknown version]", item.ShortVersion ?? "[Unknown short version]", item.DownloadLink ?? "[Unknown download link]"); - return FilterItemResult.NotThisPlatform; - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && !item.IsMacOSUpdate) - { - _logWriter?.PrintMessage("Rejecting update for {0} ({1}, {2}) because it isn't a macOS update and we're on macOS", item.Version ?? "[Unknown version]", item.ShortVersion ?? "[Unknown short version]", item.DownloadLink ?? "[Unknown download link]"); - return FilterItemResult.NotThisPlatform; - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) && !item.IsLinuxUpdate) - { - _logWriter?.PrintMessage("Rejecting update for {0} ({1}, {2}) because it isn't a Linux update and we're on Linux", item.Version ?? "[Unknown version]", item.ShortVersion ?? "[Unknown short version]", item.DownloadLink ?? "[Unknown download link]"); - return FilterItemResult.NotThisPlatform; - } -#endif - return FilterItemResult.Valid; - } - - /// - /// Check if an AppCastItem update is valid, according to platform, signature requirements and current installed version number. - /// In the case where your app implements a downgrade strategy, e.g. when switching from a beta to a - /// stable channel - there has to be a way to tell the update mechanism that you wish to ignore - /// the beta AppCastItem elements, and that the latest stable element should be installed. - /// - /// the currently installed Version - /// if true, and the item's version is less than or equal to installed - the item will be discarded --> - /// whether or not a signature is required - /// the AppCastItem under consideration, every AppCastItem found in the appcast.xml file is presented to this function once - /// FilterItemResult.Valid if the AppCastItem should be considered as a valid target for installation. - public FilterItemResult FilterAppCastItem(Version installed, bool discardVersionsSmallerThanInstalled, bool signatureNeeded, AppCastItem item) - { - return FilterAppCastItem(SemVerLike.Parse(installed.ToString()), discardVersionsSmallerThanInstalled, signatureNeeded, item); + return false; + }).ToList(); } /// - /// Check if an AppCastItem update is valid, according to platform, signature requirements and current installed version number. + /// Check if an AppCastItem update is valid, according to platform, signature requirements + /// and current installed version number. /// In the case where your app implements a downgrade strategy, e.g. when switching from a beta to a /// stable channel - there has to be a way to tell the update mechanism that you wish to ignore /// the beta AppCastItem elements, and that the latest stable element should be installed. /// /// the currently installed Version - /// if true, and the item's version is less than or equal to installed - the item will be discarded --> + /// if true, and the item's version is less than or equal to installed - the item will be discarded /// whether or not a signature is required /// the AppCastItem under consideration, every AppCastItem found in the appcast.xml file is presented to this function once /// FilterItemResult.Valid if the AppCastItem should be considered as a valid target for installation. - public FilterItemResult FilterAppCastItem(SemVerLike installed, bool discardVersionsSmallerThanInstalled, bool signatureNeeded, AppCastItem item) + protected FilterItemResult FilterAppCastItem(SemVerLike installed, bool discardVersionsSmallerThanInstalled, bool signatureNeeded, AppCastItem item) { var osFilterResult = FilterAppCastItemByOS(item); if (osFilterResult != FilterItemResult.Valid) @@ -315,10 +302,11 @@ public FilterItemResult FilterAppCastItem(SemVerLike installed, bool discardVers } } - // filter versions without signature if we need signatures. But accept version without downloads. - if (signatureNeeded && string.IsNullOrWhiteSpace(item.DownloadSignature) && !string.IsNullOrWhiteSpace(item.DownloadLink)) + // filter versions without signature if we need signatures + if (signatureNeeded && + string.IsNullOrWhiteSpace(item.DownloadSignature) && !string.IsNullOrWhiteSpace(item.DownloadLink)) { - _logWriter?.PrintMessage("Rejecting update for {0} ({1}, {2}) because it we needed a DSA/other signature and " + + _logWriter?.PrintMessage("Rejecting update for {0} ({1}, {2}) because it we needed a Ed25519/other signature and " + "the item has no signature yet has a download link of {3}", item.Version ?? "[Unknown version]", item.ShortVersion ?? "[Unknown short version]", item.Title ?? "[Unknown title]", item.DownloadLink ?? "[Unknown download link]"); return FilterItemResult.SignatureIsMissing; @@ -328,84 +316,42 @@ public FilterItemResult FilterAppCastItem(SemVerLike installed, bool discardVers } /// - /// Returns sorted list of updates between current installed version and latest version in . - /// Currently installed version is NOT included in the output. + /// Check if an AppCastItem update is valid based on operating system. The user's current operating system + /// needs to match the operating system of the AppCastItem for the AppCastItem to be valid. /// - /// A list of updates that could be installed - public virtual List GetAvailableUpdates() + /// the AppCastItem under consideration + /// FilterItemResult.Valid if the AppCastItem should be considered as a valid target for installation; + /// FilterItemResult.NotThisPlatform otherwise. + protected FilterItemResult FilterAppCastItemByOS(AppCastItem item) { - CheckSetupCalled(); - var installed = SemVerLike.Parse(_config?.InstalledVersion ?? ""); - List appCastItems = Items; - bool shouldFilterOutSmallerVersions = true; - - if (AppCastFilter != null) +#if NETFRAMEWORK + // don't allow non-windows updates + if (!item.IsWindowsUpdate) { - appCastItems = AppCastFilter.GetFilteredAppCastItems(installed, Items)?.ToList() ?? new List(); - - // AppCastReducer user has responsibility to filter out both older and not needed versions, - // so the XMLAppCast object no longer needs to handle filtering out old versions. - // Also this allows to easily switch between pre-release and retail versions, on demand. - // The XMLAppCast will still filter out items that don't match the current OS. - shouldFilterOutSmallerVersions = false; + _logWriter?.PrintMessage("Rejecting update for {0} ({1}, {2}) because it isn't a Windows update and we're on Windows", item.Version ?? "[Unknown version]", + item.ShortVersion ?? "[Unknown short version]", item.DownloadLink ?? "[Unknown download link]"); + return FilterItemResult.NotThisPlatform; } - - var signatureNeeded = Utilities.IsSignatureNeeded( - _signatureVerifier?.SecurityMode ?? SecurityMode.UseIfPossible, - _signatureVerifier?.HasValidKeyInformation() ?? false, - false); - - _logWriter?.PrintMessage("Looking for available updates; our installed version is {0}; do we need a signature? {1}; are we filtering out smaller versions than our current version? {2}", installed, signatureNeeded, shouldFilterOutSmallerVersions); - return appCastItems.Where((item) => - { - if (FilterAppCastItem(installed, shouldFilterOutSmallerVersions, signatureNeeded, item) == FilterItemResult.Valid) - { - // accept all valid items - _logWriter?.PrintMessage("Item with version {0} ({1}) is a valid update! It can be downloaded at {2}", item.Version ?? "[Unknown version]", item.ShortVersion ?? "[Unknown short version]", item.DownloadLink ?? "[Unknown download link]"); - // TODO: should we reject items with no Version or DownloadLink here? - return true; - } - return false; - }).ToList(); - } - - /// - /// Create app cast XML document as an object - /// - /// The list to include in the output file - /// Application title/title for the app cast - /// Link to the where the app cast is going to be downloaded - /// Text that describes the app cast (e.g. what it provides) - /// Language of the app cast file - /// An xml document that describes the list of passed in update items - public static XDocument GenerateAppCastXml(List items, string? title, string link = "", string description = "", string language = "en") - { - var channel = new XElement("channel"); - channel.Add(new XElement("title", title)); - - if (!string.IsNullOrWhiteSpace(link)) +#else + // check operating system and filter out ones that don't match the current + // operating system + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !item.IsWindowsUpdate) { - channel.Add(new XElement("link", link)); + _logWriter?.PrintMessage("Rejecting update for {0} ({1}, {2}) because it isn't a Windows update and we're on Windows", item.Version ?? "[Unknown version]", item.ShortVersion ?? "[Unknown short version]", item.DownloadLink ?? "[Unknown download link]"); + return FilterItemResult.NotThisPlatform; } - - if (!string.IsNullOrWhiteSpace(description)) + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && !item.IsMacOSUpdate) { - channel.Add(new XElement("description", description)); + _logWriter?.PrintMessage("Rejecting update for {0} ({1}, {2}) because it isn't a macOS update and we're on macOS", item.Version ?? "[Unknown version]", item.ShortVersion ?? "[Unknown short version]", item.DownloadLink ?? "[Unknown download link]"); + return FilterItemResult.NotThisPlatform; } - - channel.Add(new XElement("language", language)); - - foreach (var item in items) + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) && !item.IsLinuxUpdate) { - channel.Add(item.GetXElement()); + _logWriter?.PrintMessage("Rejecting update for {0} ({1}, {2}) because it isn't a Linux update and we're on Linux", item.Version ?? "[Unknown version]", item.ShortVersion ?? "[Unknown short version]", item.DownloadLink ?? "[Unknown download link]"); + return FilterItemResult.NotThisPlatform; } - - var document = new XDocument( - new XElement("rss", new XAttribute("version", "2.0"), new XAttribute(XNamespace.Xmlns + "sparkle", SparkleNamespace), - channel) - ); - - return document; +#endif + return FilterItemResult.Valid; } } } diff --git a/src/NetSparkle/AppCastHandlers/ChannelAppCastFilter.cs b/src/NetSparkle/AppCastHandlers/ChannelAppCastFilter.cs new file mode 100644 index 00000000..a3f9ec42 --- /dev/null +++ b/src/NetSparkle/AppCastHandlers/ChannelAppCastFilter.cs @@ -0,0 +1,107 @@ +using NetSparkleUpdater.Interfaces; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace NetSparkleUpdater.AppCastHandlers +{ + /// + /// Basic implementation for filtering + /// your app cast items based on a channel name (e.g. "beta"). Makes it + /// easy to allow your users to be on a beta software track or similar. + /// Note that a "stable" channel search string will not be interpreted as versions + /// like "1.0.0"; it will look for versions like "1.0.0-stable1" (aka search for + /// the string "stable"). + /// Names are compared in a case-insensitive manner. + /// + public class ChannelAppCastFilter : IAppCastFilter + { + private ILogger? _logWriter; + + /// + /// Constructor for + /// + /// Optional for logging data + public ChannelAppCastFilter(ILogger? logWriter = null) + { + RemoveOlderItems = true; + KeepItemsWithNoChannelInfo = true; + ChannelSearchNames = new List(); + _logWriter = logWriter; + } + + /// + /// Set to true to remove older items (<= the current installed version); + /// false to keep them. + /// Defaults to true. + /// + public bool RemoveOlderItems { get; set; } + + /// + /// Channel names (e.g. "beta" or "alpha") to filter by in + /// the app cast item's version and channel information. + /// Defaults to an empty list. + /// Names are compared in a case-insensitive manner. + /// + public List ChannelSearchNames { get; set; } + + /// + /// When filtering by , true to keep items + /// that have a version with no suffix (e.g. "1.2.3" only; "1.2.3-beta1" has a suffix) + /// AND no explicit Item.Channel. false to get rid of those. Setting this to true will + /// allow users on a beta channel to get updates that have no channel information + /// explicitly set. + /// Has no effect when is whitespace/empty. + /// Defaults to true. + /// + public bool KeepItemsWithNoChannelInfo { get; set; } + + /// + public IEnumerable GetFilteredAppCastItems(SemVerLike installed, IEnumerable items) + { + var lowerChannelNames = ChannelSearchNames.Select(s => s.ToLowerInvariant()).ToArray(); + return items.Where((item) => + { + var semVer = SemVerLike.Parse(item.Version); + var appCastItemChannel = item.Channel ?? ""; + if (RemoveOlderItems && semVer.CompareTo(installed) <= 0) + { + _logWriter?.PrintMessage("Removing older item from filtered app cast results"); + return false; + } + if (lowerChannelNames.Length > 0) + { + foreach (var channelName in lowerChannelNames) + { + if (!string.IsNullOrWhiteSpace(channelName)) // ignore empty channel names + { + _logWriter?.PrintMessage("Filtering by channel: {0}; keeping items with no suffix = {1}", + channelName, KeepItemsWithNoChannelInfo); + var shouldKeep = + semVer.AllSuffixes.ToLower().Contains(channelName) || + appCastItemChannel.ToLower().Contains(channelName) || + (KeepItemsWithNoChannelInfo && + string.IsNullOrWhiteSpace(semVer.AllSuffixes.Trim()) && + string.IsNullOrWhiteSpace(appCastItemChannel)); + if (shouldKeep) + { + return true; + } + } + } + _logWriter?.PrintMessage("Item with version {0} was discarded", semVer.ToString()); + return false; + } + else + { + // if we are not wanting any channels but we have a suffix on an item, discard it + if (!string.IsNullOrWhiteSpace(semVer.AllSuffixes)) + { + return false; + } + } + return true; + }).OrderByDescending(x => x.SemVerLikeVersion); + } + } +} \ No newline at end of file diff --git a/src/NetSparkle/AppCastHandlers/JsonAppCastGenerator.cs b/src/NetSparkle/AppCastHandlers/JsonAppCastGenerator.cs new file mode 100644 index 00000000..f8f6e713 --- /dev/null +++ b/src/NetSparkle/AppCastHandlers/JsonAppCastGenerator.cs @@ -0,0 +1,205 @@ +using NetSparkleUpdater.Interfaces; +using System; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace NetSparkleUpdater.AppCastHandlers +{ + /// + /// A Json-based app cast document downloader and handler + /// + public class JsonAppCastGenerator : IAppCastGenerator + { + private ILogger? _logWriter; + + /// + /// An app cast generator that reads/writes Json + /// + /// Optional for logging data + public JsonAppCastGenerator(ILogger? logger = null) + { + _logWriter = logger; + HumanReadableOutput = true; + } + + /// + /// Set to true to make serialized output human readable (newlines, indents) when written to a file. + /// Set to false to make this output not necessarily human readable. + /// Defaults to true. + /// + public bool HumanReadableOutput { get; set; } + + /// + /// Deserialize the app cast string into a list of objects. + /// When complete, the list should contain the parsed information + /// as objects that are sorted in reverse order (if shouldSort is true) like so: + /// appCast.Items.Sort((item1, item2) => -1 * item1.CompareTo(item2)); + /// + /// the non-null app cast + /// whether or not output should be sorted + public AppCast DeserializeAppCast(string appCastString, bool shouldSort = true) + { + var options = GetSerializerOptions(); +#if NETFRAMEWORK || NETSTANDARD + var appCast = JsonSerializer.Deserialize(appCastString, options) ?? new AppCast(); +#else + var jsonContext = new SourceGenerationContext(options); + var appCast = JsonSerializer.Deserialize(appCastString, jsonContext.AppCast) ?? new AppCast(); +#endif + if (shouldSort) + { + // sort versions in reverse order + appCast.Items.Sort((item1, item2) => -1 * item1.CompareTo(item2)); + } + return appCast; + } + + /// + public AppCast DeserializeAppCast(string appCastString) + { + return DeserializeAppCast(appCastString, true); + } + + /// + public async Task DeserializeAppCastAsync(string appCastString) + { + var options = GetSerializerOptions(); + using (var stream = Utilities.GenerateStreamFromString(appCastString, Encoding.UTF8)) + { +#if NETFRAMEWORK || NETSTANDARD + var output = await JsonSerializer.DeserializeAsync(stream, options) ?? new AppCast(); +#else + var jsonContext = new SourceGenerationContext(options); + var output = await JsonSerializer.DeserializeAsync(stream, jsonContext.AppCast) ?? new AppCast(); +#endif + // sort versions in reverse order + output.Items.Sort((item1, item2) => -1 * item1.CompareTo(item2)); + return output; + } + } + + /// + public AppCast DeserializeAppCastFromFile(string filePath) + { + string json = File.ReadAllText(filePath); + return DeserializeAppCast(json); + } + + /// + /// Deserialize the app cast from a file at the given path + /// into a list of objects. + /// When complete, the list is explicitly not sorted. + /// + /// Path to the file on disk to deserialize + public AppCast DeserializeAppCastFromFileWithoutSorting(string filePath) + { + string json = File.ReadAllText(filePath); + return DeserializeAppCast(json, false); + } + + /// + public async Task DeserializeAppCastFromFileAsync(string filePath) + { + var options = GetSerializerOptions(); + using (FileStream fileStream = File.OpenRead(filePath)) + { +#if NETFRAMEWORK || NETSTANDARD + var output = await JsonSerializer.DeserializeAsync(fileStream, options) ?? new AppCast(); +#else + var jsonContext = new SourceGenerationContext(options); + var output = await JsonSerializer.DeserializeAsync(fileStream, jsonContext.AppCast) ?? new AppCast(); +#endif + // sort versions in reverse order + output.Items.Sort((item1, item2) => -1 * item1.CompareTo(item2)); + return output; + } + } + + /// + /// Class to convert DateTime to universal time when serializing and then + /// convert to local time when unserializing so that time zones are always + /// written. + /// From: https://github.com/dotnet/runtime/issues/1566 + /// + public class DateTimeConverter : JsonConverter + { + /// + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.GetDateTime().ToLocalTime(); + } + + /// + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToUniversalTime()); + } + } + + /// + /// Get for serialization methods + /// + /// + protected JsonSerializerOptions GetSerializerOptions() + { + var opts = new JsonSerializerOptions + { + WriteIndented = HumanReadableOutput, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + opts.Converters.Add(new DateTimeConverter()); + return opts; + } + + /// + public string SerializeAppCast(AppCast appCast) + { + var options = GetSerializerOptions(); +#if NETFRAMEWORK || NETSTANDARD + return JsonSerializer.Serialize(appCast, options); +#else + var jsonContext = new SourceGenerationContext(options); + return JsonSerializer.Serialize(appCast, jsonContext.AppCast); +#endif + } + + /// + public async Task SerializeAppCastAsync(AppCast appCast) + { + var options = GetSerializerOptions(); + using (MemoryStream memoryStream = new MemoryStream()) + { +#if NETFRAMEWORK || NETSTANDARD + await JsonSerializer.SerializeAsync(memoryStream, appCast, options); +#else + var jsonContext = new SourceGenerationContext(options); + await JsonSerializer.SerializeAsync(memoryStream, appCast, jsonContext.AppCast); +#endif + memoryStream.Position = 0; + using var reader = new StreamReader(memoryStream); + return await reader.ReadToEndAsync(); + } + } + + /// + public void SerializeAppCastToFile(AppCast appCast, string outputPath) + { + string json = SerializeAppCast(appCast); + File.WriteAllText(outputPath, json); + } + + /// + public async Task SerializeAppCastToFileAsync(AppCast appCast, string outputPath) + { + string json = await SerializeAppCastAsync(appCast); +#if NETFRAMEWORK || NETSTANDARD + await Utilities.WriteTextAsync(outputPath, json); +#else + await File.WriteAllTextAsync(outputPath, json); +#endif + } + } +} \ No newline at end of file diff --git a/src/NetSparkle/AppCastHandlers/XMLAppCastGenerator.cs b/src/NetSparkle/AppCastHandlers/XMLAppCastGenerator.cs new file mode 100644 index 00000000..393be709 --- /dev/null +++ b/src/NetSparkle/AppCastHandlers/XMLAppCastGenerator.cs @@ -0,0 +1,462 @@ +using NetSparkleUpdater; +using NetSparkleUpdater.Configurations; +using NetSparkleUpdater.Enums; +using NetSparkleUpdater.Interfaces; +using System; +using System.Globalization; +using System.IO; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Linq; + +namespace NetSparkleUpdater.AppCastHandlers +{ + /// + /// An XML-based app cast document downloader and handler + /// + public class XMLAppCastGenerator : IAppCastGenerator + { + private ILogger? _logWriter; + + /// + /// Sparkle XML namespace + /// + public static readonly XNamespace SparkleNamespace = "http://www.andymatuschak.org/xml-namespaces/sparkle"; + + /// + /// Default XML attribute name for ed25519 signatures + /// + public static readonly string Ed25519SignatureAttribute = "edSignature"; + + /// + /// Default XML attribute name for a signature for an unspecified algorithm + /// + public static readonly string SignatureAttribute = "signature"; + + /// + /// An app cast generator that reads/writes XML + /// + /// Optional for logging data + public XMLAppCastGenerator(ILogger? logger = null) + { + _logWriter = logger; + HumanReadableOutput = true; + OutputSignatureAttribute = SignatureAttribute; + } + + /// + /// Set to true to make serialized output human readable (newlines, indents) when written to a file. + /// Set to false to make this output not necessarily human readable. + /// Defaults to true. + /// + public bool HumanReadableOutput { get; set; } + + /// + /// Output attribute title for signatures. Defaults to + /// . + /// + public string OutputSignatureAttribute { get; set; } + + /// + public AppCast DeserializeAppCast(string appCastString) + { + var appCast = new AppCast(); + if (!string.IsNullOrWhiteSpace(appCastString)) + { + XDocument doc = XDocument.Parse(appCastString); + var rss = doc?.Element("rss"); + var channel = rss?.Element("channel"); + + appCast.Title = channel?.Element("title")?.Value ?? string.Empty; + appCast.Language = channel?.Element("language")?.Value ?? "en"; + appCast.Link = channel?.Element("link")?.Value ?? ""; + appCast.Description = channel?.Element("description")?.Value ?? "Most recent changes with links to updates"; + + var docItems = doc?.Descendants("item"); + if (docItems != null) + { + foreach (var item in docItems) + { + var currentItem = ReadAppCastItem(item); + _logWriter?.PrintMessage("Found an item in the app cast: version {0} ({1}) -- os = {2}", + currentItem.Version ?? "[Unknown version]", currentItem.ShortVersion ?? "[Unknown short version]", currentItem.OperatingSystem ?? "[Unknown operating system]"); + appCast.Items.Add(currentItem); + } + } + // sort versions in reverse order + appCast.Items.Sort((item1, item2) => -1 * item1.CompareTo(item2)); + } + return appCast; + } + + /// + public async Task DeserializeAppCastAsync(string appCastString) + { + return await Task.Run(() => DeserializeAppCast(appCastString)); + } + + /// + public AppCast DeserializeAppCastFromFile(string filePath) + { + return DeserializeAppCast(File.ReadAllText(filePath)); + } + + /// + public async Task DeserializeAppCastFromFileAsync(string filePath) + { +#if NETFRAMEWORK || NETSTANDARD + var data = await Utilities.ReadAllTextAsync(filePath); +#else + var data = await File.ReadAllTextAsync(filePath); +#endif + return await Task.Run(() => DeserializeAppCast(data)); + } + + // https://stackoverflow.com/a/955698/3938401 + private class Utf8StringWriter : StringWriter + { + public override Encoding Encoding + { + get { return Encoding.UTF8; } + } + } + + /// + /// Get default settings for writing XMl + /// + /// Default XML writing settings + protected XmlWriterSettings GetXmlWriterSettings(bool async = false) + { + return new XmlWriterSettings() + { + NewLineChars = Environment.NewLine, + Encoding = new UTF8Encoding(false), + Indent = HumanReadableOutput, + Async = async + }; + } + + /// + public string SerializeAppCast(AppCast appCast) + { + var doc = GenerateAppCastXml(appCast.Items, appCast.Title, appCast.Link, appCast.Description, appCast.Language); + var settings = GetXmlWriterSettings(); + using (TextWriter writer = new Utf8StringWriter()) + { + using (XmlWriter xmlWriter = XmlWriter.Create(writer, settings)) + { + doc.Save(writer); + } + return writer.ToString() ?? ""; + } + } + + /// + public async Task SerializeAppCastAsync(AppCast appCast) + { + var doc = GenerateAppCastXml(appCast.Items, appCast.Title, appCast.Link, appCast.Description, appCast.Language); + var settings = GetXmlWriterSettings(true); + using (TextWriter writer = new Utf8StringWriter()) + { + using (XmlWriter xmlWriter = XmlWriter.Create(writer, settings)) + { +#if NETFRAMEWORK || NETSTANDARD + await Task.Run(() => doc.Save(writer)); +#else + var cancelTokenSource = new CancellationTokenSource(); + var cancelToken = cancelTokenSource.Token; + await doc.SaveAsync(xmlWriter, cancelToken); +#endif + } + return writer.ToString() ?? ""; + } + } + + /// + public void SerializeAppCastToFile(AppCast appCast, string outputPath) + { + File.WriteAllText(outputPath, SerializeAppCast(appCast)); + } + + /// + public async Task SerializeAppCastToFileAsync(AppCast appCast, string outputPath) + { + string xml = await SerializeAppCastAsync(appCast); +#if NETFRAMEWORK || NETSTANDARD + await Utilities.WriteTextAsync(outputPath, xml); +#else + await File.WriteAllTextAsync(outputPath, xml); +#endif + } + + private const string _itemNode = "item"; + private const string _titleNode = "title"; + private const string _enclosureNode = "enclosure"; + private const string _releaseNotesLinkNode = "releaseNotesLink"; + private const string _descriptionNode = "description"; + private const string _versionAttribute = "version"; + private const string _shortVersionAttribute = "shortVersionString"; + private const string _dsaSignatureAttribute = "dsaSignature"; + private const string _criticalAttribute = "criticalUpdate"; + private const string _operatingSystemAttribute = "os"; + private const string _lengthAttribute = "length"; + private const string _typeAttribute = "type"; + private const string _urlAttribute = "url"; + private const string _pubDateNode = "pubDate"; + private const string _channelNode = "channel"; + + /// + /// Parse item Xml Node to AppCastItem + /// + /// The item XML node + /// AppCastItem from Xml Node + public AppCastItem ReadAppCastItem(XElement item) + { + var newAppCastItem = new AppCastItem() + { + UpdateSize = 0, + IsCriticalUpdate = false, + Title = item.Element(_titleNode)?.Value ?? string.Empty + }; + + //release notes + var releaseNotesElement = item.Element(SparkleNamespace + _releaseNotesLinkNode); + newAppCastItem.ReleaseNotesSignature = releaseNotesElement?.Attribute(SparkleNamespace + SignatureAttribute)?.Value ?? string.Empty; + if (newAppCastItem.ReleaseNotesSignature == string.Empty) + { + newAppCastItem.ReleaseNotesSignature = releaseNotesElement?.Attribute(SparkleNamespace + _dsaSignatureAttribute)?.Value ?? string.Empty; + } + if (newAppCastItem.ReleaseNotesSignature == string.Empty) + { + newAppCastItem.ReleaseNotesSignature = releaseNotesElement?.Attribute(SparkleNamespace + Ed25519SignatureAttribute)?.Value ?? string.Empty; + } + newAppCastItem.ReleaseNotesLink = releaseNotesElement?.Value.Trim() ?? string.Empty; + + //description + newAppCastItem.Description = item.Element(_descriptionNode)?.Value.Trim() ?? string.Empty; + + newAppCastItem.Version = item.Element(SparkleNamespace + _versionAttribute)?.Value ?? string.Empty; + newAppCastItem.ShortVersion = item.Element(SparkleNamespace + _shortVersionAttribute)?.Value ?? string.Empty; + bool isCritical = false; + string? critical = item.Element(SparkleNamespace + _criticalAttribute)?.Value ?? null; + if (critical != null) + { + isCritical = true; // if element exists at all in , it is a critical version + } + newAppCastItem.IsCriticalUpdate = isCritical; + newAppCastItem.Channel = item.Element(SparkleNamespace + _channelNode)?.Value; + + // process the data + var enclosureElement = item.Element(_enclosureNode) ?? item.Element(SparkleNamespace + _enclosureNode); + + // check enclosure for info if it wasn't in the overall item + if (string.IsNullOrWhiteSpace(newAppCastItem.Version)) + { + newAppCastItem.Version = enclosureElement?.Attribute(SparkleNamespace + _versionAttribute)?.Value ?? string.Empty; + } + if (string.IsNullOrWhiteSpace(newAppCastItem.ShortVersion)) + { + newAppCastItem.ShortVersion = enclosureElement?.Attribute(SparkleNamespace + _shortVersionAttribute)?.Value ?? string.Empty; + } + newAppCastItem.DownloadLink = enclosureElement?.Attribute(_urlAttribute)?.Value ?? string.Empty; + //if (!string.IsNullOrWhiteSpace(newAppCastItem.DownloadLink) && !newAppCastItem.DownloadLink.Contains("/")) + //{ + // // Download link contains only the filename -> complete with _castUrl + // if (castUrl == null && !string.IsNullOrWhiteSpace(castUrl)) + // { + // newAppCastItem.DownloadLink = newAppCastItem.DownloadLink; + // } + // else + // { + // newAppCastItem.DownloadLink = castUrl.Substring(0, castUrl.LastIndexOf('/') + 1) + newAppCastItem.DownloadLink; + // } + //} + + newAppCastItem.DownloadSignature = enclosureElement?.Attribute(SparkleNamespace + SignatureAttribute)?.Value ?? string.Empty; + if (newAppCastItem.DownloadSignature == string.Empty) + { + newAppCastItem.DownloadSignature = enclosureElement?.Attribute(SparkleNamespace + _dsaSignatureAttribute)?.Value ?? string.Empty; + } + if (newAppCastItem.DownloadSignature == string.Empty) + { + newAppCastItem.DownloadSignature = enclosureElement?.Attribute(SparkleNamespace + Ed25519SignatureAttribute)?.Value ?? string.Empty; + } + string length = enclosureElement?.Attribute(_lengthAttribute)?.Value ?? string.Empty; + if (length != null) + { + if (long.TryParse(length, out var size)) + { + newAppCastItem.UpdateSize = size; + } + else + { + newAppCastItem.UpdateSize = 0; + } + } + // also check to see if the enclosure portion is marked as critical + // if either the overall item or the enclosure is marked critical, this is a critical update + // for the enclosure, the critical status has to be one of "true", "1", or "yes" + critical = enclosureElement?.Attribute(SparkleNamespace + _criticalAttribute)?.Value ?? string.Empty; + if (critical != null && (critical == "true" || critical == "1" || critical == "yes")) + { + isCritical = true; + } + newAppCastItem.IsCriticalUpdate |= isCritical; + + var osAttribute = enclosureElement?.Attribute(SparkleNamespace + _operatingSystemAttribute); + if (!string.IsNullOrWhiteSpace(osAttribute?.Value)) + { + newAppCastItem.OperatingSystem = osAttribute?.Value; + } + var mimeTypeAttribute = enclosureElement?.Attribute(SparkleNamespace + _typeAttribute); + if (mimeTypeAttribute?.Value != null && !string.IsNullOrWhiteSpace(mimeTypeAttribute.Value)) + { + newAppCastItem.MIMEType = mimeTypeAttribute.Value; + } + + //pub date + var pubDateElement = item.Element(_pubDateNode); + if (pubDateElement != null) + { + // "ddd, dd MMM yyyy HH:mm:ss zzz" => Standard date format + // e.g. "Sat, 26 Oct 2019 22:05:11 -05:00" + // "ddd, dd MMM yyyy HH:mm:ss Z" => Check for MS AppCenter Sparkle date format which ends with GMT + // e.g. "Sat, 26 Oct 2019 22:05:11 GMT" + // "ddd, dd MMM yyyy HH:mm:ss" => Standard date format with no timezone (fallback) + // e.g. "Sat, 26 Oct 2019 22:05:11" + string[] formats = { "ddd, dd MMM yyyy HH:mm:ss zzz", "ddd, dd MMM yyyy HH:mm:ss Z", + "ddd, dd MMM yyyy HH:mm:ss", "ddd, dd MMM yyyy HH:mm:ss K" }; + string dt = pubDateElement.Value.Trim(); + if (DateTime.TryParseExact(dt, formats, System.Globalization.CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime dateValue)) + { + _logWriter?.PrintMessage("While parsing app cast item, converted '{0}' to {1}.", dt, dateValue); + newAppCastItem.PublicationDate = dateValue; + } + else + { + _logWriter?.PrintMessage("Cannot parse item's DateTime: {0}", dt); + } + } + + return newAppCastItem; + } + + /// + /// Create XML node from this instance of AppCastItem + /// + /// An XML node + public static XElement GetXElement(AppCastItem appCastItem, string signatureAttribute = "") + { + if (string.IsNullOrWhiteSpace(signatureAttribute)) + { + signatureAttribute = SignatureAttribute; + } + + var item = new XElement(_itemNode); + item.Add(new XElement(_titleNode) { Value = appCastItem.Title ?? "" }); + + if (!string.IsNullOrWhiteSpace(appCastItem.ReleaseNotesLink)) + { + var releaseNotes = new XElement(SparkleNamespace + _releaseNotesLinkNode) { Value = appCastItem.ReleaseNotesLink }; + if (!string.IsNullOrWhiteSpace(appCastItem.ReleaseNotesSignature)) + { + releaseNotes.Add(new XAttribute(SparkleNamespace + signatureAttribute, appCastItem.ReleaseNotesSignature)); + } + item.Add(releaseNotes); + } + + if (!string.IsNullOrWhiteSpace(appCastItem.Description)) + { + item.Add(new XElement(_descriptionNode) { Value = appCastItem.Description }); + } + + if (appCastItem.PublicationDate != DateTime.MinValue && appCastItem.PublicationDate != DateTime.MaxValue) + { + item.Add(new XElement(_pubDateNode) { Value = appCastItem.PublicationDate.ToString("ddd, dd MMM yyyy HH:mm:ss zzz", System.Globalization.CultureInfo.InvariantCulture) }); + } + + if (!string.IsNullOrWhiteSpace(appCastItem.DownloadLink)) + { + var enclosure = new XElement(_enclosureNode); + enclosure.Add(new XAttribute(_urlAttribute, appCastItem.DownloadLink)); + if (!string.IsNullOrWhiteSpace(appCastItem.Version)) + { + enclosure.Add(new XAttribute(SparkleNamespace + _versionAttribute, appCastItem.Version)); + } + + if (!string.IsNullOrWhiteSpace(appCastItem.ShortVersion)) + { + enclosure.Add(new XAttribute(SparkleNamespace + _shortVersionAttribute, appCastItem.ShortVersion)); + } + + enclosure.Add(new XAttribute(_lengthAttribute, appCastItem.UpdateSize)); + enclosure.Add(new XAttribute(SparkleNamespace + _operatingSystemAttribute, appCastItem.OperatingSystem ?? AppCastItem.DefaultOperatingSystem)); + enclosure.Add(new XAttribute(_typeAttribute, appCastItem.MIMEType ?? AppCastItem.DefaultMIMEType)); + enclosure.Add(new XAttribute(SparkleNamespace + _criticalAttribute, appCastItem.IsCriticalUpdate)); + + // enhance compatibility with Sparkle app casts (#275) + item.Add(new XElement(SparkleNamespace + _versionAttribute, appCastItem.Version)); + item.Add(new XElement(SparkleNamespace + _shortVersionAttribute, appCastItem.ShortVersion)); + if (!string.IsNullOrWhiteSpace(appCastItem.Channel)) + { + item.Add(new XElement(SparkleNamespace + _channelNode) { Value = appCastItem.Channel }); + } + if (appCastItem.IsCriticalUpdate) + { + item.Add(new XElement(SparkleNamespace + _criticalAttribute)); + } + + if (!string.IsNullOrWhiteSpace(appCastItem.DownloadSignature)) + { + enclosure.Add(new XAttribute(SparkleNamespace + signatureAttribute, appCastItem.DownloadSignature)); + } + item.Add(enclosure); + } + return item; + } + + /// + /// Create app cast XML document as an object + /// + /// The list to include in the output file + /// Application title/title for the app cast + /// Link to the where the app cast is going to be downloaded + /// Text that describes the app cast (e.g. what it provides) + /// Language of the app cast file + /// An xml document that describes the list of passed in update items + public XDocument GenerateAppCastXml(List appCastItems, string? title, string? link = "", string? description = "", string? language = "en") + { + var channel = new XElement("channel"); + channel.Add(new XElement("title", title)); + + if (!string.IsNullOrWhiteSpace(link)) + { + channel.Add(new XElement("link", link)); + } + + if (!string.IsNullOrWhiteSpace(description)) + { + channel.Add(new XElement("description", description)); + } + + channel.Add(new XElement("language", language)); + + foreach (var item in appCastItems) + { + channel.Add(GetXElement(item, OutputSignatureAttribute)); + } + + var document = new XDocument( + new XElement("rss", new XAttribute("version", "2.0"), new XAttribute(XNamespace.Xmlns + "sparkle", SparkleNamespace), + channel) + ); + + return document; + } + } +} \ No newline at end of file diff --git a/src/NetSparkle/AppCastItem.cs b/src/NetSparkle/AppCastItem.cs index 93ce635f..ea468442 100644 --- a/src/NetSparkle/AppCastItem.cs +++ b/src/NetSparkle/AppCastItem.cs @@ -3,6 +3,8 @@ using System; using System.Globalization; using System.Xml.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; namespace NetSparkleUpdater { @@ -16,31 +18,35 @@ public class AppCastItem : IComparable private SemVerLike? _semVerLikeCache; private string? _version; + /// + /// Default MIME type for an App Cast Item (application/octet-stream) + /// + public static string DefaultMIMEType = "application/octet-stream"; + /// + /// Default operating system for an App Cast Item (windows) + /// + public static string DefaultOperatingSystem = "windows"; + /// /// Default constructor for an app cast item /// public AppCastItem() { - MIMEType = _defaultType; + MIMEType = DefaultMIMEType; + OperatingSystem = DefaultOperatingSystem; } - /// - /// The application name - /// - public string? AppName { get; set; } - /// - /// The installed version - /// - public string? AppVersionInstalled { get; set; } /// /// The item title /// + [JsonPropertyName("title")] public string? Title { get; set; } /// /// The available version -- this technically can be null if file parsing fails, /// but since NetSparkleUpdater runs off of version information, doing this is /// not a great way to use this library. /// + [JsonPropertyName("version")] public string? Version { get => _version; @@ -49,6 +55,7 @@ public string? Version /// /// The available version as a SemVerLike object (handles things like 1.2-alpha1) /// + [JsonIgnore] public SemVerLike SemVerLikeVersion { get @@ -63,44 +70,64 @@ public SemVerLike SemVerLikeVersion /// /// Shortened version /// + [JsonPropertyName("short_version")] public string? ShortVersion { get; set; } /// /// The release notes link /// + [JsonPropertyName("release_notes_link")] public string? ReleaseNotesLink { get; set; } /// /// The signature of the Release Notes file /// + [JsonPropertyName("release_notes_signature")] public string? ReleaseNotesSignature { get; set; } /// /// The embedded description /// + [JsonPropertyName("description")] public string? Description { get; set; } /// /// The download link /// + [JsonPropertyName("url")] public string? DownloadLink { get; set; } /// /// The signature of the download file /// + [JsonPropertyName("signature")] public string? DownloadSignature { get; set; } /// /// Date item was published /// + [JsonPropertyName("publication_date")] public DateTime PublicationDate { get; set; } /// /// Whether the update was marked critical or not via sparkle:critical /// + [JsonPropertyName("is_critical")] public bool IsCriticalUpdate { get; set; } /// /// Length of update set via sparkle:length (usually the # of bytes of the update) /// + [JsonPropertyName("size")] public long UpdateSize { get; set; } - /// /// Operating system that this update applies to /// - public string? OperatingSystemString { get; set; } + [JsonPropertyName("os")] + public string? OperatingSystem { get; set; } + /// + /// Channel for this app cast item. A channel can also be set in + /// the version property via semver, and when using + /// , both the channel and version data + /// will be used when filtering channels for this app cast item. + /// Often a single word like "beta", "gamma", "rc", etc. Note that this can be + /// an empty string, but "stable" or "release" will be treated as a special + /// channel, NOT as a "normal" channel for all users. + /// + [JsonPropertyName("channel")] + public string? Channel { get; set; } /// /// True if this update is a windows update; false otherwise. @@ -108,13 +135,14 @@ public SemVerLike SemVerLikeVersion /// checked with a case-insensitive check). If not specified, /// assumed to be a Windows update. /// + [JsonIgnore] public bool IsWindowsUpdate { get { - if (OperatingSystemString != null) + if (OperatingSystem != null) { - var lowercasedOS = OperatingSystemString.ToLower(); + var lowercasedOS = OperatingSystem.ToLower(); if (lowercasedOS == "win" || lowercasedOS == "windows") { return true; @@ -131,13 +159,14 @@ public bool IsWindowsUpdate /// checked with a case-insensitive check). If not specified, /// assumed to be a Windows update. /// + [JsonIgnore] public bool IsMacOSUpdate { get { - if (OperatingSystemString != null) + if (OperatingSystem != null) { - var lowercasedOS = OperatingSystemString.ToLower(); + var lowercasedOS = OperatingSystem.ToLower(); if (lowercasedOS == "mac" || lowercasedOS == "macos" || lowercasedOS == "osx") { return true; @@ -153,13 +182,14 @@ public bool IsMacOSUpdate /// checked with a case-insensitive check). If not specified, /// assumed to be a Linux update. /// + [JsonIgnore] public bool IsLinuxUpdate { get { - if (OperatingSystemString != null) + if (OperatingSystem != null) { - var lowercasedOS = OperatingSystemString.ToLower(); + var lowercasedOS = OperatingSystem.ToLower(); if (lowercasedOS == "linux") { return true; @@ -172,220 +202,14 @@ public bool IsLinuxUpdate /// /// MIME type for file as specified in the closure tag. Defaults to "application/octet-stream". /// + [JsonPropertyName("type")] public string MIMEType { get; set; } - #region XML - - private const string _itemNode = "item"; - private const string _titleNode = "title"; - private const string _enclosureNode = "enclosure"; - private const string _releaseNotesLinkNode = "releaseNotesLink"; - private const string _descriptionNode = "description"; - private const string _versionAttribute = "version"; - private const string _shortVersionAttribute = "shortVersionString"; - private const string _dsaSignatureAttribute = "dsaSignature"; - private const string _ed25519SignatureAttribute = "edSignature"; - private const string _signatureAttribute = "signature"; - private const string _criticalAttribute = "criticalUpdate"; - private const string _operatingSystemAttribute = "os"; - private const string _lengthAttribute = "length"; - private const string _typeAttribute = "type"; - private const string _urlAttribute = "url"; - private const string _pubDateNode = "pubDate"; - private const string _defaultOperatingSystem = "windows"; - private const string _defaultType = "application/octet-stream"; - - /// - /// Parse item Xml Node to AppCastItem - /// - /// Currently installed version - /// Application name - /// The url of the appcast - /// The item XML node - /// logwriter instance - /// AppCastItem from Xml Node - public static AppCastItem Parse(string? installedVersion, string? applicationName, string? castUrl, XElement item, ILogger? logWriter) - { - var newAppCastItem = new AppCastItem() - { - AppVersionInstalled = installedVersion, - AppName = applicationName, - UpdateSize = 0, - IsCriticalUpdate = false, - OperatingSystemString = _defaultOperatingSystem, - MIMEType = _defaultType - }; - - //title - newAppCastItem.Title = item.Element(_titleNode)?.Value ?? string.Empty; - - //release notes - var releaseNotesElement = item.Element(XMLAppCast.SparkleNamespace + _releaseNotesLinkNode); - newAppCastItem.ReleaseNotesSignature = releaseNotesElement?.Attribute(XMLAppCast.SparkleNamespace + _signatureAttribute)?.Value ?? string.Empty; - if (newAppCastItem.ReleaseNotesSignature == string.Empty) - { - newAppCastItem.ReleaseNotesSignature = releaseNotesElement?.Attribute(XMLAppCast.SparkleNamespace + _dsaSignatureAttribute)?.Value ?? string.Empty; - } - if (newAppCastItem.ReleaseNotesSignature == string.Empty) - { - newAppCastItem.ReleaseNotesSignature = releaseNotesElement?.Attribute(XMLAppCast.SparkleNamespace + _ed25519SignatureAttribute)?.Value ?? string.Empty; - } - newAppCastItem.ReleaseNotesLink = releaseNotesElement?.Value.Trim() ?? string.Empty; - - //description - newAppCastItem.Description = item.Element(_descriptionNode)?.Value.Trim() ?? string.Empty; - - //enclosure - var enclosureElement = item.Element(_enclosureNode) ?? item.Element(XMLAppCast.SparkleNamespace + _enclosureNode); - - newAppCastItem.Version = enclosureElement?.Attribute(XMLAppCast.SparkleNamespace + _versionAttribute)?.Value ?? string.Empty; - newAppCastItem.ShortVersion = enclosureElement?.Attribute(XMLAppCast.SparkleNamespace + _shortVersionAttribute)?.Value ?? string.Empty; - newAppCastItem.DownloadLink = enclosureElement?.Attribute(_urlAttribute)?.Value ?? string.Empty; - if (!string.IsNullOrWhiteSpace(newAppCastItem.DownloadLink) && !newAppCastItem.DownloadLink.Contains("/")) - { - // Download link contains only the filename -> complete with _castUrl - if (castUrl == null) - { - newAppCastItem.DownloadLink = newAppCastItem.DownloadLink; - } - else - { - newAppCastItem.DownloadLink = castUrl.Substring(0, castUrl.LastIndexOf('/') + 1) + newAppCastItem.DownloadLink; - } - } - - newAppCastItem.DownloadSignature = enclosureElement?.Attribute(XMLAppCast.SparkleNamespace + _signatureAttribute)?.Value ?? string.Empty; - if (newAppCastItem.DownloadSignature == string.Empty) - { - newAppCastItem.DownloadSignature = enclosureElement?.Attribute(XMLAppCast.SparkleNamespace + _dsaSignatureAttribute)?.Value ?? string.Empty; - } - if (newAppCastItem.DownloadSignature == string.Empty) - { - newAppCastItem.DownloadSignature = enclosureElement?.Attribute(XMLAppCast.SparkleNamespace + _ed25519SignatureAttribute)?.Value ?? string.Empty; - } - string length = enclosureElement?.Attribute(_lengthAttribute)?.Value ?? string.Empty; - if (length != null) - { - if (long.TryParse(length, out var size)) - { - newAppCastItem.UpdateSize = size; - } - else - { - newAppCastItem.UpdateSize = 0; - } - } - bool isCritical = false; - string critical = enclosureElement?.Attribute(XMLAppCast.SparkleNamespace + _criticalAttribute)?.Value ?? string.Empty; - if (critical != null && critical == "true" || critical == "1") - { - isCritical = true; - } - newAppCastItem.IsCriticalUpdate = isCritical; - - newAppCastItem.OperatingSystemString = enclosureElement?.Attribute(XMLAppCast.SparkleNamespace + _operatingSystemAttribute)?.Value ?? _defaultOperatingSystem; - - newAppCastItem.MIMEType = enclosureElement?.Attribute(_typeAttribute)?.Value ?? _defaultType; - - //pub date - var pubDateElement = item.Element(_pubDateNode); - if (pubDateElement != null) - { - // "ddd, dd MMM yyyy HH:mm:ss zzz" => Standard date format - // e.g. "Sat, 26 Oct 2019 22:05:11 -05:00" - // "ddd, dd MMM yyyy HH:mm:ss Z" => Check for MS AppCenter Sparkle date format which ends with GMT - // e.g. "Sat, 26 Oct 2019 22:05:11 GMT" - // "ddd, dd MMM yyyy HH:mm:ss" => Standard date format with no timezone (fallback) - // e.g. "Sat, 26 Oct 2019 22:05:11" - string[] formats = { "ddd, dd MMM yyyy HH:mm:ss zzz", "ddd, dd MMM yyyy HH:mm:ss Z", - "ddd, dd MMM yyyy HH:mm:ss", "ddd, dd MMM yyyy HH:mm:ss K" }; - string dt = pubDateElement.Value.Trim(); - if (DateTime.TryParseExact(dt, formats, System.Globalization.CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime dateValue)) - { - logWriter?.PrintMessage("While parsing app cast item, converted '{0}' to {1}.", dt, dateValue); - newAppCastItem.PublicationDate = dateValue; - } - else - { - logWriter?.PrintMessage("Cannot parse item's DateTime: {0}", dt); - } - } - - return newAppCastItem; - } - - /// - /// Create Xml node from this instance of AppCastItem - /// - /// An XML node - public XElement GetXElement() - { - var item = new XElement(_itemNode); - - item.Add(new XElement(_titleNode) { Value = Title ?? "" }); - - if (!string.IsNullOrWhiteSpace(ReleaseNotesLink)) - { - var releaseNotes = new XElement(XMLAppCast.SparkleNamespace + _releaseNotesLinkNode) { Value = ReleaseNotesLink }; - if (!string.IsNullOrWhiteSpace(ReleaseNotesSignature)) - { - releaseNotes.Add(new XAttribute(XMLAppCast.SparkleNamespace + _signatureAttribute, ReleaseNotesSignature)); - } - item.Add(releaseNotes); - } - - if (!string.IsNullOrWhiteSpace(Description)) - { - item.Add(new XElement(_descriptionNode) { Value = Description }); - } - - if (PublicationDate != DateTime.MinValue && PublicationDate != DateTime.MaxValue) - { - item.Add(new XElement(_pubDateNode) { Value = PublicationDate.ToString("ddd, dd MMM yyyy HH:mm:ss zzz", System.Globalization.CultureInfo.InvariantCulture) }); - } - - if (!string.IsNullOrWhiteSpace(DownloadLink)) - { - var enclosure = new XElement(_enclosureNode); - enclosure.Add(new XAttribute(_urlAttribute, DownloadLink)); - if (Version != null) - { - enclosure.Add(new XAttribute(XMLAppCast.SparkleNamespace + _versionAttribute, Version)); - } - - if (!string.IsNullOrWhiteSpace(ShortVersion)) - { - enclosure.Add(new XAttribute(XMLAppCast.SparkleNamespace + _shortVersionAttribute, ShortVersion)); - } - - enclosure.Add(new XAttribute(_lengthAttribute, UpdateSize)); - enclosure.Add(new XAttribute(XMLAppCast.SparkleNamespace + _operatingSystemAttribute, OperatingSystemString ?? _defaultOperatingSystem)); - enclosure.Add(new XAttribute(_typeAttribute, MIMEType ?? _defaultType)); - enclosure.Add(new XAttribute(XMLAppCast.SparkleNamespace + _criticalAttribute, IsCriticalUpdate)); - - // enhance compatibility with Sparkle app casts (#275) - item.Add(new XElement(XMLAppCast.SparkleNamespace + _versionAttribute, Version)); - item.Add(new XElement(XMLAppCast.SparkleNamespace + _shortVersionAttribute, ShortVersion)); - if (IsCriticalUpdate) - { - item.Add(new XElement(XMLAppCast.SparkleNamespace + _criticalAttribute)); - } - - if (!string.IsNullOrWhiteSpace(DownloadSignature)) - { - enclosure.Add(new XAttribute(XMLAppCast.SparkleNamespace + _signatureAttribute, DownloadSignature)); - } - item.Add(enclosure); - } - return item; - } - - #endregion - #region IComparable Members /// - /// Compares this version to the version of another + /// Compares this version to the version of another . + /// If versions are the same, uses channel, then title to sort. /// /// the other instance /// -1, 0, 1 if this instance is less than, equal to, or greater than the @@ -395,14 +219,20 @@ public int CompareTo(AppCastItem? other) { return 1; } - if ((string.IsNullOrWhiteSpace(Version) && string.IsNullOrWhiteSpace(other.Version)) || - (Version != null && !Version.Contains(".")) || (other.Version != null && !other.Version.Contains("."))) + // if neither one has version information, compare them via their titles. + if (string.IsNullOrWhiteSpace(Version) && string.IsNullOrWhiteSpace(other.Version)) { - return 0; + return Title?.CompareTo(other.Title) ?? 0; } SemVerLike v1 = SemVerLike.Parse(Version); SemVerLike v2 = SemVerLike.Parse(other.Version); - return v1.CompareTo(v2); + var versionCompare = v1.CompareTo(v2); + if (versionCompare == 0) + { + var channelCompare = Channel?.CompareTo(other.Channel) ?? 0; + return channelCompare == 0 ? Title?.CompareTo(other.Title) ?? 0 : channelCompare; + } + return versionCompare; } /// @@ -426,7 +256,7 @@ public override bool Equals(object? obj) { return true; } - return AppName != null && AppName.Equals(item.AppName) && CompareTo(item) == 0; + return CompareTo(item) == 0; } /// @@ -435,7 +265,7 @@ public override bool Equals(object? obj) /// the integer haschode of this app cast item public override int GetHashCode() { - return (Version?.GetHashCode() ?? 0) * 17 + (AppName?.GetHashCode() ?? 0); + return (Version?.GetHashCode() ?? 0) * 17 + (Title?.GetHashCode() ?? 0); } /// diff --git a/src/NetSparkle/Downloaders/LocalFileAppCastDownloader.cs b/src/NetSparkle/Downloaders/LocalFileAppCastDownloader.cs index 21bc6ca8..d0e37351 100644 --- a/src/NetSparkle/Downloaders/LocalFileAppCastDownloader.cs +++ b/src/NetSparkle/Downloaders/LocalFileAppCastDownloader.cs @@ -41,6 +41,16 @@ public string DownloadAndGetAppCastData(string url) } return File.ReadAllText(url); } + + /// + public async Task DownloadAndGetAppCastDataAsync(string url) + { + #if NETFRAMEWORK || NETSTANDARD + return await Utilities.ReadAllTextAsync(UseLocalUriPath ? new Uri(url).LocalPath : url); + #else + return await File.ReadAllTextAsync(UseLocalUriPath ? new Uri(url).LocalPath : url); + #endif + } /// public Encoding GetAppCastEncoding() diff --git a/src/NetSparkle/Enums/FilterItemResult.cs b/src/NetSparkle/Enums/FilterItemResult.cs index efaa4397..aaad4f37 100644 --- a/src/NetSparkle/Enums/FilterItemResult.cs +++ b/src/NetSparkle/Enums/FilterItemResult.cs @@ -3,30 +3,32 @@ namespace NetSparkleUpdater.Enums { /// - /// Provides the return values for the GetAvailableUpdates call on the IAppCastHandler. When an appcast is downloaded, - /// the IAppCastHandler will work out which AppCastItem instances match criteria for an update. + /// Provides the return values for the GetAvailableUpdates call for the AppCastHelper. + /// When an appcast is downloaded, the AppCastHelper will work out which + /// instances match some criteria for a possible update. /// - /// public enum FilterItemResult { /// - /// Indicates that the AppCastItem is a validate candidate for installation. + /// Indicates that the is a validate candidate for installation. /// Valid = 0, /// - /// The AppCastItem is for a different operating system than this one, and must be ignored. + /// The is for a different operating system + /// than this one, and must be ignored. /// NotThisPlatform = 1, /// - /// The version indicated by the AppCastItem is less than or equal to the currently installed/running version. + /// The version indicated by the is less than + /// or equal to the currently installed/running version. /// VersionIsOlderThanCurrent = 2, /// - /// A signature is required to validate the item - but it's missing from the AppCastItem + /// A signature is required to validate the item - but it's missing from the /// SignatureIsMissing = 3, /// - /// Some other issue is going on with this AppCastItem that causes us not to want to use it. + /// Some other issue is going on with this that causes us not to want to use it. /// Use this FilterItemResult if it doens't fit into one of the other categories. /// SomeOtherProblem = 4, diff --git a/src/NetSparkle/Interfaces/IAppCastDataDownloader.cs b/src/NetSparkle/Interfaces/IAppCastDataDownloader.cs index 4e7ce5c4..6a4c9d8e 100644 --- a/src/NetSparkle/Interfaces/IAppCastDataDownloader.cs +++ b/src/NetSparkle/Interfaces/IAppCastDataDownloader.cs @@ -1,4 +1,5 @@ using System.Text; +using System.Threading.Tasks; namespace NetSparkleUpdater.Interfaces { @@ -10,6 +11,7 @@ namespace NetSparkleUpdater.Interfaces public interface IAppCastDataDownloader { /// + /// Download a string of data at the given URL. /// Used for both downloading app cast and the app cast's .signature file. /// Note that you must handle your own exceptions if they occur. /// Otherwise, will act as though the appcast @@ -19,6 +21,17 @@ public interface IAppCastDataDownloader /// The app cast data encoded as a string string DownloadAndGetAppCastData(string url); + /// + /// Async download a string of data at the given URL. + /// Used for both downloading app cast and the app cast's .signature file. + /// Note that you must handle your own exceptions if they occur. + /// Otherwise, will act as though the appcast + /// failed to download. + /// + /// non-null string URL for the place where the app cast can be downloaded + /// The app cast data encoded as a string + Task DownloadAndGetAppCastDataAsync(string url); + /// /// Get the string encoding (e.g. UTF8 or ASCII) of the /// app cast file so that it can be converted to bytes. diff --git a/src/NetSparkle/Interfaces/IAppCastFilter.cs b/src/NetSparkle/Interfaces/IAppCastFilter.cs index 823334a4..a23ce3cc 100644 --- a/src/NetSparkle/Interfaces/IAppCastFilter.cs +++ b/src/NetSparkle/Interfaces/IAppCastFilter.cs @@ -12,10 +12,13 @@ namespace NetSparkleUpdater.Interfaces public interface IAppCastFilter { /// - /// Filter AppCast with SemVerLike version specification. + /// Filter AppCast with SemVerLike version specification. NOTE: When you use this interface + /// with , you must filter out old versions of + /// yourself if you want that to happen! In other words, skips this step + /// when there is an implementation available. /// /// - /// Implementor has responsibility to both order the versions in the app cast + /// Implementor has responsibility to both order the versions in the app cast if desired /// (put the ones you want in order starting at index 0) and filter out items you don't want at all. /// /// Consider these use cases: diff --git a/src/NetSparkle/Interfaces/IAppCastGenerator.cs b/src/NetSparkle/Interfaces/IAppCastGenerator.cs new file mode 100644 index 00000000..8b30b93e --- /dev/null +++ b/src/NetSparkle/Interfaces/IAppCastGenerator.cs @@ -0,0 +1,90 @@ +using NetSparkleUpdater; +using NetSparkleUpdater.Configurations; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace NetSparkleUpdater.Interfaces +{ + /// + /// Interface used by objects that serialize and deserialize + /// objects to/from strings/files. + /// + /// Implement this interface if you would like to use a custom serialize/deserialize + /// method for your app cast that isn't yet built into NetSparkle. + /// + public interface IAppCastGenerator + { + /// + /// Deserialize the app cast string into a list of objects. + /// When complete, the list should contain the parsed information + /// as objects that are sorted in reverse order like so: + /// appCast.Items.Sort((item1, item2) => -1 * item1.CompareTo(item2)); + /// + /// the non-null app cast + AppCast DeserializeAppCast(string appCastString); + + /// + /// Deserialize the app cast string asynchronously into a list of objects. + /// When complete, the list should contain the parsed information + /// as objects that are sorted in reverse order like so: + /// appCast.Items.Sort((item1, item2) => -1 * item1.CompareTo(item2)); + /// + /// the non-null app cast + Task DeserializeAppCastAsync(string appCastString); + + /// + /// Deserialize the app cast from a file at the given path + /// into a list of objects. + /// When complete, the list should contain the parsed information + /// as objects that are sorted in reverse order like so: + /// appCast.Items.Sort((item1, item2) => -1 * item1.CompareTo(item2)); + /// + /// Path to the file on disk to deserialize + AppCast DeserializeAppCastFromFile(string filePath); + + /// + /// Deserialize the app cast from a file at the given path asynchronously + /// into a list of objects. + /// When complete, the list should contain the parsed information + /// as objects that are sorted in reverse order like so: + /// appCast.Items.Sort((item1, item2) => -1 * item1.CompareTo(item2)); + /// + /// Path to the file on disk to deserialize + Task DeserializeAppCastFromFileAsync(string filePath); + + /// + /// Serialize the given to a string. + /// objects should have at least their version and download link set. + /// + /// to serialize + /// The string representation of the given + string SerializeAppCast(AppCast appCast); + + /// + /// Serialize the given to a string asyncronously. + /// objects should have at least their version and download link set. + /// + /// to serialize + /// The string representation of the given + Task SerializeAppCastAsync(AppCast appCast); + + /// + /// Serialize the given to a file. Can overwrite + /// old file, at any, at the given path. + /// objects should have at least their version and download link set. + /// + /// to serialize + /// Output path for data. + void SerializeAppCastToFile(AppCast appCast, string outputPath); + + /// + /// Serialize the given to a file asyncronously. Can overwrite + /// old file, at any, at the given path. + /// objects should have at least their version and download link set. + /// + /// to serialize + /// Output path for data. + /// The that can be await'ed for this operation + Task SerializeAppCastToFileAsync(AppCast appCast, string outputPath); + } +} \ No newline at end of file diff --git a/src/NetSparkle/Interfaces/IAppCastHandler.cs b/src/NetSparkle/Interfaces/IAppCastHandler.cs deleted file mode 100644 index 96be6c11..00000000 --- a/src/NetSparkle/Interfaces/IAppCastHandler.cs +++ /dev/null @@ -1,49 +0,0 @@ -using NetSparkleUpdater.Configurations; -using System.Collections.Generic; - -namespace NetSparkleUpdater.Interfaces -{ - /// - /// Interface used by objects that initiate a download process - /// for an app cast, perform any needed signature verification on - /// the app cast, and parse the app cast's items into a list of - /// . - /// Implement this interface if you would like to use a custom parsing - /// method for your app cast that isn't yet built into NetSparkle. - /// - public interface IAppCastHandler - { - /// - /// Setup the app cast handler info for downloading and parsing app cast information - /// - /// downloader that will manage the app cast download - /// (provided by via the - /// property. - /// full URL to the app cast file - /// configuration for handling update intervals/checks - /// (user skipped versions, etc.) - /// Object to check signatures of app cast information - /// object that you can utilize to do any necessary logging - void SetupAppCastHandler(IAppCastDataDownloader dataDownloader, string castUrl, Configuration config, - ISignatureVerifier? signatureVerifier, ILogger? logWriter = null); - - /// - /// Download the app cast file via the - /// object and parse the downloaded information. - /// If this function is successful, will call - /// to get the information. - /// Note that you must handle your own exceptions if they occur. Otherwise, - /// will act as though the appcast failed to download. - /// - /// true if downloading and parsing succeeded; false otherwise - bool DownloadAndParse(); - - /// - /// Retrieve the available updates from the app cast. - /// This should be called after has - /// successfully completed. - /// - /// a list of updates. Can be empty if no updates are available. - List GetAvailableUpdates(); - } -} diff --git a/src/NetSparkle/NetSparkle.csproj b/src/NetSparkle/NetSparkle.csproj index 51084901..a5a0976c 100644 --- a/src/NetSparkle/NetSparkle.csproj +++ b/src/NetSparkle/NetSparkle.csproj @@ -100,7 +100,7 @@ - + diff --git a/src/NetSparkle/SourceGenerationContext.cs b/src/NetSparkle/SourceGenerationContext.cs index 5fd04ebe..0d441d5f 100644 --- a/src/NetSparkle/SourceGenerationContext.cs +++ b/src/NetSparkle/SourceGenerationContext.cs @@ -1,10 +1,14 @@ +using NetSparkleUpdater.AppCastHandlers; using NetSparkleUpdater.Configurations; using System.Text.Json.Serialization; namespace NetSparkleUpdater { #if !NETFRAMEWORK && !NETSTANDARD + [JsonSerializable(typeof(AppCast))] + [JsonSerializable(typeof(AppCastItem))] [JsonSerializable(typeof(SavedConfigurationData))] + [JsonSerializable(typeof(SemVerLike))] internal partial class SourceGenerationContext : JsonSerializerContext { } #endif } diff --git a/src/NetSparkle/SparkleUpdater.cs b/src/NetSparkle/SparkleUpdater.cs index aba9a37a..3756a8d4 100644 --- a/src/NetSparkle/SparkleUpdater.cs +++ b/src/NetSparkle/SparkleUpdater.cs @@ -69,7 +69,9 @@ public partial class SparkleUpdater : IDisposable private string? _restartExecutableName; private string? _restartExecutablePath; - private IAppCastHandler? _appCastHandler; + private AppCastHelper? _appCastHelper; + private IAppCastGenerator? _appCastGenerator; + private IUpdateDownloader? _updateDownloader; /// /// The progress window is shown on a separate thread. @@ -429,7 +431,6 @@ public bool UpdateMarkedCritical } } - private IUpdateDownloader? _updateDownloader; /// /// The object responsable for downloading update files for your application /// @@ -456,17 +457,34 @@ public IUpdateDownloader UpdateDownloader /// The object responsible for parsing app cast information and checking to /// see if any updates are available in a given app cast /// - public IAppCastHandler AppCastHandler + public AppCastHelper AppCastHelper { get { - if (_appCastHandler == null) + if (_appCastHelper == null) { - _appCastHandler = new XMLAppCast(); + _appCastHelper = new AppCastHelper(); } - return _appCastHandler; + return _appCastHelper; } - set => _appCastHandler = value; + set => _appCastHelper = value; + } + + /// + /// Object responsible for serializing and deserializing + /// objects after data has been downloaded. + /// + public IAppCastGenerator AppCastGenerator + { + get + { + if (_appCastGenerator == null) + { + _appCastGenerator = new XMLAppCastGenerator(LogWriter); + } + return _appCastGenerator; + } + set => _appCastGenerator = value; } /// @@ -526,6 +544,16 @@ public Process? InstallerProcess get => _installerProcess; } + /// + /// A cache / copy of the most recently downloaded . + /// Set after parsing an app cast that was downloaded from online. + /// This property only for convenience's sake. + /// Use if you aren't storing the app cast downloaded data yourself somewhere. + /// Will be wiped/reset/changed after downloading a new app cast, so be careful using this + /// if you aren't saving the data yourself somewhere. + /// + public AppCast? AppCastCache { get; set; } + #endregion /// @@ -702,23 +730,20 @@ protected async Task GetUpdateStatus(Configuration config, bool igno { AppCastDataDownloader = new WebRequestAppCastDataDownloader(LogWriter); } - if (AppCastHandler == null) - { - AppCastHandler = new XMLAppCast(); - } - AppCastHandler.SetupAppCastHandler(AppCastDataDownloader, AppCastUrl, config, SignatureVerifier, LogWriter); + AppCastHelper.SetupAppCastHelper(AppCastDataDownloader, AppCastUrl, + config.InstalledVersion, SignatureVerifier, LogWriter); // check if any updates are available try { - await Task.Factory.StartNew(() => + LogWriter?.PrintMessage("About to start downloading the app cast..."); + var appCastStr = await AppCastHelper.DownloadAppCast(); + if (appCastStr != null && !string.IsNullOrWhiteSpace(appCastStr)) { - LogWriter?.PrintMessage("About to start downloading the app cast..."); - if (AppCastHandler.DownloadAndParse()) - { - LogWriter?.PrintMessage("App cast successfully downloaded and parsed. Getting available updates..."); - updates = AppCastHandler.GetAvailableUpdates(); - } - }); + LogWriter?.PrintMessage("App cast successfully downloaded. Parsing..."); + var appCast = AppCastCache = await AppCastGenerator.DeserializeAppCastAsync(appCastStr); + LogWriter?.PrintMessage("App cast parsed; getting available updates..."); + updates = AppCastHelper.FilterUpdates(appCast.Items); + } } catch (Exception e) { diff --git a/src/NetSparkle/Utilities.cs b/src/NetSparkle/Utilities.cs index d517d678..b8d246f3 100644 --- a/src/NetSparkle/Utilities.cs +++ b/src/NetSparkle/Utilities.cs @@ -3,6 +3,8 @@ using System.IO; using System.Runtime.InteropServices; using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; namespace NetSparkleUpdater { @@ -180,5 +182,68 @@ public static bool IsSignatureNeeded(SecurityMode securityMode, bool doesKeyInfo } return false; } + + /// + /// Read all text from file asynchronously (this method is a fill-in for + /// .NET Framework and .NET Standard) + /// From: https://stackoverflow.com/a/64860277/3938401 + /// + /// path to file to read + /// file data + /// + /// + public static async Task ReadAllTextAsync(string path) + { + switch (path) + { + case "": throw new ArgumentException("Empty path name is not legal", nameof(path)); + case null: throw new ArgumentNullException(nameof(path)); + } + + using var sourceStream = new FileStream(path, FileMode.Open, + FileAccess.Read, FileShare.Read, + bufferSize: 4096, + useAsync: true); + using var streamReader = new StreamReader(sourceStream, Encoding.UTF8, + detectEncodingFromByteOrderMarks: true); + // detectEncodingFromByteOrderMarks allows you to handle files with BOM correctly. + // Otherwise you may get chinese characters even when your text does not contain any + + return await streamReader.ReadToEndAsync(); + } + + /// + /// Write text asynchronously (this method is a fill-in for + /// .NET Framework and .NET Standard) + /// https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/using-async-for-file-access + /// + /// Location to write text + /// Text to write + /// that you can await for the completion of this function + public static async Task WriteTextAsync(string filePath, string text) + { + byte[] encodedText = Encoding.UTF8.GetBytes(text); + + using (var sourceStream = + new FileStream( + filePath, + FileMode.Create, FileAccess.Write, FileShare.None, + bufferSize: 4096, useAsync: true)) + { + await sourceStream.WriteAsync(encodedText, 0, encodedText.Length); + } + } + + /// + /// Create a from a string + /// https://stackoverflow.com/a/5238289/3938401 + /// + /// String to turn into a stream + /// for stream + /// + public static MemoryStream GenerateStreamFromString(string str, Encoding encoding) + { + return new MemoryStream(encoding.GetBytes(str ?? "")); + } } }