Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion backend/routers/google_calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]:
Expand All @@ -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'),
Expand All @@ -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,
)


Expand All @@ -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'),
Expand Down
56 changes: 56 additions & 0 deletions backend/tests/unit/test_google_calendar_event_response.py
Original file line number Diff line number Diff line change
@@ -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
113 changes: 113 additions & 0 deletions desktop/macos/Desktop/Sources/APIClient+GoogleCalendarGrant.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
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.
/// 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/\(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/\(googleCalendarAppKey)/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
)
}
}
52 changes: 51 additions & 1 deletion desktop/macos/Desktop/Sources/CalendarReaderService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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()
}
Expand All @@ -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
Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading