A user-friendly Update Notifier and Downloader (C#, WPF)

For quite some time, my update notification mechanism for most of my projects consisted of a Web Client async request to download a version file, check it against the current version, and show a message that would lead the user to a download page. This is all simple and great, but if you’re like me and you release updates constantly, you’d be better off using a delta updater, that knows which files have been updated since the last release, downloads them and installs them. For NBA Stats Tracker, I’ve taken the middle road. I release new versions quite frequently, so getting the user to the download page, then using the download link, then going through all the steps of installing it could be a hassle, deterring them from downloading and thus using the latest version. I thought I’d make the update process just a few clicks and minimize the time it requires to update to a few seconds.

Here’s how it works.

I’m using 3 additional windows for this to give it all its pretty features. The first one is quite simple, called CopyableMessageWindow. It serves a lot of purposes in NBA Stats Tracker, so I’m just re-using it to allow the user to scroll through the changelog without opening a text-editor for it. It works just like a message dialog with a MaxWidth and MaxHeight, but also has scrolling if the message is too large.

NOTE: All the code below can be found at NBA Stats Tracker’s Git repository here.

CopyableMessageWindow.xaml

<Window x:Class="NBA_Stats_Tracker.Windows.CopyableMessageWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="CopyableMessageWindow"
        SizeToContent="WidthAndHeight"
        ResizeMode="NoResize" WindowStartupLocation="CenterScreen" MinHeight="120" mc:Ignorable="d"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" d:DesignHeight="120" d:DesignWidth="245"
        MinWidth="245" WindowStyle="ToolWindow">
    <Grid>
        <Grid.RowDefinitions><RowDefinition/>
            <RowDefinition Height="40"/>
        </Grid.RowDefinitions>
        <ScrollViewer MaxHeight="550" Grid.Row="0" Margin="12" VerticalScrollBarVisibility="Auto">
            <TextBlock HorizontalAlignment="Center" Margin="0" Name="txbMsg" Text="txbMsg" VerticalAlignment="Top"
                   MaxWidth="800" TextWrapping="Wrap" TextAlignment="Center" />
        </ScrollViewer>
        <Grid Grid.Row="1">
            <StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
                <Button Content="OK" Height="23" HorizontalAlignment="Left" Name="btnOK" VerticalAlignment="Center " Width="75"
                Margin="5,0" Click="btnOK_Click" IsDefault="True"/>
                <Button Content="Copy To Clipboard" Height="23" HorizontalAlignment="Left" Margin="5,0"
                Name="btnCopyToClip" VerticalAlignment="Center" Width="122" Click="btnCopyToClip_Click" />
            </StackPanel>
        </Grid>
    </Grid>
</Window>

CopyableMessageWindow.xaml.cs

    public partial class CopyableMessageWindow
    {
        public CopyableMessageWindow(String msg, String title, TextAlignment align)
        {
            InitializeComponent();

            txbMsg.Text = msg;
            txbMsg.TextAlignment = align;
            Title = title;
        }

        private void btnOK_Click(object sender, RoutedEventArgs e)
        {
            Close();
        }

        private void btnCopyToClip_Click(object sender, RoutedEventArgs e)
        {
            Clipboard.SetText(txbMsg.Text);
            Title += " (copied to clipboard)";
        }
    }

Pretty straight-forward stuff. The ScrollViewer is used to add scrolling if the message is too long, the StackPanel is a nifty trick to have multiple buttons in the center of a form.

The second additional window is again one that’s used in various places around the program, so I just reused it. All it has is a message and a progress bar, so that the user waits while a background process is doing lengthy work. The twist is that the user can’t close the window unless the CanClose property is set programatically to true. So don’t forget to do that. And also, make sure that if the background process fails, you handle it gracefully and not just have the ProgressWindow block everything and never close.

ProgressWindow.xaml

<Window x:Class="NBA_Stats_Tracker.Windows.ProgressWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Please wait..." Height="146" Width="473" WindowStartupLocation="CenterScreen" ResizeMode="NoResize" Topmost="True" Closing="Window_Closing">
    <Grid>
        <TextBlock x:Name="txbProgress" Margin="10,10,10,0" TextWrapping="Wrap" VerticalAlignment="Top" Height="37" TextAlignment="Center"/>
        <ProgressBar x:Name="pb" Height="23" Margin="10,0,10,10" VerticalAlignment="Bottom" ValueChanged="pb_ValueChanged"/>
    </Grid>
</Window>

ProgressWindow.xaml.cs

    public partial class ProgressWindow : Window
    {
        public bool CanClose = false;

        public ProgressWindow()
        {
            InitializeComponent();
        }

        public ProgressWindow(string message) : this()
        {
            txbProgress.Text = message;
        }

        private void Window_Closing(object sender, CancelEventArgs e)
        {
            if (!CanClose)
                e.Cancel = true;
        }

        private void pb_ValueChanged(object sender, RoutedPropertyChangedEventArgs e)
        {
            Title = "Please wait (" + pb.Value + "% completed)...";
        }
    }

With that out of the way, now’s the time for the Update window. It features a message that includes the current version and the latest version, and allows the developer to set a short message, possibly summarizing the updates the latest version offers, without the technical jargon that possibly exists in the changelog. It also features buttons to allow the user to visit the Support page, the Download page, to view the changelog, to install the update now, to be reminded later, or to never be informed of updates.

UpdateWindow.xaml

<Window x:Class="NBA_Stats_Tracker.Windows.UpdateWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Update available!" MinHeight="220" MinWidth="500" 
        SizeToContent="WidthAndHeight" WindowStartupLocation="CenterScreen">
    <Grid>
        <Grid.RowDefinitions><RowDefinition/><RowDefinition Height="Auto"/></Grid.RowDefinitions>
        <Grid Grid.Row="0">
            <StackPanel HorizontalAlignment="Center" VerticalAlignment="Stretch" Margin="10">
                <TextBlock Text="There is an update available for NBA Stats Tracker!" TextAlignment="Center"/>
                <TextBlock />
                <TextBlock Text="Your current version is: " Name="txbCurrentVersion" TextAlignment="Center"/>
                <TextBlock Text="The latest version is: " Name="txbLatestVersion"  TextAlignment="Center"/>
                <TextBlock/>
                <ScrollViewer MaxHeight="550" VerticalScrollBarVisibility="Auto">
                    <TextBlock HorizontalAlignment="Center" Margin="0" Name="txbMessage" Text="txbMsg" VerticalAlignment="Top"
                   MaxWidth="600" TextWrapping="Wrap" TextAlignment="Center" />
                </ScrollViewer>
            </StackPanel>
        </Grid>
        <Grid Grid.Row="1">
            <StackPanel VerticalAlignment="Center" Margin="0,0,0,5">
                <StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Orientation="Horizontal" Margin="0,5,0,5">
                    <Button Content="Visit Download Page" Padding="10,2" Margin="5,0" Name="btnVisitDownload" Click="btnVisitDownload_Click"/>
                    <Button Content="Visit Support Page" Padding="10,2" Margin="5,0" Name="btnVisitSupport" Click="btnVisitSupport_Click"/>
                    <Button Content="View Changelog" Padding="10,2" Margin="5,0" Name="btnViewChangelog" Click="btnViewChangelog_Click"/>
                </StackPanel>
                <StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Orientation="Horizontal" Margin="0,5,0,5">
                    <Button Content="Install Now" FontWeight="Bold" Padding="10,2" Margin="5,0" Name="btnInstallNow" Click="btnInstallNow_Click"/>
                    <Button Content="Remind Me Later" Padding="10,2" Margin="5,0" Name="btnRemindMeLater" Click="btnRemindMeLater_Click" />
                    <Button Content="Never Show This Again" Padding="10,2" Margin="5,0" Name="btnDisableNotifications" Click="btnDisableNotifications_Click"/>
                </StackPanel>
            </StackPanel>
        </Grid>
    </Grid>
</Window>

UpdateWindow.xaml.cs

    public partial class UpdateWindow : Window
    {
        private string _changelogURL;
        private string _downloadURL;
        private string _installerURL;
        private string _supportURL;

        private UpdateWindow()
        {
            InitializeComponent();
        }

        public UpdateWindow(string curVersion, string newVersion, string message, string installerURL, string downloadURL, string supportURL,
                            string changelogURL) : this()
        {
            txbCurrentVersion.Text = txbCurrentVersion.Text + " " + curVersion;
            txbLatestVersion.Text = txbLatestVersion.Text + " " + newVersion;
            txbMessage.Text = message;

            _installerURL = installerURL;
            _downloadURL = downloadURL;
            _supportURL = supportURL;
            _changelogURL = changelogURL;
        }

        private void btnVisitDownload_Click(object sender, RoutedEventArgs e)
        {
            Process.Start(_downloadURL);
        }

        private void btnVisitSupport_Click(object sender, RoutedEventArgs e)
        {
            Process.Start(_supportURL);
        }

        private void btnViewChangelog_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                var webClient = new WebClient();
                string updateUri = _changelogURL;
                webClient.DownloadFileCompleted += OnChangelogDownloadCompleted;
                webClient.DownloadFileAsync(new Uri(updateUri), App.AppTempPath + "changelog.txt");
            }
            catch (Exception ex)
            {
                MessageBox.Show("The changelog couldn't be downloaded at this time. Please try again later.\n\n" + ex.Message);
            }
        }

        private void OnChangelogDownloadCompleted(object sender, AsyncCompletedEventArgs asyncCompletedEventArgs)
        {
            List lines = File.ReadAllLines(App.AppTempPath + "changelog.txt").ToList();
            lines.Add("");
            var cmw = new CopyableMessageWindow(lines.Aggregate((l1, l2) => l1 + "\n" + l2), "NBA Stats Tracker - What's New",
                                                TextAlignment.Left);
            cmw.ShowDialog();
        }

        private void btnInstallNow_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                string localInstallerPath = App.AppTempPath + "Setup.exe";
                var pw = new ProgressWindow("Please wait while the installer is being downloaded...\n" + _installerURL);
                pw.Show();
                var webClient = new WebClient();
                webClient.DownloadProgressChanged +=
                    delegate(object o, DownloadProgressChangedEventArgs args) { pw.pb.Value = args.ProgressPercentage; };
                webClient.DownloadFileCompleted += delegate
                                                   {
                                                       pw.CanClose = true;
                                                       pw.Close();
                                                       if (
                                                           MessageBox.Show(
                                                               "NBA Stats Tracker will now close to install the latest version and then restart.\n\nAre you sure you want to continue?",
                                                               "NBA Stats Tracker", MessageBoxButton.YesNo, MessageBoxImage.Question,
                                                               MessageBoxResult.Yes) != MessageBoxResult.Yes)
                                                       {
                                                           return;
                                                       }

                                                       string newUpdaterPath = App.AppTempPath + "\\Updater.exe";
                                                       try
                                                       {
                                                           File.Copy(
                                                               Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) +
                                                               "\\Updater.exe", newUpdaterPath, true);
                                                       }
                                                       catch (Exception ex)
                                                       {
                                                           MessageBox.Show("Couldn't run the Updater. " + ex.Message);
                                                           return;
                                                       }
                                                       Process.Start(newUpdaterPath, "\"" + localInstallerPath + "\"");
                                                       Environment.Exit(0);
                                                   };
                webClient.DownloadFileAsync(new Uri(_installerURL), localInstallerPath);
            }
            catch (Exception ex)
            {
                MessageBox.Show("The changelog couldn't be downloaded at this time. Please try again later.\n\n" + ex.Message);
            }
        }

        private void btnRemindMeLater_Click(object sender, RoutedEventArgs e)
        {
            Close();
        }

        private void btnDisableNotifications_Click(object sender, RoutedEventArgs e)
        {
            MainWindow.MWInstance.mnuOptionsCheckForUpdates.IsChecked = false;
            MainWindow.MWInstance.mnuOptionsCheckForUpdates_Click(null, null);
        }
    }

A lot of parameters in the window’s constructor, I know, but you can remove some buttons and thus remove the need for all those parameters. If the user wants to see the changelog, we download it silently, read all its lines and open it in a CopyableMessageWindow instance. ReadAllText could work too, but this way you can skip some lines or alter them. If the user decides to download the program, we download it and show the download progress in the ProgressWindow, and then we inform the user that the program would need to restart after installation. If the user doesn’t have a problem with that, we make a copy of the Updater app (described below) to a temp folder (running it from the installation folder would not allow the installer to update it) and then run it. Make sure to delete any copies of it when the application starts and during the uninstallation!

The Updater is a tiny WPF application (doesn’t need to be WPF really) that starts the installer, and then starts the application. You can change any hard-coded information, add additional arguments, but make sure that your application, when installed, has a (registry) value somewhere you know you can read from that has the installation path. In this case, it’s “InstallDir” in the application’s registry key, and it’s created during installation.

Updater\App.xaml.cs

public partial class App
    {
        private static readonly string AppDocsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments) +
                                                     @"\NBA Stats Tracker\";

        protected override void OnStartup(StartupEventArgs e)
        {
            base.OnStartup(e);

            if (e.Args.Length == 0)
            {
                MessageBox.Show("Updater must be ran with proper path to installer.");
                Current.Shutdown();
            }

            Process installerProc = null;
            try
            {
                installerProc = Process.Start(e.Args[0], "/SILENT");
                if (installerProc == null)
                    throw new Exception();
            }
            catch
            {
                MessageBox.Show("Can't start installer.");
                Environment.Exit(0);
            }
            installerProc.WaitForExit();

            string installDir = GetRegistrySetting("InstallDir",
                                                   Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86) +
                                                   @"\NBA Stats Tracker");
            Process.Start(installDir + @"\NBA Stats Tracker.exe");

            Environment.Exit(0);
        }

        private static string GetRegistrySetting(string setting, string defaultValue)
        {
            RegistryKey rk = Registry.CurrentUser;
            string settingValue = defaultValue;
            try
            {
                if (rk == null)
                    throw new Exception();

                rk = rk.OpenSubKey(@"SOFTWARE\Lefteris Aslanoglou\NBA Stats Tracker");
                if (rk != null)
                    settingValue = rk.GetValue(setting, defaultValue).ToString();
            }
            catch
            {
                settingValue = defaultValue;
            }

            return settingValue;
        }
    }

The installer is called with the /SILENT argument so that it skips all the dialogs the user would have to “Next”, and only shows the installation progress. This adds to the user-friendliness of the whole process. Make sure that your /SILENT installation is set-up so that it doesn’t install anything the user should have to opt-in for (like “supporting software” such as adware). After the installer completes, the program is run, and the Updater exits.

So, to check for updates, I’m using a file of the following format, and I upload it to the server every time I release a new version:

1.8.1.0
http://forums.nba-live.com/downloads.php?view=detail&df_id=4184
http://www.nba-live.com/leftos/NBA Stats Tracker.exe
http://forums.nba-live.com/viewtopic.php?f=143&t=84110
http://students.ceid.upatras.gr/~aslanoglou/nstchangelog.txt

Introduced in v1.8 is the new update mechanism, which allows you to download and install the latest NBA Stats Tracker version from inside the program, making it a much faster and easier process. Make sure to update!

The first line is the version number, the second line is the download page URL, the third line is the installer URL, the fourth line is the support page URL, and the fifth page is the changelog URL. Anything after that is the short message you want to show to the user.

The program downloads it and parses it as follows (add a call to this function from the Window_Loaded function, not in the Window constructor, because otherwise the Window won’t be shown until the above file is downloaded.

        public static void CheckForUpdates(bool showMessage = false)
        {
            _showUpdateMessage = showMessage;
            try
            {
                var webClient = new WebClient();
                string updateUri = "http://students.ceid.upatras.gr/~aslanoglou/nstversion.txt";
                if (!showMessage)
                {
                    webClient.DownloadFileCompleted += checkForUpdatesCompleted;
                    webClient.DownloadFileAsync(new Uri(updateUri), AppDocsPath + @"nstversion.txt");
                }
                else
                {
                    webClient.DownloadFile(new Uri(updateUri), AppDocsPath + @"nstversion.txt");
                    checkForUpdatesCompleted(null, null);
                }
            }
            catch
            {
            }
        }

        private static void checkForUpdatesCompleted(object sender, AsyncCompletedEventArgs e)
        {
            string[] updateInfo;
            string[] versionParts;
            try
            {
                updateInfo = File.ReadAllLines(AppDocsPath + @"nstversion.txt");
                versionParts = updateInfo[0].Split('.');
            }
            catch
            {
                return;
            }
            string curVersion = Assembly.GetExecutingAssembly().GetName().Version.ToString();
            string[] curVersionParts = curVersion.Split('.');
            var iVP = new int[versionParts.Length];
            var iCVP = new int[versionParts.Length];
            for (int i = 0; i < versionParts.Length; i++)
            {
                iVP[i] = Convert.ToInt32(versionParts[i]);
                iCVP[i] = Convert.ToInt32(curVersionParts[i]);
                if (iCVP[i] > iVP[i])
                    break;
                if (iVP[i] > iCVP[i])
                {
                    string message = "";
                    try
                    {
                        for (int j = 6; j < updateInfo.Length; j++)
                        {
                            message += updateInfo[j].Replace('\t', ' ') + "\n";
                        }
                    }
                    catch
                    {
                    }
                    var uio = new UpdateInfoContainer {CurVersion = curVersion, UpdateInfo = updateInfo, Message = message};
                    MWInstance.Dispatcher.BeginInvoke(new Action<object>(showUpdateWindow), uio);
                    return;
                }
            }
            if (_showUpdateMessage)
                MessageBox.Show("No updates found!");
        }

        private static void showUpdateWindow(object o)
        {
            var uio = (UpdateInfoContainer) o;
            string curVersion = uio.CurVersion;
            string[] updateInfo = uio.UpdateInfo;
            string message = uio.Message;
            var uw = new UpdateWindow(curVersion, updateInfo[0], message, updateInfo[2], updateInfo[1], updateInfo[3], updateInfo[4]);
            uw.ShowDialog();
        }

        private struct UpdateInfoContainer
        {
            public string Message;
            public string CurVersion;
            public string[] UpdateInfo;
        }

The file described above is downloaded, it’s first line is checked to see whether the latest version is newer than the current one, and if it is, the UpdateWindow is shown with all its parameters taken from the file, so they can be changed depending on the update, and hence don’t have to be hard-coded. Obviously, each time an update is released, you need to upload the installer, the file described above, and the changelog. I have WinSCP upload all of it to servers I have FTP access to with a simple batch file:

"E:\Program Files (x86)\WinSCP\winscp.com" /command "option batch abort" "option confirm off" "open diogenis" "put ""E:\Development\Visual Studio 2010\Projects\NBA Stats Tracker\nstversion.txt"" " "exit"
"E:\Program Files (x86)\WinSCP\winscp.com" /command "option batch abort" "option confirm off" "open diogenis" "put ""E:\Development\Visual Studio 2010\Projects\NBA Stats Tracker\NBA Stats Tracker\What's New.txt"" nstchangelog.txt" "exit"
"E:\Program Files (x86)\WinSCP\winscp.com" /command "option batch abort" "option confirm off" "open nlsc" "put ""E:\Development\Visual Studio 2010\Projects\NBA Stats Tracker\NBA Stats Tracker.exe"" " "exit"

“diogenis” and “nlsc” are sessions I have saved in WinSCP, that default to the remote server folder I want to upload to.

All the above may seem like a lot of code for such a simple function, but it ensures simplicity and efficiency, as well as user-friendliness.

Here’s a video of all that coming together, and how simple it is for the user to install an update when one is available:

Leave a comment