diff --git a/Cargo.lock b/Cargo.lock index e21ba4b7c1..30d251b3d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14703,6 +14703,7 @@ dependencies = [ "itertools 0.14.0", "objc2 0.6.3", "objc2-contacts", + "objc2-core-graphics", "objc2-event-kit", "objc2-foundation 0.3.2", "serde", @@ -15282,6 +15283,7 @@ dependencies = [ "tauri-specta", "tcc", "thiserror 2.0.17", + "tokio", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2f39b6e8bb..181234072c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -240,6 +240,7 @@ objc2-application-services = "0.3" objc2-av-foundation = "0.3" objc2-contacts = "0.3" objc2-core-foundation = "0.3" +objc2-core-graphics = "0.3" objc2-event-kit = "0.3" objc2-foundation = "0.3" objc2-user-notifications = "0.3" diff --git a/apps/desktop/src/components/devtool/seed/shared/calendar.ts b/apps/desktop/src/components/devtool/seed/shared/calendar.ts index 842cd565df..034d05199d 100644 --- a/apps/desktop/src/components/devtool/seed/shared/calendar.ts +++ b/apps/desktop/src/components/devtool/seed/shared/calendar.ts @@ -18,11 +18,23 @@ export const createCalendar = () => { "Shared Calendar", ]); + const source = faker.helpers.arrayElement([ + "iCloud", + "Exchange", + "Google", + "Local", + "Subscribed", + ]); + return { id: id(), data: { user_id: DEFAULT_USER_ID, + tracking_id: id(), name: template, + source, + provider: "apple", + enabled: 1, created_at: faker.date.past({ years: 1 }).toISOString(), } satisfies Calendar, }; diff --git a/apps/desktop/src/components/main/sidebar/timeline/index.tsx b/apps/desktop/src/components/main/sidebar/timeline/index.tsx index e29307f885..5d7dc9ce9e 100644 --- a/apps/desktop/src/components/main/sidebar/timeline/index.tsx +++ b/apps/desktop/src/components/main/sidebar/timeline/index.tsx @@ -1,6 +1,7 @@ import { startOfDay } from "date-fns"; -import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; +import { ChevronDownIcon, ChevronUpIcon, RefreshCwIcon } from "lucide-react"; import { type ReactNode, useMemo } from "react"; +import { useScheduleTaskRunCallback } from "tinytick/ui-react"; import { Button } from "@hypr/ui/components/ui/button"; import { cn } from "@hypr/utils"; @@ -18,6 +19,8 @@ import { useAnchor, useAutoScrollToAnchor } from "./anchor"; import { TimelineItemComponent } from "./item"; import { CurrentTimeIndicator, useCurrentTimeMs } from "./realtime"; +const CALENDAR_FETCH_TASK_ID = "fetchAppleCalendarChunk"; + export function TimelineView() { const buckets = useTimelineData(); const hasToday = useMemo( @@ -27,6 +30,15 @@ export function TimelineView() { const currentTab = useTabs((state) => state.currentTab); const store = main.UI.useStore(main.STORE_ID); + const calendars = main.UI.useTable("calendars", main.STORE_ID); + + const hasEnabledCalendar = useMemo(() => { + return Object.values(calendars).some((cal) => cal.enabled === 1); + }, [calendars]); + + const triggerCalendarSync = useScheduleTaskRunCallback( + CALENDAR_FETCH_TASK_ID, + ); const selectedSessionId = useMemo(() => { return currentTab?.type === "sessions" ? currentTab.id : undefined; @@ -85,13 +97,10 @@ export function TimelineView() { }, [buckets, hasToday, todayTimestamp]); return ( -
+
{buckets.map((bucket, index) => { const isToday = bucket.label === "Today"; @@ -103,12 +112,21 @@ export function TimelineView() { {shouldRenderIndicatorBefore && ( )} -
+
{bucket.label}
+ {isToday && hasEnabledCalendar && ( + + )}
{isToday ? ( p.platform === "all" || (p.platform === "macos" && isMacos), - ); - - return ( -
-

Configure Providers

- - {visibleProviders.map((provider) => - provider.id === "apple" ? ( - - ) : ( - - ), - )} - -
- ); -} +import * as main from "../../../../store/tinybase/main"; +import { PROVIDERS } from "../shared"; +import { CalendarGroup, CalendarItem, CalendarSelection } from "./shared"; function useAccessPermission(config: { queryKey: string; @@ -140,7 +122,116 @@ function AccessPermissionRow({ ); } -function AppleCalendarProviderCard() { +function appleColorToCss(color?: AppleCalendar["color"]): string | undefined { + if (!color) return undefined; + return `rgba(${Math.round(color.red * 255)}, ${Math.round(color.green * 255)}, ${Math.round(color.blue * 255)}, ${color.alpha})`; +} + +function useAppleCalendarSelection() { + const queryClient = useQueryClient(); + const store = main.UI.useStore(main.STORE_ID); + const { user_id } = main.UI.useValues(main.STORE_ID); + const storedCalendars = main.UI.useTable("calendars", main.STORE_ID); + + const { data: appleCalendars, isLoading } = useQuery({ + queryKey: ["appleCalendars"], + queryFn: async () => { + const result = await appleCalendarCommands.listCalendars(); + if (result.status === "ok") { + return result.data; + } + throw new Error(result.error); + }, + }); + + const groups = useMemo((): CalendarGroup[] => { + if (!appleCalendars) return []; + + const grouped = new Map(); + for (const cal of appleCalendars) { + const sourceTitle = cal.source.title; + if (!grouped.has(sourceTitle)) { + grouped.set(sourceTitle, []); + } + grouped.get(sourceTitle)!.push({ + id: cal.id, + title: cal.title, + color: appleColorToCss(cal.color), + }); + } + + return Array.from(grouped.entries()).map(([sourceName, calendars]) => ({ + sourceName, + calendars, + })); + }, [appleCalendars]); + + const getCalendarRowId = (trackingId: string): string | undefined => { + for (const [rowId, data] of Object.entries(storedCalendars)) { + if (data.tracking_id === trackingId) { + return rowId; + } + } + return undefined; + }; + + const isCalendarEnabled = (trackingId: string): boolean => { + for (const data of Object.values(storedCalendars)) { + if (data.tracking_id === trackingId) { + return data.enabled === 1; + } + } + return false; + }; + + const handleToggle = (calendar: CalendarItem, enabled: boolean) => { + if (!store || !user_id) return; + + const appleCalendar = appleCalendars?.find((c) => c.id === calendar.id); + if (!appleCalendar) return; + + const existingRowId = getCalendarRowId(calendar.id); + + if (existingRowId) { + store.setPartialRow("calendars", existingRowId, { + enabled: enabled ? 1 : 0, + }); + } else { + const newRowId = crypto.randomUUID(); + store.setRow("calendars", newRowId, { + user_id, + created_at: new Date().toISOString(), + tracking_id: calendar.id, + name: calendar.title, + source: appleCalendar.source.title, + provider: "apple", + enabled: enabled ? 1 : 0, + }); + } + }; + + const handleRefresh = () => { + queryClient.invalidateQueries({ queryKey: ["appleCalendars"] }); + }; + + return { groups, isLoading, isCalendarEnabled, handleToggle, handleRefresh }; +} + +function AppleCalendarSelection() { + const { groups, isCalendarEnabled, handleToggle, handleRefresh } = + useAppleCalendarSelection(); + + return ( + + ); +} + +export function AppleCalendarProviderCard() { const config = PROVIDERS.find((p) => p.id === "apple")!; const calendar = useAccessPermission({ @@ -187,38 +278,8 @@ function AppleCalendarProviderCard() { onAction={contacts.handleAction} />
+ {calendar.isAuthorized && } ); } - -function DisabledProviderCard({ - config, -}: { - config: (typeof PROVIDERS)[number]; -}) { - return ( - - -
- {config.icon} - {config.displayName} - {config.badge && ( - - {config.badge} - - )} -
-
-
- ); -} diff --git a/apps/desktop/src/components/settings/calendar/configure/cloud.tsx b/apps/desktop/src/components/settings/calendar/configure/cloud.tsx new file mode 100644 index 0000000000..f8d99a860f --- /dev/null +++ b/apps/desktop/src/components/settings/calendar/configure/cloud.tsx @@ -0,0 +1,38 @@ +import { + AccordionItem, + AccordionTrigger, +} from "@hypr/ui/components/ui/accordion"; +import { cn } from "@hypr/utils"; + +import { PROVIDERS } from "../shared"; + +export function DisabledProviderCard({ + config, +}: { + config: (typeof PROVIDERS)[number]; +}) { + return ( + + +
+ {config.icon} + {config.displayName} + {config.badge && ( + + {config.badge} + + )} +
+
+
+ ); +} diff --git a/apps/desktop/src/components/settings/calendar/configure/index.tsx b/apps/desktop/src/components/settings/calendar/configure/index.tsx new file mode 100644 index 0000000000..2eea8194bb --- /dev/null +++ b/apps/desktop/src/components/settings/calendar/configure/index.tsx @@ -0,0 +1,29 @@ +import { Accordion } from "@hypr/ui/components/ui/accordion"; + +import { useIsMacos } from "../../../../hooks/usePlatform"; +import { PROVIDERS } from "../shared"; +import { AppleCalendarProviderCard } from "./apple"; +import { DisabledProviderCard } from "./cloud"; + +export function ConfigureProviders() { + const isMacos = useIsMacos(); + + const visibleProviders = PROVIDERS.filter( + (p) => p.platform === "all" || (p.platform === "macos" && isMacos), + ); + + return ( +
+

Configure Providers

+ + {visibleProviders.map((provider) => + provider.id === "apple" ? ( + + ) : ( + + ), + )} + +
+ ); +} diff --git a/apps/desktop/src/components/settings/calendar/configure/shared.tsx b/apps/desktop/src/components/settings/calendar/configure/shared.tsx new file mode 100644 index 0000000000..9412de941f --- /dev/null +++ b/apps/desktop/src/components/settings/calendar/configure/shared.tsx @@ -0,0 +1,96 @@ +import { RefreshCwIcon } from "lucide-react"; + +import { Button } from "@hypr/ui/components/ui/button"; +import { Switch } from "@hypr/ui/components/ui/switch"; + +export interface CalendarItem { + id: string; + title: string; + color?: string; +} + +export interface CalendarGroup { + sourceName: string; + calendars: CalendarItem[]; +} + +interface CalendarSelectionProps { + groups: CalendarGroup[]; + isCalendarEnabled: (id: string) => boolean; + onToggle: (calendar: CalendarItem, enabled: boolean) => void; + onRefresh: () => void; +} + +export function CalendarSelection({ + groups, + isCalendarEnabled, + onToggle, + onRefresh, +}: CalendarSelectionProps) { + if (groups.length === 0) { + return ( +
+ No calendars found +
+ ); + } + + return ( +
+
+

Select Calendars

+ +
+
+ {groups.map((group) => ( +
+
+ {group.sourceName} +
+
+ {group.calendars.map((cal) => ( + onToggle(cal, enabled)} + /> + ))} +
+
+ ))} +
+
+ ); +} + +function CalendarToggleRow({ + calendar, + enabled, + onToggle, +}: { + calendar: CalendarItem; + enabled: boolean; + onToggle: (enabled: boolean) => void; +}) { + return ( +
+
+
+ {calendar.title} +
+ +
+ ); +} diff --git a/apps/desktop/src/components/task-manager.tsx b/apps/desktop/src/components/task-manager.tsx deleted file mode 100644 index ce94bc5f2f..0000000000 --- a/apps/desktop/src/components/task-manager.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { useScheduleTaskRun, useSetTask } from "tinytick/ui-react"; - -import { checkForUpdate } from "./main/sidebar/profile/ota/task"; - -const UPDATE_CHECK_TASK_ID = "checkForUpdate"; -const UPDATE_CHECK_INTERVAL = 30 * 1000; - -export function TaskManager() { - useSetTask(UPDATE_CHECK_TASK_ID, async () => { - await checkForUpdate(); - }); - - useScheduleTaskRun(UPDATE_CHECK_TASK_ID, undefined, 0, { - repeatDelay: UPDATE_CHECK_INTERVAL, - }); - - return null; -} diff --git a/apps/desktop/src/components/tasks/calendar-sync/index.ts b/apps/desktop/src/components/tasks/calendar-sync/index.ts new file mode 100644 index 0000000000..a780fc32dc --- /dev/null +++ b/apps/desktop/src/components/tasks/calendar-sync/index.ts @@ -0,0 +1,192 @@ +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo } from "react"; +import { + useManager, + useScheduleTaskRun, + useScheduleTaskRunCallback, + useSetTask, +} from "tinytick/ui-react"; + +import { + commands as appleCalendarCommands, + events as appleCalendarEvents, +} from "@hypr/plugin-apple-calendar"; +import { commands as permissionsCommands } from "@hypr/plugin-permissions"; + +import type { ChunkArg } from "./types"; +import { + generateCalendarChunks, + getEnabledAppleCalendarMap, + storeEvents, +} from "./utils"; + +const TASK_ID = "fetchAppleCalendarChunk"; +const SYNC_INTERVAL = 5 * 60 * 1000; + +export type CalendarStore = { + getTable: (tableId: string) => Record>; + setRow: ( + tableId: string, + rowId: string, + row: Record, + ) => unknown; +}; + +export function useCalendarSyncTask( + store: CalendarStore | undefined, + userId: string | undefined, + calendars: Record>, +) { + const manager = useManager(); + + const enabledAppleCalendars = useMemo( + () => getEnabledAppleCalendarMap(calendars), + [calendars], + ); + + const hasEnabledAppleCalendar = Object.keys(enabledAppleCalendars).length > 0; + + const calendarPermission = useQuery({ + queryKey: ["calendarPermissionForSync"], + queryFn: async () => { + const result = await permissionsCommands.checkCalendarPermission(); + return result.status === "ok" ? result.data : "denied"; + }, + refetchInterval: 10000, + enabled: hasEnabledAppleCalendar, + }); + + const hasCalendarPermission = calendarPermission.data === "authorized"; + const shouldSync = hasEnabledAppleCalendar && hasCalendarPermission; + + useSetTask( + TASK_ID, + async (arg) => { + const permCheck = await permissionsCommands.checkCalendarPermission(); + if (permCheck.status !== "ok" || permCheck.data !== "authorized") { + return; + } + + if (!store || !userId) { + return; + } + + const currentCalendars = store.getTable("calendars"); + const calendarMap = getEnabledAppleCalendarMap(currentCalendars); + + if (Object.keys(calendarMap).length === 0) { + return; + } + + const calendarTrackingIds = Object.keys(calendarMap); + + const { + chunks, + calendarTrackingIds: storedCalendarIds, + currentChunkIndex, + currentCalendarIndex, + }: ChunkArg = arg + ? JSON.parse(arg) + : { + chunks: generateCalendarChunks(), + calendarTrackingIds, + currentChunkIndex: 0, + currentCalendarIndex: 0, + }; + + const chunk = chunks[currentChunkIndex]; + const calendarTrackingId = storedCalendarIds[currentCalendarIndex]; + + if (!chunk || !calendarTrackingId) { + return; + } + + const result = await appleCalendarCommands.listEvents({ + from: chunk.from, + to: chunk.to, + calendar_tracking_id: calendarTrackingId, + }); + + if (result.status === "ok") { + storeEvents(store, result.data, calendarMap, userId); + console.log( + `[Calendar] Stored ${result.data.length} events for calendar ${currentCalendarIndex + 1}/${storedCalendarIds.length}, chunk ${currentChunkIndex + 1}/${chunks.length}`, + ); + } + + scheduleNextChunk(manager, { + chunks, + storedCalendarIds, + currentChunkIndex, + currentCalendarIndex, + }); + }, + [manager, store, userId], + ); + + useScheduleTaskRun( + shouldSync ? TASK_ID : "", + undefined, + 0, + { repeatDelay: SYNC_INTERVAL }, + [shouldSync], + ); + + const triggerCalendarSync = useScheduleTaskRunCallback(TASK_ID); + + useEffect(() => { + if (!shouldSync) { + return; + } + + const unlisten = appleCalendarEvents.calendarChangedEvent.listen(() => { + console.log("[Calendar] Change detected, triggering sync"); + triggerCalendarSync(); + }); + + return () => { + unlisten.then((fn) => fn()); + }; + }, [shouldSync, triggerCalendarSync]); +} + +function scheduleNextChunk( + manager: ReturnType, + params: { + chunks: ChunkArg["chunks"]; + storedCalendarIds: string[]; + currentChunkIndex: number; + currentCalendarIndex: number; + }, +) { + if (!manager) return; + + const { chunks, storedCalendarIds, currentChunkIndex, currentCalendarIndex } = + params; + const nextCalendarIndex = currentCalendarIndex + 1; + const nextChunkIndex = currentChunkIndex + 1; + + if (nextCalendarIndex < storedCalendarIds.length) { + manager.scheduleTaskRun( + TASK_ID, + JSON.stringify({ + chunks, + calendarTrackingIds: storedCalendarIds, + currentChunkIndex, + currentCalendarIndex: nextCalendarIndex, + }), + 100, + ); + } else if (nextChunkIndex < chunks.length) { + manager.scheduleTaskRun( + TASK_ID, + JSON.stringify({ + chunks, + calendarTrackingIds: storedCalendarIds, + currentChunkIndex: nextChunkIndex, + currentCalendarIndex: 0, + }), + 100, + ); + } +} diff --git a/apps/desktop/src/components/tasks/calendar-sync/types.ts b/apps/desktop/src/components/tasks/calendar-sync/types.ts new file mode 100644 index 0000000000..6a1c1292b4 --- /dev/null +++ b/apps/desktop/src/components/tasks/calendar-sync/types.ts @@ -0,0 +1,6 @@ +export type ChunkArg = { + chunks: Array<{ from: string; to: string }>; + calendarTrackingIds: string[]; + currentChunkIndex: number; + currentCalendarIndex: number; +}; diff --git a/apps/desktop/src/components/tasks/calendar-sync/utils.ts b/apps/desktop/src/components/tasks/calendar-sync/utils.ts new file mode 100644 index 0000000000..d29e43bd12 --- /dev/null +++ b/apps/desktop/src/components/tasks/calendar-sync/utils.ts @@ -0,0 +1,76 @@ +import type { AppleEvent } from "@hypr/plugin-apple-calendar"; + +type StoreSetRow = { + setRow: ( + tableId: string, + rowId: string, + row: Record, + ) => unknown; +}; + +function getEventUniqueId(event: AppleEvent): string { + // For recurring events, event_identifier is shared across all occurrences. + // occurrence_date distinguishes specific occurrences. + // Fall back to start_date which is always unique per occurrence. + const occurrenceKey = event.occurrence_date ?? event.start_date; + return `${event.event_identifier}:${occurrenceKey}`; +} + +export function generateCalendarChunks(): Array<{ from: string; to: string }> { + const now = new Date(); + const day = 24 * 60 * 60 * 1000; + + return [ + { + from: new Date(now.getTime() - 7 * day).toISOString(), + to: now.toISOString(), + }, + { + from: now.toISOString(), + to: new Date(now.getTime() + 7 * day).toISOString(), + }, + { + from: new Date(now.getTime() + 7 * day).toISOString(), + to: new Date(now.getTime() + 14 * day).toISOString(), + }, + ]; +} + +export function storeEvents( + store: StoreSetRow, + events: AppleEvent[], + calendarMap: Record, + userId: string, +) { + for (const event of events) { + const calendarId = calendarMap[event.calendar.id]; + if (!calendarId) continue; + + store.setRow("events", getEventUniqueId(event), { + user_id: userId, + created_at: event.creation_date ?? new Date().toISOString(), + calendar_id: calendarId, + title: event.title, + started_at: event.start_date, + ended_at: event.end_date, + location: event.location ?? "", + meeting_link: event.url ?? "", + description: event.notes ?? "", + note: "", + }); + } +} + +export function getEnabledAppleCalendarMap( + calendars: Record>, +): Record { + const result: Record = {}; + + for (const [rowId, data] of Object.entries(calendars)) { + if (data.provider === "apple" && data.enabled === 1 && data.tracking_id) { + result[data.tracking_id as string] = rowId; + } + } + + return result; +} diff --git a/apps/desktop/src/components/tasks/index.tsx b/apps/desktop/src/components/tasks/index.tsx new file mode 100644 index 0000000000..27aae7ba9a --- /dev/null +++ b/apps/desktop/src/components/tasks/index.tsx @@ -0,0 +1,14 @@ +import * as main from "../../store/tinybase/main"; +import { type CalendarStore, useCalendarSyncTask } from "./calendar-sync"; +import { useUpdateCheckTask } from "./update-check"; + +export function TaskManager() { + const store = main.UI.useStore(main.STORE_ID); + const { user_id } = main.UI.useValues(main.STORE_ID); + const calendars = main.UI.useTable("calendars", main.STORE_ID); + + useUpdateCheckTask(); + useCalendarSyncTask(store as CalendarStore | undefined, user_id, calendars); + + return null; +} diff --git a/apps/desktop/src/components/tasks/update-check.ts b/apps/desktop/src/components/tasks/update-check.ts new file mode 100644 index 0000000000..b123338290 --- /dev/null +++ b/apps/desktop/src/components/tasks/update-check.ts @@ -0,0 +1,16 @@ +import { useScheduleTaskRun, useSetTask } from "tinytick/ui-react"; + +import { checkForUpdate } from "../main/sidebar/profile/ota/task"; + +const TASK_ID = "checkForUpdate"; +const INTERVAL = 30 * 1000; + +export function useUpdateCheckTask() { + useSetTask(TASK_ID, async () => { + await checkForUpdate(); + }); + + useScheduleTaskRun(TASK_ID, undefined, 0, { + repeatDelay: INTERVAL, + }); +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index f213b02d77..82ea4dc7f8 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -17,7 +17,7 @@ import { import "@hypr/ui/globals.css"; import { ErrorComponent, NotFoundComponent } from "./components/control"; -import { TaskManager } from "./components/task-manager"; +import { TaskManager } from "./components/tasks"; import { createToolRegistry } from "./contexts/tool-registry/core"; import { env } from "./env"; import { initExtensionGlobals } from "./extension-globals"; diff --git a/apps/desktop/src/store/tinybase/main.ts b/apps/desktop/src/store/tinybase/main.ts index 144468edc0..761304e4ad 100644 --- a/apps/desktop/src/store/tinybase/main.ts +++ b/apps/desktop/src/store/tinybase/main.ts @@ -441,6 +441,18 @@ export const StoreComponent = ({ persist = true }: { persist?: boolean }) => { select("parent_folder_id"); select("created_at"); }) + .setQueryDefinition( + QUERIES.visibleCalendars, + "calendars", + ({ select }) => { + select("tracking_id"); + select("name"); + select("source"); + select("provider"); + select("enabled"); + select("created_at"); + }, + ) .setQueryDefinition( QUERIES.visibleVocabs, "memories", @@ -533,6 +545,12 @@ export const StoreComponent = ({ persist = true }: { persist?: boolean }) => { "speaker_hints", "word_id", ) + .setIndexDefinition( + INDEXES.calendarsBySource, + "calendars", + "source", + "name", + ) .setIndexDefinition( INDEXES.eventsByCalendar, "events", @@ -664,6 +682,7 @@ export const QUERIES = { visibleTemplates: "visibleTemplates", visibleChatShortcuts: "visibleChatShortcuts", visibleFolders: "visibleFolders", + visibleCalendars: "visibleCalendars", visibleVocabs: "visibleVocabs", sessionParticipantsWithDetails: "sessionParticipantsWithDetails", sessionRecordingTimes: "sessionRecordingTimes", @@ -676,6 +695,7 @@ export const METRICS = { }; export const INDEXES = { + calendarsBySource: "calendarsBySource", humansByOrg: "humansByOrg", sessionParticipantsBySession: "sessionParticipantsBySession", foldersByParent: "foldersByParent", diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 05cd3e7625..79e1fb386e 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -204,7 +204,11 @@ export const calendars = pgTable( TABLE_CALENDARS, { ...SHARED, + tracking_id: text("tracking_id").notNull(), name: text("name").notNull(), + source: text("source").notNull(), + provider: text("provider").notNull(), + enabled: integer("enabled").notNull().default(1), }, (table) => createPolicies(TABLE_CALENDARS, table.user_id), ).enableRLS(); diff --git a/packages/store/src/schema-external.ts b/packages/store/src/schema-external.ts index 7469468539..afc0a9fadf 100644 --- a/packages/store/src/schema-external.ts +++ b/packages/store/src/schema-external.ts @@ -44,9 +44,10 @@ export const eventSchema = baseEventSchema.omit({ id: true }).extend({ note: z.preprocess((val) => val ?? undefined, z.string().optional()), }); -export const calendarSchema = baseCalendarSchema - .omit({ id: true }) - .extend({ created_at: z.string() }); +export const calendarSchema = baseCalendarSchema.omit({ id: true }).extend({ + created_at: z.string(), + enabled: z.number(), +}); export const organizationSchema = baseOrganizationSchema .omit({ id: true }) @@ -261,7 +262,11 @@ export const externalTableSchemaForTinybase = { calendars: { user_id: { type: "string" }, created_at: { type: "string" }, + tracking_id: { type: "string" }, name: { type: "string" }, + source: { type: "string" }, + provider: { type: "string" }, + enabled: { type: "number" }, } as const satisfies InferTinyBaseSchema, events: { user_id: { type: "string" }, diff --git a/plugins/apple-calendar/Cargo.toml b/plugins/apple-calendar/Cargo.toml index 5a6e4a614a..2adf2fe887 100644 --- a/plugins/apple-calendar/Cargo.toml +++ b/plugins/apple-calendar/Cargo.toml @@ -33,5 +33,6 @@ tracing = { workspace = true } block2 = { workspace = true } objc2 = { workspace = true } objc2-contacts = { workspace = true, features = ["CNContactStore", "CNContact", "CNLabeledValue", "CNPhoneNumber"] } -objc2-event-kit = { workspace = true, features = ["EKEventStore", "EKCalendarItem", "EKCalendar", "EKParticipant", "EKObject", "EKEvent", "EKSource", "EKTypes", "EKRecurrenceRule", "EKRecurrenceEnd", "EKAlarm", "EKStructuredLocation", "EKRecurrenceDayOfWeek"] } +objc2-core-graphics = { workspace = true } +objc2-event-kit = { workspace = true, features = ["EKEventStore", "EKCalendarItem", "EKCalendar", "EKParticipant", "EKObject", "EKEvent", "EKSource", "EKTypes", "EKRecurrenceRule", "EKRecurrenceEnd", "EKAlarm", "EKStructuredLocation", "EKRecurrenceDayOfWeek", "objc2-core-graphics"] } objc2-foundation = { workspace = true, features = ["NSDate", "NSEnumerator", "NSPredicate", "NSError", "NSNotification"] } diff --git a/plugins/apple-calendar/js/bindings.gen.ts b/plugins/apple-calendar/js/bindings.gen.ts index a3a6657e80..082dec7de7 100644 --- a/plugins/apple-calendar/js/bindings.gen.ts +++ b/plugins/apple-calendar/js/bindings.gen.ts @@ -1,303 +1,139 @@ // @ts-nocheck -/** tauri-specta globals **/ -import { - Channel as TAURI_CHANNEL, - invoke as TAURI_INVOKE, -} from "@tauri-apps/api/core"; -import * as TAURI_API_EVENT from "@tauri-apps/api/event"; -import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow"; // This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually. /** user-defined commands **/ + export const commands = { - async openCalendar(): Promise> { +async openCalendar() : Promise> { try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:apple-calendar|open_calendar"), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async listCalendars(): Promise> { + return { status: "ok", data: await TAURI_INVOKE("plugin:apple-calendar|open_calendar") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async listCalendars() : Promise> { try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:apple-calendar|list_calendars"), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, - async listEvents(filter: EventFilter): Promise> { + return { status: "ok", data: await TAURI_INVOKE("plugin:apple-calendar|list_calendars") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async listEvents(filter: EventFilter) : Promise> { try { - return { - status: "ok", - data: await TAURI_INVOKE("plugin:apple-calendar|list_events", { - filter, - }), - }; - } catch (e) { - if (e instanceof Error) throw e; - else return { status: "error", error: e as any }; - } - }, -}; + return { status: "ok", data: await TAURI_INVOKE("plugin:apple-calendar|list_events", { filter }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +} +} /** user-defined events **/ + export const events = __makeEvents__<{ - calendarChangedEvent: CalendarChangedEvent; +calendarChangedEvent: CalendarChangedEvent }>({ - calendarChangedEvent: "plugin:apple-calendar:calendar-changed-event", -}); +calendarChangedEvent: "plugin:apple-calendar:calendar-changed-event" +}) /** user-defined constants **/ + + /** user-defined types **/ -export type Alarm = { - absolute_date: string | null; - relative_offset: number | null; - proximity: AlarmProximity | null; - alarm_type: AlarmType | null; - email_address: string | null; - sound_name: string | null; - url: string | null; - structured_location: StructuredLocation | null; -}; -export type AlarmProximity = "None" | "Enter" | "Leave"; -export type AlarmType = "Display" | "Audio" | "Procedure" | "Email"; -export type AppleCalendar = { - id: string; - title: string; - calendar_type: CalendarType; - color: CalendarColor | null; - allows_content_modifications: boolean; - is_immutable: boolean; - is_subscribed: boolean; - supported_event_availabilities: EventAvailability[]; - allowed_entity_types: CalendarEntityType[]; - source: CalendarSource; -}; -export type AppleEvent = { - event_identifier: string; - calendar_item_identifier: string; - external_identifier: string; - calendar: CalendarRef; - title: string; - location: string | null; - url: string | null; - notes: string | null; - creation_date: string | null; - last_modified_date: string | null; - time_zone: string | null; - start_date: string; - end_date: string; - is_all_day: boolean; - availability: EventAvailability; - status: EventStatus; - has_alarms: boolean; - has_attendees: boolean; - has_notes: boolean; - has_recurrence_rules: boolean; - organizer: Participant | null; - attendees: Participant[]; - structured_location: StructuredLocation | null; - recurrence: RecurrenceInfo | null; - occurrence_date: string | null; - is_detached: boolean; - alarms: Alarm[]; - birthday_contact_identifier: string | null; - is_birthday: boolean; -}; -export type CalendarChangedEvent = null; -export type CalendarColor = { - red: number; - green: number; - blue: number; - alpha: number; -}; -export type CalendarEntityType = "Event" | "Reminder"; -export type CalendarRef = { id: string; title: string }; -export type CalendarSource = { - identifier: string; - title: string; - source_type: CalendarSourceType; -}; -export type CalendarSourceType = - | "Local" - | "Exchange" - | "CalDav" - | "MobileMe" - | "Subscribed" - | "Birthdays"; -export type CalendarType = - | "Local" - | "CalDav" - | "Exchange" - | "Subscription" - | "Birthday"; -export type EventAvailability = - | "NotSupported" - | "Busy" - | "Free" - | "Tentative" - | "Unavailable"; -export type EventFilter = { - from: string; - to: string; - calendar_tracking_id: string; -}; -export type EventStatus = "None" | "Confirmed" | "Tentative" | "Canceled"; -export type GeoLocation = { latitude: number; longitude: number }; -export type Participant = { - name: string | null; - email: string | null; - is_current_user: boolean; - role: ParticipantRole; - status: ParticipantStatus; - participant_type: ParticipantType; - schedule_status: ParticipantScheduleStatus | null; - url: string | null; - contact: ParticipantContact | null; -}; -export type ParticipantContact = { - identifier: string; - given_name: string | null; - family_name: string | null; - middle_name: string | null; - organization_name: string | null; - job_title: string | null; - email_addresses: string[]; - phone_numbers: string[]; - url_addresses: string[]; - image_available: boolean; -}; -export type ParticipantRole = - | "Unknown" - | "Required" - | "Optional" - | "Chair" - | "NonParticipant"; -export type ParticipantScheduleStatus = - | "None" - | "Pending" - | "Sent" - | "Delivered" - | "RecipientNotRecognized" - | "NoPrivileges" - | "DeliveryFailed" - | "CannotDeliver"; -export type ParticipantStatus = - | "Unknown" - | "Pending" - | "Accepted" - | "Declined" - | "Tentative" - | "Delegated" - | "Completed" - | "InProgress"; -export type ParticipantType = - | "Unknown" - | "Person" - | "Room" - | "Resource" - | "Group"; -export type RecurrenceDayOfWeek = { - weekday: Weekday; - week_number: number | null; -}; -export type RecurrenceEnd = { Count: number } | { Until: string }; -export type RecurrenceFrequency = "Daily" | "Weekly" | "Monthly" | "Yearly"; -export type RecurrenceInfo = { - series_identifier: string; - has_recurrence_rules: boolean; - occurrence: RecurrenceOccurrence | null; - rules: RecurrenceRule[]; -}; -export type RecurrenceOccurrence = { - original_start: string; - is_detached: boolean; -}; -export type RecurrenceRule = { - frequency: RecurrenceFrequency; - interval: number; - days_of_week: RecurrenceDayOfWeek[]; - days_of_month: number[]; - months_of_year: number[]; - weeks_of_year: number[]; - days_of_year: number[]; - set_positions: number[]; - first_day_of_week: Weekday | null; - end: RecurrenceEnd | null; -}; -export type StructuredLocation = { - title: string; - geo: GeoLocation | null; - radius: number | null; -}; -export type Weekday = - | "Sunday" - | "Monday" - | "Tuesday" - | "Wednesday" - | "Thursday" - | "Friday" - | "Saturday"; +export type Alarm = { absolute_date: string | null; relative_offset: number | null; proximity: AlarmProximity | null; alarm_type: AlarmType | null; email_address: string | null; sound_name: string | null; url: string | null; structured_location: StructuredLocation | null } +export type AlarmProximity = "None" | "Enter" | "Leave" +export type AlarmType = "Display" | "Audio" | "Procedure" | "Email" +export type AppleCalendar = { id: string; title: string; calendar_type: CalendarType; color: CalendarColor | null; allows_content_modifications: boolean; is_immutable: boolean; is_subscribed: boolean; supported_event_availabilities: EventAvailability[]; allowed_entity_types: CalendarEntityType[]; source: CalendarSource } +export type AppleEvent = { event_identifier: string; calendar_item_identifier: string; external_identifier: string; calendar: CalendarRef; title: string; location: string | null; url: string | null; notes: string | null; creation_date: string | null; last_modified_date: string | null; time_zone: string | null; start_date: string; end_date: string; is_all_day: boolean; availability: EventAvailability; status: EventStatus; has_alarms: boolean; has_attendees: boolean; has_notes: boolean; has_recurrence_rules: boolean; organizer: Participant | null; attendees: Participant[]; structured_location: StructuredLocation | null; recurrence: RecurrenceInfo | null; occurrence_date: string | null; is_detached: boolean; alarms: Alarm[]; birthday_contact_identifier: string | null; is_birthday: boolean } +export type CalendarChangedEvent = null +export type CalendarColor = { red: number; green: number; blue: number; alpha: number } +export type CalendarEntityType = "Event" | "Reminder" +export type CalendarRef = { id: string; title: string } +export type CalendarSource = { identifier: string; title: string; source_type: CalendarSourceType } +export type CalendarSourceType = "Local" | "Exchange" | "CalDav" | "MobileMe" | "Subscribed" | "Birthdays" +export type CalendarType = "Local" | "CalDav" | "Exchange" | "Subscription" | "Birthday" +export type EventAvailability = "NotSupported" | "Busy" | "Free" | "Tentative" | "Unavailable" +export type EventFilter = { from: string; to: string; calendar_tracking_id: string } +export type EventStatus = "None" | "Confirmed" | "Tentative" | "Canceled" +export type GeoLocation = { latitude: number; longitude: number } +export type Participant = { name: string | null; email: string | null; is_current_user: boolean; role: ParticipantRole; status: ParticipantStatus; participant_type: ParticipantType; schedule_status: ParticipantScheduleStatus | null; url: string | null; contact: ParticipantContact | null } +export type ParticipantContact = { identifier: string; given_name: string | null; family_name: string | null; middle_name: string | null; organization_name: string | null; job_title: string | null; email_addresses: string[]; phone_numbers: string[]; url_addresses: string[]; image_available: boolean } +export type ParticipantRole = "Unknown" | "Required" | "Optional" | "Chair" | "NonParticipant" +export type ParticipantScheduleStatus = "None" | "Pending" | "Sent" | "Delivered" | "RecipientNotRecognized" | "NoPrivileges" | "DeliveryFailed" | "CannotDeliver" +export type ParticipantStatus = "Unknown" | "Pending" | "Accepted" | "Declined" | "Tentative" | "Delegated" | "Completed" | "InProgress" +export type ParticipantType = "Unknown" | "Person" | "Room" | "Resource" | "Group" +export type RecurrenceDayOfWeek = { weekday: Weekday; week_number: number | null } +export type RecurrenceEnd = { Count: number } | { Until: string } +export type RecurrenceFrequency = "Daily" | "Weekly" | "Monthly" | "Yearly" +export type RecurrenceInfo = { series_identifier: string; has_recurrence_rules: boolean; occurrence: RecurrenceOccurrence | null; rules: RecurrenceRule[] } +export type RecurrenceOccurrence = { original_start: string; is_detached: boolean } +export type RecurrenceRule = { frequency: RecurrenceFrequency; interval: number; days_of_week: RecurrenceDayOfWeek[]; days_of_month: number[]; months_of_year: number[]; weeks_of_year: number[]; days_of_year: number[]; set_positions: number[]; first_day_of_week: Weekday | null; end: RecurrenceEnd | null } +export type StructuredLocation = { title: string; geo: GeoLocation | null; radius: number | null } +export type Weekday = "Sunday" | "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" + +/** tauri-specta globals **/ + +import { + invoke as TAURI_INVOKE, + Channel as TAURI_CHANNEL, +} from "@tauri-apps/api/core"; +import * as TAURI_API_EVENT from "@tauri-apps/api/event"; +import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow"; type __EventObj__ = { - listen: ( - cb: TAURI_API_EVENT.EventCallback, - ) => ReturnType>; - once: ( - cb: TAURI_API_EVENT.EventCallback, - ) => ReturnType>; - emit: null extends T - ? (payload?: T) => ReturnType - : (payload: T) => ReturnType; + listen: ( + cb: TAURI_API_EVENT.EventCallback, + ) => ReturnType>; + once: ( + cb: TAURI_API_EVENT.EventCallback, + ) => ReturnType>; + emit: null extends T + ? (payload?: T) => ReturnType + : (payload: T) => ReturnType; }; export type Result = - | { status: "ok"; data: T } - | { status: "error"; error: E }; + | { status: "ok"; data: T } + | { status: "error"; error: E }; function __makeEvents__>( - mappings: Record, + mappings: Record, ) { - return new Proxy( - {} as unknown as { - [K in keyof T]: __EventObj__ & { - (handle: __WebviewWindow__): __EventObj__; - }; - }, - { - get: (_, event) => { - const name = mappings[event as keyof T]; + return new Proxy( + {} as unknown as { + [K in keyof T]: __EventObj__ & { + (handle: __WebviewWindow__): __EventObj__; + }; + }, + { + get: (_, event) => { + const name = mappings[event as keyof T]; - return new Proxy((() => {}) as any, { - apply: (_, __, [window]: [__WebviewWindow__]) => ({ - listen: (arg: any) => window.listen(name, arg), - once: (arg: any) => window.once(name, arg), - emit: (arg: any) => window.emit(name, arg), - }), - get: (_, command: keyof __EventObj__) => { - switch (command) { - case "listen": - return (arg: any) => TAURI_API_EVENT.listen(name, arg); - case "once": - return (arg: any) => TAURI_API_EVENT.once(name, arg); - case "emit": - return (arg: any) => TAURI_API_EVENT.emit(name, arg); - } - }, - }); - }, - }, - ); + return new Proxy((() => {}) as any, { + apply: (_, __, [window]: [__WebviewWindow__]) => ({ + listen: (arg: any) => window.listen(name, arg), + once: (arg: any) => window.once(name, arg), + emit: (arg: any) => window.emit(name, arg), + }), + get: (_, command: keyof __EventObj__) => { + switch (command) { + case "listen": + return (arg: any) => TAURI_API_EVENT.listen(name, arg); + case "once": + return (arg: any) => TAURI_API_EVENT.once(name, arg); + case "emit": + return (arg: any) => TAURI_API_EVENT.emit(name, arg); + } + }, + }); + }, + }, + ); } diff --git a/plugins/apple-calendar/src/apple.rs b/plugins/apple-calendar/src/apple.rs index a9e07756bc..4cbeaa8cc5 100644 --- a/plugins/apple-calendar/src/apple.rs +++ b/plugins/apple-calendar/src/apple.rs @@ -4,23 +4,23 @@ use block2::RcBlock; use itertools::Itertools; use objc2::{AllocAnyThread, msg_send, rc::Retained, runtime::Bool}; -use objc2_contacts::{CNContactStore, CNEntityType}; +use objc2_core_graphics::CGColor; use objc2_event_kit::{ EKAlarm, EKAuthorizationStatus, EKCalendar, EKCalendarType, EKEntityType, EKEvent, EKEventAvailability, EKEventStatus, EKEventStore, EKParticipant, EKParticipantRole, EKParticipantStatus, EKParticipantType, EKSourceType, EKStructuredLocation, }; use objc2_foundation::{ - NSArray, NSDate, NSInteger, NSNotification, NSNotificationCenter, NSObject, NSPredicate, - NSString, NSTimeZone, NSURL, + NSArray, NSDate, NSInteger, NSNotification, NSNotificationCenter, NSObject, NSString, + NSTimeZone, NSURL, }; +use crate::contact_resolver; use crate::error::Error; use crate::model::{ Alarm, AlarmProximity, AlarmType, AppleCalendar, AppleEvent, CalendarColor, CalendarEntityType, CalendarRef, CalendarSource, CalendarSourceType, CalendarType, EventAvailability, EventStatus, - Participant, ParticipantContact, ParticipantRole, ParticipantScheduleStatus, ParticipantStatus, - ParticipantType, StructuredLocation, + Participant, ParticipantRole, ParticipantStatus, ParticipantType, StructuredLocation, }; use crate::recurrence::{offset_date_time_from, parse_recurrence_info}; use crate::types::EventFilter; @@ -77,19 +77,20 @@ pub struct Handle { event_store: Retained, } -#[allow(clippy::new_without_default)] -impl Handle { - pub fn new() -> Self { +impl Default for Handle { + fn default() -> Self { let event_store = unsafe { EKEventStore::new() }; Self { event_store } } +} +impl Handle { fn has_calendar_access(&self) -> bool { let status = unsafe { EKEventStore::authorizationStatusForEntityType(EKEntityType::Event) }; matches!(status, EKAuthorizationStatus::FullAccess) } - fn fetch_events(&self, filter: &EventFilter) -> Retained> { + fn fetch_events(&self, filter: &EventFilter) -> Result>, Error> { let calendars: Retained> = unsafe { self.event_store.calendars() } .into_iter() .filter(|c| { @@ -99,8 +100,11 @@ impl Handle { .collect(); if calendars.is_empty() { - let empty_array: Retained> = NSArray::new(); - return empty_array; + return Err(Error::CalendarNotFound); + } + + if filter.from > filter.to { + return Err(Error::InvalidDateRange); } let (start_date, end_date) = [filter.from, filter.to] @@ -108,7 +112,7 @@ impl Handle { .sorted_by(|a, b| a.cmp(b)) .map(|v| NSDate::initWithTimeIntervalSince1970(NSDate::alloc(), v.timestamp() as f64)) .collect_tuple() - .unwrap(); + .ok_or_else(|| Error::InvalidDateRange)?; let predicate = unsafe { self.event_store @@ -119,7 +123,7 @@ impl Handle { ) }; - unsafe { self.event_store.eventsMatchingPredicate(&predicate) } + Ok(unsafe { self.event_store.eventsMatchingPredicate(&predicate) }) } pub fn list_calendars(&self) -> Result, Error> { @@ -143,8 +147,9 @@ impl Handle { return Err(Error::CalendarAccessDenied); } - let events = self - .fetch_events(&filter) + let events_array = self.fetch_events(&filter)?; + + let events: Result, _> = events_array .iter() .filter_map(|event| { let calendar = unsafe { event.calendar() }?; @@ -156,9 +161,11 @@ impl Handle { Some(transform_event(&event)) }) - .sorted_by(|a, b| a.start_date.cmp(&b.start_date)) .collect(); + let mut events = events?; + events.sort_by(|a, b| a.start_date.cmp(&b.start_date)); + Ok(events) } } @@ -167,24 +174,43 @@ fn transform_calendar(calendar: &EKCalendar) -> AppleCalendar { let id = unsafe { calendar.calendarIdentifier() }.to_string(); let title = unsafe { calendar.title() }.to_string(); let calendar_type = transform_calendar_type(unsafe { calendar.r#type() }); + let color = unsafe { calendar.CGColor() }.map(|cg_color| extract_color_components(&cg_color)); - let color = unsafe { - let cg_color: *const std::ffi::c_void = msg_send![calendar, CGColor]; - if cg_color.is_null() { - None - } else { - extract_color_components(cg_color) - } - }; + let properties = extract_calendar_properties(calendar); + AppleCalendar { + id, + title, + calendar_type, + color, + ..properties + } +} + +fn extract_calendar_properties(calendar: &EKCalendar) -> AppleCalendar { let allows_content_modifications = unsafe { calendar.allowsContentModifications() }; let is_immutable = unsafe { calendar.isImmutable() }; let is_subscribed = unsafe { calendar.isSubscribed() }; - let supported_event_availabilities = extract_supported_availabilities(calendar); let allowed_entity_types = extract_allowed_entity_types(calendar); + let source = extract_calendar_source(calendar); + + AppleCalendar { + allows_content_modifications, + is_immutable, + is_subscribed, + supported_event_availabilities, + allowed_entity_types, + source, + id: String::new(), // Will be overridden + title: String::new(), // Will be overridden + calendar_type: CalendarType::Local, // Will be overridden + color: None, // Will be overridden + } +} - let source = if let Some(src) = unsafe { calendar.source() } { +fn extract_calendar_source(calendar: &EKCalendar) -> CalendarSource { + if let Some(src) = unsafe { calendar.source() } { let source_identifier = unsafe { src.sourceIdentifier() }.to_string(); let source_title = unsafe { src.title() }.to_string(); let source_type = transform_source_type(unsafe { src.sourceType() }); @@ -194,145 +220,241 @@ fn transform_calendar(calendar: &EKCalendar) -> AppleCalendar { source_type, } } else { - CalendarSource { - identifier: String::new(), - title: String::new(), - source_type: CalendarSourceType::Local, - } - }; - - AppleCalendar { - id, - title, - calendar_type, - color, - allows_content_modifications, - is_immutable, - is_subscribed, - supported_event_availabilities, - allowed_entity_types, - source, + CalendarSource::default() } } -fn transform_event(event: &EKEvent) -> AppleEvent { - let event_identifier = unsafe { event.eventIdentifier() } - .map(|s| s.to_string()) - .unwrap_or_default(); - let calendar_item_identifier = unsafe { event.calendarItemIdentifier() }.to_string(); - let external_identifier = unsafe { event.calendarItemExternalIdentifier() } - .map(|s| s.to_string()) - .unwrap_or_default(); +fn transform_event(event: &EKEvent) -> Result { + let identifiers = extract_event_identifiers(event); + let calendar_ref = extract_event_calendar_ref(event); + let basic_info = extract_event_basic_info(event); + let dates = extract_event_dates(event); + let status_info = extract_event_status_info(event); + let flags = extract_event_flags(event); + let participants = extract_event_participants(event); + let location_info = extract_event_location_info(event); + let recurrence_info = extract_event_recurrence_info(event, flags.has_recurrence_rules); + let alarm_info = extract_event_alarm_info(event); + let birthday_info = extract_event_birthday_info(event, &calendar_ref); + + Ok(AppleEvent { + event_identifier: identifiers.event_identifier, + calendar_item_identifier: identifiers.calendar_item_identifier, + external_identifier: identifiers.external_identifier, + calendar: calendar_ref, + title: basic_info.title, + location: basic_info.location, + url: basic_info.url, + notes: basic_info.notes, + creation_date: basic_info.creation_date, + last_modified_date: basic_info.last_modified_date, + time_zone: basic_info.time_zone, + start_date: dates.start_date, + end_date: dates.end_date, + is_all_day: dates.is_all_day, + availability: status_info.availability, + status: status_info.status, + has_alarms: flags.has_alarms, + has_attendees: flags.has_attendees, + has_notes: flags.has_notes, + has_recurrence_rules: flags.has_recurrence_rules, + organizer: participants.organizer, + attendees: participants.attendees, + structured_location: location_info.structured_location, + recurrence: recurrence_info.recurrence, + occurrence_date: recurrence_info.occurrence_date, + is_detached: recurrence_info.is_detached, + alarms: alarm_info.alarms, + birthday_contact_identifier: birthday_info.birthday_contact_identifier, + is_birthday: birthday_info.is_birthday, + }) +} +struct EventIdentifiers { + event_identifier: String, + calendar_item_identifier: String, + external_identifier: String, +} + +fn extract_event_identifiers(event: &EKEvent) -> EventIdentifiers { + EventIdentifiers { + event_identifier: unsafe { event.eventIdentifier() } + .map(|s| s.to_string()) + .unwrap_or_default(), + calendar_item_identifier: unsafe { event.calendarItemIdentifier() }.to_string(), + external_identifier: unsafe { event.calendarItemExternalIdentifier() } + .map(|s| s.to_string()) + .unwrap_or_default(), + } +} + +fn extract_event_calendar_ref(event: &EKEvent) -> CalendarRef { let calendar = unsafe { event.calendar() }.unwrap(); - let calendar_ref = CalendarRef { + CalendarRef { id: unsafe { calendar.calendarIdentifier() }.to_string(), title: unsafe { calendar.title() }.to_string(), - }; + } +} - let title = unsafe { event.title() }.to_string(); - let location = unsafe { event.location() }.map(|s| s.to_string()); - let url = unsafe { - let url_obj: Option> = msg_send![event, URL]; - url_obj.and_then(|u| u.absoluteString().map(|s| s.to_string())) - }; - let notes = unsafe { event.notes() }.map(|s| s.to_string()); +struct EventBasicInfo { + title: String, + location: Option, + url: Option, + notes: Option, + creation_date: Option>, + last_modified_date: Option>, + time_zone: Option, +} - let creation_date = unsafe { - let date: Option> = msg_send![event, creationDate]; - date.map(offset_date_time_from) - }; - let last_modified_date = unsafe { - let date: Option> = msg_send![event, lastModifiedDate]; - date.map(offset_date_time_from) - }; +fn extract_event_basic_info(event: &EKEvent) -> EventBasicInfo { + EventBasicInfo { + title: unsafe { event.title() }.to_string(), + location: unsafe { event.location() }.map(|s| s.to_string()), + url: get_url_string(event, "URL"), + notes: unsafe { event.notes() }.map(|s| s.to_string()), + creation_date: unsafe { + let date: Option> = msg_send![event, creationDate]; + date.map(offset_date_time_from) + }, + last_modified_date: unsafe { + let date: Option> = msg_send![event, lastModifiedDate]; + date.map(offset_date_time_from) + }, + time_zone: unsafe { + let tz: Option> = msg_send![event, timeZone]; + tz.map(|t| t.name().to_string()) + }, + } +} - let time_zone = unsafe { - let tz: Option> = msg_send![event, timeZone]; - tz.map(|t| t.name().to_string()) - }; +struct EventDates { + start_date: chrono::DateTime, + end_date: chrono::DateTime, + is_all_day: bool, +} - let start_date = unsafe { event.startDate() }; - let end_date = unsafe { event.endDate() }; - let is_all_day = unsafe { event.isAllDay() }; +fn extract_event_dates(event: &EKEvent) -> EventDates { + EventDates { + start_date: offset_date_time_from(unsafe { event.startDate() }), + end_date: offset_date_time_from(unsafe { event.endDate() }), + is_all_day: unsafe { event.isAllDay() }, + } +} - let availability = transform_event_availability(unsafe { event.availability() }); - let status = transform_event_status(unsafe { event.status() }); +struct EventStatusInfo { + availability: EventAvailability, + status: EventStatus, +} - let has_alarms: bool = unsafe { - let b: Bool = msg_send![event, hasAlarms]; - b.as_bool() - }; - let has_attendees: bool = unsafe { - let b: Bool = msg_send![event, hasAttendees]; - b.as_bool() - }; - let has_notes: bool = unsafe { - let b: Bool = msg_send![event, hasNotes]; - b.as_bool() - }; - let has_recurrence_rules: bool = unsafe { - let b: Bool = msg_send![event, hasRecurrenceRules]; - b.as_bool() - }; +fn extract_event_status_info(event: &EKEvent) -> EventStatusInfo { + EventStatusInfo { + availability: transform_event_availability(unsafe { event.availability() }), + status: transform_event_status(unsafe { event.status() }), + } +} - let organizer = unsafe { event.organizer() }.map(|p| transform_participant(&p)); - let attendees = unsafe { event.attendees() } - .map(|arr| arr.iter().map(|p| transform_participant(&p)).collect()) - .unwrap_or_default(); +struct EventFlags { + has_alarms: bool, + has_attendees: bool, + has_notes: bool, + has_recurrence_rules: bool, +} - let structured_location = unsafe { - let loc: Option> = msg_send![event, structuredLocation]; - loc.map(|l| transform_structured_location(&l)) - }; +fn extract_event_flags(event: &EKEvent) -> EventFlags { + EventFlags { + has_alarms: unsafe { + let b: Bool = msg_send![event, hasAlarms]; + b.as_bool() + }, + has_attendees: unsafe { + let b: Bool = msg_send![event, hasAttendees]; + b.as_bool() + }, + has_notes: unsafe { + let b: Bool = msg_send![event, hasNotes]; + b.as_bool() + }, + has_recurrence_rules: unsafe { + let b: Bool = msg_send![event, hasRecurrenceRules]; + b.as_bool() + }, + } +} - let recurrence = parse_recurrence_info(event, has_recurrence_rules); +struct EventParticipants { + organizer: Option, + attendees: Vec, +} - let occurrence_date = unsafe { event.occurrenceDate() }.map(offset_date_time_from); - let is_detached = unsafe { event.isDetached() }; +fn extract_event_participants(event: &EKEvent) -> EventParticipants { + EventParticipants { + organizer: unsafe { event.organizer() }.map(|p| transform_participant(&p)), + attendees: unsafe { event.attendees() } + .map(|arr| arr.iter().map(|p| transform_participant(&p)).collect()) + .unwrap_or_default(), + } +} - let alarms = unsafe { - let alarm_arr: Option>> = msg_send![event, alarms]; - alarm_arr - .map(|arr| arr.iter().map(|a| transform_alarm(&a)).collect()) - .unwrap_or_default() - }; +struct EventLocationInfo { + structured_location: Option, +} + +fn extract_event_location_info(event: &EKEvent) -> EventLocationInfo { + EventLocationInfo { + structured_location: unsafe { + let loc: Option> = msg_send![event, structuredLocation]; + loc.map(|l| transform_structured_location(&l)) + }, + } +} + +struct EventRecurrenceInfo { + recurrence: Option, + occurrence_date: Option>, + is_detached: bool, +} + +fn extract_event_recurrence_info( + event: &EKEvent, + has_recurrence_rules: bool, +) -> EventRecurrenceInfo { + EventRecurrenceInfo { + recurrence: parse_recurrence_info(event, has_recurrence_rules), + occurrence_date: unsafe { event.occurrenceDate() }.map(offset_date_time_from), + is_detached: unsafe { event.isDetached() }, + } +} + +struct EventAlarmInfo { + alarms: Vec, +} + +fn extract_event_alarm_info(event: &EKEvent) -> EventAlarmInfo { + EventAlarmInfo { + alarms: unsafe { + let alarm_arr: Option>> = msg_send![event, alarms]; + alarm_arr + .map(|arr| arr.iter().map(|a| transform_alarm(&a)).collect()) + .unwrap_or_default() + }, + } +} + +struct EventBirthdayInfo { + birthday_contact_identifier: Option, + is_birthday: bool, +} +fn extract_event_birthday_info(event: &EKEvent, _calendar_ref: &CalendarRef) -> EventBirthdayInfo { let birthday_contact_identifier = unsafe { let id: Option> = msg_send![event, birthdayContactIdentifier]; id.map(|s| s.to_string()) }; + let is_birthday = birthday_contact_identifier.is_some() - || unsafe { calendar.r#type() } == EKCalendarType::Birthday; + || unsafe { event.calendar().unwrap().r#type() } == EKCalendarType::Birthday; - AppleEvent { - event_identifier, - calendar_item_identifier, - external_identifier, - calendar: calendar_ref, - title, - location, - url, - notes, - creation_date, - last_modified_date, - time_zone, - start_date: offset_date_time_from(start_date), - end_date: offset_date_time_from(end_date), - is_all_day, - availability, - status, - has_alarms, - has_attendees, - has_notes, - has_recurrence_rules, - organizer, - attendees, - structured_location, - recurrence, - occurrence_date, - is_detached, - alarms, + EventBirthdayInfo { birthday_contact_identifier, is_birthday, } @@ -345,17 +467,15 @@ fn transform_participant(participant: &EKParticipant) -> Participant { let role = transform_participant_role(unsafe { participant.participantRole() }); let status = transform_participant_status(unsafe { participant.participantStatus() }); let participant_type = transform_participant_type(unsafe { participant.participantType() }); - let schedule_status = unsafe { - let status: NSInteger = msg_send![participant, participantScheduleStatus]; - transform_participant_schedule_status(status) - }; + let schedule_status = contact_resolver::safe_participant_schedule_status(participant); let url = unsafe { let url_obj: Option> = msg_send![participant, URL]; url_obj.and_then(|u| u.absoluteString().map(|s| s.to_string())) }; - let (email, contact) = resolve_participant_contact(participant, url.as_deref()); + let (email, contact) = + contact_resolver::resolve_participant_contact(participant, url.as_deref()); Participant { name, @@ -370,150 +490,6 @@ fn transform_participant(participant: &EKParticipant) -> Participant { } } -fn resolve_participant_contact( - participant: &EKParticipant, - url: Option<&str>, -) -> (Option, Option) { - if let Some(contact) = try_fetch_contact(participant) { - let email = contact.email_addresses.first().cloned(); - return (email, Some(contact)); - } - - let email = parse_email_from_url(url); - (email, None) -} - -fn try_fetch_contact(participant: &EKParticipant) -> Option { - if !has_contacts_access() { - return None; - } - - let predicate: Retained = unsafe { participant.contactPredicate() }; - let contact_store = unsafe { CNContactStore::new() }; - - let keys_to_fetch: Retained> = NSArray::from_slice(&[ - &*NSString::from_str("identifier"), - &*NSString::from_str("givenName"), - &*NSString::from_str("familyName"), - &*NSString::from_str("middleName"), - &*NSString::from_str("organizationName"), - &*NSString::from_str("jobTitle"), - &*NSString::from_str("emailAddresses"), - &*NSString::from_str("phoneNumbers"), - &*NSString::from_str("urlAddresses"), - &*NSString::from_str("imageDataAvailable"), - ]); - - let contacts: Option>> = unsafe { - msg_send![ - &*contact_store, - unifiedContactsMatchingPredicate: &*predicate, - keysToFetch: &*keys_to_fetch, - error: std::ptr::null_mut::<*mut objc2_foundation::NSError>() - ] - }; - - let contacts = contacts?; - let contact = contacts.iter().next()?; - - let identifier = unsafe { - let id: Retained = msg_send![&*contact, identifier]; - id.to_string() - }; - - let given_name = get_optional_string(&contact, "givenName"); - let family_name = get_optional_string(&contact, "familyName"); - let middle_name = get_optional_string(&contact, "middleName"); - let organization_name = get_optional_string(&contact, "organizationName"); - let job_title = get_optional_string(&contact, "jobTitle"); - - let email_addresses = extract_labeled_string_values(&contact, "emailAddresses"); - let phone_numbers = extract_phone_numbers(&contact); - let url_addresses = extract_labeled_string_values(&contact, "urlAddresses"); - - let image_available: bool = unsafe { - let b: Bool = msg_send![&*contact, imageDataAvailable]; - b.as_bool() - }; - - Some(ParticipantContact { - identifier, - given_name, - family_name, - middle_name, - organization_name, - job_title, - email_addresses, - phone_numbers, - url_addresses, - image_available, - }) -} - -fn has_contacts_access() -> bool { - let status = - unsafe { CNContactStore::authorizationStatusForEntityType(CNEntityType::Contacts) }; - status.0 == 3 // CNAuthorizationStatus::Authorized -} - -fn get_optional_string(contact: &Retained, key: &str) -> Option { - unsafe { - let value: Option> = - msg_send![&**contact, valueForKey: &*NSString::from_str(key)]; - value.filter(|s| !s.is_empty()).map(|s| s.to_string()) - } -} - -fn extract_labeled_string_values( - contact: &Retained, - key: &str, -) -> Vec { - unsafe { - let labeled_values: Option> = - msg_send![&**contact, valueForKey: &*NSString::from_str(key)]; - labeled_values - .map(|arr| { - arr.iter() - .filter_map(|lv| { - let value: Option> = msg_send![&*lv, value]; - value.map(|s| s.to_string()) - }) - .collect() - }) - .unwrap_or_default() - } -} - -fn extract_phone_numbers(contact: &Retained) -> Vec { - unsafe { - let labeled_values: Option> = - msg_send![&**contact, valueForKey: &*NSString::from_str("phoneNumbers")]; - labeled_values - .map(|arr| { - arr.iter() - .filter_map(|lv| { - let phone: Option> = - msg_send![&*lv, value]; - phone.and_then(|p| { - let digits: Option> = msg_send![&*p, stringValue]; - digits.map(|s| s.to_string()) - }) - }) - .collect() - }) - .unwrap_or_default() - } -} - -fn parse_email_from_url(url: Option<&str>) -> Option { - let url = url?; - if url.starts_with("mailto:") { - Some(url.trim_start_matches("mailto:").to_string()) - } else { - None - } -} - fn transform_alarm(alarm: &EKAlarm) -> Alarm { let absolute_date = unsafe { let date: Option> = msg_send![alarm, absoluteDate]; @@ -679,23 +655,46 @@ fn transform_participant_type(t: EKParticipantType) -> ParticipantType { } } -fn transform_participant_schedule_status(status: NSInteger) -> Option { - match status { - 0 => Some(ParticipantScheduleStatus::None), - 1 => Some(ParticipantScheduleStatus::Pending), - 2 => Some(ParticipantScheduleStatus::Sent), - 3 => Some(ParticipantScheduleStatus::Delivered), - 4 => Some(ParticipantScheduleStatus::RecipientNotRecognized), - 5 => Some(ParticipantScheduleStatus::NoPrivileges), - 6 => Some(ParticipantScheduleStatus::DeliveryFailed), - 7 => Some(ParticipantScheduleStatus::CannotDeliver), - _ => None, +#[allow(unused_variables)] +fn extract_color_components(cg_color: &CGColor) -> CalendarColor { + let num_components = CGColor::number_of_components(Some(cg_color)); + let components_ptr = CGColor::components(Some(cg_color)); + let alpha = CGColor::alpha(Some(cg_color)) as f32; + + if components_ptr.is_null() || num_components < 1 { + return CalendarColor { + red: 0.5, + green: 0.5, + blue: 0.5, + alpha: 1.0, + }; } -} -#[allow(unused_variables)] -fn extract_color_components(cg_color: *const std::ffi::c_void) -> Option { - None + let components = unsafe { std::slice::from_raw_parts(components_ptr, num_components) }; + + match num_components { + 2 => { + let gray = components[0] as f32; + CalendarColor { + red: gray, + green: gray, + blue: gray, + alpha, + } + } + 3 | 4 => CalendarColor { + red: components[0] as f32, + green: components[1] as f32, + blue: components[2] as f32, + alpha, + }, + _ => CalendarColor { + red: 0.5, + green: 0.5, + blue: 0.5, + alpha: 1.0, + }, + } } fn extract_supported_availabilities(calendar: &EKCalendar) -> Vec { @@ -734,3 +733,15 @@ fn extract_allowed_entity_types(calendar: &EKCalendar) -> Vec(obj: &T, selector: &str) -> Option +where + T: objc2::Message + ?Sized, +{ + unsafe { + let sel = objc2::sel!(selector); + let url_obj: Option> = msg_send![obj, sel]; + url_obj.and_then(|u| u.absoluteString().map(|s| s.to_string())) + } +} diff --git a/plugins/apple-calendar/src/contact_resolver.rs b/plugins/apple-calendar/src/contact_resolver.rs new file mode 100644 index 0000000000..2e1bbdca3a --- /dev/null +++ b/plugins/apple-calendar/src/contact_resolver.rs @@ -0,0 +1,184 @@ +use std::panic::AssertUnwindSafe; + +use objc2::{msg_send, rc::Retained, runtime::Bool}; +use objc2_contacts::{CNContact, CNContactStore, CNEntityType, CNPhoneNumber}; +use objc2_foundation::{NSArray, NSError, NSString}; + +use crate::model::{ParticipantContact, ParticipantScheduleStatus}; + +pub fn resolve_participant_contact( + participant: &objc2_event_kit::EKParticipant, + url: Option<&str>, +) -> (Option, Option) { + if let Some(contact) = try_fetch_contact(participant) { + let email = contact.email_addresses.first().cloned(); + return (email, Some(contact)); + } + + let email = parse_email_from_url(url); + (email, None) +} + +fn try_fetch_contact(participant: &objc2_event_kit::EKParticipant) -> Option { + if !has_contacts_access() { + return None; + } + + let participant = AssertUnwindSafe(participant); + let predicate: Retained = + match unsafe { objc2::exception::catch(|| participant.contactPredicate()) } { + Ok(p) => p, + Err(_) => return None, + }; + + let contact_store = unsafe { CNContactStore::new() }; + + let keys_to_fetch: Retained> = NSArray::from_slice(&[ + &*NSString::from_str("identifier"), + &*NSString::from_str("givenName"), + &*NSString::from_str("familyName"), + &*NSString::from_str("middleName"), + &*NSString::from_str("organizationName"), + &*NSString::from_str("jobTitle"), + &*NSString::from_str("emailAddresses"), + &*NSString::from_str("phoneNumbers"), + &*NSString::from_str("urlAddresses"), + &*NSString::from_str("imageDataAvailable"), + ]); + + let contacts: Option>> = unsafe { + msg_send![ + &*contact_store, + unifiedContactsMatchingPredicate: &*predicate, + keysToFetch: &*keys_to_fetch, + error: std::ptr::null_mut::<*mut NSError>() + ] + }; + + let contacts = contacts?; + let contact = contacts.iter().next()?; + + let identifier = unsafe { + let id: Retained = msg_send![&*contact, identifier]; + id.to_string() + }; + + let given_name = get_optional_string(&contact, "givenName"); + let family_name = get_optional_string(&contact, "familyName"); + let middle_name = get_optional_string(&contact, "middleName"); + let organization_name = get_optional_string(&contact, "organizationName"); + let job_title = get_optional_string(&contact, "jobTitle"); + + let email_addresses = extract_labeled_string_values(&contact, "emailAddresses"); + let phone_numbers = extract_phone_numbers(&contact); + let url_addresses = extract_labeled_string_values(&contact, "urlAddresses"); + + let image_available: bool = unsafe { + let b: Bool = msg_send![&*contact, imageDataAvailable]; + b.as_bool() + }; + + Some(ParticipantContact { + identifier, + given_name, + family_name, + middle_name, + organization_name, + job_title, + email_addresses, + phone_numbers, + url_addresses, + image_available, + }) +} + +fn has_contacts_access() -> bool { + let status = + unsafe { CNContactStore::authorizationStatusForEntityType(CNEntityType::Contacts) }; + status.0 == 3 // CNAuthorizationStatus::Authorized +} + +fn get_optional_string(contact: &Retained, key: &str) -> Option { + unsafe { + let value: Option> = + msg_send![&**contact, valueForKey: &*NSString::from_str(key)]; + value.filter(|s| !s.is_empty()).map(|s| s.to_string()) + } +} + +fn extract_labeled_string_values(contact: &Retained, key: &str) -> Vec { + unsafe { + let labeled_values: Option> = + msg_send![&**contact, valueForKey: &*NSString::from_str(key)]; + labeled_values + .map(|arr| { + arr.iter() + .filter_map(|lv| { + let value: Option> = msg_send![&*lv, value]; + value.map(|s| s.to_string()) + }) + .collect() + }) + .unwrap_or_default() + } +} + +fn extract_phone_numbers(contact: &Retained) -> Vec { + unsafe { + let labeled_values: Option> = + msg_send![&**contact, valueForKey: &*NSString::from_str("phoneNumbers")]; + labeled_values + .map(|arr| { + arr.iter() + .filter_map(|lv| { + let phone: Option> = msg_send![&*lv, value]; + phone.and_then(|p| { + let digits: Option> = msg_send![&*p, stringValue]; + digits.map(|s| s.to_string()) + }) + }) + .collect() + }) + .unwrap_or_default() + } +} + +fn parse_email_from_url(url: Option<&str>) -> Option { + let url = url?; + if url.starts_with("mailto:") { + Some(url.trim_start_matches("mailto:").to_string()) + } else { + None + } +} + +pub fn safe_participant_schedule_status( + participant: &objc2_event_kit::EKParticipant, +) -> Option { + let participant = AssertUnwindSafe(participant); + let result = objc2::exception::catch(|| unsafe { + let raw: objc2_foundation::NSInteger = msg_send![*participant, participantScheduleStatus]; + raw + }); + + match result { + Ok(raw) => transform_participant_schedule_status(raw), + Err(_) => None, + } +} + +fn transform_participant_schedule_status( + status: objc2_foundation::NSInteger, +) -> Option { + match status { + 0 => Some(ParticipantScheduleStatus::None), + 1 => Some(ParticipantScheduleStatus::Pending), + 2 => Some(ParticipantScheduleStatus::Sent), + 3 => Some(ParticipantScheduleStatus::Delivered), + 4 => Some(ParticipantScheduleStatus::RecipientNotRecognized), + 5 => Some(ParticipantScheduleStatus::NoPrivileges), + 6 => Some(ParticipantScheduleStatus::DeliveryFailed), + 7 => Some(ParticipantScheduleStatus::CannotDeliver), + _ => None, + } +} diff --git a/plugins/apple-calendar/src/error.rs b/plugins/apple-calendar/src/error.rs index 791615e524..70ecad49c1 100644 --- a/plugins/apple-calendar/src/error.rs +++ b/plugins/apple-calendar/src/error.rs @@ -8,6 +8,22 @@ pub enum Error { CalendarAccessDenied, #[error("contacts access denied")] ContactsAccessDenied, + #[error("event not found")] + EventNotFound, + #[error("calendar not found")] + CalendarNotFound, + #[error("invalid date range")] + InvalidDateRange, + #[error("objective-c exception: {0}")] + ObjectiveCException(String), + #[error("transform error: {0}")] + TransformError(String), + #[error("permission denied: {0}")] + PermissionDenied(String), + #[error("io error: {0}")] + IoError(#[from] std::io::Error), + #[error("serde error: {0}")] + SerdeError(#[from] serde_json::Error), } impl Serialize for Error { diff --git a/plugins/apple-calendar/src/ext.rs b/plugins/apple-calendar/src/ext.rs index f92fb24a3c..20596c64ef 100644 --- a/plugins/apple-calendar/src/ext.rs +++ b/plugins/apple-calendar/src/ext.rs @@ -34,13 +34,13 @@ impl> crate::AppleCalendarPluginExt f #[tracing::instrument(skip_all)] fn list_calendars(&self) -> Result, String> { - let handle = crate::apple::Handle::new(); + let handle = crate::apple::Handle::default(); handle.list_calendars().map_err(|e| e.to_string()) } #[tracing::instrument(skip_all)] fn list_events(&self, filter: EventFilter) -> Result, String> { - let handle = crate::apple::Handle::new(); + let handle = crate::apple::Handle::default(); handle.list_events(filter).map_err(|e| e.to_string()) } } diff --git a/plugins/apple-calendar/src/lib.rs b/plugins/apple-calendar/src/lib.rs index bf06ba8936..68cde1e7a7 100644 --- a/plugins/apple-calendar/src/lib.rs +++ b/plugins/apple-calendar/src/lib.rs @@ -3,6 +3,8 @@ use tauri::Manager; #[cfg(target_os = "macos")] mod apple; #[cfg(target_os = "macos")] +mod contact_resolver; +#[cfg(target_os = "macos")] mod recurrence; mod commands; @@ -84,7 +86,38 @@ mod test { } #[test] - fn test_apple_calendar() { - let _app = create_app(tauri::test::mock_builder()); + fn test_list_calendars() { + let app = create_app(tauri::test::mock_builder()); + + let calendars = app.list_calendars(); + println!("calendars: {:?}", calendars); + } + + #[test] + fn test_list_events() { + let app = create_app(tauri::test::mock_builder()); + + // First test with a simple calendar that should exist + match app.list_calendars() { + Ok(calendars) => { + if let Some(calendar) = calendars.first() { + println!( + "Testing with calendar: {} ({})", + calendar.title, calendar.id + ); + let events = app.list_events(EventFilter { + from: chrono::Utc::now(), + to: chrono::Utc::now() + chrono::Duration::days(7), + calendar_tracking_id: calendar.id.clone(), + }); + println!("events: {:?}", events); + } else { + println!("No calendars found"); + } + } + Err(e) => { + println!("Error listing calendars: {:?}", e); + } + } } } diff --git a/plugins/apple-calendar/src/model.rs b/plugins/apple-calendar/src/model.rs index e3e347cba1..da77214f87 100644 --- a/plugins/apple-calendar/src/model.rs +++ b/plugins/apple-calendar/src/model.rs @@ -66,6 +66,16 @@ common_derives! { } } +impl Default for CalendarSource { + fn default() -> Self { + Self { + identifier: String::new(), + title: String::new(), + source_type: CalendarSourceType::Local, + } + } +} + common_derives! { pub struct AppleCalendar { pub id: String, diff --git a/plugins/permissions/Cargo.toml b/plugins/permissions/Cargo.toml index 81b926685d..90ed984c33 100644 --- a/plugins/permissions/Cargo.toml +++ b/plugins/permissions/Cargo.toml @@ -12,6 +12,8 @@ tauri-plugin = { workspace = true, features = ["build"] } [dev-dependencies] specta-typescript = { workspace = true } +tokio = { workspace = true, features = ["macros"] } +tauri-plugin-shell = { workspace = true } [dependencies] hypr-audio = { workspace = true } diff --git a/plugins/permissions/src/commands.rs b/plugins/permissions/src/commands.rs index c13a863436..a6bd137936 100644 --- a/plugins/permissions/src/commands.rs +++ b/plugins/permissions/src/commands.rs @@ -1,288 +1,121 @@ -use futures_util::StreamExt; - -use crate::models::PermissionStatus; - -#[cfg(target_os = "macos")] -use block2::StackBlock; -#[cfg(target_os = "macos")] -use objc2_av_foundation::{AVCaptureDevice, AVMediaTypeAudio}; -#[cfg(target_os = "macos")] -use objc2_contacts::{CNContactStore, CNEntityType}; -#[cfg(target_os = "macos")] -use objc2_event_kit::{EKEntityType, EKEventStore}; +use crate::PermissionsPluginExt; #[tauri::command] #[specta::specta] -pub async fn check_microphone_permission( - _app: tauri::AppHandle, -) -> Result { - #[cfg(target_os = "macos")] - { - let status = unsafe { - let media_type = AVMediaTypeAudio.unwrap(); - AVCaptureDevice::authorizationStatusForMediaType(media_type) - }; - Ok(status.into()) - } - - #[cfg(not(target_os = "macos"))] - { - let mut mic_sample_stream = hypr_audio::AudioInput::from_mic(None) - .map_err(|e| e.to_string())? - .stream(); - let sample = mic_sample_stream.next().await; - Ok(if sample.is_some() { - PermissionStatus::Authorized - } else { - PermissionStatus::Denied - }) - } +pub(crate) async fn check_microphone_permission( + app: tauri::AppHandle, +) -> Result { + app.check_microphone_permission() + .await + .map_err(|e| e.to_string()) } #[tauri::command] #[specta::specta] -pub async fn request_microphone_permission( - _app: tauri::AppHandle, +pub(crate) async fn request_microphone_permission( + app: tauri::AppHandle, ) -> Result<(), String> { - #[cfg(target_os = "macos")] - { - unsafe { - let media_type = AVMediaTypeAudio.unwrap(); - let block = StackBlock::new(|_granted| {}); - AVCaptureDevice::requestAccessForMediaType_completionHandler(media_type, &block); - } - } - - #[cfg(not(target_os = "macos"))] - { - let mut mic_sample_stream = hypr_audio::AudioInput::from_mic(None) - .map_err(|e| e.to_string())? - .stream(); - mic_sample_stream.next().await; - } - - Ok(()) + app.request_microphone_permission() + .await + .map_err(|e| e.to_string()) } #[tauri::command] #[specta::specta] -pub async fn check_system_audio_permission( - _app: tauri::AppHandle, -) -> Result { - #[cfg(target_os = "macos")] - { - let status = hypr_tcc::audio_capture_permission_status(); - Ok(status.into()) - } - - #[cfg(not(target_os = "macos"))] - { - let mut speaker_sample_stream = hypr_audio::AudioInput::from_speaker().stream(); - let sample = speaker_sample_stream.next().await; - Ok(if sample.is_some() { - PermissionStatus::Authorized - } else { - PermissionStatus::Denied - }) - } +pub(crate) async fn check_system_audio_permission( + app: tauri::AppHandle, +) -> Result { + app.check_system_audio_permission() + .await + .map_err(|e| e.to_string()) } #[tauri::command] #[specta::specta] -pub async fn request_system_audio_permission( - #[allow(unused_variables)] app: tauri::AppHandle, +pub(crate) async fn request_system_audio_permission( + app: tauri::AppHandle, ) -> Result<(), String> { - #[cfg(target_os = "macos")] - { - use tauri_plugin_shell::ShellExt; - - let bundle_id = app.config().identifier.clone(); - app.shell() - .command("tccutil") - .args(["reset", "AudioCapture", &bundle_id]) - .spawn() - .ok(); - } - - let stop = hypr_audio::AudioOutput::silence(); - - let mut speaker_sample_stream = hypr_audio::AudioInput::from_speaker().stream(); - speaker_sample_stream.next().await; - - let _ = stop.send(()); - Ok(()) + app.request_system_audio_permission() + .await + .map_err(|e| e.to_string()) } #[tauri::command] #[specta::specta] -pub async fn check_accessibility_permission( - _app: tauri::AppHandle, -) -> Result { - #[cfg(target_os = "macos")] - { - let is_trusted = macos_accessibility_client::accessibility::application_is_trusted(); - Ok(if is_trusted { - PermissionStatus::Authorized - } else { - PermissionStatus::Denied - }) - } - - #[cfg(not(target_os = "macos"))] - { - Ok(PermissionStatus::Denied) - } +pub(crate) async fn check_accessibility_permission( + app: tauri::AppHandle, +) -> Result { + app.check_accessibility_permission() + .await + .map_err(|e| e.to_string()) } #[tauri::command] #[specta::specta] -pub async fn request_accessibility_permission( - _app: tauri::AppHandle, +pub(crate) async fn request_accessibility_permission( + app: tauri::AppHandle, ) -> Result<(), String> { - #[cfg(target_os = "macos")] - { - macos_accessibility_client::accessibility::application_is_trusted_with_prompt(); - } - - Ok(()) + app.request_accessibility_permission() + .await + .map_err(|e| e.to_string()) } #[tauri::command] #[specta::specta] -pub async fn check_calendar_permission( - _app: tauri::AppHandle, -) -> Result { - #[cfg(target_os = "macos")] - { - let status = unsafe { EKEventStore::authorizationStatusForEntityType(EKEntityType::Event) }; - Ok(status.into()) - } - - #[cfg(not(target_os = "macos"))] - { - Ok(PermissionStatus::Denied) - } +pub(crate) async fn check_calendar_permission( + app: tauri::AppHandle, +) -> Result { + app.check_calendar_permission() + .await + .map_err(|e| e.to_string()) } #[tauri::command] #[specta::specta] -pub async fn request_calendar_permission( - #[allow(unused_variables)] app: tauri::AppHandle, +pub(crate) async fn request_calendar_permission( + app: tauri::AppHandle, ) -> Result<(), String> { - #[cfg(target_os = "macos")] - { - use objc2_foundation::NSError; - use tauri_plugin_shell::ShellExt; - - let bundle_id = app.config().identifier.clone(); - app.shell() - .command("tccutil") - .args(["reset", "Calendar", &bundle_id]) - .spawn() - .ok(); - - let event_store = unsafe { EKEventStore::new() }; - let (tx, rx) = std::sync::mpsc::channel::(); - let completion = - block2::RcBlock::new(move |granted: objc2::runtime::Bool, _error: *mut NSError| { - let _ = tx.send(granted.as_bool()); - }); - - unsafe { - event_store.requestFullAccessToEventsWithCompletion(&*completion as *const _ as *mut _) - }; - - let _ = rx.recv_timeout(std::time::Duration::from_secs(60)); - } - - Ok(()) + app.request_calendar_permission() + .await + .map_err(|e| e.to_string()) } #[tauri::command] #[specta::specta] -pub async fn check_contacts_permission( - _app: tauri::AppHandle, -) -> Result { - #[cfg(target_os = "macos")] - { - let status = - unsafe { CNContactStore::authorizationStatusForEntityType(CNEntityType::Contacts) }; - Ok(status.into()) - } - - #[cfg(not(target_os = "macos"))] - { - Ok(PermissionStatus::Denied) - } +pub(crate) async fn check_contacts_permission( + app: tauri::AppHandle, +) -> Result { + app.check_contacts_permission() + .await + .map_err(|e| e.to_string()) } #[tauri::command] #[specta::specta] -pub async fn request_contacts_permission( - #[allow(unused_variables)] app: tauri::AppHandle, +pub(crate) async fn request_contacts_permission( + app: tauri::AppHandle, ) -> Result<(), String> { - #[cfg(target_os = "macos")] - { - use objc2_foundation::NSError; - use tauri_plugin_shell::ShellExt; - - let bundle_id = app.config().identifier.clone(); - app.shell() - .command("tccutil") - .args(["reset", "AddressBook", &bundle_id]) - .spawn() - .ok(); - - let contacts_store = unsafe { CNContactStore::new() }; - let (tx, rx) = std::sync::mpsc::channel::(); - let completion = - block2::RcBlock::new(move |granted: objc2::runtime::Bool, _error: *mut NSError| { - let _ = tx.send(granted.as_bool()); - }); - - unsafe { - contacts_store - .requestAccessForEntityType_completionHandler(CNEntityType::Contacts, &completion); - }; - - let _ = rx.recv_timeout(std::time::Duration::from_secs(60)); - } - - Ok(()) + app.request_contacts_permission() + .await + .map_err(|e| e.to_string()) } #[tauri::command] #[specta::specta] -pub async fn open_calendar_settings( - _app: tauri::AppHandle, +pub(crate) async fn open_calendar_settings( + app: tauri::AppHandle, ) -> Result<(), String> { - #[cfg(target_os = "macos")] - { - std::process::Command::new("open") - .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars") - .spawn() - .map_err(|e| e.to_string())? - .wait() - .map_err(|e| e.to_string())?; - } - - Ok(()) + app.open_calendar_settings() + .await + .map_err(|e| e.to_string()) } #[tauri::command] #[specta::specta] -pub async fn open_contacts_settings( - _app: tauri::AppHandle, +pub(crate) async fn open_contacts_settings( + app: tauri::AppHandle, ) -> Result<(), String> { - #[cfg(target_os = "macos")] - { - std::process::Command::new("open") - .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Contacts") - .spawn() - .map_err(|e| e.to_string())? - .wait() - .map_err(|e| e.to_string())?; - } - - Ok(()) + app.open_contacts_settings() + .await + .map_err(|e| e.to_string()) } diff --git a/plugins/permissions/src/ext.rs b/plugins/permissions/src/ext.rs new file mode 100644 index 0000000000..24a3da43ef --- /dev/null +++ b/plugins/permissions/src/ext.rs @@ -0,0 +1,271 @@ +use std::future::Future; + +use crate::models::PermissionStatus; + +#[cfg(target_os = "macos")] +use block2::StackBlock; +#[cfg(target_os = "macos")] +use objc2_av_foundation::{AVCaptureDevice, AVMediaTypeAudio}; +#[cfg(target_os = "macos")] +use objc2_contacts::{CNContactStore, CNEntityType}; +#[cfg(target_os = "macos")] +use objc2_event_kit::{EKEntityType, EKEventStore}; + +pub trait PermissionsPluginExt { + fn check_microphone_permission( + &self, + ) -> impl Future>; + fn request_microphone_permission(&self) -> impl Future>; + fn check_system_audio_permission( + &self, + ) -> impl Future>; + fn request_system_audio_permission(&self) -> impl Future>; + fn check_accessibility_permission( + &self, + ) -> impl Future>; + fn request_accessibility_permission(&self) -> impl Future>; + fn check_calendar_permission( + &self, + ) -> impl Future>; + fn request_calendar_permission(&self) -> impl Future>; + fn check_contacts_permission( + &self, + ) -> impl Future>; + fn request_contacts_permission(&self) -> impl Future>; + fn open_calendar_settings(&self) -> impl Future>; + fn open_contacts_settings(&self) -> impl Future>; +} + +impl> crate::PermissionsPluginExt for T { + async fn check_microphone_permission(&self) -> Result { + #[cfg(target_os = "macos")] + { + let status = unsafe { + let media_type = AVMediaTypeAudio.unwrap(); + AVCaptureDevice::authorizationStatusForMediaType(media_type) + }; + Ok(status.into()) + } + + #[cfg(not(target_os = "macos"))] + { + use futures_util::StreamExt; + let mut mic_sample_stream = + hypr_audio::AudioInput::from_mic(None)?.stream(); + let sample = mic_sample_stream.next().await; + Ok(if sample.is_some() { + PermissionStatus::Authorized + } else { + PermissionStatus::Denied + }) + } + } + + async fn request_microphone_permission(&self) -> Result<(), crate::Error> { + #[cfg(target_os = "macos")] + { + unsafe { + let media_type = AVMediaTypeAudio.unwrap(); + let block = StackBlock::new(|_granted| {}); + AVCaptureDevice::requestAccessForMediaType_completionHandler(media_type, &block); + } + } + + #[cfg(not(target_os = "macos"))] + { + use futures_util::StreamExt; + let mut mic_sample_stream = + hypr_audio::AudioInput::from_mic(None)?.stream(); + mic_sample_stream.next().await; + } + + Ok(()) + } + + async fn check_system_audio_permission(&self) -> Result { + #[cfg(target_os = "macos")] + { + let status = hypr_tcc::audio_capture_permission_status(); + Ok(status.into()) + } + + #[cfg(not(target_os = "macos"))] + { + use futures_util::StreamExt; + let mut speaker_sample_stream = hypr_audio::AudioInput::from_speaker().stream(); + let sample = speaker_sample_stream.next().await; + Ok(if sample.is_some() { + PermissionStatus::Authorized + } else { + PermissionStatus::Denied + }) + } + } + + async fn request_system_audio_permission(&self) -> Result<(), crate::Error> { + #[cfg(target_os = "macos")] + { + use tauri_plugin_shell::ShellExt; + + let bundle_id = self.config().identifier.clone(); + self.shell() + .command("tccutil") + .args(["reset", "AudioCapture", &bundle_id]) + .spawn() + .ok(); + } + + let stop = hypr_audio::AudioOutput::silence(); + + use futures_util::StreamExt; + let mut speaker_sample_stream = hypr_audio::AudioInput::from_speaker().stream(); + speaker_sample_stream.next().await; + + let _ = stop.send(()); + Ok(()) + } + + async fn check_accessibility_permission(&self) -> Result { + #[cfg(target_os = "macos")] + { + let is_trusted = + macos_accessibility_client::accessibility::application_is_trusted(); + Ok(if is_trusted { + PermissionStatus::Authorized + } else { + PermissionStatus::Denied + }) + } + + #[cfg(not(target_os = "macos"))] + { + Ok(PermissionStatus::Denied) + } + } + + async fn request_accessibility_permission(&self) -> Result<(), crate::Error> { + #[cfg(target_os = "macos")] + { + macos_accessibility_client::accessibility::application_is_trusted_with_prompt(); + } + + Ok(()) + } + + async fn check_calendar_permission(&self) -> Result { + #[cfg(target_os = "macos")] + { + let status = + unsafe { EKEventStore::authorizationStatusForEntityType(EKEntityType::Event) }; + Ok(status.into()) + } + + #[cfg(not(target_os = "macos"))] + { + Ok(PermissionStatus::Denied) + } + } + + async fn request_calendar_permission(&self) -> Result<(), crate::Error> { + #[cfg(target_os = "macos")] + { + use objc2_foundation::NSError; + use tauri_plugin_shell::ShellExt; + + let bundle_id = self.config().identifier.clone(); + self.shell() + .command("tccutil") + .args(["reset", "Calendar", &bundle_id]) + .spawn() + .ok(); + + let event_store = unsafe { EKEventStore::new() }; + let (tx, rx) = std::sync::mpsc::channel::(); + let completion = + block2::RcBlock::new(move |granted: objc2::runtime::Bool, _error: *mut NSError| { + let _ = tx.send(granted.as_bool()); + }); + + unsafe { + event_store + .requestFullAccessToEventsWithCompletion(&*completion as *const _ as *mut _) + }; + + let _ = rx.recv_timeout(std::time::Duration::from_secs(60)); + } + + Ok(()) + } + + async fn check_contacts_permission(&self) -> Result { + #[cfg(target_os = "macos")] + { + let status = unsafe { + CNContactStore::authorizationStatusForEntityType(CNEntityType::Contacts) + }; + Ok(status.into()) + } + + #[cfg(not(target_os = "macos"))] + { + Ok(PermissionStatus::Denied) + } + } + + async fn request_contacts_permission(&self) -> Result<(), crate::Error> { + #[cfg(target_os = "macos")] + { + use objc2_foundation::NSError; + use tauri_plugin_shell::ShellExt; + + let bundle_id = self.config().identifier.clone(); + self.shell() + .command("tccutil") + .args(["reset", "AddressBook", &bundle_id]) + .spawn() + .ok(); + + let contacts_store = unsafe { CNContactStore::new() }; + let (tx, rx) = std::sync::mpsc::channel::(); + let completion = + block2::RcBlock::new(move |granted: objc2::runtime::Bool, _error: *mut NSError| { + let _ = tx.send(granted.as_bool()); + }); + + unsafe { + contacts_store.requestAccessForEntityType_completionHandler( + CNEntityType::Contacts, + &completion, + ); + }; + + let _ = rx.recv_timeout(std::time::Duration::from_secs(60)); + } + + Ok(()) + } + + async fn open_calendar_settings(&self) -> Result<(), crate::Error> { + #[cfg(target_os = "macos")] + { + std::process::Command::new("open") + .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars") + .spawn()? + .wait()?; + } + + Ok(()) + } + + async fn open_contacts_settings(&self) -> Result<(), crate::Error> { + #[cfg(target_os = "macos")] + { + std::process::Command::new("open") + .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Contacts") + .spawn()? + .wait()?; + } + + Ok(()) + } +} diff --git a/plugins/permissions/src/lib.rs b/plugins/permissions/src/lib.rs index fce8ee01ab..01328ebc09 100644 --- a/plugins/permissions/src/lib.rs +++ b/plugins/permissions/src/lib.rs @@ -1,8 +1,10 @@ mod commands; mod error; +mod ext; mod models; pub use error::{Error, Result}; +pub use ext::*; pub use models::PermissionStatus; const PLUGIN_NAME: &str = "permissions"; @@ -27,7 +29,7 @@ fn make_specta_builder() -> tauri_specta::Builder { .error_handling(tauri_specta::ErrorHandlingMode::Result) } -pub fn init() -> tauri::plugin::TauriPlugin { +pub fn init() -> tauri::plugin::TauriPlugin { let specta_builder = make_specta_builder(); tauri::plugin::Builder::new(PLUGIN_NAME) @@ -56,4 +58,19 @@ mod test { let content = std::fs::read_to_string(OUTPUT_FILE).unwrap(); std::fs::write(OUTPUT_FILE, format!("// @ts-nocheck\n{content}")).unwrap(); } + + fn create_app(builder: tauri::Builder) -> tauri::App { + builder + .plugin(tauri_plugin_shell::init()) + .plugin(init()) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap() + } + + #[tokio::test] + async fn test_permissions() { + let app = create_app(tauri::test::mock_builder()); + let status = app.check_calendar_permission().await; + println!("status: {:?}", status); + } }