For anyone using NetSparkleUpdater that wants to enable both a stable and a preview/beta channel in an app using - here’s how I did it.

What and Why

Stable and preview (beta) channels are commonplace nowadays, you can see them in many apps - regardless of the tech being used.

All the big boys are doing it - Microsoft for example, has preview features in their apps as well as Azure and they also run the Insider program for OS level preview features, whereas Apple provides it’s TestFlight platform for similar reasons.

The benefits of providing a preview channel are:

  1. Get faster feedback for new features from a smaller subset of users, and while the features must work they can change and don’t have to be 100% complete.
  2. Users can opt in/out themselves. Ah, yes, the revelation that is self service. But also, early adopters can choose to be just that, and you get to target a willing audience, who are more likely to be able to handle slightly higher risk and get some feedback.
  3. Switch to preview/stable easily - increasing confidence and the chance that someone might try it out and give you feedback.
  4. It’s easy to set user expectations, which can be done at multiple points - e.g on your website and within your application.
  5. You can build a more detailed feedback mechanism into your app for the preview users. The feedback then makes its way back into the preview channel - closing the loop.

Why me?

I wrote a small app for Windows to move & resize windows around; something I missed from my Unix days. I wanted this app to have a beta channel for the not-quiet-fully-baked ideas that I could ship and get feedback on.

Since NetSparkleUpdater didn’t handle this type of update - I decided to make a PR to tackle the problem ; hopefully this helps others too.

With this article I want to explain the reasoning as well as the technical implementation. Maybe this helps enable you to take a similar step in your app(s).

Questions that needed answers

In no particular order, here are the questions I tackled while doing this:

  1. Should I produce a single appcast.xml feed that contains everything - or will I split the feeds per channel?
  2. What kind of UX flow do I need in my app? I want the user to default to the stable channel and have a clear choice to go into the preview channel - and to get back to stable at any time safely.
  3. How does the release pipeline have to change to support both channels?
  4. What about my branching strategy, how does that need to change to indicate a preview feature?
  5. How do users opt-out from preview and get back to stable?
  6. Can I unit test any of this? Answer - yeah!
  7. Oh my goodness this is a lot of work for such a simple concept.

Single vs Multiple Appcast Files

I decided to produce one appcast file per channel.

I also decided to place my appcast files, along with assets and signatures in separate directories - this introduced a touch more complexity but made thinking about it (for me anyway) easier.

It also meant that my build pipeline needed to decide which channel to push a release to, and then “append” new releases to an existing appcast file and upload artifacts to the right place.

To the appcast files are located at a separate URI - depending on the channel name. Here’s the code - you can see I support only two channels, called “stable” and “beta”.

Yeah, I like the word preview more at this stage - but its too late now. I can change the UI but those URL’s are staying for good!

    public static string AppCastUrl()
    {
        var channelName = "stable";
        if(UseBetaChannel) // <-- this comes from config
            channelName = "beta";

        return $"https://unwind-update.cluster8.tech/windows/{channelName}/appcast.xml";
    }

UX Flow

I’ve got a WPF app, and my UI is extremely simple - I decided to opt for a non-modal checkbox to indicate which channel the user will be using.

When the user clicks the checkbox, the channel is switched over and the app forces a SparkleUpdate refresh.

the UX for Unwind to select stable/beta channel

How did the release pipeline change?

My app is built in GitHub Actions, but the principles are all the same - so just adapt this to your pipeline tech.

I keep historical releases within the appcast.xml - I do this so that regardless of when a user upgrades they’ll see all the change log entries between their version and the latest version.

To achieve this, my pipeline does the following:

  1. using the branch name, detect if this is stable or preview
  2. download the existing appcast.xml file for the given channel
  3. inject the new app version into this appcast, and upload the artifacts
  4. upload the appcast and artifacts to the channel directory

Thank goodness for the --reparse-existing command line option of netsparkle-generate-appcast.

The code for the pipeline is on GitHub as a gist .

Worth noting:

  1. The GitHub actions script just sets up an environment to build - the build itself is a PowerShell script
  2. The current version number is sourced from the .csproj file itself.
  3. The build stage uses a Windows image, the deployment stage uses a Linux image

Branching

The master branch is used as a source for the stable channel.

If I want a feature to go into preview - I will create a feature/some-description-name branch. The build system will pick up this and publish it as a preview version. The name of the branch is also published into the app binary. For this I used the fabulous GitInfo library, available via nuget.

The GitHub workflow is responsible for parsing the branch name to decide which channel the code will be going to.

Switching back to stable

Users need a way to opt-out of the preview version - this turned out to be harder than I thought, but only because of the way NetSparklerUpdater works internally.

For more information follow this GitHub PR thread: WIP: First shot at implementing an appcast filtering process .

NetSparkleUpdater Implementation

There are two major aspects to the implementation:

  1. The ability to switch to the preview channel.
  2. Reverting from a preview channel back to the stable channel.

The filtering aspect comes into play when you want to filter items out that are not relevant to the current channel, for example when switch back to stable and you are using a single appcast.xml for everything - you will want to keep only the “stable” items, and discard everything else.

Filtering happens on the XMLAppCast instance.

To use it you need to set the IAppCastFilter property of the XMLAppCast object explicitly (it was done this way to avoid making a breaking API change).

Background

By default, the SparkleUpdater object will allocate an instance of the XMLAppCast class, which is responsible for downloading and parsing the appcast.xml file.

Because the SparkleUpdater runs its tasks asynchronously, and background tasks are involved - I decided that I would tear the entire SparkleUpdater system down when the user switches between stable and preview channels. This way there can be zero doubt about which background task is calling back for filtering. This choice was also motivated by the fact when switching channels, I had to pass a different URL into the SparkleUpdater.

While my design choice might be overkill - it keeps the implementation clean and encapsulated; and it also made unit tests possible.

VersionUpdater

The VersionUpdater class encapsulates everything the app needs to know about a channel, how updates should be filtered - and most importantly for this use case it also implements IAppCastFilter.

Here is how you set up the filtering, look at line 16-18 - this is the code you want to use to explicitly cast the XMLAppCast instance and set the filter.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
    public VersionUpdater(string appCastUrl, string publicKey, IUIFactory       uiFactory, bool filterOutBetaItems, bool forceDowngradeLatest)
    {
        Updater = new SparkleUpdater(appCastUrl,
            new Ed25519Checker(SecurityMode.Strict, publicKey), null, uiFactory);

        AppCastUrl = appCastUrl;

        // meaning: should I exclude beta items, because we are in stable
        FilterOutBetaItems = filterOutBetaItems;

        // meaning: we are moving to stable from beta -> force the install of the latest version on stable regardless of what the user has
        ForceDowngradeLatestVersion = forceDowngradeLatest;

        Updater.LogWriter = this;

        XMLAppCast? cast = Updater.AppCastHandler as XMLAppCast;
        if (cast != null)
            cast.AppCastFilter = this;
    }

Switching to preview

  • it’s important to filter out the preview items so that the SparkleUpdater system will correctly detect the latest stable version.

Reverting to stable

The filtering is done via the single method called GetFilteredAppCastItems. You’ll see that if the FilterOutBetaItems is true then we throw away any appcast item that contains the text /beta/. Pretty primitive, but it works well enough.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
    // NOTE: this is called on a background thread - DO NOT attempt to access UI.
    public FilterResult GetFilteredAppCastItems(Version installed, List<AppCastItem> items)
    {
        var filteredItems = items;
        
        // if we don't need to filter, then this is essentially a no-op.
        if (FilterOutBetaItems && items != null)
        {
            // filter out ALL items that are beta. 
            filteredItems = items.Where((item) =>
            {
                if (item.DownloadLink.Contains("/beta/"))
                    return false;
                return true;
            }).ToList();
        }

        return new FilterResult(ForceDowngradeLatestVersion, filteredItems);
    }

The method must return a FilterResult instance. The FilterResult wraps two values:

  1. A bool, which indicates if a downgrade to stable is occuring - if so the NetSparkleUpdater library will automatically work out the latest version within filteredItems.
  2. A List<AppCastItem> containing the items you want the NetSparkleUpdater to consider as valid.

Lastly; note the comment - the filtering method could be called from a background thread, so make sure you do not do any UI access from within it.

Summary

I hope this helps clarify the new IAppCastFilter functionality that is part of NetSparkleUpdater, if it’s useful drop me a message here I’d love to hear that others are making use of it!