Hammerspoon 是 Mac 上一款自动化工具。提供了丰富的 API,可以让用户通过 Lua 脚本来访问系统功能,包括但不限于剪贴板、窗口、键盘事件、文件系统、电池、屏幕、Wi-Fi 等。每一个扩展插件称之为 Spoon,因为项目本身是开源的,所以社区上有许多现成的 Spoon。具体可以参考 官方 Spoon 仓库 ,及 Spoon 使用说明。项目本身是来源自 mjolnir,已经有五年多了,并且最近一直有 commit,还发了版,所以凉掉是不用担心的
官方 Spoon 仓库 中推荐:
- UnsplashZ:定时更换壁纸,壁纸来源于 Unsplash。默认是自然类别图片,可以更改代码中的
?nature
自定义图片类别 - AppLauncher:快捷键启动应用,Alfred 更好用一些
- HSearch:快捷搜索
- WinWin:窗口管理
- PasswordGenerator: 快捷生成密码并且放到粘贴版中
如果对成熟配置比较感兴趣,那么可以继续参考这篇文章
下面本文来讲一下,如何从 0 开始搞 Hammerspoon,并通过 3 个小例子来说明如何编写插件。阅读下面部分需要的前置条件:
- 安装 Hammerspoon
- Lua 基础,可以直接 Learn X in Y minutes 和 Programming in Lua(市面上有中译本)
- 浏览器打开这个页面 Hammerspoon-Doc
以下所有代码可以从 GitHub 找到
From scratch#
hammerspoon 启动时会加载 ~/.hammerspoon
中的文件。这个目录一般如下这个样子
.
|-- Spoons
| |-- xxx.spoon
| | |-- init.lua
| | `-- docs.json
| |-- yyy.spoon
| | |-- init.lua
| | `-- docs.json
`-- init.lua
init.lua
是入口,需要引入所有的 Spoon。Spoons 目录中存放所有的插件,插件需要以 .spoon
结尾,每个插件需要包含 init.lua
文件。Spoon 需要满足
- Spoon names should use TitleCase.
- Spoon methods/variables/constants/etc. should use camelCase.
加载一个 Spoon 需要通过内置的 API hs.loadSpoon()
,参数为 Spoon 的名称。调用此函数后会执行对应 Spoon 下的 init.lua
文件。我们可以直接在 init.lua
上平铺写代码,也可以通过返回一个 obj
的方式。Hammerspoon 会自动调用 obj
的 init
方法。
首先我们在根目录的 init.lua
中加载我们所有的 Spoon
spoons_list = {"Archer", "Wind", "Bitwarden"} -- 对应后文中 3 个例子的 Spoon 名称
for _, v in pairs(spoons_list) do
hs.loadSpoon(v)
end
为了使我们调试更方便,我们开启 Auto Reload 功能
local pathwatcher = require "hs.pathwatcher"
local alert = require "hs.alert"
function autoReload()
-- https://www.hammerspoon.org/go/#fancyreload
function reloadConfig(files)
doReload = false
for _, file in pairs(files) do
if file:sub(-4) == ".lua" then
doReload = true
end
end
if doReload then
hs.reload()
end
end
pathwatcher.new(os.getenv("HOME") .. "/.hammerspoon", reloadConfig):start()
alert.show("Hammerspoon Config Reloaded")
end
hs.alert
是可以在屏幕中央弹出字符串信息,然后自动消失hs.pathwatcher
可以监控目录和文件变更,如果有那么就执行回调函数
另外,我们在调试的时候需要打开 Hammerspoon 的 Console,借助 hs.logger
模块可以向打印调试日志
Example 1 键位映射#
我们首先来编写一个比较简单的 Spoon。它只作一件事情,监听键盘事件,然后决定时候更改这些事件内容。具体需求是,没有显式方向键的那种 HHKB 有点麻烦,所以使用 Alt
/Opt
+ hjkl
来做方向键。其实这种需求可以直接通过 Karabiner-Elements 完成
这里我们需要借助 hs.eventtap
模块,它不只可以监听键盘的事件,鼠标触控板也可以的。
function listener(event)
-- do something
end
tapper = eventtap.new({hs.eventtap.event.types.keyDown}, listener)
tapper:start()
hs.eventtap.new
函数接收两个参数,事件类型和回调函数。这里我们监听的是键盘的键按下的事件。额外说一点,键按下(KeyDown)和弹起(KeyUp)是不同的事件,这点其他编程框架也是一样的。如果我们监听弹起(keyUp)事件,那么比如我们按住方向键持续移动,这种情景是没有 KeyUp 的
下面我们实现回调函数:
-- custom leader key
local leaderKey = 'alt'
-- custom event mapping
local eventMap = {
-- newKeyEvent(mods, key, isDown)
h = eventtap.event.newKeyEvent({}, "left", true),
j = eventtap.event.newKeyEvent({}, "down", true),
l = eventtap.event.newKeyEvent({}, "right", true),
k = eventtap.event.newKeyEvent({}, "up", true)
}
local function listener(event)
-- Get the keyboard modifiers of an event
local flags = event:getFlags()
-- Get the keycode name of the event
local keyName = keycodes.map[event:getKeyCode()]
if not flags:containExactly({leaderKey}) then
return
end
local newEvent = eventMap[keyName]
if not (newEvent == nil) then
return true, {newEvent}
end
end
回调函数接收一个 event
对象并返回布尔值和新的 event
对象。如果布尔值为 true
那么会拦截掉原有的 event
对象,否则继续发送这个 event
。因为这里我们是重新映射,所以返回的是 true
。在回调函数中我们首先判断是否按下 Alt
/Opt
,所以通过 getFlags
来提取修饰键名称。然后我们使用 getKeyCode
来判断是否是 hjkl
。最后返回新的 event
对象,newKeyEvent
参数分别为修饰键名称数组,键名称,是否按下
OK,我们这样完成了键位映射功能。可能你也发现了,Hammerspoon 实现了很多领域的功能,但是对比那些领域中的佼佼者会显得逊色
Example 2 窗口管理#
Mac 上有一个比较好用的平铺式窗口管理工具 Amethyst 。我们这里使用 Hammerspoon 实现如下的窗口操作
local options = enum({
"center", -- 居中
"fullscreen", -- 全屏
"halfleft", -- 左半ping
"halfright",
"halftop",
"halfbottom",
"northeast",
"northwest",
"southeast",
"southwest"
})
local directions = enum({"left", "right", "up", "down"})
我们可以定义一个函数实现平移操作
function obj:panning(direction)
-- obj:updateOperatedAt()
local cwin = window.focusedWindow()
if not cwin then
alert.show("No focused window")
return
end
local cscreen = cwin:screen()
local cres = cscreen:fullFrame()
local wtopleft = cwin:topLeft()
local stepw = cres.w / 30
local steph = cres.h / 30
if direction == directions.left then
cwin:setTopLeft({x = wtopleft.x - stepw, y = wtopleft.y})
elseif direction == directions.right then
cwin:setTopLeft({x = wtopleft.x + stepw, y = wtopleft.y})
elseif direction == directions.up then
cwin:setTopLeft({x = wtopleft.x, y = wtopleft.y - steph})
elseif direction == directions.down then
cwin:setTopLeft({x = wtopleft.x, y = wtopleft.y + steph})
end
end
hs.window
模块提供了窗口的操作。首先我们使用 hs.window.focusedWindow
获取到当前窗口。这里需要说明两个概念:
- window: 窗体,一个 App 可以有多个窗体
- screen: 屏幕,多个显示器有多个屏幕
Hammerspoon 这里假定一个跨越所有屏幕的网格, 原点 0,0 在主屏幕的左上角。(主屏幕左侧或上方的屏幕将具有负坐标)
我们根据 window 对象所在的 screen 的 height, width 来计算一次移动的具体是多少。然后借助 setTopLeft
方法重新设置对象的左上角座标。这样我们就完成了窗口移动的函数了。再来我们编写左半屏,右半屏这种功能,moveToUnit
能够移动并且缩放窗口,其参数是一个 hs.geometry
,这个可以有多种表示形式,总之就是表示了 x, y, w, h。具体还是参考文档 吧
function obj:moveAndResize(option)
obj:updateOperatedAt()
local cwin = window.focusedWindow()
if not cwin then
alert.show("No focused window")
return
end
if option == options.center then
cwin:centerOnScreen()
elseif option == options.fullscreen then
cwin:toggleFullScreen()
elseif option == options.halfleft then
cwin:moveToUnit(layout.left50)
elseif option == options.halfright then
cwin:moveToUnit(layout.right50)
elseif option == options.halftop then
cwin:moveToUnit('[0,0,100,50]')
elseif option == options.halfbottom then
cwin:moveToUnit('[0,50,100,100]')
elseif option == options.northwest then
cwin:moveToUnit('[0,0,50,50]')
elseif option == options.northeast then
cwin:moveToUnit('[50,0,100,50]')
elseif option == options.southwest then
cwin:moveToUnit('[0,50,50,100]')
elseif option == options.southeast then
cwin:moveToUnit('[50,50,100,100]')
end
end
窗口操作函数都准备好了,接着我们要绑定快捷键。hs.hotkey.bind
可以新建快捷键,例如
hotkey.bind(hyper, "Left", function()
-- do something
end)
不过这样有点麻烦,我们需要使用大量的绑定。键盘上面顺手的一共就那么几个键,早晚被绑定完了,除此之外窗口移动本身是内聚性高的一组操作。所以这里我们借助 Vim 的 LeaderKey 的概念,定义一个 窗口管理的 Prefix。正好 hammperspoon 也提供了这样的 API hs.hotkey.modal
k = hs.hotkey.modal.new('cmd-shift', 'd')
function k:entered() hs.alert'Entered mode' end
function k:exited() hs.alert'Exited mode' end
k:bind('', 'escape', function() k:exit() end)
k:bind('', 'J', 'Pressed J',function() print'let the record show that J was pressed' end)
cmd-shift
+ d
进入快捷键组,我们在这个组中绑定了 J
这个键,显示提示信息 Pressed J
然后执行回调函数。entered
和 exited
都是回调函数,分别在进入和退出的时候调用。这个功能好是好,但是我们需要显示的按键才能从组中退出。一般来说, Vim 或者 tmux 这中工具都有一个超时的(或者是按键间隔时长)。这里我希望比如超过 2 秒没有按键产生,那么直接退出,恢复正常的输入状态
obj.lastOperatedAt = os.time(os.date("!*t"))
-- 每次窗口操作时更新 lastOperatedAt
function obj:updateOperatedAt()
obj.lastOperatedAt = os.time(os.date("!*t"))
end
local checker = timer.new(0.5, function()
local currentTime = os.time(os.date("!*t"))
if (currentTime - obj.lastOperatedAt) >= 2 then
k:exit()
end
end)
function obj:init()
k = hotkey.modal.new('alt', 'w')
function k:entered()
-- obj:showStatus()
if not checker:running() then
checker:start()
end
end
function k:exited()
-- obj:hideStatus()
if checker:running() then
checker:stop()
end
end
end
因为没有直接的 API 做这个事情,所以启一个定时器,每隔 0.5 秒检查一下最近 2 秒内是否进行过窗口操作就行了
在进行窗口操作的时候,我希望有一个显示的提示,比如 Vim 中 Normal/Insert Mode 的提示一样,确切的知道自己处于何种模式中,免得瞎按键误操作。万能的 hammerspoon 还提供了 hs.canvas
这种绘图的 API
function obj:showStatus()
if self.statusInfo == nil then
statusInfo = canvas.new({x = 0, y = 0, w = 0, h = 0})
statusInfo:insertElement({
type = "text", -- 文本类型
text = "wMode", -- 文本内容
textFont = "Impact",
textSize = 128,
textColor = {hex = "#1891C3"}, -- 颜色
textAlignment = "center" -- 居中
})
self.statusInfo = statusInfo
end
local mainScreen = screen.mainScreen()
local mainRes = mainScreen:fullFrame()
statusInfo:frame({
x = (mainRes.w - 512) / 2,
y = (mainRes.h - 256) / 2,
w = 512,
h = 256
})
self.statusInfo:show()
end
function obj:hideStatus()
if self.statusInfo == nil then
return
end
self.statusInfo:hide()
end
我们这里使用的是文字类型的 Element
,canvas 对象的 frame 属性需要每次设置的,因为换了切换屏幕后宽和高都会改变的
Example 3 简化 Bitwarden 密码管理#
效果如下图,这个 Spoon 目前还没有发现替代品 ( ̄_, ̄ )
P.S. 本 GIF 中黑点的数量不代表密码的真实密码的长度这个 Spoon 需要结合 Bitwarden 一起使用,并且需要安装 Bitwarden-cli。因为其实是调用的 Shell Command,用到了 lock
, unlock
, get
这 3 个命令,用法可以参考 The Bitwarden command-line tool (CLI)
我们首先封装一下 Bitwarden-cli 的几个命令
local bitwarden = {}
function bitwarden:lock()
hs.execute([[ /usr/local/bin/bw lock ]], true)
end
function bitwarden:unlock(password)
local output, status = hs.execute(string.format(
[[ /usr/local/bin/bw unlock '%s']],
password), true)
if not status then
alert.show("Login Failed")
logger:e(string.format("Login Failed, the bw cli returns: %s", output))
return false, ""
end
-- A trick way: grep the bitwarden cli output
local sessionKey = string.match(output, '%"[%w%p]+%"') -- with quotes
if not sessionKey then
alert.show("Could not find the session text from the bw command output")
logger:e(string.format(
"session key grep failed, the bw cli returns: %s", output))
return false, ""
end
return true, sessionKey
end
function bitwarden:getPassword(itemName, sessionKey)
output, status = hs.execute(string.format(
[[ /usr/local/bin/bw get password "%s" --session %s]],
itemName, sessionKey), true)
if not status then
alert.show("Get Item Password Failed")
logger:e(
string.format("get item failed, the bw cli returns: %s", output))
return false, ""
end
return true, output
end
步骤是,根据用户的 Master Password 进行 unlock
,拿到 session 后获取到用户指定 Name 下的 Password,最后 lock
销毁当前的 session。session 是使用正则从 unlock
命令的结果中扣出来的,有点 Low
因为需要用户输入密码,这里需要使用 hs.dialog
模块中的 textPrompt
。这个就是输入文本框,和 JS 差不多的。
function askBwPassword()
local app = application.frontmostApplication()
local win = window.focusedWindow()
-- Firstly, we unlock the bitwarden vault.
hs.focus() -- need manually focus: https://github.com/Hammerspoon/hammerspoon/issues/1561
local button, password = dialog.textPrompt("Your Bitwarden password", "",
"", "OK", "Cancel", true)
if button == "Cancel" then
return
end
local status, sessionKey = bitwarden:unlock(password)
if not status then
return
end
-- Secondly, we ask for the item.
local button, itemName = dialog.textPrompt("Which Item", "", "", "OK",
"Cancel", false)
if button == "Cancel" then
bitwarden:lock()
return
end
local status, itemPassword = bitwarden:getPassword(itemName, sessionKey)
if not status then
bitwarden:lock()
return
end
bitwarden:lock()
-- Thirdly, send the keyboard events to the app and focus the window.
eventtap.keyStrokes(itemPassword, app)
-- hs.pasteboard.setContents(itemPassword)
win:focus()
end
我们使用两个 textPrompt
,第一个要密码,第二个需要 Name。我们需要实现密码直接输入到之前 focus 的文本框中,那么首先我们需要获取到当前聚焦的 app 和 window(一个 app 可以有多个 window)。通过 hs.eventtap.keyStrokes
发送模拟键盘事件到 App 中,然后还原窗口聚焦。这样一般我们再敲一下回车就可以登录了
Bitwarden 在浏览器上是有扩展插件的,但是这个 Spoon 解决了在所有 App 中输入密码的问题
Next Step#
编写插件,自娱自乐 <( ̄ˇ ̄)/