.NET下使用全局Shell Hook

今天我解决了一个长期困扰我的问题,那就是如何在.NET程序中使用全局Shell Hook。

豆瓣电台需要响应用户按下键盘上的“播放/暂停”键与“下一首”键,无论豆瓣电台的窗口是否处于活动状态。

响应多媒体指令的最佳方法就是处理WM_APPCOMMAND消息,但WM_APPCOMMAND消息只在窗口处于活动状态时才会触发,当窗口处于非活动状态时,WM_APPCOMMAND是不会触发的。从某种角度上来说,WM_APPCOMMAND消息不是全局广播的。另一个办法是使用全局Shell Hook,使用SetWindowsHookEx函数添加一个类型为WH_SHELL的全局钩子,并在Shell钩子的回调函数中处理wParam参数为HSHELL_APPCOMMAND的消息。一切看起来很美好,不是吗?别急,下面才是重点。豆瓣电台使用C#编写,生成的代码当然为托管代码,而包含全局钩子的代码必须编译为本机DLL,所以单纯使用托管代码是无法安装全局钩子的(见此文的“在 .NET 框架中不支持全局挂钩”一节)。难道要逼我用C++写个DLL?注册热键的方法也不太好,因为热键有唯一性,可能会出现热键冲突。

虽然说.NET程序号称无法安装全局钩子,但是实际上还是可以安装WH_KEYBOARD_LL和WH_MOUSE_LL类型的全局钩子的,这个并不需要将钩子写到DLL里面。所以可以通过监控键盘输入来知晓用户是否按下了多媒体按键。之前我就是这样干的,具体的代码请看这里。这个方法的缺点是什么呢?到现在为止我已经数不清有多少用户反映360安全卫士提示我的豆瓣电台监控键盘输入了……

今天又有人向我反映豆瓣电台监控键盘输入,于是我又Google了一番,终于,看到了这篇文章,才发现有RegisterShellHookWindow这样的函数。简单来说,RegisterShellHookWindow函数的作用就是将一般Shell Hook采用的回调函数改为窗口消息发送给指定的窗口。它的好处是可以在全局范围内使用,也不用写在DLL里。调用RegisterShellHookWindow后,指定的窗口就会收到WM_SHELLHOOKMESSAGE消息(值得注意的是,WM_SHELLHOOKMESSAGE不是一个常数,需要用RegisterWindowMessage(TEXT(“SHELLHOOK”))获取),消息的wParam参数即代表hook code,包括HSHELL_APPCOMMAND。当不再需要使用钩子时调用DeregisterShellHookWindow函数就可以了。

下面是我用C#的实现:

using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;

namespace DoubanFM
{
    public class AppCommand : IDisposable
    {
        public class AppCommandEventArgs : EventArgs
        {
            public Command Command { get; private set; }
            public Device Device { get; private set; }
            public Keys Keys { get; private set; }
            public bool Handled { get; set; }

            public AppCommandEventArgs(Command command, Device device, Keys keys)
            {
                Command = command;
                Device = device;
                Keys = keys;
            }
        }

        public delegate void AppCommndEventhandler(object sender, AppCommandEventArgs e);

        public event AppCommndEventhandler Fire;

        protected virtual void OnFire(AppCommandEventArgs e)
        {
            AppCommndEventhandler handler = Fire;
            if (handler != null) handler(this, e);
        }

        [DllImport("user32.dll", SetLastError = true)]
        private static extern bool RegisterShellHookWindow(IntPtr hWnd);

        [DllImport("user32.dll", SetLastError = true)]
        private static extern bool DeregisterShellHookWindow(IntPtr hWnd);

        [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)]
        private static extern uint RegisterWindowMessage(string lpString);

        private static int WM_SHELLHOOKMESSAGE;
        private static readonly IntPtr HSHELL_APPCOMMAND = new IntPtr(12);
        private const uint FAPPCOMMAND_MASK = 0xF000;

        private IntPtr hWnd;
        private HwndSource source;
        private bool disposed = false;

        public AppCommand(IntPtr hWnd)
        {
            this.hWnd = hWnd;
        }

        public AppCommand(Window window)
            : this(new WindowInteropHelper(window).EnsureHandle())
        {
        }

        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }

        protected virtual void Dispose(bool disposing)
        {
            if (!disposed)
            {
                if (disposing)
                {
                }

                Stop();
                hWnd = IntPtr.Zero;

                disposed = true;

            }
        }

        ~AppCommand()
        {
            Dispose(false);
        }

        public void Start()
        {
            if (disposed)
            {
                throw new ObjectDisposedException(null);
            }

            if (source == null)
            {
                source = HwndSource.FromHwnd(hWnd);
                if (source == null)
                {
                    throw new InvalidOperationException("hWnd is invalid.");
                }
                source.AddHook(WndProc);
                WM_SHELLHOOKMESSAGE = (int) RegisterWindowMessage("SHELLHOOK");
                if (WM_SHELLHOOKMESSAGE == 0)
                {
                    int error = Marshal.GetLastWin32Error();
                    throw new Win32Exception(error, "Register window message 'SHELLHOOK' failed.");
                }
                if (!RegisterShellHookWindow(hWnd))
                {
                    int error = Marshal.GetLastWin32Error();
                    throw new Win32Exception(error, "Call RegisterShellHookWindow failed.");
                }
            }
        }

        private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
        {
            if (msg == WM_SHELLHOOKMESSAGE && wParam == HSHELL_APPCOMMAND)
            {
                var command = GetAppCommandLParam(lParam);
                var device = GetDeviceLParam(lParam);
                var keys = GetKeyStateLParam(lParam);

                var e = new AppCommandEventArgs(command, device, keys);
                OnFire(e);
                handled = e.Handled;
            }
            return IntPtr.Zero;
        }

        protected static Command GetAppCommandLParam(IntPtr lParam)
        {
            return (Command) ((short) (((ushort) ((((uint) lParam.ToInt64()) >> 16) & 0xffff)) & ~FAPPCOMMAND_MASK));
        }

        protected static Device GetDeviceLParam(IntPtr lParam)
        {
            return (Device) ((ushort) (((ushort) ((((uint) lParam.ToInt64()) >> 16) & 0xffff)) & FAPPCOMMAND_MASK));
        }

        protected static Keys GetKeyStateLParam(IntPtr lParam)
        {
            return (Keys) ((ushort) (((uint) lParam.ToInt64()) & 0xffff));
        }

        public void Stop()
        {
            if (disposed)
            {
                throw new ObjectDisposedException(null);
            }

            if (source != null)
            {
                source.RemoveHook(WndProc);
                if (!source.IsDisposed)
                {
                    if (!DeregisterShellHookWindow(hWnd))
                    {
                        int error = Marshal.GetLastWin32Error();
                        throw new Win32Exception(error, "Call DeregisterShellHookWindow failed.");
                    }
                    source.Dispose();
                }
                source = null;
            }
        }

        public enum Command
        {
            APPCOMMAND_BASS_BOOST = 20,
            APPCOMMAND_BASS_DOWN = 19,
            APPCOMMAND_BASS_UP = 21,
            APPCOMMAND_BROWSER_BACKWARD = 1,
            APPCOMMAND_BROWSER_FAVORITES = 6,
            APPCOMMAND_BROWSER_FORWARD = 2,
            APPCOMMAND_BROWSER_HOME = 7,
            APPCOMMAND_BROWSER_REFRESH = 3,
            APPCOMMAND_BROWSER_SEARCH = 5,
            APPCOMMAND_BROWSER_STOP = 4,
            APPCOMMAND_CLOSE = 31,
            APPCOMMAND_COPY = 36,
            APPCOMMAND_CORRECTION_LIST = 45,
            APPCOMMAND_CUT = 37,
            APPCOMMAND_DICTATE_OR_COMMAND_CONTROL_TOGGLE = 43,
            APPCOMMAND_FIND = 28,
            APPCOMMAND_FORWARD_MAIL = 40,
            APPCOMMAND_HELP = 27,
            APPCOMMAND_LAUNCH_APP1 = 17,
            APPCOMMAND_LAUNCH_APP2 = 18,
            APPCOMMAND_LAUNCH_MAIL = 15,
            APPCOMMAND_LAUNCH_MEDIA_SELECT = 16,
            APPCOMMAND_MEDIA_CHANNEL_DOWN = 52,
            APPCOMMAND_MEDIA_CHANNEL_UP = 51,
            APPCOMMAND_MEDIA_FAST_FORWARD = 49,
            APPCOMMAND_MEDIA_NEXTTRACK = 11,
            APPCOMMAND_MEDIA_PAUSE = 47,
            APPCOMMAND_MEDIA_PLAY = 46,
            APPCOMMAND_MEDIA_PLAY_PAUSE = 14,
            APPCOMMAND_MEDIA_PREVIOUSTRACK = 12,
            APPCOMMAND_MEDIA_RECORD = 48,
            APPCOMMAND_MEDIA_REWIND = 50,
            APPCOMMAND_MEDIA_STOP = 13,
            APPCOMMAND_MIC_ON_OFF_TOGGLE = 44,
            APPCOMMAND_MICROPHONE_VOLUME_DOWN = 25,
            APPCOMMAND_MICROPHONE_VOLUME_MUTE = 24,
            APPCOMMAND_MICROPHONE_VOLUME_UP = 26,
            APPCOMMAND_NEW = 29,
            APPCOMMAND_OPEN = 30,
            APPCOMMAND_PASTE = 38,
            APPCOMMAND_PRINT = 33,
            APPCOMMAND_REDO = 35,
            APPCOMMAND_REPLY_TO_MAIL = 39,
            APPCOMMAND_SAVE = 32,
            APPCOMMAND_SEND_MAIL = 41,
            APPCOMMAND_SPELL_CHECK = 42,
            APPCOMMAND_TREBLE_DOWN = 22,
            APPCOMMAND_TREBLE_UP = 23,
            APPCOMMAND_UNDO = 34,
            APPCOMMAND_VOLUME_DOWN = 9,
            APPCOMMAND_VOLUME_MUTE = 8,
            APPCOMMAND_VOLUME_UP = 10
        }

        public enum Device
        {
            FAPPCOMMAND_KEY = 0,
            FAPPCOMMAND_MOUSE = 0x8000,
            FAPPCOMMAND_OEM = 0x1000
        }

        [Flags]
        public enum Keys
        {
            MK_CONTROL = 0x0008,
            MK_LBUTTON = 0x0001,
            MK_MBUTTON = 0x0010,
            MK_RBUTTON = 0x0002,
            MK_SHIFT = 0x0004,
            MK_XBUTTON1 = 0x0020,
            MK_XBUTTON2 = 0x0040
        }
    }
}

 

Update at 2013.01.04: 修复了上面贴的代码中在64位系统下可能算术溢出的bug。

.NET下使用全局Shell Hook》上有15条评论

      1. Zasz

        啊,似乎是因为我用了autohotkey把常用那几个键映射成了豆瓣FM官方的那几个,所以怎么也设不了……
        捂脸退下了,thx……

        回复
  1. vincent

    注册全局快捷键不就可以了?用得着Hook?如果win7下UAC没关的话,Hook会提示的,我觉得不如注册全局快捷键 API- RegisterHotKey

    回复
    1. K.F.Storm 文章作者

      hook不会提示的。注册快捷键也可以,不过快捷键是独占的,如果同时开启了多个播放器,可能会有意料之外的结果。

      回复

发表评论

电子邮件地址不会被公开。 必填项已用*标注