Mac UI 自动化

作者: lip2up | 来源:发表于2017-04-17 18:04 被阅读149次

    我是一个爱折腾的极客,曾经将 WinXP 不断安装、卸载不下 20 次,为了加快 WinXP 的安装速度,又将默认安装大小 1.2G 左右的 WinXP,精简到 700M 左右,做成 Ghost 镜像,刻录到 CD 上

    这次我们折腾 Mac

    之前,我用很多应用,帮助我更加方便地使用 Mac,但最近我卸载了这些应用,用一个应用,即可完成那几个应用的所有功能,并且还要更加灵活、方便,这款应用就是:Hammerspoon

    Hammerspoon 是一款 Mac 下的 UI 自动化工具,使用 Lua 语言进行配置

    一、快速启动应用或执行某项功能

    通过按 F1 ~ F12,我想快速启动某款应用,或者执行某些功能

    首先,先在系统设置里,选中“将 F1、F2 等键用作标准功能键”,将功能键都“放出来”

    Mac系统设置

    其次,安装 Hammerspoon,然后运行,它会在系统菜单栏的右方“托盘区域”内,生成一个图标,点击弹出菜单,选择 Open Config:

    打开配置

    会用你的默认编辑器,打开文件 ~/.hammerspoon/init.lua,写入如下代码:

    hs.hotkey.bind({}, 'f1', function()
        hs.application.launchOrFocus('Google Chrome')
    end)
    

    然后保存,选择上面的 Reload Config,按 F1 键,即可启动(若未启动)并切换到 Chrome

    为了方便,我们绑定一个快捷键,如下代码,绑定 alt + cmd + r

    hs.hotkey.bind({'cmd', 'alt'}, 'r', function() hs.reload() end)
    alert('config loaded')
    

    这样,每次通过按下快捷键,即可使新代码起作用

    当然,这是初始版本,我实现了一个复杂的版本:

    -- app:allWindows() 不够准确,例如对 Chrome
    function getWinList(name)
        return hs.window.filter.new(false):setAppFilter(name, { currentSpace = true }):getWindows()
    end
    
    function launchOrNextWindow(name, showName)
        local findName = showName or name
        local appName = hs.application.frontmostApplication():name()
        if findName ~= appName then
            hs.application.launchOrFocus(name)
        else
            local wlist = getWinList(findName)
            local wcount = #wlist
            if wcount > 1 then
                hs.eventtap.keyStroke({'cmd'}, '`')
            else
                local win = wlist[1]
                if win:isMinimized() then win:unminimize() else win:minimize() end
            end
        end
    end
    
    function mapLaunch(key, name, showName)
        hs.hotkey.bind({}, key, function()
            launchOrNextWindow(name, showName)
        end)
    end
    
    mapLaunch('f1', 'Google Chrome')
    mapLaunch('f2', 'Sublime Text')
    mapLaunch('f4', 'QQ')
    mapLaunch('f5', 'WeChat', '微信')
    mapLaunch('f6', '有道词典')
    mapLaunch('f11', 'Reminders', '提醒事项')
    mapLaunch('f12', 'iTerm', 'iTerm2')
    

    代码就不解释了,起什么作用呢?还以 Chrome 为例,在任意时刻,但我按下 F1 时:

    1、若当前应用不是 Chrome
      1.1 若 Chrome 已经启动,切换到 Chrome
      1.2 否则,启动,并切换到 Chrome

    2、若当前应用已经是 Chrome,判断它有几个窗口
      2.1 若有多个,则切换到下个窗口,这样,不断按 F1,在 Chrome 的各窗口循环切换
      2.2 若只有一个,最小化或从最小化状态恢复,即循环切换最小化状态

    逻辑虽然绕,但使用体验很爽(至少爽了我自己),通过功能键,我能在我常用的应用间,以及应用不同窗口间,快速切换

    值得一提的是,我用 iTerm2 这款神器,取代了系统默认的 Terminal,体验很棒

    对于我来说,用的最多的是 Chrome、ITerm2、Sublime 这三款应用,所以,我把它们分别绑定到 F1、F12、F2,都是离左右手最近的几个功能键

    我没有使用 F3,原因是 F3 在 Sublime 里有特殊用途

    废话少说,我们继续折腾

    二、保留特殊功能键的功能

    将 F1 ~ F12 “放出来”后,原来很方便的,如调整音量的功能键,就不能使用了,这多少有点不美

    音量调整的功能键,本来是 F11、F12,但 F11、F12 被占用,于是我脑洞大开,不如映射到 F7、F9 键吧,这两个键,本来就是“上一首”、“下一首”的快捷键,重新映射一下,改变其行为,也是很适宜的,直接上代码:

    function sysEvent(name)
        hs.eventtap.event.newSystemKeyEvent(name, true):post()
        hs.eventtap.event.newSystemKeyEvent(name, false):post()
    end
    
    function mapSysKey(key, name)
        hs.hotkey.bind({}, key, function()
            sysEvent(name)
        end, nil, function()
            sysEvent(name)
        end)
    end
    
    mapSysKey('f7', 'SOUND_DOWN')
    mapSysKey('f9', 'SOUND_UP')
    

    即实现按 F7 减少音量,F9 增加音量

    三、操控音乐播放器

    Mac 下有个“很好用”的功能,长按 F8 启动默认的音乐播放器 iTunes,But,贫僧不用 iTunes 呀!我用 QQ音乐!哪里可以改默认音乐播放器?

    下面让我们用代码纠正苹果的愚蠢设定,直接上代码:

    hs.hotkey.bind({}, 'f8', function()
        local app = hs.application.get('QQ音乐')
        if app == nil then
            hs.application.launchOrFocus('QQMusic')
        else
            -- 放在 else 里为了防止启动 iTunes
            sysEvent('PLAY')
        end
    end)
    

    实现的功能为:

    查找“QQ音乐”是否启动,若未启动,则启动QQ音乐(不再需要长按,轻按即可)

    若已启动,则发出系统事件 PLAY,该事件会切换播放、暂停状态,实现原有的功能

    这里要吐槽一下 Hammerspoon 的 API,不够统一,hs.application.get 时我不得不使用“QQ音乐”,hs.application.launchOrFocus 时不得不使用 QQMusic

    四、快速锁屏

    长按键盘右上角的 Power 键,可以锁定屏幕,但网络也断了,不美

    Hammerspoon 不能绑定 Power 键,不美

    Hammerspoon 也提供了锁屏的 API,但有一个 3D 切换效果,虽酷但太慢,不美

    于是我找到一款名叫 Lock Screen Bundle 的应用,据说是用 Apple Script 实现的,然后包装成一款应用,虽然仍然没有 Windows 下的 Win + L 酸爽,但目前是我发现的,比较好的锁屏方式了

    用下面的代码,给它绑定一个快捷键,凑合着用:

    hs.hotkey.bind({}, 'f10', function()
        hs.application.launchOrFocus('Lock Screen Bundle')
    end)
    

    未来我打算直接用 Hammerspoon 调用 Apple Script 实现锁屏,抛弃 Lock Screen Bundle

    五、关闭最后一个窗口,退出应用

    Mac 与 Windows 显著的不同在于,大部分应用必须通过 cmd + q 显式退出,我找了很久,没有解决之道,还是让代码改变世界吧

    function watchClose(name, forceKill)
        local wf = hs.window.filter.new(false):setAppFilter(name, { currentSpace = nil })
        wf:subscribe(hs.window.filter.windowDestroyed, function(win, appName)
            -- alert(name .. ': ' .. tostring(#wf:getWindows()))
            if #wf:getWindows() == 0 then
                local app = win:application()
                if app ~= nil then
                    print('~~~ ' .. appName .. ': kill')
                    if forceKill then killForce(app) else app:kill() end
                end
            end
        end, true)
    end
    
    watchClose('预览')
    watchClose('Safari')
    watchClose('Google Chrome')
    watchClose('Sublime Text')
    watchClose('iTerm2')
    watchClose('有道词典')
    watchClose('Sequel Pro')
    watchClose('Photoshop')
    watchClose('Microsoft Excel', true)
    watchClose('Microsoft Word', true)
    watchClose('Microsoft PowerPoint', true)
    

    这个功能,让我找回了 Windows 的感觉,这酸爽!

    细心的你会发现,我采用白名单的方式,QQ 与 微信我并没有启用这种行为

    细心的你会发现,Microsoft(卖考烧)的三款办公软件,我用了 killForce,就是强制退出,killForce 定义如下:

    -- ** 自动关闭应用程序 **
    function killForce(app, checkDelay)
        app:kill9()
        hs.timer.doAfter(checkDelay or 1, function()
            if app:isRunning() then
                print('~~~ ' .. app:name() .. ' kill9 again')
                app:kill9()
            end
        end)
    end
    

    从代码中就可以看出,我调用了 app:kill9,kill9 与 kill 的区别,需要你熟悉 unix 的 kill 命令,简单理解,kill9 就是强制退出

    但,对于卖烤烧,仅仅强制退出还不够,我又用 hs.timer.doAfter 设了一个定时,即 1 秒后,继续检查 app 是否在运行,若还在运行,再次用 kill9 进行杀除

    卖烤烧的东西真的很强大,连退出都这么“强大”(可能是我没设置好),还没完,在 kill9 卖烤烧的办公软件时,还会弹出一个错误提示框,也很烦人。那么怎么办?继续用代码改造世界吧:

    hs.application.watcher.new(function(name, type, app)
        if app == nil then return end
    
        if type == hs.application.watcher.launching then
            if name == 'Microsoft 错误报告' then killForce(app) end
        end
    end):start()
    

    代码监听应用 launching(注意,是 lauching,而非 launched,时机很重要,launched 虽然也行,但会切换焦点,影响心情),发现“Microsoft 错误报告”,直接 killForce

    美中不足的是,hs.application.watcher 的实现,有 BUG,一段时间后,就会失效,目前我还没有折腾出有效的办法 restart watcher,因为 Hammerspoon 没有相应的 API 检测 application watcher 是否已停止

    六、当从 Submit 切换到 Chrome,自动刷新 Chrome 的当前页面

    作为前端工程师,我经常重复这个动作

    在 Sublime 中修改代码,然后切换到 Chrome,刷新当前页面

    下面的代码,把这个过程自动化:

    local lastAppName = nil
    
    hs.application.watcher.new(function(name, type, app)
        if app == nil then return end
    
        if type == hs.application.watcher.launching then
            if name == 'Microsoft 错误报告' then killForce(app) end
        end
    
        -- 当从 Sublime 切换到 Chrome,自动刷新当前页面
    
        if type == hs.application.watcher.deactivated then
            lastAppName = app:name()
        end
    
        if type == hs.application.watcher.activated then
            if app:name() == 'Google Chrome' then
                hs.timer.doAfter(0.2, function()
                    if lastAppName == 'Sublime Text' then
                        hs.eventtap.keyStroke({'cmd'}, 'r')
                    end
                end)
            end
        end
    end):start()
    

    与上面同样的问题,hs.application.watcher 的实现,有 BUG,这个功能目前只能等 Hammerspoon 修复,或者我继续折腾出别的办法

    我知道,有相应的插件(Chrome 或 Sublime 插件),以及 Webpack 等框架,能实现我需要的功能,我也玩过,但,要么配置太麻烦,要么不适合我的开发场景,遂放弃

    七、窗口分屏、居中、最大化(非 Mac 最大化,Mac 的应该叫全屏)

    代码有点长,实现如下功能:

    1、按 alt + 左箭头,使当前窗口,占据左半边屏幕
    2、按 alt + 右箭头,占据右半边屏幕
    3、按 alt + 上箭头,占据上半边屏幕
    4、按 alt + 下箭头,占据下半边屏幕
    5、按 alt + cmd + 左箭头,占据左下角 1/4 屏幕
    6、按 alt + cmd + 右箭头,占据右下角 1/4 屏幕
    7、左上角与右下角,功能已经实现,但我暂时没有绑定任何快捷键,目前处于雪藏状态
    8、按 alt + enter 键,居中当前窗口,若发现当前窗口是最大化的,则在居中前,将窗口的长宽调整为当前屏幕的 3/4 大小后,再居中
    9、按 cmd + enter 键,最大化当前窗口,若已经是最大化的,则切换到最大化之前的状态(变量 rectMap 用来保存这种状态)

    local rectMap = {}
    
    -- 如果已经是最大状态,适当缩小
    function suitSize(rect, max)
        if max.w - rect.w < 10 and max.h - rect.h < 10 then
            rect.w = max.w * 3 / 4
            rect.h = max.h * 3 / 4
            return true
        end
    end
    
    function centerIt(rect, max)
        rect.x = max.x + (max.w - rect.w) / 2
        rect.y = max.y + (max.h - rect.h) / 2
    end
    
    function setRect(rectTo, rectFrom)
        rectTo.x = rectFrom.x
        rectTo.y = rectFrom.y
        rectTo.w = rectFrom.w
        rectTo.h = rectFrom.h
    end
    
    function setFrame(type)
        local win = hs.window.focusedWindow()
        local f = win:frame()
        local screen = win:screen()
        local max = screen:frame()
        local winId = win:id()
        
        if type == 'left' then
            setRect(f, { x = max.x, y = max.y, w = max.w / 2, h = max.h })
        elseif type == 'right' then
            setRect(f, { x = max.x + max.w / 2, y = max.y, w = max.w / 2, h = max.h })
        elseif type == 'up' then
            setRect(f, { x = max.x, y = max.y, w = max.w, h = max.h / 2 })
        elseif type == 'down' then
            setRect(f, { x = max.x, y = max.y + max.h / 2, w = max.w, h = max.h / 2 })
        elseif type == 'upper-left' then
            setRect(f, { x = max.x, y = max.y, w = max.w / 2, h = max.h / 2 })
        elseif type == 'upper-right' then
            setRect(f, { x = max.x + max.w / 2, y = max.y, w = max.w / 2, h = max.h / 2 })
        elseif type == 'lower-left' then
            setRect(f, { x = max.x, y = max.y + max.h / 2, w = max.w / 2, h = max.h / 2 })
        elseif type == 'lower-right' then
            setRect(f, { x = max.x + max.w / 2, y = max.y + max.h / 2, w = max.w / 2, h = max.h / 2 })
        elseif type == 'max' then
            if max.w - f.w < 10 and max.h - f.h < 10 then
                local last = rectMap[winId]
                if last ~= nil then
                    -- 若 last 记录的就是最大 size,首先缩小,然后居中
                    if suitSize(last, max) then centerIt(last, max) end
                else
                    rectMap[winId] = { x = max.x, y = max.y, w = max.w, h = max.h }
                    last = max
                end
                setRect(f, last)
            else
                rectMap[winId] = { x = f.x, y = f.y, w = f.w, h = f.h }
                setRect(f, max)
            end
        elseif type == 'center' then
            suitSize(f, max)
            centerIt(f, max)
        end
    
        win:setFrame(f, 0)
    end
    
    function mapFrame(meta, key, name)
        hs.hotkey.bind(meta, key, function()
            setFrame(name or key)
        end)
    end
    
    mapFrame({'alt'}, 'left')
    mapFrame({'alt'}, 'right')
    mapFrame({'alt'}, 'up')
    mapFrame({'alt'}, 'down')
    mapFrame({'alt', 'cmd'}, 'left', 'lower-left')
    mapFrame({'alt', 'cmd'}, 'right', 'lower-right')
    mapFrame({'alt'}, 'return', 'center')
    mapFrame({'cmd'}, 'return', 'max')
    

    八、优化 cmd + w 体验

    系统默认的 cmd + w 仅仅是关闭窗口,下面对某些应用,进行优化,实现我们自定义的操作

    由于是优化,所以不能用绑定的方式,如果我们绑定了 cmd + w,其他程序的 cmd + w 功能就会受影响,所以,我采取监听模式:

    -- 优化 cmd + w 体验
    function isCmd(flag)
        -- 分别为:left cmd、right cmd、鼠标手势发出的 cmd
        local cmdMap = { [1048840] = 1, [1048848] = 1, [537919488] = 1 }
        return cmdMap[flag] ~= nil
    end
    
    function toggleMin(name)
        local winList = getWinList(name)
        -- 当 QQ 等程序中有图片窗口时,若扔执行 minimize,会最小化图片窗口
        if #winList == 1 then
            local win = winList[1]
            if win:isMinimized() then win:unminimize() else win:minimize() end
            return true
        end
    end
    
    function killApp(name, app)
        app:kill()
    end
    
    local cmdWActs = {
        ['微信'] = toggleMin,
        QQ = toggleMin,
        ['QQ音乐'] = toggleMin,
        ['日历'] = killApp,
    }
    
    local tapDisabledByTimeout = 4294967294
    local tapDisabledByUserInput = 4294967295
    
    local eventWatcher = hs.eventtap.new({
        hs.eventtap.event.types.keyDown,
        tapDisabledByTimeout,
        tapDisabledByUserInput,
    }, function(ev)
        local type = ev:getType()
        if type == hs.eventtap.event.types.keyDown then
            local data = ev:getRawEventData().NSEventData
            -- print(hs.inspect(data))
            if isCmd(data.modifierFlags) then
                local char = data.characters
                local app = hs.application.frontmostApplication()
                local name = app:name()
                local act = nil
    
                if char == 'w' then act = cmdWActs[name] end
                if char == 'm' then act = toggleMin end
    
                if act ~= nil then return act(name, app) end
            end
        else
            print('---***--- restart event watcher')
            eventWatcher:start()
        end
    end)
    

    上面的代码,对日历应用,直接调用 killApp 退出,对微信、QQ、QQ音乐这三款应用,当我按下 cmd + w 时,调用 toggleMin 切换最小化状态

    细心的你,已经发现了,上面也有一个按下某键,发现窗口只有一个时,切换最小化状态

    为什么我老是喜欢“最小化程序”呢,因为我喜欢 Mac 的这个“神奇效果”,每次看到都很鸡冻的感觉

    神奇效果真神奇

    我顺便优化了 cmd + m 的体验,使 cmd + m 可以切换最小化状态

    美中不足的是,hs.eventtap 有 bug,事件监听,在闲置一段时间后,会被系统禁用

    我查阅了 mac 的开发文档,添加了对 tapDisabledByTimeout 与 tapDisabledByUserInput 的监听,当发生时,调用 eventWatcher:start() 重新监听,但似乎还是不起作用

    于是我又使用定时器,定时检查事件监听状态,代码如下:

    -- tapDisabledByTimeout 不触发
    function eventWatcherKeep()
        if not eventWatcher:isEnabled() then
            eventWatcher:start()
            print('~~~***~~~ (re)start event watcher')
        end
        hs.timer.doAfter(3, eventWatcherKeep)
    end
    eventWatcherKeep()
    

    应该感谢 Hammerspoon 为 eventtap watcher 提供了 isEnabled 状态查询,如果像上面的 hs.application.watcher 一样,没有 isEnabled API,我就无计可施了,除非我懂 Mac 编程,直接用 ObjC 或 Swift 调用原生的 API,那就失去 UI 自动化的意义了

    我粗略看过 Hammerspoon 的 ObjC 源码(细看我也看不懂呀),发现他们似乎也意识到了这个问题,也在代码中进行了处理,但实际情况是:仍没有处理好

    九、感谢、吐槽时间

    Hammerspoon 很赞,虽然有些小 bug,感谢作者的付出
    Lua 真的很难用,Javascript 爱好者表示,写起来真繁琐
    生命不息,折腾不止

    相关文章

      网友评论

        本文标题:Mac UI 自动化

        本文链接:https://www.haomeiwen.com/subject/aaatzttx.html