Mac UI 自动化

我是一个爱折腾的极客,曾经将 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 爱好者表示,写起来真繁琐
生命不息,折腾不止

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,470评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,393评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,577评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,176评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,189评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,155评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,041评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,903评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,319评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,539评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,703评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,417评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,013评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,664评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,818评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,711评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,601评论 2 353

推荐阅读更多精彩内容