import AppKit import EventKit import Foundation import MacToolsPluginKit @MainActor enum CalendarEventAuthorization: Equatable { case notDetermined case fullAccess case denied(String) var isFullAccess: Bool { if case .fullAccess = self { return true } return false } } @MainActor protocol CalendarEventServicing: AnyObject { var authorization: CalendarEventAuthorization { get } func requestAccess() async -> CalendarEventAuthorization func events(from startDate: Date, to endDate: Date) async throws -> [CalendarEventInput] func openSystemCalendar(at date: Date) } @MainActor final class CalendarEventService: CalendarEventServicing { private var eventStore = EKEventStore() private let localization: PluginLocalization init(localization: PluginLocalization = PluginLocalization(bundle: .main)) { self.localization = localization } var authorization: CalendarEventAuthorization { Self.authorization(from: EKEventStore.authorizationStatus(for: .event), localization: localization) } func requestAccess() async -> CalendarEventAuthorization { guard authorization == .notDetermined else { return authorization } do { _ = try await requestFullAccessToEvents() eventStore = EKEventStore() return authorization } catch { eventStore = EKEventStore() return .denied(error.localizedDescription) } } private func requestFullAccessToEvents() async throws -> Bool { let eventStore = eventStore return try await withCheckedThrowingContinuation { continuation in eventStore.requestFullAccessToEvents { granted, error in if let error { continuation.resume(throwing: error) return } continuation.resume(returning: granted) } } } func events(from startDate: Date, to endDate: Date) async throws -> [CalendarEventInput] { guard authorization.isFullAccess else { return [] } let calendars = eventStore.calendars(for: .event) guard !calendars.isEmpty else { return [] } let predicate = eventStore.predicateForEvents( withStart: startDate, end: endDate, calendars: calendars ) return eventStore.events(matching: predicate).map { Self.input(from: $0, localization: localization) } } func openSystemCalendar(at date: Date) { Task { @MainActor [weak self] in guard let self else { return } _ = await requestAccess() openSystemCalendarAfterAccessCheck(at: date) } } private func openSystemCalendarAfterAccessCheck(at date: Date) { let script = Self.openingScript(for: date) var error: NSDictionary? NSAppleScript(source: script)?.executeAndReturnError(&error) if error != nil { openCalendarApplication() } } private static func authorization( from status: EKAuthorizationStatus, localization: PluginLocalization ) -> CalendarEventAuthorization { switch status { case .notDetermined: return .notDetermined case .fullAccess: return .fullAccess case .denied: return .denied(localization.string("authorization.denied", defaultValue: "未获得日历访问权限")) case .restricted: return .denied(localization.string("authorization.restricted", defaultValue: "系统限制了日历访问")) case .writeOnly: return .denied(localization.string("authorization.writeOnly", defaultValue: "仅允许写入日历,无法读取日程")) @unknown default: return .denied(localization.string("authorization.unknown", defaultValue: "当前系统不允许读取日历")) } } private static func input(from event: EKEvent, localization: PluginLocalization) -> CalendarEventInput { CalendarEventInput( id: event.eventIdentifier ?? event.calendarItemIdentifier, title: event.title ?? localization.string("event.untitled", defaultValue: "未命名日程"), startDate: event.startDate, endDate: event.endDate, isAllDay: event.isAllDay, color: CalendarEventColor(nsColor: event.calendar.color) ) } static func openingScript(for date: Date) -> String { openingScript(for: date, dateFormatter: appleScriptDateFormatter()) } static func openingScript(for date: Date, dateFormatter: DateFormatter) -> String { let dateText = appleScriptEscapedString(dateFormatter.string(from: date)) return """ tell application "Calendar" activate switch view to day view view calendar at date "\(dateText)" end tell """ } private static func appleScriptDateFormatter() -> DateFormatter { let calendar = CalendarComponentCalendars.gregorianFollowingSystem() let formatter = DateFormatter() formatter.calendar = calendar formatter.locale = .autoupdatingCurrent formatter.timeZone = calendar.timeZone formatter.dateStyle = .full formatter.timeStyle = .none return formatter } private static func appleScriptEscapedString(_ value: String) -> String { value .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") } private func openCalendarApplication() { let calendarURL = URL(fileURLWithPath: "/System/Applications/Calendar.app") let configuration = NSWorkspace.OpenConfiguration() configuration.activates = true NSWorkspace.shared.openApplication(at: calendarURL, configuration: configuration) { runningApplication, error in if error != nil { NSWorkspace.shared.open(calendarURL) Task { @MainActor in Self.activateRunningCalendarApplication() } return } runningApplication?.activate(options: [.activateAllWindows]) } } private static func activateRunningCalendarApplication() { NSRunningApplication .runningApplications(withBundleIdentifier: "com.apple.iCal") .first? .activate(options: [.activateAllWindows]) } } private extension CalendarEventColor { init(nsColor: NSColor?) { guard let color = nsColor?.usingColorSpace(.deviceRGB) else { self = .accent return } self.init( red: Double(color.redComponent), green: Double(color.greenComponent), blue: Double(color.blueComponent), alpha: Double(color.alphaComponent) ) } }