diff --git a/README.ko.md b/README.ko.md index 03bd1ab..ff61f1e 100644 --- a/README.ko.md +++ b/README.ko.md @@ -66,6 +66,8 @@ open build/DeskPet.app | 항목 | 설명 | |---|---| | 캐릭터 보이기/숨기기 | 숨기면 애니메이션 타이머까지 멈춰 CPU를 쓰지 않습니다 | +| 집중 시작 | 기본 25분 타이머를 시작합니다. 딴짓 감지 중인 시간은 집중 시간에서 제외됩니다 | +| 집중 감지 | 현재 앱/사이트를 딴짓 목록에 추가하고, 브라우저 탭 감지를 켜거나 끕니다 | | 크기 | 작게(96pt) / 보통(144pt) / 크게(192pt) | | 옷 | 파란 체크 셔츠 / 주황 패딩 / I ♥ KIXLAB 티셔츠 / LG 트윈스 유니폼 | | 말풍선 · 말풍선 빈도 | 끄기 / 드물게 · 보통 · 자주 | @@ -75,6 +77,10 @@ open build/DeskPet.app | 입력 감지 권한 | 현재 상태 표시 + 시스템 설정 열기 | | 위치 초기화 · 종료 | | +집중 중에는 앞에 있는 앱과(선택 시) 지원 브라우저의 현재 탭을 **기기 안에서만** 확인합니다. +딴짓 목록에 있는 앱·사이트를 6초 넘게 보고 있으면 캐릭터가 알려 줍니다. 키 입력 내용, +URL, 방문 기록은 저장하거나 전송하지 않습니다. +
캐릭터 상태 11가지 diff --git a/Scripts/build_app.sh b/Scripts/build_app.sh index 6453389..aaa1cca 100755 --- a/Scripts/build_app.sh +++ b/Scripts/build_app.sh @@ -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" @@ -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" diff --git a/Sources/DeskPetApp/AppDelegate.swift b/Sources/DeskPetApp/AppDelegate.swift index f882a6d..9e26616 100644 --- a/Sources/DeskPetApp/AppDelegate.swift +++ b/Sources/DeskPetApp/AppDelegate.swift @@ -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 아이콘 없이 메뉴 막대에서만 동작한다. @@ -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() @@ -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) } } diff --git a/Sources/DeskPetApp/AttentionMonitor.swift b/Sources/DeskPetApp/AttentionMonitor.swift new file mode 100644 index 0000000..bc1c76e --- /dev/null +++ b/Sources/DeskPetApp/AttentionMonitor.swift @@ -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: "") + } +} diff --git a/Sources/DeskPetApp/Diagnose.swift b/Sources/DeskPetApp/Diagnose.swift index b8694ec..66319d4 100644 --- a/Sources/DeskPetApp/Diagnose.swift +++ b/Sources/DeskPetApp/Diagnose.swift @@ -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) } } diff --git a/Sources/DeskPetApp/KeyActivityMonitor.swift b/Sources/DeskPetApp/KeyActivityMonitor.swift index 31a61c6..e4b983c 100644 --- a/Sources/DeskPetApp/KeyActivityMonitor.swift +++ b/Sources/DeskPetApp/KeyActivityMonitor.swift @@ -1,4 +1,5 @@ import AppKit +import ApplicationServices import Carbon.HIToolbox /// macOS 전체의 키 입력을 "눌렸다는 사실"만 감지한다. @@ -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) @@ -44,10 +57,26 @@ 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 @@ -55,12 +84,31 @@ final class KeyActivityMonitor { } 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.fromOpaque(userInfo).takeUnretainedValue() + monitor.receiveEvent(type: type) + return Unmanaged.passUnretained(event) + } + func stop() { removeMonitors() permissionTimer?.invalidate() @@ -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) } diff --git a/Sources/DeskPetApp/MenuBarController.swift b/Sources/DeskPetApp/MenuBarController.swift index 9d710f2..a51fef8 100644 --- a/Sources/DeskPetApp/MenuBarController.swift +++ b/Sources/DeskPetApp/MenuBarController.swift @@ -9,23 +9,29 @@ final class MenuBarController: NSObject, NSMenuDelegate { private let sound: SoundPlayer private let windowController: PetWindowController private let keyMonitor: KeyActivityMonitor + private let pomodoro: PomodoroController private let visibilityItem = NSMenuItem() private let soundItem = NSMenuItem() private let bubbleItem = NSMenuItem() private let loginItem = NSMenuItem() private let permissionItem = NSMenuItem() + private let focusItem = NSMenuItem() + private let focusStatusItem = NSMenuItem() + private let browserWatchingItem = NSMenuItem() private var sizeItems: [PetSize: NSMenuItem] = [:] private var frequencyItems: [SpeechFrequency: NSMenuItem] = [:] private var outfitItems: [Outfit: NSMenuItem] = [:] private var languageItems: [AppLanguage: NSMenuItem] = [:] init(prefs: Preferences, sound: SoundPlayer, - windowController: PetWindowController, keyMonitor: KeyActivityMonitor) { + windowController: PetWindowController, keyMonitor: KeyActivityMonitor, + pomodoro: PomodoroController) { self.prefs = prefs self.sound = sound self.windowController = windowController self.keyMonitor = keyMonitor + self.pomodoro = pomodoro self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) super.init() @@ -43,6 +49,36 @@ final class MenuBarController: NSObject, NSMenuDelegate { visibilityItem.target = self menu.addItem(visibilityItem) + focusItem.action = #selector(toggleFocus) + focusItem.target = self + menu.addItem(focusItem) + + focusStatusItem.isEnabled = false + menu.addItem(focusStatusItem) + + let attentionMenu = NSMenu() + browserWatchingItem.title = L10n.t("브라우저 탭도 감지", "Watch browser tabs") + browserWatchingItem.action = #selector(toggleBrowserWatching) + browserWatchingItem.target = self + attentionMenu.addItem(browserWatchingItem) + attentionMenu.addItem(.separator()) + + let addApp = NSMenuItem(title: L10n.t("현재 앱을 딴짓 목록에 추가", "Add current app as a distraction"), action: #selector(addCurrentApp), keyEquivalent: "") + addApp.target = self + attentionMenu.addItem(addApp) + let addSite = NSMenuItem(title: L10n.t("현재 사이트를 딴짓 목록에 추가", "Add current site as a distraction"), action: #selector(addCurrentSite), keyEquivalent: "") + addSite.target = self + attentionMenu.addItem(addSite) + attentionMenu.addItem(.separator()) + let resetList = NSMenuItem(title: L10n.t("딴짓 목록 비우기", "Clear distraction list"), action: #selector(clearDistractionList), keyEquivalent: "") + resetList.target = self + attentionMenu.addItem(resetList) + let attentionItem = NSMenuItem(title: L10n.t("집중 감지", "Attention Detection"), action: nil, keyEquivalent: "") + attentionItem.submenu = attentionMenu + menu.addItem(attentionItem) + + menu.addItem(.separator()) + let sizeMenu = NSMenu() for size in PetSize.allCases { let item = NSMenuItem(title: size.title, action: #selector(changeSize(_:)), keyEquivalent: "") @@ -133,6 +169,17 @@ final class MenuBarController: NSObject, NSMenuDelegate { : L10n.t("캐릭터 보이기", "Show Character") soundItem.state = prefs.soundEnabled ? .on : .off bubbleItem.state = prefs.bubblesEnabled ? .on : .off + browserWatchingItem.state = prefs.attentionWatchBrowser ? .on : .off + switch pomodoro.phase { + case .idle, .finished: + focusItem.title = L10n.t("집중 시작 (\(Int(prefs.pomodoroMinutes))분)", "Start Focus (\(Int(prefs.pomodoroMinutes)) min)") + case .focusing: + focusItem.title = L10n.t("집중 일시정지", "Pause Focus") + case .paused: + focusItem.title = L10n.t("집중 다시 시작", "Resume Focus") + } + let warning = pomodoro.warningCount == 0 ? "" : L10n.t(" · 경고 \(pomodoro.warningCount)회", " · \(pomodoro.warningCount) warnings") + focusStatusItem.title = L10n.t("집중 타이머: \(pomodoro.timeText)\(warning)", "Focus timer: \(pomodoro.timeText)\(warning)") loginItem.state = LaunchAtLogin.isEnabled ? .on : .off loginItem.isEnabled = LaunchAtLogin.isSupported for (size, item) in sizeItems { item.state = (prefs.size == size) ? .on : .off } @@ -151,12 +198,14 @@ final class MenuBarController: NSObject, NSMenuDelegate { item.isEnabled = prefs.bubblesEnabled } - if keyMonitor.isTrusted { + if keyMonitor.isInputMonitoringTrusted && keyMonitor.isGlobalMonitorInstalled { permissionItem.title = keyMonitor.isSecureInputEnabled ? L10n.t("입력 감지: 잠시 쉬는 중(보안 입력)", "Typing detection: paused (secure input)") : L10n.t("입력 감지: 켜짐", "Typing detection: on") + } else if keyMonitor.isInputMonitoringTrusted { + permissionItem.title = L10n.t("입력 감지: 앱을 다시 실행해 주세요…", "Typing detection: relaunch required…") } else { - permissionItem.title = L10n.t("입력 감지 권한 허용하기…", "Grant typing access…") + permissionItem.title = L10n.t("입력 모니터링 권한 허용하기…", "Grant Input Monitoring access…") } } @@ -169,6 +218,41 @@ final class MenuBarController: NSObject, NSMenuDelegate { refresh() } + @objc private func toggleFocus() { + pomodoro.toggle() + refresh() + } + + @objc private func toggleBrowserWatching() { + prefs.attentionWatchBrowser.toggle() + refresh() + } + + @objc private func addCurrentApp() { + pomodoro.sampleCurrentContext() + let bundleID = pomodoro.currentAppName + let id = pomodoro.currentBundleID + guard !id.isEmpty, !prefs.distractionApps.contains(where: { $0.caseInsensitiveCompare(id) == .orderedSame }) else { return } + prefs.distractionApps.append(id) + windowController.model.showHint(L10n.t("\(bundleID)을 딴짓 목록에 추가했어.", "Added \(bundleID) to distractions."), seconds: 3) + refresh() + } + + @objc private func addCurrentSite() { + pomodoro.sampleCurrentContext() + let host = pomodoro.currentHost + guard !host.isEmpty, !prefs.distractionSites.contains(where: { $0.caseInsensitiveCompare(host) == .orderedSame }) else { return } + prefs.distractionSites.append(host) + windowController.model.showHint(L10n.t("\(host)을 딴짓 목록에 추가했어.", "Added \(host) to distractions."), seconds: 3) + refresh() + } + + @objc private func clearDistractionList() { + prefs.distractionApps = [] + prefs.distractionSites = [] + refresh() + } + @objc private func changeSize(_ sender: NSMenuItem) { guard let raw = sender.representedObject as? String, let size = PetSize(rawValue: raw) else { return } prefs.size = size @@ -222,11 +306,16 @@ final class MenuBarController: NSObject, NSMenuDelegate { } @objc private func openPermission() { - if keyMonitor.isTrusted { + if keyMonitor.isInputMonitoringTrusted && keyMonitor.isGlobalMonitorInstalled { PermissionOnboarding.showMessage( title: L10n.t("입력 감지가 켜져 있어요", "Typing detection is on"), message: L10n.t("타이핑하면 캐릭터도 같이 키보드를 두드려요.\n입력한 내용은 저장하지도, 어디로 보내지도 않아요.", "The character types along when you do.\nWhat you type is never stored or sent anywhere.")) + } else if keyMonitor.isInputMonitoringTrusted { + PermissionOnboarding.showMessage( + title: L10n.t("DeskPet을 다시 실행해 주세요", "Please relaunch DeskPet"), + message: L10n.t("입력 모니터링 권한을 반영하려면 DeskPet을 완전히 종료한 뒤 다시 실행해야 해요.", + "Quit DeskPet completely and reopen it to apply Input Monitoring access.")) } else { PermissionOnboarding.requestAccess(keyMonitor: keyMonitor) } diff --git a/Sources/DeskPetApp/PermissionOnboarding.swift b/Sources/DeskPetApp/PermissionOnboarding.swift index 87a4117..0035b56 100644 --- a/Sources/DeskPetApp/PermissionOnboarding.swift +++ b/Sources/DeskPetApp/PermissionOnboarding.swift @@ -8,9 +8,9 @@ enum PermissionOnboarding { /// - 처음 실행: 왜 필요한지 설명하는 창을 보여 준다. /// - 그 뒤 실행: 시스템 권한 목록에 DeskPet 이 등록되도록 표준 프롬프트만 조용히 띄운다. static func showIntroIfNeeded(prefs: Preferences, keyMonitor: KeyActivityMonitor) { - guard !keyMonitor.isTrusted else { return } + guard !keyMonitor.isInputMonitoringTrusted else { return } if prefs.permissionIntroShown { - keyMonitor.requestPermission() // 손쉬운 사용 목록에 앱을 등록시킨다 + keyMonitor.requestPermission() // 입력 모니터링 목록에 앱을 등록시킨다 } else { prefs.permissionIntroShown = true requestAccess(keyMonitor: keyMonitor, isFirstRun: true) @@ -19,11 +19,11 @@ enum PermissionOnboarding { static func requestAccess(keyMonitor: KeyActivityMonitor, isFirstRun: Bool = false) { let alert = NSAlert() - alert.messageText = L10n.t("타이핑을 따라 하려면 권한이 하나 필요해요", - "One permission is needed to type along") + alert.messageText = L10n.t("타이핑을 따라 하려면 입력 모니터링 권한이 필요해요", + "Input Monitoring permission is needed to type along") alert.informativeText = L10n.t(""" 캐릭터가 여러분이 타자를 칠 때 같이 키보드를 두드리게 하려면 - macOS 의 손쉬운 사용(입력 감지) 권한이 필요합니다. + macOS 의 입력 모니터링 권한이 필요합니다. • 앱은 "키가 눌렸다는 사실"과 "마지막 입력 시각"만 사용합니다. • 어떤 글자를 눌렀는지, 비밀번호, 사용 중인 앱은 읽지도 저장하지도 않습니다. @@ -32,7 +32,7 @@ enum PermissionOnboarding { 권한을 주지 않아도 캐릭터는 계속 잘 지냅니다. (대기 동작만 해요) """, """ To make the character type along with you, DeskPet needs - macOS Accessibility (input monitoring) permission. + macOS Input Monitoring permission. • The app only uses "a key was pressed" and "when it happened". • It never reads or stores which key, your passwords, or which app you use. diff --git a/Sources/DeskPetApp/PetView.swift b/Sources/DeskPetApp/PetView.swift index ce322e9..2c48bab 100644 --- a/Sources/DeskPetApp/PetView.swift +++ b/Sources/DeskPetApp/PetView.swift @@ -18,13 +18,54 @@ struct PetView: View { .frame(height: model.bubbleAreaHeight) .animation(.easeInOut(duration: 0.32), value: model.bubbleText) - Image(nsImage: model.image) - .resizable() - .interpolation(.none) - .antialiased(false) - .frame(width: model.spriteSize.width, height: model.spriteSize.height) + ZStack(alignment: .top) { + Image(nsImage: model.image) + .resizable() + .interpolation(.none) + .antialiased(false) + .colorMultiply(model.attentionAlert + ? Color(red: 1, green: 0.18, blue: 0.18) + : .white) + .shadow(color: model.attentionAlert ? .red.opacity(0.95) : .clear, + radius: model.attentionAlert ? 14 : 0) + // 평소에는 원본 픽셀 프레임만 표시한다. 경고 상태일 때만 + // 붉은 글로우와 확대를 적용해 일반 애니메이션에 부담을 주지 않는다. + .scaleEffect(model.attentionAlert ? 1.06 : 1) + .offset(x: model.attentionAlert ? 2 : 0) + + if let time = model.focusTimeText { + FocusTimerBadge(time: time, paused: model.focusIsPaused, + alerting: model.attentionAlert) + .padding(.top, 4) + } + } + .frame(width: model.spriteSize.width, height: model.spriteSize.height) } .frame(width: model.windowSize.width, height: model.windowSize.height, alignment: .bottom) + .animation(.easeInOut(duration: 0.18), value: model.attentionAlert) + } +} + +private struct FocusTimerBadge: View { + let time: String + let paused: Bool + let alerting: Bool + + var body: some View { + HStack(spacing: 5) { + Image(systemName: paused ? "pause.fill" : "timer") + Text(time).monospacedDigit() + } + .font(.system(size: 12, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + Capsule().fill(alerting ? Color(red: 0.82, green: 0.03, blue: 0.06) : Color(red: 0.12, green: 0.16, blue: 0.24)) + ) + .overlay(Capsule().strokeBorder(alerting ? Color.red.opacity(0.95) : .white.opacity(0.35), lineWidth: 1.5)) + .shadow(color: alerting ? .red.opacity(0.9) : .black.opacity(0.22), radius: alerting ? 8 : 3, y: 2) + .accessibilityLabel(paused ? "Focus timer paused" : "Focus timer") } } diff --git a/Sources/DeskPetApp/PetViewModel.swift b/Sources/DeskPetApp/PetViewModel.swift index a86585b..c92bf4d 100644 --- a/Sources/DeskPetApp/PetViewModel.swift +++ b/Sources/DeskPetApp/PetViewModel.swift @@ -7,6 +7,9 @@ final class PetViewModel: ObservableObject { @Published private(set) var image: NSImage @Published private(set) var bubbleText: String? + @Published private(set) var focusTimeText: String? + @Published private(set) var focusIsPaused = false + @Published private(set) var attentionAlert = false let brain: PetBrain private var speech = SpeechDirector() @@ -48,11 +51,14 @@ final class PetViewModel: ObservableObject { CGSize(width: max(spriteSize.width, 220), height: spriteSize.height + bubbleAreaHeight) } - init(size: PetSize, now: TimeInterval = ProcessInfo.processInfo.systemUptime) { + init(size: PetSize, outfit: Outfit = .checkShirt, + now: TimeInterval = ProcessInfo.processInfo.systemUptime) { self.size = size + self.outfit = outfit self.startedAt = now self.brain = PetBrain(now: now) - let pose = Pose() + var pose = Pose() + pose.outfit = outfit self.currentCanvas = CharacterSprite.render(pose) self.image = SpriteImageProvider.image(for: currentCanvas, size: size.spriteSize) } @@ -120,6 +126,13 @@ final class PetViewModel: ObservableObject { bubbleEndsAt = now + seconds } + /// 집중 타이머가 화면에 표시할 상태. 타이머 로직은 별도 컨트롤러가 맡는다. + func setFocusState(isActive: Bool, isPaused: Bool, timeText: String, alerting: Bool) { + focusTimeText = isActive ? timeText : nil + focusIsPaused = isPaused + attentionAlert = alerting + } + /// 캐릭터 그림 위(불투명 픽셀)인지 검사한다. 창의 나머지 영역은 클릭이 통과한다. func isOpaquePixel(canvasX: Int, canvasY: Int) -> Bool { currentCanvas.isOpaque(x: canvasX, y: canvasY) diff --git a/Sources/DeskPetApp/PetWindowController.swift b/Sources/DeskPetApp/PetWindowController.swift index c2f9fce..faf1540 100644 --- a/Sources/DeskPetApp/PetWindowController.swift +++ b/Sources/DeskPetApp/PetWindowController.swift @@ -26,7 +26,9 @@ final class PetWindowController { init(prefs: Preferences, sound: SoundPlayer) { self.prefs = prefs self.sound = sound - self.model = PetViewModel(size: prefs.size) + // SwiftUI 호스팅 뷰를 만들기 전부터 저장된 옷을 적용해, 기본 옷이 잠깐 + // 보였다가 바뀌는 첫 프레임 깜빡임을 없앤다. + self.model = PetViewModel(size: prefs.size, outfit: prefs.outfit) self.panel = PetPanel(size: model.windowSize) self.hostingView = NSHostingView(rootView: PetView(model: model)) @@ -149,8 +151,9 @@ final class PetWindowController { private func startTimer() { guard timer == nil else { return } - let t = Timer(timeInterval: 1.0 / 20.0, repeats: true) { [weak self] _ in self?.tick() } - t.tolerance = 0.01 + // 픽셀 프레임 전환이 답답하게 느껴지지 않도록 30fps로 갱신한다. + let t = Timer(timeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in self?.tick() } + t.tolerance = 0.005 RunLoop.main.add(t, forMode: .common) timer = t } diff --git a/Sources/DeskPetApp/PomodoroController.swift b/Sources/DeskPetApp/PomodoroController.swift new file mode 100644 index 0000000..2882250 --- /dev/null +++ b/Sources/DeskPetApp/PomodoroController.swift @@ -0,0 +1,131 @@ +import Foundation +import DeskPetCore + +/// 집중 시간이 흐르는 동안 주의 이탈을 감지한다. 경고 중인 시간은 타이머에서 제외한다. +final class PomodoroController { + enum Phase { case idle, focusing, paused, finished } + + private let prefs: Preferences + private let monitor = AttentionMonitor() + private weak var model: PetViewModel? + private var ticker: Timer? + private var endDate: Date? + private var badSince: Date? + private var alerting = false + private var lastAlertText = "" + private var lastAlertShown = Date.distantPast + + private(set) var phase: Phase = .idle + private(set) var remaining: TimeInterval = 25 * 60 + private(set) var warningCount = 0 + var onChange: (() -> Void)? + + init(prefs: Preferences, model: PetViewModel) { + self.prefs = prefs + self.model = model + remaining = prefs.pomodoroMinutes * 60 + let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in self?.tick() } + timer.tolerance = 0.15 + RunLoop.main.add(timer, forMode: .common) + ticker = timer + } + + deinit { ticker?.invalidate() } + + var isFocusing: Bool { phase == .focusing } + var timeText: String { + let seconds = max(0, Int(ceil(remaining))) + return String(format: "%d:%02d", seconds / 60, seconds % 60) + } + var currentAppName: String { monitor.frontAppName } + var currentBundleID: String { monitor.frontBundleID } + var currentHost: String { monitor.currentHost } + + func toggle() { + switch phase { + case .idle, .finished: + remaining = prefs.pomodoroMinutes * 60 + warningCount = 0 + endDate = Date().addingTimeInterval(remaining) + phase = .focusing + model?.showHint(L10n.t("집중 시작! 옆에서 보고 있을게.", "Focus started! I'll keep watch."), seconds: 4) + case .focusing: + phase = .paused + endDate = nil + clearAlert() + case .paused: + endDate = Date().addingTimeInterval(remaining) + phase = .focusing + model?.showHint(L10n.t("다시 집중해 보자.", "Let's get back to it."), seconds: 3) + } + publishState() + onChange?() + } + + func reset() { + phase = .idle + remaining = prefs.pomodoroMinutes * 60 + warningCount = 0 + endDate = nil + clearAlert() + publishState() + onChange?() + } + + func sampleCurrentContext() { + monitor.sample(watchBrowserURL: prefs.attentionWatchBrowser, active: isFocusing) + } + + private func tick() { + sampleCurrentContext() + guard phase == .focusing, var end = endDate else { return } + evaluateAttention() + if alerting { + end = end.addingTimeInterval(1) + endDate = end + if Date().timeIntervalSince(lastAlertShown) >= 6 { + lastAlertShown = Date() + model?.showHint(lastAlertText, seconds: 6) + } + } + remaining = max(0, end.timeIntervalSinceNow) + if remaining == 0 { + phase = .finished + endDate = nil + clearAlert() + model?.showHint(L10n.t("집중 완료! 정말 잘했어!", "Focus complete—great work!"), seconds: 6) + } + publishState() + onChange?() + } + + private func evaluateAttention() { + let verdict = monitor.verdict(distractionApps: prefs.distractionApps, distractionSites: prefs.distractionSites) + if verdict.isDistracting { + if badSince == nil { badSince = Date() } + guard !alerting, let badSince, Date().timeIntervalSince(badSince) >= prefs.attentionGraceSeconds else { return } + alerting = true + warningCount += 1 + let target = verdict.detail.isEmpty ? verdict.appName : verdict.detail + lastAlertText = L10n.t("\(target) 보고 있네? 집중으로 돌아오자!", "\(target)? Let's return to focus!") + lastAlertShown = Date() + model?.showHint(lastAlertText, seconds: 7) + } else { + clearAlert() + } + } + + private func clearAlert() { + alerting = false + badSince = nil + lastAlertText = "" + lastAlertShown = .distantPast + } + + private func publishState() { + model?.setFocusState(isActive: phase == .focusing || phase == .paused, + isPaused: phase == .paused, + timeText: timeText, + alerting: alerting) + } +} diff --git a/Sources/DeskPetApp/Preferences.swift b/Sources/DeskPetApp/Preferences.swift index 1f71357..02fdcfd 100644 --- a/Sources/DeskPetApp/Preferences.swift +++ b/Sources/DeskPetApp/Preferences.swift @@ -18,6 +18,11 @@ final class Preferences { static let originY = "pet.origin.y" static let hasPosition = "pet.origin.saved" static let permissionIntroShown = "pet.permissionIntroShown" + static let pomodoroMinutes = "pomodoro.minutes" + static let attentionGraceSeconds = "pomodoro.attentionGraceSeconds" + static let attentionWatchBrowser = "pomodoro.attentionWatchBrowser" + static let distractionApps = "pomodoro.distractionApps" + static let distractionSites = "pomodoro.distractionSites" } private let defaults: UserDefaults @@ -31,7 +36,12 @@ final class Preferences { Key.bubbleFrequency: SpeechFrequency.normal.rawValue, Key.size: PetSize.medium.rawValue, Key.outfit: Outfit.checkShirt.rawValue, - Key.language: AppLanguage.system.rawValue + Key.language: AppLanguage.system.rawValue, + Key.pomodoroMinutes: 25.0, + Key.attentionGraceSeconds: 6.0, + Key.attentionWatchBrowser: true, + Key.distractionApps: ["com.kakao.KakaoTalkMac", "com.apple.MobileSMS", "com.hnc.Discord", "com.netflix.Netflix", "com.tencent.xin"], + Key.distractionSites: ["youtube.com", "youtu.be", "netflix.com", "twitch.tv", "instagram.com", "x.com", "twitter.com", "reddit.com", "tiktok.com", "dcinside.com", "fmkorea.com", "ruliweb.com", "clien.net", "inven.co.kr", "chzzk.naver.com"] ]) } @@ -91,4 +101,29 @@ final class Preferences { get { defaults.bool(forKey: Key.permissionIntroShown) } set { defaults.set(newValue, forKey: Key.permissionIntroShown) } } + + var pomodoroMinutes: Double { + get { max(1, defaults.double(forKey: Key.pomodoroMinutes)) } + set { defaults.set(max(1, newValue), forKey: Key.pomodoroMinutes) } + } + + var attentionGraceSeconds: Double { + get { max(0, defaults.double(forKey: Key.attentionGraceSeconds)) } + set { defaults.set(max(0, newValue), forKey: Key.attentionGraceSeconds) } + } + + var attentionWatchBrowser: Bool { + get { defaults.bool(forKey: Key.attentionWatchBrowser) } + set { defaults.set(newValue, forKey: Key.attentionWatchBrowser) } + } + + var distractionApps: [String] { + get { defaults.stringArray(forKey: Key.distractionApps) ?? [] } + set { defaults.set(newValue, forKey: Key.distractionApps) } + } + + var distractionSites: [String] { + get { defaults.stringArray(forKey: Key.distractionSites) ?? [] } + set { defaults.set(newValue, forKey: Key.distractionSites) } + } } diff --git a/Sources/DeskPetApp/SelfTest.swift b/Sources/DeskPetApp/SelfTest.swift index 3aa79a5..d995667 100644 --- a/Sources/DeskPetApp/SelfTest.swift +++ b/Sources/DeskPetApp/SelfTest.swift @@ -155,8 +155,10 @@ final class SelfTest: NSObject { private func checkMenuAndSizes() { let monitor = KeyActivityMonitor() + let pomodoro = PomodoroController(prefs: prefs, model: controller.model) let menuBar = MenuBarController(prefs: prefs, sound: sound, - windowController: controller, keyMonitor: monitor) + windowController: controller, keyMonitor: monitor, + pomodoro: pomodoro) menuBar.refresh() check("메뉴 막대 아이콘과 메뉴 구성", true) diff --git a/Sources/DeskPetCore/AnimationLibrary.swift b/Sources/DeskPetCore/AnimationLibrary.swift index 0ae66ba..efdffb1 100644 --- a/Sources/DeskPetCore/AnimationLibrary.swift +++ b/Sources/DeskPetCore/AnimationLibrary.swift @@ -64,14 +64,14 @@ public enum AnimationLibrary { return p } - /// 천천히 숨 쉬기. + /// 편안하지만 처지지 않게 숨 쉬기. static func idle() -> SpriteAnimation { var a = base(); a.headOffsetY = 0; a.bodyOffsetY = 0 var b = base(); b.headOffsetY = 1; b.bodyOffsetY = 1 var c = base(); c.headOffsetY = 1; c.bodyOffsetY = 0 return SpriteAnimation(frames: [ - AnimationFrame(a, 1.1), AnimationFrame(b, 0.9), - AnimationFrame(c, 0.5), AnimationFrame(a, 0.9) + AnimationFrame(a, 0.7), AnimationFrame(b, 0.55), + AnimationFrame(c, 0.3), AnimationFrame(a, 0.65) ], loops: true) }