Thursday, June 10, 2010

Перехват сообщений Windows в WPF-приложении. Аналог WndProc.

Проблема

Для второй версии монитора буфера обмена (с которой мы будем разбираться в следующем посте) мы создадим WPF-приложение. Однако, у Windows Presentation Foundation есть одно существенное отличие от WinForms — в WPF-форме такой код из первой версии работать не будет:
protected override void WndProc(ref Message m)
{
    switch (m.Msg)
    {
        case WM_DRAWCLIPBOARD:
            // содержимое буфера обмена изменилось
            {
                // реагируем на изменение буфера
                ClipboardChanged();
                // пересылаем сообщение следующему окну в цепочке
                SendMessage(nextClipboardViewer, WM_DRAWCLIPBOARD, IntPtr.Zero, IntPtr.Zero);
                break;
            }
        case WM_CHANGECBCHAIN:
            // цепочка приложений, подписанных на WM_DRAWCLIPBOARD изменилась
            {
                if (m.WParam == nextClipboardViewer)
                {
                    // окно, которому мы передавали WM_DRAWCLIPBOARD, удалено из
                    // цепочки, и нам нужно обновить переменную
                    nextClipboardViewer = m.LParam;
                }
                else
                {
                    // просто передаем новости дальше
                    SendMessage(nextClipboardViewer, WM_CHANGECBCHAIN, m.WParam, m.LParam);
                }
                m.Result = IntPtr.Zero; // уведомляем систему об обработке сообщения
                break;
            }
        default:
            // поведение для остальных сообщений - по умолчанию
            {
                base.WndProc(ref m); 
                break;
            }
    }
}
Аналогом класса Form в Windows Forms является класс Window, в котором нет метода WndProc, который мы могли бы переопределить. Возвращаемся в Windows Forms? Нет, решение всё-таки существует.

Решение

Для решения нашей задачи предназначен замечательный класс HwndSource, который находится в пространстве имен System.Windows.Interop. HwndSource — класс, который представляет Win32-окно, в котором находится контент WPF. Именно им нужно будет пользоваться, если нам нужно вызывать какие-либо функции WinAPI, которые требуют дескриптор окна в качестве одного из параметров. Также в экземпляре класса HwndSource есть два метода, которые позволяют получить доступ к сообщениям Windows:
public void AddHook(HwndSourceHook hook);
public void RemoveHook(HwndSourceHook hook);
Эти методы позволяют соответственно установить и удалить функцию-перехватчик. Сигнатура HwndSourceHook выглядит так:
public delegate IntPtr HwndSourceHook(
 IntPtr hwnd, // дескриптор окна
 int msg, // сообщение Windows
 IntPtr wParam, // параметры
 IntPtr lParam, // сообщения
 ref bool handled // обработано ли сообщение
)
Чтобы создать экземпляр класса HwndSource, мы можем воспользоваться его конструктором и создать новое окно, сообщения которому мы и будем перехватывать. Однако, можно сделать и по-другому. Поскольку HwndSource является наследником класса PresentationSource, достаточно будет воспользоваться статическим методом PresentationSource.FromVisual(Visual) с нашим главным окном в качестве параметра и привести результат к типу HwndSource.
В этом моменте я допустил ошибку, вызвав FromVisual в конструкторе формы. В момент вызова окно ещё не было полностью инициализировано, и результатом стало исключение. После этой попытки я переопределил вызов OnSourceInitialized и завершил инициализвцию HwndSource в нём.

Код

Полный код перехвата сообщений Windows выглядит так:
public partial class MainWindow : Window
{
    private const int WM_DRAWCLIPBOARD = 0x0308;
    private HwndSource hwndSource;

    // функция-перехватчик
    private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
    {
        if (msg == WM_DRAWCLIPBOARD)
        {
            // обрабатываем сообщение
        }
        return IntPtr.Zero;
    }

    public MainWindow()
    {
        InitializeComponent();   
    }

    protected override void OnSourceInitialized(EventArgs e)
    {
        base.OnSourceInitialized(e);
        // создаем экземпляр HwndSource
        hwndSource = PresentationSource.FromVisual(this) as HwndSource; 
        // и устанавливаем перехватчик
        hwndSource.AddHook(WndProc);
    }
}
HwndSource Class (System.Windows.Interop)
HwndSourceHook delegate (System.Windows.Interop)

Monday, June 7, 2010

Странные ошибки и ExternalException при работе с буфером обмена

В комментарии к предыдущей записи Омен подбросил задачку:
… предлагаю для решения простую задачку: посмотреть, как работает пример из этого поста при запущенной вот этой маленькой программке. Выяснить, что и почему происходит, и как с этим бороться.
Чур без рефлектора:)
Ну что же, давайте разбираться. Грузим архив, открываем приложение. Видим обычную форму без каких-либо компонентов. Что же с ней не так? В этот момент я отвлекся на сообщение в скайпе, а когда попытался скопировать оттуда ссылку, вот что получилось:
Получается, наш TestClipboard.exe каким-то образом запорол доступ к буферу обмена (причём, не только для скайпа — чтобы сделать скриншот выше, тест-приложение пришлось закрыть). Как он это сделал? Можно, конечно, посмотреть рефлектором, но нас же просили этого не делать — поэтому пойдем слегка обходным путём:
  1. У меня уже была открыта Visual Studio с каким-то проектом, и я банально нажал F11, чтобы перейти в режим отладки. Открытый проект совершенно не важен, потому как нам будет нужен Immediate Window. Давайте попробуем из него получить текст из буфера обмена:
    Clipboard.GetText()
  2. Студия, как и скайп раньше, не может получить доступ к буферу обмена, только в этот раз у нас куда больше информации:
  3. Параметр ErrorCode — как раз то, что нам нужно. Поскольку под классом Clipboard находятся нативные функции Windows, мы будем смотреть, что не так у них. Конвертируем -2147221040 в hex, получаем 0x800401d0. Поискав число в MSDN или заглянув в winerror.h, узнаём, что числу соответствует константа CLIPBRD_E_CANT_OPEN.
  4. Давайте посмотрим теперь, какая функция WinAPI вернула нам такой результат. В описании константы в MSDN это указано:
    0x800401D0 CLIPBRD_E_CANT_OPEN
    OpenClipboard failed.
    Итак, «виновница» торжества — функция WinAPI OpenClipboard.
В процессе расследования мы спустились с вершин дотнета в старое доброе WinAPI. И вот как в нём устроена работа с буфером обмена:
  1. Приложение вызывает функцию OpenClipboard для получения доступа к буферу обмена. С этого момента доступ к буферу закрыт для всех остальных приложений.
  2. Приложение вызывает либо функцию GetClipboardData для получения данных, либо EmptyClipboard для его очистки и SetClipboardData для записи данных в буфер обмена.
  3. Приложение закрывает буфер обмена при помощи функции CloseClipboard.
Итак, после первого пункта больше никто, кроме приложения, не может получить доступ к буферу. Если вызвать OpenClipboard и не закрывать буфер (что и делает наше тестовое приложение), то можно отобрать доступ к буферу у всех остальных (не делайте так, пожалуйста). Также работа с буфером может вызвать исключение, если в момент вызова какое-то приложение находится на пункте 2 работы. Это довольно редкая ситуация, но её стоит учитывать — поэтому работа с буфером всегда должна обрабатывать ExternalException. Мы учтём это в следующей версии нашего монитора буфера обмена.

OpenClipboard Function (Windows)
Clipboard (Windows)

    Tuesday, June 1, 2010

    Уведомление о изменении буфера обмена (Clipboard) Windows в С#, часть 1

    Введение

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

    Класс Clipboard

    В .net-приложении буфер обмена Windows доступен в любой момент при помощи класса
    System. Windows. Forms. Clipboard. Для получения данных, которые находятся в буфере, предназначены четыре метода:
    public static string Clipboard.GetText();
    public static string Clipboard.GetText(TextDataFormat format);
    public static Object Clipboard.GetData(string format);
    public static IDataObject Clipboard.GetDataObject();
    
    Найболее простой из них — первый метод, который получает текстовые данные из буфера обмена. Если в буфере не текстовые данные (а, например, изображение), то результатом будет пустая строка.
    Метод GetText (TextDataFormat format) отличается от собственной перегрузки без параметров тем, что позволяет получить текст из буфера обмена в определенном формате (всего их пять — Text, UnicodeText, Rtf, Html и CommaSeparatedValue). Опять же, если в буфере обмена не текстовые данные, либо данные не запрошенного формата, результатом вызова метода будет пустая строка.
    Для проверки того, содержит ли буфер текст нужного формата, существуют следующие методы:
    public static bool ContainsText();
    public static bool ContainsText(TextDataFormat format);
    
    Синтаксис их вызова идентичен методам GetText, но результатом будет true, если буфер обмена содержит текст заданного формата и false в обратном случае.
    Последние два Get-метода предназначены для получения объекта определенного формата из буфера (о других форматах мы поговорим в следущей части статьи) и имеют соответствующие Contains-методы.

    Уведомления об изменении содержимого буфера обмена

    Что нужно сделать, чтобы узнать, когда содержимое буфера изменилось? Простейшее решение — воспользоваться таймером и периодически опрашивать буфер на предмет изменения. Это не лучшее решение — содержимое буфера может измениться несколько раз между итерациями таймера, а слишком частый опрос может быть губительным для производительности.
    В этой задаче нам на помощь приходят три WinAPI-функции:
    //Register a window handle as a clipboard viewer
    [DllImport("User32.dll", CharSet = CharSet.Auto)]
    public static extern IntPtr SetClipboardViewer(IntPtr hWnd);
    //Remove a window handle from the clipboard viewers chain
    [DllImport("User32.dll", CharSet = CharSet.Auto)]
    public static extern bool ChangeClipboardChain(
        IntPtr hWndRemove,  // handle to window to remove
        IntPtr hWndNewNext  // handle to next window
        );
    [DllImport("user32.dll", CharSet = CharSet.Auto)]
    public static extern int SendMessage(IntPtr hwnd, int wMsg, IntPtr wParam, IntPtr lParam);
    
    Функция SetClipboardViewer регистрирует окно для получения сообщения WM_DRAWCLIPBOARD (о нём ниже), ChangeClipboardChain предназначена для удаления идентификатора окна из цепочки получения WM_DRAWCLIPBOARD, а SendMessage — функция WinAPI для отправки сообщений Windows другим окнам.
    Сообщение WM_DRAWCLIPBOARD рассылается системой при изменении содержимого буфера обмена. С помощью функции SetClipboardViewer мы подпишем наше приложение в цепочку получателей этого сообщения. Небольшой сложностью этого подхода будет то, что наше приложение должно будет передать сообщение дальше по цепочке — адрес окна, которому нужно будет передать сообщение, вернет функция SetClipboardViewer.
    Кроме обработки WM_DRAWCLIPBOARD, нашему приложению придётся обрабатывать также и сообщение WM_CHANGECBCHAIN. Система рассылает это сообщение, когда исключает одно из окон из цепочки рассылки.

    Время для кода

    Итак, откроем Visual Studio, создадим новый проект типа Windows Forms Application, и приступим к работе. Создадим простой интерфейс для приложения:
    Кнопки Start и Stop Monitoring будут соответственно регистрировать окно нашего приложения в цепочке уведомлений и удалять его оттуда, а в текстовое поле будет выводиться изменения в буфере и прочая диагностическая информация. Для этого будет предназначена вспомогательная функция Output:
    private void Output(string message)
    {
        tbOutput.Text += message + Environment.NewLine; // добавить наше сообщение
        tbOutput.SelectionStart = tbOutput.Text.Length; // установить курсор в конец текста
        tbOutput.ScrollToCaret(); // прокрутка до курсора для показа сообщения
    }
    
    Добавим переменную, в которой будем хранить дескриптор окна, которому будем передавать сообщения WM_DRAWCLIPBOARD и WM_CHANGECBCHAIN:
    private IntPtr nextClipboardViewer;
    
    Добавим обработчики событий для кнопок:
    private void btnStart_Click(object sender, EventArgs e)
    {
        nextClipboardViewer = SetClipboardViewer(this.Handle);
        Output("Registered clipboard viewer.");
    }
    
    private void btnStop_Click(object sender, EventArgs e)
    {
        ChangeClipboardChain(this.Handle, nextClipboardViewer);
        Output("Unregistered clipboard viewer.");
    }
    
    Переопределим функцию WndProc, чтобы обработать пришедшие нам сообщения:
    protected override void WndProc(ref Message m)
    {
        switch (m.Msg)
        {
            case WM_DRAWCLIPBOARD:
                // содержимое буфера обмена изменилось
                {
                    // реагируем на изменение буфера
                    ClipboardChanged();
                    // пересылаем сообщение следующему окну в цепочке
                    SendMessage(nextClipboardViewer, WM_DRAWCLIPBOARD, IntPtr.Zero, IntPtr.Zero);
                    break;
                }
            case WM_CHANGECBCHAIN:
                // цепочка приложений, подписанных на WM_DRAWCLIPBOARD изменилась
                {
                    if (m.WParam == nextClipboardViewer)
                    {
                        // окно, которому мы передавали WM_DRAWCLIPBOARD, удалено из
                        // цепочки, и нам нужно обновить переменную
                        nextClipboardViewer = m.LParam;
                    }
                    else
                    {
                        // просто передаем новости дальше
                        SendMessage(nextClipboardViewer, WM_CHANGECBCHAIN, m.WParam, m.LParam);
                    }
                    m.Result = IntPtr.Zero; // уведомляем систему об обработке сообщения
                    break;
                }
            default:
                // поведение для остальных сообщений - по умолчанию
                {
                    base.WndProc(ref m); 
                    break;
                }
        }
    }
    
    И последнее, что осталось сделать — реализовать метод ClipboardChanged:
    private void ClipboardChanged()
    {
        Output("The clipboard content has been changed.");
        string s = "New clipboard content: ";
        if (Clipboard.ContainsText()) // будем выводить только текстовое содержимое
        {
            s += Clipboard.GetText();
        }
        else
        {
            s += "[non-text]";
        }
        Output(s);
    }
    
    Готово! Монитор буфера обмена готов к работе:
    Обратите внимание, что при регистрации монитора Windows тут же присылает нам сообщение о изменении буфера — что-то вроде «добро пожаловать» :)
    В следующей части мы рассмотрим работу с другими форматами данных буфера обмена, и пропагрейдим наш монитор до менеджера буфера обмена — с историей и прочими полезными функциями.
    Исходник проекта (Visual Studio 2008)
    SetClipboardViewer Function (Windows)
    Clipboard Class (System. Windows. Forms)