Screendrop은 맥 메뉴바에 사는 화면 캡처·녹화 앱입니다. 창이 없습니다. Dock에도 안 뜹니다. 단축키를 누르면 화면을 찍고, 찍은 것이 화면 구석에 카드처럼 떠오르고, 거기서 바로 화살표를 그리거나 개인정보를 가리거나 영상을 잘라내고, 복사하거나 저장하거나 링크로 공유합니다.
기능만 나열하면 CleanShot X나 Loom과 겹칩니다. 다른 점은 공유 링크를 만드는 서버가 개발자 것이 아니라 내 것이라는 데 있습니다. Screendrop은 서버를 운영하지 않습니다. 대신 Cloudflare에 올릴 수 있는 작은 서버 코드를 따로 주고, 앱은 그 주소와 비밀번호 한 개만 들고 있습니다.
Loom 같은 서비스는 사진관입니다. 찍어 주고, 보관해 주고, 링크도 만들어 줍니다. 편한 대신 필름이 전부 사진관 창고에 쌓입니다. 사진관이 문을 닫거나 가격을 올리면 방법이 없습니다.
Screendrop은 카메라만 파는 가게입니다. 찍는 건 내 맥에서 끝나고, 파일도 내 맥에 남습니다. 남에게 보여줄 게 생기면 그때 내 이름으로 계약한 창고(내 Cloudflare 계정)에 넣고 주소를 뽑습니다. 앱이 아는 건 창고 주소와 열쇠 하나뿐이고, 열쇠는 맥의 금고(키체인)에 있습니다.
숫자로 보면 이렇습니다. Swift 174개 파일 49,605줄, Go 2개 파일 1,061줄. 외부 라이브러리는 딱 2개(자동 업데이트용 Sparkle, Dock 아이콘 진행률용 DockProgress). 나머지 — 네트워크, JSON, 키체인, 이미지·영상 인코딩, 문자 인식, 음성 인식, 전역 단축키 — 는 전부 애플 기본 프레임워크로 직접 짰습니다.
NSApp.setActivationPolicy(.accessory) 한 줄이 "나는 Dock에 안 나갈래"라는 선언입니다.| 비교 대상 | 비용·구조 | Screendrop과의 차이 |
|---|---|---|
| Loom | 구독제. 영상이 Loom 서버에 저장 | Screendrop은 영상이 내 맥에 남고, 공유할 때만 내 Cloudflare로 간다 |
| CleanShot X | 유료. 클라우드는 별도 구독 | 기능 겹침이 가장 큰 상대. Screendrop은 무료 + 소스 공개 + 서버 자가호스팅 |
| Kap | 오픈소스이지만 Electron 기반 | Screendrop은 네이티브 Swift — 메모리·배터리·캡처 지연에서 유리 |
맥 기본 ⌘⇧5 | 공짜, 이미 깔려 있음 | 기본 도구엔 주석·블러·업로드·히스토리·타임라인 편집이 없다 |
자동 업데이트 목록 파일(appcast.xml)을 세어 보면 릴리스가 26개입니다. 첫 릴리스가 2026-05-26, 최신이 2026-08-20 — 86일 동안 3.3일에 한 번씩 냈다는 뜻입니다. 저장소를 보는 사람이 "이거 방치된 프로젝트인가"를 판단할 때 별 개수보다 정확한 신호가 이것입니다.
애플은 2022년부터 화면 캡처는 ScreenCaptureKit을 쓰라고 안내합니다. Screendrop은 스크린샷에는 안 씁니다. 대신 맥에 원래 들어 있는 명령줄 도구 /usr/sbin/screencapture를 실행합니다. 그리고 그 이유가 주석에 적혀 있습니다.
맥에서 창에는 그림자가 깔립니다. 흰 배경 위에 흰 창이 떠 있어도 그림자 덕분에 경계가 보이죠.
그런데 ScreenCaptureKit은 화면에 이미 그려진 그림을 그대로 가져오는 게 아니라 창들을 다시 합성해서 만듭니다. 이 과정에서 시스템이 창 사이에 넣어 준 그림자가 빠집니다. 결과: 밝은 배경 위 창이 경계가 없는 하얀 덩어리가 됩니다.
screencapture는 화면에 실제로 표시된 결과를 그대로 뜹니다. 그림자가 그대로 남습니다. "최신 API"보다 "결과가 맞는 쪽"을 고른 것입니다.
/// ScreenCaptureKit의 디스플레이 캡처는 창을 스스로 재합성하면서
/// WindowServer가 넣어 준 창 그림자를 떨어뜨린다. 그래서 밝은 배경 위
/// 창은 경계를 잃는다. screencapture는 실제 화면 합성 결과를 뜬다.
func captureFullscreen(displayID: CGDirectDisplayID?) async -> URL? {
let displayIndex = Self.screencaptureDisplayIndex(for: displayID)
guard let url = await runScreencapture(args: ["-D", "\(displayIndex)"]) else { return nil }
trimEmptyMenuBarIfNeeded(at: url, displayID: displayID)
return url
}
영상 녹화에는 반대로 ScreenCaptureKit을 씁니다. 녹화는 프레임을 초당 60번 실시간으로 받아야 하는데, 그건 screencapture로는 불가능하기 때문입니다. 같은 앱 안에서 용도에 따라 다른 답을 골랐고, 왜 그랬는지가 코드에 남아 있습니다.
대부분의 튜토리얼은 "옛날 방식 → 요즘 방식"으로만 가르칩니다. 실무에서는 요즘 방식이 특정 상황에서 결과를 망치는 일이 자주 있고, 그때 필요한 건 판단과 그 판단을 남기는 주석입니다. 이 저장소는 그 예시를 최소 세 군데(스크린샷 API, 전역 단축키, 커서 처리) 갖고 있습니다.
.agents/skills/ 폴더에 SKILL.md 4개가 들어 있습니다. "이 프로젝트를 빌드하는 법", "빌드해서 실행하는 법", "릴리스를 내는 법", 그리고 "맥 노치(카메라 홈) 모양 UI를 만드는 법". 앞의 셋은 이 저장소 전용이지만, 마지막 하나는 앱 이름이 {{AppName}} 자리표시자로 되어 있어 다른 앱에도 그대로 갖다 쓸 수 있게 쓰여 있습니다.
즉 이 프로젝트는 "나를 어떻게 빌드·배포하는가"를 README 옆에 흘려 쓴 게 아니라, 버전 관리되는 별도 문서로, AI 협업자를 독자로 두고 관리합니다. 2026년 오픈소스 저장소가 실제로 어떻게 바뀌고 있는지 보여주는 표본입니다.
AGENTS.md는 낡았다저장소 루트의 AGENTS.md(에이전트·기여자용 아키텍처 설명서)를 소스와 하나씩 대조해 봤더니 5개 항목 중 4개가 현재 코드와 다릅니다. "SPM 의존성 없음"(→ 지금 2개), "Screendrop/에 하위 폴더 없음"(→ Engine/ 29개 파일), "전체화면은 ScreenCaptureKit"(→ 아님), "좌표는 0~1로 정규화"(→ 아님, 픽셀 좌표).
이건 이 저장소를 깎아내리는 얘기가 아니라 이 문서를 읽는 사람이 가져갈 가장 실용적인 교훈입니다: 빠르게 크는 코드베이스에서 사람이 손으로 쓴 설명 문서는 반드시 코드보다 뒤처집니다. 그래서 이 딥다이브의 모든 수치와 구조는 AGENTS.md가 아니라 git clone한 소스에서 직접 확인했습니다.
| 층 | 사용 기술 | 메모 |
|---|---|---|
| UI | SwiftUI (MenuBarExtra + WindowGroup) | 메뉴바 아이콘을 직접 만들지 않고 SwiftUI 선언형으로 |
| 상태 관리 | @Observable (Swift Observation) | 구형 ObservableObject는 딱 한 곳만 — 뒤에서 설명 |
| 동시성 | async/await + @MainActor 기본값 | 이 프로젝트의 핵심 설정. 아래 표 참조 |
| 스크린샷 | /usr/sbin/screencapture (Process 실행) | ScreenCaptureKit 미사용 — 그림자 보존 때문 |
| 화면 녹화 | ScreenCaptureKit SCStream | 화면·시스템오디오·마이크 3트랙 동시 |
| 영상 인코딩 | AVFoundation AVAssetWriter | HEVC, 60fps, 2초 조각 단위 기록 |
| 웹캠 | AVFoundation | 별도 camera.mov 트랙으로 저장 |
| 전역 단축키 | Carbon RegisterEventHotKey | 2001년 API. 왜 아직도 이걸 쓰는지는 6장 |
| 키 입력 자막 | CGEventTap + NSEvent 모니터 | 둘 다 켜고 중복 제거 |
| 문자 인식 | Vision (OCR) | 화면 속 글자를 찾아 자동 가리기 후보 제안 |
| 음성 인식 | Speech | 자막·프롬프터·"말로 편집" 기능 |
| 비밀 저장 | Security (Keychain) | 업로드 토큰만. 나머지는 UserDefaults |
| 자동 업데이트 | Sparkle 2.9.1 (SPM) | EdDSA 서명 검증 |
| Dock 진행률 | DockProgress 5.1.0 (SPM) | 내보내기·업로드 중 아이콘에 링 표시 |
외부 의존성이 2개뿐이라는 게 이 스택의 성격을 요약합니다. HTTP 클라이언트 라이브러리도, 이미지 처리 라이브러리도, 단축키 라이브러리도 없습니다. URLSession·Codable·SecItemAdd를 직접 씁니다.
Xcode 프로젝트 파일(project.pbxproj)에 이렇게 박혀 있습니다.
SWIFT_VERSION = 5.0
SWIFT_APPROACHABLE_CONCURRENCY = YES
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor@MainActor를 붙이죠. 그러다 하나 빠뜨리면 UI가 이상하게 깨지거나 크래시가 납니다. 이 설정을 켜면 실수의 방향이 뒤집힙니다: 빠뜨리면 "느려질" 뿐, "깨지지" 않습니다.그러면 진짜로 백그라운드에서 돌아야 하는 것들(스트림 콜백, 인코더, 합성기)은 어떻게 할까요? 명시적으로 빠져나옵니다.
nonisolated final class ScreenRecordingCapture: NSObject,
SCStreamOutput, SCStreamDelegate, @unchecked Sendable { ... }
이렇게 메인 스레드 밖으로 나간 클래스가 저장소 전체에 12개입니다 — 스트림 캡처(ScreenRecordingCapture), 파일 기록(ScreenRecordingWriter), 프레임 합성(StudioFrameCompositor), 웹캠(CameraCaptureEngine) 같은, 정말로 백그라운드여야 하는 것들뿐입니다. 49,605줄짜리 앱에서 동시성 버그를 찾을 때 들여다볼 곳이 nonisolated 한 단어 검색으로 12군데로 좁혀집니다.
cmd/screendrop-release/main.go, 992줄, 외부 패키지 0개(Go 표준 라이브러리만). 이 하나가 빌드→공증→DMG→서명→appcast 갱신→GitHub 릴리스→Homebrew 캐스크 갱신까지 전부 합니다. 5장에서 단계별로 뜯습니다.
Swift 앱 49,605줄에는 테스트가 하나도 없습니다(테스트 타깃 자체가 없음). 그런데 Go 릴리스 도구 992줄에는 단위 테스트가 2개 있습니다.
이상해 보이지만 이유는 분명합니다. 앱은 사람이 실행해 보면 틀린 걸 압니다. 릴리스 스크립트는 틀려도 릴리스가 나간 뒤에야 압니다. 되돌리기 어려운 쪽에 안전장치를 걸었습니다.
| 구성요소 | 역할 |
|---|---|
| Worker | 업로드 요청을 받아 토큰 검증 → R2로 스트리밍 전달(메모리에 안 담음) → 공유 URL 반환 |
| R2 | 실제 이미지·영상 파일 보관. 나갈 때 트래픽 요금이 없는 게 이 조합의 핵심 |
| D1 | 업로드 기록(누가·언제·어떤 파일) 저장용 가벼운 SQL 데이터베이스 |
앱이 아는 비밀은 업로드 토큰 하나뿐입니다. R2 접근 키 같은 건 앱에 없습니다 — Worker 안에만 있습니다. 예전 버전은 S3 자격증명을 앱이 들고 있었는데, 그 시절 키체인 항목(cloud_s3_access_key_id 등)을 삭제하는 코드가 아직 남아 있어서 설계가 바뀐 흔적을 그대로 볼 수 있습니다.
@main
struct ScreendropApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
MenuBarExtra("Screendrop", image: "MenuBarIcon", isInserted: $showMenuBarIcon) {
MenuBarView()
}
WindowGroup("Screendrop Annotate", id: "ANNOTATION_EDITOR", for: URL.self) { value in
AnnotationEditorWindow(url: value)
}
WindowGroup("Screendrop Recording Editor", id: "VIDEO_EDITOR", for: URL.self) { value in
RecordingStudioWindow(url: value)
}
}
}
WindowGroup(for: URL.self)가 핵심입니다. "주석 편집기 창"이 아니라 "이 파일을 편집하는 창"으로 정의됩니다. 그러면 같은 파일을 두 번 열어도 창이 하나만 뜨고, 앱을 껐다 켜면 열려 있던 창이 알아서 복원됩니다. 창 목록을 직접 관리하는 코드가 필요 없습니다.
다만 SwiftUI만으로 안 되는 것들이 있어 AppDelegate가 따로 붙습니다: Dock에 안 뜨게 하기, Carbon 단축키 등록, Sparkle 시작, 그리고 "녹화 중에 종료 누르면 막기". 마지막 것이 특히 실무적입니다 — 경고창을 띄우고 .terminateLater를 돌려줘서 파일 쓰기가 끝날 때까지 앱이 안 죽게 붙잡습니다.
녹화 설정에 showsCursor = false가 있습니다. 마우스 커서를 영상에 안 담습니다. 대신 커서가 어디 있었는지를 좌표로만 따로 기록해 두고(input.json), 편집·내보내기 때 가짜 커서를 부드럽게 그려 넣습니다.
화면에 찍힌 커서는 지울 수 없습니다. 픽셀에 섞여 버렸으니까요. 확대(줌)를 걸면 커서도 같이 뭉개져 커지고, 커서 모양을 바꾸거나 클릭 효과를 넣을 수도 없습니다.
좌표만 갖고 있으면 전부 나중에 정할 수 있습니다 — 커서를 크게, 클릭할 때 파문 효과, 손떨림 보정, 아예 숨기기까지. "픽셀로 굽기 전에 데이터로 남긴다"는 원칙이고, 이건 영상 편집 도구 설계의 반복되는 교훈입니다.
let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mov)
// 조각 단위 무비: 중간에 죽어도 마지막 조각까지는 재생 가능한 파일이 남는다.
writer.movieFragmentInterval = CMTime(seconds: 2, preferredTimescale: 600)
let bitRate = max(20_000_000, videoWidth * videoHeight * 4)
let videoSettings: [String: Any] = [
AVVideoCodecKey: AVVideoCodecType.hevc,
AVVideoWidthKey: videoWidth, AVVideoHeightKey: videoHeight,
AVVideoCompressionPropertiesKey: [
AVVideoAverageBitRateKey: bitRate,
AVVideoExpectedSourceFrameRateKey: 60,
AVVideoMaxKeyFrameIntervalKey: 60
] as [String: Any]
]
보통 동영상 파일은 맨 마지막에 목차를 써야 완성됩니다. 녹화 중에 앱이 죽으면 목차가 없어서 파일 전체가 열리지 않습니다. movieFragmentInterval = 2초는 2초마다 작은 목차를 붙이라는 뜻이고, 그러면 죽어도 직전 2초까지는 멀쩡히 재생됩니다.
소스 주석에 이 원칙이 한 줄로 적혀 있습니다: "시작된 녹화는 조용히 버려지지 않는다." 같은 원칙이 저장 위치에도 적용돼 있어서, 녹화 폴더는 임시 폴더가 아니라 ~/Library/Application Support에 만들어집니다 — OS가 임시 폴더를 청소하다 영상을 지우는 일을 막기 위해서입니다.
주석(화살표·사각형·블러 등)의 좌표를 어디에 맞출 것인가는 이런 편집기의 최대 설계 결정입니다. Screendrop의 답은 Engine/Shape.swift 주석에 있습니다.
방법 A(정규화): 좌표를 0~1로 저장. "이미지 가로의 30% 지점". 해상도가 달라져도 유지되지만, 도형을 회전·확대할 때마다 가로세로 비율 때문에 계산이 지저분해집니다.
방법 B(픽셀 고정, Screendrop이 택한 쪽): 좌표를 그 스크린샷의 픽셀 그대로 저장. 화면에 보이는 확대·이동은 "카메라"라는 별도 계산으로만 처리하고, 도형 데이터는 절대 안 건드립니다. 내보낼 때는 카메라를 끄고 1:1로 그리면 끝입니다.
이 선택의 보상이 주석에 그대로 적혀 있습니다 — "캔버스가 그 위의 순수한 카메라가 되고, 그래서 모든 도구가 같은 회전/크기조절 동작을 공짜로 얻는다." 도구를 새로 추가할 때 회전·크기조절 코드를 다시 안 짜도 된다는 뜻입니다.
흥미로운 건 같은 앱 안에서 녹화 쪽은 반대로 0~1 정규화를 씁니다. 커서 좌표(input.json)는 나중에 1080p로도 4K로도 렌더링될 수 있으니 해상도에 독립적이어야 하거든요. 규칙을 통일한 게 아니라, 각 데이터가 살아갈 미래에 맞춰 다르게 정한 것입니다.
주석 엔진 (Engine/Document.swift) | 영상 스튜디오 (RecordingStudioModel) | |
|---|---|---|
| 방식 | 문서 전체를 통째로 스냅샷 | UndoManager + 역함수 클로저 등록 |
| 코드 | snapshot() / restore(_:) | 이전 값을 캡처해 같은 함수를 다시 호출하는 클로저 등록 |
| 장점 | 단순. 버그가 거의 안 생긴다 | 맥 기본 편집 메뉴에 "자르기 취소"처럼 이름이 뜬다 |
| 단점 | 도형이 많아지면 메모리를 먹는다 | 모든 변경 함수마다 역함수를 손으로 등록해야 한다 |
private func applyClipTimeline(_ requested: RecordingClipTimeline, ...,
actionName: String) {
let next = requested.normalized(to: sourceDuration)
guard !next.segments.isEmpty, next != clipTimeline else { return }
let previousTimeline = clipTimeline // 이전 값을 붙잡아 두고
let previousSelection = selectedClipID
registerUndo(actionName) { target in // 그 값으로 자기 자신을 다시 호출
target.applyClipTimeline(previousTimeline, selectedID: previousSelection, ...)
}
clipTimeline = next
}
등록한 클로저가 같은 함수를 다시 부르기 때문에, 되돌리기를 실행하는 순간 "다시 실행(redo)"이 자동으로 등록됩니다. 한 함수로 undo/redo가 둘 다 되는 구조입니다. 한 앱 안에 두 방식이 공존하니 비교 학습 자료로 드물게 좋습니다.
static let directoryExtension = "screendroprec"
static let screenFileName = "screen.mov" // 화면
static let cameraFileName = "camera.mov" // 웹캠
static let pointerCaptureFileName = "input.json" // 커서·키 입력 기록
static let captureManifestFileName = "capture.json" // 촬영 당시 설정
static let editDocumentFileName = "edit.json" // 저장한 편집 내용
static let draftDocumentFileName = "edit.draft.json"// 자동 저장 (별도!)
static let renderStampFileName = "render.json" // 내보낸 파일이 최신인지 증명
static let posterFileName = "poster.jpg" // 썸네일
맥의 .rtfd나 .pages처럼 겉으론 파일 하나로 보이지만 실제로는 폴더인 구조입니다. 애플이 제공하는 NSDocument/FileWrapper를 안 쓰고 FileManager로 직접 만들었습니다.
여기서 눈여겨볼 세 가지:
edit.draft.json과 edit.json을 분리 — 자동 저장과 "저장 버튼 눌러 저장"을 다른 파일에 씁니다. 편집 중 크래시해도 잃는 게 없고, 저장 안 한 상태는 저장 안 한 상태로 남습니다.render.json은 캐시 무효화 도장 — 내보낸 mp4가 지금 편집 내용과 일치하는지를 해시나 수정시각이 아니라 값 비교로 판정합니다. 같으면 재사용, 다르면 다시 렌더.flushDrafts() — 끄기 전에 초안을 전부 디스크에 밀어 넣습니다.| 줄 수 | 파일 | 무엇 |
|---|---|---|
| 3,193 | RecordingStudioWindow.swift | 영상 편집기 화면 전체. 한 파일 안에 뷰 구조체 28개(27개가 private) |
| 2,087 | RecordingStudioModel.swift | 편집기 창 하나의 상태·로직 전부를 담은 단일 클래스 |
| 1,350 | ScreenRecordingManager.swift | SCStream 캡처 + AVAssetWriter 기록 |
| 1,237 | RecordingStudioExporter.swift | 최종 합성·내보내기 (픽셀 직접 조작) |
| 857 | ScreenshotPreviewStack.swift | 떠 있는 미리보기 카드 더미 |
| 823 | AnnotationEditorModel.swift | 스크린샷 주석 편집기 모델 |
| 774 | RecordingPointerCapture.swift | 녹화 중 커서·클릭 좌표 기록 (4장 패턴 ②) |
| 770 | RecordingClipTimelineView.swift | 영상 편집 타임라인 UI |
이어서 AnnotationCanvas.swift(769) · Engine/AnnoEditorInteraction.swift(717)가 붙습니다. 2천~3천 줄짜리 파일이 상위에 있는 게 눈에 띌 겁니다. 교과서적 MVVM이라면 RecordingStudioModel 하나를 열댓 개로 쪼갰을 텐데, 이 프로젝트는 편집기 창 하나당 "만능 객체" 하나로 갑니다. 재생·자르기·속도·줌·자막·필러 제거·내보내기·공유가 전부 그 안에 있습니다.
정답이 있는 문제가 아닙니다. 쪼개면 파일당 읽기는 쉬워지지만 "자르기를 바꾸면 줌 큐도 같이 옮겨야 한다" 같은 연쇄를 여러 파일에 흩뿌리게 됩니다. 영상 편집기는 상태끼리 서로 얽혀 있는 대표적인 도메인이라, 한곳에 모아 두는 쪽이 실제로 덜 틀릴 수 있습니다.
다만 대가는 분명합니다: 여러 명이 동시에 손대면 충돌하고, 테스트가 어렵고(그래서 없고), 신규 기여자의 진입 문턱이 높습니다. 1인 개발 + 3.3일마다 릴리스라는 이 프로젝트의 조건에는 맞는 선택입니다.
runCmdRetry라는 헬퍼가 네트워크가 필요한 단계들을 재시도로 감쌉니다. 그리고 이 도구의 존재 이유가 release-screendrop/SKILL.md에 적혀 있습니다 — "채팅 세션에서 바로 릴리스를 낼 수 있도록". 사람이 아니라 AI 에이전트가 실행할 것을 전제로 설계된 릴리스 파이프라인입니다.
ScreendropApp.swift (220줄) — 앱 전체 구조가 한 화면에 들어온다ScreenshotManager.swift — 2장의 "왜 ScreenCaptureKit을 안 쓰나" 주석을 원문으로PreviewWindowCaptureExclusion.swift — 가장 짧고 가장 놀라운 파일 (아래 6장)Engine/Shape.swift — 좌표계 결정과 그 근거RecordingSession.swift — 폴더 번들 포맷 정의CloudCredentialStore.swift — 키체인 사용법의 교과서적 예시화면 녹화 앱의 고전적 문제: 녹화하는 앱 자신의 UI가 녹화 화면에 찍힙니다. 미리보기 카드, 녹화 컨트롤바 같은 것들이요. Screendrop의 해결책 전부가 이 한 줄입니다.
private func applyCaptureVisibility(to window: NSWindow) {
window.sharingType = Self.includesAppWindowsInCaptures ? .readOnly : .none
}
NSWindow.sharingType = .none은 시스템에게 "이 창은 화면 공유·녹화 대상에서 빼 달라"고 말하는 속성입니다. ScreenCaptureKit 쪽에서 필터로 걸러낼 필요도, 좌표를 계산해 잘라낼 필요도 없습니다. 화면 녹화 도구를 만들 때 이걸 모르면 며칠을 헤맵니다.
| 방법 | 감지 | 가로채기 | 권한 필요 |
|---|---|---|---|
NSEvent.addGlobalMonitor | ○ | ✕ | 없음 |
Carbon RegisterEventHotKey | ○ | ○ | 없음 |
CGEventTap | ○ | ○ | 입력 모니터링 허용 필요 |
Screendrop은 캡처 단축키에 Carbon을 씁니다. 2001년에 나온 C API이고, 애플이 "레거시"라 부르지만 권한 없이 키를 가로챌 수 있는 유일한 방법이라 2026년에도 표준입니다. C 콜백에서 Swift 객체를 되찾는 방식이 볼 만합니다.
// 등록할 때: self의 주소를 그냥 포인터로 넘긴다
Unmanaged.passUnretained(self).toOpaque()
// C 콜백 안에서: 그 포인터를 다시 Swift 객체로 되돌린다
let manager = Unmanaged<HotkeyManager>.fromOpaque(userData).takeUnretainedValue()
manager.handleHotKey(id: hotKeyID.id)
한편 키 입력 자막 기능은 어떤 키를 눌렀는지 표시만 하면 되므로 가로챌 필요가 없고, 대신 모든 키를 봐야 하니 CGEventTap과 NSEvent 모니터를 둘 다 켭니다. 그러면 같은 키가 두 번 들어오는데, 이걸 이렇게 처리합니다.
/// 같은 물리 키 입력이 CGEventTap과 NSEvent 모니터 양쪽에서 도착할 수 있다.
/// duplicateEventWindow 안의 중복은 버린다.
private var lastProcessedEventTimes: [Int: TimeInterval] = [:]
private static let duplicateEventWindow: TimeInterval = 0.05
private static func setKeychainItem(key: String, value: String) {
let data = Data(value.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecAttrService as String: keychainService,
]
SecItemDelete(query as CFDictionary) // 있으면 지우고
var newQuery = query
newQuery[kSecValueData as String] = data
SecItemAdd(newQuery as CFDictionary, nil) // 새로 넣는다 (update보다 단순)
}
"수정"이라는 개념 없이 지우고 다시 넣습니다. SecItemUpdate는 항목이 있는지 먼저 확인해야 해서 분기가 늘어나는데, 삭제는 없어도 에러가 아니라서 이 방식이 더 짧고 안전합니다.
중요한 건 무엇을 어디 넣었는가입니다: 업로드 토큰(비밀) → 키체인, Worker 주소(비밀 아님) → UserDefaults. 비밀만 금고에 넣습니다. 전부 키체인에 넣으면 느려지고 디버깅이 괴로워집니다.
49,605줄 전체에서 Process()를 쓴 곳은 딱 2곳입니다 — screencapture와, 배경 이미지 팩을 푸는 /usr/bin/ditto. 그 두 번째에 교훈이 하나 붙어 있습니다.
외부 프로그램의 출력을 Pipe로 받으면서 waitUntilExit()를 먼저 부르면 영원히 안 끝날 수 있습니다. 파이프 버퍼(보통 64KB)가 꽉 차면 상대 프로그램이 "누가 좀 읽어 가야 계속 쓰지"하며 멈추고, 내 쪽은 "끝나야 읽지"하며 기다립니다. 서로를 기다리는 교착 상태입니다.
Screendrop은 파이프를 먼저 다 읽고 나서 waitUntilExit()를 부릅니다. 이 순서가 정답입니다.
그리고 비동기 처리 방식도 볼 만합니다 — Process는 옛날 블로킹 API인데, 이걸 async/await 세계로 끌어올리는 표준 다리가 withCheckedContinuation입니다.
return await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture")
process.arguments = args + ["-x", "-t", "png", filePath]
try? process.run()
process.waitUntilExit()
continuation.resume(returning: process.terminationStatus == 0
? URL(fileURLWithPath: filePath) : nil)
}
}
앱 전체가 @Observable인데 UpdaterManager 하나만 ObservableObject + Combine입니다. 실수가 아닙니다 — Sparkle이 canCheckForUpdates를 KVO(옛날 Objective-C 관찰 방식)로 노출하기 때문이고, KVO를 Swift 값으로 바꾸는 다리가 Combine의 .publisher(for:)입니다. "낡은 걸 남겨 둔 게 아니라, 외부 라이브러리와 맞닿은 한 지점에만 남긴 것"입니다.
Engine/ 29개 파일의 헤더 주석에 출처가 적혀 있습니다: "드로잉 앱의 Model/Shape.swift에서 이식, 화이트보드엔 없는 도구(가리기·스포트라이트·번호 콜아웃)까지 넓힘." 저자가 예전에 만든 별도 프로젝트의 2D 벡터 엔진을 통째로 가져와 확장한 것입니다.
그 안에는 perfect-freehand(tldraw·Excalidraw가 쓰는 필압 자유곡선 알고리즘)의 Swift 포팅도 있습니다 — thinning·smoothing·streamline 같은 필드명과 펜 감속 공식 t*0.65 + sin(t*π/2)*0.35가 그대로 남아 있어 확인됩니다.
최종 내보내기(RecordingStudioExporter.swift)는 AVVideoComposition이나 Core Animation 레이어를 쓰지 않습니다. CVPixelBuffer의 메모리 주소를 잠그고 거기에 직접 픽셀을 씁니다. 화면·웹캠·가짜 커서·키 입력 자막·자막·줌 화면을 프레임마다 손으로 합칩니다.
튜토리얼에서는 거의 안 나오는 저수준 기법입니다. 대신 합성 규칙을 100% 내 마음대로 정할 수 있습니다 — 프레임워크가 지원하지 않는 효과라도 넣을 수 있죠. 대가는 성능·정확도를 전부 직접 책임진다는 것.
README와 appcast의 sparkle:minimumSystemVersion이 26.4를 요구합니다. 빌드 설정에는 MACOSX_DEPLOYMENT_TARGET = 26.0도 섞여 있어 완전히 일치하지는 않는데, 배포 쪽 기준은 26.4입니다. 2026년 8월 기준 상당수 맥이 아직 여기 못 미칩니다. 구형 맥에서는 소스를 읽는 것까지가 현실적인 한계입니다.
| 항목 | 요구 | 비고 |
|---|---|---|
| OS | macOS 26.4+ | 일부 기능은 26.4에서만 되는 API를 씀 (음성 분석 등) |
| 빌드 | Xcode 26.4 베타 | 정식판으로는 안 열릴 수 있음 |
| Swift | 언어 모드 5.0 | 단 SWIFT_DEFAULT_ACTOR_ISOLATION 등 최신 옵션 사용 |
| 샌드박스 | 꺼짐 (ENABLE_APP_SANDBOX = NO) | 화면 캡처 + 외부 프로그램 실행 때문에 불가피 |
| Hardened Runtime | 켜짐 | 공증(notarization)에 필요 |
| 권한(엔타이틀먼트) | 마이크·카메라 2개뿐 | 샌드박스가 꺼져 있어 나머지는 불필요 |
| 사용 권한 문구 | 카메라·마이크·화면기록·입력모니터링 | Info.plist의 4개 NS...UsageDescription |
| GPU/CPU | Apple Silicon 권장 | HEVC 60fps 인코딩 + 실시간 합성 부하 |
| 디스크 | 녹화 길이에 비례해 빠르게 증가 | 비트레이트 최소 20Mbps — 10분이면 1.5GB 안팎 |
배포된 앱을 처음 열면 "확인되지 않은 개발자" 경고가 뜰 수 있습니다. 앱을 우클릭 → 열기, 또는 시스템 설정 → 개인정보 보호 및 보안에서 허용하면 됩니다.
① FFmpeg: README는 압축·변환에 brew install ffmpeg가 필요하다고 합니다. 그런데 소스 전체에서 ffmpeg 실행 호출이 하나도 없습니다. 자르기는 AVAssetExportSession, 인코딩은 AVAssetWriter — 전부 애플 기본 프레임워크입니다. 소스 주석도 ffmpeg를 "ffmpeg 시절의 트림 서비스"처럼 과거형으로 언급합니다. 내부적으로 네이티브 전환이 끝났는데 README가 안 따라온 것으로 보입니다(추정이며, 그렇게 적힌 설명은 못 찾았습니다).
② 라이선스: 저장소 라이선스 파일은 CC0-1.0(사실상 퍼블릭 도메인)인데, README 하단에는 "첫 공개 릴리스 전에 라이선스 정보를 추가할 예정"이라고 적혀 있습니다. 상업적으로 쓸 계획이라면 이 상태 그대로 믿지 말고 저자에게 확인하는 게 안전합니다.
이 문서를 쓰면서 실제로 걸린 함정입니다. GitHub 저장소 페이지를 그대로 읽었더니 별 240개·릴리스 최신 v0.21.3(6월)로 나왔는데, API로 다시 확인하니 별 986개였고 최신 릴리스는 appcast와 같은 0.31.2(8월 20일)였습니다. 렌더링된 페이지가 몇 시간~며칠 묵은 캐시였던 것입니다.
빠르게 뜨는 저장소를 조사할 때는 웹페이지가 아니라 API나 클론한 소스를 기준으로 삼으세요. 위 수치들은 그렇게 다시 확인한 값입니다.
Xcode에서 macOS App 템플릿으로 새 프로젝트를 만들고, WindowGroup을 지운 뒤 MenuBarExtra만 남깁니다. AppDelegate에서 NSApp.setActivationPolicy(.accessory)를 호출하세요.
확인: 실행했을 때 Dock에 아이콘이 안 뜨고 메뉴바에만 보이면 성공. 막히는 지점: .accessory를 applicationDidFinishLaunching이 아닌 곳에서 부르면 Dock 아이콘이 잠깐 깜빡입니다.
같은 화면을 (A) Process로 /usr/sbin/screencapture -D 1 -x -t png out_a.png, (B) SCScreenshotManager로 각각 찍어 나란히 놓고 봅니다. 밝은 배경 위에 밝은 창을 하나 띄워 두는 게 요령입니다.
확인할 것: 창 그림자가 A에는 있고 B에는 없는가? 2장에서 읽은 주석이 사실인지 내 눈으로 검증하는 과제입니다. 덤: 소요 시간도 재 보세요 — Process 방식이 프로세스 실행 오버헤드로 더 느릴 겁니다. 그 대가를 왜 감수했는지 이해하게 됩니다.
과제 1의 앱에 떠 있는 작은 패널을 하나 붙입니다(NSPanel, styleMask: [.borderless, .nonactivatingPanel], isFloatingPanel = true). 그 상태로 ⌘⇧5로 화면을 녹화해 패널이 찍히는지 봅니다.
그다음 panel.sharingType = .none 한 줄을 넣고 다시 녹화합니다. 확인: 화면에는 보이는데 녹화본에는 없으면 성공. 이어서: canBecomeKey를 true로, canBecomeMain을 false로 오버라이드하면 무엇이 달라지는지 체감해 보세요(키보드는 받되 앱의 주 창은 되지 않음).
Screendrop 앱 없이 서버 쪽만 만들어 봅니다. Worker에 PUT /api/upload를 열고, Authorization: Bearer 헤더의 토큰을 검증한 뒤, 요청 본문을 메모리에 담지 말고 그대로 R2로 흘려보냅니다(스트리밍). D1에 파일명·크기·시각을 한 줄 남기고 짧은 URL을 돌려주세요.
테스트: curl -X PUT --data-binary @test.png -H "Authorization: Bearer 토큰" https://내워커/api/upload. 핵심 학습: "왜 스트리밍인가" — 500MB 영상을 메모리에 올리면 Worker 메모리 한도에서 죽습니다. 덤: GET /api/ping도 만들어 "연결 확인" 버튼의 서버 쪽을 완성해 보세요.
SCStream으로 showsCursor = false로 녹화하면서, 동시에 타이머로 마우스 좌표를 0~1로 정규화해 JSON에 기록합니다. 녹화가 끝나면 그 JSON을 읽어 CVPixelBuffer에 직접 원을 그려 커서를 합성한 영상을 만듭니다.
단계별로: ① 좌표 기록만 → ② 정지 이미지에 커서 그리기 → ③ 프레임마다 그리기 → ④ 좌표 사이를 보간해 부드럽게. 막히는 지점: 녹화 프레임 시각과 커서 기록 시각을 맞추는 것(CMTime 기준으로 통일해야 합니다).
여기까지 하면 Screendrop의 가장 어려운 부분을 축소판으로 재현한 것이고, 영상 편집 도구가 왜 그렇게 만들어지는지 몸으로 알게 됩니다.
| 주차 | 주제 | 목표 / 산출물 |
|---|---|---|
| 1주차 | SwiftUI 앱 수명주기 | App·Scene·WindowGroup·MenuBarExtra의 차이를 손으로. 산출물: 과제 1 완성 + 창이 있는 버전/없는 버전 둘 다 |
| 2주차 | AppKit과의 다리 | NSApplicationDelegateAdaptor·NSWindow·NSPanel·NSViewRepresentable. SwiftUI만으로 안 되는 일이 무엇인지 목록으로 정리해 보기. 산출물: 과제 3 |
| 3주차 | Swift 동시성 | async/await → @MainActor → Sendable → nonisolated. 목표는 문법이 아니라 "이 코드가 어느 스레드에서 도는지 컴파일러가 어떻게 아는가"를 설명할 수 있게 되는 것 |
| 4주차 | 권한과 시스템 접근 | TCC(화면 기록·입력 모니터링·마이크) 권한 흐름, CGPreflightListenEventAccess, 샌드박스 켜고/끄고의 차이. 산출물: 권한 거부 상태를 정상 처리하는 앱 |
| 5~6주차 | ScreenCaptureKit | SCShareableContent → SCContentFilter → SCStream → 콜백에서 CMSampleBuffer 받기. 산출물: 화면을 창에 실시간 미리보기 |
| 7~8주차 | AVFoundation 쓰기 | AVAssetWriter·AVAssetWriterInput·CMTime·코덱 설정. 산출물: 5주차 스트림을 mov 파일로 저장 + movieFragmentInterval 있고/없고 강제 종료 실험 |
| 9주차 | 픽셀 다루기 | CVPixelBuffer 잠그고 쓰기, 픽셀 포맷(BGRA), Core Image와의 관계. 산출물: 녹화본 위에 워터마크 합성 |
| 10주차 | 저장 설계 | 왜 SwiftData가 아니라 JSON인가. 스키마 진화(decodeIfPresent ?? 기본값), 사이드카 vs 폴더 번들, draft/saved 분리. 산출물: 버전 필드가 있는 문서 포맷 하나 설계 |
| 11주차 | 배포 파이프라인 | 서명 → 공증(notarization) → 스테이플 → DMG → Sparkle appcast → Homebrew 캐스크. 산출물: 내 앱을 실제로 서명·공증까지 |
| 12주차 | 자가호스팅 백엔드 | Cloudflare Worker + R2 + D1, 토큰 인증, 스트리밍 업로드. 산출물: 과제 4 완성 + 앱에서 실제 업로드 연결 |
5주차부터 나오는 SCStream 콜백은 백그라운드 스레드에서 옵니다. 동시성을 모른 채 들어가면 "가끔 크래시하는데 이유를 모르겠다"에서 몇 주를 씁니다. Screendrop이 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor를 켠 이유가 정확히 이 고통을 컴파일 오류로 앞당기기 위해서입니다.
| 용어 | 뜻 |
|---|---|
| MenuBarExtra | SwiftUI로 메뉴바 아이콘·드롭다운을 만드는 Scene. 예전엔 NSStatusItem으로 직접 만들어야 했다 |
| @Observable | Swift의 최신 상태 관찰 방식. 클래스에 붙이면 SwiftUI가 바뀐 값만 골라 다시 그린다. 구형은 ObservableObject |
| @MainActor | "이 코드는 메인(UI) 스레드에서만 돈다"는 표시. 컴파일러가 위반을 잡아 준다 |
| nonisolated | @MainActor 기본값에서 빠져나오는 표시. 백그라운드로 나가야 하는 곳에만 붙인다 |
| Sendable | "이 값은 스레드를 넘어가도 안전하다"는 약속. @unchecked Sendable은 "내가 책임진다"는 수동 선언 |
| ScreenCaptureKit (SCStream) | 애플의 최신 화면 캡처 프레임워크. 실시간 프레임 스트림을 준다. Screendrop은 녹화에만 쓴다 |
| screencapture | 맥에 기본 내장된 명령줄 스크린샷 도구. -D 전체화면 / -w 창 / -s 영역 |
| AVAssetWriter | 프레임을 받아 영상 파일로 써 주는 AVFoundation 클래스 |
| movieFragmentInterval | N초마다 재생 가능한 조각을 마감하는 설정. 크래시해도 그 지점까지는 살아남는다 |
| HEVC (H.265) | H.264 다음 세대 코덱. 같은 화질에 파일이 더 작지만 인코딩이 무겁다 |
| CVPixelBuffer | 영상 프레임 한 장이 담긴 메모리 덩어리. 주소를 잠그고 직접 픽셀을 쓸 수 있다 |
| Carbon / RegisterEventHotKey | 2001년 맥 API. 권한 없이 전역 단축키를 가로챌 수 있는 유일한 방법이라 아직 쓰인다 |
| CGEventTap | 시스템 입력 이벤트를 엿보는 저수준 장치. 강력하지만 입력 모니터링 권한이 필요하다 |
| Unmanaged | Swift 객체를 C 포인터로 바꿔 넘기고 되찾는 도구. C 콜백과 대화할 때 쓴다 |
| 키체인 (Keychain) | 맥·아이폰의 비밀 저장소. kSecClassGenericPassword가 앱 토큰용 기본 종류 |
| sharingType = .none | NSWindow에 붙이면 화면 공유·녹화에서 그 창이 빠진다 |
| NSPanel | 보조용 떠 있는 창. nonactivatingPanel이면 클릭해도 앱이 앞으로 튀어나오지 않는다 |
| 사이드카 (sidecar) 파일 | 원본 옆에 같은 이름 + 다른 확장자로 두는 부가 정보 파일. 여기선 foo.png.screendrop |
| 폴더 번들 | 겉으론 파일 하나, 속은 폴더인 구조. 맥의 .app·.rtfd가 그렇고 여기선 .screendroprec |
| Sparkle | 맥 앱 자동 업데이트 표준 라이브러리. appcast.xml을 읽고 EdDSA 서명을 검증한다 |
| 공증 (notarization) | 애플에 앱을 보내 악성코드 검사를 받는 절차. 안 하면 "확인되지 않은 개발자" 경고가 뜬다 |
| Cloudflare R2 | S3 호환 파일 저장소. 다운로드 트래픽 요금이 없다 — 이 조합의 핵심 |
| Cloudflare D1 | Worker에서 쓰는 가벼운 SQL 데이터베이스(SQLite 기반) |
| perfect-freehand | 필압에 따라 굵기가 변하는 자유곡선 알고리즘. tldraw·Excalidraw가 쓴다 |
| Icon Composer | Xcode 26의 새 앱 아이콘 포맷. png 대신 icon.json + SVG 레이어로 관리 |
Screendrop/ScreendropApp.swift (220줄) — 전체 구조가 한 화면에Screendrop/PreviewWindowCaptureExclusion.swift — 가장 짧고 가장 놀랍다Screendrop/ScreenshotManager.swift — "왜 ScreenCaptureKit이 아닌가" 주석 원문Screendrop/Engine/Shape.swift — 좌표계를 왜 픽셀로 고정했는지Screendrop/RecordingSession.swift — 폴더 번들 포맷 정의cmd/screendrop-release/main.go (992줄) — 배포 자동화의 실물.agents/skills/macos-notch-ui/ — 다른 앱에 그대로 옮겨 쓸 수 있는 레시피Engine/의 자유곡선 알고리즘 원본(JS)