Files
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

561 lines
20 KiB
Swift

import Foundation
import Capacitor
import EventKit
import UIKit
@objc(AppleCalendarPlugin)
public class AppleCalendarPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "AppleCalendarPlugin"
public let jsName = "AppleCalendar"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "checkPermissions", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "requestPermissions", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "listCalendars", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "listEvents", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "createEvent", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "updateEvent", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "deleteEvent", returnType: CAPPluginReturnPromise),
]
private let eventStore = EKEventStore()
private let maxTitleLength = 512
private let maxDescriptionLength = 20000
private let maxLocationLength = 1024
private let unsupportedRecurrenceFields = [
"recurrence",
"recurrenceRule",
"recurrenceRules",
"rrule",
]
private lazy var isoWithFractionalSeconds: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
private lazy var isoWithoutFractionalSeconds: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
return formatter
}()
@objc public override func checkPermissions(_ call: CAPPluginCall) {
call.resolve(permissionResult())
}
@objc public override func requestPermissions(_ call: CAPPluginCall) {
let status = EKEventStore.authorizationStatus(for: .event)
if isGranted(status) || isDeniedOrRestricted(status) {
call.resolve(permissionResult())
return
}
if #available(iOS 17.0, *) {
eventStore.requestFullAccessToEvents { [weak self] _, error in
DispatchQueue.main.async {
var result = self?.permissionResult() ?? [
"calendar": "restricted",
"canRequest": false,
]
if let error {
result["reason"] = error.localizedDescription
}
call.resolve(result)
}
}
} else {
eventStore.requestAccess(to: .event) { [weak self] _, error in
DispatchQueue.main.async {
var result = self?.permissionResult() ?? [
"calendar": "restricted",
"canRequest": false,
]
if let error {
result["reason"] = error.localizedDescription
}
call.resolve(result)
}
}
}
}
@objc func listCalendars(_ call: CAPPluginCall) {
guard hasFullAccess() else {
call.resolve(permissionError())
return
}
let defaultCalendar = eventStore.defaultCalendarForNewEvents
let calendars = eventStore.calendars(for: .event).map {
calendarJson($0, defaultCalendar: defaultCalendar)
}
call.resolve(["ok": true, "calendars": calendars])
}
@objc func listEvents(_ call: CAPPluginCall) {
guard hasFullAccess() else {
call.resolve(permissionError())
return
}
guard let timeMin = parseDate(call.getString("timeMin") ?? ""),
let timeMax = parseDate(call.getString("timeMax") ?? ""),
timeMax > timeMin
else {
call.resolve(nativeError("Calendar event window is invalid."))
return
}
let requestedCalendarId = (call.getString("calendarId") ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
var calendars: [EKCalendar]? = nil
if !requestedCalendarId.isEmpty && requestedCalendarId != "all" {
guard let calendar = calendar(withIdentifier: requestedCalendarId, requireWritable: false) else {
call.resolve([
"ok": false,
"error": "not_found",
"message": "Apple Calendar was not found.",
])
return
}
calendars = [calendar]
}
let predicate = eventStore.predicateForEvents(
withStart: timeMin,
end: timeMax,
calendars: calendars
)
let events = eventStore.events(matching: predicate)
.sorted { $0.startDate < $1.startDate }
.map(eventJson)
call.resolve(["ok": true, "events": events])
}
@objc func createEvent(_ call: CAPPluginCall) {
guard hasFullAccess() else {
call.resolve(permissionError())
return
}
let event = EKEvent(eventStore: eventStore)
if let error = applyEventPayload(call, to: event, requireTitle: true) {
call.resolve(error)
return
}
do {
try eventStore.save(event, span: .thisEvent, commit: true)
call.resolve(["ok": true, "event": eventJson(event)])
} catch {
call.resolve(nativeError("Failed to create Apple Calendar event: \(error.localizedDescription)"))
}
}
@objc func updateEvent(_ call: CAPPluginCall) {
guard hasFullAccess() else {
call.resolve(permissionError())
return
}
guard let eventId = nonEmptyString(call.getString("eventId")) else {
call.resolve(nativeError("Calendar event id is required."))
return
}
guard let item = eventStore.calendarItem(withIdentifier: eventId) as? EKEvent else {
call.resolve([
"ok": false,
"error": "not_found",
"message": "Apple Calendar event was not found.",
])
return
}
guard item.calendar.allowsContentModifications else {
call.resolve(nativeError("Apple Calendar event is not writable."))
return
}
if let error = applyEventPayload(call, to: item, requireTitle: false) {
call.resolve(error)
return
}
do {
try eventStore.save(item, span: .thisEvent, commit: true)
call.resolve(["ok": true, "event": eventJson(item)])
} catch {
call.resolve(nativeError("Failed to update Apple Calendar event: \(error.localizedDescription)"))
}
}
@objc func deleteEvent(_ call: CAPPluginCall) {
guard hasFullAccess() else {
call.resolve(permissionError())
return
}
guard let eventId = nonEmptyString(call.getString("eventId")) else {
call.resolve(nativeError("Calendar event id is required."))
return
}
guard let event = eventStore.calendarItem(withIdentifier: eventId) as? EKEvent else {
call.resolve([
"ok": false,
"error": "not_found",
"message": "Apple Calendar event was not found.",
])
return
}
do {
try eventStore.remove(event, span: .thisEvent, commit: true)
call.resolve(["ok": true])
} catch {
call.resolve(nativeError("Failed to delete Apple Calendar event: \(error.localizedDescription)"))
}
}
private func permissionResult() -> [String: Any] {
let status = EKEventStore.authorizationStatus(for: .event)
return [
"calendar": permissionString(status),
"canRequest": permissionString(status) == "prompt",
"reason": NSNull(),
]
}
private func permissionString(_ status: EKAuthorizationStatus) -> String {
if isGranted(status) {
return "granted"
}
if isDenied(status) {
return "denied"
}
if isRestricted(status) {
return "restricted"
}
return "prompt"
}
private func hasFullAccess() -> Bool {
isGranted(EKEventStore.authorizationStatus(for: .event))
}
private func isGranted(_ status: EKAuthorizationStatus) -> Bool {
if #available(iOS 17.0, *) {
if status == .fullAccess {
return true
}
if status == .writeOnly {
return false
}
}
return status == .authorized
}
private func isDenied(_ status: EKAuthorizationStatus) -> Bool {
status == .denied
}
private func isRestricted(_ status: EKAuthorizationStatus) -> Bool {
if #available(iOS 17.0, *), status == .writeOnly {
return true
}
return status == .restricted
}
private func isDeniedOrRestricted(_ status: EKAuthorizationStatus) -> Bool {
isDenied(status) || isRestricted(status)
}
private func permissionError() -> [String: Any] {
[
"ok": false,
"error": "permission",
"message": "Apple Calendar access has not been granted.",
]
}
private func nativeError(_ message: String) -> [String: Any] {
[
"ok": false,
"error": "native_error",
"message": message,
]
}
private func nonEmptyString(_ value: String?) -> String? {
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty
else {
return nil
}
return trimmed
}
private func parseDate(_ value: String) -> Date? {
guard !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return nil
}
if let date = isoWithFractionalSeconds.date(from: value) {
return date
}
return isoWithoutFractionalSeconds.date(from: value)
}
private func textValue(
_ call: CAPPluginCall,
key: String,
maxLength: Int,
required: Bool
) -> (value: String?, error: [String: Any]?) {
let rawValue = call.options[key]
if rawValue == nil || rawValue is NSNull {
if required {
return (nil, nativeError("Calendar event \(key) is required."))
}
return (nil, nil)
}
guard rawValue is String else {
return (nil, nativeError("Calendar event \(key) must be a string."))
}
guard let value = call.getString(key) else {
if required {
return (nil, nativeError("Calendar event \(key) is required."))
}
return (nil, nil)
}
guard value.count <= maxLength else {
return (nil, nativeError("Calendar event \(key) is too long."))
}
return (value, nil)
}
private func isoString(_ date: Date?) -> String {
guard let date else { return "" }
return isoWithFractionalSeconds.string(from: date)
}
private func calendar(withIdentifier identifier: String, requireWritable: Bool) -> EKCalendar? {
if identifier.isEmpty || identifier == "primary" {
if let calendar = eventStore.defaultCalendarForNewEvents,
!requireWritable || calendar.allowsContentModifications
{
return calendar
}
if requireWritable {
return eventStore.calendars(for: .event)
.first(where: { $0.allowsContentModifications })
}
return eventStore.defaultCalendarForNewEvents
}
guard let calendar = eventStore.calendars(for: .event)
.first(where: { $0.calendarIdentifier == identifier })
else {
return nil
}
if requireWritable && !calendar.allowsContentModifications {
return nil
}
return calendar
}
private func calendarJson(_ calendar: EKCalendar, defaultCalendar: EKCalendar?) -> [String: Any] {
let color = UIColor(cgColor: calendar.cgColor)
let components = color.resolvedColor(with: UITraitCollection.current).cgColor.components ?? []
let red = components.indices.contains(0) ? components[0] : 0
let green = components.indices.contains(1) ? components[1] : red
let blue = components.indices.contains(2) ? components[2] : red
let hex = String(
format: "#%02X%02X%02X",
Int(max(0, min(1, red)) * 255),
Int(max(0, min(1, green)) * 255),
Int(max(0, min(1, blue)) * 255)
)
return [
"calendarId": calendar.calendarIdentifier,
"summary": calendar.title,
"description": calendar.source.title,
"primary": calendar.calendarIdentifier == defaultCalendar?.calendarIdentifier,
"accessRole": calendar.allowsContentModifications ? "writer" : "reader",
"backgroundColor": hex,
"foregroundColor": NSNull(),
"timeZone": TimeZone.current.identifier,
"selected": true,
]
}
private func participantEmail(_ participant: EKParticipant) -> String? {
guard participant.url.scheme?.lowercased() == "mailto" else {
return nil
}
let raw = participant.url.absoluteString
let prefix = "mailto:"
guard raw.lowercased().hasPrefix(prefix) else {
return nil
}
let address = String(raw.dropFirst(prefix.count))
return address.removingPercentEncoding ?? address
}
private func participantStatus(_ status: EKParticipantStatus) -> String {
switch status {
case .unknown: return "unknown"
case .pending: return "pending"
case .accepted: return "accepted"
case .declined: return "declined"
case .tentative: return "tentative"
case .delegated: return "delegated"
case .completed: return "completed"
case .inProcess: return "in_process"
@unknown default: return "unknown"
}
}
private func participantJson(_ participant: EKParticipant) -> [String: Any] {
[
"email": participantEmail(participant) ?? NSNull(),
"displayName": participant.name ?? NSNull(),
"responseStatus": participantStatus(participant.participantStatus),
"self": participant.isCurrentUser,
"organizer": participant.participantRole == .chair,
"optional": participant.participantRole == .optional,
]
}
private func eventStatus(_ status: EKEventStatus) -> String {
switch status {
case .none: return "none"
case .confirmed: return "confirmed"
case .tentative: return "tentative"
case .canceled: return "cancelled"
@unknown default: return "unknown"
}
}
private func eventJson(_ event: EKEvent) -> [String: Any] {
let identifier = event.calendarItemIdentifier
return [
"id": identifier,
"externalId": identifier,
"calendarId": event.calendar.calendarIdentifier,
"calendarSummary": event.calendar.title,
"title": event.title?.isEmpty == false ? event.title as Any : "(untitled)",
"description": event.notes ?? "",
"location": event.location ?? "",
"status": eventStatus(event.status),
"startAt": isoString(event.startDate),
"endAt": isoString(event.endDate),
"isAllDay": event.isAllDay,
"timezone": event.timeZone?.identifier ?? NSNull(),
"htmlLink": NSNull(),
"conferenceLink": NSNull(),
"organizer": event.organizer.map(participantJson) ?? NSNull(),
"attendees": event.attendees?.map(participantJson) ?? [],
]
}
private func applyEventPayload(
_ call: CAPPluginCall,
to event: EKEvent,
requireTitle: Bool
) -> [String: Any]? {
for key in unsupportedRecurrenceFields where call.options.keys.contains(key) {
return [
"ok": false,
"error": "unsupported_feature",
"message": "Apple Calendar recurrence editing is not supported by this bridge.",
]
}
if call.options.keys.contains("attendees") {
guard let attendees = call.options["attendees"] as? [Any] else {
return nativeError("Calendar event attendees must be an array.")
}
if !attendees.isEmpty {
return [
"ok": false,
"error": "unsupported_feature",
"message": "Apple Calendar does not allow this app to create or edit event invitees through EventKit. Remove attendees or use Google Calendar for invited meetings.",
]
}
}
if call.options.keys.contains("title") || requireTitle {
let titleResult = textValue(
call,
key: "title",
maxLength: maxTitleLength,
required: true
)
if let error = titleResult.error {
return error
}
guard let title = nonEmptyString(titleResult.value) else {
return nativeError("Calendar event title is required.")
}
event.title = title
}
if call.options.keys.contains("description") {
let descriptionResult = textValue(
call,
key: "description",
maxLength: maxDescriptionLength,
required: false
)
if let error = descriptionResult.error {
return error
}
event.notes = descriptionResult.value
}
if call.options.keys.contains("location") {
let locationResult = textValue(
call,
key: "location",
maxLength: maxLocationLength,
required: false
)
if let error = locationResult.error {
return error
}
event.location = locationResult.value
}
if call.options.keys.contains("timeZone") {
guard let timeZoneName = nonEmptyString(call.getString("timeZone")) else {
return nativeError("Calendar event timeZone is invalid.")
}
guard let timeZone = TimeZone(identifier: timeZoneName) else {
return nativeError("Calendar event timeZone is invalid.")
}
event.timeZone = timeZone
}
if call.options.keys.contains("calendarId") {
guard let calendar = calendar(
withIdentifier: call.getString("calendarId") ?? "",
requireWritable: true
) else {
return nativeError("The selected Apple Calendar is not writable or was not found.")
}
event.calendar = calendar
}
if call.options.keys.contains("isAllDay") {
event.isAllDay = call.getBool("isAllDay") ?? false
}
if call.options.keys.contains("startAt") {
guard let start = parseDate(call.getString("startAt") ?? "") else {
return nativeError("Calendar event startAt is invalid.")
}
event.startDate = start
}
if call.options.keys.contains("endAt") {
guard let end = parseDate(call.getString("endAt") ?? "") else {
return nativeError("Calendar event endAt is invalid.")
}
event.endDate = end
}
guard event.startDate != nil, event.endDate != nil else {
return nativeError("Calendar event startAt and endAt are required.")
}
guard event.endDate > event.startDate else {
return nativeError("Calendar event endAt must be later than startAt.")
}
if event.calendar == nil {
guard let calendar = calendar(withIdentifier: "primary", requireWritable: true) else {
return nativeError("No writable Apple Calendar is available.")
}
event.calendar = calendar
}
return nil
}
}