HAMMERSPOON · GETTING STARTED

Hammerspoon 시작 가이드 한글판

원문(hammerspoon.org/go)의 튜토리얼을 한글로 옮기고, 5개 파트로 나누어 읽기 쉽게 정리했습니다. 위에서 아래로 순서대로 따라 하면 단축키, 창 관리, 이벤트 자동화까지 감을 잡을 수 있습니다.

28개 섹션 모든 코드는 init.lua에 붙여 넣어 실행 코드 블록 우측 상단 “복사” 버튼
PART 1

🚀 준비하기

Hammerspoon이 무엇인지 알고, 설치해서 설정 파일을 여는 데까지

1Hammerspoon이란?What is Hammerspoon?

Hammerspoon은 macOS용 데스크톱 자동화 도구입니다. 시스템 수준의 여러 API를 Lua 스크립트 엔진에 연결해 주기 때문에, Lua 스크립트를 작성하는 것만으로 시스템에 강력한 동작을 시킬 수 있습니다.

무엇을 하나창 배치, 단축키, 앱·WiFi·USB 이벤트 반응, 메뉴바 아이템, 화면 그리기 등을 스크립트로 자동화
어떻게 하나~/.hammerspoon/init.lua 파일에 Lua 코드를 쓰고 설정을 리로드
API는 어디에API 문서 인덱스의 hs.* 모듈들이 제공하는 기능

2Lua란?What is Lua?

Lua는 간단한 프로그래밍 언어입니다. Lua를 써 본 적이 없다면 시작하기 전에 Learn Lua in Y minutes를 한 번 훑어보길 권합니다.

3설치와 설정Setup

1
Hammerspoon 최신 릴리스를 다운로드해서 /Applications 폴더로 옮깁니다.
2
Hammerspoon.app을 실행하고, 안내에 따라 앱의 손쉬운 사용(Accessibility) 권한을 켭니다.
3
메뉴바의 Hammerspoon 아이콘을 클릭하고 메뉴에서 Open Config를 선택합니다.
4
브라우저에서 Hammerspoon API 문서를 열어, 제공되는 확장(모듈)과 그 함수들을 살펴봅니다.
PART 2

👋 첫 스크립트와 Spoon

단축키에 알림을 연결해 보고, 남이 만든 플러그인(Spoon) 쓰는 법

4Hello WorldHello World

모든 프로그래밍 튜토리얼은 Hello World로 시작하니, Hammerspoon의 키보드 단축키 바인딩 기능으로 간단한 알림을 띄워 보겠습니다.

init.lua에 다음을 넣으세요:

init.lua
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "W", function()
  hs.alert.show("Hello World!")
end)

파일을 저장하고 메뉴바의 Hammerspoon 아이콘에서 Reload Config를 선택하세요. 이제 ⌘⌥ctrlW를 누르면 화면에 Hello World 알림이 표시됩니다.

여기서 일어나는 일은, Hammerspoon에게 익명 함수를 특정 단축키에 바인딩하라고 시키는 것입니다. 단축키는 수정키 테이블(여기서는 ⌘, ⌥, ctrl)과 일반 키(W)로 지정합니다. 익명 함수는 이름이 없는 함수일 뿐입니다. alert 함수를 따로 이름을 붙여 정의한 뒤 그 이름을 hs.hotkey.bind()에 넘겨도 되지만, Lua는 함수를 인라인으로 정의하기 쉽게 되어 있습니다.

5조금 더 멋진 Hello WorldFancier Hello World

hs.alert도 유용하지만 macOS 기본 알림을 쓰고 싶다면, 앞의 예제를 다음과 같이 바꾸기만 하면 됩니다:

init.lua
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "W", function()
  hs.notify.new({title="Hammerspoon", informativeText="Hello World"}):send()
end)

6Spoon 소개Introduction to Spoons

Spoon은 Hammerspoon용으로 미리 만들어진 플러그인입니다.

7Spoon 사용하기Using a Spoon

이 예제에서는 “AClock” Spoon을 사용합니다. 설치하려면 Spoon 저장소에서 zip 파일을 받아 압축을 풀고 AClock.spoon 파일을 더블클릭하세요. 그러면 Hammerspoon이 이를 설치하면서 다운로드 폴더에서 치워 줍니다.

Spoon을 설치했다고 해서 자동으로 실행되지는 않으므로, 이제 이를 불러와서 사용하는 설정을 추가합니다:

init.lua
hs.loadSpoon("AClock")
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "C", function()
  spoon.AClock:toggleShow()
end)
PART 3

🪟 창 다루기

가장 바로 쓸모 있는 기능. 창 이동부터 다중 창 레이아웃, 창 필터까지

8창 이동 입문Introduction to window movement

Hammerspoon으로 할 수 있는 가장 즉각적으로 유용한 일 중 하나는 화면 위의 창을 조작하는 것입니다. 간단한 예제로 시작해서 점점 복잡하게 만들어 보겠습니다.

init.lua에 다음을 추가하세요:

init.lua
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "H", function()
  local win = hs.window.focusedWindow()
  local f = win:frame()

  f.x = f.x - 10
  win:setFrame(f)
end)

이제 ⌘⌥ctrlH를 누르면 현재 포커스된 창이 왼쪽으로 10픽셀 이동합니다. 현재 포커스된 창을 가져온 뒤 그 창의 프레임(frame)을 얻는 흐름입니다. 프레임은 창의 위치와 크기를 나타냅니다. 프레임을 수정한 다음 setFrame()으로 창에 다시 적용합니다.

hs.window.focusedWindow()지금 포커스된 창 가져오기
win:frame()창의 위치·크기(x, y, w, h) 얻기
win:setFrame(f)수정한 프레임을 창에 적용

9잠깐: 콜론(:) 문법A quick aside on colon syntax

함수를 호출할 때 어떨 때는 점(.)을, 어떨 때는 콜론(:)을 쓰는 것을 눈치채셨을 겁니다. 콜론 문법은 그 객체의 메서드를 호출한다는 뜻입니다. 여전히 함수 호출이지만, 객체 자신이 self 인자로 암묵적으로 전달됩니다.

init.lua
-- 아래 두 줄은 같은 의미입니다 (원문에 없는 비교 예시)
local f = win:frame()
local f = win.frame(win)

10잠깐: 변수의 생명주기A quick aside about variable lifecycles

Lua는 메모리를 정리하기 위해 가비지 컬렉션을 사용합니다. 더 이상 쓰이지 않는다고 판단한 객체는 나중에 어느 시점엔가 파괴됩니다. (정확히 언제인지는 예측하기 어렵고, Lua 코드가 얼마나 활발히 도는지에 좌우됩니다.)

즉, 함수·반복문 등의 안에서만 존재하는 변수는 그 실행이 끝나는 즉시 가비지 컬렉션 대상이 됩니다. 이는 init.lua도 마찬가지여서, init.lua 전체가 하나의 스코프이고 마지막 줄이 실행되면 그 스코프가 끝납니다.

예를 들어 다음 코드를 봅시다:

init.lua
hs.pathwatcher.new(.....):start()

여기서 반환된 hs.pathwatcher 객체는 어디에도 담기지 않았으므로, init.lua 실행이 끝나자마자 가비지 컬렉션 대상이 됩니다. 실제로 파괴되는 것은 몇 분~몇 시간 뒤일 가능성이 높아서, 나중에 왜 pathwatcher가 동작하지 않는지 혼란스러워지게 됩니다. 대신 아래처럼 하면 설정을 리로드하거나 Hammerspoon을 종료할 때까지 유지됩니다:

init.lua
myWatcher = hs.pathwatcher.new(.....):start()

myWatcher는 전역 변수이므로 스코프를 벗어나는 일이 없습니다.

변수 생명주기에 관한 한 가지 더: 콘솔 창에서는 한 줄을 입력하고 Enter를 칠 때마다 별개의 Lua 스코프가 만들어지고, 실행되고, 끝납니다. 따라서 콘솔에서 만든 local 변수는 Enter를 치는 순간 스코프가 닫히므로 곧바로 접근할 수 없게 됩니다.

11더 복잡한 창 이동More complex window movement

간단한 창 이동 예제를 확장해서, nethack 이동 키를 사용해 모든 방향으로 창을 옮기는 단축키 세트를 만들 수 있습니다:

키 배치
y   k   u
h       l
b   j   n

이를 위해서는 앞의 hs.hotkey.bind() 호출을 프레임 수정 부분만 조금씩 바꿔 반복하면 됩니다:

init.lua
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "Y", function()
  local win = hs.window.focusedWindow()
  local f = win:frame()

  f.x = f.x - 10
  f.y = f.y - 10
  win:setFrame(f)
end)

hs.hotkey.bind({"cmd", "alt", "ctrl"}, "K", function()
  local win = hs.window.focusedWindow()
  local f = win:frame()

  f.y = f.y - 10
  win:setFrame(f)
end)

hs.hotkey.bind({"cmd", "alt", "ctrl"}, "U", function()
  local win = hs.window.focusedWindow()
  local f = win:frame()

  f.x = f.x + 10
  f.y = f.y - 10
  win:setFrame(f)
end)

hs.hotkey.bind({"cmd", "alt", "ctrl"}, "H", function()
  local win = hs.window.focusedWindow()
  local f = win:frame()

  f.x = f.x - 10
  win:setFrame(f)
end)

hs.hotkey.bind({"cmd", "alt", "ctrl"}, "L", function()
  local win = hs.window.focusedWindow()
  local f = win:frame()

  f.x = f.x + 10
  win:setFrame(f)
end)

hs.hotkey.bind({"cmd", "alt", "ctrl"}, "B", function()
  local win = hs.window.focusedWindow()
  local f = win:frame()

  f.x = f.x - 10
  f.y = f.y + 10
  win:setFrame(f)
end)

hs.hotkey.bind({"cmd", "alt", "ctrl"}, "J", function()
  local win = hs.window.focusedWindow()
  local f = win:frame()

  f.y = f.y + 10
  win:setFrame(f)
end)

hs.hotkey.bind({"cmd", "alt", "ctrl"}, "N", function()
  local win = hs.window.focusedWindow()
  local f = win:frame()

  f.x = f.x + 10
  f.y = f.y + 10
  win:setFrame(f)
end)

12창 크기 조절Window sizing

이번에는 창 관리의 흔한 기능인, 창이 화면의 왼쪽 절반 또는 오른쪽 절반을 차지하도록 옮기는 기능을 구현합니다. 두 창을 나란히 놓아 Productivity™를 얻을 수 있습니다.

init.lua
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "Left", function()
  local win = hs.window.focusedWindow()
  local f = win:frame()
  local screen = win:screen()
  local max = screen:frame()

  f.x = max.x
  f.y = max.y
  f.w = max.w / 2
  f.h = max.h
  win:setFrame(f)
end)

여기서는 ⌘⌥ctrl←(왼쪽 화살표 키)를 다음 동작을 하는 함수에 바인딩했습니다. 포커스된 창을 가져오고, 그 창이 있는 화면을 가져오고, 화면의 프레임을 가져온 뒤, 창의 프레임을 화면의 왼쪽 절반으로 설정합니다.

이를 완성하기 위해, 창을 화면의 오른쪽 절반으로 옮기는 함수도 추가합니다:

init.lua
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "Right", function()
  local win = hs.window.focusedWindow()
  local f = win:frame()
  local screen = win:screen()
  local max = screen:frame()

  f.x = max.x + (max.w / 2)
  f.y = max.y
  f.w = max.w / 2
  f.h = max.h
  win:setFrame(f)
end)

13다중 창 레이아웃Multi-window layouts

여러 앱을 항상 열어 두고 창들을 특정한 배치로 유지하고 싶다면 hs.layout 확장을 쓸 수 있습니다:

init.lua
local laptopScreen = "Color LCD"
local windowLayout = {
    {"Safari",  nil,          laptopScreen, hs.layout.left50,    nil, nil},
    {"Mail",    nil,          laptopScreen, hs.layout.right50,   nil, nil},
    {"iTunes",  "iTunes",     laptopScreen, hs.layout.maximized, nil, nil},
    {"iTunes",  "MiniPlayer", laptopScreen, nil, nil, hs.geometry.rect(0, -48, 400, 48)},
}
hs.layout.apply(windowLayout)

조금 나눠서 설명하면, 먼저 Mac의 메인 화면 이름을 담은 변수를 만듭니다. 화면 이름은 hs.screen 객체의 :name() 메서드로 알 수 있습니다. (예: Hammerspoon 콘솔에 hs.screen.allScreens()[1]:name() 입력)

그다음 원하는 레이아웃을 설명하는 테이블을 만듭니다. windowLayout의 각 항목은 또 하나의 테이블이며, 대상 창을 고르고 원하는 위치와 크기를 지정합니다. 항목의 각 자리는 다음 의미입니다:

1번째: 앱 이름영향을 줄 앱의 이름
2번째: 창 제목영향을 줄 창의 제목. 1·2번째는 각각 nil일 수 있지만 둘 다 nil일 수는 없음. 앱 이름이 nil이면 모든 앱에서 해당 제목의 창을 찾고, 창 제목이 nil이면 해당 앱의 모든 창이 대상
3번째: 화면창을 놓을 화면 이름. 화면을 지정하는 다른 방법은 API 문서를 참고
4번째: 비율 recths.window:moveToUnit()에 전달되는 rect. x, y, w, h가 0.0~1.0 사이 값이라 해상도를 신경 쓰지 않고 화면의 비율로 배치 가능. 예: hs.layout.left50은 {x=0, y=0, w=0.5, h=1}이 미리 정의된 값
5번째: 픽셀 recths.window:setFrame()에 전달되는 rect. 화면 위 픽셀 좌표로 지정하며 OS 메뉴바와 Dock을 고려하지 않음
6번째: 픽셀 rect (메뉴바·Dock 반영)5번째와 비슷하지만 OS 메뉴바와 Dock을 고려함

6번째 항목의 예는 위 코드의 iTunes MiniPlayer 창입니다. Dock이 있어도 화면 맨 아래 왼쪽에 놓입니다. hs.geometry.rect() 도우미 함수로 rect 테이블을 만들었고, y 값이 음수라는 것은 창의 상단이 화면 하단보다 48픽셀 위에서 시작한다는 뜻입니다.

옵션이 꽤 복잡해 보이지만 시간을 들여 익힐 가치가 있습니다. 특히 시스템 이벤트(모니터를 꽂아서 화면 수가 바뀌는 경우 등)에 반응하거나, 단축키 하나로 엉망이 된 창들을 원래대로 복구하는 등 아주 강력한 창 레이아웃이 가능해집니다.

14창 필터Window filters

특정 상황이나 앱에서만 단축키가 바인딩되고 다른 곳에서는 안 되면 좋지 않을까요? 위치, 크기, 작업 흐름 등 무엇이든 기준으로 창을 정리하고 이벤트에 반응할 수 있다면요? 매우 다재다능한 hs.window.filter 모듈이 바로 그것을 해 주며, 필터링 규칙과 이벤트 워처로 복잡한 창 그룹과 동작을 만들 수 있습니다. 이 모듈의 힘은 예제로 보는 것이 가장 좋습니다.

Safari에서 Messages.app으로 내용을 복사해 붙여 넣으면 모든 링크가 펼쳐져서 글을 읽기 어려워집니다:

붙여 넣은 결과 (문제 상황)
Thrushes make up the Turdidae, a family <https://en.wikipedia.org/wiki/Family_(biology)> of passerine <https://en.wikipedia.org/wiki/Passerine> birds <https://en.wikipedia.org/wiki/Bird> that occurs worldwide.

이 문제는 Safari→Messages 조합에서만 생기고, 다른 macOS 앱들은 대체로 놀랄 일 없이 복사·붙여넣기가 됩니다. 이 불편은 windowfilter로 깔끔하게 해결할 수 있습니다:

init.lua
local function cleanPasteboard()
  local pb = hs.pasteboard.contentTypes()
  local contains = hs.fnutils.contains
  if contains(pb, "com.apple.webarchive") and contains(pb, "public.rtf") then
    hs.pasteboard.setContents(hs.pasteboard.getContents())
  end
end

local messagesWindowFilter = hs.window.filter.new(false):setAppFilter('Messages')
messagesWindowFilter:subscribe(hs.window.filter.windowFocused, cleanPasteboard)

cleanPasteboard 함수는 클립보드의 콘텐츠 메타데이터 타입을 확인한 뒤, 클립보드에 있는 Safari의 ‘리치 텍스트’를 일반 텍스트로 바꿔 넣습니다. 타입을 확인하기 때문에 Safari에서 이미지를 복사해 붙여 넣는 동작은 그대로 유지됩니다.

동작 순서는 다음과 같습니다:

  1. hs.window.filter.new(false): false로 초기화해서 기본적으로 모든 창을 제외하는 빈 windowfilter를 만듭니다.
  2. :setAppFilter('Messages'): Messages 앱 필터를 추가해서 이 windowfilter가 Messages 창만 관찰하게 합니다.
  3. :subscribe(hs.window.filter.windowFocused, cleanPasteboard): Messages 창이 포커스를 얻을 때마다 cleanPasteboard가 호출되도록 구독합니다.

같은 방식으로 특정 창이나 앱이 포커스를 가졌을 때만 사용자 정의 단축키를 켜고 끌 수도 있습니다.

windowfilter는 동적이어서, 설정한 제약에 따라 백그라운드에서 자동으로 필터링합니다. windowfilter를 술어 함수(predicate function)로 초기화하면 임의로 복잡한 필터 규칙을 만들 수 있습니다:

init.lua
local wf = hs.window.filter.new(function(win)
    local fw = hs.window.focusedWindow()
    return (
      win:isStandard() and
      win:application() == fw:application() and
      win:screen() == fw:screen()
    )
  end)

이 windowfilter는 현재 포커스된 창과 앱과 화면이 모두 같은 표준 창(숨겨지지 않았고 모달이 아닌 창)을 담습니다. 포커스된 창에 따라 대상 창 집합이 계속 갱신됩니다. 이를 hs.window.switcher나 직접 만든 순환 도구와 함께 쓰면 현재 화면에서 포커스된 앱의 창들을 돌아가며 전환할 수 있습니다.

PART 4

🔄 설정 자동 리로드

설정 파일을 고칠 때마다 메뉴에서 Reload Config를 누르는 번거로움 없애기

15간단한 설정 리로드Simple configuration reloading

설정을 편집하다 보면 고칠 때마다 Reload Config 메뉴를 일일이 선택하는 것이 조금 귀찮습니다. 설정을 리로드하는 키보드 단축키를 추가해서 해결할 수 있습니다:

init.lua
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "R", function()
  hs.reload()
end)
hs.alert.show("Config loaded")

이제 ⌘⌥⌃R가 설정을 리로드하는 함수에 바인딩되었고, 설정이 로드되면 화면에 간단한 알림 배너가 몇 초간 표시됩니다.

16고급 설정 리로드Fancy configuration reloading

이제 수동으로 리로드를 강제할 수 있지만, 컴퓨터가 알아서 해 줄 수 있는데 굳이 우리가 해야 할까요?

아래 스니펫은 또 다른 새 확장인 pathwatcher를 소개합니다. 설정 파일이 바뀔 때마다 자동으로 리로드해 줍니다:

init.lua
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
myWatcher = hs.pathwatcher.new(os.getenv("HOME") .. "/.hammerspoon/", reloadConfig):start()
hs.alert.show("Config loaded")

이 예제에서 짚어 볼 점이 몇 가지 있습니다.

  1. 경로 만들기: Lua 함수 os.getenv()로 시스템 환경 변수 HOME(홈 디렉터리 위치)을 가져옵니다. 그리고 Lua의 .. 연산자로 그 문자열에 우리가 아는 경로 부분인 /.hammerspoon/을 이어 붙여, Hammerspoon 설정 디렉터리의 전체 경로를 얻습니다.
  2. 워처 생성: 이 경로로 새 pathwatcher를 만들고, .hammerspoon 디렉터리에서 무언가 바뀔 때마다 reloadConfig 함수를 호출하도록 합니다. 그리고 곧바로 pathwatcher 객체의 start()를 호출해서 동작을 시작시킵니다.
  3. 이름 있는 함수: 이 예제에서는 설정 리로드 함수를 별도의 이름 있는 함수로 구현해 hs.pathwatcher.new()에 인자로 넘겼습니다. 이름 있는 함수를 넘길지, 인라인 익명 함수를 쓸지는 전적으로 취향입니다.
  4. 콜백의 동작: 이 함수는 수정된 파일 이름들이 담긴 테이블을 인자 하나로 받습니다. 목록을 돌면서 각 파일이 .lua로 끝나는지 검사하고, Lua 파일이 하나라도 바뀌었으면 현재 Lua 환경을 파괴하고 설정 파일을 다시 로드하도록 Hammerspoon에게 지시합니다.

17Spoon으로 스마트하게 설정 리로드Smart configuration reloading with Spoons

Hammerspoon은 “Spoon”이라 부르는 Lua 플러그인을 지원합니다. 누구나 Hammerspoon API로 유용한 기능을 만들어 다른 사람에게 배포할 수 있게 해 줍니다.

설정 리로드는 많은 사용자가 원할 기능이라 Spoon으로 만들기에 딱 좋고, 공식 Spoon 저장소에 ReloadConfiguration이라는 Spoon이 이미 있습니다.

1
Spoon 웹페이지의 Download 링크를 클릭합니다. Zip 파일이 Downloads 폴더에 받아지고 압축이 풀려 Spoon 아이콘으로 나타납니다.
2
그 파일을 열면 Hammerspoon이 자동으로 Spoon을 ~/.hammerspoon/Spoons/에 가져옵니다.
3
init.lua에 아래 두 줄을 추가하면 끝입니다.
init.lua
hs.loadSpoon("ReloadConfiguration")
spoon.ReloadConfiguration:start()
PART 5

⚡ 실전 자동화 예제

메뉴 조작, 메뉴바, 앱 · WiFi · USB 이벤트, AppleScript, 화면 그리기, URL 트리거

18애플리케이션 메뉴 조작Interacting with application menus

가끔은 무언가를 자동화하는 유일한 방법이 앱의 GUI를 조작하는 것입니다. 이상적이지는 않지만 일을 끝내려면 종종 필요합니다.

이를 보여 주기 위해 Safari가 여러 User Agent 문자열(웹 서버에 자신을 알리는 방식)을 돌아가며 쓰게 하는 단축키를 만들어 봅니다. 이를 하려면 Safari의 개발 메뉴가 켜져 있어야 합니다. Safari→설정→고급에서 메뉴 막대에서 개발자용 메뉴 보기를 체크하면 됩니다.

init.lua
function cycle_safari_agents()
    hs.application.launchOrFocus("Safari")
    local safari = hs.appfinder.appFromName("Safari")

    local str_default = {"Develop", "User Agent", "Default (Automatically Chosen)"}
    local str_edge = {"Develop", "User Agent", "Microsoft Edge — macOS"}
    local str_chrome = {"Develop", "User Agent", "Google Chrome — Windows"}

    local default = safari:findMenuItem(str_default)
    local edge = safari:findMenuItem(str_edge)
    local chrome = safari:findMenuItem(str_chrome)

    if (default and default["ticked"]) then
        safari:selectMenuItem(str_edge)
        hs.alert.show("Edge")
    end
    if (edge and edge["ticked"]) then
        safari:selectMenuItem(str_chrome)
        hs.alert.show("Chrome")
    end
    if (chrome and chrome["ticked"]) then
        safari:selectMenuItem(str_default)
        hs.alert.show("Safari")
    end
end
hs.hotkey.bind({"cmd", "alt", "ctrl"}, '7', cycle_safari_agents)

여기서는 먼저 Safari를 실행하거나, 이미 실행 중이면 맨 앞으로 가져옵니다. 이는 메뉴를 조작할 때 중요한 단계입니다. 포커스되지 않은 앱의 메뉴는 대개 비활성화되어 있기 때문입니다.

그다음 hs.appfinder.appFromName()으로 Safari 자체에 대한 참조를 얻습니다. 이 객체로 사용 가능한 메뉴 항목을 검색하고 조작할 수 있습니다. 구체적으로는 Develop→User Agent에 있는 세 문자열의 현재 상태를 찾은 뒤, 어느 것이 체크되어 있는지 확인하고 다음 것을 선택합니다.

따라서 ⌘⌥⌃7을 반복해서 누르면 기본 User Agent, Edge, Chrome이 순환합니다. 매번 순환한 User Agent 이름을 화면에 간단한 알림으로 표시합니다.

launchOrFocus("앱")앱을 실행하거나 앞으로 가져오기
appFromName("앱")이름으로 앱 객체 얻기
findMenuItem({...})메뉴 경로 배열로 메뉴 항목 찾기 (ticked 등 상태 포함)
selectMenuItem({...})메뉴 항목 실행

19간단한 메뉴바 아이템 만들기Creating a simple menubar item

많은 Mac 유틸리티가 시스템 메뉴바에 작은 아이콘을 두어 상태를 보여 주고 상호작용하게 합니다. Hammerspoon 확장 두 가지를 사용해서 인기 유틸리티 Caffeine의 아주 단순한 대체품을 만들어 봅시다.

init.lua
caffeine = hs.menubar.new()
function setCaffeineDisplay(state)
    if state then
        caffeine:setTitle("AWAKE")
    else
        caffeine:setTitle("SLEEPY")
    end
end

function caffeineClicked()
    setCaffeineDisplay(hs.caffeinate.toggle("displayIdle"))
end

if caffeine then
    caffeine:setClickCallback(caffeineClicked)
    setCaffeineDisplay(hs.caffeinate.get("displayIdle"))
end

이 스니펫은 기기가 사용하지 않을 때 잠자기 상태로 들어가도록 허용되면 SLEEPY, 잠자기를 거부하면 AWAKE를 보여 주는 메뉴바 아이템을 만듭니다. 디스플레이가 잠들지 않게 막는 기능은 hs.caffeinate 확장이, 메뉴바 아이템 자체는 hs.menubar가 제공합니다.

메뉴바 아이템을 만들고, 그 클릭 이벤트에 콜백(여기서는 caffeineClicked())을 연결했습니다. 텍스트 대신 아이콘을 쓸 수도 있습니다. 작은 이미지 파일을 ~/.hammerspoon/에 두고 메뉴바 객체의 :setIcon() 메서드를 쓰면 됩니다. 자세한 내용은 hs.menubar API 문서를 보세요.

20앱 이벤트에 반응하기Reacting to application events

hs.application.watcher 콜백을 쓰면 앱이 실행, 종료, 숨김, 활성화되는 등의 앱 수준 이벤트에 반응할 수 있습니다.

아주 간단한 콜백으로 시연해 보겠습니다. Finder 앱을 활성화하면 Finder의 모든 창이 화면 앞으로 오도록 만듭니다.

init.lua
function applicationWatcher(appName, eventType, appObject)
    if (eventType == hs.application.watcher.activated) then
        if (appName == "Finder") then
            -- Finder 창 하나가 활성화되면 Finder의 모든 창을 앞으로 가져옴
            appObject:selectMenuItem({"Window", "Bring All to Front"})
        end
    end
end
appWatcher = hs.application.watcher.new(applicationWatcher)
appWatcher:start()

먼저 세 개의 매개변수를 받는 콜백 함수를 정의하고, 함수를 촉발한 이벤트 종류가 앱 활성화인지 확인합니다. 그다음 활성화된 앱이 Finder인지 확인합니다. 그렇다면 메뉴 항목을 선택해서 모든 창을 앞으로 가져옵니다.

이어서 우리 함수를 호출하는 앱 워처 객체를 만들고 시작시킵니다.

21WiFi 이벤트에 반응하기Reacting to wifi events

MacBook을 쓴다면 집에 WiFi 네트워크가 있을 겁니다. Hammerspoon에서는 집에 도착해 WiFi에 붙거나, 집을 떠나 연결이 끊길 때 이벤트를 트리거하는 것이 매우 간단합니다. 여기서는 간단한 일을 해 보겠습니다. 집을 벗어나 있을 때 MacBook의 오디오 볼륨을 0으로 맞춥니다. (카페에서 MacBook을 열었는데 집에서 틀어 놓은 음악이 크게 울려 퍼지는 창피함에서 구해 줍니다!)

init.lua
wifiWatcher = nil
homeSSID = "MyHomeNetwork"
lastSSID = hs.wifi.currentNetwork()

function ssidChangedCallback()
    newSSID = hs.wifi.currentNetwork()

    if newSSID == homeSSID and lastSSID ~= homeSSID then
        -- 방금 집 WiFi 네트워크에 접속함
        hs.audiodevice.defaultOutputDevice():setVolume(25)
    elseif newSSID ~= homeSSID and lastSSID == homeSSID then
        -- 방금 집 WiFi 네트워크에서 벗어남
        hs.audiodevice.defaultOutputDevice():setVolume(0)
    end

    lastSSID = newSSID
end

wifiWatcher = hs.wifi.watcher.new(ssidChangedCallback)
wifiWatcher:start()

현재 WiFi 네트워크 이름과 이전 네트워크 이름을 비교하는 콜백 함수를 만들었습니다. 미리 정해 둔 집 네트워크에서 다른 곳으로 옮겨 갔는지, 혹은 그 반대인지 살펴본 뒤 hs.audiodevice로 시스템 볼륨을 조절합니다.

22USB 이벤트에 반응하기Reacting to USB events

반응하고 싶은 USB 하드웨어가 있다면 hs.usb.watcher가 그 확장입니다. 아래 예제에서는 스캐너를 꽂으면 스캐너 소프트웨어를 자동으로 실행하고, 뽑으면 그 소프트웨어를 종료합니다.

init.lua
usbWatcher = nil

function usbDeviceCallback(data)
    if (data["productName"] == "ScanSnap S1300i") then
        if (data["eventType"] == "added") then
            hs.application.launchOrFocus("ScanSnap Manager")
        elseif (data["eventType"] == "removed") then
            app = hs.appfinder.appFromName("ScanSnap Manager")
            app:kill()
        end
    end
end

usbWatcher = hs.usb.watcher.new(usbDeviceCallback)
usbWatcher:start()

23붙여넣기 차단 우회하기Defeating paste blocking

일부 프로그램과 웹사이트는 비밀번호 붙여넣기를 막으려고 애를 씁니다. 그게 더 안전하다고 생각하는 모양이지만, 강력하게 암호화된 비밀번호 관리자의 시대에 이는 물론 말이 안 됩니다.

다행히 가짜 키보드 이벤트를 발생시켜 클립보드 내용을 직접 타이핑하는 방식으로 이 훼방을 우회할 수 있습니다:

init.lua
hs.hotkey.bind({"cmd", "alt"}, "V", function() hs.eventtap.keyStrokes(hs.pasteboard.getContents()) end)

24AppleScript 실행Running AppleScript

필요한 자동화가 어떤 애플리케이션 안에 갇혀 있어서 Hammerspoon으로 제어하는 것이 불가능해 보일 때가 있습니다. 하지만 많은 앱이 AppleScript로 기능을 노출하고 있고, Hammerspoon은 이를 대신 실행해 줄 수 있습니다:

init.lua
ok,result = hs.applescript('tell Application "iTunes" to artist of the current track as string')
hs.alert.show(result)

이 코드는 iTunes에게 지금 재생 중인 트랙의 아티스트를 물어보고, hs.alert로 화면에 표시합니다.

하지만 iTunes 관련 AppleScript를 잔뜩 쓰러 달려가기 전에, 이 가이드의 다음 항목을 확인해 보세요.

25iTunes / Spotify 제어Controlling iTunes/Spotify

hs.itunes와 hs.spotify를 쓰면 iTunes와 Spotify의 여러 측면을 조회하고 제어할 수 있습니다. 예를 들어 한 앱에서 다른 앱으로 전환해야 한다면:

init.lua
hs.itunes.pause()
hs.spotify.play()
hs.spotify.displayCurrentTrack()

26화면에 그리기Drawing on the screen

마우스 포인터가 어디 있는지 도저히 못 찾을 때가 있습니다. 분명히 어딘가에 두었는데 모니터 중 하나에 숨어 있고, 마우스를 흔들어도 찾을 수 없습니다. 다행히 마우스 포인터를 조회하고 제어할 수 있고, 화면 위에 무언가를 그릴 수도 있으므로 다음과 같은 것을 할 수 있습니다:

init.lua
mouseCircle = nil
mouseCircleTimer = nil

function mouseHighlight()
    -- 기존 강조 표시가 있으면 삭제
    if mouseCircle then
        mouseCircle:delete()
        if mouseCircleTimer then
            mouseCircleTimer:stop()
        end
    end
    -- 마우스 포인터의 현재 좌표를 가져옴
    mousepoint = hs.mouse.absolutePosition()
    -- 마우스 포인터 주변에 큰 빨간 원을 준비
    mouseCircle = hs.drawing.circle(hs.geometry.rect(mousepoint.x-40, mousepoint.y-40, 80, 80))
    mouseCircle:setStrokeColor({["red"]=1,["blue"]=0,["green"]=0,["alpha"]=1})
    mouseCircle:setFill(false)
    mouseCircle:setStrokeWidth(5)
    mouseCircle:show()

    -- 3초 뒤에 원을 삭제하도록 타이머 설정
    mouseCircleTimer = hs.timer.doAfter(3, function()
      mouseCircle:delete()
      mouseCircle = nil
    end)
end
hs.hotkey.bind({"cmd","alt","shift"}, "D", mouseHighlight)

현재 지원되는 그리기 객체는 선, 원, 상자, 텍스트, 이미지 등 여러 종류가 있습니다. 종류마다 속성이 다르며 모두 API 문서에 자세히 나와 있습니다.

그리기 객체는 다른 모든 창 위 또는 바탕화면 아이콘 뒤에 놓을 수 있습니다. 그래서 (이 마우스 찾기 예제처럼) 화면 위에 맥락 정보를 오버레이로 보여 주거나, 모든 창 뒤에 좀 더 영구적인 정보 표시(사람들이 GeekTool로 하던 상태 정보 같은 것)를 두는 데 유용합니다.

27iMessage / SMS 보내기Sending iMessage/SMS messages

원문은 이 예제를 설명하지 않고 “무슨 일을 하는지 직접 알아맞혀 보라”고 합니다. WiFi 이벤트에 반응하기에서 본 WiFi 부분이 눈에 익을 겁니다:

init.lua
coffeeShopWifi = "Baristartisan_Guest"
lastSSID = hs.wifi.currentNetwork()
wifiWatcher = nil

function ssidChanged()
    newSSID = hs.wifi.currentNetwork()

    if newSSID == coffeeShopWifi and lastSSID ~= coffeeShopWifi then
        -- 카페에 도착함
        hs.messages.iMessage("iphonefriend@hipstermail.com", "Hey! I'm at Baristartisan's, come join me!")
        hs.messages.SMS("+1234567890", "Hey, you don't have an iPhone, but you should still come for a coffee")
    end

    lastSSID = newSSID
end

wifiWatcher = hs.wifi.watcher.new(ssidChanged)
wifiWatcher:start()

눈치채셨겠지만, 이 코드는 Mac이 좋아하는 힙한 카페에 도착할 때마다 두 사람에게 메시지를 보냅니다. 동작하려면 macOS의 메시지(Messages) 앱이 iMessage와 SMS 전송을 모두 할 수 있게 설정되어 있어야 합니다. (SMS는 iPhone의 SMS 릴레이를 통해 보냅니다.)

28URL로 Hammerspoon 자동화하기Automating Hammerspoon with URLs

가끔은 자동화 도구 자체를 자동화해야 할 때가 있고, Hammerspoon은 여러 방법으로 자동화할 수 있습니다. 여기서 다룰 첫 번째 방법은 URL입니다. 구체적으로 hammerspoon://로 시작하는 URL입니다. 다음 간단한 스니펫을 봅시다:

init.lua
hs.urlevent.bind("someAlert", function(eventName, params)
    hs.alert.show("Received someAlert")
end)

이제 someAlert라는 이름의 이벤트에 대한 URL 이벤트 핸들러를 바인딩했고, 이 핸들러는 화면에 작은 텍스트 알림을 띄웁니다. 이 이벤트를 트리거하려면 터미널에서 다음을 실행합니다:

Terminal
open -g hammerspoon://someAlert

많은 애플리케이션이 URL을 열 수 있으므로, 이는 Hammerspoon이 어떤 동작을 하도록 자동화하는 아주 간단한 방법이 됩니다.

Credits — 이 가이드는 Joseph Holsten과 그의 Mjolnir 가이드에 큰 빚을 지고 있습니다. (원문 표기)

출처: https://www.hammerspoon.org/go/ (2026-09-30 기준 확인). 코드는 원문 그대로이며, 코드 안 주석만 한국어로 옮겼습니다.

“참고 / 주의 / 핵심 정리 / 직접 해보기” 상자 중 원문에 없는 보충 설명에는 본문에 그렇게 표시해 두었습니다. 파트 구분과 섹션 번호는 정리자가 붙였습니다.