Python win32 编程: 实现快捷键切换窗口并注册为service

问题描述

窗口切换是我们平时经常用到的一个操作, 我的感觉是普通的Alt + Tab的切换方式有些许不足, 在效率方面有值得改进的地方. 一个很普遍的现象是, 虽然一般我们会开很多个窗口, 但是使用最频繁的那些窗口数量是有限的, 我使用最频繁的几个窗口是: Emacs编辑器, cmd控制台, Chrome浏览器, 这几个基本是一直开着. 其他的窗口基本都是临时打开的, 或者很少去看的.

然而使用Alt + Tab的方式, 这些很频繁的操作很少能够一键直达, 而是要筛选, 而且会分散注意力, 因为Alt + Tab之后要计算还需按几下才能到达目的地. 做多了之后, 就会感觉是个负担. 其实在Emacs内也有同样的问题, 在不同的Buffer之间切换的时候, 有时候也要找一找. 应该说凡是带有标签栏的多窗口应用程序例如浏览器, Emacs, Eclipse之类的IDE, 都存在这选择困难低效的情况.

所以如果能给那些常用的窗口分配一个热键的话, 就能大大的减轻任务切换的负担, 也减少了无意义的注意力分散的负担. 具体思路: 枚举所有活动的窗口, 提取他们的窗口句柄, 窗口标题和窗口class name, 然后根据特征直接定位某个窗口, 为这个窗口注册一个热键, 将这个窗口切换到最顶端. 这个程序最好是在后台运行, 不在任务栏占用位置, 也不需要GUI.

选择实现方案

最直接的当然是用Visual C或者C++直接操作微软SDK中的win32 API. 这个肯定是可以做到的, 但是似乎有点大材小用, 这只是一个脚本, 而不是一个应用程序, 最好根本不需要编译, 也不要有二进制文件, 纯文本的脚本是最好的.

第二个考虑方案是Java, 但是同样的问题, 要考虑类型, 而且要下载JAR包, 再加上 JAVA和win32 api隔得实在有些远, 而且这个程序必须一直在后台运行的, 即需要一个JVM一直挂在后台, 而一个JVM消耗的资源还是太高了, 当然我们可以在Clojure 的REPL中来启动这个程序, 这样他就 和Clojure REPL共享同一个JVM. 不过还感觉很麻烦.

而同时, Python却非常适合与win32 api打交道, 虽然没有实际的写过这方面的代码, 但是听说过. 于是决定使用Python了.

事实证明这是最方便的方案, 基本上拷贝几行Python脚本就OK了.

枚举活动窗口

首先导入ctypes模块, 这是Python中win32编程的基石

 
from ctypes import *
 

其他可能需要用到的导入模块

 
 
import win32service
import win32serviceutil
import win32api
import win32event
import win32evtlogutil
import os
 
import win32con
from win32con import SWP_FRAMECHANGED 
from win32con import SWP_NOMOVE 
from win32con import SWP_NOSIZE 
from win32con import SWP_NOZORDER
from win32con import SW_HIDE
from win32con import SW_FORCEMINIMIZE
from win32con import SW_SHOWNORMAL
 
from win32con import GW_OWNER 
from win32con import GWL_STYLE 
from win32con import GWL_EXSTYLE 
 
from win32con import WM_CLOSE 
 
from win32con import WS_CAPTION 
from win32con import WS_EX_APPWINDOW 
from win32con import WS_EX_CONTROLPARENT
from win32con import WS_EX_TOOLWINDOW
from win32con import WS_EX_WINDOWEDGE
 

枚举所有窗口的代码

 
EnumWindows = windll.user32.EnumWindows
EnumWindowsProc = WINFUNCTYPE(c_bool, POINTER(c_int), POINTER(c_int))
GetWindowText = windll.user32.GetWindowTextW
GetWindowTextLength = windll.user32.GetWindowTextLengthW
IsWindowVisible = windll.user32.IsWindowVisible
GetClassName = windll.user32.GetClassNameW
BringWindowToTop = windll.user32.BringWindowToTop
 
titles = []
def foreach_window(hwnd, lParam):
    if IsWindowVisible(hwnd):
        length = GetWindowTextLength(hwnd)
        classname = create_unicode_buffer(100 + 1)
        GetClassName(hwnd, classname, 100 + 1)
        buff = create_unicode_buffer(length + 1)
        GetWindowText(hwnd, buff, length + 1)
        titles.append((hwnd, buff.value.encode('gbk', 'backslashreplace').decode('gbk', 'backslashreplace'), classname.value))
    return True
EnumWindows(EnumWindowsProc(foreach_window), 0)
 

titles是一个列表, 元素是tuple, 包含窗口句柄, 窗口标题, 窗口class name .

将窗口设置为顶级窗口

解决这个问题尝试了好几个方案, 首先最简单的方法直接用现成的API, BringWindowToTop:

 
BringWindowToTop(titles[2][0])
 

但是并不管用, 虽然返回值显示是成功的, 但是实际切换并没有发生. 第二种方案:

 
def bring_to_top(HWND):
    windll.user32.ShowWindow(HWND, win32con.SW_RESTORE)
    windll.user32.SetWindowPos(HWND,win32con.HWND_NOTOPMOST, 0, 0, 0, 0, win32con.SWP_NOMOVE + win32con.SWP_NOSIZE)  
    windll.user32.SetWindowPos(HWND,win32con.HWND_TOPMOST, 0, 0, 0, 0, win32con.SWP_NOMOVE + win32con.SWP_NOSIZE)  
    windll.user32.SetWindowPos(HWND,win32con.HWND_NOTOPMOST, 0, 0, 0, 0, win32con.SWP_SHOWWINDOW + win32con.SWP_NOMOVE + win32con.SWP_NOSIZE)
 
 

这个在我的WIN8系统上仍然不行, 可能是和操作系统有关系, 也许这些方案在其他平台上是OK的. 经过实测, 下面最后一种方案在WIN8上可行:

 
def bring_to_top2(hWnd):
    windll.user32.ShowWindow(hWnd,win32con.SW_SHOW); 
    windll.user32.BringWindowToTop(hWnd);
    windll.user32.SetForegroundWindow(hWnd)
 

筛选指定的窗口

根据窗口标题和窗口class name的特征可以定位一些典型的窗口, 例如cmd 命令行, Emacs和Chrome浏览器. 例如Emacs窗口的标题格式为

 
emacs@username
 

而Chrome浏览器的窗口标题以 " - Google Chrome" 结尾. 下面的几个函数可以直接筛选出我们想要的窗口

 
def get_hwnd_by_title_classname(wins, title, classname):
    for item in wins:
        if title in item[1] and classname in item[2]:
            return item[0]
 
def get_hwnd_by_title_classname_cmd(wins, title, classname):
    for item in wins:
        if title in item[1] and classname in item[2] and "clj" not in item[1]:
            return item[0]
 
def bring_emacs_top(wins):
    hwnd = get_hwnd_by_title_classname(wins, "emacs@", "Emacs")
    if hwnd:
        bring_to_top2(hwnd)
 
 
def bring_chrome_top(wins):
    hwnd = get_hwnd_by_title_classname(wins, " - Google Chrome", "Chrome_WidgetWin")
    if hwnd:
        bring_to_top2(hwnd)
 
def bring_cmd_top(wins):
    hwnd = get_hwnd_by_title_classname_cmd(wins, "cmd -", "ConsoleWindowClass")
    if hwnd:
        bring_to_top2(hwnd)
 
 

注册热键

直接在github上找到了一个现成的类: globalhotkeys.py

 
import ctypes
import ctypes.wintypes
import win32con
 
 
class GlobalHotKeys(object):
    """
    Register a key using the register() method, or using the @register decorator
    Use listen() to start the message pump
 
    Example:
 
    from globalhotkeys import GlobalHotKeys
 
    @GlobalHotKeys.register(GlobalHotKeys.VK_F1)
    def hello_world():
        print 'Hello World'
 
    GlobalHotKeys.listen()
    """
 
    key_mapping = []
    user32 = ctypes.windll.user32
 
    MOD_ALT = win32con.MOD_ALT
    MOD_CTRL = win32con.MOD_CONTROL
    MOD_CONTROL = win32con.MOD_CONTROL
    MOD_SHIFT = win32con.MOD_SHIFT
    MOD_WIN = win32con.MOD_WIN
 
    @classmethod
    def register(cls, vk, modifier=0, func=None):
        """
        vk is a windows virtual key code
         - can use ord('X') for A-Z, and 0-1 (note uppercase letter only)
         - or win32con.VK_* constants
         - for full list of VKs see: http://msdn.microsoft.com/en-us/library/dd375731.aspx
 
        modifier is a win32con.MOD_* constant
 
        func is the function to run.  If False then break out of the message loop
        """
 
        # Called as a decorator?
        if func is None:
            def register_decorator(f):
                cls.register(vk, modifier, f)
                return f
            return register_decorator
        else:
            cls.key_mapping.append((vk, modifier, func))
 
 
    @classmethod
    def listen(cls):
        """
        Start the message pump
        """
 
        for index, (vk, modifiers, func) in enumerate(cls.key_mapping):
            # cmd 下没问题, 但是在服务中运行的时候抛出异常
            if not cls.user32.RegisterHotKey(None, index, modifiers, vk):
                raise Exception('Unable to register hot key: ' + str(vk) + ' error code is: ' + str(ctypes.windll.kernel32.GetLastError()))
 
        try:
            msg = ctypes.wintypes.MSG()
            while cls.user32.GetMessageA(ctypes.byref(msg), None, 0, 0) != 0:
                if msg.message == win32con.WM_HOTKEY:
                    (vk, modifiers, func) = cls.key_mapping[msg.wParam]
                    if not func:
                        break
                    func()
 
                cls.user32.TranslateMessage(ctypes.byref(msg))
                cls.user32.DispatchMessageA(ctypes.byref(msg))
 
        finally:
            for index, (vk, modifiers, func) in enumerate(cls.key_mapping):
                cls.user32.UnregisterHotKey(None, index)
 
 
    @classmethod
    def _include_defined_vks(cls):
        for item in win32con.__dict__:
            item = str(item)
            if item[:3] == 'VK_':
                setattr(cls, item, win32con.__dict__[item])
 
 
    @classmethod
    def _include_alpha_numeric_vks(cls):
        for key_code in (list (range(ord('A'), ord('Z') + 1)) + list(range(ord('0'), ord('9') + 1)) ):
            setattr(cls, 'VK_' + chr(key_code), key_code)
 
 
# Not sure if this is really a good idea or not?
#
# It makes decorators look a little nicer, and the user doesn't have to explicitly use win32con (and we add missing VKs
# for A-Z, 0-9
#
# But there no auto-complete (as it's done at run time), and lint'ers hate it
GlobalHotKeys._include_defined_vks()
GlobalHotKeys._include_alpha_numeric_vks()
 
 

用法如下

 
from globalhotkeys import GlobalHotKeys
 
@GlobalHotKeys.register(GlobalHotKeys.VK_F1, GlobalHotKeys.MOD_SHIFT)
def shift_f1():
    bring_emacs_top(titles)
 
 
@GlobalHotKeys.register(GlobalHotKeys.VK_F2, GlobalHotKeys.MOD_SHIFT)
def shift_f2():
    bring_chrome_top(titles)
 
@GlobalHotKeys.register(GlobalHotKeys.VK_F3, GlobalHotKeys.MOD_SHIFT)
def shift_f3():
    bring_cmd_top(titles)
 

启用热键只需调用listen即可

 
GlobalHotKeys.listen()
 

直接在cmd中运行的话, 这个cmd窗口将会一直被占用 .

后台运行

接下来我们希望这个脚本可以在后台运行, 第一个方案是将Python脚本作为windows 服务来启动.

下面同样是Google找到的方案

 
class aservice(win32serviceutil.ServiceFramework):
 
   _svc_name_ = "aservice"
   _svc_display_name_ = "a service - it does nothing"
   _svc_description_ = "Tests Python service framework by receiving and echoing messages over a named pipe"
 
   def __init__(self, args):
           win32serviceutil.ServiceFramework.__init__(self, args)
           self.hWaitStop = win32event.CreateEvent(None, 0, 0, None)           
 
   def SvcStop(self):
           self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
           win32event.SetEvent(self.hWaitStop)                    
 
   def SvcDoRun(self):
      import servicemanager      
      servicemanager.LogMsg(servicemanager.EVENTLOG_INFORMATION_TYPE,servicemanager.PYS_SERVICE_STARTED,(self._svc_name_, ''))
 
      # 启动热键
      GlobalHotKeys.listen()
 
      self.timeout = 3000
 
      while 1:
         # Wait for service stop signal, if I timeout, loop again
         rc = win32event.WaitForSingleObject(self.hWaitStop, self.timeout)
         # Check to see if self.hWaitStop happened
         if rc == win32event.WAIT_OBJECT_0:
            # Stop signal encountered
            servicemanager.LogInfoMsg("aservice - STOPPED")
            break
         else:
            servicemanager.LogInfoMsg("aservice - is alive and well")   
 
 
def ctrlHandler(ctrlType):
   return True
 
if __name__ == '__main__':
    win32api.SetConsoleCtrlHandler(ctrlHandler, True)   
    win32serviceutil.HandleCommandLine(aservice)
 
 
 

ERROR_ACCESS_DENIED错误

执行命令

 
python winapi-EnumWindows.py   --startup auto install
 

安装服务, 结果报错:

 
Error installing service: 拒绝访问。 (5)

MSDN的错误描述是

 
The handle to the SCM database does not have the SC_MANAGER_CREATE_SERVICE access right.
 

原因是调用CreateService返回ERROR_ACCESS_DENIED 错误. 解决办法是以管理员身份打开cmd命令行窗口, 然后再执行命令.

ERROR_REQUIRES_INTERACTIVE_WINDOWSTATION

安装成功之后启动服务, 报错, 打开事件查看器, 应用程序日志中的错误信息

 
The instance's SvcRun() method failed 
Traceback (most recent call last):
  File "C:\App\python3.4.3\lib\site-packages\win32\lib\win32serviceutil.py", line 835, in SvcRun
    self.SvcDoRun()
  File "C:\codebase\python\testcode\winapi-EnumWindows.py", line 205, in SvcDoRun
    GlobalHotKeys.listen()
  File "C:\codebase\python\testcode\globalhotkeys.py", line 63, in listen
    raise Exception('Unable to register hot key: ' + str(vk) + ' error code is: ' + str(ctypes.windll.kernel32.GetLastError()))
Exception: Unable to register hot key: 112 error code is: 1459 
%2: %3

RegisterHotKey函数在cmd运行的时候没问题, 到service中运行则会报错误: ERROR_REQUIRES_INTERACTIVE_WINDOWSTATION.

这个错误的意思是说service不能和桌面交互, 其中就包括注册热键, 如果一定要与桌面交互, 那么创建的服务的类型必须是Interactive Services, 这需要在CreateService的时候指定一个标志位 SERVICE_INTERACTIVE_PROCESS, 但是从Vista开始, 这个选项就不再支持了.

目前使用的是WIN8, 当然也是不支持的.

换句话说, WIN8 中的service无法注册全局热键 .

其实还有一种方法比service好得多, 那就是pythonw.exe, 用这个启动脚本, 直接进入后台运行, 不会占用cmd窗口.