From 1a6226551b234f924c73ac186f6e36ca4f104a72 Mon Sep 17 00:00:00 2001 From: Aryan Date: Wed, 26 Aug 2026 20:57:09 +0530 Subject: [PATCH 1/6] fix(desktop): stop flattening connector error copy into "Try again" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connector sheet stores an operation's user-facing message as a plain String and re-sanitizes it at display time. The sanitizer's last rule was `text.hasSuffix(".") && text.count < 120` — a guess at "is this curated" layered on top of the raw-error fingerprints that already ran. CalendarReaderError.notSignedIn's description is exactly 120 characters, so it failed that ceiling by one and rendered as the generic fallback: "Couldn't connect to Calendar. Try again." That is the single most common Google connect failure and the one that most needs its next step shown. Four more messages were lost the same way, for lacking a trailing period: both pythonNotFound cases and Gmail's network and decrypt errors. Sentence shape does not separate copy this app wrote from raw system text. The fingerprints do, so they are now the whole decision — and the `Error Domain=` / `nsurlerror` pair is replaced by `errordomain`, which also catches bare NSPOSIXErrorDomain spellings the old checks missed. Verified: - desktop/macos/scripts/dev-feedback.py --once swift 'UserFacingErrorPresentationTests' → Executed 7 tests, 0 failures. - Against the unmodified predicate the same suite reports 5 failures, the first of them literally `XCTAssertEqual failed: ("Couldn't connect to Calendar. Try again.")` — the reported string, reproduced from production code. Failure-Class: none --- .../UserFacingErrorPresentation.swift | 21 ++++-- .../UserFacingErrorPresentationTests.swift | 65 +++++++++++++++++++ 2 files changed, 80 insertions(+), 6 deletions(-) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/UserFacingErrorPresentation.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/UserFacingErrorPresentation.swift index ec25b257cf2..4b5c9ff05bf 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/UserFacingErrorPresentation.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/UserFacingErrorPresentation.swift @@ -119,17 +119,26 @@ enum UserFacingErrorPresentation { return fallback(for: context) } + /// Raw-system-error fingerprints. Copy that carries none of them is copy this + /// app wrote, and is shown verbatim. + /// + /// This deliberately does not judge by sentence shape. A previous version also + /// required a trailing period and fewer than 120 characters, which flattened + /// five real connector messages into the generic fallback — including the + /// Calendar not-signed-in guidance, at exactly 120 characters, which is the + /// single most common Google connect failure and the one that most needs its + /// next step shown. Length and punctuation do not separate curated copy from + /// raw system text; the fingerprints below do. private static func shouldPreserveCuratedCopy(_ text: String) -> Bool { let lowered = text.lowercased() if text.count > 160 { return false } - if text.contains("://") || text.contains("Error Domain=") { return false } - if lowered.contains("nsurlerror") || lowered.contains("cfstream") { return false } + if text.contains("://") { return false } + // Catches `Error Domain=` and every bare `NS*ErrorDomain` spelling. + if lowered.contains("errordomain") { return false } + if lowered.contains("cfstream") { return false } if lowered.range(of: #"\b[45]\d{2}\b"#, options: .regularExpression) != nil { return false } if text.filter({ $0 == ":" }).count > 1 { return false } - if lowered.hasPrefix("omi ") || lowered.hasPrefix("couldn't ") || lowered.hasPrefix("didn't ") { - return true - } - return text.hasSuffix(".") && text.count < 120 + return true } private static func fallback(for context: Context) -> String { diff --git a/desktop/macos/Desktop/Tests/UserFacingErrorPresentationTests.swift b/desktop/macos/Desktop/Tests/UserFacingErrorPresentationTests.swift index 4488f7a1fe2..52667384507 100644 --- a/desktop/macos/Desktop/Tests/UserFacingErrorPresentationTests.swift +++ b/desktop/macos/Desktop/Tests/UserFacingErrorPresentationTests.swift @@ -49,6 +49,71 @@ final class UserFacingErrorPresentationTests: XCTestCase { ) } + /// The connector sheet stores an operation's user-facing message as a plain + /// String and re-sanitizes it at display time, so any connector error whose + /// copy the sanitizer rejects reaches the user as "Couldn't connect to + /// . Try again." — the next step the taxonomy exists to give is lost. + /// + /// Driven off the production enums rather than copied literals: editing a + /// message into a shape the sanitizer drops fails here instead of shipping. + func testEveryGoogleConnectorErrorSurvivesDisplaySanitization() { + let calendarErrors: [CalendarReaderError] = [ + .noBrowserFound, + .notSignedIn, + .sessionExpired, + .cookieDecryptionFailed("browser session could not be decrypted"), + .networkError("connection reset"), + .configurationError("API key is invalid or unavailable"), + .pythonNotFound, + ] + let gmailErrors: [GmailReaderError] = [ + .noBrowserFound, + .noGmailCookies, + .notSignedIn, + .sessionExpired, + .cookieDecryptionFailed("browser session could not be decrypted"), + .networkError("connection reset"), + .authFailed, + .pythonNotFound, + ] + + for description in calendarErrors.compactMap(\.errorDescription) { + XCTAssertEqual( + UserFacingErrorPresentation.message(from: description, while: .integration("Calendar")), + description, + "Calendar copy was replaced by the generic fallback" + ) + } + for description in gmailErrors.compactMap(\.errorDescription) { + XCTAssertEqual( + UserFacingErrorPresentation.message(from: description, while: .integration("Gmail")), + description, + "Gmail copy was replaced by the generic fallback" + ) + } + } + + /// The other direction: widening the sanitizer must not start leaking the raw + /// system text it exists to hide. + func testStillHidesRawSystemErrorText() { + let raw = [ + "The operation couldn't be completed. (NSURLErrorDomain error -1009.)", + "The operation couldn't be completed. (NSPOSIXErrorDomain error 2.)", + "Error Domain=kCFErrorDomainCFNetwork Code=310", + "GET https://api.omi.me/v1/dev/user/memories failed", + "upstream returned 503 while reading the response", + "sqlite: prepare: step: no such table: cookies", + ] + + for text in raw { + XCTAssertEqual( + UserFacingErrorPresentation.message(from: text, while: .integration("Calendar")), + "Couldn't connect to Calendar. Try again.", + "raw system text leaked to the user: \(text)" + ) + } + } + func testProvidesNetworkRecovery() { XCTAssertEqual( UserFacingErrorPresentation.message( From 94790deb618d457fa2370e9665efbfe8586851d4 Mon Sep 17 00:00:00 2001 From: Aryan Date: Wed, 26 Aug 2026 20:57:37 +0530 Subject: [PATCH 2/6] fix(desktop): connect Calendar with Google sign-in, not browser cookies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop Calendar was the only client still authenticating by decrypting Google auth cookies out of a Chromium profile. That mechanism has three failure modes with no recoverable action, all in issue #10459: no Chromium browser installed (Safari or Firefox users), cookies re-encrypted under a scheme the reader will not open, and a declined browser Keychain "Safe Storage" prompt. Each one dead-ends at "Try again", where nothing about the machine has changed and trying again cannot help. The backend already owns a real Google Calendar OAuth grant — the same one the mobile app connects. This routes the desktop through it: - readEvents prefers the account's server-held grant and keeps cookies as the fallback, so the fix reaches all six call sites (connector, Settings, onboarding, chat provider, automation bridge) through the one function they share rather than six call-site patches. - Connect Calendar now opens Google's consent screen and polls for the grant when both sources are exhausted, mirroring the existing connectX flow, instead of dead-ending. - verifyConnection reports connected off a real grant-backed fetch, so status stops claiming "needs sign in" to accounts that hold a grant. - The event payload gains location, description and all_day, which the cookie reader read straight from Google; without them every memory built from an event silently loses detail. max_results ceiling goes 100 → 500 for the connector's one-pass year of history (Google's own cap is 2500). The cookie reader stays. Retiring it means deleting ~800 lines of Swift, its embedded Python, the Settings account picker and their tests — the unreviewable migration AGENTS.md says not to fold into a bug fix. It stays tracked on #10459. Gmail is deliberately not migrated: gmail.readonly is a Google *restricted* scope and integrations_registry.py withholds it from the consent request until verification and CASA are granted, so routing Gmail through the grant would report connected: false forever. It keeps the cookie reader and, from the previous commit, honest errors. Verified: - backend: python3 -m pytest tests/unit/test_google_calendar_event_response.py → 4 passed. - desktop: dev-feedback.py --once swift 'UserFacingErrorPresentationTests|GoogleCalendarGrantEventTests' → Executed 9 tests, 0 failures. - Built and launched as the named bundle omi-calendar-oauth against https://api.omi.me/ (confirmed via omi-ctl health). - All three endpoints the desktop now calls are live in prod today: v1/integrations/google_calendar, .../oauth-url and v1/calendar/google/events each return 401 unauthenticated, while a control path under the same prefix returns 404. NOT verified, stated plainly: the authenticated grant read was not exercised end-to-end. The named bundle came up signed out ("AUTH_LISTENER: No saved session") and signing in is not an action I take on someone's account, so the live grant → events → memories path still needs one manual run by a signed-in maintainer. Failure-Class: none --- backend/routers/google_calendar.py | 13 ++- .../test_google_calendar_event_response.py | 56 ++++++++++ .../APIClient+GoogleCalendarGrant.swift | 105 ++++++++++++++++++ .../Sources/CalendarReaderService.swift | 52 ++++++++- .../Pages/ConnectorImportOperations.swift | 94 ++++++++++++++-- .../Tests/GoogleCalendarGrantEventTests.swift | 64 +++++++++++ ...260826-calendar-connect-google-signin.json | 3 + 7 files changed, 373 insertions(+), 14 deletions(-) create mode 100644 backend/tests/unit/test_google_calendar_event_response.py create mode 100644 desktop/macos/Desktop/Sources/APIClient+GoogleCalendarGrant.swift create mode 100644 desktop/macos/Desktop/Tests/GoogleCalendarGrantEventTests.swift create mode 100644 desktop/macos/changelog/unreleased/20260826-calendar-connect-google-signin.json diff --git a/backend/routers/google_calendar.py b/backend/routers/google_calendar.py index 4753c2d7692..7e589c0aff9 100644 --- a/backend/routers/google_calendar.py +++ b/backend/routers/google_calendar.py @@ -37,6 +37,9 @@ class GoogleCalendarEvent(BaseModel): start_time: datetime = Field(description="Event start time") end_time: datetime = Field(description="Event end time") html_link: Optional[str] = Field(default=None, description="Link to open event in Google Calendar") + location: str = Field(default='', description="Event location") + description: str = Field(default='', description="Event description, truncated for transport") + all_day: bool = Field(default=False, description="True when the event has a date but no time") def _get_google_calendar_token(uid: str) -> tuple[str, Dict[str, Any]]: @@ -62,6 +65,9 @@ def _event_to_response(event: Dict[str, Any]) -> Optional[GoogleCalendarEvent]: attendee_names, attendee_emails = extract_attendees(event) + start_raw = event.get('start') or {} + all_day = isinstance(start_raw, dict) and 'date' in start_raw and 'dateTime' not in start_raw + return GoogleCalendarEvent( event_id=event.get('id', ''), title=event.get('summary', 'Untitled Event'), @@ -70,6 +76,9 @@ def _event_to_response(event: Dict[str, Any]) -> Optional[GoogleCalendarEvent]: start_time=start_time, end_time=end_time, html_link=event.get('htmlLink'), + location=(event.get('location') or '')[:200], + description=(event.get('description') or '')[:300], + all_day=all_day, ) @@ -82,7 +91,9 @@ async def list_google_calendar_events( time_min: Optional[datetime] = Query(None, description="Minimum time for events (ISO format)"), time_max: Optional[datetime] = Query(None, description="Maximum time for events (ISO format)"), q: Optional[str] = Query(None, description="Search query to filter events"), - max_results: int = Query(20, ge=1, le=100, description="Maximum number of events to return"), + # Ceiling raised from 100 for the desktop connector import, which reads a + # year of history in one pass; Google's own list cap is 2500. + max_results: int = Query(20, ge=1, le=500, description="Maximum number of events to return"), x_app_platform: Optional[str] = Header(None, alias='X-App-Platform'), x_app_version: Optional[str] = Header(None, alias='X-App-Version'), x_app_build: Optional[str] = Header(None, alias='X-App-Build'), diff --git a/backend/tests/unit/test_google_calendar_event_response.py b/backend/tests/unit/test_google_calendar_event_response.py new file mode 100644 index 00000000000..a4a45e68987 --- /dev/null +++ b/backend/tests/unit/test_google_calendar_event_response.py @@ -0,0 +1,56 @@ +"""The desktop connector reads calendar events through the OAuth grant instead of +scraping browser cookies, so the event payload has to carry the fields the +memory extractor used to get straight from Google: location, description, and +whether the event is all-day. +""" + +from routers.google_calendar import _event_to_response + + +def _timed_event(**overrides): + event = { + 'id': 'evt-1', + 'summary': 'Design review', + 'start': {'dateTime': '2026-08-26T15:00:00Z'}, + 'end': {'dateTime': '2026-08-26T16:00:00Z'}, + 'location': 'Room 4', + 'description': 'Walk through the new connector flow.', + } + event.update(overrides) + return event + + +def test_timed_event_carries_location_and_description(): + response = _event_to_response(_timed_event()) + + assert response is not None + assert response.location == 'Room 4' + assert response.description == 'Walk through the new connector flow.' + assert response.all_day is False + + +def test_all_day_event_is_flagged(): + response = _event_to_response(_timed_event(start={'date': '2026-08-26'}, end={'date': '2026-08-27'})) + + assert response is not None + assert response.all_day is True + + +def test_missing_optional_fields_become_empty_strings(): + event = _timed_event() + del event['location'] + event['description'] = None + + response = _event_to_response(event) + + assert response is not None + assert response.location == '' + assert response.description == '' + + +def test_long_free_text_is_truncated_for_transport(): + response = _event_to_response(_timed_event(location='L' * 500, description='D' * 900)) + + assert response is not None + assert len(response.location) == 200 + assert len(response.description) == 300 diff --git a/desktop/macos/Desktop/Sources/APIClient+GoogleCalendarGrant.swift b/desktop/macos/Desktop/Sources/APIClient+GoogleCalendarGrant.swift new file mode 100644 index 00000000000..a934ee7cd77 --- /dev/null +++ b/desktop/macos/Desktop/Sources/APIClient+GoogleCalendarGrant.swift @@ -0,0 +1,105 @@ +import Foundation + +/// Backend transport for the Google Calendar OAuth grant — the same grant the +/// mobile app connects, held server-side. +/// +/// The desktop's original Calendar path decrypts Google auth cookies out of a +/// Chromium profile. That mechanism fails for whole classes of user with no +/// recoverable action: no Chromium browser installed (Safari/Firefox), cookies +/// re-encrypted under a scheme the reader can't open, or a declined Keychain +/// "Safe Storage" prompt. See issue #10459. This transport is the durable +/// alternative — the backend already owns the token, so nothing on this machine +/// needs to read a browser at all. +extension APIClient { + /// Whether this account holds a live Google Calendar grant. + func googleCalendarGrantConnected() async throws -> Bool { + let response: IntegrationConnectionResponse = try await get("v1/integrations/google_calendar") + return response.connected + } + + /// Google's consent URL for the Calendar grant. The backend owns the redirect + /// and the CSRF state, so the desktop only has to open what it returns. + func googleCalendarOAuthURL() async throws -> URL { + let response: IntegrationOAuthURLResponse = try await get("v1/integrations/google_calendar/oauth-url") + guard let url = URL(string: response.authUrl) else { throw APIError.invalidResponse } + return url + } + + /// Read events through the backend grant. Mirrors the window the cookie + /// reader uses so both sources produce the same shape for callers. + func googleCalendarGrantEvents( + daysBack: Int, + daysForward: Int, + maxResults: Int + ) async throws -> [CalendarEvent] { + let formatter = ISO8601DateFormatter() + let now = Date() + let timeMin = formatter.string(from: now.addingTimeInterval(-Double(daysBack) * 86_400)) + let timeMax = formatter.string(from: now.addingTimeInterval(Double(daysForward) * 86_400)) + + func encoded(_ value: String) -> String { + value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value + } + + // The endpoint's ceiling is 500; asking for more is a 422, not a clamp. + let capped = min(max(maxResults, 1), 500) + let events: [GoogleCalendarGrantEvent] = try await get( + "v1/calendar/google/events?time_min=\(encoded(timeMin))&time_max=\(encoded(timeMax))&max_results=\(capped)" + ) + return events.map(\.asCalendarEvent) + } +} + +struct IntegrationConnectionResponse: Decodable { + let connected: Bool + let appKey: String? + + enum CodingKeys: String, CodingKey { + case connected + case appKey = "app_key" + } +} + +struct IntegrationOAuthURLResponse: Decodable { + let authUrl: String + + enum CodingKeys: String, CodingKey { + case authUrl = "auth_url" + } +} + +/// Wire shape of `GET /v1/calendar/google/events`. +struct GoogleCalendarGrantEvent: Decodable { + let eventId: String + let title: String + let attendees: [String] + let startTime: String + let endTime: String + let location: String + let description: String + let allDay: Bool + + enum CodingKeys: String, CodingKey { + case eventId = "event_id" + case title + case attendees + case startTime = "start_time" + case endTime = "end_time" + case location + case description + case allDay = "all_day" + } + + var asCalendarEvent: CalendarEvent { + CalendarEvent( + id: eventId, + summary: title, + startTime: startTime, + endTime: endTime, + attendees: attendees, + location: location, + description: description, + isAllDay: allDay + ) + } +} diff --git a/desktop/macos/Desktop/Sources/CalendarReaderService.swift b/desktop/macos/Desktop/Sources/CalendarReaderService.swift index 8b2b5cb4a78..62864da949f 100644 --- a/desktop/macos/Desktop/Sources/CalendarReaderService.swift +++ b/desktop/macos/Desktop/Sources/CalendarReaderService.swift @@ -222,7 +222,17 @@ enum CalendarOutcomeParser { actor CalendarReaderService { static let shared = CalendarReaderService() - /// Read calendar events using browser cookies + SAPISID auth. + /// Read calendar events, preferring the account's server-held Google OAuth + /// grant and falling back to the Chromium cookie reader. + /// + /// The grant comes first because it is strictly more available: it works with + /// no Chromium browser installed, with cookies this machine cannot decrypt, + /// and with the browser Keychain prompt declined — the three failure modes in + /// issue #10459 that leave the cookie reader with nothing to recover to. The + /// cookie path stays for accounts that have never granted OAuth, so nobody + /// who is working today stops working. Retiring it belongs to that issue, not + /// to this fix. + /// /// Tries Arc, Chrome, Brave, and Edge across all Chromium profiles. /// Fetches events from `daysBack` days ago to `daysForward` days from now. /// Browser Keychain consent is only eligible for an explicitly requested read. @@ -234,6 +244,15 @@ actor CalendarReaderService { ) async throws -> [CalendarEvent] { + // Absent grant and failed grant read both fall through to cookies. + if let granted = try? await readEventsViaGrant( + daysBack: daysBack, + daysForward: daysForward, + maxResults: maxResults + ) { + return granted + } + if userInitiated { BrowserKeychainCache.shared.beginUserInitiatedOperation() } @@ -247,6 +266,29 @@ actor CalendarReaderService { return events.sorted { $0.startTime > $1.startTime } } + /// Returns nil when this account has no Google grant. Callers that can fall + /// back to cookies treat a thrown read the same way, so a backend blip cannot + /// take Calendar down for someone the cookie reader still serves. + func readEventsViaGrant( + daysBack: Int, + daysForward: Int, + maxResults: Int + ) async throws -> [CalendarEvent]? { + guard await hasGoogleGrant() else { return nil } + let events = try await APIClient.shared.googleCalendarGrantEvents( + daysBack: daysBack, + daysForward: daysForward, + maxResults: maxResults + ) + return events.sorted { $0.startTime > $1.startTime } + } + + /// A grant check must never be the reason a read fails: a backend hiccup here + /// reads as "no grant" and the cookie path still gets its turn. + func hasGoogleGrant() async -> Bool { + (try? await APIClient.shared.googleCalendarGrantConnected()) ?? false + } + /// Lightweight functional probe — does the integration actually work right now? /// /// Per `docs/integrations-philosophy.md` §3/§4, "connected" must mean "verified @@ -255,6 +297,14 @@ actor CalendarReaderService { /// one-time success. It runs the same real fetch path over a tiny window so a /// green result guarantees the whole chain (cookies → auth → API) works. func verifyConnection(userInitiated: Bool = false) async -> CalendarConnectionStatus { + // Same real-fetch standard as the cookie probe below, over the grant: a + // green result means the whole chain (grant → backend → Google) works. + if await hasGoogleGrant(), + (try? await APIClient.shared.googleCalendarGrantEvents(daysBack: 1, daysForward: 1, maxResults: 1)) != nil + { + return .connected(verifiedAt: Date()) + } + if userInitiated { BrowserKeychainCache.shared.beginUserInitiatedOperation() } diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/ConnectorImportOperations.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/ConnectorImportOperations.swift index fdb796e866b..f4b15d8f98f 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/ConnectorImportOperations.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/ConnectorImportOperations.swift @@ -228,19 +228,15 @@ enum ConnectorImportOperations { maxResults: 500, userInitiated: true ) - progress.update( - title: "Importing calendar events", - detail: "Saving events as memories and generating action-oriented summaries." - ) - let rawImport = await CalendarReaderService.shared.saveAsMemories(events: events, limit: 200) - let synthesis = await CalendarReaderService.shared.synthesizeFromEvents(events: events) - let memoryCount = rawImport.saved + synthesis.memories - return .success( - SyncResult(sourceCount: events.count, memoryCount: memoryCount, newItems: events.count), - message: "Read \(events.count.formatted()) calendar events and saved \(memoryCount.formatted()) memories." - ) + return await saveCalendarEvents(events, progress: progress) } catch let error as CalendarReaderError { - return .failure(message: error.localizedDescription, failureClass: Self.failureClass(for: error)) + // Every `CalendarReaderError` means the browser-cookie reader found no + // usable Google session on this machine, and `readEvents` only reaches it + // when the account has no OAuth grant either. Both sources are exhausted, + // so "try again" is not advice — nothing about the machine has changed. + // Google's own consent screen is the one path that does not depend on + // which browser is installed or whether its cookies can be decrypted. + return await connectCalendarViaOAuth(progress: progress, cookieFailure: error) } catch { return .failure( message: error.localizedDescription, @@ -248,6 +244,80 @@ enum ConnectorImportOperations { } } + /// Backend-mediated Google OAuth, same shape as `connectX`: open the consent + /// URL, poll until the grant lands, then read through it. + @MainActor + private static func connectCalendarViaOAuth( + progress: ConnectorImportRunner.ProgressSink, + cookieFailure: CalendarReaderError + ) async -> Outcome { + let authURL: URL + do { + authURL = try await APIClient.shared.googleCalendarOAuthURL() + } catch { + // Server-side OAuth is unavailable, so the cookie reader's own diagnosis + // is still the most actionable thing the user can be told. + return .failure( + message: cookieFailure.localizedDescription, + failureClass: Self.failureClass(for: cookieFailure)) + } + + guard NSWorkspace.shared.open(authURL) else { + return .failure( + message: "Couldn't open the Google sign-in page. Check your default browser, then try again.", + failureClass: .noBrowser) + } + + progress.update( + title: "Waiting for Google sign-in", + detail: "Approve calendar access in your browser. This window updates automatically." + ) + + for _ in 0..<60 { + try? await Task.sleep(for: .seconds(2)) + guard await CalendarReaderService.shared.hasGoogleGrant() else { continue } + progress.update( + title: "Importing calendar events", + detail: "Reading past events and upcoming commitments for memory extraction." + ) + do { + let events = + try await CalendarReaderService.shared.readEventsViaGrant( + daysBack: 365, + daysForward: 30, + maxResults: 500 + ) ?? [] + return await saveCalendarEvents(events, progress: progress) + } catch { + return .failure( + message: "Connected to Google, but reading your calendar failed. Try Sync now.", + failureClass: IntegrationConnectTelemetry.ErrorClass.fromMessage(error.localizedDescription)) + } + } + + return .failure( + message: "Didn't hear back from Google. If you approved access, try again.", + failureClass: .notSignedIn) + } + + @MainActor + private static func saveCalendarEvents( + _ events: [CalendarEvent], + progress: ConnectorImportRunner.ProgressSink + ) async -> Outcome { + progress.update( + title: "Importing calendar events", + detail: "Saving events as memories and generating action-oriented summaries." + ) + let rawImport = await CalendarReaderService.shared.saveAsMemories(events: events, limit: 200) + let synthesis = await CalendarReaderService.shared.synthesizeFromEvents(events: events) + let memoryCount = rawImport.saved + synthesis.memories + return .success( + SyncResult(sourceCount: events.count, memoryCount: memoryCount, newItems: events.count), + message: "Read \(events.count.formatted()) calendar events and saved \(memoryCount.formatted()) memories." + ) + } + @MainActor static func importAppleNotes(progress: ConnectorImportRunner.ProgressSink) async -> Outcome { do { diff --git a/desktop/macos/Desktop/Tests/GoogleCalendarGrantEventTests.swift b/desktop/macos/Desktop/Tests/GoogleCalendarGrantEventTests.swift new file mode 100644 index 00000000000..995af294da0 --- /dev/null +++ b/desktop/macos/Desktop/Tests/GoogleCalendarGrantEventTests.swift @@ -0,0 +1,64 @@ +import XCTest + +@testable import Omi_Computer + +/// The grant path replaces a browser-cookie read that produced `CalendarEvent` +/// directly, so the wire shape has to decode into the same value — a silent +/// field drop here would quietly degrade every memory built from an event. +final class GoogleCalendarGrantEventTests: XCTestCase { + private func decode(_ json: String) throws -> [GoogleCalendarGrantEvent] { + try JSONDecoder().decode([GoogleCalendarGrantEvent].self, from: Data(json.utf8)) + } + + func testDecodesBackendEventIntoCalendarEvent() throws { + let events = try decode( + """ + [{ + "event_id": "evt-1", + "title": "Design review", + "attendees": ["Dana", "Ren"], + "attendee_emails": ["dana@example.com"], + "start_time": "2026-08-26T15:00:00Z", + "end_time": "2026-08-26T16:00:00Z", + "html_link": "https://calendar.google.com/event?eid=1", + "location": "Room 4", + "description": "Walk through the new connector flow.", + "all_day": false + }] + """ + ) + + let event = try XCTUnwrap(events.first).asCalendarEvent + XCTAssertEqual(event.id, "evt-1") + XCTAssertEqual(event.summary, "Design review") + XCTAssertEqual(event.attendees, ["Dana", "Ren"]) + XCTAssertEqual(event.startTime, "2026-08-26T15:00:00Z") + XCTAssertEqual(event.endTime, "2026-08-26T16:00:00Z") + XCTAssertEqual(event.location, "Room 4") + XCTAssertEqual(event.description, "Walk through the new connector flow.") + XCTAssertFalse(event.isAllDay) + } + + func testDecodesAllDayEvent() throws { + let events = try decode( + """ + [{ + "event_id": "evt-2", + "title": "Offsite", + "attendees": [], + "attendee_emails": [], + "start_time": "2026-08-26T00:00:00Z", + "end_time": "2026-08-26T23:59:59Z", + "location": "", + "description": "", + "all_day": true + }] + """ + ) + + let event = try XCTUnwrap(events.first).asCalendarEvent + XCTAssertTrue(event.isAllDay) + XCTAssertEqual(event.attendees, []) + XCTAssertEqual(event.location, "") + } +} diff --git a/desktop/macos/changelog/unreleased/20260826-calendar-connect-google-signin.json b/desktop/macos/changelog/unreleased/20260826-calendar-connect-google-signin.json new file mode 100644 index 00000000000..b5ac5a9232b --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260826-calendar-connect-google-signin.json @@ -0,0 +1,3 @@ +{ + "change": "Connecting Calendar now signs you in with Google instead of relying on a Chrome-family browser session, so it works on Safari and Firefox too — and when a connection does fail, Omi tells you what went wrong instead of just \"try again\"" +} From d23bb3c46aeb9c98975aab79463f75e5b0a4b45a Mon Sep 17 00:00:00 2001 From: Aryan Date: Wed, 26 Aug 2026 22:27:54 +0530 Subject: [PATCH 3/6] chore(desktop): cover the Google grant transport in the connector e2e flow The new APIClient+GoogleCalendarGrant.swift is the transport that google-connector-read already exercises through CalendarReaderService, so it belongs in the same flow's covers list. Verified: python3 desktop/macos/scripts/check-e2e-flow-coverage.py --strict --base origin/main -> Covered: 4, Uncovered: 0. --- desktop/macos/e2e/flows/google-connector-read.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/desktop/macos/e2e/flows/google-connector-read.yaml b/desktop/macos/e2e/flows/google-connector-read.yaml index 3067b49922f..2d00b83f1c7 100644 --- a/desktop/macos/e2e/flows/google-connector-read.yaml +++ b/desktop/macos/e2e/flows/google-connector-read.yaml @@ -11,6 +11,7 @@ covers: - desktop/macos/Desktop/Sources/BrowserGoogleSession.swift - desktop/macos/Desktop/Sources/GmailReaderService.swift - desktop/macos/Desktop/Sources/CalendarReaderService.swift + - desktop/macos/Desktop/Sources/APIClient+GoogleCalendarGrant.swift - desktop/macos/Desktop/Sources/Integrations/GmailAccountSelection.swift preconditions: - automation_bridge_ready From e50365bb013b14e28472d2ec730ee82bc4d4ff3b Mon Sep 17 00:00:00 2001 From: Aryan Date: Wed, 26 Aug 2026 22:48:14 +0530 Subject: [PATCH 4/6] chore(api): regenerate app-client OpenAPI for the calendar event payload Picks up the three fields the desktop connector needs from GoogleCalendarEvent (location, description, all_day) and the max_results ceiling moving 100 -> 500. Verified: backend/.venv/bin/python scripts/export_openapi.py --surface app-client --write ../docs/api-reference/app-client-openapi.json -> 19 insertions, 1 deletion, all in GoogleCalendarEvent and its max_results bound. --- docs/api-reference/app-client-openapi.json | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/api-reference/app-client-openapi.json b/docs/api-reference/app-client-openapi.json index 9efc4ebfb28..530dae4c1af 100644 --- a/docs/api-reference/app-client-openapi.json +++ b/docs/api-reference/app-client-openapi.json @@ -13657,6 +13657,12 @@ "GoogleCalendarEvent": { "description": "Response model for a Google Calendar event.", "properties": { + "all_day": { + "default": false, + "description": "True when the event has a date but no time", + "title": "All Day", + "type": "boolean" + }, "attendee_emails": { "default": [], "description": "List of attendee email addresses", @@ -13675,6 +13681,12 @@ "title": "Attendees", "type": "array" }, + "description": { + "default": "", + "description": "Event description, truncated for transport", + "title": "Description", + "type": "string" + }, "end_time": { "description": "Event end time", "format": "date-time", @@ -13698,6 +13710,12 @@ "description": "Link to open event in Google Calendar", "title": "Html Link" }, + "location": { + "default": "", + "description": "Event location", + "title": "Location", + "type": "string" + }, "start_time": { "description": "Event start time", "format": "date-time", @@ -32430,7 +32448,7 @@ "schema": { "default": 20, "description": "Maximum number of events to return", - "maximum": 100, + "maximum": 500, "minimum": 1, "title": "Max Results", "type": "integer" From 1280e1f3d9896ea7de7502307d964d73666db204 Mon Sep 17 00:00:00 2001 From: Aryan Date: Wed, 26 Aug 2026 22:51:26 +0530 Subject: [PATCH 5/6] chore(api): regenerate TypeScript clients for the calendar event fields Generated artifacts only, required by the openapi-contract gate. Purely additive: location, description and all_day arrive as optional fields on GoogleCalendarEvent in each client. Verified: backend/.venv/bin/python scripts/generate_ts_openapi_types.py -> 4 files, 12 insertions, 0 deletions. --- desktop/windows/src/renderer/src/lib/omiApi.generated.ts | 3 +++ web/admin/lib/services/omi-api/omiApi.generated.ts | 3 +++ web/app/src/lib/omiApi.generated.ts | 3 +++ web/personas-open-source/src/lib/omiApi.generated.ts | 3 +++ 4 files changed, 12 insertions(+) diff --git a/desktop/windows/src/renderer/src/lib/omiApi.generated.ts b/desktop/windows/src/renderer/src/lib/omiApi.generated.ts index f274ea81ed9..89a71ecad1a 100644 --- a/desktop/windows/src/renderer/src/lib/omiApi.generated.ts +++ b/desktop/windows/src/renderer/src/lib/omiApi.generated.ts @@ -2187,11 +2187,14 @@ export interface GoalUpdate { } export interface GoogleCalendarEvent { + all_day?: boolean; attendee_emails?: Array; attendees?: Array; + description?: string; end_time: string; event_id: string; html_link?: string | null; + location?: string; start_time: string; title: string; } diff --git a/web/admin/lib/services/omi-api/omiApi.generated.ts b/web/admin/lib/services/omi-api/omiApi.generated.ts index f274ea81ed9..89a71ecad1a 100644 --- a/web/admin/lib/services/omi-api/omiApi.generated.ts +++ b/web/admin/lib/services/omi-api/omiApi.generated.ts @@ -2187,11 +2187,14 @@ export interface GoalUpdate { } export interface GoogleCalendarEvent { + all_day?: boolean; attendee_emails?: Array; attendees?: Array; + description?: string; end_time: string; event_id: string; html_link?: string | null; + location?: string; start_time: string; title: string; } diff --git a/web/app/src/lib/omiApi.generated.ts b/web/app/src/lib/omiApi.generated.ts index f274ea81ed9..89a71ecad1a 100644 --- a/web/app/src/lib/omiApi.generated.ts +++ b/web/app/src/lib/omiApi.generated.ts @@ -2187,11 +2187,14 @@ export interface GoalUpdate { } export interface GoogleCalendarEvent { + all_day?: boolean; attendee_emails?: Array; attendees?: Array; + description?: string; end_time: string; event_id: string; html_link?: string | null; + location?: string; start_time: string; title: string; } diff --git a/web/personas-open-source/src/lib/omiApi.generated.ts b/web/personas-open-source/src/lib/omiApi.generated.ts index f274ea81ed9..89a71ecad1a 100644 --- a/web/personas-open-source/src/lib/omiApi.generated.ts +++ b/web/personas-open-source/src/lib/omiApi.generated.ts @@ -2187,11 +2187,14 @@ export interface GoalUpdate { } export interface GoogleCalendarEvent { + all_day?: boolean; attendee_emails?: Array; attendees?: Array; + description?: string; end_time: string; event_id: string; html_link?: string | null; + location?: string; start_time: string; title: string; } From b8f3c5b5211ce188674af23f21c9b11a4e31193a Mon Sep 17 00:00:00 2001 From: Aryan Date: Thu, 27 Aug 2026 13:32:25 +0530 Subject: [PATCH 6/6] fix(desktop): express the calendar grant routes as the templated app_key path test_desktop_rest_inventory extracts route literals from every APIClient*.swift source and matches them against the app-client OpenAPI spec. Both routes were written with the app key baked into the literal: get("v1/integrations/google_calendar") get("v1/integrations/google_calendar/oauth-url") The spec carries these as /v1/integrations/{app_key} and /v1/integrations/{app_key}/oauth-url, and _normalize_for_match only rewrites {...} placeholders -- so a literal key can never match a templated path and the routes read as missing from the spec: E assert not ['/v1/integrations/google_calendar', '/v1/integrations/google_calendar/oauth-url'] Nothing was actually missing: both templated routes are in the spec and both backend routes exist (integrations.py:288 and :421). Interpolating the key makes the Swift say what the contract is -- these are the {app_key} routes with google_calendar as the key, not routes of their own. The extractor rewrites Swift interpolation to {param}, which is the same form _normalize_for_match reduces {app_key} to, so they match. Chose this over KNOWN_MISSING_ROUTES: nothing is missing, so listing them would record a gap that does not exist, and the entry would outlive the confusion. Verified the mechanism rather than just the outcome: extracted: /v1/integrations/{param} /v1/integrations/{param}/oauth-url tests/unit/test_desktop_rest_inventory.py 9 passed (was 1 failed, 8 passed) Failure-Class: none --- .../Sources/APIClient+GoogleCalendarGrant.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/desktop/macos/Desktop/Sources/APIClient+GoogleCalendarGrant.swift b/desktop/macos/Desktop/Sources/APIClient+GoogleCalendarGrant.swift index a934ee7cd77..8bd2931b52f 100644 --- a/desktop/macos/Desktop/Sources/APIClient+GoogleCalendarGrant.swift +++ b/desktop/macos/Desktop/Sources/APIClient+GoogleCalendarGrant.swift @@ -10,17 +10,25 @@ import Foundation /// "Safe Storage" prompt. See issue #10459. This transport is the durable /// alternative — the backend already owns the token, so nothing on this machine /// needs to read a browser at all. +/// The `app_key` path parameter for both integration routes. Kept as an +/// interpolated value rather than baked into the literal because these are the +/// templated `/v1/integrations/{app_key}` routes, not routes of their own — +/// `test_desktop_rest_inventory` extracts the literal and matches it against the +/// app-client OpenAPI spec, so a hardcoded key reads as a route the spec lacks. +private let googleCalendarAppKey = "google_calendar" + extension APIClient { /// Whether this account holds a live Google Calendar grant. func googleCalendarGrantConnected() async throws -> Bool { - let response: IntegrationConnectionResponse = try await get("v1/integrations/google_calendar") + let response: IntegrationConnectionResponse = try await get("v1/integrations/\(googleCalendarAppKey)") return response.connected } /// Google's consent URL for the Calendar grant. The backend owns the redirect /// and the CSRF state, so the desktop only has to open what it returns. func googleCalendarOAuthURL() async throws -> URL { - let response: IntegrationOAuthURLResponse = try await get("v1/integrations/google_calendar/oauth-url") + let response: IntegrationOAuthURLResponse = try await get( + "v1/integrations/\(googleCalendarAppKey)/oauth-url") guard let url = URL(string: response.authUrl) else { throw APIError.invalidResponse } return url }