Пожалуйста, опишите ошибку

Нашли баг? Помогите нам его исправить, заполнив эту форму

Создание инсталлятора средствами WPF и WIX Toolset

Андрей Гришин
web/desktop - разработчик

Многим, думаю, известно, что для установки классических приложений в ОС Windows требуются специальные инсталляционные пакеты. В своём большинстве, они почти все однотипны и содержат стандартный функционал по настройке установщика и развёртыванию самого приложения. Но бывает и так, что можно встретить кастомизированные приложения для установки, которые и выглядят по-другому и функционала у них больше.

Связано это чаще всего с тем, что для компании, продвигающий свой продукт, не подходят стандартные средства для создания инсталляторов, и они хотят создать что-то своё, что-то индивидуальное.

В данной статье я хотел бы рассказать, как можно создать собственный инсталлятор на платформе .NET с использованием языка C#.

создание инсталлятора средствами WPF и WIX Toolset

В данный момент ОС Windows имеет два типа инсталляционных пакетов:

  1. Для классических приложений, используя Windows Installer, расширение файлов .msi
  2. Appx пакеты для Windows Store приложений

Для создания инсталляционных пакетов на рынке существует множество софта, который поможет собрать пакет без знания каких-либо языков программирования. Перечислю некоторые, с которыми имел дело.

Inno Setup – достаточно мощное средство для создания инсталляционных пакетов. При помощи языка Delphi и растровой графики можно соорудить красивый инсталлятор.

Advanced Installer – хороший Wizard c приятным и понятным интерфейсом. Подойдёт для построения инсталляторов небольших приложений.

Visual Studio Installer Deployment – стандартное средство для сбора инсталляционного пакета вашего приложения в Visual Studio. Удобен, но слишком примитивен.

Так же существует и множество другого софта, которое поможет вам собрать инсталлятор, который развернёт MSI пакет на вашем компьютер.

Плюсы подобного софта зачастую – это его же минусы. Он помогает собирать шаблонную программу по установке основного приложения с небольшими настройками устанавливаемого процесса, но проконтролировать или как-то обработать исключительные ситуации, которые могут произойти во время установки, чаще всего не представляется возможным. А так как основная цель инсталлятора – это правильно установить приложение на компьютер пользователя, значит, в первую очередь, установщик должен быть надёжным и максимально чётко выполнять те задачи, которые в него заложены. Далее, в качестве примера, мы рассмотрим, как создать веб инсталлятор приложения на языке C#, использую технологию WPF и WIX Toolset.

WIX Toolset

WIX Toolset – набор инструментов для создания инсталляционных пакетов на основе XML описания. Официальный сайт WIX.

Можно обойтись и без XML разметки используя WIX#/WIXSharp Framework, который поможет осуществить настройку MSI дистрибутива с помощью C# кода. Почитать о данном инструменте можно например здесь

Кратко рассмотрим структуру инсталляционных пакетов. Инсталляционный пакет описывает установку одного продукта и имеет свой GUID. Продукт состоит из компонентов (components), сгруппированных в возможности (features).

Компонент (component) — минимальная неделимая установочная единица, представляющая собой группу файлов, значений реестра, создаваемых папок и других элементов, объединённых общим именем (именем компоненты) и либо устанавливаемых вместе, либо не устанавливаемых. Компоненты скрыты от конечного пользователя. Каждая компонента имеет ключевой путь (key path) — например, имя своего главного файла — по которому определяется наличие этой компоненты на компьютере пользователя.

Записи в реестре о установленных приложениях хранятся по пути @HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{GUID}

Так же ключи установленных приложений могут храниться, в зависимости от разрядности системы, в ветке SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall или ветке, относящейся к текущему пользователю, вошедшему в систему HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Uninstall.

В данных записях может храниться различная информация об установленном пакете. А по пути C:\Windows\Installer располагаются MSI пакеты установленных приложений. Это значит, что когда мы нажимаем, например, “Удалить” или “Исправить” в приложении, то вызывается установочный MSI пакет, который производит деинсталляцию приложения.

Рассмотрим, к примеру, запись об установочном файле Google Drive. Можно увидеть множество ключей, значения которых соответствуют различной информации об установленном приложении. Данные записи помогают перенаправлять систему на необходимые установочные пакеты, которые сохраняются в системе после установки. А также предоставлять дополнительную информацию о продукте, например: издатель, адрес сайта приложения, телефон поддержки и тд.

При создании кастомного инсталлятора можно пойти несколькими путями:

  1. Создание UI на WPF, а основной функционал за установку отдать WIX (но тогда опять же получится неуправляемый установщик)
  2. WPF + основная логика на C# + настройка инсталлируемого пакета с помощью WIX.
  3. WPF + полное конфигурирование инсталляционного пакета с помощью С# кода. (WIX будет отвечать только за установку необходимого .NET Framework пакета для работы с самим установщиком)

Какой способ для вас предпочтительней, выбирайте сами. Далее рассмотрим последние 2 метода.

Стоит обратить внимание, что для работы самого инсталлятора на платформе .NET нам будет необходим .NET Framework определённой версии. А без него инсталлятор не запустится вовсе, поэтому необходимо выбрать минимально возможную версию фреймворка для функционирования вашего инсталлятора и найти способ установки .NET Framework из этого же инсталлятора.

К счастью WIX тоже справляется с этой проблемой и проверяет перед запуском инсталлятора версию текущего SDK на машине, и после может предложить установить его, если фреймворк отсутствует. Это потому, что WIX для работы использует C++ библиотеки.

Создание проекта

WPF + WIX Bootstrapper

Что такое WPF и для чего эта технология используется, думаю, большинству известно. Если же нет, то ознакомиться с ней можно на MSDN либо на любом другом ресурсе.

Для начала рассмотрим, как создать проект с подключения локальных пакетов для развёртывания с помощью WIX, а после – как построить веб инсталлятор с использованием только логики, написанной на C#.

Сперва необходимо установить расширение WIX Toolset. Данное расширение поддерживается начиная с VisualStudio 2010.

Так же, кроме расширения необходим установить набор WIX библиотек. Скачать установочный набор можно на официальном сайте

После успешной установки в шаблонах проектов появятся новые типы шаблонов.

В Solution необходимо будет добавить три проекта:

Из Wix Toolset

  1. Setup Project
  2. Bootstrapper Project

Из Visual C#

  1. Приложение WPF

WPF проект лучше строить на архитектурном паттерне MVVM, поэтому сразу стоит установить NuGet пакет MVVM Light. Первое, что нам необходимо будет сделать после, это настроить WPF проект для работы с WIX.

Сам инсталлятор будет запускать из собранного exe файла в проекте Bootstrapper, а WIX будет запускать UI часть как DLL файл. Поэтому сразу необходимо перестроить WPF проект. В свойствах проекта: Свойства -> Приложение -> Тип входных данных | изменить на Библиотека классов.

Теперь WPF проект будет собираться в Dll библиотеку. На схеме ниже я попытался проиллюстрировать принцип построения проекта и использованием WIX.

Во главе собираемого проекта будет стоять WIX Bootstrapper. К нему можно подключить дополнительные инсталляционные пакеты. Это будет полезно, например, когда приложение состоит из нескольких модулей.

За управление будет отвечать собранная dll библиотека из WPF приложения. Из WPF проекта можно осуществлять загрузку данных с сервера и инсталлировать их в систему.

Далее в References необходимо добавить ссылку на Core библиотеку WIX. Располагается она в папке установленной SDK в ..\WiX Toolset v3.**\SDK\BootstrapperCore.dll

После необходимо переопределить точку входа в WPF проекте. Создадим класс, например BootstrapperInstaller, который должен быть унаследован от BootstrapperApplication. Рассмотрим данный класс подробнее.


using Microsoft.Tools.WindowsInstallerXml.Bootstrapper;
using System;
using System.Windows.Threading;
using WIXInstaller.Model;
using WIXInstaller.ViewModel;

namespace WIXInstaller
{
    public class BootstrapperInstaller : BootstrapperApplication
    {
        public static Dispatcher BootstrapperDispatcher { get; private set; }
        public static MainViewModel viewModel;

        public static MainWindow view;

        public static string Args = string.Empty;

        // entry point for our custom UI
        protected override void Run()
        {
            //Debugger.Launch();

            Engine.Log(LogLevel.Verbose, "Launching");

            BootstrapperDispatcher = Dispatcher.CurrentDispatcher;

            if (!Launcher.IsRunAsAdmin())
                Launcher.RunAsAdministrator();


            viewModel = new MainViewModel(this);
            viewModel.Bootstrapper.Engine.Detect();
            view = new MainWindow { DataContext = viewModel };
            view.Closed += (sender, e) => BootstrapperDispatcher.InvokeShutdown();
            view.Show();

            Dispatcher.Run();

            Engine.Quit(0);
        }
    }
}

Метод Run будет новой точкой входа в программу. Напрямую запустить отладку не получится. Поэтому при необходимости подключения отладчика собираем проект в режиме Debug и вначале точки входа запускаем дебагер. Debugger.Launch();. После, при запуске собранного инсталлятора нам будет предложен отладчик.

Здесь же объявляем нужные нам объекты view и viewModel. А также главный диспетчер приложения.

Для работы инсталлятору в большинстве случаев потребуются права администратора. Сначала необходимо проверить с какими правами запущено приложение. А уже после, по необходимости, запросить перезапуск с правами администратора.


public static bool IsRunAsAdmin()
{
    WindowsIdentity id = WindowsIdentity.GetCurrent();
    WindowsPrincipal principal = new WindowsPrincipal(id);
    return principal.IsInRole(WindowsBuiltInRole.Administrator);
}
            
public static void RunAsAdministrator()
{
    string directory = Environment.CurrentDirectory;
    string fileName = Process.GetCurrentProcess().MainModule.ModuleName;
    string path = directory + $@"\{fileName}";
    // Launch itself as administrator
    ProcessStartInfo proc = new ProcessStartInfo
    {
        UseShellExecute = true,
        WorkingDirectory = Environment.CurrentDirectory,
        FileName = path,
        Verb = "runas"
    };

    try
    {
        Process.Start(proc);
    }
    catch (Exception e)
    {
        // The user refused the elevation.
        Environment.Exit(0);
     }
     Environment.Exit(0); // Quit itself

}

После необходимо добавить Xml файл конфигурации для Bootstrapper. Ниже приведён пример Xml разметки для данного файла.

Настроим Setup Project, файл Product.wxs.

В данном файле мы конфигурируем дополнительный установочный пакет. Таких пакетов в Bundle сборке может быть несколько. Можно прикреплять уже существующие.

Далее переходим к конфигурированию файла Bundle.wsx

Разберём данный файл подробнее.

Bundle – здесь мы описываем и конфигурируем наш Bundle. В данном теге можно объявить такие атрибуты, как

  1. Name
  2. Version
  3. Manufacturer
  4. UpgradeCode
  5. IconSourceFile
  6. AboutUrl
  7. HelpPhone
  8. и др.

Все данные теги отвечают за информацию об устанавливаемом приложении. BootstrapperApplicationRef – в теле данного тега мы объявляем ссылки на используемые библиотеки и компоненты, связанные с Bundle. В блоке Chain мы объявили ссылки на 2 компонента. Это инсталляционный пакет .NET Framework и дополнительный пакет, который мы создали AdditionalInstaller. util:RegistrySearch – обращается к WixUtilExtension.dll. Производит поиск по реестру с целью получения версии .NET Framework. Если необходимая версия не найдена, то запускается установка прикреплённого установочного пакета .NET Framework.

Перейдём теперь к WPF проекту и заглянем в файл MainViewModel.cs


public BootstrapperApplication Bootstrapper { get; private set; }
       
    public MainViewModel(BootstrapperInstaller bootstrapperInstaller)
    {
        IsThinking = false;
        Bootstrapper = bootstrapperInstaller;
        Bootstrapper.ApplyComplete += OnApplyComplete;
        Bootstrapper.DetectPackageComplete += OnDetectPackageComplete;
        Bootstrapper.PlanComplete += OnPlanComplete;
        Downloader.OnDownloadAppPartsComplite += Installer.Configurate;
        Downloader.OnSizeAppPartsUpdate += DownloaderOnSizeAppPartsUpdate;
        Installer.OnComplite += InstallerOnComplite;
    }

    private Frame MainFrame => view?.MainFrame;
    private FinishPage finishPage;
    private InstallPage installPage;
    private SelectPathPage selectPathPage;

    private NavigationService service;

Во ViewModel создаём новый экземпляр Bootstrapper и подписываем некоторые из его событий на обработку.

OnDetectPackageComplete – событие, которое срабатывает после обнаружения встроенных в Bundle компонентов. В данном методе мы проверяем id встраемого пакета и проверяем, установлен ли он в системе. Если да, то запускаем кешированный файл для удаления и делаем активной кнопку Unistall. Если же необходимый пакет отсутствует, то просто делаем активной кнопку Install.


private void OnDetectPackageComplete(object sender, DetectPackageCompleteEventArgs e)
    {
    if (e.PackageId == "InstallationPackageId")
    {
       if (e.State == PackageState.Absent)
            InstallEnabled = true;
        else if (e.State == PackageState.Present)
        {
            UninstallEnabled = true;
            if (args.Length != 0 && args == "/IsCacheFile")
                return;

            Launcher.CheckInstalledInstance();
        }
    }
}

OnPlanComplete – метод, который вызовется после окончания планирования. Если планирование было успешно, то для Bootstrapper Engine будет указано значение 0, что будет означать успех. Если же e.Status меньше 0, то это значит, что произошла какая-то ошибка.


private void OnPlanComplete(object sender, PlanCompleteEventArgs e)
    {
        if (e.Status >= 0)
        Bootstrapper.Engine.Apply(IntPtr.Zero);
    }

OnApplyComplete – данный метод вызывается, когда установка Bundle была завершена.


private void OnApplyComplete(object sender, ApplyCompleteEventArgs e)
    {
        IsThinking = false;
        InstallCompleted = "Installation completed";
        view.Dispatcher.Invoke(() =>
        {
            MainFrame.Navigate(finishPage);
        });
    }

Надеюсь, суть работы с Wix Toolset немного понятна. У Bootstrapper имеется множество эвентов, которые можно отслеживать, но опять же, минусом данного метода будет неуправляемый код. Установка одного из пакетов, например, может завершиться неуспехом на каком-то из этапов работы бутстраппера, произойдёт откат изменений, а после просто выдаст ApplyCompleteEventArgs Status отрицательного значения. И это не хорошо, поэтому подробнее остановимся на втором методе, когда можно контроллировать установку от начала до конца.

WPF + Install via C#

Данный метод считаю более удобным, потому что мы можем полностью контролировать процесс установки софта.

Проект, описанный ниже, будет доступен для скачивания в конце статьи. Рассмотрим полную структуру проекта.

В данном случае уже можно не использовать дополнительный пакет для определения наличия установленного приложения.

Bundle.wsx можно оставить без изменений, необходимо лишь удалить ссылку на дополнительный MsiPackage.

Во вкладке View мы видим один объект типа Windows и несколько Page. Главное окно через элемент Frame будет отображать необходимые нам шаги установки с помощью страниц с навигацией.

Для самой минимальной реализации view модели можно создать главное окно и несколько страниц. Какие страницы создавать решать вам. Ниже приведу пример минимального набора для стандартной установки.

  1. MainPage – страница с выбором действий. Install/Unistall в нашем случае.
  2. SelectPathPage – страница для выбора пути устанавливаемого приложения. Так же можно добавить и другие настройки на эту страницу. Например, выбор создания ярлыков приложения.
  3. InstallPage – страница, на которой будем отображать прогресс инсталляции.
  4. FinishPage – ну а на данной странице можно выводить информации о успешности/неуспешности инсталляции, а также выбор на запуск приложения.

За ViewModel в проекте отвечает один файл MainViewModel.cs. Во вкладке Assets хранятся различные медиаресурсы, а в Resources словарь ресурсов стилей.

Для Model я создал несколько классов, это:

  1. Configurator – объект, отвечающий за различную настройку директорий, ярлыков и реестра.
  2. Downloader – отвечает за закачку компонентов.
  3. Installer – класс, отвечающий за установку и удаление Bundle.
  4. Launcher – содержит несколько методов для запуска исполняемых файлов.
  5. ServiceInstaller – объект для установки/удаления служб. В данном примере не используется.
  6. Settings – объект с различными полями, которые используются в процессе установки

Рассмотрим метод реализующий команды для установки.


public static void StartInstallation()
        {
            Task.Factory.StartNew(() =>
            {
                if (!Configurator.CreateTempDirectory())
                {
                    MessageBox.Show("Error Installation. Could not create temporary installer folder.");
                    return;
                }

            }).ContinueWith((t) =>
            {
                try
                {
                    CreateApplicationDirectories();
                }
                catch (Exception){ MessageBox.Show("Error Installation. Could not create application directory."); }

            }).ContinueWith((t) =>
            {
                Downloader.DownloadAppParts();
            });
        }

Метод CreateTempDirectory создаёт временную папку по пути %ROOT:\Users\User\AppData\Local\Temp\ApplicationName. В неё он будет качать архивы с компонентами приложения. Если создание данной директории было успешно, то создаётся директория приложения. После успешного создания директории, куда будет распаковываться само приложение, начнётся закачка компонентов, через объект Downloader методом DownloadAppParts.

Рассмотрим класс Downloader.


public delegate void OnDownloadComplite();

        public delegate void OnSizeUpdate(long size);

        public static event OnDownloadComplite OnDownloadAppPartsComplite;

        public static event OnSizeUpdate OnSizeAppPartsUpdate;

        private static readonly Dictionary ApplicationParts = new Dictionary
        {
            { "https://raw.githubusercontent.com/AndreyGrishin/Files/master/MyAppication.zip", "MyAppication.zip"}
        };

        private static WebClient _client;


Сам класс сделаем статичным и объявим в нем некоторые переменные. Ссылки на скачиваемые компоненты будем хранить в словаре ApplicationParts. При необходимости можно добавлять в него устанавливаемые компоненты в зависимости от условий.

Через события OnSizeAppPartsUpdate будем передавать во ViewModel максимальный размер для прогресса бара, а через OnDownloadAppPartsComplite передавать управление на следующий метод установщика.


    public static async void DownloadAppParts()
        {
            long size = ApplicationParts.Sum(part => GetFileSizeAsync(part.Key).Result);

            SetSizeProgressBar(size);

            _client = new WebClient();
            _client.DownloadProgressChanged += ClientOnDownloadProgressChanged;
            foreach (var appPair in ApplicationParts)
            {
                bytesIn = 0;
                try
                {
                    await _client.DownloadFileTaskAsync(new Uri(appPair.Key), TempPath + appPair.Value);
                }
                catch (WebException e)
                {
                    // Add event handlers
                }
                ExtractFiles(TempPath + appPair.Value, viewModel.InstallFolderPath + $@"\{ApplicationName}");
            }

            OnDownloadAppPartsComplite?.Invoke();
        }

Все основные действия по загрузке компонентов происходят в методе DownloadAppParts. Изначально подсчитываем размер для прогресса бара в методе GetFileSizeAsync. В данном методе мы получаем размеры файлов, которые необходимо скачать и конкатенируем их в переменную size. После отправляем полученный размер во ViewModel что бы она обновила значение progressBar.Maximum на новое значение, SetSizeProgressBar(size).


    private static async Task GetFileSizeAsync(string url)
        {
            long size = 0;
            WebRequest req = WebRequest.Create(url);
            req.Timeout = 6000;
            req.Method = "HEAD";
            using (WebResponse resp = await req.GetResponseAsync().ConfigureAwait(false))
            {
                if (long.TryParse(resp.Headers.Get("Content-Length"), out long contentLength))
                {
                    size += contentLength;
                }
            }
            return size;
        }

Далее через метод _client.DownloadFileTaskAsync(new Uri(appPair.Key), TempPath + appPair.Value); асинхронно загружаем каждую из частей и последовательно распаковываем её в созданную ранее папку. ExtractFiles(TempPath + appPair.Value, viewModel.InstallFolderPath + $@”\{ApplicationName}”);


    private static void ExtractFiles(string zipPath, string extractPath)
        {
            try
            {
                ZipFile.ExtractToDirectory(zipPath, extractPath);
            }
            catch (Exception e)
            {
                // Add event handlers
            }
        }

Завершаем установку конфигурированием реестра и сохранением файла инсталлятора в папку кэшированных файлов.


    public static void Configurate()
    {
        Configurator.CreateStartMenuDirectory();
        Configurator.CreateShortcuts();

        view.Dispatcher.Invoke(() => { OnComplite?.Invoke(); });
    }

Если с созданием директорий всё просто и понятно в нашем случае, то с ярлыками может возникнуть проблемы. Нужно либо регистрировать COM объект в сборку Bundle, либо рефлексивно обращаться к Windows Script Host Shell Object.


   private static void CreateShorcut(string shortcutPath, string targetPath)
        {
            Type t = Type.GetTypeFromCLSID(new Guid("72C24DD5-D70A-438B-8A42-98424B88AFB8"));
            dynamic shell = Activator.CreateInstance(t);
            try
            {
                var shortcut = shell.CreateShortcut(shortcutPath);
                try
                {

                    shortcut.TargetPath = targetPath;
                    shortcut.IconLocation = ExecutableFile;
                    shortcut.Save();
                }
                finally
                {
                    Marshal.FinalReleaseComObject(shortcut);
                }
            }
            catch (Exception e)
            {
                // ignored
            }
            finally
            {
                Marshal.FinalReleaseComObject(shell);
            }
        }


Завершаем установку конфигурированием реестра и сохранением файла инсталлятора в папку кешированных файлов.


   private static string guid;

private static string GUID
        {
            get
            {
                if (guid != null)
                    return guid;

                guid = "{" + Guid.NewGuid() + "}";
                return guid;
            }

        } 

private void InstallCacheAndRegistry()
        {
            Configurator.CreateCacheFile();
            Configurator.ConfigurateRegistry();
            OnInstallComplete();
        }
    
internal static void CreateCacheFile()
        {
            try
            {
                Directory.CreateDirectory(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData) + @"\Package Cache\" + GUID);

                string directory = Environment.CurrentDirectory;
                string fileName = Process.GetCurrentProcess().MainModule.ModuleName;
                string installerPath = directory + $@"\{fileName}";

                File.Copy(installerPath, Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData) + @"\Package Cache\" + GUID + $@"\{fileName}");
            }
            catch (Exception e)
            {

            }
        }
    
internal static void ConfigurateRegistry()
        {
            string fileName = Process.GetCurrentProcess().MainModule.ModuleName;
            string cacheFilePath = $@"{Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData)}\Package Cache\{GUID}\{fileName}";
            RegistryKey registryKey = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", true);
            var key = registryKey?.CreateSubKey(GUID);
            if (key != null)
            {
                key.SetValue("ApplicationPath", viewModel.InstallFolderPath);
                key.SetValue("BundleCachePath", $@"{cacheFilePath} ");
                key.SetValue("BundleProviderKey", GUID);
                key.SetValue("BundleVersion", "1.0.0.0");
                key.SetValue("DisplayIcon", $@"{cacheFilePath} ,0");
                key.SetValue("DisplayName", ApplicationName);
                key.SetValue("DisplayVersion", Settings.Version);
                key.SetValue("EstimatedSize", EstimatedSize);
                key.SetValue("Installed", 1);
                key.SetValue("NoElevateOnModify", 1);
                key.SetValue("Publisher", Manufacturer);
                key.SetValue("QuietUninstallString", 1);
                key.SetValue("UninstallString", $"{cacheFilePath} /uninstall");
                key.SetValue("URLInfoAbout", SupportUrl);
            }
        }

Удаление происходит по обратному сценарию. Удаляем папку с приложением, ярлыки и очищаем реестр.

Заключение

В результате можно получить любой инсталлятор. Например такой:

Данный пример показывает, что инсталлятор может быть каким захотите. Можно, например, включить воспроизведение видео пока устанавливается софт/игра, создать выбор на установку дополнительных компонентов или приложений и т.п.

Скачать исходники представленного проекта можно здесь.

Читать и комментировать