我是一个爱折腾的极客,曾经将 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 否则,启动,并切换到 Chrome2、若当前应用已经是 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 爱好者表示,写起来真繁琐
生命不息,折腾不止
网友评论