Кастомизация окна прогресса в оболочке Windows

в 13:01, , рубрики: api, dolus, GUI, ruvds_перевод, windows, диалоговые окна, загрузчик windows, Программирование

Кастомизация окна прогресса в оболочке Windows - 1


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

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

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

Кастомизация окна прогресса в оболочке Windows - 2

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

▍ Поиск компромиссов с 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();

Кастомизация окна прогресса в оболочке Windows - 3

▍ Кастомизация диалогового окна

Несмотря на то, что интерфейс 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) иконки.

Кастомизация окна прогресса в оболочке Windows - 4

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

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

Видите ли, в прекрасном мире Windows всё является окном.

Кнопка? Тоже окно. Текстовые метки? Окна. Шкала прогресса? Можете не верить, но и она тоже является окном.

Кастомизация окна прогресса в оболочке Windows - 5

И эта вселенская «оконность» в данном случае оборачивается для нас благом. То есть мы сможем манипулировать практически любым элементом, если просто заполучим его дескриптор. Так что давайте разберёмся, как найти и изменить текст этой злополучной кнопки «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 требуется небольшой пинок.

Кастомизация окна прогресса в оболочке Windows - 6

▍ Расширение функциональности окна: режим 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, и происходит отправка нужных сообщений для запуска/прекращения анимации в форме бегущего индикатора.

Кастомизация окна прогресса в оболочке Windows - 7

▍ Дилемма с обновлением прогресса

А вот здесь самое интересное. Несмотря на то, что наш переключатель 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 - 8

▍ Подытожим

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

Если вам интересны более подробные детали, или же вы хотите сами просмотреть весь код загрузчика, то он доступен на GitHub.

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

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

Автор: Bright_Translate

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js