Вот задеплоили мы своё приложение, и, как правильные и опытные разработчики, не забыли вставить в него крэш-репортер. Получаем первые репорты, открываем стек, смотрим на окружение, пробуем воспроизвести, обламываемся и задаём в пространство вопрос «чем ты это сказал? а как так получилось-то?» Что же там пользователь сделал такого, что приложение завалилось?
И тут нам здорово пригодились бы отладочные логи приложения, или хотя бы логи последних действий пользователя, что открывал, куда тыкал и т.п. Далее речь пойдет именно о логгировании действий пользователя в WinForms приложении и включении этого лога в крэшрепорт.
Первое что приходит в голову — наивный подход: вписать обработчики на каждое действие юзера и добавлять в лог запись. Но это куча работы и тупого кода. О’кей, может тогда сделаем базовую форму/контрол, где подпишемся на нужные события у всех, кто лежит внутри? Ой, а контролы оказывается умеют добавляться и удаляться, приложение leak-овать и всё это вместе ещё подтормаживать из-за нашего кода. А вот как бы нам написать всё то же самое, только без базовых форм, отслеживания изменений на форме, подписки на кучу событий, да ещё чтобы не тормозило?
Оказывается, всё уже придумано до нас — хуки! Но, позвольте, какой-же это WinForms, это же самый что ни на есть Win32 API?! А что делать, жить захочешь — не так раскорячишься вам шашечки, или ехать?
Итак, нам интересно что пользователь делал, чтобы завалить приложение. То есть куда и как тыкал мышкой, какие клавиши нажимал, что вводил, какие окна открывал, как фокус двигал. Хватит для начала.
За мышку отвечают мышиные хуки, обычный и низкоуровневый. За клавиатуру, соответственно, клавиатурные, также обычный и низкоуровневый. Нам надо следить за вводом только в одном приложении, поэтому глобальные низкоуровневые хуки для нас, пожалуй, перебор.
А раз обычных хуков нам достаточно, то можно сразу на хук сообщений повеситься, там и мышка будет и клавиатура, в одном флаконе. Активацию окон и перемещение фокуса удобно ловить в CBT-хуке.
Куда копать понятно,
delegate int HookProc(int ncode, IntPtr wParam, IntPtr lParam);
[DllImport("kernel32.dll", ExactSpelling = true, CharSet = CharSet.Auto)]
static extern int GetCurrentThreadId ();
[DllImport("USER32.dll", CharSet = CharSet.Auto)]
static extern IntPtr SetWindowsHookEx(int idHook, HookProc lpfn, IntPtr hMod, int dwThreadId);
[DllImport("USER32.dll", CharSet = CharSet.Auto)]
static extern int CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);
[DllImport("USER32.dll", CharSet = CharSet.Auto)]
static extern bool UnhookWindowsHookEx(IntPtr hhk);
Подписываемся:
const int WH_GETMESSAGE = 3;
const int WH_CBT = 5;
IntPtr getMsgHookHandle = SetWindowsHookEx(WH_GETMESSAGE, GetMsgProc, IntPtr.Zero, threadId);
IntPtr cbtHookHandle = SetWindowsHookEx(WH_CBT, CbtProc, IntPtr.Zero, threadId)
static int GetMsgProc(int nCode, IntPtr wParam, IntPtr lParam) {
return CallNextHookEx(IntPtr.Zero, nCode, wParam, lParam);
}
static int CbtProc(int nCode, IntPtr wParam, IntPtr lParam) {
return CallNextHookEx(IntPtr.Zero, nCode, wParam, lParam);
}
Для каждой нитки нужно ставить свой хук (см. threadId), поэтому заведём словарик
static readonly Dictionary<int, HookInfo> hooks = new Dictionary<int, HookInfo>();
где
class HookInfo {
internal IntPtr GetMsgHookHandle;
internal IntPtr CbtHookHandle;
internal bool InHook;
}
При подписке в словарик запись добавляем, при отписке убираем, все чин чинарём.
Хренак, хренак, и в продакшен! Ага, щаззз, это .NET, детка. Валится, если не сразу, то после 1-2 подписок/отписок на хуки, прямо на вызове CallNextHookEx. Интуитивно ясно, что имеем dangling pointer, но при беглом взгляде на код непонятно, а с чего бы? Помотрим внимательно на SetWindowsHookEx. Вторым параметром идёт указатель на функцию-обработчик. То есть адрес обработчика, в терминах C# это будет IntPtr. Тааак, а если заменить HookProc lpfn на IntPtr lpfn? Тогда придется использовать Marshal. GetFunctionPointerForDelegate для получения адреса из делегата. Смотрим на изначальный код — в явном виде делегат мы не создаем. Смотрим при помощи ilspy:
SetWindowsHookEx(3, new HookProc(Win32HookManagerBase.GetMsgProc), IntPtr.Zero, threadId);
Так вот ты какой, северный олень! Спасибо тебе, синтаксический сахар, ты спрятал от нас важную вещь — создаётся промежуточный объект делегата, содержащий в себе адрес, который успешно передаётся в unmanaged-код. После чего объект делегата успешно прибивается сборщиком мусора и unmanaged код начинает валиться при попытке передать управление в никуда.
Ок, явным образом сохраняем делегаты:
static HookProc procGetMsg = Win32HookManager.GetMsgProc;
static HookProc procCbt = Win32HookManager.CbtProc;
SetWindowsHookEx(WH_GETMESSAGE, procGetMsg, IntPtr.Zero, threadId);
SetWindowsHookEx(WH_CBT, procCbt, IntPtr.Zero, threadId);
Компилим, проверяем — работает.
Полдела сделали, уведомления о событиях научились получать. Теперь надо собрать и записать информацию, которая позволит нам показывать события в понятном и удобочитаемом для человека виде.
Возьмём, например
Win32.WindowsMessages message = (Win32.WindowsMessages)m.Msg;
if (message == Win32.WindowsMessages.WM_LBUTTONUP)
ProcessMouseMessage(ref m, MouseButtons.Left, true);
else if (message == Win32.WindowsMessages.WM_RBUTTONUP)
ProcessMouseMessage(ref m, MouseButtons.Right, true);
else if (message == Win32.WindowsMessages.WM_MBUTTONUP)
ProcessMouseMessage(ref m, MouseButtons.Middle, true);
else if (message == Win32.WindowsMessages.WM_LBUTTONDOWN)
ProcessMouseMessage(ref m, MouseButtons.Left, false);
else if (message == Win32.WindowsMessages.WM_RBUTTONDOWN)
ProcessMouseMessage(ref m, MouseButtons.Right, false);
else if (message == Win32.WindowsMessages.WM_MBUTTONDOWN)
ProcessMouseMessage(ref m, MouseButtons.Middle, false);
void ProcessMouseMessage(ref Message m, MouseButtons button, bool isUp) {
Dictionary<string, string> data = new Dictionary<string, string>();
data["mouseButton"] = button.ToString();
data["action"] = isUp ? "up" : "down";
Breadcrumb item = new Breadcrumb();
item.Event = isUp ? BreadcrumbEvent.MouseUp : BreadcrumbEvent.MouseDown;
item.CustomData = data;
AddBreadcrumb(item);
}
Затем сделаем простенькое приложение, в котором включим запись юзерских действий и в обработчике одной из кнопок уроним приложение, чтобы крэшрепорт с «шагами для воспроизведения» отправить в систему.
static void Main() {
LogifyAlert client = LogifyAlert.Instance;
client.ApiKey = "<my-api-key>";
client.CollectBreadcrumbs = true;
client.StartExceptionsHandling();
// etc
}
void showMessageButton_Click(object sender, EventArgs e) {
MessageBox.Show(this, "text");
}
void crashMeButton_ClickEvent(object sender, EventArgs e) {
object o = null;
o.ToString();
}
На серверной стороне формируем описания действий пользователя на основе собранной информации, примерно вот так:
string GenerateMouseEventMessage(Breadcrumb breadcrumb) {
return
GetBreadcrumbCustomField(breadcrumb, "mouseButton") +
" mouse button" +
GetBreadcrumbCustomField(breadcrumb, "action");
}
Открываем и смотрим на присланные записи.
Понятно, что ничего непонятно. Да, пользователь тыкал мышкой, но куда именно? А это мы не записали и не прислали. Что у нас там интересного в мышиных сообщениях есть? О, hWnd, то что надо.
[DllImport("user32.dll")]
static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
string GetWindowText(ref Message m) {
StringBuilder windowText = new StringBuilder(1024);
GetWindowText(m.Hwnd, windowText, 1024);
return windowText.ToString();
}
Заодно уж впишем в лог, какие клавиши нажимал пользователь.
if (message == Win32.WindowsMessages.WM_KEYDOWN)
ProcessKeyMessage(ref m, false);
else if (message == Win32.WindowsMessages.WM_KEYUP)
ProcessKeyMessage(ref m, true);
else if (message == Win32.WindowsMessages.WM_CHAR)
ProcessKeyCharMessage(ref m);
void ProcessKeyMessage(ref Message m, bool isUp) {
Dictionary<string, string> data = new Dictionary<string, string>();
data["key"] = ((Keys)m.WParam).ToString();
data["action"] = isUp ? "up" : "down";
data["windowCaption"] = GetWindowText(ref m);
Breadcrumb item = new Breadcrumb();
item.Event = isUp ? BreadcrumbEvent.KeyUp : BreadcrumbEvent.KeyDown;
item.CustomData = data;
AddBreadcrumb(item);
}
void ProcessKeyCharMessage(ref Message m) {
Dictionary<string, string> data = new Dictionary<string, string>();
data["char"] = new string((char)m.WParam, 1);
data["action"] = "press";
data["windowCaption"] = GetWindowText(ref m);
Breadcrumb item = new Breadcrumb();
item.Event = BreadcrumbEvent.KeyPress;
item.CustomData = data;
AddBreadcrumb(item);
}
string GenerateMouseEventMessage(Breadcrumb breadcrumb) {
return
GetBreadcrumbCustomField(breadcrumb, "mouseButton") +
" mouse button" +
GetBreadcrumbCustomField(breadcrumb, "action") +
" over " +
GetBreadcrumbCustomField(breadcrumb, "windowCaption");
}
string GenerateKeyEventMessage(Breadcrumb breadcrumb) {
return
GetBreadcrumbCustomField(breadcrumb, "key") +
GetBreadcrumbCustomField(breadcrumb, "action");
}
string GenerateKeyCharEventMessage(Breadcrumb breadcrumb) {
string character = GetBreadcrumbCustomField(breadcrumb, "char");
if (!String.IsNullOrEmpty(character))
return "Type " + character;
else
return GetBreadcrumbCustomField(breadcrumb, "key") + " press";
}
Пересобираем, запускаем, генерируем и смотрим новый крэшрепорт.
Уже неплохо. И нажатия клавиш адекватно показались, можно начинать пароли собрирать. Кстати о птичках, пароли. Наверное, нехорошо отправлять приватную информацию без явного на то согласия пользователя. Пожалуй,
bool ProcessKeyMessage(ref Message m, bool isUp) {
string key = ((Keys)m.WParam).ToString();
bool maskKey = IsPasswordBox(ref m);
if (maskKey)
key = Keys.Multiply.ToString();
Dictionary<string, string> data = new Dictionary<string, string>();
data["key"] = key;
// etc
}
bool ProcessKeyCharMessage(ref Message m) {
char @char = (char)m.WParam;
bool maskKey = IsPasswordBox(ref m);
if (maskKey)
@char = '*';
Dictionary<string, string> data = new Dictionary<string, string>();
data["char"] = new string(@char, 1);
// etc
}
Теперь надо реализовать метод IsPasswordBox, и станет совсем хорошо. Чем же отличается обычное поле ввода, от поля ввода паролей? Вновь вызываем из глубин памяти воспоминания о WinAPI, находим вот такой примерчик. Из него видим, что нам нужен edit control со стилем ES_PASSWORD, у которого выставлен ненулевой password char. Реализуем:
[DllImport("USER32.dll")]
static extern int GetWindowLong(IntPtr hwnd, int flags);
[DllImport("USER32.dll")]
static extern int SendMessage(IntPtr hWnd, int Msg, IntPtr wParam, IntPtr lParam);
static bool IsPasswordBox(IntPtr hWnd) {
const int ES_PASSWORD = 32;
int style = GetWindowLong(hWnd, GWL_STYLE);
if ((style & ES_PASSWORD) == 0)
return false;
int result = SendMessage(hWnd, EM_GETPASSWORDCHAR, IntPtr.Zero, IntPtr.Zero);
return result != 0;
}
Получаем:
С кнопочками и полями ввода все хорошо работает. А если усложнить задачку? Встроимся в демо-приложение со сложными контролами, гридом и риббоном.
Запустим, потыкаем в грид и кнопки на риббоне, посмотрим на результат:
Печальное зрелище. На весь риббон у нас один единственный hWnd, а на грид аж целых два, один на сам грид, а другой на активный редактор. И что же нам со всем этим добром делать? Копаться в потрохах конкретных контролов не вариант — их слишком много и от разных вендоров, да и внутренности могут вполне себе регулярно меняться. Вспоминаем про Section 508, и начинаем выяснять, что это такое и с чем его едят. В результате узнаем про IAccessible а через некоторое время и про методы AccessibleObjectFromWindow и AccessibleObjectFromPoint. Первый метод как раз подойдет для клавиатурных событий, а второй для мышиных.
[return: MarshalAs(UnmanagedType.Bool)]
static extern bool ClientToScreen(IntPtr hwnd, ref POINT point);
[DllImport("oleacc.dll")]
static extern IntPtr AccessibleObjectFromPoint(POINT pt, [Out, MarshalAs(UnmanagedType.Interface)] out IAccessible accObj, [Out] out object ChildID);
[DllImport("oleacc.dll")]
static extern int AccessibleObjectFromWindow(IntPtr hwnd, int id, ref Guid iid, [In, Out, MarshalAs(UnmanagedType.IUnknown)] ref object ppvObject);
static IAccessible GetAccessibleObject(IntPtr hWnd) {
Guid guid = new Guid("618736e0-3c3d-11cf-810c-00aa00389b71");
Object instance = null;
const int OBJID_WINDOW = 0x0;
int hResult = AccessibleObjectFromWindow(hWnd, OBJID_WINDOW, ref guid, ref instance);
if (hResult != 0 || instance == null)
return null;
return instance as IAccessible;
}
static IAccessible GetAccessibleObject(IntPtr hWnd, Point point) {
IAccessible accObj;
object obj = new object();
POINT pt = new POINT();
pt.X = point.X;
pt.Y = point.Y;
ClientToScreen(hWnd, ref pt);
if (AccessibleObjectFromPoint(pt, out accObj, out obj) != IntPtr.Zero)
return null;
if (accObj == null)
return null;
return accObj;
}
Дальше уже дело техники. Если получили ненулевой IAccessible, то анализируем AccessibleRole, и записываем accName контрола и его родителя, если это надо.
Далее допиливаем серверную часть и получаем уже вот такую картину:
Стало гораздо информативнее, вполне понятно к какому контролу (или к какой его части) относится каждая запись. Работать уже можно, но теперь уже захотелось увидеть те же самые действия пользователя, но в еще более компактном виде. Чем далее и займёмся.
PS:
→ Исходники WinForms клиента
Если кому интересно, сайт проекта и документация. Также вводная статья про Logify здесь, на Хабре.
Автор: Антон Миронов