diff --git a/apps/desktop/src/main/calendar/provider/credentials.test.ts b/apps/desktop/src/main/calendar/provider/credentials.test.ts new file mode 100644 index 000000000..a6bbd49cb --- /dev/null +++ b/apps/desktop/src/main/calendar/provider/credentials.test.ts @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import keytar from 'keytar' + +vi.mock('keytar', () => ({ + default: { + setPassword: vi.fn(), + getPassword: vi.fn(), + deletePassword: vi.fn() + } +})) + +import { + calendarCredentialService, + deleteProviderSecret, + getProviderAccountKey, + getProviderSecret, + setProviderSecret +} from './credentials' +import { LEGACY_DEFAULT_ACCOUNT_ID, getGoogleCalendarTokens } from '../providers/google/keychain' + +describe('per-provider calendar credentials (#1394)', () => { + const keytarStore = new Map() + + beforeEach(() => { + vi.clearAllMocks() + keytarStore.clear() + delete process.env.MEMRY_DEVICE + + vi.mocked(keytar.setPassword).mockImplementation(async (service, account, value) => { + keytarStore.set(`${service}:${account}`, value) + }) + vi.mocked(keytar.getPassword).mockImplementation( + async (service, account) => keytarStore.get(`${service}:${account}`) ?? null + ) + vi.mocked(keytar.deletePassword).mockImplementation(async (service, account) => + keytarStore.delete(`${service}:${account}`) + ) + }) + + afterEach(() => { + delete process.env.MEMRY_DEVICE + }) + + it('gives google the exact service name the Google-only build hard-coded', () => { + // A different string here strands every credential already on disk. + expect(calendarCredentialService('google')).toBe('com.memry.calendar.google') + expect(calendarCredentialService('caldav')).toBe('com.memry.calendar.caldav') + expect(calendarCredentialService('microsoft')).toBe('com.memry.calendar.microsoft') + }) + + it('preserves the account-key scheme, dev-profile suffix included', () => { + expect(getProviderAccountKey('alice@example.com', 'refresh-token')).toBe( + 'refresh-token-alice@example.com' + ) + + process.env.MEMRY_DEVICE = 'b' + expect(getProviderAccountKey('alice@example.com', 'refresh-token')).toBe( + 'refresh-token-alice@example.com-b' + ) + }) + + it('rejects an empty provider id or account id rather than writing a bare key', async () => { + expect(() => calendarCredentialService('')).toThrow(/non-empty providerId/) + expect(() => getProviderAccountKey(' ', 'password')).toThrow(/non-empty accountId/) + }) + + it('resolves a credential a previous Google-only build wrote', async () => { + // #given the exact keytar entry the old code path produced + keytarStore.set('com.memry.calendar.google:refresh-token-alice@example.com', 'old-refresh') + + // #then both the neutral reader and the Google accessor find it + expect( + await getProviderSecret({ + providerId: 'google', + accountId: 'alice@example.com', + kind: 'refresh-token' + }) + ).toBe('old-refresh') + expect(await getGoogleCalendarTokens('alice@example.com')).toEqual({ + accessToken: null, + refreshToken: 'old-refresh' + }) + }) + + it('resolves a pre-multi-account credential stored under the legacy account id', async () => { + keytarStore.set( + `com.memry.calendar.google:refresh-token-${LEGACY_DEFAULT_ACCOUNT_ID}`, + 'legacy-refresh' + ) + + expect(await getGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)).toEqual({ + accessToken: null, + refreshToken: 'legacy-refresh' + }) + }) + + it('partitions two providers that share an account id', async () => { + await setProviderSecret({ + providerId: 'google', + accountId: 'alice@example.com', + kind: 'refresh-token', + value: 'google-refresh' + }) + await setProviderSecret({ + providerId: 'caldav', + accountId: 'alice@example.com', + kind: 'password', + value: 'app-password' + }) + + expect( + await getProviderSecret({ + providerId: 'google', + accountId: 'alice@example.com', + kind: 'refresh-token' + }) + ).toBe('google-refresh') + expect( + await getProviderSecret({ + providerId: 'caldav', + accountId: 'alice@example.com', + kind: 'password' + }) + ).toBe('app-password') + + // Disconnecting CalDAV must not log the user out of Google. + await deleteProviderSecret({ + providerId: 'caldav', + accountId: 'alice@example.com', + kind: 'password' + }) + expect( + await getProviderSecret({ + providerId: 'google', + accountId: 'alice@example.com', + kind: 'refresh-token' + }) + ).toBe('google-refresh') + }) + + it('treats an empty value as a delete rather than storing a blank secret', async () => { + await setProviderSecret({ + providerId: 'caldav', + accountId: 'alice@example.com', + kind: 'password', + value: 'app-password' + }) + await setProviderSecret({ + providerId: 'caldav', + accountId: 'alice@example.com', + kind: 'password', + value: ' ' + }) + + expect( + await getProviderSecret({ + providerId: 'caldav', + accountId: 'alice@example.com', + kind: 'password' + }) + ).toBeNull() + }) + describe('keychain failures name the provider', () => { + it('wraps a write failure rather than surfacing a bare keytar error', async () => { + vi.mocked(keytar.setPassword).mockRejectedValueOnce(new Error('keychain locked')) + + await expect( + setProviderSecret({ + providerId: 'caldav', + accountId: 'alice@example.com', + kind: 'password', + value: 'app-password' + }) + ).rejects.toThrow(/Failed to store caldav calendar credential .*keychain locked/) + }) + + it('wraps a delete failure the same way', async () => { + vi.mocked(keytar.deletePassword).mockRejectedValueOnce(new Error('keychain locked')) + + await expect( + deleteProviderSecret({ + providerId: 'google', + accountId: 'alice@example.com', + kind: 'refresh-token' + }) + ).rejects.toThrow(/Failed to delete google calendar credential/) + }) + + it('reports a non-Error rejection as an unknown error rather than "undefined"', async () => { + vi.mocked(keytar.setPassword).mockRejectedValueOnce('nope') + + await expect( + setProviderSecret({ + providerId: 'ics', + accountId: 'alice@example.com', + kind: 'password', + value: 'x' + }) + ).rejects.toThrow(/unknown error/) + }) + }) +}) diff --git a/apps/desktop/src/main/calendar/provider/credentials.ts b/apps/desktop/src/main/calendar/provider/credentials.ts new file mode 100644 index 000000000..7741ab80e --- /dev/null +++ b/apps/desktop/src/main/calendar/provider/credentials.ts @@ -0,0 +1,101 @@ +import { deleteSecret, getSecret, setSecret } from '../../secrets/secret-storage' + +/** + * What a calendar provider can need to store. + * + * - `access-token` / `refresh-token` — OAuth2 (Google, Microsoft Graph) + * - `password` — HTTP Basic / app password (CalDAV: Fastmail, Nextcloud, iCloud) + * + * ICS feeds have no secret at all; the URL itself is the credential and lives + * in `calendar_sources.metadata`, not the keychain. + */ +export type ProviderSecretKind = 'access-token' | 'refresh-token' | 'password' + +/** + * One keychain service per provider. `google` resolves to + * `com.memry.calendar.google`, byte-identical to the constant it replaces, so + * an existing install's stored tokens keep resolving. + */ +export function calendarCredentialService(providerId: string): string { + if (!providerId || !providerId.trim()) { + throw new Error('calendarCredentialService requires a non-empty providerId') + } + return `com.memry.calendar.${providerId}` +} + +/** + * The account slot inside a provider's service. Scheme preserved verbatim from + * the Google-only era — `${kind}-${accountId}`, with the dev-profile suffix — + * because changing it would strand every credential already on disk. + */ +export function getProviderAccountKey(accountId: string, kind: ProviderSecretKind): string { + if (!accountId || !accountId.trim()) { + throw new Error('getProviderAccountKey requires a non-empty accountId') + } + const deviceSuffix = process.env.MEMRY_DEVICE + const base = `${kind}-${accountId}` + return deviceSuffix ? `${base}-${deviceSuffix}` : base +} + +export async function setProviderSecret(input: { + providerId: string + accountId: string + kind: ProviderSecretKind + value: string | null +}): Promise { + const service = calendarCredentialService(input.providerId) + const account = getProviderAccountKey(input.accountId, input.kind) + + try { + if (!input.value || input.value.trim().length === 0) { + await deleteSecret(service, account) + return + } + await setSecret(service, account, input.value.trim()) + } catch (error) { + throw new Error( + `Failed to store ${input.providerId} calendar credential (${account}): ${error instanceof Error ? error.message : 'unknown error'}` + ) + } +} + +export async function getProviderSecret(input: { + providerId: string + accountId: string + kind: ProviderSecretKind +}): Promise { + const service = calendarCredentialService(input.providerId) + const account = getProviderAccountKey(input.accountId, input.kind) + + try { + // Every consumer of these secrets either overwrites them (the connect flow + // and the refresh path), deletes them (disconnect), or only asks whether + // the account is still authorized. So an entry we cannot decrypt — the + // profiles stranded by the v2026-08-06 app-identity rename — must read as + // absent rather than throw, otherwise the pre-write read kills the connect + // flow before the fresh credential is ever stored and the account can never + // be reconnected from inside the app. + return await getSecret(service, account, { treatUnreadableAsAbsent: true }) + } catch (error) { + throw new Error( + `Failed to read ${input.providerId} calendar credential (${account}): ${error instanceof Error ? error.message : 'unknown error'}` + ) + } +} + +export async function deleteProviderSecret(input: { + providerId: string + accountId: string + kind: ProviderSecretKind +}): Promise { + const service = calendarCredentialService(input.providerId) + const account = getProviderAccountKey(input.accountId, input.kind) + + try { + await deleteSecret(service, account) + } catch (error) { + throw new Error( + `Failed to delete ${input.providerId} calendar credential (${account}): ${error instanceof Error ? error.message : 'unknown error'}` + ) + } +} diff --git a/apps/desktop/src/main/calendar/provider/settings.test.ts b/apps/desktop/src/main/calendar/provider/settings.test.ts new file mode 100644 index 000000000..915f6f8cf --- /dev/null +++ b/apps/desktop/src/main/calendar/provider/settings.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, afterEach, describe, expect, it } from 'vitest' +import { settings } from '@memry/db-schema/schema/settings' +import { + CalendarGoogleSettingsSchema, + CalendarProviderSettingsSchema +} from '@memry/contracts/settings-schemas' +import { + asClientDb, + createTestDataDb, + type TestDatabaseResult, + type TestDb +} from '@tests/utils/test-db' +import { getSetting } from '../../settings/settings-store' +import { + calendarProviderSettingsKey, + hasAgentReadConsent, + readCalendarProviderSettings, + writeCalendarProviderSettings +} from './settings' + +/** + * The exact JSON an install running the Google-only build wrote to + * `settings['calendar.google']`. Nothing here may need a migration. + */ +const LEGACY_GOOGLE_ROW = { + defaultTargetCalendarId: 'work@group.calendar.google.com', + onboardingCompleted: true, + promoteConfirmDismissed: true, + pushEventsToGoogle: false, + agentReadEventsConsent: true +} + +/** Written before `agentReadEventsConsent` shipped — the key is simply absent. */ +const OLDER_GOOGLE_ROW = { + defaultTargetCalendarId: null, + onboardingCompleted: true, + promoteConfirmDismissed: false, + pushEventsToGoogle: true +} + +describe('per-provider calendar settings (#1394)', () => { + let dbResult: TestDatabaseResult + let db: TestDb + + beforeEach(() => { + dbResult = createTestDataDb() + db = dbResult.db + }) + + afterEach(() => { + dbResult.close() + }) + + function seedGroup(key: string, value: unknown): void { + db.insert(settings) + .values({ key, value: JSON.stringify(value), modifiedAt: '2026-04-12T09:00:00.000Z' }) + .run() + } + + describe('the google group is frozen in its historic shape', () => { + it('parses a settings fixture written by the Google-only build, unchanged', () => { + expect(CalendarGoogleSettingsSchema.safeParse(LEGACY_GOOGLE_ROW).success).toBe(true) + expect(CalendarGoogleSettingsSchema.safeParse(OLDER_GOOGLE_ROW).success).toBe(true) + }) + + it('reads that fixture back through the neutral accessor', () => { + seedGroup('calendar.google', LEGACY_GOOGLE_ROW) + + expect(readCalendarProviderSettings(asClientDb(db), 'google')).toEqual({ + defaultTargetCalendarId: 'work@group.calendar.google.com', + onboardingCompleted: true, + promoteConfirmDismissed: true, + // The one translated key: stored as pushEventsToGoogle, read as neutral. + pushEventsToProvider: false, + agentReadEventsConsent: true + }) + }) + + it('defaults a pre-consent row to "not asked", not to allowed', () => { + seedGroup('calendar.google', OLDER_GOOGLE_ROW) + + const read = readCalendarProviderSettings(asClientDb(db), 'google') + expect(read.agentReadEventsConsent).toBeNull() + expect(hasAgentReadConsent(asClientDb(db), 'google')).toBe(false) + }) + + it('writes back the legacy key and adds no new ones', () => { + seedGroup('calendar.google', LEGACY_GOOGLE_ROW) + + writeCalendarProviderSettings(asClientDb(db), 'google', { pushEventsToProvider: true }) + + const raw = JSON.parse(getSetting(asClientDb(db), 'calendar.google') ?? '{}') as Record< + string, + unknown + > + expect(raw.pushEventsToGoogle).toBe(true) + expect(raw).not.toHaveProperty('pushEventsToProvider') + expect(Object.keys(raw).sort()).toEqual(Object.keys(LEGACY_GOOGLE_ROW).sort()) + }) + + it('keeps the historic group key', () => { + expect(calendarProviderSettingsKey('google')).toBe('calendar.google') + }) + }) + + describe('a new provider gets its own group in the neutral shape', () => { + it('stores under calendar. and never touches google', () => { + seedGroup('calendar.google', LEGACY_GOOGLE_ROW) + + writeCalendarProviderSettings(asClientDb(db), 'caldav', { + defaultTargetCalendarId: 'https://caldav.fastmail.com/personal', + pushEventsToProvider: false + }) + + const caldav = readCalendarProviderSettings(asClientDb(db), 'caldav') + expect(caldav.defaultTargetCalendarId).toBe('https://caldav.fastmail.com/personal') + expect(caldav.pushEventsToProvider).toBe(false) + expect(CalendarProviderSettingsSchema.safeParse(caldav).success).toBe(true) + + // Google's row is untouched — no migration, no cross-writes. + expect(JSON.parse(getSetting(asClientDb(db), 'calendar.google') ?? '{}')).toEqual( + LEGACY_GOOGLE_ROW + ) + }) + + it('starts every provider at "agent may not read", including ones never written', () => { + expect(hasAgentReadConsent(asClientDb(db), 'ics')).toBe(false) + expect(hasAgentReadConsent(asClientDb(db), 'caldav')).toBe(false) + + writeCalendarProviderSettings(asClientDb(db), 'ics', { agentReadEventsConsent: false }) + expect(hasAgentReadConsent(asClientDb(db), 'ics')).toBe(false) + + writeCalendarProviderSettings(asClientDb(db), 'ics', { agentReadEventsConsent: true }) + expect(hasAgentReadConsent(asClientDb(db), 'ics')).toBe(true) + // Consenting to one provider says nothing about another. + expect(hasAgentReadConsent(asClientDb(db), 'caldav')).toBe(false) + }) + + it('falls back to defaults when the stored group is corrupt', () => { + db.insert(settings) + .values({ + key: 'calendar.caldav', + value: 'not json', + modifiedAt: '2026-04-12T09:00:00.000Z' + }) + .run() + + expect(readCalendarProviderSettings(asClientDb(db), 'caldav').pushEventsToProvider).toBe(true) + expect(hasAgentReadConsent(asClientDb(db), 'caldav')).toBe(false) + }) + }) +}) diff --git a/apps/desktop/src/main/calendar/provider/settings.ts b/apps/desktop/src/main/calendar/provider/settings.ts new file mode 100644 index 000000000..75cdd941c --- /dev/null +++ b/apps/desktop/src/main/calendar/provider/settings.ts @@ -0,0 +1,92 @@ +import { + CALENDAR_GOOGLE_SETTINGS_DEFAULTS, + CALENDAR_PROVIDER_SETTINGS_DEFAULTS, + type CalendarGoogleSettings, + type CalendarProviderSettings +} from '@memry/contracts/settings-schemas' +import { getSetting, setSetting } from '../../settings/settings-store' +import type { DataDb } from '../../database/types' + +export const GOOGLE_LEGACY_SETTINGS_PROVIDER_ID = 'google' + +export function calendarProviderSettingsKey(providerId: string): string { + return `calendar.${providerId}` +} + +function readGroup(db: DataDb, key: string, defaults: T): T { + const raw = getSetting(db, key) + if (!raw) return { ...defaults } + try { + const parsed = JSON.parse(raw) as Partial + return { ...defaults, ...parsed } + } catch { + return { ...defaults } + } +} + +/** + * Read one provider's settings in the neutral shape. + * + * Google is the one exception, and it is a naming exception only: its group was + * written before there was a second provider, so its outbound toggle is stored + * as `pushEventsToGoogle`. The row is read and written in that exact shape — + * no migration, no extra keys — and translated here so callers only ever see + * `pushEventsToProvider`. + */ +export function readCalendarProviderSettings( + db: DataDb, + providerId: string +): CalendarProviderSettings { + if (providerId === GOOGLE_LEGACY_SETTINGS_PROVIDER_ID) { + const google = readGroup( + db, + calendarProviderSettingsKey(providerId), + CALENDAR_GOOGLE_SETTINGS_DEFAULTS + ) + return { + defaultTargetCalendarId: google.defaultTargetCalendarId, + onboardingCompleted: google.onboardingCompleted, + promoteConfirmDismissed: google.promoteConfirmDismissed, + pushEventsToProvider: google.pushEventsToGoogle, + agentReadEventsConsent: google.agentReadEventsConsent + } + } + + return readGroup(db, calendarProviderSettingsKey(providerId), CALENDAR_PROVIDER_SETTINGS_DEFAULTS) +} + +/** Merge a partial update into one provider's group, from inside the main process. */ +export function writeCalendarProviderSettings( + db: DataDb, + providerId: string, + updates: Partial +): void { + const key = calendarProviderSettingsKey(providerId) + + if (providerId === GOOGLE_LEGACY_SETTINGS_PROVIDER_ID) { + const current = readGroup(db, key, CALENDAR_GOOGLE_SETTINGS_DEFAULTS) + const { pushEventsToProvider, ...neutral } = updates + const next: CalendarGoogleSettings = { + ...current, + ...neutral, + ...(pushEventsToProvider === undefined ? {} : { pushEventsToGoogle: pushEventsToProvider }) + } + setSetting(db, key, JSON.stringify(next)) + return + } + + const current = readGroup(db, key, CALENDAR_PROVIDER_SETTINGS_DEFAULTS) + setSetting(db, key, JSON.stringify({ ...current, ...updates })) +} + +/** + * Whether the AI agent may read this provider's external events. + * + * The rule is provider-neutral: nothing other than a stored `true` opens the + * gate. `null` — never asked — reads as no, exactly like an explicit refusal. + * Google Workspace Limited Use is what forced the question first; the answer + * became the house rule for every provider. + */ +export function hasAgentReadConsent(db: DataDb, providerId: string): boolean { + return readCalendarProviderSettings(db, providerId).agentReadEventsConsent === true +} diff --git a/apps/desktop/src/main/calendar/providers/google/keychain.ts b/apps/desktop/src/main/calendar/providers/google/keychain.ts index 507fc1943..718a1099f 100644 --- a/apps/desktop/src/main/calendar/providers/google/keychain.ts +++ b/apps/desktop/src/main/calendar/providers/google/keychain.ts @@ -1,18 +1,30 @@ -import { deleteSecret, getSecret, setSecret } from '../../../secrets/secret-storage' - -const SERVICE = 'com.memry.calendar.google' - +import { + deleteProviderSecret, + getProviderAccountKey, + getProviderSecret, + setProviderSecret, + type ProviderSecretKind +} from '../../provider/credentials' +import { GOOGLE_PROVIDER_ID } from './capabilities' + +/** + * Google's slot in the per-provider keychain. The service name resolves to + * `com.memry.calendar.google` — the exact string this file used to hard-code — + * and the account-key scheme is unchanged, so every credential already on disk + * still resolves. No migration. + */ + +/** + * The account id used before multi-account support existed. Still read by the + * OAuth layer when it finds a stored credential with no account attached. + */ export const LEGACY_DEFAULT_ACCOUNT_ID = '__memry_default__' -export type GoogleTokenKind = 'access-token' | 'refresh-token' +/** Google only ever stores OAuth tokens; CalDAV's `password` is not reachable here. */ +export type GoogleTokenKind = Extract export function getAccountKey(accountId: string, kind: GoogleTokenKind): string { - if (!accountId || !accountId.trim()) { - throw new Error('getAccountKey requires a non-empty accountId') - } - const deviceSuffix = process.env.MEMRY_DEVICE - const base = `${kind}-${accountId}` - return deviceSuffix ? `${base}-${deviceSuffix}` : base + return getProviderAccountKey(accountId, kind) } async function setPassword( @@ -20,51 +32,15 @@ async function setPassword( kind: GoogleTokenKind, value: string | null ): Promise { - const account = getAccountKey(accountId, kind) - - try { - if (!value || value.trim().length === 0) { - await deleteSecret(SERVICE, account) - return - } - - await setSecret(SERVICE, account, value.trim()) - } catch (error) { - throw new Error( - `Failed to store Google Calendar credential (${account}): ${error instanceof Error ? error.message : 'unknown error'}` - ) - } + await setProviderSecret({ providerId: GOOGLE_PROVIDER_ID, accountId, kind, value }) } async function getPassword(accountId: string, kind: GoogleTokenKind): Promise { - const account = getAccountKey(accountId, kind) - - try { - // Every consumer of these tokens either overwrites them (the OAuth connect - // and the refresh path), deletes them (disconnect), or only asks whether the - // account is still authorized. So an entry we cannot decrypt — the profiles - // stranded by the v2026-08-06 app-identity rename — must read as absent - // rather than throw, otherwise the pre-write read at oauth.ts kills the - // connect flow before the fresh tokens are ever stored and the account can - // never be reconnected from inside the app. - return await getSecret(SERVICE, account, { treatUnreadableAsAbsent: true }) - } catch (error) { - throw new Error( - `Failed to read Google Calendar credential (${account}): ${error instanceof Error ? error.message : 'unknown error'}` - ) - } + return await getProviderSecret({ providerId: GOOGLE_PROVIDER_ID, accountId, kind }) } async function deletePassword(accountId: string, kind: GoogleTokenKind): Promise { - const account = getAccountKey(accountId, kind) - - try { - await deleteSecret(SERVICE, account) - } catch (error) { - throw new Error( - `Failed to delete Google Calendar credential (${account}): ${error instanceof Error ? error.message : 'unknown error'}` - ) - } + await deleteProviderSecret({ providerId: GOOGLE_PROVIDER_ID, accountId, kind }) } export async function storeGoogleCalendarTokens(input: { diff --git a/apps/desktop/src/main/calendar/providers/google/sync-service.ts b/apps/desktop/src/main/calendar/providers/google/sync-service.ts index 0a29478ee..7873008e6 100644 --- a/apps/desktop/src/main/calendar/providers/google/sync-service.ts +++ b/apps/desktop/src/main/calendar/providers/google/sync-service.ts @@ -8,7 +8,7 @@ import { } from './oauth' import { resolveTargetGoogleAccountId } from './account-routing' import { createGoogleCalendarClient } from './client' -import { readCalendarGoogleSettings } from './calendar-google-settings' +import { readCalendarProviderSettings } from '../../provider/settings' import { GOOGLE_CAPABILITIES, GOOGLE_PROVIDER_ID } from './capabilities' import { applyProviderDelete, @@ -44,10 +44,11 @@ export const googleSyncContext: ProviderSyncContext = { listAccountIds: (db) => listGoogleAccountIds(db), resolveDefaultAccountId: (db) => resolveDefaultGoogleAccountId(db), hasConnection: (db) => hasGoogleCalendarConnection(db), - isPushEnabled: (db) => readCalendarGoogleSettings(db).pushEventsToGoogle, + isPushEnabled: (db) => readCalendarProviderSettings(db, GOOGLE_PROVIDER_ID).pushEventsToProvider, resolveTargetAccountId: (db, target, existingBinding) => resolveTargetGoogleAccountId(db, target, existingBinding), - readDefaultTargetCalendarId: (db) => readCalendarGoogleSettings(db).defaultTargetCalendarId + readDefaultTargetCalendarId: (db) => + readCalendarProviderSettings(db, GOOGLE_PROVIDER_ID).defaultTargetCalendarId } export async function discoverGoogleCalendarSources( 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 d339abf89..38398912f 100644 --- a/apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts +++ b/apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts @@ -279,7 +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:getCalendarProviderSettings": (...args: [any]) => Awaited<{ defaultTargetCalendarId: string | null; onboardingCompleted: boolean; promoteConfirmDismissed: boolean; pushEventsToProvider: 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; }> @@ -308,7 +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:setCalendarProviderSettings": (...args: [string, Partial<{ defaultTargetCalendarId: string | null; onboardingCompleted: boolean; promoteConfirmDismissed: boolean; pushEventsToProvider: boolean; agentReadEventsConsent: boolean | null; }>]) => Awaited<{ success: boolean; error: string; } | { success: boolean; error?: 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.test.ts b/apps/desktop/src/main/ipc/settings-handlers.test.ts index f85970cfc..6b4aafa3a 100644 --- a/apps/desktop/src/main/ipc/settings-handlers.test.ts +++ b/apps/desktop/src/main/ipc/settings-handlers.test.ts @@ -1151,4 +1151,116 @@ describe('settings-handlers', () => { expect(removeHandlerCalls).toContain(SettingsChannels.invoke.SET_CALENDAR_GOOGLE_SETTINGS) }) }) + describe('calendar. settings group (#1394)', () => { + it('#given no stored row #when GET invoked for a new provider #then returns neutral defaults', async () => { + registerSettingsHandlers() + ;(settingsQueries.getSetting as Mock).mockReturnValue(null) + + const result = await invokeHandler<{ + defaultTargetCalendarId: string | null + pushEventsToProvider: boolean + agentReadEventsConsent: boolean | null + }>(SettingsChannels.invoke.GET_CALENDAR_PROVIDER_SETTINGS, 'caldav') + + expect(result.defaultTargetCalendarId).toBeNull() + expect(result.pushEventsToProvider).toBe(true) + // Never asked reads as "the agent may not read this provider". + expect(result.agentReadEventsConsent).toBeNull() + }) + + it('#given a legacy calendar.google row #when GET invoked for google #then translates the historic key', async () => { + registerSettingsHandlers() + ;(settingsQueries.getSetting as Mock).mockReturnValue( + JSON.stringify({ + defaultTargetCalendarId: 'primary@group.calendar.google.com', + onboardingCompleted: true, + promoteConfirmDismissed: true, + pushEventsToGoogle: false, + agentReadEventsConsent: true + }) + ) + + const result = await invokeHandler<{ + defaultTargetCalendarId: string | null + pushEventsToProvider: boolean + }>(SettingsChannels.invoke.GET_CALENDAR_PROVIDER_SETTINGS, 'google') + + expect(settingsQueries.getSetting).toHaveBeenCalledWith(expect.anything(), 'calendar.google') + expect(result.defaultTargetCalendarId).toBe('primary@group.calendar.google.com') + expect(result.pushEventsToProvider).toBe(false) + }) + + it('#when SET invoked for a new provider #then writes calendar. and broadcasts', async () => { + registerSettingsHandlers() + ;(settingsQueries.getSetting as Mock).mockReturnValue(null) + + const result = await invokeHandler<{ success: boolean }>( + SettingsChannels.invoke.SET_CALENDAR_PROVIDER_SETTINGS, + 'caldav', + { agentReadEventsConsent: true } + ) + + expect(result.success).toBe(true) + const [, key, value] = (settingsQueries.setSetting as Mock).mock.calls.at(-1) as [ + unknown, + string, + string + ] + expect(key).toBe('calendar.caldav') + expect(JSON.parse(value)).toMatchObject({ agentReadEventsConsent: true }) + expect(mockSend).toHaveBeenCalledWith( + SettingsChannels.events.CHANGED, + expect.objectContaining({ key: 'calendar.caldav' }) + ) + }) + + it('#when SET invoked for google #then keeps the historic pushEventsToGoogle key', async () => { + registerSettingsHandlers() + ;(settingsQueries.getSetting as Mock).mockReturnValue(null) + + await invokeHandler(SettingsChannels.invoke.SET_CALENDAR_PROVIDER_SETTINGS, 'google', { + pushEventsToProvider: false + }) + + const [, key, value] = (settingsQueries.setSetting as Mock).mock.calls.at(-1) as [ + unknown, + string, + string + ] + expect(key).toBe('calendar.google') + const written = JSON.parse(value) as Record + expect(written.pushEventsToGoogle).toBe(false) + expect(written).not.toHaveProperty('pushEventsToProvider') + }) + + it('#given no vault open #then GET falls back to defaults and SET reports the error', async () => { + registerSettingsHandlers() + ;(getDatabase as Mock).mockImplementation(() => { + throw new Error('Database not initialized') + }) + + const read = await invokeHandler<{ pushEventsToProvider: boolean }>( + SettingsChannels.invoke.GET_CALENDAR_PROVIDER_SETTINGS, + 'caldav' + ) + const write = await invokeHandler<{ success: boolean; error?: string }>( + SettingsChannels.invoke.SET_CALENDAR_PROVIDER_SETTINGS, + 'caldav', + { agentReadEventsConsent: true } + ) + + expect(read.pushEventsToProvider).toBe(true) + expect(write.success).toBe(false) + expect(write.error).toBeTruthy() + expect(settingsQueries.setSetting).not.toHaveBeenCalled() + }) + + it('unregisters both provider channels alongside the google aliases', () => { + registerSettingsHandlers() + unregisterSettingsHandlers() + + expect(removeHandlerCalls).toContain(SettingsChannels.invoke.GET_CALENDAR_PROVIDER_SETTINGS) + expect(removeHandlerCalls).toContain(SettingsChannels.invoke.SET_CALENDAR_PROVIDER_SETTINGS) + }) + }) }) diff --git a/apps/desktop/src/main/ipc/settings-handlers.ts b/apps/desktop/src/main/ipc/settings-handlers.ts index e01658ec4..c0c778703 100644 --- a/apps/desktop/src/main/ipc/settings-handlers.ts +++ b/apps/desktop/src/main/ipc/settings-handlers.ts @@ -19,6 +19,7 @@ import { BACKUP_SETTINGS_DEFAULTS, VOICE_TRANSCRIPTION_SETTINGS_DEFAULTS, CALENDAR_GOOGLE_SETTINGS_DEFAULTS, + CALENDAR_PROVIDER_SETTINGS_DEFAULTS, CALENDAR_SETTINGS_DEFAULTS, FEATURES_SETTINGS_DEFAULTS, INBOX_SETTINGS_DEFAULTS @@ -32,12 +33,17 @@ import type { BackupSettings, VoiceTranscriptionSettings, CalendarGoogleSettings, + CalendarProviderSettings, CalendarSettings, FeaturesSettings, InboxSettings } from '@memry/contracts/settings-schemas' import { GRAPH_SETTINGS_DEFAULTS } from '@memry/contracts/graph-api' import type { GraphSettings } from '@memry/contracts/graph-api' +import { + readCalendarProviderSettings, + writeCalendarProviderSettings +} from '../calendar/provider/settings' import { createLogger } from '../lib/logger' import { getDatabase } from '../database' import { getSetting, setSetting, deleteSetting } from '../settings/settings-store' @@ -928,25 +934,27 @@ 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 - ) - ) + // `calendar.`, in the neutral shape. Google's row keeps its exact + // historical key and keys — `readCalendarProviderSettings` translates its + // `pushEventsToGoogle` to `pushEventsToProvider` on the way out, so an + // existing install reads and writes the same row it always has (#1394). + ipcMain.handle(SettingsChannels.invoke.GET_CALENDAR_PROVIDER_SETTINGS, (_event, providerId) => { + const db = getDbOrNull() + if (!db) return { ...CALENDAR_PROVIDER_SETTINGS_DEFAULTS } + return readCalendarProviderSettings(db, String(providerId)) + }) ipcMain.handle( SettingsChannels.invoke.SET_CALENDAR_PROVIDER_SETTINGS, - (_event, providerId: string, updates: Partial) => - writeGroupSettings( - calendarProviderSettingsKey(String(providerId)), - CALENDAR_GOOGLE_SETTINGS_DEFAULTS, - updates - ) + (_event, providerId: string, updates: Partial) => { + const db = getDbOrNull() + if (!db) return { success: false, error: getMainI18n().t('errors:ipc.noVaultOpen') } + writeCalendarProviderSettings(db, String(providerId), updates) + broadcastToAllWindows(SettingsChannels.events.CHANGED, { + key: `calendar.${String(providerId)}`, + value: updates + }) + return { success: true } + } ) // Permanent compatibility aliases: an older renderer against a newer main diff --git a/apps/desktop/src/renderer/src/agent-mcp/desktop-api-handler.test.tsx b/apps/desktop/src/renderer/src/agent-mcp/desktop-api-handler.test.tsx index 3f1f3b445..c15098c1d 100644 --- a/apps/desktop/src/renderer/src/agent-mcp/desktop-api-handler.test.tsx +++ b/apps/desktop/src/renderer/src/agent-mcp/desktop-api-handler.test.tsx @@ -23,10 +23,11 @@ describe('useAgentMcpDesktopApiResponder', () => { let respondToMainInvoke: ReturnType let templatesList: ReturnType let templatesCreate: ReturnType + let calendarListProviders: ReturnType let calendarGetProviderStatus: ReturnType let calendarGetRange: ReturnType let calendarListEvents: ReturnType - let getCalendarGoogleSettings: ReturnType + let getCalendarProviderSettings: ReturnType beforeEach(() => { onMainInvokeCallback = undefined @@ -40,8 +41,11 @@ describe('useAgentMcpDesktopApiResponder', () => { calendarGetRange = vi.fn().mockResolvedValue({ items: [] }) calendarListEvents = vi.fn().mockResolvedValue({ events: [] }) // null = the user has not answered the agent-access prompt yet. Until they - // grant it, Google-synced events stay out of every agent read. - getCalendarGoogleSettings = vi.fn().mockResolvedValue({ agentReadEventsConsent: null }) + // grant it, that provider's synced events stay out of every agent read. + getCalendarProviderSettings = vi.fn().mockResolvedValue({ agentReadEventsConsent: null }) + calendarListProviders = vi.fn().mockResolvedValue({ + providers: [{ id: 'google', capabilities: null }] + }) mocks.logError.mockReset() ;(window as Window & { api: unknown }).api = { onMainInvoke: vi.fn( @@ -62,12 +66,13 @@ describe('useAgentMcpDesktopApiResponder', () => { create: templatesCreate }, calendar: { + listProviders: calendarListProviders, getProviderStatus: calendarGetProviderStatus, getRange: calendarGetRange, listEvents: calendarListEvents }, settings: { - getCalendarGoogleSettings + getCalendarProviderSettings } } }) @@ -123,8 +128,10 @@ describe('useAgentMcpDesktopApiResponder', () => { 'calendar.getProviderStatus', 'calendar.listSources', 'calendar.listGoogleCalendars', + 'calendar.listProviderCalendars', 'calendar.promoteExternalEvent', - 'settings.getCalendarGoogleSettings' + 'settings.getCalendarGoogleSettings', + 'settings.getCalendarProviderSettings' ] for (const operation of operations) { @@ -212,7 +219,7 @@ describe('useAgentMcpDesktopApiResponder', () => { }) it('keeps Google events out of range reads when consent is denied, ignoring caller flags', async () => { - getCalendarGoogleSettings.mockResolvedValue({ agentReadEventsConsent: false }) + getCalendarProviderSettings.mockResolvedValue({ agentReadEventsConsent: false }) renderHook(() => useAgentMcpDesktopApiResponder()) await waitFor(() => expect(window.api.onMainInvoke).toHaveBeenCalled()) @@ -226,7 +233,7 @@ describe('useAgentMcpDesktopApiResponder', () => { }) it('includes Google events in range reads once the user grants consent', async () => { - getCalendarGoogleSettings.mockResolvedValue({ agentReadEventsConsent: true }) + getCalendarProviderSettings.mockResolvedValue({ agentReadEventsConsent: true }) renderHook(() => useAgentMcpDesktopApiResponder()) await waitFor(() => expect(window.api.onMainInvoke).toHaveBeenCalled()) @@ -240,7 +247,7 @@ describe('useAgentMcpDesktopApiResponder', () => { }) it('falls back to excluding Google events when the consent lookup fails', async () => { - getCalendarGoogleSettings.mockRejectedValue(new Error('settings unavailable')) + getCalendarProviderSettings.mockRejectedValue(new Error('settings unavailable')) renderHook(() => useAgentMcpDesktopApiResponder()) await waitFor(() => expect(window.api.onMainInvoke).toHaveBeenCalled()) diff --git a/apps/desktop/src/renderer/src/agent-mcp/desktop-api-handler.ts b/apps/desktop/src/renderer/src/agent-mcp/desktop-api-handler.ts index 6b6f7694b..98a2d7f75 100644 --- a/apps/desktop/src/renderer/src/agent-mcp/desktop-api-handler.ts +++ b/apps/desktop/src/renderer/src/agent-mcp/desktop-api-handler.ts @@ -41,13 +41,27 @@ async function normalizeDesktopApiArgs(operation: string, args: unknown[]): Prom } } -// Google Workspace Limited Use: Google-synced events are invisible to the agent -// until the user explicitly opts in. Anything other than a stored `true` — not -// asked yet, opted out, or a settings read that failed — stays native-only. -async function hasAgentGoogleEventConsent(): Promise { +/** + * External calendar events are invisible to the agent until every calendar + * provider this build can connect has been explicitly consented to. Anything + * other than a stored `true` — never asked, opted out, or a settings read that + * failed — stays native-only. + * + * Google Workspace Limited Use forced the question first; the answer is the + * house rule now (#1394). The gate is all-or-nothing because + * `calendar.getRange` takes a single `includeExternal` flag: until it can + * filter per provider, one unconsented provider keeps every external event out + * rather than risking one leaking through. + */ +async function hasAgentExternalEventConsent(): Promise { try { - const settings = await window.api.settings.getCalendarGoogleSettings() - return settings.agentReadEventsConsent === true + const { providers } = await window.api.calendar.listProviders() + if (providers.length === 0) return false + + const settings = await Promise.all( + providers.map((provider) => window.api.settings.getCalendarProviderSettings(provider.id)) + ) + return settings.every((group) => group.agentReadEventsConsent === true) } catch (error) { log.warn('Calendar consent lookup failed; keeping agent reads native-only', error) return false @@ -70,7 +84,7 @@ async function normalizeCalendarRangeInput(args: unknown[]): Promise endAt: end ? normalizeCalendarRangeBound(end, 'end') : end, // Resolved from stored consent, never from the caller: an agent that asks // for external events cannot talk its way past the user's answer. - includeExternal: await hasAgentGoogleEventConsent() + includeExternal: await hasAgentExternalEventConsent() } } diff --git a/apps/docs/src/user-guide/ai/agent-mcp.md b/apps/docs/src/user-guide/ai/agent-mcp.md index ec0d5a065..750a9296d 100644 --- a/apps/docs/src/user-guide/ai/agent-mcp.md +++ b/apps/docs/src/user-guide/ai/agent-mcp.md @@ -457,14 +457,17 @@ Calendar desktop reads accept the same single-object shape as the renderer bridg `args: ["2026-05-14", "2026-06-14"]` or `args: [{"startAt": "2026-05-14T00:00:00.000Z", "endAt": "2026-06-15T00:00:00.000Z"}]`. -Google-integration operations — calendar sources, provider status, Google calendar lists, promoting -external events, and Google calendar settings — are excluded from the agent allowlists outright. - -Google-synced events themselves are gated on explicit user consent. `calendar.getRange` resolves -`includeExternal` from the stored answer to the **Let AI read Google Calendar events** setting, never -from the caller: an agent that passes `includeExternal: true` still gets native-only results unless -the user granted access. Not asked yet, declined, or a settings read that failed all resolve to -native-only. See [Calendar → Google Data and AI Features](/user-guide/calendar#google-data-and-ai-features). +Calendar-integration operations — calendar sources, provider status, provider calendar lists, +promoting external events, and calendar provider settings — are excluded from the agent allowlists +outright. That applies to the provider-neutral operations and their Google-named aliases alike; they +reach the same connected accounts, so allowlisting either would re-open the same hole. + +Events synced from a connected calendar are gated on explicit per-provider consent. +`calendar.getRange` resolves `includeExternal` from the stored answers, never from the caller: an +agent that passes `includeExternal: true` still gets native-only results unless every connected +provider has been granted access. Not asked yet, declined, or a settings read that failed all +resolve to native-only. See +[Calendar → Connected Calendars and AI Features](/user-guide/calendar#connected-calendars-and-ai-features). Google user data is never used to train or improve AI models, in line with the Google API Services User Data Policy (Limited Use). diff --git a/apps/docs/src/user-guide/calendar.md b/apps/docs/src/user-guide/calendar.md index 3d3cb922d..1ee676bc4 100644 --- a/apps/docs/src/user-guide/calendar.md +++ b/apps/docs/src/user-guide/calendar.md @@ -199,7 +199,29 @@ an account whose sign-in it cannot read. See Right-click an external event → **Promote to vault** to copy it into your encrypted vault. Useful when you want to attach notes, tags, or reminders that wouldn't survive on the source calendar. -### Google Data and AI Features +### Connected Calendars and AI Features + +**The rule: no connected calendar is readable by the AI assistant until you say so, one provider at +a time.** Google Workspace's Limited Use terms are what made us ask the question first, but the +answer is the house rule for every calendar you connect — Google today, and ICS feeds, CalDAV +servers and Outlook as they arrive. Each provider is asked separately, and consenting to one says +nothing about another. + +Three details follow from that: + +- **Not asked yet reads as no.** Until you answer, the assistant sees nothing from that calendar. + There is no "on by default" state. +- **Turning a provider off does not touch the others.** Your answers are stored per provider, in + that provider's own settings group. +- **While any connected provider is unconsented, external events stay out entirely.** The + assistant's calendar reads carry one include-external flag rather than a per-provider list, so + memrynote errs on the side of showing it nothing. Consent to every connected provider — or + disconnect the one you don't want it reading — to open the gate. + +Events you [promoted to your vault](#promote-external-events) are exempt: a promoted event is your +own memrynote event, and the assistant reads it like any other. + +#### Google specifically AI access to your Google Calendar events is off until you turn it on. The first time you open the calendar with Google calendars imported, memrynote asks once: **Let AI read your Google Calendar diff --git a/packages/contracts/src/settings-schemas.ts b/packages/contracts/src/settings-schemas.ts index e15382de8..6a831fc86 100644 --- a/packages/contracts/src/settings-schemas.ts +++ b/packages/contracts/src/settings-schemas.ts @@ -218,6 +218,44 @@ export const CALENDAR_GOOGLE_SETTINGS_DEFAULTS: CalendarGoogleSettings = { agentReadEventsConsent: null } +// ============================================================================ +// Calendar — per-provider settings (`calendar.`) +// ============================================================================ +// +// The shape above is the *google* instance of this base, frozen in its historic +// form: its outbound toggle is spelled `pushEventsToGoogle` and its group key is +// `calendar.google`. Both stay exactly as they are — a live install must read +// back the row it already wrote, and there is no migration. +// +// Every provider added from here on stores `calendar.` in this +// neutral shape, where the same toggle is `pushEventsToProvider`. The main +// process translates between the two so the sync engine only ever sees the +// neutral name. +// +// `agentReadEventsConsent` is deliberately part of the *base*, not a Google +// extension. Google Workspace Limited Use is what forced the question first, +// but the answer became the house rule: no provider's external events are +// readable by the agent until that provider has been explicitly consented to. +// Null means "not asked yet" and reads as no. +export const CalendarProviderSettingsSchema = z.object({ + defaultTargetCalendarId: z.string().nullable(), + onboardingCompleted: z.boolean(), + promoteConfirmDismissed: z.boolean(), + /** false = one-way (inbound only): pull events in, never push ours out. */ + pushEventsToProvider: z.boolean(), + agentReadEventsConsent: z.boolean().nullable().default(null) +}) + +export type CalendarProviderSettings = z.infer + +export const CALENDAR_PROVIDER_SETTINGS_DEFAULTS: CalendarProviderSettings = { + defaultTargetCalendarId: null, + onboardingCompleted: false, + promoteConfirmDismissed: false, + pushEventsToProvider: true, + agentReadEventsConsent: null +} + // ============================================================================ // Features Settings (optional module toggles) // ============================================================================ diff --git a/packages/rpc/src/settings.ts b/packages/rpc/src/settings.ts index 49d826e76..5c2cac8cf 100644 --- a/packages/rpc/src/settings.ts +++ b/packages/rpc/src/settings.ts @@ -1,6 +1,7 @@ import type { BackupSettings, CalendarGoogleSettings, + CalendarProviderSettings, CalendarSettings, EditorSettings, FeaturesSettings, @@ -283,13 +284,13 @@ export const settingsRpc = defineDomain({ params: ['settings'] }), getCalendarProviderSettings: defineMethod< - (providerId: string) => Promise + (providerId: string) => Promise >({ channel: SettingsChannels.invoke.GET_CALENDAR_PROVIDER_SETTINGS, params: ['providerId'] }), setCalendarProviderSettings: defineMethod< - (providerId: string, settings: Partial) => SuccessResponse + (providerId: string, settings: Partial) => SuccessResponse >({ channel: SettingsChannels.invoke.SET_CALENDAR_PROVIDER_SETTINGS, params: ['providerId', 'settings']