Skip to content

feat: add support for multiple Google Calendar accounts#911

Open
vlordier wants to merge 14 commits intoleits:masterfrom
vlordier:feat/multi-google-accounts
Open

feat: add support for multiple Google Calendar accounts#911
vlordier wants to merge 14 commits intoleits:masterfrom
vlordier:feat/multi-google-accounts

Conversation

@vlordier
Copy link
Copy Markdown

@vlordier vlordier commented Apr 3, 2026

Summary

  • Allow users to connect multiple Google accounts (e.g., personal and work) simultaneously
  • Each account's calendars are fetched and displayed grouped by email address
  • Add/remove accounts directly from Preferences with confirmation dialog

Changes

  • New model: GoogleAccount for tracking multiple accounts
  • GCEventStore refactored: manages per-account OAuth states, each stored under a unique Keychain entry
  • Calendar ID prefixing: accountId:calendarId prevents collisions across accounts
  • UI: New "Google Accounts" section in Preferences with add/remove functionality
  • Persistence: Accounts stored in Defaults, auth states in Keychain

UX Improvements

  • Confirmation dialog before removing an account (shows email and warns about consequences)
  • Clear empty state when no accounts are connected
  • "Connected" status shown under each account email
  • Reactive UI that stays in sync with account changes
  • Disabled button while adding to prevent double-clicks

Testing

  • Build succeeds with no new SwiftLint violations
  • All pre-existing lint violations are in unrelated files

Summary by CodeRabbit

  • New Features

    • Multi-account Google Calendar support: add, list and remove multiple Google accounts from Preferences.
    • Calendars and events load per connected account (IDs scoped by account); signing in only adds accounts when none exist, signing out removes connected Google accounts.
    • Preferences UI: Google Accounts section, add-account sheet, per-account removal, and clearer empty-state messaging.
  • Localization

    • Added strings for Google account management, empty states, dialogs, and sign-in flow.

Allow users to connect multiple Google accounts (e.g., personal and work)
simultaneously. Each account's calendars are fetched and displayed grouped
by email address.

Changes:
- Add GoogleAccount model for tracking multiple accounts
- Refactor GCEventStore to manage per-account OAuth states
- Prefix calendar IDs with account ID to prevent collisions
- Add Google Accounts section in Preferences with add/remove UI
- Add confirmation dialog before removing an account
- Persist accounts in Defaults and auth states in Keychain
Copilot AI review requested due to automatic review settings April 3, 2026 15:30
@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Apr 3, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 3, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

GCEventStore is refactored from a single-account model to a multi-account model: adds GoogleAccount and Defaults[.googleAccounts], per-account in-memory authStates, account-scoped token refresh/revocation, calendar ID prefixing with {accountId}:calendarId, and UI account management APIs (getAccounts, addAccount, removeAccount).

Changes

Cohort / File(s) Summary
Multi-account core
MeetingBar/Core/EventStores/GCEventStore.swift, MeetingBar/Core/Models/GoogleAccount.swift, MeetingBar/Extensions/DefaultsKeys.swift
GCEventStore now tracks authStates: [accountId: OIDAuthState], persists accounts to Defaults[.googleAccounts], adds getAccounts(), addAccount() async throws -> GoogleAccount, removeAccount(_:) async, account-scoped token handling, and per-account refresh tasks/keychain handling. Added GoogleAccount model and Defaults key.
Calendar & event fetching
MeetingBar/Core/EventStores/GCEventStore.swift
Calendar list and event fetches are account-aware: fetchAllCalendars() prefixes calendar IDs with {accountId}:calendarId; fetchEventsForDateRange groups by account; getCalendarEventsForDateRange requires accountId and strips prefix. Auth failures remove the affected account; JSON parsing enforces items array shape.
Preferences UI
MeetingBar/UI/Views/Preferences/CalendarsTab.swift
Adds GoogleAccountsSection and AddAccountSheet, account add/remove flows (sheet + confirmation), updates empty-state branching for Google provider, removes Google sign-out from provider picker, and localizes refresh label and other strings.
Tests
MeetingBarTests/GoogleAccountTests.swift, MeetingBarTests/TimelineLogicTests.swift
Adds tests for GoogleAccount (Codable/Hashable/Defaults), calendar ID prefixing/grouping and selected-calendar cleanup on account removal, and extensive GC event parsing tests. Replaced SwiftUICore import with SwiftUI in timeline tests.
Localization & helpers
MeetingBar/Resources/Localization/en.lproj/Localizable.strings, MeetingBar/Utilities/Helpers.swift
Added English localization keys for multi-account UI (accounts, add/remove dialogs, empty states, auth waiting, refresh). Added DateFormatter.yyyyMMdd shared formatter.

Sequence Diagram

sequenceDiagram
    participant User
    participant UI as CalendarsTab (UI)
    participant Store as GCEventStore
    participant Defaults
    participant Keychain
    participant Google as Google API

    User->>UI: Tap "Add Google Account"
    UI->>Store: addAccount()
    Store->>Google: Start OAuth flow
    Google->>Store: Return OIDAuthState
    Store->>Defaults: Append GoogleAccount to googleAccounts
    Store->>Keychain: Store auth state for accountId
    Store->>UI: Return new GoogleAccount

    User->>UI: Request calendars
    UI->>Store: fetchAllCalendars()
    Store->>Defaults: Read googleAccounts
    loop per account
        Store->>Keychain: Load auth state for accountId
        Store->>Google: Fetch calendar list (using account token)
        Google->>Store: Return calendars
        Store->>Store: Prefix calendar IDs as "accountId:calendarId"
    end
    Store->>UI: Return aggregated prefixed calendars
    UI->>User: Display calendars grouped by account
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

size:L, lgtm

Suggested reviewers

  • leits
  • mskopp

Poem

🐰 I hopped to split one into many,
Tokens tucked by account, tidy and plenty.
Prefixed calendars march in a row,
I guard each key and help them grow.
🥕📅

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.07% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately and concisely summarizes the main change—adding support for multiple Google Calendar accounts—which is the core feature introduced across all modified files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@dosubot dosubot Bot added the feature New feature or request label Apr 3, 2026
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds multi-account Google Calendar support, allowing users to connect multiple Google accounts and fetch/display calendars grouped by account email, with add/remove account management in Preferences.

Changes:

  • Added a GoogleAccount model and persisted connected accounts in Defaults (googleAccounts).
  • Refactored GCEventStore to manage per-account OAuth state, store each auth state under a unique Keychain entry, and prefix calendar IDs as accountId:calendarId to avoid collisions.
  • Updated Preferences → Calendars UI to show a new “Google Accounts” section with add/remove flows and a remove confirmation dialog.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
MeetingBar/UI/Views/Preferences/CalendarsTab.swift Adds Google Accounts management UI and a Google empty state in the calendars list.
MeetingBar/Extensions/DefaultsKeys.swift Persists connected Google accounts in Defaults via a new key.
MeetingBar/Core/Models/GoogleAccount.swift Introduces the model representing a connected Google account (id + email).
MeetingBar/Core/Managers/EventManager.swift Adds a weak shared instance reference (currently unused).
MeetingBar/Core/EventStores/GCEventStore.swift Implements per-account OAuth storage and multi-account calendar/event fetching with ID prefixing.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +24 to +29
if Defaults[.eventStoreProvider] == .googleCalendar {
GroupBox(label: Label("Google Accounts", systemImage: "person.3")) {
GoogleAccountsSection(eventManager: eventManager)
}
.padding(.bottom, 5)
}
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New UI strings in this file are hard-coded (e.g., "Google Accounts", "No accounts connected…", "Add Google Account", "Connected", "Remove Account", etc.) while the surrounding Preferences UI uses localized strings via .loco(). Please switch these new user-facing strings to the localization system so they participate in translations.

Copilot uses AI. Check for mistakes.
Comment on lines +36 to +39
} else {
Text("No accounts connected. Add a Google account to get started.")
.foregroundColor(.secondary)
.padding()
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The empty-state message shown when eventManager.calendars.isEmpty currently assumes no Google accounts are connected. calendars can also be empty due to fetch errors, permissions issues, or accounts that exist but have no calendars, so this text can be misleading. Consider basing the message on Defaults[.googleAccounts].isEmpty (and/or surfacing an error/loading state) rather than calendars.isEmpty alone.

Suggested change
} else {
Text("No accounts connected. Add a Google account to get started.")
.foregroundColor(.secondary)
.padding()
} else if Defaults[.googleAccounts].isEmpty {
Text("No accounts connected. Add a Google account to get started.")
.foregroundColor(.secondary)
.padding()
} else {
Text("No calendars available. Try refreshing or check account access.")
.foregroundColor(.secondary)
.padding()

Copilot uses AI. Check for mistakes.
Comment thread MeetingBar/UI/Views/Preferences/CalendarsTab.swift Outdated
Comment thread MeetingBar/Core/Managers/EventManager.swift Outdated
Comment on lines +143 to +156
func removeAccount(_ account: GoogleAccount) async {
guard let state = authStates[account.id] else { return }

// Revoke tokens in parallel
let access = state.lastTokenResponse?.accessToken
let refresh = state.lastTokenResponse?.refreshToken
await withTaskGroup(of: Void.self) { grp in
if let acc = access { grp.addTask { try? await self.revoke(token: acc) } }
if let ref = refresh { grp.addTask { try? await self.revoke(token: ref) } }
}

clearAuthState()
authStates.removeValue(forKey: account.id)
accounts.removeAll { $0.id == account.id }
Keychain.delete(for: accountKeychainKey(accountId: account.id))
}
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removeAccount returns early when there’s no in-memory authStates[account.id]. This prevents users from removing an account if Keychain restore failed or state was already evicted, leaving a stuck account entry in Defaults[.googleAccounts]. removeAccount should still remove the account from Defaults, delete the Keychain entry, and ideally also purge any selectedCalendarIDs prefixed with this account id.

Copilot uses AI. Check for mistakes.
Comment on lines 79 to 84
// Singleton
static let shared = GCEventStore()
private override init() {
super.init()
self.authState = restoreAuthState()
// delegates were set in didSet, but set them again just in case restore returned nil
self.authState?.stateChangeDelegate = self
self.authState?.errorDelegate = self
restoreAllAuthStates()
}
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change switches Keychain storage from a single legacy key (googleAuthKeychainName) to per-account keys (googleAuthKeychainName_<uuid>), but restoreAllAuthStates() only loads states for accounts already present in Defaults[.googleAccounts]. On upgrade, existing users will likely have a valid legacy auth state in Keychain but no googleAccounts entry, effectively forcing a sign-out and leaving selectedCalendarIDs unmigrated. Consider adding a one-time migration path: if googleAccounts is empty, try loading the legacy Keychain entry, derive email from the ID token, create an initial GoogleAccount, persist it under the new key, and migrate existing selected calendar IDs by prefixing them.

Copilot uses AI. Check for mistakes.
Comment thread MeetingBar/Core/EventStores/GCEventStore.swift Outdated
Comment thread MeetingBar/Core/EventStores/GCEventStore.swift Outdated
Comment on lines +225 to +227
let calendarsByAccount = Dictionary(grouping: calendars) { calendar in
calendar.id.components(separatedBy: ":").first ?? ""
}
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The account id extraction uses calendar.id.components(separatedBy: ":").first, which will treat any unprefixed calendar id as the account id and silently skip fetching events (no matching authStates entry). It also splits on every :, which is more work than needed and can be error-prone if ids ever contain :. Consider parsing with a single split (maxSplits: 1) and explicitly handling/migrating unprefixed ids.

Copilot uses AI. Check for mistakes.
Comment on lines +366 to +375
nonisolated func didChange(_ state: OIDAuthState) {
let accessToken = state.lastTokenResponse?.accessToken
Task { @MainActor in
if let account = self.accounts.first(where: { account in
guard let storedState = self.authStates[account.id] else { return false }
return storedState.lastTokenResponse?.accessToken == accessToken
}) {
self.persistAuthState(for: account)
}
}
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

didChange(_:) / didEncounterAuthorizationError attempt to map the incoming OIDAuthState back to an account by comparing access token strings. This can fail when the access token is nil, has just rotated, or (in edge cases) if two states temporarily share the same token value, leading to missed persistence or removing the wrong account. Since OIDAuthState is a class, prefer mapping by object identity (e.g., storedState === state) or keep a dedicated [ObjectIdentifier: accountId] index.

Copilot uses AI. Check for mistakes.
- GoogleAccount model: creation, hashable, codable, defaults persistence
- Multi-account calendar: ID prefixing, uniqueness, grouping by source
@dosubot dosubot Bot added size:XL This PR changes 500-999 lines, ignoring generated files. and removed size:L This PR changes 100-499 lines, ignoring generated files. labels Apr 3, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@MeetingBar/Core/EventStores/GCEventStore.swift`:
- Around line 305-317: In restoreAllAuthStates(), handle failures instead of
silently skipping: when Keychain.load(for: accountKeychainKey(accountId:))
returns nil or NSKeyedUnarchiver throws, remove or mark that account from the
accounts collection (and delete any stale Keychain entry) so it won't be treated
as connected by signIn()/fetchAllCalendars(); additionally, if replacing a
legacy single-account storage, attempt to detect and migrate that legacy
OIDAuthState into the per-account list here before pruning. Ensure
state.stateChangeDelegate and state.errorDelegate are still set and
authStates[account.id] is populated only for successfully restored or migrated
states.
- Around line 125-133: The code is creating a new GoogleAccount with a fresh
accountId even when the email already exists, causing duplicate accounts; change
the logic in GCEventStore (where GoogleAccount(id: accountId, email: email) is
created and appended) to first check for an existing account by email in
self.accounts, and if found reuse that account's id and update that account (do
not append), otherwise create and append a new GoogleAccount; also update
self.authStates[accountId] (using the reused id), set state.stateChangeDelegate
and state.errorDelegate, call persistAuthState(for:) with the reused/existing
GoogleAccount, and only call sendNotification/cont.resume once with the correct
account instance. Ensure no duplicate account objects are appended to
self.accounts.
- Around line 366-389: The code currently matches accounts by comparing access
tokens in didChange(_:) and authState(_:didEncounterAuthorizationError:);
instead, find the matching account by comparing the stored OIDAuthState instance
identity with the provided state using ===. In both nonisolated methods
(didChange and authState(_:didEncounterAuthorizationError:)) iterate your
authStates to locate the account whose storedState === state (or check
authStates[account.id] === state) and then call persistAuthState(for: account)
or await removeAccount(account) as before; remove reliance on
lastTokenResponse?.accessToken which may be nil or rotated.
- Around line 143-156: The removal currently waits for revoke tasks before
clearing local state; instead make removeAccount(_ account: GoogleAccount) clear
local records first (remove from authStates, drop from accounts, call
Keychain.delete(accountKeychainKey(accountId:))) and then fire-and-forget
revocations by calling revoke(token:) asynchronously (e.g., spawn tasks without
awaiting or use Task { try? await self.revoke(token: ...) }) so revocation is
best-effort and does not block local deletion; apply the same pattern for
signOut() to ensure local state is cleared immediately.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 033c97e6-5882-429e-ae8b-8b93929d8a64

📥 Commits

Reviewing files that changed from the base of the PR and between 48373d7 and 7ca2600.

📒 Files selected for processing (5)
  • MeetingBar/Core/EventStores/GCEventStore.swift
  • MeetingBar/Core/Managers/EventManager.swift
  • MeetingBar/Core/Models/GoogleAccount.swift
  • MeetingBar/Extensions/DefaultsKeys.swift
  • MeetingBar/UI/Views/Preferences/CalendarsTab.swift

Comment thread MeetingBar/Core/EventStores/GCEventStore.swift Outdated
Comment thread MeetingBar/Core/EventStores/GCEventStore.swift
Comment thread MeetingBar/Core/EventStores/GCEventStore.swift
Comment on lines +185 to +187
let prefixedCalendarId = "\(account.id):\(calendarID)"
return MBCalendar(title: title,
id: prefixedCalendarId,
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Migrate stored Google calendar IDs before namespacing them.

MeetingBar/Core/Managers/EventManager.swift, Line 117 still matches Defaults[.selectedCalendarIDs] by exact string equality. After introducing accountId:calendarId, every previously saved Google selection becomes unmatched and users have to reselect calendars on upgrade.

✅ Addressed in commit 59b9cc4

Comment thread MeetingBar/Core/EventStores/GCEventStore.swift
Comment on lines +366 to +389
nonisolated func didChange(_ state: OIDAuthState) {
let accessToken = state.lastTokenResponse?.accessToken
Task { @MainActor in
if let account = self.accounts.first(where: { account in
guard let storedState = self.authStates[account.id] else { return false }
return storedState.lastTokenResponse?.accessToken == accessToken
}) {
self.persistAuthState(for: account)
}
}
}

func authState(_ state: OIDAuthState, didEncounterAuthorizationError error: Error) {
nonisolated func authState(_ state: OIDAuthState, didEncounterAuthorizationError error: Error) {
let nsErr = error as NSError
if nsErr.domain == OIDOAuthTokenErrorDomain {
// refresh token invalid → clean state & notify
clearAuthState()
let accessToken = state.lastTokenResponse?.accessToken
Task { @MainActor in
if let account = self.accounts.first(where: { account in
guard let storedState = self.authStates[account.id] else { return false }
return storedState.lastTokenResponse?.accessToken == accessToken
}) {
await self.removeAccount(account)
}
}
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Look up delegate callbacks by OIDAuthState identity, not access token.

lastTokenResponse?.accessToken is unstable: it rotates on refresh and can be nil on authorization-error paths. Matching on that value can persist/remove the wrong account or none at all. Compare the stored OIDAuthState instances with === instead.

Suggested change
 nonisolated func didChange(_ state: OIDAuthState) {
-    let accessToken = state.lastTokenResponse?.accessToken
     Task { `@MainActor` in
         if let account = self.accounts.first(where: { account in
-            guard let storedState = self.authStates[account.id] else { return false }
-            return storedState.lastTokenResponse?.accessToken == accessToken
+            self.authStates[account.id] === state
         }) {
             self.persistAuthState(for: account)
         }
@@
 nonisolated func authState(_ state: OIDAuthState, didEncounterAuthorizationError error: Error) {
     let nsErr = error as NSError
     if nsErr.domain == OIDOAuthTokenErrorDomain {
-        let accessToken = state.lastTokenResponse?.accessToken
         Task { `@MainActor` in
             if let account = self.accounts.first(where: { account in
-                guard let storedState = self.authStates[account.id] else { return false }
-                return storedState.lastTokenResponse?.accessToken == accessToken
+                self.authStates[account.id] === state
             }) {
                 await self.removeAccount(account)
             }
         }
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@MeetingBar/Core/EventStores/GCEventStore.swift` around lines 366 - 389, The
code currently matches accounts by comparing access tokens in didChange(_:) and
authState(_:didEncounterAuthorizationError:); instead, find the matching account
by comparing the stored OIDAuthState instance identity with the provided state
using ===. In both nonisolated methods (didChange and
authState(_:didEncounterAuthorizationError:)) iterate your authStates to locate
the account whose storedState === state (or check authStates[account.id] ===
state) and then call persistAuthState(for: account) or await
removeAccount(account) as before; remove reliance on
lastTokenResponse?.accessToken which may be nil or rotated.

✅ Addressed in commits 6eb345c to df75974

- Always clean up Defaults/Keychain in removeAccount even if auth state missing
- Clean up selectedCalendarIDs when removing an account
- Add legacy auth state migration for existing single-account users
- Restore forcePrompt/consent behavior for refresh_token
- Fix didChange delegate to use ObjectIdentifier instead of token matching
- Remove unused ensureAccountSignedIn and signInTask
- Fix force-cast of items in fetchJSON, throw descriptive error
- Add accessibility labels to remove button
- Remove unused EventManager.shared
- Use forcePrompt parameter in signIn
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 3, 2026

Codecov Report

❌ Patch coverage is 8.00604% with 609 lines in your changes missing coverage. Please review.
✅ Project coverage is 34.51%. Comparing base (84e0a9f) to head (77bbaeb).
⚠️ Report is 15 commits behind head on master.

Files with missing lines Patch % Lines
MeetingBar/Core/EventStores/GCEventStore.swift 0.00% 299 Missing ⚠️
MeetingBarTests/GoogleAccountTests.swift 20.66% 192 Missing ⚠️
MeetingBar/UI/Views/Preferences/CalendarsTab.swift 0.00% 112 Missing ⚠️
MeetingBar/Utilities/Helpers.swift 0.00% 6 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #911      +/-   ##
==========================================
+ Coverage   26.30%   34.51%   +8.21%     
==========================================
  Files          36       50      +14     
  Lines        5645     5536     -109     
  Branches     2210     1797     -413     
==========================================
+ Hits         1485     1911     +426     
+ Misses       4103     3568     -535     
  Partials       57       57              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
MeetingBar/Core/EventStores/GCEventStore.swift (1)

32-38: ⚠️ Potential issue | 🟠 Major

Decode JWT payload segments as Base64URL before extracting email.

JWT ID token payload segments are Base64URL-encoded and commonly unpadded. The current code uses plain Data(base64Encoded:), which will fail to decode valid Google ID tokens. This causes state.userEmail to return nil, breaking account creation and legacy migration flows.

Before decoding with Data(base64Encoded:), normalize the segment by replacing - with +, _ with /, and adding padding as needed to reach a multiple of 4 characters.

🩹 Suggested fix
     var userEmail: String? {
         guard let idToken = lastTokenResponse?.idToken else { return nil }
         let parts = idToken.split(separator: ".")
-        guard parts.count > 1,
-              let payloadData = Data(base64Encoded: String(parts[1])),
+        let payload = String(parts[1])
+            .replacingOccurrences(of: "-", with: "+")
+            .replacingOccurrences(of: "_", with: "/")
+        let paddedPayload = payload + String(repeating: "=", count: (4 - payload.count % 4) % 4)
+        guard parts.count > 1,
+              let payloadData = Data(base64Encoded: paddedPayload),
              let json = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
         else { return nil }
         return json["email"] as? String
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@MeetingBar/Core/EventStores/GCEventStore.swift` around lines 32 - 38,
userEmail currently decodes idToken payload using Data(base64Encoded:) which
fails for Base64URL (unpadded) JWT segments; update the userEmail getter
(referencing lastTokenResponse and idToken) to normalize the middle JWT segment
by replacing '-'→'+', '_'→'/', and appending '=' padding until length % 4 == 0
before calling Data(base64Encoded:), then proceed with JSONSerialization to
extract the "email" field.
♻️ Duplicate comments (5)
MeetingBar/Core/EventStores/GCEventStore.swift (5)

144-160: ⚠️ Potential issue | 🟠 Major

Clear local state before awaiting revoke calls.

removeAccount(_:) waits on best-effort token revocations before deleting Defaults/Keychain state. Because the shared URLSession is configured with waitsForConnectivity, an offline remove/sign-out can sit for a long time while the UI still shows the account as connected. Drop local state first and revoke in background.

⚡ Suggested ordering
 func removeAccount(_ account: GoogleAccount) async {
-    if let state = authStates[account.id] {
-        let access  = state.lastTokenResponse?.accessToken
-        let refresh = state.lastTokenResponse?.refreshToken
-        await withTaskGroup(of: Void.self) { grp in
-            if let acc = access { grp.addTask { try? await self.revoke(token: acc) } }
-            if let ref = refresh { grp.addTask { try? await self.revoke(token: ref) } }
-        }
-        authStates.removeValue(forKey: account.id)
-    }
-
+    let state = authStates.removeValue(forKey: account.id)
     accounts.removeAll { $0.id == account.id }
     Keychain.delete(for: accountKeychainKey(accountId: account.id))
 
     let prefix = "\(account.id):"
     Defaults[.selectedCalendarIDs] = Defaults[.selectedCalendarIDs].filter { !$0.hasPrefix(prefix) }
+
+    if let access = state?.lastTokenResponse?.accessToken {
+        Task { try? await self.revoke(token: access) }
+    }
+    if let refresh = state?.lastTokenResponse?.refreshToken {
+        Task { try? await self.revoke(token: refresh) }
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@MeetingBar/Core/EventStores/GCEventStore.swift` around lines 144 - 160, The
code currently awaits token revocations before clearing local state in
removeAccount(_:); instead capture access/refresh tokens from authStates, then
immediately remove authStates/account entry, Keychain.delete and update
Defaults[.selectedCalendarIDs], and only after local cleanup spawn a background
Task (or Task.detached) that performs the revoke(token:) calls via a
withTaskGroup without awaiting it; reference removeAccount(_:), authStates,
accounts, Keychain.delete, Defaults[.selectedCalendarIDs], and revoke(token:)
when making the change.

189-194: ⚠️ Potential issue | 🟠 Major

Migrate legacy Google selections before prefixing calendar IDs.

fetchAllCalendars() now rewrites every Google ID to accountId:calendarId, but MeetingBar/Core/Managers/EventManager.swift still filters calendars by exact membership in Defaults[.selectedCalendarIDs]. Existing Google selections will all drop on upgrade unless those stored IDs are migrated, at least for the single-account legacy path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@MeetingBar/Core/EventStores/GCEventStore.swift` around lines 189 - 194,
fetchAllCalendars() currently prefixes Google calendar IDs to
"accountId:calendarId" which will break existing stored selections in
Defaults[.selectedCalendarIDs]; before you construct MBCalendar with the
prefixed id, detect and migrate legacy Google IDs (entries that match raw
calendarIDs or lack the "accountId:" prefix) to the new "accountId:calendarId"
form and update Defaults[.selectedCalendarIDs] accordingly (handle the
single-account legacy path specifically), so EventManager.swift's
exact-membership check against Defaults[.selectedCalendarIDs] continues to
match; modify the logic around where you build prefixedCalendarId in
fetchAllCalendars() to perform this migration step and persist the updated
selected IDs.

126-133: ⚠️ Potential issue | 🟠 Major

Don't create a new logical account for an email that's already connected.

Both auth paths always mint a fresh accountId and append it. Re-authing the same mailbox duplicates calendars/events under the same source section, and the new prefix invalidates previously saved selectedCalendarIDs for that mailbox. Upsert by email and reuse the existing account ID instead.

Also applies to: 289-296

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@MeetingBar/Core/EventStores/GCEventStore.swift` around lines 126 - 133, When
creating a GoogleAccount in GCEventStore (where code currently does
GoogleAccount(id: accountId, email: email), sets self.authStates[accountId] =
state, state.stateChangeDelegate = self, state.errorDelegate = self,
self.persistAuthState(for: account), and self.accounts.append(account)), first
look up an existing account by the email and if found reuse its existing
account.id instead of minting a new accountId; update authStates[existingId] =
state, set the delegates on state, call persistAuthState(for: existingAccount)
and do not append a duplicate to self.accounts. Apply the same upsert-by-email
logic to the other auth path that also constructs GoogleAccount to avoid
duplicated accounts and invalidated selectedCalendarIDs.

380-391: ⚠️ Potential issue | 🟠 Major

Prune accounts whose auth state can't be restored.

Missing or unreadable Keychain entries are silently skipped, but the corresponding GoogleAccount stays in Defaults. The Preferences UI then still shows it as connected, fetchAllCalendars() ignores it, and signIn() early-returns because accounts is non-empty. Remove the stale account here, delete its bad keychain entry, and clean its prefixed selections.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@MeetingBar/Core/EventStores/GCEventStore.swift` around lines 380 - 391, In
restoreAllAuthStates(), when Keychain.load(for: accountKeychainKey(accountId:))
returns nil or unarchiving OIDAuthState fails, treat the account as stale:
remove it from the accounts/defaults collection (so signIn()/fetchAllCalendars()
won't early-return), delete its keychain entry (e.g. Keychain.delete(for: key)),
and clear any per-account prefixed selections stored in Defaults (keys that
include the account.id, e.g. selected-calendar or similar prefixes) before
continuing; also ensure authStates[account.id] is not left set.

59-60: ⚠️ Potential issue | 🟠 Major

Serialize addAccount while an OAuth flow is already in flight.

currentAuthorizationFlow and pendingAuthAccountId are singleton-wide, but neither add path rejects a second call. A later add can overwrite the first session, and the first callback then clears the second flow's state. The sheet in MeetingBar/UI/Views/Preferences/CalendarsTab.swift can still be dismissed mid-auth, so this race is user-reachable today.

🔒 Minimal guard
 enum AuthError: Error {
     case notSignedIn
     case refreshFailed
     case invalidResponse
+    case authorizationInProgress
 }
@@
 func addAccount() async throws -> GoogleAccount {
+    guard currentAuthorizationFlow == nil else { throw AuthError.authorizationInProgress }
     let accountId = UUID().uuidString
@@
 private func addAccountWithForcePrompt(_ forcePrompt: Bool) async throws -> GoogleAccount {
+    guard currentAuthorizationFlow == nil else { throw AuthError.authorizationInProgress }
     let accountId = UUID().uuidString

Also applies to: 93-95, 118-124, 251-253, 281-287

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@MeetingBar/Core/EventStores/GCEventStore.swift` around lines 59 - 60,
Concurrent calls to addAccount can overwrite singleton state
(currentAuthorizationFlow and pendingAuthAccountId) causing one auth flow to
cancel another; fix by serializing and validating flows: in addAccount (and any
other methods that set currentAuthorizationFlow/pendingAuthAccountId) guard at
the start under MainActor that no authorization is already in flight (e.g., if
currentAuthorizationFlow != nil or pendingAuthAccountId != nil then
reject/return an error or queue the request), set both variables only once, and
in the OIDExternalUserAgentSession completion handlers verify that the session
and pendingAuthAccountId still match the values you set before clearing them (do
not unconditionally nil out currentAuthorizationFlow/pendingAuthAccountId);
apply this pattern to the other affected sites that reference
currentAuthorizationFlow and pendingAuthAccountId to prevent races.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@MeetingBar/Core/EventStores/GCEventStore.swift`:
- Around line 32-38: userEmail currently decodes idToken payload using
Data(base64Encoded:) which fails for Base64URL (unpadded) JWT segments; update
the userEmail getter (referencing lastTokenResponse and idToken) to normalize
the middle JWT segment by replacing '-'→'+', '_'→'/', and appending '=' padding
until length % 4 == 0 before calling Data(base64Encoded:), then proceed with
JSONSerialization to extract the "email" field.

---

Duplicate comments:
In `@MeetingBar/Core/EventStores/GCEventStore.swift`:
- Around line 144-160: The code currently awaits token revocations before
clearing local state in removeAccount(_:); instead capture access/refresh tokens
from authStates, then immediately remove authStates/account entry,
Keychain.delete and update Defaults[.selectedCalendarIDs], and only after local
cleanup spawn a background Task (or Task.detached) that performs the
revoke(token:) calls via a withTaskGroup without awaiting it; reference
removeAccount(_:), authStates, accounts, Keychain.delete,
Defaults[.selectedCalendarIDs], and revoke(token:) when making the change.
- Around line 189-194: fetchAllCalendars() currently prefixes Google calendar
IDs to "accountId:calendarId" which will break existing stored selections in
Defaults[.selectedCalendarIDs]; before you construct MBCalendar with the
prefixed id, detect and migrate legacy Google IDs (entries that match raw
calendarIDs or lack the "accountId:" prefix) to the new "accountId:calendarId"
form and update Defaults[.selectedCalendarIDs] accordingly (handle the
single-account legacy path specifically), so EventManager.swift's
exact-membership check against Defaults[.selectedCalendarIDs] continues to
match; modify the logic around where you build prefixedCalendarId in
fetchAllCalendars() to perform this migration step and persist the updated
selected IDs.
- Around line 126-133: When creating a GoogleAccount in GCEventStore (where code
currently does GoogleAccount(id: accountId, email: email), sets
self.authStates[accountId] = state, state.stateChangeDelegate = self,
state.errorDelegate = self, self.persistAuthState(for: account), and
self.accounts.append(account)), first look up an existing account by the email
and if found reuse its existing account.id instead of minting a new accountId;
update authStates[existingId] = state, set the delegates on state, call
persistAuthState(for: existingAccount) and do not append a duplicate to
self.accounts. Apply the same upsert-by-email logic to the other auth path that
also constructs GoogleAccount to avoid duplicated accounts and invalidated
selectedCalendarIDs.
- Around line 380-391: In restoreAllAuthStates(), when Keychain.load(for:
accountKeychainKey(accountId:)) returns nil or unarchiving OIDAuthState fails,
treat the account as stale: remove it from the accounts/defaults collection (so
signIn()/fetchAllCalendars() won't early-return), delete its keychain entry
(e.g. Keychain.delete(for: key)), and clear any per-account prefixed selections
stored in Defaults (keys that include the account.id, e.g. selected-calendar or
similar prefixes) before continuing; also ensure authStates[account.id] is not
left set.
- Around line 59-60: Concurrent calls to addAccount can overwrite singleton
state (currentAuthorizationFlow and pendingAuthAccountId) causing one auth flow
to cancel another; fix by serializing and validating flows: in addAccount (and
any other methods that set currentAuthorizationFlow/pendingAuthAccountId) guard
at the start under MainActor that no authorization is already in flight (e.g.,
if currentAuthorizationFlow != nil or pendingAuthAccountId != nil then
reject/return an error or queue the request), set both variables only once, and
in the OIDExternalUserAgentSession completion handlers verify that the session
and pendingAuthAccountId still match the values you set before clearing them (do
not unconditionally nil out currentAuthorizationFlow/pendingAuthAccountId);
apply this pattern to the other affected sites that reference
currentAuthorizationFlow and pendingAuthAccountId to prevent races.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: fa33a629-18ee-41a2-a054-41e175f2b401

📥 Commits

Reviewing files that changed from the base of the PR and between 7ca2600 and df75974.

📒 Files selected for processing (4)
  • MeetingBar/Core/EventStores/GCEventStore.swift
  • MeetingBar/UI/Views/Preferences/CalendarsTab.swift
  • MeetingBarTests/GoogleAccountTests.swift
  • MeetingBarTests/TimelineLogicTests.swift
✅ Files skipped from review due to trivial changes (1)
  • MeetingBarTests/TimelineLogicTests.swift

- Add localization strings for all new UI text (preferences_calendars_*)
- Use @default(.googleAccounts) instead of @State + manual refresh
- Distinguish between no accounts vs no calendars in empty state
- Safe calendar ID parsing with hasPrefix check instead of blind dropFirst
- Use split on first ':' instead of components(separatedBy:)
- Remove duplicate addAccountWithForcePrompt method
- Fix async closure in AddAccountSheet
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
MeetingBar/Core/EventStores/GCEventStore.swift (1)

160-163: Unused forcePrompt parameter.

The forcePrompt parameter is ignored (underscore prefix). If the intention is that signIn only adds an account when none exist, consider removing this parameter from the protocol/signature to avoid confusion. Otherwise, if force-prompting should be supported, the logic needs adjustment.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@MeetingBar/Core/EventStores/GCEventStore.swift` around lines 160 - 163, The
signIn(forcePrompt _: Bool) method currently ignores its forcePrompt parameter;
either remove the unused parameter from the signIn signature in GCEventStore
(and the protocol it implements) or honor it by changing the logic in signIn to
call addAccount() when forcePrompt is true even if accounts is not empty; update
the method on GCEventStore (symbol: signIn) and any protocol/interface
declarations, or adjust the implementation to check the forcePrompt boolean and
always call addAccount() when true (symbol: addAccount, accounts) so the
parameter is no longer unused.
MeetingBar/UI/Views/Preferences/CalendarsTab.swift (1)

160-164: Consider disabling cancel while authentication is in progress.

The cancel button remains active while isAdding is true, allowing users to dismiss the sheet while the OAuth flow is pending in the browser. While the account will still be added successfully, users may be confused seeing no feedback after completing browser authentication.

Suggested improvement
 Button("preferences_calendars_cancel") {
     presentationMode.wrappedValue.dismiss()
 }
 .keyboardShortcut(.escape, modifiers: [])
+.disabled(isAdding)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@MeetingBar/UI/Views/Preferences/CalendarsTab.swift` around lines 160 - 164,
The Cancel button in CalendarsTab.swift should be disabled while the OAuth flow
is in progress to prevent dismissing the sheet mid-auth; update the Button (the
one calling presentationMode.wrappedValue.dismiss()) to honor the isAdding state
(e.g., add a .disabled(isAdding) or .disabled(viewModel.isAdding) modifier) so
it becomes inactive during authentication and optionally also prevent the Escape
keyboard shortcut from firing while isAdding is true.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@MeetingBar/UI/Views/Preferences/CalendarsTab.swift`:
- Around line 35-43: When Defaults[.googleAccounts] is non-empty but the
calendars list is empty, change the UI branch to distinguish between "no
calendars" and load/auth failures: detect per-account fetch/auth state (e.g. an
account.calendarFetchError or account.authRestored flag provided by your
calendar loading logic) and, for accounts with auth or network failures, show an
account-specific message like "Couldn't load calendars for {account.email} —
re-authenticate or remove account" plus Buttons that call existing or new
handlers such as reauthenticateAccount(account) and
removeGoogleAccount(account); only fall back to the generic "no calendars
available" message when the account is healthy and truly has no calendars.

---

Nitpick comments:
In `@MeetingBar/Core/EventStores/GCEventStore.swift`:
- Around line 160-163: The signIn(forcePrompt _: Bool) method currently ignores
its forcePrompt parameter; either remove the unused parameter from the signIn
signature in GCEventStore (and the protocol it implements) or honor it by
changing the logic in signIn to call addAccount() when forcePrompt is true even
if accounts is not empty; update the method on GCEventStore (symbol: signIn) and
any protocol/interface declarations, or adjust the implementation to check the
forcePrompt boolean and always call addAccount() when true (symbol: addAccount,
accounts) so the parameter is no longer unused.

In `@MeetingBar/UI/Views/Preferences/CalendarsTab.swift`:
- Around line 160-164: The Cancel button in CalendarsTab.swift should be
disabled while the OAuth flow is in progress to prevent dismissing the sheet
mid-auth; update the Button (the one calling
presentationMode.wrappedValue.dismiss()) to honor the isAdding state (e.g., add
a .disabled(isAdding) or .disabled(viewModel.isAdding) modifier) so it becomes
inactive during authentication and optionally also prevent the Escape keyboard
shortcut from firing while isAdding is true.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 0a06c60d-0096-4e8b-9bee-e34c71b08adc

📥 Commits

Reviewing files that changed from the base of the PR and between df75974 and 59b9cc4.

📒 Files selected for processing (3)
  • MeetingBar/Core/EventStores/GCEventStore.swift
  • MeetingBar/Resources /Localization /en.lproj/Localizable.strings
  • MeetingBar/UI/Views/Preferences/CalendarsTab.swift
✅ Files skipped from review due to trivial changes (1)
  • MeetingBar/Resources /Localization /en.lproj/Localizable.strings

Comment on lines +35 to 43
} else if Defaults[.googleAccounts].isEmpty {
Text("preferences_calendars_no_accounts_connected")
.foregroundColor(.secondary)
.padding()
} else {
Text("preferences_calendars_no_calendars_available")
.foregroundColor(.secondary)
.padding()
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

"No calendars available" may be misleading for failed auth states.

When Defaults[.googleAccounts] is non-empty but calendars is empty, this could mean:

  1. The account genuinely has no calendars
  2. Auth state restoration failed (phantom account)
  3. Network error during fetch

The message "no calendars available" doesn't guide users to re-authenticate if their session is invalid. Consider showing account-specific status or prompting to remove and re-add accounts that fail to load.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@MeetingBar/UI/Views/Preferences/CalendarsTab.swift` around lines 35 - 43,
When Defaults[.googleAccounts] is non-empty but the calendars list is empty,
change the UI branch to distinguish between "no calendars" and load/auth
failures: detect per-account fetch/auth state (e.g. an
account.calendarFetchError or account.authRestored flag provided by your
calendar loading logic) and, for accounts with auth or network failures, show an
account-specific message like "Couldn't load calendars for {account.email} —
re-authenticate or remove account" plus Buttons that call existing or new
handlers such as reauthenticateAccount(account) and
removeGoogleAccount(account); only fall back to the generic "no calendars
available" message when the account is healthy and truly has no calendars.

…parsing

- Add GCParserTests (9 tests): minimal event, full event, all-day,
  cancelled, unknown status, attendees, conference data, multiple
  entry points, all attendee statuses
- Add calendar ID prefix check and safe split-on-first-colon tests
- Add selectedCalendarIDs cleanup verification on account removal
- Add test for Defaults[.googleAccounts] empty default
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@MeetingBarTests/GoogleAccountTests.swift`:
- Around line 100-114: The test test_selectedCalendarIDsCleanedOnAccountRemoval
currently mutates Defaults[.selectedCalendarIDs] directly to emulate cleanup;
instead invoke the real account-removal flow so the production cleanup logic is
exercised. Replace the inline filter with a call to the app's removal API/path
(e.g., the method that actually removes GoogleAccount instances such as the
AccountManager/AccountsController remove method) passing the GoogleAccount(id:
accountId, ...), then assert Defaults[.selectedCalendarIDs] no longer contains
entries with prefix "\(accountId):"; keep references to GoogleAccount and
Defaults[.selectedCalendarIDs] in the test to locate where to change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: f38847ac-10c8-462a-8065-aff338e0e9f1

📥 Commits

Reviewing files that changed from the base of the PR and between 59b9cc4 and 4332a5a.

📒 Files selected for processing (1)
  • MeetingBarTests/GoogleAccountTests.swift

Comment thread MeetingBarTests/GoogleAccountTests.swift Outdated
vlordier added 3 commits April 3, 2026 18:29
- Replace as! with guard let for event id, start, and end fields
- Safely parse dates with ISO8601 and yyyyMMdd formatters, return nil on failure
- Add NSLog diagnostics for malformed events
- Simplify redundant ternary for recurrent field
- Replace empty Text spacer with HStack spacing in CalendarRow
- Add DateFormatter.yyyyMMdd static extension with POSIX locale
- Replace hardcoded notification strings with localization keys
- Log errors when token revocation fails instead of silently ignoring
- Add notification localization keys to English strings file
- Remove local state before revoking tokens (atomic removal)
- Upsert existing account by email instead of creating duplicates
- Prune accounts with missing/unreadable auth states after restore
- Use ObjectIdentifier for delegate state matching (already done)
- Use firstIndex for calendar ID splitting (already done)
- Test uses real GCEventStore.removeAccount API instead of duplicating logic
- Add refreshed notification variant for re-authenticated accounts
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
MeetingBar/Core/EventStores/GCEventStore.swift (1)

212-221: ⚠️ Potential issue | 🟠 Major

Properly encode the calendar ID in the path component.

Google Calendar IDs are often email addresses (e.g., user@example.com), which contain the @ character that requires URL encoding. Direct interpolation into the URL string bypasses this encoding, breaking the request for calendars with such IDs. Use appendPathComponent() to ensure proper percent-encoding.

Suggested change
-        var comps = URLComponents(string: "https://www.googleapis.com/calendar/v3/calendars/\(originalCalendarId)/events")!
+        var url = URL(string: "https://www.googleapis.com/calendar/v3/calendars")!
+        url.appendPathComponent(originalCalendarId)
+        url.appendPathComponent("events")
+        var comps = URLComponents(url: url, resolvingAgainstBaseURL: false)!
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@MeetingBar/Core/EventStores/GCEventStore.swift` around lines 212 - 221, The
calendar ID is interpolated into the URL string which breaks for IDs containing
characters like '@'; instead build the URL by appending the calendar id as a
path component so it gets percent-encoded: create a base URL for
"https://www.googleapis.com/calendar/v3/calendars", call
appendingPathComponent(originalCalendarId) to get the calendar-specific URL,
then initialize URLComponents(url:calendarURL, resolvingAgainstBaseURL:false),
set the queryItems, and pass components.url to fetchJSON(accountId:). Update the
code around URLComponents/string creation (the variables comps,
originalCalendarId, and the fetchJSON call) accordingly.
♻️ Duplicate comments (5)
MeetingBar/Core/EventStores/GCEventStore.swift (4)

330-341: ⚠️ Potential issue | 🟠 Major

Drop accounts whose auth state cannot be restored.

This loop silently skips missing or unreadable Keychain entries, but accounts still makes those mailboxes look connected and signIn() returns early because the array is non-empty.

Suggested change
 private func restoreAllAuthStates() {
+    authStates.removeAll()
+    var restoredAccounts: [GoogleAccount] = []
     for account in accounts {
         let key = accountKeychainKey(accountId: account.id)
-        guard let data = Keychain.load(for: key) else { continue }
+        guard let data = Keychain.load(for: key) else {
+            Keychain.delete(for: key)
+            continue
+        }
         do {
-            guard let state = try NSKeyedUnarchiver.unarchivedObject(ofClass: OIDAuthState.self, from: data) else { continue }
+            guard let state = try NSKeyedUnarchiver.unarchivedObject(ofClass: OIDAuthState.self, from: data) else {
+                Keychain.delete(for: key)
+                continue
+            }
             state.stateChangeDelegate = self
             state.errorDelegate = self
             authStates[account.id] = state
+            restoredAccounts.append(account)
         } catch {
             NSLog("Error unarchiving OIDAuthState: \(error)")
+            Keychain.delete(for: key)
         }
     }
+    accounts = restoredAccounts
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@MeetingBar/Core/EventStores/GCEventStore.swift` around lines 330 - 341, In
restoreAllAuthStates, if Keychain.load(for: accountKeychainKey(accountId:))
returns nil or unarchiving OIDAuthState fails, remove that account from the
in-memory accounts collection (e.g. accounts.removeAll { $0.id == account.id })
so it no longer appears connected and so signIn() won't short-circuit; also
ensure you do not insert a nil authStates entry and keep the existing NSLog for
errors (and set delegates only on successfully restored OIDAuthState instances).

126-133: ⚠️ Potential issue | 🟠 Major

Upsert by email instead of appending a fresh account every time.

Re-authing the same mailbox still creates a new accountId, which duplicates calendars/events and changes the {accountId}:calendarId prefix that saved selections are matched against.

Suggested change
-                    let account = GoogleAccount(id: accountId, email: email)
-                    self.authStates[accountId] = state
+                    let account = self.accounts.first(where: {
+                        $0.email.caseInsensitiveCompare(email) == .orderedSame
+                    }) ?? GoogleAccount(id: accountId, email: email)
+                    self.authStates[account.id] = state
                     state.stateChangeDelegate = self
                     state.errorDelegate = self
                     self.persistAuthState(for: account)
-                    self.accounts.append(account)
+                    if !self.accounts.contains(where: { $0.id == account.id }) {
+                        self.accounts.append(account)
+                    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@MeetingBar/Core/EventStores/GCEventStore.swift` around lines 126 - 133, When
handling a new auth state in the block that creates GoogleAccount(id: accountId,
email: email), do an upsert by email instead of blindly appending: look for an
existing GoogleAccount in self.accounts whose email matches state.userEmail; if
found reuse that account's id (replace accountId with the existing account.id),
update/replace the stored auth state in self.authStates for that id, set
delegates and persistAuthState(for: existingAccount), and do not append a new
GoogleAccount or change the id/prefix; only append a new GoogleAccount when no
account with that email exists, then persist and notify as currently done.

93-124: ⚠️ Potential issue | 🟠 Major

Serialize addAccount() before the discovery request suspends.

currentAuthorizationFlow and pendingAuthAccountId are still singleton-wide, but neither is claimed until after configuration discovery returns. A second caller can start another OAuth flow in that window and overwrite the first one. The add-account sheet is still dismissible while browser auth is pending, so this is reachable today.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@MeetingBar/Core/EventStores/GCEventStore.swift` around lines 93 - 124, The
addAccount() method can race because pendingAuthAccountId and
currentAuthorizationFlow are only set after the asynchronous
OIDAuthorizationService.discoverConfiguration call returns; set/claim
pendingAuthAccountId (and if possible reserve currentAuthorizationFlow) before
starting discovery so a second caller can't overwrite them while awaiting
discovery, then proceed with discovery and clear the claim on error or when the
flow completes; touch the symbols addAccount(), pendingAuthAccountId,
currentAuthorizationFlow and the discovery call
OIDAuthorizationService.discoverConfiguration to implement this reservation
logic (or use a serializing lock/actor around addAccount()) and ensure claims
are cleared in all failure/completion paths.

142-157: ⚠️ Potential issue | 🟠 Major

Clear local state first, then revoke tokens best-effort.

This still waits on network revocation before removing the account from Defaults, Keychain, and selectedCalendarIDs. When revoke is slow or offline, the app keeps showing a connected account that should already be gone, and signOut() pays that latency for every account.

Suggested change
 func removeAccount(_ account: GoogleAccount) async {
-    if let state = authStates[account.id] {
-        let access  = state.lastTokenResponse?.accessToken
-        let refresh = state.lastTokenResponse?.refreshToken
-        await withTaskGroup(of: Void.self) { grp in
-            if let acc = access { grp.addTask { try? await self.revoke(token: acc) } }
-            if let ref = refresh { grp.addTask { try? await self.revoke(token: ref) } }
-        }
-        authStates.removeValue(forKey: account.id)
-    }
-
+    let state = authStates.removeValue(forKey: account.id)
     accounts.removeAll { $0.id == account.id }
     Keychain.delete(for: accountKeychainKey(accountId: account.id))
 
     let prefix = "\(account.id):"
     Defaults[.selectedCalendarIDs] = Defaults[.selectedCalendarIDs].filter { !$0.hasPrefix(prefix) }
+
+    if let access = state?.lastTokenResponse?.accessToken {
+        Task { try? await self.revoke(token: access) }
+    }
+    if let refresh = state?.lastTokenResponse?.refreshToken {
+        Task { try? await self.revoke(token: refresh) }
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@MeetingBar/Core/EventStores/GCEventStore.swift` around lines 142 - 157, The
removeAccount(_:) implementation currently revokes tokens before clearing local
state, causing UI and signOut latency; change it to first remove local state
(authStates, accounts, Keychain.delete, Defaults[.selectedCalendarIDs])
immediately, then perform token revocation as a best-effort background task
(fire-and-forget) so revoke(token:) is awaited only if needed but does not block
removal; specifically, in removeAccount(_:) clear authStates[account.id], remove
the account from accounts, delete the keychain entry (accountKeychainKey), and
filter Defaults[.selectedCalendarIDs] first, then dispatch revoke(token:) calls
for lastTokenResponse?.accessToken and refreshToken via an unawaited Task or a
Task.detached/Task { } so failures are ignored (try? await) and do not delay UI.
MeetingBar/UI/Views/Preferences/CalendarsTab.swift (1)

160-186: ⚠️ Potential issue | 🟡 Minor

Don’t let the sheet look cancelable while OAuth is still running.

isAdding disables only the sign-in button. Cancel and Escape can still dismiss the sheet while GCEventStore.shared.addAccount() continues in the background, so the flow appears canceled and can be reopened into another auth attempt.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@MeetingBar/UI/Views/Preferences/CalendarsTab.swift` around lines 160 - 186,
The cancel button and ESC shortcut must be disabled while the async OAuth is
running so the sheet cannot be dismissed mid-flow; update the Button labeled
"preferences_calendars_cancel" and its .keyboardShortcut to be inactive when
isAdding is true (or guard the presentationMode.wrappedValue.dismiss() so it
only runs when !isAdding) and also apply interactiveDismissDisabled(isAdding) on
the sheet hosting CalendarsTab so system/drag dismiss is prevented while
GCEventStore.shared.addAccount() is in progress; ensure
presentationMode.wrappedValue.dismiss() is only called after await
onAccountAdded() completes.
🧹 Nitpick comments (1)
MeetingBar/Utilities/Helpers.swift (1)

272-279: Well-implemented static formatter with thread-safe initialization and POSIX locale.

The static lazy-initialized DateFormatter follows best practices: Swift's static let guarantees thread-safe initialization, and the POSIX locale ensures consistent parsing regardless of user locale settings.

Optional follow-up: Consider refactoring openInFantastical (lines 182-183) to use this static formatter instead of creating an inline instance—it would eliminate duplication and add the missing POSIX locale to that function.

,

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@MeetingBar/Utilities/Helpers.swift` around lines 272 - 279, The
openInFantastical function currently creates an inline DateFormatter without the
POSIX locale; replace that inline instance with the shared static formatter
DateFormatter.yyyyMMdd to remove duplication and ensure POSIX locale
usage—locate the openInFantastical method and swap its local DateFormatter
creation/usage to reference DateFormatter.yyyyMMdd wherever the date is
formatted for the Fantastical URL.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@MeetingBar/UI/Views/Preferences/CalendarsTab.swift`:
- Around line 114-129: The confirmation dialog is receiving the selected
GoogleAccount (accountToRemove) but the title/message do not display which
account will be removed; update the .confirmationDialog block to interpolate the
passed-in account (the closure parameter named account) into the localized title
and/or message so the destructive prompt identifies the specific GoogleAccount
(e.g., include account.name or account.email in the displayed Text). Keep the
same destructive Button that calls GCEventStore.shared.removeAccount(account)
and eventManager.refreshSources(), only change the Text(...) for the
title/message inside the presenting closure to include the account identifier.

---

Outside diff comments:
In `@MeetingBar/Core/EventStores/GCEventStore.swift`:
- Around line 212-221: The calendar ID is interpolated into the URL string which
breaks for IDs containing characters like '@'; instead build the URL by
appending the calendar id as a path component so it gets percent-encoded: create
a base URL for "https://www.googleapis.com/calendar/v3/calendars", call
appendingPathComponent(originalCalendarId) to get the calendar-specific URL,
then initialize URLComponents(url:calendarURL, resolvingAgainstBaseURL:false),
set the queryItems, and pass components.url to fetchJSON(accountId:). Update the
code around URLComponents/string creation (the variables comps,
originalCalendarId, and the fetchJSON call) accordingly.

---

Duplicate comments:
In `@MeetingBar/Core/EventStores/GCEventStore.swift`:
- Around line 330-341: In restoreAllAuthStates, if Keychain.load(for:
accountKeychainKey(accountId:)) returns nil or unarchiving OIDAuthState fails,
remove that account from the in-memory accounts collection (e.g.
accounts.removeAll { $0.id == account.id }) so it no longer appears connected
and so signIn() won't short-circuit; also ensure you do not insert a nil
authStates entry and keep the existing NSLog for errors (and set delegates only
on successfully restored OIDAuthState instances).
- Around line 126-133: When handling a new auth state in the block that creates
GoogleAccount(id: accountId, email: email), do an upsert by email instead of
blindly appending: look for an existing GoogleAccount in self.accounts whose
email matches state.userEmail; if found reuse that account's id (replace
accountId with the existing account.id), update/replace the stored auth state in
self.authStates for that id, set delegates and persistAuthState(for:
existingAccount), and do not append a new GoogleAccount or change the id/prefix;
only append a new GoogleAccount when no account with that email exists, then
persist and notify as currently done.
- Around line 93-124: The addAccount() method can race because
pendingAuthAccountId and currentAuthorizationFlow are only set after the
asynchronous OIDAuthorizationService.discoverConfiguration call returns;
set/claim pendingAuthAccountId (and if possible reserve
currentAuthorizationFlow) before starting discovery so a second caller can't
overwrite them while awaiting discovery, then proceed with discovery and clear
the claim on error or when the flow completes; touch the symbols addAccount(),
pendingAuthAccountId, currentAuthorizationFlow and the discovery call
OIDAuthorizationService.discoverConfiguration to implement this reservation
logic (or use a serializing lock/actor around addAccount()) and ensure claims
are cleared in all failure/completion paths.
- Around line 142-157: The removeAccount(_:) implementation currently revokes
tokens before clearing local state, causing UI and signOut latency; change it to
first remove local state (authStates, accounts, Keychain.delete,
Defaults[.selectedCalendarIDs]) immediately, then perform token revocation as a
best-effort background task (fire-and-forget) so revoke(token:) is awaited only
if needed but does not block removal; specifically, in removeAccount(_:) clear
authStates[account.id], remove the account from accounts, delete the keychain
entry (accountKeychainKey), and filter Defaults[.selectedCalendarIDs] first,
then dispatch revoke(token:) calls for lastTokenResponse?.accessToken and
refreshToken via an unawaited Task or a Task.detached/Task { } so failures are
ignored (try? await) and do not delay UI.

In `@MeetingBar/UI/Views/Preferences/CalendarsTab.swift`:
- Around line 160-186: The cancel button and ESC shortcut must be disabled while
the async OAuth is running so the sheet cannot be dismissed mid-flow; update the
Button labeled "preferences_calendars_cancel" and its .keyboardShortcut to be
inactive when isAdding is true (or guard the
presentationMode.wrappedValue.dismiss() so it only runs when !isAdding) and also
apply interactiveDismissDisabled(isAdding) on the sheet hosting CalendarsTab so
system/drag dismiss is prevented while GCEventStore.shared.addAccount() is in
progress; ensure presentationMode.wrappedValue.dismiss() is only called after
await onAccountAdded() completes.

---

Nitpick comments:
In `@MeetingBar/Utilities/Helpers.swift`:
- Around line 272-279: The openInFantastical function currently creates an
inline DateFormatter without the POSIX locale; replace that inline instance with
the shared static formatter DateFormatter.yyyyMMdd to remove duplication and
ensure POSIX locale usage—locate the openInFantastical method and swap its local
DateFormatter creation/usage to reference DateFormatter.yyyyMMdd wherever the
date is formatted for the Fantastical URL.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: c9db9d43-4a3c-4f0f-ae2c-a7f5b065b7eb

📥 Commits

Reviewing files that changed from the base of the PR and between 4332a5a and a1d7362.

📒 Files selected for processing (3)
  • MeetingBar/Core/EventStores/GCEventStore.swift
  • MeetingBar/UI/Views/Preferences/CalendarsTab.swift
  • MeetingBar/Utilities/Helpers.swift

Comment thread MeetingBar/UI/Views/Preferences/CalendarsTab.swift
Copy link
Copy Markdown
Author

@vlordier vlordier left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All Copilot review comments reference the initial commit and have been addressed in subsequent commits:

  1. Hardcoded UI strings → ✅ All user-facing strings now use .loco() localization keys
  2. Empty state misleading → ✅ Distinguishes no accounts vs no calendars via Defaults[.googleAccounts].isEmpty
  3. removeAccount early return → ✅ Always cleans up Defaults/Keychain/selectedCalendarIDs regardless of auth state
  4. Legacy migration → ✅ migrateLegacyAuthStateIfNeeded() runs in init
  5. signIn forcePrompt → ✅ Parameter kept for protocol conformance
  6. Calendar ID parsing → ✅ Uses firstIndex(of: ":") for safe single split
  7. didChange token matching → ✅ Uses ObjectIdentifier for identity comparison
  8. Duplicate accounts → ✅ Upserts by email instead of creating duplicates
  9. Atomic removal → ✅ Clears local state first, then revokes tokens
  10. Prune missing auth states → ✅ pruneAccountsMissingAuthState() runs after restore

All changes are verified by 20 passing tests.

vlordier

This comment was marked as duplicate.

vlordier added 3 commits April 3, 2026 18:51
- Button text now reads 'Remove user@example.com'
- Message text now includes the specific account email
- Replace force-unwraps with descriptive fatalError messages
- Tells users exactly which env var is missing and how to configure it
- Replace force unwrap on redirectURL with guard that validates URL creation
- Show descriptive error message in UI when credentials are missing
- Mock mode bypasses all OAuth requirements entirely
@dosubot dosubot Bot added size:XXL This PR changes 1000+ lines, ignoring generated files. and removed size:XL This PR changes 500-999 lines, ignoring generated files. labels Apr 3, 2026
- Extract status, URL, organizer, attendee, and date parsing into
  private top-level functions
- Replace 3-tuple return with named ParsedDates struct
- GCParser.event complexity reduced from 17 to 5 (threshold: 15)
vlordier added 2 commits April 4, 2026 11:43
- Detect unresolved $(VARIABLE) placeholders in Info.plist
- Fall back to mock mode automatically when credentials are missing
- No manual env var setup needed for local testing
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature or request size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants