맥에서 뜨는 알림을 아이폰에서도 받고 싶었습니다. 상용 푸시 서비스는 앱별 아이콘을 붙일 수 없고, 알림 본문이 남의 서버를 거칩니다. 그래서 셀프호스팅 Bark 서버로 맥의 모든 알림센터 알림을 그대로 흘려보내는 에이전트를 만들었습니다.
이 글에서는 알림센터 DB를 직접 읽는 방법, 그 과정에서 만난 TCC(전체 디스크 접근) 권한의 함정, 그리고 launchd로 상시 실행하는 구성을 정리합니다. 완성된 코드는 okdohyuk/bark-mac에 MIT 라이선스로 공개해 두었습니다.
Bark 서버는 예전에 홈 네트워크 젠킨스 무중단 배포 구축기에서 만들어 둔 미니PC 환경에 올렸습니다. 터미널 작업의 완료 알림은 muxpad 회고에서 다룬 것처럼 tmux 출력 스트림으로도 받고 있지만, 그건 터미널 안에서 나온 신호만 잡습니다. 이 글의 대상은 맥에 뜨는 알림센터 알림 전체입니다.
알림센터 DB는 이사했습니다
맥의 알림은 SQLite DB에 쌓입니다. 검색하면 대부분 이 경로를 알려줍니다.
/var/folders/**/com.apple.notificationcenter/db2/dbmacOS Sequoia(15) 이후로는 이 경로가 빈 껍데기입니다. 실제 데이터는 그룹 컨테이너로 옮겨졌습니다.
~/Library/Group Containers/group.com.apple.usernoted/db2/db구조는 단순합니다. app 테이블이 app_id ↔ 번들 식별자를 매핑하고, record 테이블에 알림 한 건이 한 행씩 쌓입니다. 본문은 data 컬럼에 바이너리 plist로 들어 있습니다.
SELECT app_id, identifier FROM app;
SELECT rec_id, app_id, data FROM record WHERE rec_id > ? ORDER BY rec_id ASC LIMIT 200;plist를 풀면 req 딕셔너리 아래에 제목(titl)·부제(subt)·본문(body)이 있습니다.
func parse(_ data: Data) -> (titl: String, subt: String, body: String) {
guard !data.isEmpty,
let pl = try? PropertyListSerialization.propertyList(
from: data, options: [], format: nil) as? [String: Any],
let req = pl["req"] as? [String: Any] else { return ("", "", "") }
func s(_ k: String) -> String {
(req[k] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
}
return (s("titl"), s("subt"), s("body"))
}여기까지는 쉽습니다. 문제는 이 DB를 읽을 수 있느냐입니다.
TCC는 launchd로 띄운 스크립트에 전파되지 않습니다
이 DB는 TCC의 전체 디스크 접근 권한(Full Disk Access, 이하 FDA) 으로 보호됩니다. 터미널에 FDA를 주고 파이썬 스크립트를 돌리면 잘 읽힙니다. 그래서 처음에는 파이썬 폴러를 짜고 launchd에 등록했습니다.
그리고 바로 막혔습니다. 로그에는 계속 이 줄만 찍혔습니다.
DB 열기 실패(전체 디스크 접근 권한 필요)원인은 TCC의 책임 프로세스(responsible process) 개념입니다. macOS는 권한을 "실행된 바이너리"가 아니라 "그 실행에 책임이 있는 프로세스"에 귀속시킵니다.
- 터미널에서
python3 poller.py→ 책임 프로세스는 터미널. 터미널의 FDA가 적용됩니다. - launchd가
python3 poller.py를 띄움 → 책임 프로세스는 launchd. 터미널의 FDA와 무관하고,/usr/bin/python3에 FDA를 줘도 시스템 바이너리라 그 권한이 안정적으로 유지되지 않습니다.
즉 셸 스크립트·파이썬·osascript 같은 인터프리터를 거치는 방식은 launchd 상시 실행과 FDA를 동시에 만족시킬 수 없습니다.
📌 해결책은 하나였습니다. 서명된 네이티브 앱이 자기 자신으로 DB를 직접 읽게 하는 것입니다. 그러면 책임 프로세스가 그 앱 자신이 되고, 시스템 설정에서 그 앱에 부여한 FDA가 그대로 적용됩니다.
서명된 Swift 앱으로 다시 만들기
본체를 Swift 단일 파일로 다시 썼습니다. SQLite3를 읽기 전용으로 열고, 3초 주기로 새 행만 가져옵니다.
let dbPath = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Group Containers/group.com.apple.usernoted/db2/db")
func openDB() -> OpaquePointer? {
var db: OpaquePointer?
if sqlite3_open_v2(dbPath.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK {
sqlite3_busy_timeout(db, 3000)
return db
}
if let db = db { sqlite3_close(db) }
return nil
}빌드는 swiftc로 바이너리를 만들고 .app 번들로 감싼 뒤 Apple Development 인증서로 서명합니다. 로컬에서만 실행하므로 공증(notarization)은 필요 없습니다.
SIGN_ID="$(security find-identity -v -p codesigning \
| awk -F'"' '/Apple Development/{print $2; exit}')"
xcrun swiftc -O -o "$TMPBIN" Sources/main.swift
mkdir -p "$APP/Contents/MacOS"
cp "$TMPBIN" "$APP/Contents/MacOS/BarkNotify"
cp Info.plist "$APP/Contents/Info.plist"
codesign --force --sign "$SIGN_ID" --timestamp=none "$APP"⚠ 여기서 중요한 성질이 하나 있습니다. 같은 인증서 + 같은 bundle id로 재빌드하면 FDA가 유지됩니다. 코드를 고치고 다시 빌드해도 권한을 다시 줄 필요가 없습니다. 반대로 bundle id를 바꾸면 FDA를 다시 부여해야 합니다. 개발 중에 이걸 모르면 "왜 갑자기 또 안 읽히지"로 한참 헤맵니다.
새 알림만 골라내기
폴링이라 커서 관리가 필요합니다. 마지막으로 처리한 rec_id를 파일에 저장하고, 그보다 큰 행만 읽습니다.
첫 실행 시에는 과거 알림을 전부 쏘면 안 되므로 MAX(rec_id)로 커서를 초기화만 하고 넘어갑니다. 알림센터가 비워져 rec_id가 되감기는 경우도 있어서 역전 감지도 넣었습니다.
let mx = scalarInt(db, "SELECT MAX(rec_id) FROM record")
if last < 0 { // 첫 실행: 과거 알림 미전송
last = mx; saveState(last); return
}
if mx < last { // DB가 비워짐: 커서 보정
log("커서 리셋 \(last)->\(mx)"); last = mx; saveState(last)
}전송에 실패하면 커서를 전진시키지 않고 다음 주기에 재시도합니다. 알림을 흘리는 것보다 중복이 낫다는 판단입니다.
알림을 알림답게 만드는 세 가지 파라미터
DB에서 텍스트만 꺼내 보내면 "어느 앱에서 온 알림인지" 알 수 없는 밋밋한 푸시가 됩니다. Bark의 세 파라미터로 채웠습니다.
| 파라미터 | 용도 | 구현 |
|---|---|---|
group | 앱별 그룹 묶기 | NSWorkspace로 앱 표시 이름을 얻어 macOS-<앱이름> |
icon | 알림에 출처 앱 아이콘 | 아이콘을 PNG로 추출해 webhook에 1회 업로드 후 조회 URL |
url | 탭하면 아이폰 대응 앱 열기 | 번들 ID → iOS URL scheme 매핑 테이블 |
아이콘은 NSWorkspace에서 앱 아이콘을 꺼내 128×128 PNG로 저장합니다.
if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bid) {
let img = NSWorkspace.shared.icon(forFile: appURL.path)
var rect = NSRect(x: 0, y: 0, width: 128, height: 128)
if let cg = img.cgImage(forProposedRect: &rect, context: nil, hints: nil) {
let rep = NSBitmapImageRep(cgImage: cg)
let png = rep.representation(using: .png, properties: [:])
// 로컬 캐시 후 webhook에 최초 1회 업로드
}
}업로드 완료 목록은 메모리가 아니라 파일에 영구 기록합니다. 그러지 않으면 재부팅할 때마다 모든 앱 아이콘을 다시 올립니다.
이미지 업로드·조회 webhook은 새로 만들지 않았습니다. n8n으로 만든 이미지 서비스를 그대로 재사용했습니다. MinIO에 저장하고 크기를 맞춰 돌려주는 구성이라, 아이콘을 올리고 조회 URL을 받는 용도로 바로 쓸 수 있었습니다.
딥링크는 맥 앱 번들 ID를 iOS 앱의 URL scheme으로 바꿔주는 정적 매핑입니다.
let deepLinkMap: [String: String] = [
"com.apple.mail": "message://",
"com.apple.ical": "calshow://",
"com.apple.notes": "mobilenotes://",
"ru.keepcoder.telegram": "tg://",
// Universal Link: 앱이 있으면 앱, 없으면 웹
"com.openai.chat": "https://chatgpt.com",
]설치되지 않은 scheme은 탭해도 아무 일도 일어나지 않으므로 무해합니다. 웹 푸시 알림은 출처가 _web_center_:web.<도메인> 형태로 들어오니, 도메인을 잘라 https://<도메인>으로 연결했습니다.
launchd 등록과 권한 부여 순서
LaunchAgent plist를 만들고 bootstrap → enable → kickstart 순서로 등록합니다.
<key>Label</key><string>com.example.bark-notify</string>
<key>ProgramArguments</key><array>
<string>/Users/me/Applications/BarkNotify.app/Contents/MacOS/BarkNotify</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>10</integer>
<key>ProcessType</key><string>Background</string>launchctl bootstrap "gui/$(id -u)" "$PLIST"
launchctl enable "gui/$(id -u)/com.example.bark-notify"
launchctl kickstart -k "gui/$(id -u)/com.example.bark-notify"여기서 순서가 중요합니다. 앱을 먼저 설치·서명해 실체를 만든 다음 시스템 설정 → 개인정보 보호 및 보안 → 전체 디스크 접근 권한에 BarkNotify.app을 추가하고, 그 후 kickstart -k로 재시작해야 권한이 반영됩니다. 권한을 먼저 주려 해도 목록에 추가할 앱 자체가 없습니다.
검증은 알림 하나 띄워보면 끝납니다.
osascript -e 'display notification "테스트" with title "셋업검증"'
tail -f ~/Library/Logs/bark-notify.log이 과정(빌드 → 서명 → LaunchAgent 등록 → 안내 출력)은 저장소의 install.sh 하나로 묶어뒀습니다. 설정 파일에 서버 주소와 device key만 채우면 나머지는 스크립트가 처리하고, 사람이 할 일은 시스템 설정에서 전체 디스크 접근 권한을 켜주는 것뿐입니다.
bash ~/.config/bark-mac/app-src/install.sh한계와 보안
✅ 동작은 만족스럽지만, 구조상 분명한 한계가 있습니다.
- 폴링 기반입니다. 알림 도착 이벤트를 구독하는 게 아니라 3초마다
record를 훑습니다. 에이전트가 죽어 있는 동안 온 알림은 전달되지 않습니다. - 알림 본문이 전부 전달됩니다. 2FA 코드, 메신저 내용까지 포함됩니다. 그래서 제외 목록(
IGNORE_APPS)을 설정 키로 뺐고, 민감한 앱은 처음부터 제외하는 것을 기본 안내로 두었습니다. - 서버와 이미지 webhook은 셀프호스팅입니다. Bark는 종단간 암호화(ciphertext)도 지원하므로, APNs 구간까지 가리고 싶다면 그 옵션을 쓰면 됩니다.
자격증명은 저장소에 두지 않고 ~/.config/bark-mac/config.env(권한 600)에만 둡니다. 공개 저장소의 문서·설정 예시는 전부 bark.example.com 같은 placeholder로 바꿔두었습니다.
정리
- 알림 DB 경로는 Sequoia 이후
group.com.apple.usernoted로 옮겨졌고 FDA로 보호됩니다. - FDA는 launchd로 띄운 인터프리터에 전파되지 않습니다. Team ID로 서명된 네이티브 앱이 직접 읽어야 합니다.
- 같은 인증서·같은 bundle id면 재빌드해도 권한이 유지되고, bundle id를 바꾸면 재부여가 필요합니다.
- 알림의 체감 품질은
group·icon·url세 파라미터에서 갈립니다.
참고로 여기로 들어오는 알림 중 상당수는 AI 코딩 도구의 훅이 쏘는 작업 완료 신호입니다. 그 훅을 어떻게 구성했는지는 Claude Code 하네스로 개인 프로젝트 운영하기에 정리해 뒀습니다.
맥에서 뭔가 끝나면 아이폰이 울립니다. 빌드가 끝났는지 보려고 자리로 돌아가는 일이 없어졌습니다.
전체 코드와 설치 가이드는 okdohyuk/bark-mac 에 있습니다. Swift 소스 한 벌과 빌드·설치·제거 스크립트, 그리고 Bark 서버 구축 문서가 들어 있고, 저장소의 SETUP_PROMPT.md를 AI 코딩 도구에 그대로 붙여넣으면 다른 맥에도 같은 구성을 자동으로 올릴 수 있습니다. 자격증명은 저장소 밖(~/.config)에만 두는 구조라 포크해서 바로 쓰셔도 됩니다.