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

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

Итак, что у нас остаётся? Можно углубиться в Windows API и создать собственное «окно», но это кроличья нора, сулящая кошмары при обслуживании. К счастью, в Windows есть диалоговое окно прогресса.



По сути, установка ПО — это просто комбинация различных задач. При этом прогресс одних оказывается вполне очевиден, а других — не особо. На первый взгляд, штатное окно прогресса неплохо обрабатывает и те и другие, предоставляя пользователям достаточно обратной связи для понимания динамики процесса. Но как мы вскоре поймём, заставить его гармонично работать в контексте конкретно наших нужд будет не так просто.

▍ Поиск компромиссов с AOT


Windows Progress Dialog — это компонент оболочки, доступный для стороннего кода посредством COM-интерфейса IProgressDialog. В типичном сценарии .NET мы бы импортировали этот интерфейс, инициализировали его экземпляр и получили желаемый результат. Но наш AOT-компилируемый загрузчик привносит свои нюансы.

▍ Традиционный подход (который использовать не получится)


Обычно мы бы проделали что-то типа такого:

  1. Определили интерфейс COM с нужными атрибутами:

    [ComImport, Guid("EBBC7C04-315E-11d2-B62F-006097DF5BD4")]
    public interface IProgressDialog { /* ... */ }

  2. Создали его экземпляр:

    var type = Type.GetTypeFromCLSID(
     new Guid("{F8383852-FCD3-11d1-A6B9-006097DF5BD4}"));
    var dialog = (IProgressDialog)Activator.CreateInstance(type);

  3. Использовали диалоговое окно и произвели очистку:

    try {
        dialog.StartProgressDialog(/* ... */);
    } finally {
        Marshal.FinalReleaseComObject(dialog);
    }

Вроде всё просто, не так ли? Но не спешите.

▍ AOT-усложнение


AOT-компиляция отменяет встроенную поддержку COM, что означает:

  • Отсутствие инициализации типов на основе отражения (reflection).
  • Отсутствие автоматической совместимости через COM.

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

▍ Генерация кода для ComWrappers


В .NET 6+ есть спасательный круг, а именно возможность генерации кода для ComWrappers. Она позволяет в процессе компиляции генерировать код для функциональной совместимости с COM, тем самым обходя ограничения AOT.

Используем мы её так:

  1. Переопределим интерфейс с атрибутами генерации исходного кода:

    [GeneratedComInterface]
    [Guid("EBBC7C04-315E-11d2-B62F-006097DF5BD4")]
    [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
    public partial interface IProgressDialog
    {
        void StartProgressDialog(nint hwndParent, nint punkEnableModless,
            PROGDLG dwFlags, nint pvResevered);
        // ... остальные методы ...
    }

  2. Вручную создадим объект COM:

    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;
    }

  3. Используем 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.

▍ Установка своей иконки


Одним из первых дел вы можете решить назначить для своего установщика собственную иконку. Реализовать эту задачу можно в три шага:

  1. По заголовку отыскать диалоговое окно после его создания:

    HWND dialogWindow = PInvoke.FindWindow(null, _title);

  2. Располагая дескриптором окна, установить для него иконку, которую предварительно нужно загрузить из массива байтов. Давайте разберём всю логику процесса её извлечения:

    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);
        }
    }
    

    Этот метод проделывает несколько важных действий:

    • Проверяет данные иконки, чтобы убедиться в её правильном формате.
    • Парсит каталог иконок в поиске всех иконок в файле.
    • Выбирает крупнейшую иконку, которая обычно имеет самое высокое качество.
    • Извлекает необработанные данные выбранной иконки.
    • Наконец, используя Windows API, создаёт на основе извлечённых данных дескриптор иконки.

  3. Загрузив иконку, мы можем установить её в диалоговое окно:

    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) иконки.



▍ Изменение текста кнопки Cancel


Вы можете подумать, что изменить текст какой-то там кнопки будет несложно, но тут вас ждёт сюрприз, поскольку интерфейс 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);
    }
}

Этот метод выполняет несколько ключевых действий:

  1. Начинает с родительского окна и ищет все дочерние окна класса "CtrlNotifySink".
  2. Внутри каждого "CtrlNotifySink" ищет окна "Button".
  3. В отношении каждой найденной кнопки проверяет, видима ли она.
  4. Если находит видимую кнопку (в диалоговом окне такая всего одна), сохраняет её дескриптор. Готово!

Ну а теперь, когда у нас есть дескриптор кнопки «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}");
    }
}

Что здесь происходит:

  1. Мы проверяем, есть ли у нас валидный дескриптор кнопки отмены.
  2. Используем SetWindowText для изменения текста кнопки. Да, даже этот текст в Windows является просто текстом окна.
  3. Принудительно вызываем повторную отрисовку кнопки, так как иногда Windows требуется небольшой пинок.



▍ Расширение функциональности окна: режим marquee и обновление прогресса


Разобравшись с иконками и текстом кнопки, мы столкнулись с ещё одним ограничением: невозможностью после запуска диалогового окна переключаться между режимом marquee (бегущий индикатор) и стандартным режимом отображения прогресса. Это нетривиальный выбор дизайна, особенно с учётом того, что Microsoft.Windows.Common-Controls такую функциональность поддерживает. Но мы не сдаёмся, и наша философия «Всё является окном» вновь приходит на выручку.

▍ Переключение режима marquee


Переключение режима 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.

А если вы хотите пронаблюдать результат в действии, то почему бы не познакомиться с Dolus? Установить этот инструмент можно отсюда.

Желаем вам успешного программирования, и пусть Windows всегда реагирует на ваши сообщения!

Telegram-канал со скидками, розыгрышами призов и новостями IT ?

Комментарии (7)


  1. KivApple
    06.09.2024 13:41
    +8

    С учётом всех переделок, не проще ли создать окно полностью вручную через WinAPI? Оно не такое уж сложное.

    Ну и исходный поиск окна по заголовку выглядит костылем, так как вдруг есть другие, не наши, окна с таким заголовком.


    1. d_ilyich
      06.09.2024 13:41
      +2

      Мне кажется, вопрос должен стоять "не правильнее ли... ?"

      Помнится, я со времён WinXP первым делом переключался на классический интерфейс. И мне тоже нравились подобные изощрения: изменить стиль элемента или цвет где не предусмотрено, всякие SetWindowLong и т.п. Хитрожопость 80lvl, ага. А потом Win10 и "Привет, я лажа". Стыдно.

      С одной стороны, я понимаю восторг, который испытываешь, когда проделываешь подобные вещи; с другой, если делаешь продукт не для себя, лучше без крайней необходимости не изощряться, ИМХО.


      1. pda0
        06.09.2024 13:41

        Не правильнее. Потому что гарантированно не выбиваться из стиля это будет лишь в текущей версии Windows.

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


        1. Romano
          06.09.2024 13:41

          Т. е. правильнее использовать методы описанные в статье?

          Функционал "штатных окон" сильно ограничен, поэтому кастомные диалоги есть у многих приложений сложнее калькулятора. И делать такие диалоги лучше полностью вручную через WinAPI, чем обкладывать костылями существующие. И тут будет меньше проблем с соблюдением стиля на разных версиях ОС.


        1. d_ilyich
          06.09.2024 13:41
          +1

          В моём понимании "правильно" означает использовать или создать изначально полностью подконтрольную сущность, нежели брать нечто не вполне подходящее и не предназначенное для "обширной кастомизации", а потом изощряться. Ну, или работать с тем, что есть в штатном режиме. Тут ведь не исходный код модифицируется под свои нужды, а уже готовый "объект", который не предоставляет штатных средств. Да и имеющиеся штатные средства приходится подпинывать (обновление прогресса), потому что они не расчитаны на работу в кастомизированном режиме.

          Если создать диалог из стандартных элементов ОС, то из стиля ничего и не выбьется. А точную копию делать я и не предлагал.

          Я восхищаюсь пытливостью и изобретательностью, сам ещё не забыл каково это. За это и плюсанул. Но я бы возражал против такого решения "в проде".


    1. HardWrMan
      06.09.2024 13:41
      +3

      Тоже об этом подумал. Помню, купил и почитал книжку Зубкова (обложка ниже) и бегом создавать окна в ассемблере. После этого стало понятно наполнение описателя ресурсов окна *.dfm у Борландов...

      А ещё, игры со Spy++ тоже увлекали, узнать хэндл таскбара или кнопки "Пуск", чтобы послать ему WM_CLOSE, это же так весело!


  1. dude_sam
    06.09.2024 13:41

    С учётом всех переделок, не проще ли ©KivApple открыть консоль и "рисовать" символами по-олдскульному.

    Да, и в вашей теме это будет выглядеть как-то более сурово и уважительно.