Добрый день всем! В своей прошлой статье я поставил задачу генерации патчей и начал обзор технологии их создания на Wix (с использованием PatchWiz). Там же мы пришли к выводу, что для полноценного решения задачи нужно «что-то еще». Добро пожаловать в часть 2, где я опишу наши организационный и технический подходы со всеми исходниками.
Для начала я все же скажу, что описываю наш подход, то есть свой опыт и результат, а не догму.
Итак для того чтобы избежать всех указанных в прошлый раз недостатков, нам пришлось разработать правила для создания инсталляций и утилиту для создания патчей.
Наше организационное решение задачи
Решили делать так: инсталляции собираются из merge modules.
Выделена некая базовая сборка (базовая линия), включающая в себя модули с бинарниками, обязательные для всех. Она собирается для каждой новой версии продукта. При чем, если был изменен только Build number, то происходит создание разностного патча, где базовой версией является предыдущая с Build number = 0 (назовем их опорными сборками).
Кружками на оси времени обозначены созданные сборки. Синим цветом обозначены опорные сборки, зеленым – сборки, где дополнительно создавался разностный патч на основе предыдущей опорной сборки. Кроме того, дополнительно, для опорных сборок тоже формируется разностный патч на основе предыдущей опорной сборки.
Таким образом, когда клиент запрашивает у нас последнюю версию, мы уточняем, есть ли у него установленная версия сейчас и каков ее номер. Далее – либо посылается полная сборка либо патч.
Аналогичная схема работает и при автоматическом обновлении: с сервера приходит либо msi, либо msp пакет, который устанавливается стандартными средствами msiexec.
Для понимания реализация этого механизма рассмотрим следующие решение.
Обзор решения
Для начала рассмотрим общую структуру решения (само собой папки названы для наглядности). Полностью его исходники выложены на GitHub, ссылка в конце.
Папки содержат:
1. Непосредственно само приложение.
2. Общие файлы для сбора инсталляций, включая расширение для Wix.
3. Проект инсталляция приложения.
4. Утилита для создания патчей.
5. Батник для теста.
Номера папок отражают порядок необходимых действий. Начнем.
Идем по шагам
Шаг 1. Написать приложение
В нашем случае это будет простое консольное приложение «Hello, World!», у которого мы будем менять версию.
Шаг 2. Пишем общие части для инсталляций
Идем по файлам:
Deploy.Variables.wxi — общие переменные для всех инсталляций компании:
<Include>
<?define Manufacturer="Предприятие"?>
<?define ManufacturerUrl="http://company.ru"?>
<?define Language="1049"?>
<?define Codepage="1251"?>
</Include>
Мне кажется здесь все ясно: указываются переменные, которые будут использоваться во всех проектах инсталляций.
Deploy.Yasen.Variables.wxi — общий Wix файл для всех инсталляций продукта:
<Include>
<?define ProductName="Ясень" ?>
<?define YasenProductCode="{06CABA42-492E-49CE-9849-F85E87442E99}"?>
<?define YasenUpgradeCode="{BA8CCE3C-4267-4291-B330-16EE510F023B}"?>
</Include>
Зачем мы здесь выносим отдельно коды продукта и обновления? Потому что они потом нам потребуются во многих местах (в разных проектах инсталляций и описателе патча).
Deploy.Yasen.ProductContent.wxi – общий файл с разнообразными свойствами инсталляций.
<Include>
<Package
Id="$(var.PackageId)"
InstallerVersion="200"
Compressed="yes"
Languages="$(var.Language)"
SummaryCodepage="$(var.Codepage)"
Comments="Установщик $(var.ProductName)"
Keywords="Лес, пожар, учет, анализ, обмен, данные"
Description="$(var.Subject)"
InstallScope="perMachine"
/>
<!-- Запрос прав администратора -->
<Property Id="MSIUSEREALADMINDETECTION" Value="1" />
<MajorUpgrade DowngradeErrorMessage ="Уже установлена новая версия этого продукта" AllowDowngrades="no" />
<Upgrade Id='$(var.UpgradeCode)' >
<UpgradeVersion
OnlyDetect="no"
Maximum="$(var.ProductVersion)" IncludeMaximum="no"
Property="OLDERVERSIONBEINGUPGRADED"
MigrateFeatures="yes"
/>
</Upgrade>
<!-- Media -->
<Media Id="1" Cabinet="media1.cab" EmbedCab="yes" />
</Include>
В этом файле мы, наконец, начинаем использовать переменные из предыдущих файлов (они будут доступны здесь позже, когда мы будем создавать основной файл инсталляции). Указываются необходимый элемент Media и задается несколько дополнительных (в основном касающихся обновлений):
- MSIUSEREALADMINDETECTION – если = 1, то будет затребованы права реального администратора (имеется ввиду через UAC).
- Элемент MajorUpgrade – указывает возможности для Major upgrade, в нашем случае – разрешено обновление на новую версию и запрещено откат на старую.
- Элемент Upgrade – содержит описание возможных обновлений.
- Элемент UpgradeVersion описывает условия обнаружения предыдущих версий (по UpgradeCode) и действия в случае их обнаружения. Возможные варианты (все не привожу):
Minimum – минимальная версия, которую может обновить данная инсталляция.
Maximum – максимальная версия, которую может обновить данная инсталляция.
IncludeMaximum – «включительно» максимальную версию (по умолчанию — да).
IncludeMinimum – «включительно» минимальную версию.
Property – содержит имя некоторой переменной. В случае, если по указанным версиям будет найден соответствующий продукт, то в эту переменную будут записано их коды через ‘;’. Значения этой переменный может в дальнейшем участвовать, например, при выполнении CustomAction.
OnlyDetect – Если = ‘yes’, то указанные версии будут только обнаруживаться и записываться в указанные свойства, но не будет происходить их деинсталляция.
IgnoreRemoveFailure – сообщает игнорировать ли ошибки при деинсталляции обнаруженной версии.
В нашем случае, UpgradeVersion настроен так, чтобы можно было обновлять все предыдущие версии в рамках нашего UpgradeCode.
Идем далее: Deploy.Yasen.PatchCreation.xml – описатель патчей для данного продукта.
<Include xmlns="http://schemas.microsoft.com/wix/2006/wi">
<?include Deploy.Variables.wxi?>
<?include Deploy.Yasen.Variables.wxi?>
<?define PatchDescription="Обновление $(var.ProductName)"?>
<PatchCreation
Id="$(var.PatchId)" Codepage="$(var.Codepage)"
CleanWorkingFolder="yes"
OutputPath="patch.pcp"
WholeFilesOnly="yes"
>
<PatchInformation
Description="$(var.PatchDescription)"
Comments="$(var.PatchDescription)"
Manufacturer="$(var.Manufacturer)"/>
<PatchMetadata
AllowRemoval="yes"
Description="$(var.PatchDescription)"
ManufacturerName="$(var.Manufacturer)"
TargetProductName="$(var.ProductName)"
MoreInfoURL="$(var.ManufacturerUrl)"
Classification="Update"
DisplayName="$(var.PatchDescription) до версии $(var.PatchVersion)"/>
<Family DiskId="2" Name="$(var.Family)" SequenceStart="5000" >
<UpgradeImage SourceFile="$(var.NewMsi)" Id="NewPackage" >
<TargetImage SourceFile="$(var.BaseMsi)" Order="2" Id="BasePackage" IgnoreMissingFiles="no" Validation = "0x00000912" />
</UpgradeImage>
</Family>
<PatchSequence PatchFamily="$(var.PatchFamily)" Sequence="$(var.PatchVersion)" Supersede="yes" ProductCode="$(var.ProductCode)"/>
</PatchCreation>
</Include>
Структуру PatchCreation мы достаточно подробно рассматривали в части 1. Но здесь можно увидеть минимум 2 очень важных отличия:
- Атрибут Validation у элемента TargetImage. Именно этот атрибут позволяет нам разрешить пропуск патчей. Подробно он описан здесь. Итак, чтобы разрешить обновлять несколько версий, обычно надо указывать их все (элементами TargetImage), что в свою очередь увеличивает размер патча. Учитывая наши требования и организационные меры (принцип версий), мы обошлись указанием только предыдущей опорной сборки и флагом Validation со значение 912. Это значение указывает, что для применения патча на установленный продукт должно выполняться условие: UpgradeCode, ProductCode и magor и minor версии патча (до которой обновляем) и установленной версии должны совпадать (а build number — нет). Таким образом, пропуски обновлений внутри minor версии будут разрешены! Дай пять! Значение Validation может принимать довольно широкий диапазон значений и можно получить очень интересный эффект. Enjoy!
- Также в файле PatchCreation используются переменные, которые нигде не были объявлены ранее, например: var.PatchFamily, var.PatchVersion. Их использование нам станет ясно на шаге 4.
И еще на шаге 2 присутствует некий проект Incom.WixExtensions (в папке). Это проект с расширением для Wix. Он логически должен присутствовать именно здесь, а его использование станет понятно на шаге 3.
Шаг 3. Собираем инсталляцию для некоторого заказчика
Сначала подключим Wix файлы с переменными, которые мы объявили до этого.
<?define WixCommonPath="$(var.ProjectDir).."?>
<?include $(var.WixCommonPath)Deploy.Variables.wxi?>
<?include $(var.WixCommonPath)Deploy.Yasen.Variables.wxi?>
Затем объявим версию ПО и текущие коды продукта и линейки обновления.
<?define Subject="Калининградская область"?>
<?define ProductVersion="$(incom.FileVersion($(var.Yasen.UI.TargetPath)))"?>
<?define UpgradeCode="$(var.YasenUpgradeCode)"?>
<?define ProductCode="$(incom.ChangeGuid($(var.YasenProductCode),$(var.ProductVersion), 2))"?>
Обратите внимание, что UpgradeCode используется «как есть», а для кода продукта применяется некое преобразование.
Используя это преобразование, мы стремимся к следующей цели: при изменении Build number Product Code не должен меняться, чтобы можно было создать разностный патч, а при изменении Major или Minor версии – Product Code должен меняться (это было описано в первой части). Соответственно, мы делаем следующее: используем код продукта из глобальной переменной и вызываем функцию преобразования, указывая от каких частей версии итоговая версия зависит. При чем, 1 – только от major, 2 – от major и minor, и далее по аналогии до значения 4.
Настало время для упомянутого выше расширения для Wix.
Так как цель статьи не в описании технологии создания расширений для Wix (можно посмотреть тут), то я приведу суть кратко: создается расширение для препроцессора, у которого переопределен метод EvaluateFunction. Он будет вызывается Wix’ом при использовании функций с префиксом incom.
В этом методе мы выполняем 2 функции:
• Получение версии файла
• Универсальная функция изменения Guid
/// <summary>
/// Выполнить функцию
/// </summary>
/// <param name="prefix">Префикс</param>
/// <param name="function">Имя функции</param>
/// <param name="args">Аргументы</param>
/// <returns>Вычисленное значение</returns>
public override string EvaluateFunction(string prefix, string function, string[] args)
{
if (prefix == "incom")
{
switch (function.ToLower())
{
case "fileversion":
var ver = FileVersionInfo.GetVersionInfo(Path.GetFullPath(args[0])).FileVersion;
Console.WriteLine(string.Format("Version of {0}: {1}", args[0], ver));
return ver;
case "changeguid":
var guid = Guid.Parse(args[0]).ToByteArray();
version = Version.Parse(args[1]);
var major = BitConverter.GetBytes((Int16)((version.Major & 0xFF) ^ ((version.Major >> 16) & 0xFF)));
var minor = BitConverter.GetBytes((Int16)((version.Minor & 0xFF) ^ ((version.Minor >> 16) & 0xFF)));
var build = BitConverter.GetBytes((Int16)((version.Build & 0xFF) ^ ((version.Build >> 16) & 0xFF)));
var revision = BitConverter.GetBytes((Int16)((version.Revision & 0xFF) ^ ((version.Revision >> 16) & 0xFF)));
var len = 4;
if (args.Length > 2)
len = int.Parse(args[2]);
if (len > 0)
{
guid[0] = major[0];
guid[1] = major[1];
}
if (len > 1)
{
guid[2] = minor[0];
guid[3] = minor[1];
}
if (len > 2)
{
guid[4] = build[0];
guid[5] = build[1];
}
if (len > 3)
{
guid[6] = revision[0];
guid[7] = revision[1];
}
return new Guid(guid).ToString();
}
}
return base.EvaluateFunction(prefix, function, args);
}
Таким образом, наши коды ведут себя предсказуемо и зависят от версии продукта. Когда мы меняем код немного – мы меняем Build number (раз в 2-3 недели), что-то посерьезней – меняем Minor number (примерно раз в 2-3 месяца), чтобы сформировалась опорная сборка. Когда переписывается все – меняется Major number (примерно раз в 3-4 года).
Вернемся к wix файлам. Далее все стандартно: используя переменные, объявленные выше, создаем блок Product, указываем файлы, компоненты, фичи, используем Deploy.Yasen.ProductContent, описанный на предыдущем шаге.
<Product Id="$(var.ProductCode)"
Name="$(var.ProductName)"
Language="$(var.Language)"
Version="$(var.ProductVersion)"
Manufacturer="$(var.Manufacturer)"
UpgradeCode="$(var.UpgradeCode)" >
<?include $(var.WixCommonPath)Deploy.Yasen.ProductContent.wxi?>
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="ProgramFilesFolder">
<Directory Id="APPLICATIONFOLDER" Name="$(var.ProductName)" DiskId="1" >
<!-- Content -->
<Component Id="Component1" Guid="{1F4A6EF3-4B65-4405-8E08-D750E5038C75}">
<File Id="File1" Name="content.txt" Source="....Incom.Yasen.Contentcontent.txt"/>
<File Id="File2" Name="Yasen.UI.exe" Source="$(var.Yasen.UI.TargetPath)"/>
</Component>
</Directory>
</Directory>
</Directory>
<Feature Id="ClientSide" Title="Клиент $(var.ProductName)" Level="1" Absent="disallow">
<ComponentRef Id="Component1"/>
</Feature>
</Product>
Шаг 4. Создание патча
Сейчас мы имеем все необходимое для создания инсталляций нашего продукта и это уже даже можно сделать, но наша цель – патчи.
Как было отмечено в части 1, создавать патчи из командной строки – дело довольно хлопотное, поэтому на этом шаге мы пишем новую утилиту MakeMsp, которая выполняет эти шаги за нас. Требования по использованию будут такие: в аргументах указывается базовая сборка, конечная сборка, описатель патчей и путь к результату.
Incom.MakeMsp.exe "YasenSetup1.msi" "YasenSetup1.0.1.msi" "Deploy.Yasen.PatchCreation.xml" "Patch.msp"
В общем алгоритм следующий:
1. Копируем обе msi во временную папку
Task.WaitAll(
Task.Run(
() =>
{
Console.WriteLine("Start copying RTM...");
File.Copy(args[0], rtmFilePath, true);
Console.WriteLine("Finished copying RTM...");
})
,
Task.Run(
() =>
{
Console.WriteLine("Start copying latest...");
File.Copy(args[1], latestFilePath, true);
Console.WriteLine("Finished copying latest...");
}));
2. Помните, на шаге 2, при создании PatchCreation, мы использовали неизвестные переменные? Настало время их определить. Для этого утилита создает временный файл с Wix структурой, куда записываются значения этих переменных.
var productName = MsiReader.GetMSIParameters(latestFilePath, "ProductName");
var wixPachCreationReference = string.Format(
@"<?xml version=""1.0"" encoding=""utf-8""?>
<Wix xmlns='http://schemas.microsoft.com/wix/2006/wi'>
<?define Family='{0}'?>
<?define PatchFamily='{0}'?>
<?define PatchId='{1}'?>
<?define ProductCode='{2}'?>
<?define PatchVersion='{3}'?>
<?define BaseMsi='{4}'?>
<?define NewMsi='{5}'?>
<?include {6}?>
</Wix>",
new string(Transliterate(productName).Where(char.IsLetterOrDigit).Take(8).ToArray()),
Guid.NewGuid().ToString(),
MsiReader.GetMSIParameters(latestFilePath, "ProductCode"),
MsiReader.GetMSIParameters(latestFilePath, "ProductVersion"),
Path.Combine(rtmPath, "rtm.msi"),
Path.Combine(latesPath, "last.msi"),
Path.GetFullPath(args[2])
);
В качестве PatchFamily используется имя продукта с транслитерацией. PatchId – новый Guid. ProductCode, PatchVersion – извлекаются из конечной msi, BaseMsi и NewMsi – пути ко временным msi (скопированным во временную папку). В конце делается include на сам файл с PatchCreation.
3. Далее выполняются шаги компиляции, описанные в части 1.
Административная установка:
exec("msiexec", string.Format("/a "{0}" /qn TARGETDIR="{1}\"", rtmFilePath, rtmPath));
exec("msiexec", string.Format("/a "{0}" /qn TARGETDIR="{1}\"", latestFilePath, latesPath));
Компиляция и создание патча:
exec("candle", string.Format(""{0}" -out "{1}\patch.wixobj"", Path.Combine(tempDir, "desc.xml"), tempDir));
exec("light", string.Format(""{0}\patch.wixobj" -out "{0}\patch.pcp"", tempDir));
exec("msimsp", string.Format("-s "{0}\patch.pcp" -p "{1}" -l "{0}\msimsp.log"", tempDir, args[3]));
Шаг 5. Компиляция всего и вся
Теперь у нас есть все, что нам надо: проект инсталляции, расширения для Wix, описатель патча, утилита для создания патча. Настало время собрать все вместе.
Для этого в корне решения есть файл CompileAll.bat, который соберет все это вместе (требуется framework 4.0) и положит результат в папку Releases. Можете увидеть все это в исходниках.
Использование результатов
Полученный результат после запуска CompileAll.bat.
Обновление патчами
Поставить версию 1 можно просто по DblClick. Результат:
Поставить патч тоже можно по DblClick:
Просмотр установленных обновлений покажет это:
Обновление полным пакетом
Если нам потребуется обновить версию с 1.0.0 до 1.0.1 при помощи полного пакета msi, то придется использовать консоль со следующими параметрами:
msiexec /i YasenSetup1.0.1.msi REINSTALL=ALL REINSTALLMODE=vomus
Здесь:
REINSTALL – Указывает, какие фичи будут переустановлены данным пакетом при обновлении (мы указываем, что все). Если мы это свойство не укажем, то при попытке запуска пакета (и установленной старой версией) будет выдано сообщение «Другая версия уже установлена». (подробности)
REINSTALLMODE – свойство, которое указывает как именно будет проходить переустановка (обновление) файлов. (подробности). В нашем случае это:
v – необходимо перекэшировать пакет в локальном хранилище. Дело в том, что для каждого продукта (ProductCode) Windows запоминает значение Package.Id, откуда был поставлен продукт. Если продукт уже установлен и выполняется попытка установить даже идентичный по содержанию и версии пакет, но с другим Package.Id (просто сделали перестроение решения), то кэшированное значение Package.Id не совпадет с Package.Id новой инсталляции и будет выдано предупреждение, что установлена другая версия. При наличии «v» Package.Id не будет проверяться на соответствие.
o – переписываем файл, если версия текущего меньше новой, либо файл отсутствует.
m – перезаписываются ключи реестра (HKEY_LOCAL_MACHINE и HKEY_CLASSES_ROOT)
u – перезаписываются ключи реестра (HKEY_CURRENT_USER и HKEY_USERS)
s – переписать все ярлыки и переписать кэш иконок.
Есть варианты, как избежать этого неудобного способа обновления и добиться работы только по DblClick, но это уже совсем другая история.
Подытожим
Итак, для решения задачи были сделаны следующие шаги:
1) Выработаны специальные требования для написания Wix инсталляций для конечных пользователей, которые включают в себя:
a. Вынести все, что возможно в общие файлы, merge modules и использовать директиву include.
b. Генерировать Product.ProductCode на основе базового значения.
2) Создать единый проект для генерации патчей.
3) Сделать специальную утилиту, которая поможет формировать патчи.
Окончательный итог
Wix помогает решить большую головную боль, за что ему большое спасибо.
Конечно, некоторые вещи остались в тени, но самое важно и ценное представлено читателю.
Спасибо.
Ссылки:
Все исходники на GitHub
Автор: neisbut