- PVSM.RU - https://www.pvsm.ru -

Но есть здесь и нюанс: загрузчик — это первое, что встречают пользователи, поэтому ему нужен GUI. А поскольку написан он на C# и с целью сохранения лёгкости компилируется перед исполнением (AOT, ahead-of-time), традиционные решения исключаются. Соблазнительным вариантом выглядит Avalonia, но в этом случае сам установщик станет больше той программы, которую он должен устанавливать.
Итак, что у нас остаётся? Можно углубиться в Windows API и создать собственное «окно», но это кроличья нора, сулящая кошмары при обслуживании. К счастью, в Windows есть диалоговое окно прогресса.

По сути, установка ПО — это просто комбинация различных задач. При этом прогресс одних оказывается вполне очевиден, а других — не особо. На первый взгляд, штатное окно прогресса неплохо обрабатывает и те и другие, предоставляя пользователям достаточно обратной связи для понимания динамики процесса. Но как мы вскоре поймём, заставить его гармонично работать в контексте конкретно наших нужд будет не так просто.
Windows Progress Dialog — это компонент оболочки, доступный для стороннего кода посредством COM-интерфейса IProgressDialog. В типичном сценарии .NET мы бы импортировали этот интерфейс, инициализировали его экземпляр и получили желаемый результат. Но наш AOT-компилируемый загрузчик привносит свои нюансы.
Обычно мы бы проделали что-то типа такого:
[ComImport, Guid("EBBC7C04-315E-11d2-B62F-006097DF5BD4")]
public interface IProgressDialog { /* ... */ }
var type = Type.GetTypeFromCLSID(
new Guid("{F8383852-FCD3-11d1-A6B9-006097DF5BD4}"));
var dialog = (IProgressDialog)Activator.CreateInstance(type);
try {
dialog.StartProgressDialog(/* ... */);
} finally {
Marshal.FinalReleaseComObject(dialog);
}
Вроде всё просто, не так ли? Но не спешите.
AOT-компиляция отменяет встроенную поддержку COM, что означает:
В итоге мы остаёмся у разбитого корыта, лишённые возможности использовать присущую .NET функциональную совместимость через COM. Но не стоит отчаиваться, так как правильно приложенные усилия вкупе с продуманными функциями .NET позволят нам найти выход.
В .NET 6+ есть спасательный круг, а именно возможность генерации кода для ComWrappers [1]. Она позволяет в процессе компиляции генерировать код для функциональной совместимости с COM, тем самым обходя ограничения AOT.
Используем мы её так:
[GeneratedComInterface]
[Guid("EBBC7C04-315E-11d2-B62F-006097DF5BD4")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public partial interface IProgressDialog
{
void StartProgressDialog(nint hwndParent, nint punkEnableModless,
PROGDLG dwFlags, nint pvResevered);
// ... остальные методы ...
}
private nint CreateComObject()
{
Guid clsid = new Guid("F8383852-FCD3-11d1-A6B9-006097DF5BD4");
Guid iid = typeof(IProgressDialog).GUID;
int hr = Ole32.CoCreateInstance(ref clsid, IntPtr.Zero,
(uint)CLSCTX.CLSCTX_INPROC_SERVER, ref iid, out nint ptr);
if (hr != 0)
Marshal.ThrowExceptionForHR(hr);
return ptr;
}
ComWrappers для получения управляемого объекта:
var comWrappers = new StrategyBasedComWrappers();
var dialogPointer = CreateComObject();
var dialog = (IProgressDialog)comWrappers
.GetOrCreateObjectForComInstance(
dialogPointer, CreateObjectFlags.None);
Теперь можно использовать объект dialog так, будто нам полностью доступна функциональность COM:
dialog.StartProgressDialog(
IntPtr.Zero, IntPtr.Zero, PROGDLG.Normal, IntPtr.Zero);
// ... использование диалогового окна ...
dialog.StopProgressDialog();

Несмотря на то, что интерфейс IProgressDialog обеспечивает нам хорошую отправную точку, у него есть свои ограничения. Например, он позволяет настроить заголовок, но не даёт возможности установить собственную иконку.
К счастью, мы не ограничены одними лишь возможностями IProgressDialog. В конце концов мы имеем дело со стандартным диалоговым окном Windows, а значит, можем дополнительно его кастомизировать с помощью Windows API.
Одним из первых дел вы можете решить назначить для своего установщика собственную иконку. Реализовать эту задачу можно в три шага:
HWND dialogWindow = PInvoke.FindWindow(null, _title);
private static DestroyIconSafeHandle LoadIconFromByteArray(
byte[] iconData)
{
if (iconData == null || iconData.Length == 0)
{
throw new ArgumentException(
"Icon data is null or empty", nameof(iconData));
}
try
{
// Проверяем, с правильного ли заголовка иконки начинаются её данные
if (iconData.Length < 6 || iconData[0] != 0 ||
iconData[1] != 0 || iconData[2] != 1 ||
iconData[3] != 0)
{
throw new ArgumentException(
"Invalid icon format. Expected .ico file data.");
}
ushort iconCount = BitConverter.ToUInt16(iconData, 4);
Debug.WriteLine($"Icon count: {iconCount}");
int largestIconIndex = -1;
int largestIconSize = 0;
int largestIconOffset = 0;
// Парсим каталог иконок в поиске самой большой
for (int i = 0; i < iconCount; i++)
{
int entryOffset = 6 + (i * 16);
// 6 байтов для заголовка, 16 на запись
if (entryOffset + 16 > iconData.Length) break;
int width = iconData[entryOffset] == 0 ?
256 : iconData[entryOffset];
int height = iconData[entryOffset + 1] == 0 ?
256 : iconData[entryOffset + 1];
int size = BitConverter.ToInt32(iconData,
entryOffset + 8);
int offset = BitConverter.ToInt32(iconData,
entryOffset + 12);
if (width * height > largestIconSize)
{
largestIconSize = width * height;
largestIconIndex = i;
largestIconOffset = offset;
}
}
if (largestIconIndex == -1)
{
throw new ArgumentException(
"No valid icon found in the data");
}
// Извлекаем данные самой крупной иконки
int dataSize = iconData.Length - largestIconOffset;
byte[] resourceData = new byte[dataSize];
Array.Copy(iconData, largestIconOffset, resourceData, 0, dataSize);
DestroyIconSafeHandle hIcon = PInvoke.CreateIconFromResourceEx(
new Span<byte>(resourceData),
true,
0x00030000, // MAKELONG(3, 0)
default,
default,
IMAGE_FLAGS.LR_DEFAULTCOLOR);
if (hIcon.IsInvalid)
{
int error = Marshal.GetLastWin32Error();
throw new Exception($"Failed to create icon. Error code: " +
$"{error}, Icon data size: {resourceData.Length}");
}
return hIcon;
}
catch (Exception ex)
{
Debug.WriteLine($"Error creating icon: {ex}");
throw new Exception(
"Error creating icon from byte array. " +
$"Details: {ex.Message}", ex);
}
}
Этот метод проделывает несколько важных действий:
if (_icon is not null && !_icon.IsInvalid)
{
PInvoke.SendMessage(dialogWindow, PInvoke.WM_SETICON,
new WPARAM(0), new LPARAM(_icon.DangerousGetHandle()));
PInvoke.SendMessage(dialogWindow, PInvoke.WM_SETICON,
new WPARAM(1), new LPARAM(_icon.DangerousGetHandle()));
}
Этот код отправляет в наше диалоговое окно сообщение WM_SETICON, устанавливая как малые (0), так и большие (1) иконки.

Вы можете подумать, что изменить текст какой-то там кнопки будет несложно, но тут вас ждёт сюрприз, поскольку интерфейс IProgressDialog не предоставляет для этого способа. К счастью, мы работаем с Windows, где всегда можно найти выход — обычно с привлечением дополнительных окон.
Видите ли, в прекрасном мире Windows всё является окном.
Кнопка? Тоже окно. Текстовые метки? Окна. Шкала прогресса? Можете не верить, но и она тоже является окном.

И эта вселенская «оконность» в данном случае оборачивается для нас благом. То есть мы сможем манипулировать практически любым элементом, если просто заполучим его дескриптор. Так что давайте разберёмся, как найти и изменить текст этой злополучной кнопки «Cancel».
Первым делом нужно отыскать саму кнопку. Для этого потребуется немного покопаться в оконных дебрях:
private unsafe void FindCancelButton(HWND directUIHWNDHandle)
{
HWND ctrlNotifySinkHandle =
PInvoke.FindWindowEx(directUIHWNDHandle, HWND.Null,
"CtrlNotifySink", null);
while (!ctrlNotifySinkHandle.IsNull)
{
Console.WriteLine($"Searching for cancel button in " +
$"CtrlNotifySink handle: {ctrlNotifySinkHandle.Value}");
HWND buttonHandle =
PInvoke.FindWindowEx(ctrlNotifySinkHandle, HWND.Null,
"Button", null);
while (!buttonHandle.IsNull)
{
Console.WriteLine($"Found a Button handle: " +
$"{buttonHandle.Value}");
_cancelButtonHandle = buttonHandle;
// Проверяем, видима ли кнопка.
if (PInvoke.IsWindowVisible(buttonHandle))
{
Console.WriteLine("Found actual handle");
return;
}
buttonHandle =
PInvoke.FindWindowEx(ctrlNotifySinkHandle,
buttonHandle, "Button", null);
}
ctrlNotifySinkHandle =
PInvoke.FindWindowEx(directUIHWNDHandle,
ctrlNotifySinkHandle, "CtrlNotifySink", null);
}
}
Этот метод выполняет несколько ключевых действий:
"CtrlNotifySink"."CtrlNotifySink" ищет окна "Button".Ну а теперь, когда у нас есть дескриптор кнопки «Cancel», можно изменить её текст:
public void SetCancelButtonText(string newText)
{
if (_cancelButtonHandle.IsNull)
{
return;
}
if (PInvoke.SetWindowText(_cancelButtonHandle, newText))
{
// Вызываем повторную отрисовку кнопки Cancel
RECT? rect = null;
PInvoke.InvalidateRect(_cancelButtonHandle, rect, true);
PInvoke.UpdateWindow(_cancelButtonHandle);
}
else
{
int error = Marshal.GetLastWin32Error();
Console.WriteLine($"Failed to set Cancel button text. " +
$"Error code: {error}");
}
}
Что здесь происходит:
SetWindowText для изменения текста кнопки. Да, даже этот текст в Windows является просто текстом окна.

Разобравшись с иконками и текстом кнопки, мы столкнулись с ещё одним ограничением: невозможностью после запуска диалогового окна переключаться между режимом marquee (бегущий индикатор) и стандартным режимом отображения прогресса. Это нетривиальный выбор дизайна, особенно с учётом того, что Microsoft.Windows.Common-Controls такую функциональность поддерживает. Но мы не сдаёмся, и наша философия «Всё является окном» вновь приходит на выручку.
Переключение режима marquee заключается в манипулировании стилем окна шкалы прогресса. Вот важнейшая часть нашей реализации:
if (_state != ProgressDialogState.Stopped &&
!_progressBarHandle.IsNull)
{
int style = (int)GetWindowLongPtr(_progressBarHandle,
(int)GWL.GWL_STYLE);
if (value) // Включение marquee
{
style |= (int)PBS.PBS_MARQUEE;
SetWindowLongPtr(_progressBarHandle, (int)GWL.GWL_STYLE,
(IntPtr)style);
PInvoke.SendMessage(_progressBarHandle, PBM_SETMARQUEE,
PInvoke.MAKEWPARAM(1, 0), 0);
}
else // Выключение marquee
{
style &= ~(int)PBS.PBS_MARQUEE;
SetWindowLongPtr(_progressBarHandle, (int)GWL.GWL_STYLE,
(IntPtr)style);
PInvoke.SendMessage(_progressBarHandle, PBM_SETMARQUEE,
PInvoke.MAKEWPARAM(0, 0), 0);
// Сброс диапазона и позиции
PInvoke.SendMessage(_progressBarHandle, PBM_SETRANGE32,
0, _maximum);
PInvoke.SendMessage(_progressBarHandle, PBM_SETPOS,
PInvoke.MAKEWPARAM((ushort)_value, 0), 0);
}
}
В этом фрагменте кода показано, как включается флаг стиля PBS_MARQUEE, и происходит отправка нужных сообщений для запуска/прекращения анимации в форме бегущего индикатора.

А вот здесь самое интересное. Несмотря на то, что наш переключатель marquee прекрасно работал, мы обнаружили, что вызов nativeProgressDialog.SetProgress после принудительной смены режима ни к чему не приводит. Похоже, что IProgressDialog сохраняет некое внутреннее состояние и, думая, что всё ещё находится в режиме marquee, прогресс не обновляет.
Но вспомним, что у нас есть отдельная строка для окна шкалы прогресса. Мы можем передать IProgressDialog полностью и обновить прогресс сами:
private void UpdateProgress()
{
if (_nativeProgressDialog != null &&
_state != ProgressDialogState.Stopped)
{
_nativeProgressDialog.SetProgress(
(uint)_value, (uint)_maximum);
if (!_progressBarHandle.IsNull && !Marquee)
{
// Непосредственное обновление шкалы прогресса
PInvoke.SendMessage(_progressBarHandle, PBM_SETPOS,
PInvoke.MAKEWPARAM((ushort)_value, 0), 0);
}
}
}
Отправляя сообщение PBM_SETPOS напрямую в окно шкалы прогресса, мы обеспечиваем его обновление вне зависимости от того, какой режим себе воображает IProgressDialog.
Совместив всё описанное, мы получаем полностью кастомизированное окно прогресса:

Мы провернули обширный процесс по кастомизации окна прогресса в оболочке Windows, превратив простой компонент в гибкий, настраиваемый инструмент для нашего установщика. В ходе этого процесса мы увидели, как понимание Windows API способно открыть возможности, выходящие далеко за те, которые предоставляет нам поверхностный интерфейс.
Если вам интересны более подробные детали, или же вы хотите сами просмотреть весь код загрузчика, то он доступен на GitHub [2].
А если вы хотите пронаблюдать результат в действии, то почему бы не познакомиться с Dolus? Установить этот инструмент можно отсюда [3].
Желаем вам успешного программирования, и пусть Windows всегда реагирует на ваши сообщения!
Автор: Bright_Translate
Источник [4]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/programmirovanie/395913
Ссылки в тексте:
[1] генерации кода для ComWrappers: https://learn.microsoft.com/en-us/dotnet/standard/native-interop/comwrappers-source-generation
[2] доступен на GitHub: https://github.com/dolusapp/dboot
[3] отсюда: https://dolus.app/install/
[4] Источник: https://habr.com/ru/companies/ruvds/articles/840706/?utm_source=habrahabr&utm_medium=rss&utm_campaign=840706
Нажмите здесь для печати.