Оно само упало, или следствие ведут колобки
Вот задеплоили мы своё приложение, и, как правильные и опытные разработчики, не забыли вставить в него крэш-репортер. Получаем первые репорты, открываем стек, смотрим на окружение, пробуем воспроизвести, обламываемся и задаём в пространство вопрос «чем ты это сказал? а как так получилось-то?» Что же там пользователь сделал такого, что приложение завалилось? И тут нам здорово пригодились бы отладочные логи приложения, или хотя бы логи последних действий пользователя, что открывал, куда тыкал и т.п. Далее речь пойдет именно о логгировании действий пользователя в WinForms приложении и включении этого лога в крэшрепорт.
Первое что приходит в голову — наивный подход: вписать обработчики на каждое действие юзера и добавлять в лог запись. Но это куча работы и тупого кода. О’кей, может тогда сделаем базовую форму/контрол, где подпишемся на нужные события у всех, кто лежит внутри? Ой, а контролы оказывается умеют добавляться и удаляться, приложение leak-овать и всё это вместе ещё подтормаживать из-за нашего кода. А вот как бы нам написать всё то же самое, только без базовых форм, отслеживания изменений на форме, подписки на кучу событий, да ещё чтобы не тормозило?
Оказывается, всё уже придумано до нас — хуки! Но, позвольте, какой-же это WinForms, это же самый что ни на есть Win32 API?! А что делать, жить захочешь — не так раскорячишься вам шашечки, или ехать?
Итак, нам интересно что пользователь делал, чтобы завалить приложение. То есть куда и как тыкал мышкой, какие клавиши нажимал, что вводил, какие окна открывал, как фокус двигал. Хватит для начала.
За мышку отвечают мышиные хуки, обычный и низкоуровневый. За клавиатуру, соответственно, клавиатурные, также обычный и низкоуровневый. Нам надо следить за вводом только в одном приложении, поэтому глобальные низкоуровневые хуки для нас, пожалуй, перебор.
А раз обычных хуков нам достаточно, то можно сразу на хук сообщений повеситься, там и мышка будет и клавиатура, в одном флаконе. Активацию окон и перемещение фокуса удобно ловить в CBT-хуке.
Куда копать понятно,
Для каждой нитки нужно ставить свой хук (см. threadId), поэтому заведём словарик
При подписке в словарик запись добавляем, при отписке убираем, все чин чинарём.
Хренак, хренак, и в продакшен! Ага, щаззз, это .NET, детка. Валится, если не сразу, то после 1-2 подписок/отписок на хуки, прямо на вызове CallNextHookEx. Интуитивно ясно, что имеем dangling pointer, но при беглом взгляде на код непонятно, а с чего бы? Помотрим внимательно на SetWindowsHookEx. Вторым параметром идёт указатель на функцию-обработчик. То есть адрес обработчика, в терминах C# это будет IntPtr. Тааак, а если заменить HookProc lpfn на IntPtr lpfn? Тогда придется использовать Marshal. GetFunctionPointerForDelegate для получения адреса из делегата. Смотрим на изначальный код — в явном виде делегат мы не создаем. Смотрим при помощи ilspy:
Так вот ты какой, северный олень! Спасибо тебе, синтаксический сахар, ты спрятал от нас важную вещь — создаётся промежуточный объект делегата, содержащий в себе адрес, который успешно передаётся в unmanaged-код. После чего объект делегата успешно прибивается сборщиком мусора и unmanaged код начинает валиться при попытке передать управление в никуда.
Ок, явным образом сохраняем делегаты:
Компилим, проверяем — работает.
Полдела сделали, уведомления о событиях научились получать. Теперь надо собрать и записать информацию, которая позволит нам показывать события в понятном и удобочитаемом для человека виде.
Затем сделаем простенькое приложение, в котором включим запись юзерских действий и в обработчике одной из кнопок уроним приложение, чтобы крэшрепорт с «шагами для воспроизведения» отправить в систему.
На серверной стороне формируем описания действий пользователя на основе собранной информации, примерно вот так:
Понятно, что ничего непонятно. Да, пользователь тыкал мышкой, но куда именно? А это мы не записали и не прислали. Что у нас там интересного в мышиных сообщениях есть? О, hWnd, то что надо.
Заодно уж впишем в лог, какие клавиши нажимал пользователь.
Пересобираем, запускаем, генерируем и смотрим новый крэшрепорт.
Уже неплохо. И нажатия клавиш адекватно показались, можно начинать пароли собирать. Кстати о птичках, пароли. Наверное, нехорошо отправлять приватную информацию без явного на то согласия пользователя. Пожалуй,
Теперь надо реализовать метод IsPasswordBox, и станет совсем хорошо. Чем же отличается обычное поле ввода, от поля ввода паролей? Вновь вызываем из глубин памяти воспоминания о WinAPI, находим вот такой примерчик. Из него видим, что нам нужен edit control со стилем ES_PASSWORD, у которого выставлен ненулевой password char. Реализуем:
С кнопочками и полями ввода все хорошо работает. А если усложнить задачку? Встроимся в демо-приложение со сложными контролами, гридом и риббоном.
Запустим, потыкаем в грид и кнопки на риббоне, посмотрим на результат:
Печальное зрелище. На весь риббон у нас один единственный hWnd, а на грид аж целых два, один на сам грид, а другой на активный редактор. И что же нам со всем этим добром делать? Копаться в потрохах конкретных контролов не вариант — их слишком много и от разных вендоров, да и внутренности могут вполне себе регулярно меняться. Вспоминаем про Section 508, и начинаем выяснять, что это такое и с чем его едят. В результате узнаем про IAccessible а через некоторое время и про методы AccessibleObjectFromWindow и AccessibleObjectFromPoint. Первый метод как раз подойдет для клавиатурных событий, а второй для мышиных.
Дальше уже дело техники. Если получили ненулевой IAccessible, то анализируем AccessibleRole, и записываем accName контрола и его родителя, если это надо.
Далее допиливаем серверную часть и получаем уже вот такую картину:
Стало гораздо информативнее, вполне понятно к какому контролу (или к какой его части) относится каждая запись. Работать уже можно, но теперь уже захотелось увидеть те же самые действия пользователя, но в еще более компактном виде. Чем далее и займёмся.