diff --git a/apps/desktop/src/main/calendar/provider/adapter.ts b/apps/desktop/src/main/calendar/provider/adapter.ts index 6680c66e8..f79721506 100644 --- a/apps/desktop/src/main/calendar/provider/adapter.ts +++ b/apps/desktop/src/main/calendar/provider/adapter.ts @@ -7,33 +7,20 @@ import type { import type { CalendarSyncSourceType } from '../types' /** - * How a provider tells us "what changed since last time". + * The capability model lives in contracts because the renderer needs it too — + * a provider that cannot write must not be offered a "push events" toggle. + * Re-exported here under the main-process names so adapter code reads locally. * - * - `sync-token` — Google: opaque token returned with each page - * - `delta-link` — Microsoft Graph: `@odata.deltaLink` - * - `sync-collection` — CalDAV RFC 6578 - * - `ctag-etag` — CalDAV without RFC 6578: collection ctag, then per-item etags - * - `conditional-get` — plain HTTP `ETag` / `Last-Modified` on a whole feed (ICS) - * - `full` — no incremental support; every pass re-reads everything + * `incrementalMode` is how a provider tells us "what changed since last time": + * `sync-token` (Google), `delta-link` (Microsoft Graph), `sync-collection` + * (CalDAV RFC 6578), `ctag-etag` (CalDAV without it), `conditional-get` + * (plain HTTP ETag on a whole ICS feed), `full` (no incremental support). */ -export type ProviderIncrementalMode = - 'sync-token' | 'delta-link' | 'sync-collection' | 'ctag-etag' | 'conditional-get' | 'full' - -/** Which connect flow the UI shell has to render for this provider. */ -export type ProviderAuthFlow = 'oauth2' | 'basic' | 'url' | 'none' - -export interface ProviderCapabilities { - /** False for read-only providers (ICS). The engine — not the adapter — refuses writes. */ - supportsWrite: boolean - /** Can we provision our own "memrynote" calendar on the remote? */ - supportsCreateCalendar: boolean - /** Real-time change notifications. False means the runner polls. */ - supportsPush: boolean - /** More than one connected account per provider. */ - supportsMultiAccount: boolean - incrementalMode: ProviderIncrementalMode - authFlow: ProviderAuthFlow -} +export type { + CalendarProviderAuthFlow as ProviderAuthFlow, + CalendarProviderCapabilities as ProviderCapabilities, + CalendarProviderIncrementalMode as ProviderIncrementalMode +} from '@memry/contracts/calendar-api' export interface RemoteCalendarDescriptor { id: string diff --git a/apps/desktop/src/main/calendar/provider/builtin.ts b/apps/desktop/src/main/calendar/provider/builtin.ts new file mode 100644 index 000000000..c17a773f7 --- /dev/null +++ b/apps/desktop/src/main/calendar/provider/builtin.ts @@ -0,0 +1,23 @@ +import { googleProviderDefinition } from '../providers/google/provider-definition' +import { registerProvider } from './registry' + +/** + * Registers the providers this build ships with. + * + * Kept out of `registry.ts` so the registry never imports a provider and the + * providers never import the registry's contents — the definitions only need + * its types. Idempotent: handler registration runs more than once across a + * window teardown/rebuild, and the tests call it directly. + */ +let registered = false + +export function ensureBuiltInCalendarProviders(): void { + if (registered) return + registered = true + registerProvider(googleProviderDefinition) +} + +// Register on import as well, so a module that only reads the registry (an +// agent tool, a sync effect) cannot observe an empty one just because it +// loaded before the IPC handlers did. +ensureBuiltInCalendarProviders() diff --git a/apps/desktop/src/main/calendar/provider/registry.test.ts b/apps/desktop/src/main/calendar/provider/registry.test.ts new file mode 100644 index 000000000..deda9e6ca --- /dev/null +++ b/apps/desktop/src/main/calendar/provider/registry.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { ensureBuiltInCalendarProviders } from './builtin' +import { + getProvider, + getProviderCapabilities, + listProviders, + registerProvider, + unsupportedProviderMessage, + type CalendarProviderDefinition +} from './registry' + +describe('calendar provider registry', () => { + it('resolves google from the built-in registration', () => { + ensureBuiltInCalendarProviders() + + const google = getProvider('google') + + expect(google?.id).toBe('google') + expect(google?.capabilities).toEqual({ + supportsWrite: true, + supportsCreateCalendar: true, + supportsPush: true, + supportsMultiAccount: true, + incrementalMode: 'sync-token', + authFlow: 'oauth2' + }) + }) + + it('returns null for a provider this build does not ship', () => { + ensureBuiltInCalendarProviders() + + expect(getProvider('caldav')).toBeNull() + expect(getProviderCapabilities('caldav')).toBeNull() + }) + + it('keeps the unsupported-provider message byte-identical to the old guards', () => { + expect(unsupportedProviderMessage('caldav')).toBe('Unsupported calendar provider: caldav') + expect(unsupportedProviderMessage('ics')).toBe('Unsupported calendar provider: ics') + }) + + it('registering a second provider does not disturb the first', () => { + ensureBuiltInCalendarProviders() + const readOnly = { + id: 'test-read-only', + capabilities: { + supportsWrite: false, + supportsCreateCalendar: false, + supportsPush: false, + supportsMultiAccount: false, + incrementalMode: 'conditional-get', + authFlow: 'url' + } + } as CalendarProviderDefinition + + registerProvider(readOnly) + + expect(getProvider('test-read-only')?.capabilities.supportsWrite).toBe(false) + expect(getProvider('google')?.capabilities.supportsWrite).toBe(true) + expect(listProviders().map((definition) => definition.id)).toEqual( + expect.arrayContaining(['google', 'test-read-only']) + ) + }) +}) diff --git a/apps/desktop/src/main/calendar/provider/registry.ts b/apps/desktop/src/main/calendar/provider/registry.ts new file mode 100644 index 000000000..303250ce7 --- /dev/null +++ b/apps/desktop/src/main/calendar/provider/registry.ts @@ -0,0 +1,101 @@ +import type { + CalendarProviderCapabilities, + ListProviderCalendarsResponse, + SetDefaultProviderCalendarResponse +} from '@memry/contracts/calendar-api' +import type { DataDb } from '../../database/types' +import type { CalendarProviderAdapter } from './adapter' + +/** + * What a provider hands back once the user has finished its connect flow. + * The caller turns this into the `calendar_sources` rows — id conventions and + * sync bookkeeping are the engine's business, not the provider's. + */ +export interface ProviderConnectResult { + accountId: string + account: { + remoteId: string + title: string + timezone: string | null + email: string + } + primaryCalendar: { + remoteId: string + title: string + timezone: string | null + color: string | null + isPrimary: boolean + } +} + +export interface CalendarProviderDefinition { + readonly id: string + readonly capabilities: CalendarProviderCapabilities + + /** An adapter bound to one connected account. */ + createAdapter(accountId: string): CalendarProviderAdapter + + /** Run the provider's own connect flow (OAuth window, URL prompt, …). */ + connect(input: { accountId?: string }): Promise + + /** Forget one account's stored credentials. Must tolerate an unknown id. */ + disconnect(accountId: string): Promise + + /** Accounts this provider currently has rows for. */ + listAccountIds(db: DataDb): string[] + + /** The account a provider-wide request (list calendars, push) routes to. */ + resolveDefaultAccountId(db: DataDb): string | null + + /** The provider's calendars, plus which one is the current default target. */ + listCalendars(db: DataDb): Promise + + /** Persist the onboarding choice of default target calendar. */ + setDefaultCalendar( + db: DataDb, + input: { calendarId: string | null; markOnboardingComplete: boolean } + ): SetDefaultProviderCalendarResponse + + /** Does any / this one account still hold usable local credentials? */ + hasLocalAuth(db: DataDb): Promise + hasAccountLocalAuth(accountId: string): Promise + + /** Refresh the calendar list for one account into `calendar_sources`. */ + discoverSources(db: DataDb, accountId: string): Promise + + /** Pull everything this provider has, now. */ + syncNow(db: DataDb): Promise + + /** Re-run one `calendar_sources` row. */ + syncSource(db: DataDb, sourceId: string): Promise + + startSyncRunner(): Promise + stopSyncRunner(): void +} + +const providers = new Map() + +export function registerProvider(definition: CalendarProviderDefinition): void { + providers.set(definition.id, definition) +} + +export function getProvider(id: string): CalendarProviderDefinition | null { + return providers.get(id) ?? null +} + +export function listProviders(): CalendarProviderDefinition[] { + return [...providers.values()] +} + +export function getProviderCapabilities(id: string): CalendarProviderCapabilities | null { + return providers.get(id)?.capabilities ?? null +} + +/** + * The message an unregistered provider gets. Kept as one function so every + * call site says exactly the same thing — it is asserted verbatim by the IPC + * tests, and the renderer surfaces it as-is. + */ +export function unsupportedProviderMessage(id: string): string { + return `Unsupported calendar provider: ${id}` +} diff --git a/apps/desktop/src/main/calendar/providers/google/provider-definition.ts b/apps/desktop/src/main/calendar/providers/google/provider-definition.ts new file mode 100644 index 000000000..b97688e0c --- /dev/null +++ b/apps/desktop/src/main/calendar/providers/google/provider-definition.ts @@ -0,0 +1,105 @@ +import type { + CalendarProviderCapabilities, + ListProviderCalendarsResponse, + SetDefaultProviderCalendarResponse +} from '@memry/contracts/calendar-api' +import type { DataDb } from '../../../database/types' +import type { CalendarProviderDefinition, ProviderConnectResult } from '../../provider/registry' +import { createGoogleCalendarClient } from './client' +import { listGoogleCalendars, setDefaultGoogleCalendar } from './onboarding' +import { + connectGoogleCalendar, + disconnectGoogleCalendar, + hasAnyGoogleCalendarLocalAuth, + hasGoogleCalendarLocalAuth, + listGoogleAccountIds, + resolveDefaultGoogleAccountId +} from './oauth' +import { + discoverGoogleCalendarSources, + startGoogleCalendarSyncRunner, + stopGoogleCalendarSyncRunner, + syncGoogleCalendarNow, + syncGoogleCalendarSource +} from './sync-service' + +export const GOOGLE_PROVIDER_ID = 'google' + +export const GOOGLE_CAPABILITIES: CalendarProviderCapabilities = { + supportsWrite: true, + supportsCreateCalendar: true, + supportsPush: true, + supportsMultiAccount: true, + incrementalMode: 'sync-token', + authFlow: 'oauth2' +} + +export const googleProviderDefinition: CalendarProviderDefinition = { + id: GOOGLE_PROVIDER_ID, + capabilities: GOOGLE_CAPABILITIES, + + createAdapter(accountId: string) { + return createGoogleCalendarClient({ accountId }) + }, + + async connect(): Promise { + return await connectGoogleCalendar() + }, + + async disconnect(accountId: string): Promise { + await disconnectGoogleCalendar(accountId) + }, + + listAccountIds(db: DataDb): string[] { + return listGoogleAccountIds(db) + }, + + resolveDefaultAccountId(db: DataDb): string | null { + return resolveDefaultGoogleAccountId(db) + }, + + async listCalendars(db: DataDb): Promise { + const accountId = resolveDefaultGoogleAccountId(db) + if (!accountId) { + return { calendars: [], primary: null, currentDefaultId: null } + } + return await listGoogleCalendars(db, createGoogleCalendarClient({ accountId })) + }, + + setDefaultCalendar( + db: DataDb, + input: { calendarId: string | null; markOnboardingComplete: boolean } + ): SetDefaultProviderCalendarResponse { + // Still writes the `calendar.google` settings group verbatim; the + // per-provider settings namespace lands in #1394. + return setDefaultGoogleCalendar(db, input) + }, + + async hasLocalAuth(db: DataDb): Promise { + return await hasAnyGoogleCalendarLocalAuth(db) + }, + + async hasAccountLocalAuth(accountId: string): Promise { + return await hasGoogleCalendarLocalAuth(accountId) + }, + + async discoverSources(db: DataDb, accountId: string): Promise { + await discoverGoogleCalendarSources(db, createGoogleCalendarClient({ accountId }), accountId) + }, + + async syncNow(db: DataDb): Promise { + await syncGoogleCalendarNow(db) + }, + + async syncSource(db: DataDb, sourceId: string): Promise { + await syncGoogleCalendarSource(db, sourceId) + }, + + async startSyncRunner(): Promise { + await startGoogleCalendarSyncRunner() + }, + + stopSyncRunner(): void { + stopGoogleCalendarSyncRunner() + } +} diff --git a/apps/desktop/src/main/ipc/calendar-handlers.test.ts b/apps/desktop/src/main/ipc/calendar-handlers.test.ts index e855eea6c..36e91d06f 100644 --- a/apps/desktop/src/main/ipc/calendar-handlers.test.ts +++ b/apps/desktop/src/main/ipc/calendar-handlers.test.ts @@ -127,6 +127,14 @@ import { enqueueLocalSyncUpdate } from '../sync/local-mutations' import { registerCalendarHandlers, unregisterCalendarHandlers } from './calendar-handlers' +import { GOOGLE_CAPABILITIES } from '../calendar/providers/google/provider-definition' + +/** + * Every provider status now carries the registry's capability record. Asserted + * against the definition itself rather than a copied literal, so a capability + * flip has to be a deliberate edit in one place. + */ +const GOOGLE_PROVIDER_CAPABILITIES = GOOGLE_CAPABILITIES describe('calendar-handlers', () => { let dbResult: TestDatabaseResult @@ -499,6 +507,7 @@ describe('calendar-handlers', () => { }) expect(status).toEqual({ provider: 'google', + capabilities: GOOGLE_PROVIDER_CAPABILITIES, connected: true, hasLocalAuth: false, account: expect.objectContaining({ @@ -543,6 +552,7 @@ describe('calendar-handlers', () => { success: true, status: { provider: 'google', + capabilities: GOOGLE_PROVIDER_CAPABILITIES, connected: true, hasLocalAuth: true, account: { @@ -586,6 +596,7 @@ describe('calendar-handlers', () => { success: true, status: { provider: 'google', + capabilities: GOOGLE_PROVIDER_CAPABILITIES, connected: false, hasLocalAuth: false, account: null, @@ -1183,6 +1194,7 @@ describe('calendar-handlers', () => { success: false, status: { provider: 'google', + capabilities: GOOGLE_PROVIDER_CAPABILITIES, connected: false, hasLocalAuth: false, account: null, @@ -1238,6 +1250,7 @@ describe('calendar-handlers', () => { success: true, status: { provider: 'google', + capabilities: GOOGLE_PROVIDER_CAPABILITIES, connected: true, hasLocalAuth: true, account: { @@ -1318,4 +1331,110 @@ describe('calendar-handlers', () => { // #then — an empty list, not an error expect(found.events).toEqual([]) }) + + describe('provider registry (#1392)', () => { + it('reports the providers this build can connect, with their capabilities', async () => { + registerCalendarHandlers() + + const listed = await invokeHandler(CalendarChannels.invoke.LIST_PROVIDERS) + + expect(listed).toEqual({ + providers: [{ id: 'google', capabilities: GOOGLE_PROVIDER_CAPABILITIES }] + }) + }) + + it.each([ + CalendarChannels.invoke.CONNECT_PROVIDER, + CalendarChannels.invoke.DISCONNECT_PROVIDER, + CalendarChannels.invoke.REFRESH_PROVIDER + ])('rejects an unregistered provider on %s with the historical message', async (channel) => { + registerCalendarHandlers() + + const result = await invokeHandler(channel, { provider: 'caldav' }) + + // Byte-identical to the string the four `!== 'google'` guards returned. + expect(result.success).toBe(false) + expect(result.error).toBe('Unsupported calendar provider: caldav') + // …and it reports the unknown provider's status rather than throwing, + // with null capabilities because this build does not know it. + expect(result.status).toMatchObject({ provider: 'caldav', capabilities: null }) + }) + + it('never runs a provider flow for an unregistered provider', async () => { + registerCalendarHandlers() + + await invokeHandler(CalendarChannels.invoke.CONNECT_PROVIDER, { provider: 'ics' }) + + expect(mockConnectGoogleCalendar).not.toHaveBeenCalled() + expect(mockStartGoogleCalendarSyncRunner).not.toHaveBeenCalled() + }) + + describe('legacy channels are permanent aliases', () => { + // An older renderer talking to a newer main still sends these. Deleting + // one breaks the app for the length of a partial update, so they are a + // compatibility surface, not dead code. + it('calendar:list-google-calendars resolves to the google provider', async () => { + registerCalendarHandlers() + mockResolveDefaultGoogleAccountId.mockReturnValue('user@example.com') + + const legacy = await invokeHandler(CalendarChannels.invoke.LIST_GOOGLE_CALENDARS, {}) + const generic = await invokeHandler(CalendarChannels.invoke.LIST_PROVIDER_CALENDARS, { + provider: 'google' + }) + + expect(legacy).toEqual(generic) + expect(mockListGoogleCalendars).toHaveBeenCalledTimes(2) + }) + + it('calendar:set-default-google-calendar resolves to the google provider', async () => { + registerCalendarHandlers() + + const legacy = await invokeHandler(CalendarChannels.invoke.SET_DEFAULT_GOOGLE_CALENDAR, { + calendarId: 'cal-1', + markOnboardingComplete: true + }) + + expect(legacy).toMatchObject({ success: true }) + expect(mockSetDefaultGoogleCalendar).toHaveBeenCalledWith(expect.anything(), { + calendarId: 'cal-1', + markOnboardingComplete: true + }) + }) + + it('calendar:retry-google-source-sync and calendar:retry-source-sync share one handler', async () => { + registerCalendarHandlers() + db.run(sql` + INSERT INTO calendar_sources ( + id, provider, kind, account_id, remote_id, title, timezone, + is_selected, sync_status, created_at, modified_at + ) VALUES ( + ${'google-calendar-1'}, ${'google'}, ${'calendar'}, + ${'user@example.com'}, ${'primary'}, ${'Primary'}, ${'UTC'}, + ${1}, ${'ok'}, ${'2026-04-12T08:00:00.000Z'}, ${'2026-04-12T08:00:00.000Z'} + ) + `) + + const legacy = await invokeHandler( + CalendarChannels.invoke.RETRY_GOOGLE_CALENDAR_SOURCE_SYNC, + { sourceId: 'google-calendar-1' } + ) + const generic = await invokeHandler(CalendarChannels.invoke.RETRY_SOURCE_SYNC, { + sourceId: 'google-calendar-1' + }) + + expect(legacy.success).toBe(true) + expect(generic.success).toBe(true) + expect(mockSyncGoogleCalendarSource).toHaveBeenCalledTimes(2) + }) + + it('unregisters every channel it registered, legacy included', () => { + registerCalendarHandlers() + const registered = handleCalls.map(([channel]) => channel as string) + + unregisterCalendarHandlers() + + expect(removeHandlerCalls).toEqual(expect.arrayContaining(registered)) + }) + }) + }) }) diff --git a/apps/desktop/src/main/ipc/calendar-handlers.ts b/apps/desktop/src/main/ipc/calendar-handlers.ts index 11a6bd24c..04b6fa4d6 100644 --- a/apps/desktop/src/main/ipc/calendar-handlers.ts +++ b/apps/desktop/src/main/ipc/calendar-handlers.ts @@ -8,10 +8,12 @@ import { ListCalendarEventsSchema, ListCalendarSourcesSchema, ListGoogleCalendarsSchema, + ListProviderCalendarsSchema, PromoteExternalEventSchema, RetryCalendarSourceSyncSchema, SearchCalendarEventsSchema, SetDefaultGoogleCalendarSchema, + SetDefaultProviderCalendarSchema, UpdateCalendarSourceSelectionSchema, CalendarProviderRequestSchema, UpdateCalendarEventSchema, @@ -30,10 +32,11 @@ import { type CalendarSourceListResponse, type CalendarSourceMutationResponse, type CalendarSourceRecord, - type ListGoogleCalendarsResponse, + type ListCalendarProvidersResponse, + type ListProviderCalendarsResponse, type PromoteExternalEventResponse, type RetryCalendarSourceSyncResponse, - type SetDefaultGoogleCalendarResponse + type SetDefaultProviderCalendarResponse } from '@memry/contracts/calendar-api' import { calendarEvents } from '@memry/db-schema/schema/calendar-events' import { calendarExternalEvents } from '@memry/db-schema/schema/calendar-external-events' @@ -45,36 +48,23 @@ import { trackMainError } from '../telemetry/diagnostics' import { trackMainEvent } from '../telemetry/track' import { requireDatabase, getIndexDatabase, type DataDb } from '../database' import { generateId } from '../lib/id' -import { createStringHandler, createValidatedHandler, withDb } from './validate' +import { createHandler, createStringHandler, createValidatedHandler, withDb } from './validate' +import { ensureBuiltInCalendarProviders } from '../calendar/provider/builtin' +import { + getProvider, + listProviders, + unsupportedProviderMessage, + type CalendarProviderDefinition +} from '../calendar/provider/registry' import { getCalendarSourceById, listCalendarSources as listCalendarSourceRows, upsertCalendarSource } from '../calendar/repositories/calendar-sources-repository' import { searchCalendarEventsByTitle } from '../calendar/repositories/calendar-events-repository' -import { - connectGoogleCalendar, - disconnectGoogleCalendar, - hasAnyGoogleCalendarLocalAuth, - hasGoogleCalendarLocalAuth, - listGoogleAccountIds, - resolveDefaultGoogleAccountId -} from '../calendar/providers/google/oauth' import { getCalendarRangeProjection } from '../calendar/projection' import { getCalendarEnabledPropertyNames } from '../calendar/calendar-property-visibility' import { getCalendarSettings } from './settings-handlers' -import { - discoverGoogleCalendarSources, - startGoogleCalendarSyncRunner, - stopGoogleCalendarSyncRunner, - syncGoogleCalendarNow, - syncGoogleCalendarSource -} from '../calendar/providers/google/sync-service' -import { - listGoogleCalendars, - setDefaultGoogleCalendar -} from '../calendar/providers/google/onboarding' -import { createGoogleCalendarClient } from '../calendar/providers/google/client' import { getGooglePushRuntime } from '../calendar/providers/google/push-runtime' import { promoteExternalEvent, @@ -170,8 +160,8 @@ async function buildProviderAccountStatus( if (!accountId) return null const metadata = (source.metadata as { email?: string; lastError?: string } | null) ?? null - const hasLocalAuth = - source.provider === 'google' ? await hasGoogleCalendarLocalAuth(accountId) : false + const definition = getProvider(source.provider) + const hasLocalAuth = definition ? await definition.hasAccountLocalAuth(accountId) : false let status: CalendarProviderAccountConnectionStatus if (!hasLocalAuth) { @@ -192,6 +182,7 @@ async function buildProviderAccountStatus( } async function buildProviderStatus(db: DataDb, provider: string): Promise { + const definition = getProvider(provider) const allSources = listCalendarSourceRows(db, { provider }) const accountSources = allSources.filter((source) => source.kind === 'account') const account = accountSources[0] ?? null @@ -200,7 +191,10 @@ async function buildProviderStatus(db: DataDb, provider: string): Promise source.lastSyncedAt ?? null), ...calendars.map((source) => source.lastSyncedAt ?? null) ].filter((value): value is string => Boolean(value)) - const hasLocalAuth = provider === 'google' ? await hasAnyGoogleCalendarLocalAuth(db) : false + // An unregistered provider has no credentials we know how to read, so it + // reports no local auth — same answer the `provider === 'google'` check gave + // for everything that was not Google. + const hasLocalAuth = definition ? await definition.hasLocalAuth(db) : false const accounts: CalendarProviderAccountStatus[] = [] for (const source of accountSources) { @@ -210,6 +204,7 @@ async function buildProviderStatus(db: DataDb, provider: string): Promise { + const provider = definition.id try { - await disconnectGoogleCalendar(accountId) + await definition.disconnect(accountId) } catch (err) { - log.warn('Google Calendar disconnect failed', { accountId, err }) + log.warn('Calendar provider account disconnect failed', { provider, accountId, err }) } trackMainEvent('calendar_google_disconnected', { @@ -357,7 +353,9 @@ async function disconnectGoogleAccount( } } - const pushRuntime = getGooglePushRuntime() + // Only push-capable providers have channels to tear down. The runtime itself + // is still Google's until the relay is generalized (#1404). + const pushRuntime = definition.capabilities.supportsPush ? getGooglePushRuntime() : null if (pushRuntime) { for (const source of targetSources) { if (source.kind !== 'calendar' || source.isMemryManaged) continue @@ -400,6 +398,8 @@ async function disconnectGoogleAccount( } export function registerCalendarHandlers(): void { + ensureBuiltInCalendarProviders() + ipcMain.handle( CalendarChannels.invoke.CREATE_EVENT, createValidatedHandler( @@ -671,21 +671,25 @@ export function registerCalendarHandlers(): void { createValidatedHandler( CalendarProviderRequestSchema, withDb(async (db, input): Promise => { - if (input.provider !== 'google') { + const definition = getProvider(input.provider) + if (!definition) { return { success: false, status: await buildProviderStatus(db, input.provider), - error: `Unsupported calendar provider: ${input.provider}` + error: unsupportedProviderMessage(input.provider) } } - const connected = await connectGoogleCalendar() + const connected = await definition.connect({ accountId: input.accountId }) const now = new Date().toISOString() - const accountSourceId = `google-account:${connected.accountId}` - const primaryCalendarSourceId = `google-calendar:${connected.primaryCalendar.remoteId}` + // `${providerId}-account:` / `${providerId}-calendar:` — for google this + // produces byte-identical ids to the literals it replaces, so existing + // rows keep matching. + const accountSourceId = `${definition.id}-account:${connected.accountId}` + const primaryCalendarSourceId = `${definition.id}-calendar:${connected.primaryCalendar.remoteId}` syncCalendarSourceUpsert(db, { id: accountSourceId, - provider: 'google', + provider: definition.id, kind: 'account', accountId: connected.accountId, remoteId: connected.account.remoteId, @@ -709,7 +713,7 @@ export function registerCalendarHandlers(): void { syncCalendarSourceUpsert(db, { id: primaryCalendarSourceId, - provider: 'google', + provider: definition.id, kind: 'calendar', accountId: connected.accountId, remoteId: connected.primaryCalendar.remoteId, @@ -733,26 +737,28 @@ export function registerCalendarHandlers(): void { // than the primary to offer. Non-fatal: a failure here leaves the user // connected with the primary working, and the next sync retries it. try { - await discoverGoogleCalendarSources( - db, - createGoogleCalendarClient({ accountId: connected.accountId }), - connected.accountId - ) + await definition.discoverSources(db, connected.accountId) } catch (error) { log.warn('Calendar discovery failed after connect', { + provider: definition.id, accountId: connected.accountId, error }) trackMainError('calendar', 'source_discovery', error) } - void startGoogleCalendarSyncRunner().catch((error) => { + void definition.startSyncRunner().catch((error) => { // Only the inner sync self-logs; pre-sync awaits (keychain read, auth // checks) can throw before that. Swallow to keep connect success green. - log.warn('startGoogleCalendarSyncRunner failed after connect', error) + log.warn('Calendar sync runner failed to start after connect', { + provider: definition.id, + error + }) trackMainError('calendar', 'sync_runner_start', error) }) + // Event names stay Google-shaped until the multi-provider telemetry + // pass (#1406); google is still the only registered provider. trackCalendar('calendar_google_connected', 'connected', 'google') return { @@ -768,25 +774,30 @@ export function registerCalendarHandlers(): void { createValidatedHandler( CalendarProviderRequestSchema, withDb(async (db, input): Promise => { - if (input.provider !== 'google') { + const definition = getProvider(input.provider) + if (!definition) { return { success: false, status: await buildProviderStatus(db, input.provider), - error: `Unsupported calendar provider: ${input.provider}` + error: unsupportedProviderMessage(input.provider) } } if (input.accountId) { - return await disconnectGoogleAccount(db, input.provider, input.accountId) + return await disconnectProviderAccount(db, definition, input.accountId) } - stopGoogleCalendarSyncRunner() - const accountIdsToDisconnect = listGoogleAccountIds(db) + definition.stopSyncRunner() + const accountIdsToDisconnect = definition.listAccountIds(db) for (const accountId of accountIdsToDisconnect) { try { - await disconnectGoogleCalendar(accountId) + await definition.disconnect(accountId) } catch (err) { - log.warn('Google Calendar disconnect failed', { accountId, err }) + log.warn('Calendar provider disconnect failed', { + provider: definition.id, + accountId, + err + }) } } @@ -872,11 +883,12 @@ export function registerCalendarHandlers(): void { createValidatedHandler( CalendarProviderRequestSchema, withDb(async (db, input): Promise => { - if (input.provider !== 'google') { + const definition = getProvider(input.provider) + if (!definition) { return { success: false, status: await buildProviderStatus(db, input.provider), - error: `Unsupported calendar provider: ${input.provider}` + error: unsupportedProviderMessage(input.provider) } } @@ -888,7 +900,7 @@ export function registerCalendarHandlers(): void { } } - if (!(await hasAnyGoogleCalendarLocalAuth(db))) { + if (!(await definition.hasLocalAuth(db))) { return { success: false, status: await buildProviderStatus(db, input.provider), @@ -896,8 +908,8 @@ export function registerCalendarHandlers(): void { } } - await syncGoogleCalendarNow(db) - emitCalendarChanged({ entityType: 'projection', id: 'google-refresh' }) + await definition.syncNow(db) + emitCalendarChanged({ entityType: 'projection', id: `${definition.id}-refresh` }) trackCalendar('calendar_google_sync_completed', 'sync_completed', 'google') @@ -909,70 +921,122 @@ export function registerCalendarHandlers(): void { ) ) + ipcMain.handle( + CalendarChannels.invoke.LIST_PROVIDERS, + createHandler((): ListCalendarProvidersResponse => { + return { + providers: listProviders().map((definition) => ({ + id: definition.id, + capabilities: definition.capabilities + })) + } + }) + ) + + const listProviderCalendarsHandler = withDb( + async (db, input: { provider: string }): Promise => { + const definition = getProvider(input.provider) + // An unregistered provider has no calendars to offer. The picker renders + // an empty list rather than an error — the same shape a connected + // provider with no accounts already returns. + if (!definition) return { calendars: [], primary: null, currentDefaultId: null } + return await definition.listCalendars(db) + }, + 'errors:calendar.listGoogleCalendarsFailed' + ) + + ipcMain.handle( + CalendarChannels.invoke.LIST_PROVIDER_CALENDARS, + createValidatedHandler(ListProviderCalendarsSchema, listProviderCalendarsHandler) + ) + + // Legacy alias — an older renderer sends no provider, which has always meant + // google. See the comment on the channel constant: never remove this. ipcMain.handle( CalendarChannels.invoke.LIST_GOOGLE_CALENDARS, - createValidatedHandler( - ListGoogleCalendarsSchema, - withDb(async (db): Promise => { - const accountId = resolveDefaultGoogleAccountId(db) - if (!accountId) { - return { calendars: [], primary: null, currentDefaultId: null } - } - return await listGoogleCalendars(db, createGoogleCalendarClient({ accountId })) - }, 'errors:calendar.listGoogleCalendarsFailed') + createValidatedHandler(ListGoogleCalendarsSchema, async () => + listProviderCalendarsHandler({ provider: 'google' }) ) ) + const setDefaultProviderCalendarHandler = withDb( + ( + db, + input: { provider: string; calendarId: string | null; markOnboardingComplete: boolean } + ): SetDefaultProviderCalendarResponse => { + const definition = getProvider(input.provider) + if (!definition) { + return { success: false, error: unsupportedProviderMessage(input.provider) } + } + return definition.setDefaultCalendar(db, { + calendarId: input.calendarId, + markOnboardingComplete: input.markOnboardingComplete + }) + }, + 'errors:calendar.setDefaultGoogleCalendarFailed' + ) + + ipcMain.handle( + CalendarChannels.invoke.SET_DEFAULT_PROVIDER_CALENDAR, + createValidatedHandler(SetDefaultProviderCalendarSchema, setDefaultProviderCalendarHandler) + ) + + // Legacy alias — see above. ipcMain.handle( CalendarChannels.invoke.SET_DEFAULT_GOOGLE_CALENDAR, - createValidatedHandler( - SetDefaultGoogleCalendarSchema, - withDb((db, input): SetDefaultGoogleCalendarResponse => { - return setDefaultGoogleCalendar(db, input) - }, 'errors:calendar.setDefaultGoogleCalendarFailed') + createValidatedHandler(SetDefaultGoogleCalendarSchema, (input) => + setDefaultProviderCalendarHandler({ ...input, provider: 'google' }) ) ) - ipcMain.handle( - CalendarChannels.invoke.RETRY_GOOGLE_CALENDAR_SOURCE_SYNC, - createValidatedHandler( - RetryCalendarSourceSyncSchema, - withDb(async (db, input): Promise => { - const source = getCalendarSourceById(db, input.sourceId) - if (!source) { - return { - success: false, - source: null, - error: getMainI18n().t('errors:calendar.sourceNotFound') - } - } - if (source.provider !== 'google' || source.kind !== 'calendar') { - return { - success: false, - source: null, - error: getMainI18n().t('errors:calendar.onlyGoogleSourcesRetryable') - } + const retrySourceSyncHandler = withDb( + async (db, input: { sourceId: string }): Promise => { + const source = getCalendarSourceById(db, input.sourceId) + if (!source) { + return { + success: false, + source: null, + error: getMainI18n().t('errors:calendar.sourceNotFound') } - try { - await syncGoogleCalendarSource(db, source.id) - } catch (err) { - log.warn('Google Calendar source retry sync failed', err) - trackMainError('calendar', 'google_source_retry', err) - const updated = getCalendarSourceById(db, source.id) - return { - success: false, - source: updated ? mapCalendarSource(updated) : null, - error: - err instanceof Error ? err.message : getMainI18n().t('errors:calendar.syncFailed') - } + } + const definition = getProvider(source.provider) + if (!definition || source.kind !== 'calendar') { + return { + success: false, + source: null, + error: getMainI18n().t('errors:calendar.onlyGoogleSourcesRetryable') } - const refreshed = getCalendarSourceById(db, source.id) + } + try { + await definition.syncSource(db, source.id) + } catch (err) { + log.warn('Calendar source retry sync failed', { provider: definition.id, err }) + trackMainError('calendar', 'google_source_retry', err) + const updated = getCalendarSourceById(db, source.id) return { - success: true, - source: refreshed ? mapCalendarSource(refreshed) : null + success: false, + source: updated ? mapCalendarSource(updated) : null, + error: err instanceof Error ? err.message : getMainI18n().t('errors:calendar.syncFailed') } - }, 'errors:calendar.retryGoogleSourceSyncFailed') - ) + } + const refreshed = getCalendarSourceById(db, source.id) + return { + success: true, + source: refreshed ? mapCalendarSource(refreshed) : null + } + }, + 'errors:calendar.retryGoogleSourceSyncFailed' + ) + + ipcMain.handle( + CalendarChannels.invoke.RETRY_SOURCE_SYNC, + createValidatedHandler(RetryCalendarSourceSyncSchema, retrySourceSyncHandler) + ) + + // Legacy alias — see above. + ipcMain.handle( + CalendarChannels.invoke.RETRY_GOOGLE_CALENDAR_SOURCE_SYNC, + createValidatedHandler(RetryCalendarSourceSyncSchema, retrySourceSyncHandler) ) ipcMain.handle( @@ -1013,6 +1077,10 @@ export function unregisterCalendarHandlers(): void { ipcMain.removeHandler(CalendarChannels.invoke.CONNECT_PROVIDER) ipcMain.removeHandler(CalendarChannels.invoke.DISCONNECT_PROVIDER) ipcMain.removeHandler(CalendarChannels.invoke.REFRESH_PROVIDER) + ipcMain.removeHandler(CalendarChannels.invoke.LIST_PROVIDERS) + ipcMain.removeHandler(CalendarChannels.invoke.LIST_PROVIDER_CALENDARS) + ipcMain.removeHandler(CalendarChannels.invoke.SET_DEFAULT_PROVIDER_CALENDAR) + ipcMain.removeHandler(CalendarChannels.invoke.RETRY_SOURCE_SYNC) ipcMain.removeHandler(CalendarChannels.invoke.LIST_GOOGLE_CALENDARS) ipcMain.removeHandler(CalendarChannels.invoke.SET_DEFAULT_GOOGLE_CALENDAR) ipcMain.removeHandler(CalendarChannels.invoke.PROMOTE_EXTERNAL_EVENT) diff --git a/apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts b/apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts index 783cbe370..d339abf89 100644 --- a/apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts +++ b/apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts @@ -62,12 +62,16 @@ export interface MainIpcInvokeHandlers { "calendar:get-range": (...args: [{ startAt: string; endAt: string; includeUnselectedSources?: boolean | undefined; includeExternal?: boolean | undefined; }]) => Awaited> "calendar:list-events": (...args: [{ includeArchived?: boolean | undefined; }]) => Awaited> "calendar:list-google-calendars": (...args: [Record | undefined]) => Awaited> + "calendar:list-provider-calendars": (...args: [{ provider?: string | undefined; } | undefined]) => Awaited> + "calendar:list-providers": (...args: []) => Awaited> "calendar:list-sources": (...args: [{ provider?: string | undefined; kind?: "calendar" | "account" | undefined; selectedOnly?: boolean | undefined; }]) => Awaited> "calendar:promote-external-event": (...args: [{ externalEventId: string; }]) => Awaited> "calendar:refresh-provider": (...args: [{ provider: string; accountId?: string | undefined; }]) => Awaited> "calendar:retry-google-source-sync": (...args: [{ sourceId: string; }]) => Awaited> + "calendar:retry-source-sync": (...args: [{ sourceId: string; }]) => Awaited> "calendar:search-events": (...args: [{ query: string; limit?: number | undefined; }]) => Awaited> "calendar:set-default-google-calendar": (...args: [{ calendarId: string | null; markOnboardingComplete?: boolean | undefined; }]) => Awaited> + "calendar:set-default-provider-calendar": (...args: [{ calendarId: string | null; provider?: string | undefined; markOnboardingComplete?: boolean | undefined; }]) => Awaited> "calendar:update-event": (...args: [{ id: string; title?: string | undefined; description?: string | null | undefined; location?: string | null | undefined; startAt?: string | undefined; endAt?: string | null | undefined; timezone?: string | undefined; isAllDay?: boolean | undefined; recurrenceRule?: Record | null | undefined; recurrenceExceptions?: string[] | null | undefined; targetCalendarId?: string | null | undefined; }]) => Awaited> "calendar:update-source-selection": (...args: [{ id: string; isSelected: boolean; }]) => Awaited> "canvas:create": (...args: [{ title?: string | null | undefined; scene?: string | undefined; folder?: string | null | undefined; icon?: string | null | undefined; }]) => Awaited> @@ -275,6 +279,7 @@ export interface MainIpcInvokeHandlers { "settings:getAISettings": (...args: []) => Awaited "settings:getBackupSettings": (...args: []) => Awaited<{ autoBackup: boolean; frequencyHours: 1 | 6 | 12 | 24; maxBackups: number; lastBackupAt: string | null; }> "settings:getCalendarGoogleSettings": (...args: []) => Awaited<{ defaultTargetCalendarId: string | null; onboardingCompleted: boolean; promoteConfirmDismissed: boolean; pushEventsToGoogle: boolean; agentReadEventsConsent: boolean | null; }> + "settings:getCalendarProviderSettings": (...args: [any]) => Awaited<{ defaultTargetCalendarId: string | null; onboardingCompleted: boolean; promoteConfirmDismissed: boolean; pushEventsToGoogle: boolean; agentReadEventsConsent: boolean | null; }> "settings:getCalendarSettings": (...args: []) => Awaited<{ dayCellClickBehavior: "calendar" | "journal"; calendarPageClickOverride: "calendar" | "inherit" | "journal"; weekStartDay: "sunday" | "monday"; showNotesOnCalendar: boolean; }> "settings:getEditorSettings": (...args: []) => Awaited<{ width: string; toolbarMode: "floating" | "sticky"; spellCheck: boolean; }> "settings:getFeaturesSettings": (...args: []) => Awaited<{ home: boolean; inbox: boolean; journal: boolean; tasks: boolean; calendar: boolean; graph: boolean; spatialCanvas: boolean; }> @@ -303,6 +308,7 @@ export interface MainIpcInvokeHandlers { "settings:setAISettings": (...args: [Partial]) => Awaited<{ success: boolean; error: string; } | { success: boolean; error?: undefined; }> "settings:setBackupSettings": (...args: [Partial<{ autoBackup: boolean; frequencyHours: 1 | 6 | 12 | 24; maxBackups: number; lastBackupAt: string | null; }>]) => Awaited<{ success: boolean; error?: string | undefined; }> "settings:setCalendarGoogleSettings": (...args: [Partial<{ defaultTargetCalendarId: string | null; onboardingCompleted: boolean; promoteConfirmDismissed: boolean; pushEventsToGoogle: boolean; agentReadEventsConsent: boolean | null; }>]) => Awaited<{ success: boolean; error?: string | undefined; }> + "settings:setCalendarProviderSettings": (...args: [string, Partial<{ defaultTargetCalendarId: string | null; onboardingCompleted: boolean; promoteConfirmDismissed: boolean; pushEventsToGoogle: boolean; agentReadEventsConsent: boolean | null; }>]) => Awaited<{ success: boolean; error?: string | undefined; }> "settings:setCalendarSettings": (...args: [Partial<{ dayCellClickBehavior: "calendar" | "journal"; calendarPageClickOverride: "calendar" | "inherit" | "journal"; weekStartDay: "sunday" | "monday"; showNotesOnCalendar: boolean; }>]) => Awaited<{ success: boolean; error?: string | undefined; }> "settings:setEditorSettings": (...args: [Partial<{ width: "normal" | "full"; toolbarMode: "floating" | "sticky"; spellCheck: boolean; }>]) => Awaited<{ success: boolean; error?: string | undefined; }> "settings:setFeaturesSettings": (...args: [Partial<{ home: boolean; inbox: boolean; journal: boolean; tasks: boolean; calendar: boolean; graph: boolean; spatialCanvas: boolean; }>]) => Awaited<{ success: boolean; error?: string | undefined; }> diff --git a/apps/desktop/src/main/ipc/settings-handlers.ts b/apps/desktop/src/main/ipc/settings-handlers.ts index 8f7e225fa..e01658ec4 100644 --- a/apps/desktop/src/main/ipc/settings-handlers.ts +++ b/apps/desktop/src/main/ipc/settings-handlers.ts @@ -928,6 +928,29 @@ export function registerSettingsHandlers(): void { writeGroupSettings('graph', GRAPH_SETTINGS_DEFAULTS, updates) ) + // `calendar.` — google keeps its exact historical key, so an + // existing install reads and writes the same row it always has. The + // per-provider schema split lands in #1394. + const calendarProviderSettingsKey = (providerId: string): string => `calendar.${providerId}` + + ipcMain.handle(SettingsChannels.invoke.GET_CALENDAR_PROVIDER_SETTINGS, (_event, providerId) => + readGroupSettings( + calendarProviderSettingsKey(String(providerId)), + CALENDAR_GOOGLE_SETTINGS_DEFAULTS + ) + ) + ipcMain.handle( + SettingsChannels.invoke.SET_CALENDAR_PROVIDER_SETTINGS, + (_event, providerId: string, updates: Partial) => + writeGroupSettings( + calendarProviderSettingsKey(String(providerId)), + CALENDAR_GOOGLE_SETTINGS_DEFAULTS, + updates + ) + ) + + // Permanent compatibility aliases: an older renderer against a newer main + // calls these with no provider, which has always meant google. Do not remove. ipcMain.handle(SettingsChannels.invoke.GET_CALENDAR_GOOGLE_SETTINGS, () => readGroupSettings('calendar.google', CALENDAR_GOOGLE_SETTINGS_DEFAULTS) ) @@ -1169,6 +1192,8 @@ export function unregisterSettingsHandlers(): void { ipcMain.removeHandler(SettingsChannels.invoke.SET_BACKUP_SETTINGS) ipcMain.removeHandler(SettingsChannels.invoke.GET_GRAPH_SETTINGS) ipcMain.removeHandler(SettingsChannels.invoke.SET_GRAPH_SETTINGS) + ipcMain.removeHandler(SettingsChannels.invoke.GET_CALENDAR_PROVIDER_SETTINGS) + ipcMain.removeHandler(SettingsChannels.invoke.SET_CALENDAR_PROVIDER_SETTINGS) ipcMain.removeHandler(SettingsChannels.invoke.GET_CALENDAR_GOOGLE_SETTINGS) ipcMain.removeHandler(SettingsChannels.invoke.SET_CALENDAR_GOOGLE_SETTINGS) ipcMain.removeHandler(SettingsChannels.invoke.GET_CALENDAR_SETTINGS) diff --git a/apps/desktop/src/preload/generated-rpc.ts b/apps/desktop/src/preload/generated-rpc.ts index bca5f6d55..0ed7cb51c 100644 --- a/apps/desktop/src/preload/generated-rpc.ts +++ b/apps/desktop/src/preload/generated-rpc.ts @@ -275,6 +275,8 @@ export function createGeneratedRpcApi({ "setBackupSettings": ((settings) => invoke("settings:setBackupSettings", settings)) as GeneratedRpcApi["settings"]["setBackupSettings"], "getGraphSettings": (() => invoke("settings:getGraphSettings")) as GeneratedRpcApi["settings"]["getGraphSettings"], "setGraphSettings": ((settings) => invoke("settings:setGraphSettings", settings)) as GeneratedRpcApi["settings"]["setGraphSettings"], + "getCalendarProviderSettings": ((providerId) => invoke("settings:getCalendarProviderSettings", providerId)) as GeneratedRpcApi["settings"]["getCalendarProviderSettings"], + "setCalendarProviderSettings": ((providerId, settings) => invoke("settings:setCalendarProviderSettings", providerId, settings)) as GeneratedRpcApi["settings"]["setCalendarProviderSettings"], "getCalendarGoogleSettings": (() => invoke("settings:getCalendarGoogleSettings")) as GeneratedRpcApi["settings"]["getCalendarGoogleSettings"], "setCalendarGoogleSettings": ((settings) => invoke("settings:setCalendarGoogleSettings", settings)) as GeneratedRpcApi["settings"]["setCalendarGoogleSettings"], "getCalendarSettings": (() => invoke("settings:getCalendarSettings")) as GeneratedRpcApi["settings"]["getCalendarSettings"], @@ -304,6 +306,10 @@ export function createGeneratedRpcApi({ "connectProvider": ((input) => invoke("calendar:connect-provider", input)) as GeneratedRpcApi["calendar"]["connectProvider"], "disconnectProvider": ((input) => invoke("calendar:disconnect-provider", input)) as GeneratedRpcApi["calendar"]["disconnectProvider"], "refreshProvider": ((input) => invoke("calendar:refresh-provider", input)) as GeneratedRpcApi["calendar"]["refreshProvider"], + "listProviders": (() => invoke("calendar:list-providers")) as GeneratedRpcApi["calendar"]["listProviders"], + "listProviderCalendars": ((options) => invoke("calendar:list-provider-calendars", options ?? { provider: 'google' })) as GeneratedRpcApi["calendar"]["listProviderCalendars"], + "setDefaultProviderCalendar": ((input) => invoke("calendar:set-default-provider-calendar", input)) as GeneratedRpcApi["calendar"]["setDefaultProviderCalendar"], + "retryCalendarSourceSync": ((input) => invoke("calendar:retry-source-sync", input)) as GeneratedRpcApi["calendar"]["retryCalendarSourceSync"], "listGoogleCalendars": ((options) => invoke("calendar:list-google-calendars", options ?? {})) as GeneratedRpcApi["calendar"]["listGoogleCalendars"], "setDefaultGoogleCalendar": ((input) => invoke("calendar:set-default-google-calendar", input)) as GeneratedRpcApi["calendar"]["setDefaultGoogleCalendar"], "promoteExternalEvent": ((input) => invoke("calendar:promote-external-event", input)) as GeneratedRpcApi["calendar"]["promoteExternalEvent"], diff --git a/packages/contracts/src/agent-mcp-channels.test.ts b/packages/contracts/src/agent-mcp-channels.test.ts index a9c7e409e..53cff0137 100644 --- a/packages/contracts/src/agent-mcp-channels.test.ts +++ b/packages/contracts/src/agent-mcp-channels.test.ts @@ -5,13 +5,23 @@ import { AgentMcpDesktopReadOperations, AgentMcpDesktopWriteOperations } from '. // Google Workspace Limited Use compliance: data obtained through the Google // Calendar integration must never be readable by AI backends, so no // Google-integration operation may appear in the agent allowlists. +// +// The provider-neutral twins (#1392) are listed alongside the Google-named +// originals: they reach the same connected accounts, so allowlisting one would +// re-open exactly the hole the Google-named exclusion closes. const googleIntegrationOperations = [ 'calendar.listSources', 'calendar.getProviderStatus', + 'calendar.listProviders', + 'calendar.listProviderCalendars', 'calendar.listGoogleCalendars', 'calendar.promoteExternalEvent', 'calendar.updateSourceSelection', + 'calendar.setDefaultProviderCalendar', 'calendar.setDefaultGoogleCalendar', + 'calendar.retryCalendarSourceSync', + 'settings.getCalendarProviderSettings', + 'settings.setCalendarProviderSettings', 'settings.getCalendarGoogleSettings', 'settings.setCalendarGoogleSettings' ] as const diff --git a/packages/contracts/src/calendar-api.ts b/packages/contracts/src/calendar-api.ts index 315d47758..8af8aa9a2 100644 --- a/packages/contracts/src/calendar-api.ts +++ b/packages/contracts/src/calendar-api.ts @@ -71,6 +71,55 @@ export const SetDefaultGoogleCalendarSchema = z.object({ markOnboardingComplete: z.boolean().default(true) }) +// ============================================================================ +// Calendar provider capability model +// ============================================================================ +// +// Mirrors `main/calendar/provider/adapter.ts`. It lives in contracts because +// the renderer needs it: a provider that cannot write must not be offered a +// "push events to provider" toggle, and one that cannot push must not claim +// real-time sync. `provider` stays a plain string everywhere — the set of +// providers is open, and old clients must tolerate ids they have never heard of. + +export const CalendarProviderIncrementalModeSchema = z.enum([ + 'sync-token', + 'delta-link', + 'sync-collection', + 'ctag-etag', + 'conditional-get', + 'full' +]) + +export const CalendarProviderAuthFlowSchema = z.enum(['oauth2', 'basic', 'url', 'none']) + +export const CalendarProviderCapabilitiesSchema = z.object({ + supportsWrite: z.boolean(), + supportsCreateCalendar: z.boolean(), + supportsPush: z.boolean(), + supportsMultiAccount: z.boolean(), + incrementalMode: CalendarProviderIncrementalModeSchema, + authFlow: CalendarProviderAuthFlowSchema +}) + +export type CalendarProviderIncrementalMode = z.infer +export type CalendarProviderAuthFlow = z.infer +export type CalendarProviderCapabilities = z.infer + +/** The provider-neutral form of `ListGoogleCalendarsSchema`. */ +export const ListProviderCalendarsSchema = z + .object({ + provider: z.string().min(1).default('google') + }) + .optional() + .default({ provider: 'google' }) + +/** The provider-neutral form of `SetDefaultGoogleCalendarSchema`. */ +export const SetDefaultProviderCalendarSchema = z.object({ + provider: z.string().min(1).default('google'), + calendarId: z.string().nullable(), + markOnboardingComplete: z.boolean().default(true) +}) + export const ListCalendarEventsSchema = z.object({ includeArchived: z.boolean().default(false) }) @@ -135,6 +184,8 @@ export type CalendarProviderRequest = z.infer export type ListGoogleCalendarsInput = z.infer export type SetDefaultGoogleCalendarInput = z.infer +export type ListProviderCalendarsInput = z.infer +export type SetDefaultProviderCalendarInput = z.infer export interface CalendarEventAttendeeRecord { email: string @@ -279,10 +330,7 @@ export interface CalendarProjectionItem { } export type CalendarProviderAccountConnectionStatus = - | 'connected' - | 'disconnected' - | 'reconnect_required' - | 'error' + 'connected' | 'disconnected' | 'reconnect_required' | 'error' export interface CalendarProviderAccountStatus { accountId: string @@ -294,6 +342,12 @@ export interface CalendarProviderAccountStatus { export interface CalendarProviderStatus { provider: string + /** + * What this provider can do, so the renderer can hide affordances it does + * not have. `null` when the provider id is not registered in this build — + * which is exactly what an older client sees for a provider added later. + */ + capabilities: CalendarProviderCapabilities | null connected: boolean hasLocalAuth: boolean account: Pick | null @@ -391,3 +445,19 @@ export interface SetDefaultGoogleCalendarResponse { success: boolean error?: string } + +// Provider-neutral aliases for the shapes above. Same objects on the wire — +// only the names stop saying "Google". +export type ProviderCalendarDescriptorRecord = GoogleCalendarDescriptorRecord +export type ListProviderCalendarsResponse = ListGoogleCalendarsResponse +export type SetDefaultProviderCalendarResponse = SetDefaultGoogleCalendarResponse + +/** One entry of `calendar:list-providers` — what main is willing to connect. */ +export interface CalendarProviderDescriptor { + id: string + capabilities: CalendarProviderCapabilities +} + +export interface ListCalendarProvidersResponse { + providers: CalendarProviderDescriptor[] +} diff --git a/packages/contracts/src/ipc-channels.ts b/packages/contracts/src/ipc-channels.ts index ed1345878..35d26340b 100644 --- a/packages/contracts/src/ipc-channels.ts +++ b/packages/contracts/src/ipc-channels.ts @@ -447,9 +447,17 @@ export const SettingsChannels = { GET_BACKUP_SETTINGS: 'settings:getBackupSettings', /** Update backup configuration */ SET_BACKUP_SETTINGS: 'settings:setBackupSettings', - /** M2: get Google Calendar defaults (target calendar, onboarding flag, promote-dialog flag) */ + /** Get one calendar provider's defaults (target calendar, onboarding flag, promote-dialog flag) */ + GET_CALENDAR_PROVIDER_SETTINGS: 'settings:getCalendarProviderSettings', + /** Update one calendar provider's defaults (partial merge) */ + SET_CALENDAR_PROVIDER_SETTINGS: 'settings:setCalendarProviderSettings', + /** + * M2: get Google Calendar defaults. Permanent compatibility alias for + * GET_CALENDAR_PROVIDER_SETTINGS with `provider` pinned to 'google' — an + * older renderer against a newer main still calls it. Do not delete. + */ GET_CALENDAR_GOOGLE_SETTINGS: 'settings:getCalendarGoogleSettings', - /** M2: update Google Calendar defaults (partial merge) */ + /** M2: update Google Calendar defaults (partial merge). Permanent alias, see above. */ SET_CALENDAR_GOOGLE_SETTINGS: 'settings:setCalendarGoogleSettings', /** Get calendar preferences (day panel dot source + click behavior) */ GET_CALENDAR_SETTINGS: 'settings:getCalendarSettings', @@ -676,6 +684,22 @@ export const CalendarChannels = { REFRESH_PROVIDER: 'calendar:refresh-provider', /** M2: copy an external Google event into an editable Memry event */ PROMOTE_EXTERNAL_EVENT: 'calendar:promote-external-event', + /** Providers this build can connect, with their capabilities (#1392) */ + LIST_PROVIDERS: 'calendar:list-providers', + /** List one provider's calendars for target/default selection */ + LIST_PROVIDER_CALENDARS: 'calendar:list-provider-calendars', + /** Persist the onboarding choice for a provider's default target calendar */ + SET_DEFAULT_PROVIDER_CALENDAR: 'calendar:set-default-provider-calendar', + /** Re-run sync for a single calendar source (Retry button on sync-health UI) */ + RETRY_SOURCE_SYNC: 'calendar:retry-source-sync', + + // ------------------------------------------------------------------ + // Permanent compatibility aliases. NOT dead code — during a partial + // update an older renderer talks to a newer main, and removing a channel + // breaks the app for the length of that window. These resolve to the same + // handlers as their provider-neutral twins above, with `provider` pinned + // to 'google'. Do not delete them. + // ------------------------------------------------------------------ /** M2: list the user's Google calendars for target/default selection */ LIST_GOOGLE_CALENDARS: 'calendar:list-google-calendars', /** M2: persist the onboarding choice for default target Google calendar */ diff --git a/packages/rpc/src/calendar.ts b/packages/rpc/src/calendar.ts index bf9781e5c..06e62d78c 100644 --- a/packages/rpc/src/calendar.ts +++ b/packages/rpc/src/calendar.ts @@ -5,12 +5,14 @@ import { UpdateCalendarEventSchema, ListCalendarEventsSchema, ListGoogleCalendarsSchema, + ListProviderCalendarsSchema, GetCalendarRangeSchema, ListCalendarSourcesSchema, PromoteExternalEventSchema, RetryCalendarSourceSyncSchema, SearchCalendarEventsSchema, SetDefaultGoogleCalendarSchema, + SetDefaultProviderCalendarSchema, UpdateCalendarSourceSelectionSchema, CalendarProviderRequestSchema, type CalendarChangedEvent, @@ -27,10 +29,13 @@ import { type CalendarSourceListResponse, type CalendarSourceMutationResponse, type CalendarSourceRecord, + type ListCalendarProvidersResponse, type ListGoogleCalendarsResponse, + type ListProviderCalendarsResponse, type PromoteExternalEventResponse, type RetryCalendarSourceSyncResponse, - type SetDefaultGoogleCalendarResponse + type SetDefaultGoogleCalendarResponse, + type SetDefaultProviderCalendarResponse } from '../../contracts/src/calendar-api.ts' import { defineDomain, @@ -49,8 +54,10 @@ export type ListCalendarSourcesInput = z.input export type UpdateCalendarSourceSelectionInput = z.input export type CalendarProviderRequest = z.input export type ListGoogleCalendarsInput = z.input +export type ListProviderCalendarsInput = z.input export type PromoteExternalEventInput = z.input export type SetDefaultGoogleCalendarInput = z.input +export type SetDefaultProviderCalendarInput = z.input export type RetryCalendarSourceSyncInput = z.input export type { @@ -68,10 +75,13 @@ export type { CalendarSourceListResponse, CalendarSourceMutationResponse, CalendarSourceRecord, + ListCalendarProvidersResponse, ListGoogleCalendarsResponse, + ListProviderCalendarsResponse, PromoteExternalEventResponse, RetryCalendarSourceSyncResponse, - SetDefaultGoogleCalendarResponse + SetDefaultGoogleCalendarResponse, + SetDefaultProviderCalendarResponse } export const calendarRpc = defineDomain({ @@ -151,6 +161,30 @@ export const calendarRpc = defineDomain({ channel: CalendarChannels.invoke.REFRESH_PROVIDER, params: ['input'] }), + listProviders: defineMethod<() => Promise>({ + channel: CalendarChannels.invoke.LIST_PROVIDERS, + params: [] + }), + listProviderCalendars: defineMethod< + (options?: ListProviderCalendarsInput) => Promise + >({ + channel: CalendarChannels.invoke.LIST_PROVIDER_CALENDARS, + params: ['options'], + invokeArgs: ["options ?? { provider: 'google' }"] + }), + setDefaultProviderCalendar: defineMethod< + (input: SetDefaultProviderCalendarInput) => Promise + >({ + channel: CalendarChannels.invoke.SET_DEFAULT_PROVIDER_CALENDAR, + params: ['input'] + }), + retryCalendarSourceSync: defineMethod< + (input: RetryCalendarSourceSyncInput) => Promise + >({ + channel: CalendarChannels.invoke.RETRY_SOURCE_SYNC, + params: ['input'] + }), + // Permanent compatibility aliases — see the channel constants. Never remove. listGoogleCalendars: defineMethod< (options?: ListGoogleCalendarsInput) => Promise >({ diff --git a/packages/rpc/src/settings.ts b/packages/rpc/src/settings.ts index 2baadac83..49d826e76 100644 --- a/packages/rpc/src/settings.ts +++ b/packages/rpc/src/settings.ts @@ -282,6 +282,19 @@ export const settingsRpc = defineDomain({ channel: SettingsChannels.invoke.SET_GRAPH_SETTINGS, params: ['settings'] }), + getCalendarProviderSettings: defineMethod< + (providerId: string) => Promise + >({ + channel: SettingsChannels.invoke.GET_CALENDAR_PROVIDER_SETTINGS, + params: ['providerId'] + }), + setCalendarProviderSettings: defineMethod< + (providerId: string, settings: Partial) => SuccessResponse + >({ + channel: SettingsChannels.invoke.SET_CALENDAR_PROVIDER_SETTINGS, + params: ['providerId', 'settings'] + }), + // Permanent compatibility aliases — see the channel constants. Never remove. getCalendarGoogleSettings: defineMethod<() => Promise>({ channel: SettingsChannels.invoke.GET_CALENDAR_GOOGLE_SETTINGS }),