This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Swift 기반 macOS 스크린 타임 추적 도구. 데몬(focusd)이 백그라운드에서 앱 사용을 모니터링하고, CLI(focus)로 제어 및 조회합니다.
┌─────────────┐ ┌─────────────┐
│ focus │ ──── Direct DB Access ──── │ focusd │
│ (CLI) │ │ (Daemon) │
└─────────────┘ └─────────────┘
│ │
└──────────────┬───────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ FocusCore │
│ ┌──────────┐ ┌──────────┐ ┌─────────────────────┐ │
│ │ Config │ │ Models │ │ Database (GRDB) │ │
│ └──────────┘ └──────────┘ └─────────────────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌─────────────────────┐ │
│ │ Logger │ │DateUtils │ │ ExclusionConfig │ │
│ └──────────┘ └──────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Sources/
├── FocusCore/ # 공유 라이브러리
│ ├── Config.swift # 경로 상수 (~/.local/share/focus/, ~/.config/focus/)
│ ├── Models.swift # Session, AppInfo, DaemonStatus, SummaryRecord 타입
│ ├── Database.swift # GRDB 기반 SQLite 레이어
│ ├── DurationFormatter.swift # 시간 포맷 유틸리티 (detailed/compact)
│ ├── ExclusionConfig.swift # 제외 설정 모델 및 glob 패턴 매칭
│ ├── ConfigWatcher.swift # 설정 파일 변경 감시 (FSEvents)
│ ├── Logger.swift # 스레드 안전 로깅 (NSLock 기반)
│ ├── DateUtils.swift # 스레드 안전 날짜 파싱 및 범위 계산
│ └── SessionFormatter.swift # 세션 출력 포맷팅
├── focusd/ # 데몬 프로세스
│ ├── main.swift # 진입점, 시그널 핸들러
│ ├── AppMonitor.swift # NSWorkspace + AXObserver 모니터링
│ └── SessionRecorder.swift # 세션 기록 actor
└── focus/ # CLI 도구
├── main.swift # ArgumentParser 기반 CLI
└── Commands/
├── SummaryCommand.swift # 앱/윈도우별 사용 시간 요약
├── LogCommand.swift # 세션 로그 검색 및 조회
├── DeleteCommand.swift # 세션 데이터 삭제
├── InstallCommand.swift # launchd 에이전트 설치
└── UninstallCommand.swift # launchd 에이전트 제거
Tests/
└── FocusCoreTests/ # 테스트 스위트 (Swift Testing 프레임워크)
├── DatabaseTests.swift # GRDB 통합 테스트
├── ExclusionConfigTests.swift # 제외 로직 및 glob 패턴 테스트
├── DateUtilsTests.swift # 날짜 파싱 및 범위 계산 테스트
├── DurationFormatterTests.swift # 시간 포맷 테스트
├── ModelsTests.swift # 데이터 모델 테스트
└── CommandTests.swift # CLI 커맨드 파싱 테스트
SessionRecorder: actor로 구현하여 데이터 레이스 방지AppMonitor: MainActor에서 실행 (AppKit 요구사항)
Session:FetchableRecord,PersistableRecord프로토콜 준수Columnsenum으로 타입 안전한 쿼리lastInsertedRowID로 auto-increment ID 획득
detailed(_:): 상세 포맷 "1h 2m 3s" - 상태, 로그, 삭제 확인용compact(_:): 간략 포맷 "1h 2m" - 타임라인, 검색용
- 제외할 앱/윈도우 설정을
~/.config/focus/config.json에서 로드 ConfigWatcher가 파일 변경 감시, 실시간 반영 (데몬 재시작 불필요)- glob 패턴:
*(0개 이상 문자),?(단일 문자) 지원 - 기본값:
com.apple.loginwindow만 제외
NSLock기반 스레드 안전 로깅LogLevel:.fault,.error,.warning,.notice,.info,.debug지원 (os.Logger 호환)- 데몬(
focusd)에서 주로 사용
- 스레드 안전 날짜 파싱 및 포맷팅 (
NSLock기반) parse(_:): "YYYY-MM-DD" 또는 "YYYY-MM-DD HH:mm" 형식 지원parseDateOptions(): CLI의--date,--from,--to옵션 처리
swift build # 빌드
swift test # 전체 테스트
swift test --filter FocusCoreTests.TestClassName/testMethodName # 단일 테스트
swift build -c release # 릴리스 빌드테스트 타겟은 FocusCoreTests 하나뿐입니다.
AppMonitor에서 CFTypeRef를 AXUIElement로 변환할 때 as! force cast를 사용합니다.
CoreFoundation 타입은 Swift 브리징 특성상 as? 조건부 캐스트가 항상 성공하여 컴파일 에러가 발생하므로,
CFGetTypeID 검사 후 as!를 사용하는 것이 올바른 패턴입니다.
ConfigWatcher.start()는 throws 함수입니다. 데몬에서는 설정 감시 실패가 치명적이지 않으므로
do-catch로 감싸서 경고만 출력하고 계속 실행합니다. 설정 변경 자동 감지만 비활성화됩니다.
focusd는 AppKit RunLoop이 필요하므로 GUI 앱처럼 동작- 접근성 권한 없으면 창 제목 추적 불가 (앱 전환만 추적)
- launchd plist 경로는
~/Library/LaunchAgents/dev.sunb.focus.plist
데몬이 비정상 종료(크래시, 강제 종료 등)되면 열린 세션(endedAt이 NULL인 세션)이 남습니다.
다음 데몬 시작 시 이 "고아 세션"들은 복구 대신 삭제됩니다.
이유: 데이터 정확도 우선
- 크래시 시점의 정확한 종료 시간을 알 수 없음
- 부정확한 추정 시간(예: PID 파일의 마지막 갱신 시간)을 기록하면 통계가 왜곡됨
- 사용자에게 "대략적인" 데이터보다 정확한 데이터가 더 가치 있음
영향: 드물게 발생하는 크래시 시 해당 세션의 데이터 손실 가능
설정 파일(~/.config/focus/config.json)에 별도 버전 필드를 두지 않습니다.
이유: 현재 설정 구조가 단순하고(excludedApps, excludedWindows) 확장 가능성이 낮음. 향후 구조 변경이 필요하면 새 필드 추가 시 기본값 폴백으로 하위 호환성을 유지하거나, 그때 버전 필드를 도입하면 충분합니다.
다음 항목들은 문제가 아니므로 이슈로 보고하지 말 것:
- deleteSession 반환값: GRDB
deleteOne은 row 존재 시 true, 부재 시 false를 반환. fetch와 delete 사이 race는 실제로 발생하지 않음 (CLI 단일 실행) - Delete 명령어 fetch-delete 타이밍: 세션 조회 후 삭제 전에 데몬이 세션을 종료할 수 있음. 표시된 정보와 실제 삭제 시점의 세션 상태가 다를 수 있으나 ID 기반 삭제로 데이터 무결성 문제 없음
- ConfigWatcher 시작 순서: AppMonitor보다 뒤에 시작하지만, 초기
ExclusionConfig.load()로 설정이 이미 로드된 상태이므로 문제없음 - AXObserver 콜백 메모리 안전성:
takeUnretainedValue()는 의도된 패턴.stop()에서removeAXObserver()가 항상 먼저 호출되어 해제 후 접근 불가 - closeAllSessions 데드락:
nonisolated이고 GRDBdbQueue.write가 자체 직렬화를 수행하므로 데드락 불가 - bundle ID "unknown" 폴백: bundle ID 없는 앱은 극히 드물고, 있더라도 추적하는 것이 합리적
- Logger DateFormatter thread-safety:
formatter.string(from:)호출이lock.lock()이후에 위치하므로 이미 thread-safe함. DateFormatter가 static이라도 모든 접근이 lock 범위 내에서 발생 - ConfigWatcher atomic save 시 fd 누수:
waitForFile()에서source?.cancel()후start()를 호출할 때 fd 누수가 발생한다는 분석은 오류.setCancelHandler { close(fd) }가 설정되어 있어cancel()호출 시 cancelHandler가 실행되어 기존 fd가 닫힘. 새start()는 새 fd를 열지만 이전 fd는 이미 정리된 상태
데몬은 시작 시 접근성 권한을 확인하고, 없으면 시스템 설정 팝업을 띄운 뒤 exit 1로 종료합니다.
launchd plist는 KeepAlive > SuccessfulExit: true로 설정되어 exit 0일 때만 재시작하므로,
권한 없이 exit 1로 종료하면 재시작 루프에 빠지지 않습니다.
데몬 종료 시 5초 타임아웃 후 exit(1)로 강제 종료하는데, 이 시점에서 DB 연결을 명시적으로 닫지 않습니다.
이유: 타임아웃이 발생했다는 것은 이미 비정상 상태(정상 종료 루틴이 5초 내에 완료되지 않음)이므로,
그 시점에서 DB 정리를 시도해도 의미가 없습니다. 정상 종료 시에는 closeAllSessions()가 완료된 후
RunLoop이 종료되므로 타임아웃에 도달하지 않습니다. SQLite의 WAL 모드가 비정상 종료 복구를 처리합니다.