Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ open build/DeskPet.app
| 항목 | 설명 |
|---|---|
| 캐릭터 보이기/숨기기 | 숨기면 애니메이션 타이머까지 멈춰 CPU를 쓰지 않습니다 |
| 집중 시작 | 기본 25분 타이머를 시작합니다. 딴짓 감지 중인 시간은 집중 시간에서 제외됩니다 |
| 집중 감지 | 현재 앱/사이트를 딴짓 목록에 추가하고, 브라우저 탭 감지를 켜거나 끕니다 |
| 크기 | 작게(96pt) / 보통(144pt) / 크게(192pt) |
| 옷 | 파란 체크 셔츠 / 주황 패딩 / I ♥ KIXLAB 티셔츠 / LG 트윈스 유니폼 |
| 말풍선 · 말풍선 빈도 | 끄기 / 드물게 · 보통 · 자주 |
Expand All @@ -75,6 +77,10 @@ open build/DeskPet.app
| 입력 감지 권한 | 현재 상태 표시 + 시스템 설정 열기 |
| 위치 초기화 · 종료 | |

집중 중에는 앞에 있는 앱과(선택 시) 지원 브라우저의 현재 탭을 **기기 안에서만** 확인합니다.
딴짓 목록에 있는 앱·사이트를 6초 넘게 보고 있으면 캐릭터가 알려 줍니다. 키 입력 내용,
URL, 방문 기록은 저장하거나 전송하지 않습니다.

<details>
<summary><b>캐릭터 상태 11가지</b></summary>

Expand Down
11 changes: 9 additions & 2 deletions Scripts/build_app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ CONFIG="release"
echo "▸ swift build (${CONFIG})"
swift build --package-path "$ROOT" -c "$CONFIG"

BIN="$(swift build --package-path "$ROOT" -c "$CONFIG" --show-bin-path)/DeskPet"
# 바로 앞 빌드 결과를 사용한다. `--show-bin-path`를 위해 SwiftPM을 한 번 더
# 호출하지 않아 느린 디스크/네트워크 볼륨에서도 build.db 잠금 충돌을 피한다.
BIN="$(find "$ROOT/.build" -path "*/$CONFIG/DeskPet" -type f -perm -111 -print -quit)"
if [[ -z "$BIN" ]]; then
echo "✗ 빌드된 DeskPet 실행 파일을 찾지 못했습니다"
exit 1
fi
APP="$ROOT/build/DeskPet.app"

rm -rf "$APP"
Expand All @@ -30,7 +36,8 @@ rm -rf "$ICONSET"
# Finder 가 붙이는 확장 속성(com.apple.FinderInfo 등)이 남아 있으면 서명 검증이 실패하고,
# 그러면 시스템 설정에서 권한을 켜도 macOS 가 무시한다. 그래서 서명 전에 반드시 지운다.
echo "▸ 확장 속성 정리 (xattr)"
xattr -cr "$APP"
xattr -c "$APP"
find "$APP" -type f -exec xattr -c {} +

echo "▸ codesign (ad-hoc)"
codesign --force --sign - --timestamp=none "$APP"
Expand Down
17 changes: 10 additions & 7 deletions Sources/DeskPetApp/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private let keyMonitor = KeyActivityMonitor()
private var windowController: PetWindowController!
private var menuBar: MenuBarController!
private var pomodoro: PomodoroController!

func applicationDidFinishLaunching(_ notification: Notification) {
// Dock 아이콘 없이 메뉴 막대에서만 동작한다.
Expand All @@ -18,20 +19,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
windowController = PetWindowController(prefs: prefs, sound: sound)
windowController.model.bubblesEnabled = prefs.bubblesEnabled
windowController.model.bubbleFrequency = prefs.bubbleFrequency
windowController.model.outfit = prefs.outfit
pomodoro = PomodoroController(prefs: prefs, model: windowController.model)

menuBar = MenuBarController(prefs: prefs, sound: sound,
windowController: windowController, keyMonitor: keyMonitor)
windowController: windowController, keyMonitor: keyMonitor,
pomodoro: pomodoro)
pomodoro.onChange = { [weak self] in self?.menuBar.refresh() }

keyMonitor.onKeystroke = { [weak self] in
self?.windowController.registerKeystroke()
}
keyMonitor.onPermissionChange = { [weak self] trusted in
keyMonitor.onPermissionChange = { [weak self] inputMonitoringGranted in
guard let self else { return }
self.menuBar.refresh()
if trusted {
if inputMonitoringGranted {
self.windowController.model.showHint(
L10n.t("이제 같이 칠 수 있다!", "Now we can type together!"), seconds: 4)
L10n.t("입력 감지 켜졌어! 이제 같이 칠 수 있다!", "Input monitoring is on—now we can type together!"), seconds: 4)
}
}
keyMonitor.start()
Expand All @@ -46,9 +49,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
// 권한이 없으면 캐릭터가 한 번 알려 준다(모달 대신 말풍선으로).
DispatchQueue.main.asyncAfter(deadline: .now() + 6) { [weak self] in
guard let self, !self.keyMonitor.isTrusted else { return }
guard let self, !self.keyMonitor.isInputMonitoringTrusted else { return }
self.windowController.model.showHint(
L10n.t("손쉬운 사용 켜 주면 같이 타자 칠게", "Turn on Accessibility and I'll type along"),
L10n.t("입력 모니터링 켜 주면 같이 타자 칠게", "Turn on Input Monitoring and I'll type along"),
seconds: 6)
}
}
Expand Down
83 changes: 83 additions & 0 deletions Sources/DeskPetApp/AttentionMonitor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import AppKit

/// 앞에 있는 앱과 지원 브라우저의 현재 탭을 확인한다. URL은 기기 밖으로 전송하지 않는다.
final class AttentionMonitor {
struct Verdict: Equatable {
let isDistracting: Bool
let appName: String
let detail: String
}

static let browsers: [String: String] = [
"com.google.Chrome": "active tab of front window",
"com.brave.Browser": "active tab of front window",
"com.microsoft.edgemac": "active tab of front window",
"company.thebrowser.dia": "active tab of front window",
"company.thebrowser.Browser": "active tab of front window",
"com.naver.Whale": "active tab of front window",
"com.vivaldi.Vivaldi": "active tab of front window",
"com.apple.Safari": "current tab of front window"
]

private let scriptQueue = DispatchQueue(label: "deskpet.attention.osascript")
private let ownBundleID = Bundle.main.bundleIdentifier ?? ""
private var fetchingURL = false
private var lastURLFetch = Date.distantPast

private(set) var frontBundleID = ""
private(set) var frontAppName = ""
private(set) var currentURL = ""

var currentHost: String {
guard let host = URL(string: currentURL)?.host, !host.isEmpty else { return "" }
return host.hasPrefix("www.") ? String(host.dropFirst(4)) : host
}

func sample(watchBrowserURL: Bool, active: Bool) {
guard let front = NSWorkspace.shared.frontmostApplication else { return }
let bundleID = front.bundleIdentifier ?? ""
guard !bundleID.isEmpty, bundleID != ownBundleID else { return }
frontBundleID = bundleID
frontAppName = front.localizedName ?? bundleID

guard watchBrowserURL, let tab = Self.browsers[bundleID] else {
currentURL = ""
return
}
let interval: TimeInterval = active ? 1.2 : 4
guard !fetchingURL, Date().timeIntervalSince(lastURLFetch) >= interval else { return }
fetchingURL = true
lastURLFetch = Date()
let source = "tell application id \"\(bundleID)\" to return URL of \(tab)"
scriptQueue.async { [weak self] in
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
process.arguments = ["-e", source]
let output = Pipe()
process.standardOutput = output
process.standardError = Pipe()
var result = ""
do {
try process.run()
let data = output.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
result = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
} catch { }
DispatchQueue.main.async {
self?.currentURL = result
self?.fetchingURL = false
}
}
}

func verdict(distractionApps: [String], distractionSites: [String]) -> Verdict {
if distractionApps.contains(where: { $0.caseInsensitiveCompare(frontBundleID) == .orderedSame }) {
return Verdict(isDistracting: true, appName: frontAppName, detail: "")
}
let url = currentURL.lowercased()
if let site = distractionSites.first(where: { url.contains($0.lowercased()) }) {
return Verdict(isDistracting: true, appName: frontAppName, detail: site)
}
return Verdict(isDistracting: false, appName: frontAppName, detail: "")
}
}
6 changes: 4 additions & 2 deletions Sources/DeskPetApp/Diagnose.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,24 @@ final class Diagnose: NSObject {
log("실행 경로: \(Bundle.main.bundlePath)")
log("번들 ID: \(Bundle.main.bundleIdentifier ?? "(없음)")")
log("손쉬운 사용(AXIsProcessTrusted): \(monitor.isTrusted)")
log("입력 모니터링(CGPreflightListenEventAccess): \(monitor.isInputMonitoringTrusted)")
log("보안 입력(Secure Input): \(monitor.isSecureInputEnabled)")
log("─────── 지금부터 \(seconds)초간 키 입력을 셉니다 ───────")

monitor.onKeystroke = { [weak self] in self?.keyCount += 1 }
monitor.start()
log("전역 키 이벤트 탭 설치: \(monitor.isGlobalMonitorInstalled)")

let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] t in
guard let self else { return }
self.elapsed += 1
self.log("\(self.elapsed)초: 감지된 키 입력 누적 \(self.keyCount)회 (권한=\(self.monitor.isTrusted))")
self.log("\(self.elapsed)초: 감지된 키 입력 누적 \(self.keyCount)회 (입력 모니터링=\(self.monitor.isInputMonitoringTrusted), 이벤트 탭=\(self.monitor.isGlobalMonitorInstalled))")
if self.elapsed >= seconds {
t.invalidate()
self.log("─────── 결과 ───────")
self.log(self.keyCount > 0
? "정상: 키 입력이 앱까지 도달합니다 (\(self.keyCount)회)"
: "문제: 키 입력이 전혀 도달하지 않습니다. 손쉬운 사용 권한을 확인하세요.")
: "문제: 키 입력이 전혀 도달하지 않습니다. 입력 모니터링 권한과 앱 재실행을 확인하세요.")
exit(0)
}
}
Expand Down
74 changes: 62 additions & 12 deletions Sources/DeskPetApp/KeyActivityMonitor.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import ApplicationServices
import Carbon.HIToolbox

/// macOS 전체의 키 입력을 "눌렸다는 사실"만 감지한다.
Expand All @@ -15,27 +16,39 @@ final class KeyActivityMonitor {
/// 권한 상태가 바뀌면 호출된다.
var onPermissionChange: ((Bool) -> Void)?

private var globalMonitor: Any?
private var eventTap: CFMachPort?
private var eventTapSource: CFRunLoopSource?
private var localMonitor: Any?
private var permissionTimer: Timer?
private(set) var lastKnownTrust = false
private(set) var lastKnownInputMonitoring = false

var isTrusted: Bool { AXIsProcessTrusted() }

/// macOS 10.15부터 다른 앱의 키 입력을 받으려면 손쉬운 사용과 별도로
/// '입력 모니터링' 권한이 필요할 수 있다.
var isInputMonitoringTrusted: Bool { CGPreflightListenEventAccess() }
var isGlobalMonitorInstalled: Bool { eventTap != nil }

/// 암호 입력 등 보안 입력 중에는 어떤 앱도 키 이벤트를 받을 수 없다(정상 동작).
var isSecureInputEnabled: Bool { IsSecureEventInputEnabled() }

func start() {
lastKnownTrust = isTrusted
lastKnownInputMonitoring = isInputMonitoringTrusted
installMonitors()
// 사용자가 나중에 권한을 켜면 자동으로 다시 붙는다.
let timer = Timer(timeInterval: 3.0, repeats: true) { [weak self] _ in
guard let self else { return }
let trusted = self.isTrusted
if trusted != self.lastKnownTrust {
self.lastKnownTrust = trusted
let accessibility = self.isTrusted
let inputMonitoring = self.isInputMonitoringTrusted
if accessibility != self.lastKnownTrust
|| inputMonitoring != self.lastKnownInputMonitoring
|| (inputMonitoring && !self.isGlobalMonitorInstalled) {
self.lastKnownTrust = accessibility
self.lastKnownInputMonitoring = inputMonitoring
self.installMonitors()
self.onPermissionChange?(trusted)
self.onPermissionChange?(inputMonitoring)
}
}
RunLoop.main.add(timer, forMode: .common)
Expand All @@ -44,23 +57,58 @@ final class KeyActivityMonitor {

private func installMonitors() {
removeMonitors()
// 권한이 없으면 global 모니터는 조용히 아무 이벤트도 받지 않는다(앱은 계속 동작).
globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.keyDown]) { [weak self] _ in
self?.onKeystroke?()

// NSEvent 전역 모니터는 설치 성공 여부를 확인할 수 없다. CGEventTap은
// 실제 입력 모니터링 권한이 없으면 nil을 반환하므로 상태를 정확히 알 수 있다.
let mask = CGEventMask(1) << CGEventType.keyDown.rawValue
if let tap = CGEvent.tapCreate(
tap: .cgSessionEventTap,
place: .headInsertEventTap,
options: .listenOnly,
eventsOfInterest: mask,
callback: Self.eventTapCallback,
userInfo: Unmanaged.passUnretained(self).toOpaque()
) {
let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
eventTap = tap
eventTapSource = source
CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes)
CGEvent.tapEnable(tap: tap, enable: true)
}

// 앱 자체가 키 윈도우가 되는 진단/안내 상황에서도 입력을 센다.
localMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { [weak self] event in
self?.onKeystroke?()
return event
}
}

private func removeMonitors() {
if let globalMonitor { NSEvent.removeMonitor(globalMonitor) }
if let eventTapSource {
CFRunLoopRemoveSource(CFRunLoopGetMain(), eventTapSource, .commonModes)
}
if let eventTap { CFMachPortInvalidate(eventTap) }
if let localMonitor { NSEvent.removeMonitor(localMonitor) }
globalMonitor = nil
eventTapSource = nil
eventTap = nil
localMonitor = nil
}

private func receiveEvent(type: CGEventType) {
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
if let eventTap { CGEvent.tapEnable(tap: eventTap, enable: true) }
return
}
if type == .keyDown { onKeystroke?() }
}

private static let eventTapCallback: CGEventTapCallBack = { _, type, event, userInfo in
guard let userInfo else { return Unmanaged.passUnretained(event) }
let monitor = Unmanaged<KeyActivityMonitor>.fromOpaque(userInfo).takeUnretainedValue()
monitor.receiveEvent(type: type)
return Unmanaged.passUnretained(event)
}

func stop() {
removeMonitors()
permissionTimer?.invalidate()
Expand All @@ -71,11 +119,13 @@ final class KeyActivityMonitor {
@discardableResult
func requestPermission() -> Bool {
let key = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
return AXIsProcessTrustedWithOptions([key: true] as CFDictionary)
let accessibilityGranted = AXIsProcessTrustedWithOptions([key: true] as CFDictionary)
let inputMonitoringGranted = CGRequestListenEventAccess()
return accessibilityGranted || inputMonitoringGranted
}

static func openAccessibilitySettings() {
let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!
let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent")!
NSWorkspace.shared.open(url)
}

Expand Down
Loading