From 7ca2600865df67b37dd04923e30db791a069e30a Mon Sep 17 00:00:00 2001 From: vlordier Date: Fri, 3 Apr 2026 17:28:34 +0200 Subject: [PATCH 01/14] feat: add support for multiple Google Calendar accounts 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 --- .../Core/EventStores/GCEventStore.swift | 271 ++++++++++-------- MeetingBar/Core/Managers/EventManager.swift | 3 + MeetingBar/Core/Models/GoogleAccount.swift | 20 ++ MeetingBar/Extensions/DefaultsKeys.swift | 2 + .../UI/Views/Preferences/CalendarsTab.swift | 173 ++++++++++- 5 files changed, 338 insertions(+), 131 deletions(-) create mode 100644 MeetingBar/Core/Models/GoogleAccount.swift diff --git a/MeetingBar/Core/EventStores/GCEventStore.swift b/MeetingBar/Core/EventStores/GCEventStore.swift index ebc3f24d..11d6da37 100644 --- a/MeetingBar/Core/EventStores/GCEventStore.swift +++ b/MeetingBar/Core/EventStores/GCEventStore.swift @@ -8,6 +8,7 @@ import AppAuthCore import AppKit +import Defaults import Foundation let googleClientNumber = Bundle.main.object(forInfoDictionaryKey: "GOOGLE_CLIENT_NUMBER") as! String @@ -22,13 +23,11 @@ enum AuthError: Error { } extension OIDAuthState { - /// token is considered fresh if it expires later than 300 seconds from now var isTokenFresh: Bool { guard let exp = lastTokenResponse?.accessTokenExpirationDate else { return false } return exp > Date().addingTimeInterval(300) } - /// convenience email extraction from ID token var userEmail: String? { guard let idToken = lastTokenResponse?.idToken else { return nil } let parts = idToken.split(separator: ".") @@ -53,23 +52,20 @@ final class GCEventStore: NSObject, private static let kClientID = "\(googleClientNumber).apps.googleusercontent.com" private static let kClientSecret = googleClientSecret private static let kRedirectURI = "com.googleusercontent.apps.\(googleClientNumber):/oauthredirect" - private static let kKeychainName = googleAuthKeychainName // MARK: Stored properties @MainActor var currentAuthorizationFlow: OIDExternalUserAgentSession? - private(set) var userEmail: String? - - private var authState: OIDAuthState? { - didSet { - // ensure delegates always set - authState?.stateChangeDelegate = self - authState?.errorDelegate = self - persistAuthState() - } + @MainActor var pendingAuthAccountId: String? + + private var accounts: [GoogleAccount] { + get { Defaults[.googleAccounts] } + set { Defaults[.googleAccounts] = newValue } } + private var authStates: [String: OIDAuthState] = [:] + private var signInTask: Task? - private var refreshTask: Task? + private var refreshTask: [String: Task] = [:] // Shared URLSession to leverage connection reuse private static let session: URLSession = { @@ -84,40 +80,30 @@ final class GCEventStore: NSObject, 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() } // MARK: Public API - func signIn(forcePrompt: Bool = false) async throws { - // if already authorised, nothing to do - if authState?.isAuthorized == true { return } + func getAccounts() -> [GoogleAccount] { + return accounts + } + + func addAccount() async throws -> GoogleAccount { + let accountId = UUID().uuidString - // discover configuration for Google issuer let config = try await withCheckedThrowingContinuation { cont in OIDAuthorizationService.discoverConfiguration(forIssuer: URL(string: Self.kIssuer)!) { cfg, err in if let cfg { cont.resume(returning: cfg) } else { cont.resume(throwing: err ?? NSError(domain: "GoogleSignIn", code: -1)) } } } - // request scopes we need let scopes = [ "email", "https://www.googleapis.com/auth/calendar.calendarlist.readonly", "https://www.googleapis.com/auth/calendar.events.readonly" ] - // additional parameters to be sure we get refresh_token - var extra = [ - "access_type": "offline" - ] - if forcePrompt { - extra["prompt"] = "consent" - } - let request = OIDAuthorizationRequest( configuration: config, clientId: Self.kClientID, @@ -125,36 +111,38 @@ final class GCEventStore: NSObject, scopes: scopes, redirectURL: URL(string: Self.kRedirectURI)!, responseType: OIDResponseTypeCode, - additionalParameters: extra + additionalParameters: ["access_type": "offline"] ) - try await withCheckedThrowingContinuation { cont in - self.currentAuthorizationFlow = OIDAuthState.authState(byPresenting: request, - externalUserAgent: self) { [weak self] state, error in + let account = try await withCheckedThrowingContinuation { cont in + pendingAuthAccountId = accountId + currentAuthorizationFlow = OIDAuthState.authState(byPresenting: request, + externalUserAgent: self) { [weak self] state, error in guard let self else { return } - if let state { - self.authState = state // didSet handles persistence & delegates - self.userEmail = state.userEmail - sendNotification("Google Account connected", "\(self.userEmail ?? "") is connected") - cont.resume() + self.pendingAuthAccountId = nil + self.currentAuthorizationFlow = nil + + if let state, let email = state.userEmail { + let account = GoogleAccount(id: accountId, email: email) + self.authStates[accountId] = state + state.stateChangeDelegate = self + state.errorDelegate = self + self.persistAuthState(for: account) + self.accounts.append(account) + sendNotification("Google Account connected", "\(email) is connected") + cont.resume(returning: account) } else { cont.resume(throwing: error ?? NSError(domain: "GoogleSignIn", code: 1)) } } } - } - func signOut() async { - guard let state = authState else { return } + return account + } - if let flow = currentAuthorizationFlow { - currentAuthorizationFlow = nil - await MainActor.run { flow.cancel() } - } else { - currentAuthorizationFlow = nil - } + 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 @@ -162,38 +150,61 @@ final class GCEventStore: NSObject, 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)) + } + + func signIn(forcePrompt: Bool = false) async throws { + if !accounts.isEmpty { return } + _ = try await addAccount() + } + + func signOut() async { + for account in accounts { + await removeAccount(account) + } } func refreshSources() async {} func fetchAllCalendars() async throws -> [MBCalendar] { - try await ensureSignedIn() + var allCalendars: [MBCalendar] = [] - let url = URL(string: "https://www.googleapis.com/calendar/v3/users/me/calendarList?maxResults=250&showHidden=true")! - let items = try await fetchJSON(url) + for account in accounts { + guard let state = authStates[account.id], state.isAuthorized else { continue } - return items.compactMap { item -> MBCalendar? in - guard let title = item["summary"] as? String, - let calendarID = item["id"] as? String, - let backgroundColor = item["backgroundColor"] as? String else { return nil } + let url = URL(string: "https://www.googleapis.com/calendar/v3/users/me/calendarList?maxResults=250&showHidden=true")! + let items = try await fetchJSON(url, forAccount: account.id) - return MBCalendar(title: title, - id: calendarID, - source: userEmail, - email: userEmail, - color: hexStringToUIColor(hex: backgroundColor)) + let calendars = items.compactMap { item -> MBCalendar? in + guard let title = item["summary"] as? String, + let calendarID = item["id"] as? String, + let backgroundColor = item["backgroundColor"] as? String else { return nil } + + let prefixedCalendarId = "\(account.id):\(calendarID)" + return MBCalendar(title: title, + id: prefixedCalendarId, + source: account.email, + email: account.email, + color: hexStringToUIColor(hex: backgroundColor)) + } + allCalendars.append(contentsOf: calendars) } + + return allCalendars } private func getCalendarEventsForDateRange(calendar: MBCalendar, + accountId: String, dateFrom: Date, dateTo: Date) async throws -> [MBEvent] { let iso = ISO8601DateFormatter() let timeMin = iso.string(from: dateFrom) let timeMax = iso.string(from: dateTo) - var comps = URLComponents(string: "https://www.googleapis.com/calendar/v3/calendars/\(calendar.id)/events")! + let originalCalendarId = String(calendar.id.dropFirst("\(accountId):".count)) + var comps = URLComponents(string: "https://www.googleapis.com/calendar/v3/calendars/\(originalCalendarId)/events")! comps.queryItems = [ .init(name: "singleEvents", value: "true"), .init(name: "orderBy", value: "startTime"), @@ -202,43 +213,45 @@ final class GCEventStore: NSObject, .init(name: "timeMin", value: timeMin) ] - let items = try await fetchJSON(comps.url!) + let items = try await fetchJSON(comps.url!, forAccount: accountId) return items.compactMap { GCParser.event(from: $0, calendar: calendar) } } func fetchEventsForDateRange(for calendars: [MBCalendar], from: Date, to: Date) async throws -> [MBEvent] { - try await ensureSignedIn() var result: [MBEvent] = [] - for cal in calendars { - let ev = try await getCalendarEventsForDateRange(calendar: cal, dateFrom: from, dateTo: to) - result.append(contentsOf: ev) + + let calendarsByAccount = Dictionary(grouping: calendars) { calendar in + calendar.id.components(separatedBy: ":").first ?? "" + } + + for (accountId, accountCalendars) in calendarsByAccount { + guard let state = authStates[accountId], state.isAuthorized else { continue } + + for cal in accountCalendars { + let ev = try await getCalendarEventsForDateRange(calendar: cal, accountId: accountId, dateFrom: from, dateTo: to) + result.append(contentsOf: ev) + } } + return result } // MARK: - Private helpers - private func ensureSignedIn() async throws { - if authState?.isAuthorized == true, - authState?.lastTokenResponse?.refreshToken != nil { - return - } - - if let running = signInTask { return try await running.value } - let forceConsent = authState?.lastTokenResponse?.refreshToken == nil + private func accountKeychainKey(accountId: String) -> String { + return "\(googleAuthKeychainName)_\(accountId)" + } - let task = Task { - try await signIn(forcePrompt: forceConsent) - } - signInTask = task - defer { signInTask = nil } - try await task.value + private func ensureAccountSignedIn(_ accountId: String) async throws { + guard let state = authStates[accountId] else { throw AuthError.notSignedIn } + if state.isAuthorized, state.lastTokenResponse?.refreshToken != nil { return } + throw AuthError.notSignedIn } - private func validAccessToken(forceRefresh: Bool = false) async throws -> String { - guard let state = authState else { throw AuthError.notSignedIn } + private func validAccessToken(for accountId: String, forceRefresh: Bool = false) async throws -> String { + guard let state = authStates[accountId] else { throw AuthError.notSignedIn } if !forceRefresh, state.isTokenFresh, @@ -246,21 +259,23 @@ final class GCEventStore: NSObject, return token } - if let running = refreshTask { return try await running.value } + if let running = refreshTask[accountId] { return try await running.value } let task = Task { - defer { refreshTask = nil } + defer { refreshTask[accountId] = nil } return try await withCheckedThrowingContinuation { cont in if forceRefresh { state.setNeedsTokenRefresh() } state.performAction { [weak self] accessToken, _, error in guard let self else { return } if let token = accessToken { - cont.resume(returning: token) // stateChangeDelegate persists new tokens + cont.resume(returning: token) } else { if let err = error as NSError?, err.domain == OIDOAuthTokenErrorDomain { - self.clearAuthState() + if let account = self.accounts.first(where: { $0.id == accountId }) { + Task { await self.removeAccount(account) } + } } cont.resume(throwing: error ?? AuthError.refreshFailed) } @@ -268,46 +283,44 @@ final class GCEventStore: NSObject, } } - refreshTask = task + refreshTask[accountId] = task return try await task.value } // MARK: Keychain persistence - private func persistAuthState() { - guard let state = authState else { - Keychain.delete(for: Self.kKeychainName) + private func persistAuthState(for account: GoogleAccount) { + guard let state = authStates[account.id] else { + Keychain.delete(for: accountKeychainKey(accountId: account.id)) return } do { let data = try NSKeyedArchiver.archivedData(withRootObject: state, requiringSecureCoding: true) - Keychain.save(data: data, for: Self.kKeychainName) + Keychain.save(data: data, for: accountKeychainKey(accountId: account.id)) } catch { NSLog("Error archiving OIDAuthState: \(error)") } } - private func restoreAuthState() -> OIDAuthState? { - guard let data = Keychain.load(for: Self.kKeychainName) else { return nil } - do { - guard let state = try NSKeyedUnarchiver.unarchivedObject(ofClass: OIDAuthState.self, from: data) else { return nil } - userEmail = state.userEmail - return state - } catch { - NSLog("Error unarchiving OIDAuthState: \(error)") - return nil + private func restoreAllAuthStates() { + for account in accounts { + let key = accountKeychainKey(accountId: account.id) + guard let data = Keychain.load(for: key) else { continue } + do { + guard let state = try NSKeyedUnarchiver.unarchivedObject(ofClass: OIDAuthState.self, from: data) else { continue } + state.stateChangeDelegate = self + state.errorDelegate = self + authStates[account.id] = state + } catch { + NSLog("Error unarchiving OIDAuthState: \(error)") + } } } - private func clearAuthState() { - authState = nil - userEmail = nil - Keychain.delete(for: Self.kKeychainName) - } - // MARK: Networking helper - private func fetchJSON(_ url: URL, retrying: Bool = false) async throws -> [[String: Any]] { - let token = try await validAccessToken() + + private func fetchJSON(_ url: URL, forAccount accountId: String, retrying: Bool = false) async throws -> [[String: Any]] { + let token = try await validAccessToken(for: accountId) var req = URLRequest(url: url) req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") @@ -318,12 +331,12 @@ final class GCEventStore: NSObject, switch http.statusCode { case 401, 403: if !retrying { - // force-refresh and retry - _ = try await validAccessToken(forceRefresh: true) - return try await fetchJSON(url, retrying: true) + _ = try await validAccessToken(for: accountId, forceRefresh: true) + return try await fetchJSON(url, forAccount: accountId, retrying: true) + } + if let account = accounts.first(where: { $0.id == accountId }) { + Task { await self.removeAccount(account) } } - // refresh token revoked – force re‑login - clearAuthState() throw AuthError.notSignedIn case 200...299: break @@ -349,20 +362,36 @@ final class GCEventStore: NSObject, } // MARK: - OIDAuthState Delegates - func didChange(_ state: OIDAuthState) { - // persist every change (e.g., refreshed token) - persistAuthState() + + 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) + } + } } } // MARK: - OIDExternalUserAgent + func present(_ request: OIDExternalUserAgentRequest, session _: OIDExternalUserAgentSession) -> Bool { if let url = request.externalUserAgentRequestURL(), NSWorkspace.shared.open(url) { return true diff --git a/MeetingBar/Core/Managers/EventManager.swift b/MeetingBar/Core/Managers/EventManager.swift index 614c0ee0..115af61d 100644 --- a/MeetingBar/Core/Managers/EventManager.swift +++ b/MeetingBar/Core/Managers/EventManager.swift @@ -23,6 +23,8 @@ public class EventManager: ObservableObject { @Published public private(set) var calendars: [MBCalendar] = [] @Published public private(set) var events: [MBEvent] = [] + static weak var shared: EventManager? + var provider: EventStore private let refreshInterval: TimeInterval private var cancellables = Set() @@ -43,6 +45,7 @@ public class EventManager: ObservableObject { await configureProvider(Defaults[.eventStoreProvider]) setupPublishers() refreshSubject.send() // initial load + EventManager.shared = self } public func changeEventStoreProvider(_ newProvider: EventStoreProvider, withSignOut: Bool = false) async { diff --git a/MeetingBar/Core/Models/GoogleAccount.swift b/MeetingBar/Core/Models/GoogleAccount.swift new file mode 100644 index 00000000..08c174ec --- /dev/null +++ b/MeetingBar/Core/Models/GoogleAccount.swift @@ -0,0 +1,20 @@ +// +// GoogleAccount.swift +// MeetingBar +// +// Created for multi-account Google Calendar support. +// Copyright © 2026 Andrii Leitsius. All rights reserved. +// + +import Defaults +import Foundation + +public struct GoogleAccount: Identifiable, Codable, Hashable, Sendable, Defaults.Serializable { + public let id: String + public let email: String + + public init(id: String, email: String) { + self.id = id + self.email = email + } +} diff --git a/MeetingBar/Extensions/DefaultsKeys.swift b/MeetingBar/Extensions/DefaultsKeys.swift index f8bb88b9..959167d7 100644 --- a/MeetingBar/Extensions/DefaultsKeys.swift +++ b/MeetingBar/Extensions/DefaultsKeys.swift @@ -19,6 +19,8 @@ extension Defaults.Keys { static let selectedCalendarIDs = Key<[String]>("selectedCalendarIDs", default: []) static let eventStoreProvider = Key("eventStoreProvider", default: .macOSEventKit) + static let googleAccounts = Key<[GoogleAccount]>("googleAccounts", default: []) + static let onboardingCompleted = Key("onboardingCompleted", default: false) static let showEventsForPeriod = Key("showEventsForPeriod", default: .today) diff --git a/MeetingBar/UI/Views/Preferences/CalendarsTab.swift b/MeetingBar/UI/Views/Preferences/CalendarsTab.swift index f77c6b36..ee6e0c08 100644 --- a/MeetingBar/UI/Views/Preferences/CalendarsTab.swift +++ b/MeetingBar/UI/Views/Preferences/CalendarsTab.swift @@ -20,11 +20,23 @@ struct CalendarsTab: View { ProviderPicker(eventManager: eventManager) } .padding(.bottom, 5) + + if Defaults[.eventStoreProvider] == .googleCalendar { + GroupBox(label: Label("Google Accounts", systemImage: "person.3")) { + GoogleAccountsSection(eventManager: eventManager) + } + .padding(.bottom, 5) + } + Label("preferences_calendars_select_calendars_title".loco(), systemImage: "calendar").padding(5) List { if eventManager.calendars.isEmpty { if Defaults[.eventStoreProvider] == .macOSEventKit { AccessDeniedBanner() + } else { + Text("No accounts connected. Add a Google account to get started.") + .foregroundColor(.secondary) + .padding() } Button("Refresh") { Task { try await eventManager.refreshSources() } @@ -39,10 +51,159 @@ struct CalendarsTab: View { } } +struct GoogleAccountsSection: View { + @ObservedObject var eventManager: EventManager + @State private var accounts: [GoogleAccount] = [] + @State private var showingAddAccount = false + @State private var accountToRemove: GoogleAccount? + @State private var showingRemoveConfirmation = false + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if accounts.isEmpty { + Text("No Google accounts connected") + .foregroundColor(.secondary) + .font(.caption) + } + + ForEach(accounts) { account in + HStack { + Image(systemName: "person.circle.fill") + .foregroundColor(.blue) + VStack(alignment: .leading) { + Text(account.email) + .font(.system(size: 13)) + Text("Connected") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + Button(action: { + accountToRemove = account + showingRemoveConfirmation = true + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + } + .buttonStyle(.plain) + .help("Remove \(account.email)") + } + .padding(.vertical, 2) + } + + Divider() + + Button(action: { + showingAddAccount = true + }) { + Label("Add Google Account", systemImage: "plus.circle.fill") + } + .sheet(isPresented: $showingAddAccount) { + AddAccountSheet(onAccountAdded: { + Task { + await refreshAccounts() + try? await eventManager.refreshSources() + } + }) + } + } + .padding(5) + .onAppear { + Task { await refreshAccounts() } + } + .onChange(of: Defaults[.googleAccounts]) { _ in + Task { await refreshAccounts() } + } + .confirmationDialog( + "Remove Account", + isPresented: $showingRemoveConfirmation, + titleVisibility: .visible, + presenting: accountToRemove + ) { account in + Button("Remove \(account.email)", role: .destructive) { + Task { + await GCEventStore.shared.removeAccount(account) + await refreshAccounts() + try? await eventManager.refreshSources() + } + } + Button("Cancel", role: .cancel) {} + } message: { account in + Text("This will remove \(account.email) and all its calendars from MeetingBar. You can add it back later.") + } + } + + private func refreshAccounts() async { + await MainActor.run { + accounts = Defaults[.googleAccounts] + } + } +} + +struct AddAccountSheet: View { + @Environment(\.presentationMode) var presentationMode + @State private var errorMessage: String? + @State private var isAdding = false + var onAccountAdded: () -> Void + + var body: some View { + VStack(spacing: 16) { + Text("Add Google Account") + .font(.headline) + + Text("You'll be redirected to Google to sign in and grant calendar access.") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + if let error = errorMessage { + Text(error) + .foregroundColor(.red) + .font(.caption) + } + + if isAdding { + ProgressView("Waiting for authentication...") + .frame(maxWidth: .infinity, alignment: .center) + } + + HStack(spacing: 12) { + Button("Cancel") { + presentationMode.wrappedValue.dismiss() + } + .keyboardShortcut(.escape, modifiers: []) + + Button(action: { + Task { + isAdding = true + do { + _ = try await GCEventStore.shared.addAccount() + onAccountAdded() + presentationMode.wrappedValue.dismiss() + } catch { + errorMessage = error.localizedDescription + isAdding = false + } + } + }) { + if isAdding { + ProgressView() + } else { + Text("Sign in with Google") + } + } + .buttonStyle(.borderedProminent) + .disabled(isAdding) + } + } + .padding() + .frame(width: 320, height: 180) + } +} + struct CalendarSectionsView: View { let calendars: [MBCalendar] - // 1. Compute once, with explicit types private var grouped: [String: [MBCalendar]] { Dictionary(grouping: calendars, by: \.source) } @@ -75,14 +236,6 @@ struct ProviderPicker: View { .onChange(of: picker) { provider in Task { await eventManager.changeEventStoreProvider(provider) } } - - if Defaults[.eventStoreProvider] == .googleCalendar { - Button("preferences_calendars_provider_gcalendar_change_account".loco()) { - Task { - await eventManager.changeEventStoreProvider(.googleCalendar, withSignOut: true) - } - } - } } } } @@ -126,7 +279,7 @@ struct CalendarRow: View { } else { Defaults[.selectedCalendarIDs].removeAll { $0 == calendar.id } } - Defaults[.selectedCalendarIDs] = Array(Set(Defaults[.selectedCalendarIDs])) // Deduplication + Defaults[.selectedCalendarIDs] = Array(Set(Defaults[.selectedCalendarIDs])) } } } From 6eb345c27a3b881bb2dd153ba6813821e567607f Mon Sep 17 00:00:00 2001 From: vlordier Date: Fri, 3 Apr 2026 17:37:22 +0200 Subject: [PATCH 02/14] test: add tests for multi-account Google Calendar support - GoogleAccount model: creation, hashable, codable, defaults persistence - Multi-account calendar: ID prefixing, uniqueness, grouping by source --- MeetingBarTests/GoogleAccountTests.swift | 95 ++++++++++++++++++++++++ MeetingBarTests/TimelineLogicTests.swift | 2 +- 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 MeetingBarTests/GoogleAccountTests.swift diff --git a/MeetingBarTests/GoogleAccountTests.swift b/MeetingBarTests/GoogleAccountTests.swift new file mode 100644 index 00000000..ffc6cd8f --- /dev/null +++ b/MeetingBarTests/GoogleAccountTests.swift @@ -0,0 +1,95 @@ +// +// GoogleAccountTests.swift +// MeetingBar +// +// Created for multi-account Google Calendar support. +// Copyright © 2026 Andrii Leitsius. All rights reserved. +// + +@testable import MeetingBar +import Defaults +import XCTest + +@MainActor +final class GoogleAccountTests: BaseTestCase { + + func test_googleAccountCreation() { + let account = GoogleAccount(id: "test-id", email: "test@example.com") + + XCTAssertEqual(account.id, "test-id") + XCTAssertEqual(account.email, "test@example.com") + } + + func test_googleAccountHashable() { + let account1 = GoogleAccount(id: "id-1", email: "a@example.com") + let account2 = GoogleAccount(id: "id-1", email: "a@example.com") + let account3 = GoogleAccount(id: "id-2", email: "b@example.com") + + XCTAssertEqual(account1, account2) + XCTAssertEqual(account1.hashValue, account2.hashValue) + XCTAssertNotEqual(account1, account3) + } + + func test_googleAccountCodable() throws { + let account = GoogleAccount(id: "enc-id", email: "encoded@example.com") + let data = try JSONEncoder().encode(account) + let decoded = try JSONDecoder().decode(GoogleAccount.self, from: data) + + XCTAssertEqual(decoded.id, "enc-id") + XCTAssertEqual(decoded.email, "encoded@example.com") + } + + func test_googleAccountPersistedInDefaults() { + let accounts = [ + GoogleAccount(id: "personal", email: "personal@example.com"), + GoogleAccount(id: "work", email: "work@example.com") + ] + + Defaults[.googleAccounts] = accounts + + let loaded = Defaults[.googleAccounts] + XCTAssertEqual(loaded.count, 2) + XCTAssertEqual(loaded[0].email, "personal@example.com") + XCTAssertEqual(loaded[1].email, "work@example.com") + } +} + +@MainActor +final class MultiAccountCalendarTests: BaseTestCase { + + func test_calendarIdPrefixing() { + let accountId = "acc-123" + let originalCalendarId = "primary" + let prefixedId = "\(accountId):\(originalCalendarId)" + + XCTAssertEqual(prefixedId, "acc-123:primary") + + let extractedPrefix = prefixedId.components(separatedBy: ":").first ?? "" + XCTAssertEqual(extractedPrefix, accountId) + + let extractedOriginal = String(prefixedId.dropFirst("\(accountId):".count)) + XCTAssertEqual(extractedOriginal, originalCalendarId) + } + + func test_multiAccountCalendarGrouping() { + let cal1 = MBCalendar(title: "Personal", id: "acc1:primary", source: "personal@example.com", email: "personal@example.com", color: .blue) + let cal2 = MBCalendar(title: "Work", id: "acc2:primary", source: "work@example.com", email: "work@example.com", color: .red) + let cal3 = MBCalendar(title: "Birthdays", id: "acc1:birthday", source: "personal@example.com", email: "personal@example.com", color: .green) + + let calendars = [cal1, cal2, cal3] + let grouped = Dictionary(grouping: calendars, by: \.source) + + XCTAssertEqual(grouped["personal@example.com"]?.count, 2) + XCTAssertEqual(grouped["work@example.com"]?.count, 1) + } + + func test_calendarIdsAreUniqueAcrossAccounts() { + let personalCalId = "acc1:primary" + let workCalId = "acc2:primary" + + XCTAssertNotEqual(personalCalId, workCalId) + + let allIds = [personalCalId, workCalId] + XCTAssertEqual(allIds.count, Set(allIds).count) + } +} diff --git a/MeetingBarTests/TimelineLogicTests.swift b/MeetingBarTests/TimelineLogicTests.swift index 7adcaaa2..f45f24e0 100644 --- a/MeetingBarTests/TimelineLogicTests.swift +++ b/MeetingBarTests/TimelineLogicTests.swift @@ -8,7 +8,7 @@ import XCTest @testable import MeetingBar -import SwiftUICore +import SwiftUI final class TimelineLogicTests: XCTestCase { From df75974be1788259265f45a24d431289d89a82c9 Mon Sep 17 00:00:00 2001 From: vlordier Date: Fri, 3 Apr 2026 17:44:55 +0200 Subject: [PATCH 03/14] fix: address Copilot review feedback - 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 --- .../Core/EventStores/GCEventStore.swift | 137 ++++++++++++++---- MeetingBar/Core/Managers/EventManager.swift | 3 - .../UI/Views/Preferences/CalendarsTab.swift | 1 + 3 files changed, 109 insertions(+), 32 deletions(-) diff --git a/MeetingBar/Core/EventStores/GCEventStore.swift b/MeetingBar/Core/EventStores/GCEventStore.swift index 11d6da37..71f66342 100644 --- a/MeetingBar/Core/EventStores/GCEventStore.swift +++ b/MeetingBar/Core/EventStores/GCEventStore.swift @@ -20,6 +20,7 @@ extension OIDServiceConfiguration: @unchecked @retroactive Sendable {} enum AuthError: Error { case notSignedIn case refreshFailed + case invalidResponse } extension OIDAuthState { @@ -52,6 +53,7 @@ final class GCEventStore: NSObject, private static let kClientID = "\(googleClientNumber).apps.googleusercontent.com" private static let kClientSecret = googleClientSecret private static let kRedirectURI = "com.googleusercontent.apps.\(googleClientNumber):/oauthredirect" + private static let legacyKeychainName = googleAuthKeychainName // MARK: Stored properties @MainActor var currentAuthorizationFlow: OIDExternalUserAgentSession? @@ -63,8 +65,6 @@ final class GCEventStore: NSObject, } private var authStates: [String: OIDAuthState] = [:] - - private var signInTask: Task? private var refreshTask: [String: Task] = [:] // Shared URLSession to leverage connection reuse @@ -80,6 +80,7 @@ final class GCEventStore: NSObject, static let shared = GCEventStore() private override init() { super.init() + migrateLegacyAuthStateIfNeeded() restoreAllAuthStates() } @@ -141,23 +142,26 @@ final class GCEventStore: NSObject, } func removeAccount(_ account: GoogleAccount) async { - guard let state = authStates[account.id] else { return } - - 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) } } + 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) } - 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) } } func signIn(forcePrompt: Bool = false) async throws { if !accounts.isEmpty { return } - _ = try await addAccount() + _ = try await addAccountWithForcePrompt(forcePrompt) } func signOut() async { @@ -244,10 +248,58 @@ final class GCEventStore: NSObject, return "\(googleAuthKeychainName)_\(accountId)" } - private func ensureAccountSignedIn(_ accountId: String) async throws { - guard let state = authStates[accountId] else { throw AuthError.notSignedIn } - if state.isAuthorized, state.lastTokenResponse?.refreshToken != nil { return } - throw AuthError.notSignedIn + private func addAccountWithForcePrompt(_ forcePrompt: Bool) async throws -> GoogleAccount { + let accountId = UUID().uuidString + + let config = try await withCheckedThrowingContinuation { cont in + OIDAuthorizationService.discoverConfiguration(forIssuer: URL(string: Self.kIssuer)!) { cfg, err in + if let cfg { cont.resume(returning: cfg) } else { cont.resume(throwing: err ?? NSError(domain: "GoogleSignIn", code: -1)) } + } + } + + let scopes = [ + "email", + "https://www.googleapis.com/auth/calendar.calendarlist.readonly", + "https://www.googleapis.com/auth/calendar.events.readonly" + ] + + var extraParams: [String: String] = ["access_type": "offline"] + if forcePrompt { + extraParams["prompt"] = "consent" + } + + let request = OIDAuthorizationRequest( + configuration: config, + clientId: Self.kClientID, + clientSecret: Self.kClientSecret, + scopes: scopes, + redirectURL: URL(string: Self.kRedirectURI)!, + responseType: OIDResponseTypeCode, + additionalParameters: extraParams + ) + + return try await withCheckedThrowingContinuation { cont in + pendingAuthAccountId = accountId + currentAuthorizationFlow = OIDAuthState.authState(byPresenting: request, + externalUserAgent: self) { [weak self] state, error in + guard let self else { return } + self.pendingAuthAccountId = nil + self.currentAuthorizationFlow = nil + + if let state, let email = state.userEmail { + let account = GoogleAccount(id: accountId, email: email) + self.authStates[accountId] = state + state.stateChangeDelegate = self + state.errorDelegate = self + self.persistAuthState(for: account) + self.accounts.append(account) + sendNotification("Google Account connected", "\(email) is connected") + cont.resume(returning: account) + } else { + cont.resume(throwing: error ?? NSError(domain: "GoogleSignIn", code: 1)) + } + } + } } private func validAccessToken(for accountId: String, forceRefresh: Bool = false) async throws -> String { @@ -287,6 +339,29 @@ final class GCEventStore: NSObject, return try await task.value } + // MARK: Legacy migration + + private func migrateLegacyAuthStateIfNeeded() { + guard accounts.isEmpty, + let data = Keychain.load(for: Self.legacyKeychainName), + let state = try? NSKeyedUnarchiver.unarchivedObject(ofClass: OIDAuthState.self, from: data), + let email = state.userEmail + else { return } + + let accountId = UUID().uuidString + let account = GoogleAccount(id: accountId, email: email) + let newKey = accountKeychainKey(accountId: accountId) + + do { + let newData = try NSKeyedArchiver.archivedData(withRootObject: state, requiringSecureCoding: true) + Keychain.save(data: newData, for: newKey) + Keychain.delete(for: Self.legacyKeychainName) + accounts = [account] + } catch { + NSLog("Failed to migrate legacy auth state: \(error)") + } + } + // MARK: Keychain persistence private func persistAuthState(for account: GoogleAccount) { @@ -345,8 +420,12 @@ final class GCEventStore: NSObject, } } - let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:] - return root["items"] as! [[String: Any]] + guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = root["items"] as? [[String: Any]] + else { + throw AuthError.invalidResponse + } + return items } private func revoke(token: String) async throws { @@ -364,13 +443,13 @@ final class GCEventStore: NSObject, // MARK: - OIDAuthState Delegates nonisolated func didChange(_ state: OIDAuthState) { - let accessToken = state.lastTokenResponse?.accessToken + let stateIdentity = ObjectIdentifier(state) 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) + for (accountId, storedState) in self.authStates where ObjectIdentifier(storedState) == stateIdentity { + if let account = self.accounts.first(where: { $0.id == accountId }) { + self.persistAuthState(for: account) + } + break } } } @@ -378,13 +457,13 @@ final class GCEventStore: NSObject, nonisolated func authState(_ state: OIDAuthState, didEncounterAuthorizationError error: Error) { let nsErr = error as NSError if nsErr.domain == OIDOAuthTokenErrorDomain { - let accessToken = state.lastTokenResponse?.accessToken + let stateIdentity = ObjectIdentifier(state) 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) + for (accountId, storedState) in self.authStates where ObjectIdentifier(storedState) == stateIdentity { + if let account = self.accounts.first(where: { $0.id == accountId }) { + await self.removeAccount(account) + } + break } } } diff --git a/MeetingBar/Core/Managers/EventManager.swift b/MeetingBar/Core/Managers/EventManager.swift index 115af61d..614c0ee0 100644 --- a/MeetingBar/Core/Managers/EventManager.swift +++ b/MeetingBar/Core/Managers/EventManager.swift @@ -23,8 +23,6 @@ public class EventManager: ObservableObject { @Published public private(set) var calendars: [MBCalendar] = [] @Published public private(set) var events: [MBEvent] = [] - static weak var shared: EventManager? - var provider: EventStore private let refreshInterval: TimeInterval private var cancellables = Set() @@ -45,7 +43,6 @@ public class EventManager: ObservableObject { await configureProvider(Defaults[.eventStoreProvider]) setupPublishers() refreshSubject.send() // initial load - EventManager.shared = self } public func changeEventStoreProvider(_ newProvider: EventStoreProvider, withSignOut: Bool = false) async { diff --git a/MeetingBar/UI/Views/Preferences/CalendarsTab.swift b/MeetingBar/UI/Views/Preferences/CalendarsTab.swift index ee6e0c08..324532d7 100644 --- a/MeetingBar/UI/Views/Preferences/CalendarsTab.swift +++ b/MeetingBar/UI/Views/Preferences/CalendarsTab.swift @@ -87,6 +87,7 @@ struct GoogleAccountsSection: View { } .buttonStyle(.plain) .help("Remove \(account.email)") + .accessibilityLabel("Remove Google account \(account.email)") } .padding(.vertical, 2) } From 59b9cc42dd5281365638b25bc6be9c84f1bec651 Mon Sep 17 00:00:00 2001 From: vlordier Date: Fri, 3 Apr 2026 17:59:29 +0200 Subject: [PATCH 04/14] fix: address second round of Copilot review feedback - 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 --- .../Core/EventStores/GCEventStore.swift | 72 +++---------------- .../en.lproj/Localizable.strings | 20 ++++++ .../UI/Views/Preferences/CalendarsTab.swift | 72 ++++++++----------- 3 files changed, 61 insertions(+), 103 deletions(-) diff --git a/MeetingBar/Core/EventStores/GCEventStore.swift b/MeetingBar/Core/EventStores/GCEventStore.swift index 71f66342..c74e3f89 100644 --- a/MeetingBar/Core/EventStores/GCEventStore.swift +++ b/MeetingBar/Core/EventStores/GCEventStore.swift @@ -115,7 +115,7 @@ final class GCEventStore: NSObject, additionalParameters: ["access_type": "offline"] ) - let account = try await withCheckedThrowingContinuation { cont in + return try await withCheckedThrowingContinuation { cont in pendingAuthAccountId = accountId currentAuthorizationFlow = OIDAuthState.authState(byPresenting: request, externalUserAgent: self) { [weak self] state, error in @@ -137,8 +137,6 @@ final class GCEventStore: NSObject, } } } - - return account } func removeAccount(_ account: GoogleAccount) async { @@ -159,9 +157,9 @@ final class GCEventStore: NSObject, Defaults[.selectedCalendarIDs] = Defaults[.selectedCalendarIDs].filter { !$0.hasPrefix(prefix) } } - func signIn(forcePrompt: Bool = false) async throws { + func signIn(forcePrompt _: Bool) async throws { if !accounts.isEmpty { return } - _ = try await addAccountWithForcePrompt(forcePrompt) + _ = try await addAccount() } func signOut() async { @@ -207,7 +205,10 @@ final class GCEventStore: NSObject, let timeMin = iso.string(from: dateFrom) let timeMax = iso.string(from: dateTo) - let originalCalendarId = String(calendar.id.dropFirst("\(accountId):".count)) + let prefix = "\(accountId):" + guard calendar.id.hasPrefix(prefix) else { return [] } + let originalCalendarId = String(calendar.id.dropFirst(prefix.count)) + var comps = URLComponents(string: "https://www.googleapis.com/calendar/v3/calendars/\(originalCalendarId)/events")! comps.queryItems = [ .init(name: "singleEvents", value: "true"), @@ -227,7 +228,10 @@ final class GCEventStore: NSObject, var result: [MBEvent] = [] let calendarsByAccount = Dictionary(grouping: calendars) { calendar in - calendar.id.components(separatedBy: ":").first ?? "" + if let idx = calendar.id.firstIndex(of: ":") { + return String(calendar.id[.. GoogleAccount { - let accountId = UUID().uuidString - - let config = try await withCheckedThrowingContinuation { cont in - OIDAuthorizationService.discoverConfiguration(forIssuer: URL(string: Self.kIssuer)!) { cfg, err in - if let cfg { cont.resume(returning: cfg) } else { cont.resume(throwing: err ?? NSError(domain: "GoogleSignIn", code: -1)) } - } - } - - let scopes = [ - "email", - "https://www.googleapis.com/auth/calendar.calendarlist.readonly", - "https://www.googleapis.com/auth/calendar.events.readonly" - ] - - var extraParams: [String: String] = ["access_type": "offline"] - if forcePrompt { - extraParams["prompt"] = "consent" - } - - let request = OIDAuthorizationRequest( - configuration: config, - clientId: Self.kClientID, - clientSecret: Self.kClientSecret, - scopes: scopes, - redirectURL: URL(string: Self.kRedirectURI)!, - responseType: OIDResponseTypeCode, - additionalParameters: extraParams - ) - - return try await withCheckedThrowingContinuation { cont in - pendingAuthAccountId = accountId - currentAuthorizationFlow = OIDAuthState.authState(byPresenting: request, - externalUserAgent: self) { [weak self] state, error in - guard let self else { return } - self.pendingAuthAccountId = nil - self.currentAuthorizationFlow = nil - - if let state, let email = state.userEmail { - let account = GoogleAccount(id: accountId, email: email) - self.authStates[accountId] = state - state.stateChangeDelegate = self - state.errorDelegate = self - self.persistAuthState(for: account) - self.accounts.append(account) - sendNotification("Google Account connected", "\(email) is connected") - cont.resume(returning: account) - } else { - cont.resume(throwing: error ?? NSError(domain: "GoogleSignIn", code: 1)) - } - } - } - } - private func validAccessToken(for accountId: String, forceRefresh: Bool = false) async throws -> String { guard let state = authStates[accountId] else { throw AuthError.notSignedIn } diff --git a/MeetingBar/Resources /Localization /en.lproj/Localizable.strings b/MeetingBar/Resources /Localization /en.lproj/Localizable.strings index 5c95734f..416e78cb 100644 --- a/MeetingBar/Resources /Localization /en.lproj/Localizable.strings +++ b/MeetingBar/Resources /Localization /en.lproj/Localizable.strings @@ -336,3 +336,23 @@ "link_url_cant_open_title" = "Oops! Unable to open the link in %@"; "link_url_cant_open_message" = "Ensure you have %@ installed, or open such links in a web browser from the preferences instead."; + +// MARK: - Multi-account Google Calendar + +"preferences_calendars_google_accounts_title" = "Google Accounts"; +"preferences_calendars_no_accounts_connected" = "No accounts connected. Add a Google account to get started."; +"preferences_calendars_no_calendars_available" = "No calendars available. Try refreshing or check account access."; +"preferences_calendars_refresh" = "Refresh"; +"preferences_calendars_no_google_accounts_connected" = "No Google accounts connected"; +"preferences_calendars_account_connected" = "Connected"; +"preferences_calendars_remove_account_help" = "Remove %@"; +"preferences_calendars_remove_account_label" = "Remove Google account %@"; +"preferences_calendars_add_google_account" = "Add Google Account"; +"preferences_calendars_remove_account_dialog_title" = "Remove Account"; +"preferences_calendars_remove_account_action" = "Remove %@"; +"preferences_calendars_cancel" = "Cancel"; +"preferences_calendars_remove_account_message" = "This will remove %@ and all its calendars from MeetingBar. You can add it back later."; +"preferences_calendars_add_google_account_sheet_title" = "Add Google Account"; +"preferences_calendars_add_google_account_sheet_description" = "You'll be redirected to Google to sign in and grant calendar access."; +"preferences_calendars_waiting_for_authentication" = "Waiting for authentication..."; +"preferences_calendars_sign_in_with_google" = "Sign in with Google"; diff --git a/MeetingBar/UI/Views/Preferences/CalendarsTab.swift b/MeetingBar/UI/Views/Preferences/CalendarsTab.swift index 324532d7..f40edf1e 100644 --- a/MeetingBar/UI/Views/Preferences/CalendarsTab.swift +++ b/MeetingBar/UI/Views/Preferences/CalendarsTab.swift @@ -8,7 +8,6 @@ import EventKit import SwiftUI - import Defaults struct CalendarsTab: View { @@ -22,23 +21,27 @@ struct CalendarsTab: View { .padding(.bottom, 5) if Defaults[.eventStoreProvider] == .googleCalendar { - GroupBox(label: Label("Google Accounts", systemImage: "person.3")) { + GroupBox(label: Label("preferences_calendars_google_accounts_title", systemImage: "person.3")) { GoogleAccountsSection(eventManager: eventManager) } .padding(.bottom, 5) } - Label("preferences_calendars_select_calendars_title".loco(), systemImage: "calendar").padding(5) + Label("preferences_calendars_select_calendars_title", systemImage: "calendar").padding(5) List { if eventManager.calendars.isEmpty { if Defaults[.eventStoreProvider] == .macOSEventKit { AccessDeniedBanner() + } else if Defaults[.googleAccounts].isEmpty { + Text("preferences_calendars_no_accounts_connected") + .foregroundColor(.secondary) + .padding() } else { - Text("No accounts connected. Add a Google account to get started.") + Text("preferences_calendars_no_calendars_available") .foregroundColor(.secondary) .padding() } - Button("Refresh") { + Button("preferences_calendars_refresh") { Task { try await eventManager.refreshSources() } } @@ -53,7 +56,7 @@ struct CalendarsTab: View { struct GoogleAccountsSection: View { @ObservedObject var eventManager: EventManager - @State private var accounts: [GoogleAccount] = [] + @Default(.googleAccounts) private var accounts @State private var showingAddAccount = false @State private var accountToRemove: GoogleAccount? @State private var showingRemoveConfirmation = false @@ -61,7 +64,7 @@ struct GoogleAccountsSection: View { var body: some View { VStack(alignment: .leading, spacing: 8) { if accounts.isEmpty { - Text("No Google accounts connected") + Text("preferences_calendars_no_google_accounts_connected") .foregroundColor(.secondary) .font(.caption) } @@ -73,7 +76,7 @@ struct GoogleAccountsSection: View { VStack(alignment: .leading) { Text(account.email) .font(.system(size: 13)) - Text("Connected") + Text("preferences_calendars_account_connected") .font(.caption) .foregroundColor(.secondary) } @@ -86,8 +89,8 @@ struct GoogleAccountsSection: View { .foregroundColor(.red) } .buttonStyle(.plain) - .help("Remove \(account.email)") - .accessibilityLabel("Remove Google account \(account.email)") + .help("preferences_calendars_remove_account_help") + .accessibilityLabel("preferences_calendars_remove_account_label") } .padding(.vertical, 2) } @@ -97,46 +100,32 @@ struct GoogleAccountsSection: View { Button(action: { showingAddAccount = true }) { - Label("Add Google Account", systemImage: "plus.circle.fill") + Label("preferences_calendars_add_google_account", systemImage: "plus.circle.fill") } .sheet(isPresented: $showingAddAccount) { AddAccountSheet(onAccountAdded: { Task { - await refreshAccounts() try? await eventManager.refreshSources() } }) } } .padding(5) - .onAppear { - Task { await refreshAccounts() } - } - .onChange(of: Defaults[.googleAccounts]) { _ in - Task { await refreshAccounts() } - } .confirmationDialog( - "Remove Account", + "preferences_calendars_remove_account_dialog_title", isPresented: $showingRemoveConfirmation, titleVisibility: .visible, presenting: accountToRemove ) { account in - Button("Remove \(account.email)", role: .destructive) { + Button("preferences_calendars_remove_account_action", role: .destructive) { Task { await GCEventStore.shared.removeAccount(account) - await refreshAccounts() try? await eventManager.refreshSources() } } - Button("Cancel", role: .cancel) {} - } message: { account in - Text("This will remove \(account.email) and all its calendars from MeetingBar. You can add it back later.") - } - } - - private func refreshAccounts() async { - await MainActor.run { - accounts = Defaults[.googleAccounts] + Button("preferences_calendars_cancel", role: .cancel) {} + } message: { _ in + Text("preferences_calendars_remove_account_message") } } } @@ -145,14 +134,14 @@ struct AddAccountSheet: View { @Environment(\.presentationMode) var presentationMode @State private var errorMessage: String? @State private var isAdding = false - var onAccountAdded: () -> Void + var onAccountAdded: () async -> Void var body: some View { VStack(spacing: 16) { - Text("Add Google Account") + Text("preferences_calendars_add_google_account_sheet_title") .font(.headline) - Text("You'll be redirected to Google to sign in and grant calendar access.") + Text("preferences_calendars_add_google_account_sheet_description") .font(.caption) .foregroundColor(.secondary) .multilineTextAlignment(.center) @@ -164,12 +153,12 @@ struct AddAccountSheet: View { } if isAdding { - ProgressView("Waiting for authentication...") + ProgressView("preferences_calendars_waiting_for_authentication") .frame(maxWidth: .infinity, alignment: .center) } HStack(spacing: 12) { - Button("Cancel") { + Button("preferences_calendars_cancel") { presentationMode.wrappedValue.dismiss() } .keyboardShortcut(.escape, modifiers: []) @@ -179,7 +168,7 @@ struct AddAccountSheet: View { isAdding = true do { _ = try await GCEventStore.shared.addAccount() - onAccountAdded() + await onAccountAdded() presentationMode.wrappedValue.dismiss() } catch { errorMessage = error.localizedDescription @@ -190,7 +179,7 @@ struct AddAccountSheet: View { if isAdding { ProgressView() } else { - Text("Sign in with Google") + Text("preferences_calendars_sign_in_with_google") } } .buttonStyle(.borderedProminent) @@ -231,7 +220,7 @@ struct ProviderPicker: View { var body: some View { HStack { Picker("", selection: $picker) { - Text("access_screen_provider_macos_title".loco()).tag(EventStoreProvider.macOSEventKit) + Text("access_screen_provider_macos_title").tag(EventStoreProvider.macOSEventKit) Text("Google Calendar API").tag(EventStoreProvider.googleCalendar) } .onChange(of: picker) { provider in @@ -244,11 +233,11 @@ struct ProviderPicker: View { struct AccessDeniedBanner: View { var body: some View { VStack(alignment: .leading, spacing: 4) { - Text("access_screen_access_screen_access_denied_go_to_title".loco()) - Button("access_screen_access_denied_system_preferences_button".loco()) { + Text("access_screen_access_screen_access_denied_go_to_title") + Button("access_screen_access_denied_system_preferences_button") { NSWorkspace.shared.open(Links.calendarPreferences) } - Text("access_screen_access_denied_relaunch_title".loco()) + Text("access_screen_access_denied_relaunch_title") } .padding(.top, 8) } @@ -288,7 +277,6 @@ struct CalendarRow: View { #Preview { List { CalendarSectionsView(calendars: [MBCalendar(title: "Calendar #1", id: "1", source: "Source #1", email: nil, color: .brown)]) - CalendarSectionsView(calendars: [MBCalendar(title: "Calendar #2", id: "2", source: "Source #2", email: nil, color: .blue)]) }.listStyle(.sidebar) .frame(width: 300, height: 200) From 4332a5a89d160d29686974acb332a4a7fb22fc23 Mon Sep 17 00:00:00 2001 From: vlordier Date: Fri, 3 Apr 2026 18:16:18 +0200 Subject: [PATCH 05/14] test: expand coverage for GCParser, account removal, and calendar ID 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 --- MeetingBarTests/GoogleAccountTests.swift | 254 +++++++++++++++++++++++ 1 file changed, 254 insertions(+) diff --git a/MeetingBarTests/GoogleAccountTests.swift b/MeetingBarTests/GoogleAccountTests.swift index ffc6cd8f..800bce41 100644 --- a/MeetingBarTests/GoogleAccountTests.swift +++ b/MeetingBarTests/GoogleAccountTests.swift @@ -52,6 +52,10 @@ final class GoogleAccountTests: BaseTestCase { XCTAssertEqual(loaded[0].email, "personal@example.com") XCTAssertEqual(loaded[1].email, "work@example.com") } + + func test_googleAccountDefaultsEmptyByDefault() { + XCTAssertTrue(Defaults[.googleAccounts].isEmpty) + } } @MainActor @@ -92,4 +96,254 @@ final class MultiAccountCalendarTests: BaseTestCase { let allIds = [personalCalId, workCalId] XCTAssertEqual(allIds.count, Set(allIds).count) } + + func test_selectedCalendarIDsCleanedOnAccountRemoval() { + let accountId = "acc-to-remove" + let keptAccountId = "acc-kept" + + Defaults[.selectedCalendarIDs] = [ + "\(accountId):primary", + "\(accountId):meetings", + "\(keptAccountId):primary", + "unprefixed-calendar" + ] + + let account = GoogleAccount(id: accountId, email: "remove@example.com") + let prefix = "\(account.id):" + Defaults[.selectedCalendarIDs] = Defaults[.selectedCalendarIDs].filter { !$0.hasPrefix(prefix) } + + XCTAssertEqual(Defaults[.selectedCalendarIDs].count, 2) + XCTAssertTrue(Defaults[.selectedCalendarIDs].contains("\(keptAccountId):primary")) + XCTAssertTrue(Defaults[.selectedCalendarIDs].contains("unprefixed-calendar")) + XCTAssertFalse(Defaults[.selectedCalendarIDs].contains("\(accountId):primary")) + XCTAssertFalse(Defaults[.selectedCalendarIDs].contains("\(accountId):meetings")) + } + + func test_calendarIdExtractionWithFirstColon() { + let id1 = "acc1:primary" + let id2 = "acc2:calendar:with:colons" + let id3 = "no-colon" + + if let idx = id1.firstIndex(of: ":") { + XCTAssertEqual(String(id1[.. Date: Fri, 3 Apr 2026 18:29:13 +0200 Subject: [PATCH 06/14] fix: eliminate force-unwraps in GCParser and improve code quality - 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 --- .../Core/EventStores/GCEventStore.swift | 45 ++++++++++++------- .../UI/Views/Preferences/CalendarsTab.swift | 3 +- MeetingBar/Utilities/Helpers.swift | 9 ++++ 3 files changed, 39 insertions(+), 18 deletions(-) diff --git a/MeetingBar/Core/EventStores/GCEventStore.swift b/MeetingBar/Core/EventStores/GCEventStore.swift index c74e3f89..00321202 100644 --- a/MeetingBar/Core/EventStores/GCEventStore.swift +++ b/MeetingBar/Core/EventStores/GCEventStore.swift @@ -434,7 +434,10 @@ final class GCEventStore: NSObject, enum GCParser { static func event(from item: [String: Any], calendar: MBCalendar) -> MBEvent? { - let eventID = item["id"] as! String + guard let eventID = item["id"] as? String else { + NSLog("GCParser: missing event id") + return nil + } let formatter = ISO8601DateFormatter() let lastModifiedDate = formatter.date(from: item["updated"] as? String ?? "") @@ -491,27 +494,37 @@ final class GCEventStore: NSObject, attendees.append(attendee) } - var startDate: Date - var endDate: Date - var isAllDay: Bool + guard let itemStart = item["start"] as? [String: String], + let itemEnd = item["end"] as? [String: String] + else { + NSLog("GCParser: missing start/end for event \(eventID)") + return nil + } - let itemStart = item["start"] as! [String: String] - let itemEnd = item["end"] as! [String: String] + let startDate: Date + let endDate: Date + let isAllDay: Bool - if let startDateTime = itemStart["dateTime"], let endDateTime = itemEnd["dateTime"] { - let formatter = ISO8601DateFormatter() - startDate = formatter.date(from: startDateTime)! - endDate = formatter.date(from: endDateTime)! + if let startDateTime = itemStart["dateTime"], + let endDateTime = itemEnd["dateTime"], + let parsedStart = ISO8601DateFormatter().date(from: startDateTime), + let parsedEnd = ISO8601DateFormatter().date(from: endDateTime) { + startDate = parsedStart + endDate = parsedEnd isAllDay = false - } else { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - startDate = formatter.date(from: itemStart["date"]!)! - endDate = formatter.date(from: itemEnd["date"]!)! + } else if let startDateStr = itemStart["date"], + let endDateStr = itemEnd["date"], + let parsedStart = DateFormatter.yyyyMMdd.date(from: startDateStr), + let parsedEnd = DateFormatter.yyyyMMdd.date(from: endDateStr) { + startDate = parsedStart + endDate = parsedEnd isAllDay = true + } else { + NSLog("GCParser: invalid date format for event \(eventID)") + return nil } - let recurrent = (item["recurringEventId"] != nil) ? true : false + let recurrent = item["recurringEventId"] != nil return MBEvent( id: eventID, diff --git a/MeetingBar/UI/Views/Preferences/CalendarsTab.swift b/MeetingBar/UI/Views/Preferences/CalendarsTab.swift index f40edf1e..0f199bf5 100644 --- a/MeetingBar/UI/Views/Preferences/CalendarsTab.swift +++ b/MeetingBar/UI/Views/Preferences/CalendarsTab.swift @@ -254,8 +254,7 @@ struct CalendarRow: View { var body: some View { Toggle(isOn: $isSelected) { - HStack { - Text("") + HStack(spacing: 6) { Circle().fill(Color(calendar.color)).frame(width: 10, height: 10) Text(calendar.title) } diff --git a/MeetingBar/Utilities/Helpers.swift b/MeetingBar/Utilities/Helpers.swift index ff1a3b2b..d9679088 100644 --- a/MeetingBar/Utilities/Helpers.swift +++ b/MeetingBar/Utilities/Helpers.swift @@ -268,3 +268,12 @@ extension NSImage { return copy } } + +extension DateFormatter { + static let yyyyMMdd: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + }() +} From 4ac42895a0161f96dddc9fc761937adce5ed6e3c Mon Sep 17 00:00:00 2001 From: vlordier Date: Fri, 3 Apr 2026 18:32:01 +0200 Subject: [PATCH 07/14] fix: localize notification strings and log revoke errors - 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 --- MeetingBar/Core/EventStores/GCEventStore.swift | 16 ++++++++++++---- .../Localization /en.lproj/Localizable.strings | 5 +++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/MeetingBar/Core/EventStores/GCEventStore.swift b/MeetingBar/Core/EventStores/GCEventStore.swift index 00321202..495f1a7a 100644 --- a/MeetingBar/Core/EventStores/GCEventStore.swift +++ b/MeetingBar/Core/EventStores/GCEventStore.swift @@ -130,7 +130,7 @@ final class GCEventStore: NSObject, state.errorDelegate = self self.persistAuthState(for: account) self.accounts.append(account) - sendNotification("Google Account connected", "\(email) is connected") + sendNotification("notifications_google_account_connected_title".loco(), "notifications_google_account_connected_body".loco(email)) cont.resume(returning: account) } else { cont.resume(throwing: error ?? NSError(domain: "GoogleSignIn", code: 1)) @@ -141,11 +141,19 @@ final class GCEventStore: NSObject, func removeAccount(_ account: GoogleAccount) async { if let state = authStates[account.id] { - let access = state.lastTokenResponse?.accessToken + 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) } } + if let acc = access { + grp.addTask { + do { try await self.revoke(token: acc) } catch { NSLog("GCEventStore: failed to revoke access token: \(error)") } + } + } + if let ref = refresh { + grp.addTask { + do { try await self.revoke(token: ref) } catch { NSLog("GCEventStore: failed to revoke refresh token: \(error)") } + } + } } authStates.removeValue(forKey: account.id) } diff --git a/MeetingBar/Resources /Localization /en.lproj/Localizable.strings b/MeetingBar/Resources /Localization /en.lproj/Localizable.strings index 416e78cb..c7ddee82 100644 --- a/MeetingBar/Resources /Localization /en.lproj/Localizable.strings +++ b/MeetingBar/Resources /Localization /en.lproj/Localizable.strings @@ -356,3 +356,8 @@ "preferences_calendars_add_google_account_sheet_description" = "You'll be redirected to Google to sign in and grant calendar access."; "preferences_calendars_waiting_for_authentication" = "Waiting for authentication..."; "preferences_calendars_sign_in_with_google" = "Sign in with Google"; + +// MARK: - Google Account Notifications + +"notifications_google_account_connected_title" = "Google Account connected"; +"notifications_google_account_connected_body" = "%@ is connected"; From 05f3e9a77d0082fdead5e32ce6065e50c3395af0 Mon Sep 17 00:00:00 2001 From: vlordier Date: Fri, 3 Apr 2026 18:43:24 +0200 Subject: [PATCH 08/14] fix: address all remaining review comments - 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 --- .../Core/EventStores/GCEventStore.swift | 46 ++++++++++++++----- .../en.lproj/Localizable.strings | 1 + MeetingBarTests/GoogleAccountTests.swift | 8 ++-- 3 files changed, 40 insertions(+), 15 deletions(-) diff --git a/MeetingBar/Core/EventStores/GCEventStore.swift b/MeetingBar/Core/EventStores/GCEventStore.swift index 495f1a7a..5780e787 100644 --- a/MeetingBar/Core/EventStores/GCEventStore.swift +++ b/MeetingBar/Core/EventStores/GCEventStore.swift @@ -82,6 +82,7 @@ final class GCEventStore: NSObject, super.init() migrateLegacyAuthStateIfNeeded() restoreAllAuthStates() + pruneAccountsMissingAuthState() } // MARK: Public API @@ -91,8 +92,6 @@ final class GCEventStore: NSObject, } func addAccount() async throws -> GoogleAccount { - let accountId = UUID().uuidString - let config = try await withCheckedThrowingContinuation { cont in OIDAuthorizationService.discoverConfiguration(forIssuer: URL(string: Self.kIssuer)!) { cfg, err in if let cfg { cont.resume(returning: cfg) } else { cont.resume(throwing: err ?? NSError(domain: "GoogleSignIn", code: -1)) } @@ -116,7 +115,7 @@ final class GCEventStore: NSObject, ) return try await withCheckedThrowingContinuation { cont in - pendingAuthAccountId = accountId + pendingAuthAccountId = UUID().uuidString currentAuthorizationFlow = OIDAuthState.authState(byPresenting: request, externalUserAgent: self) { [weak self] state, error in guard let self else { return } @@ -124,6 +123,17 @@ final class GCEventStore: NSObject, self.currentAuthorizationFlow = nil if let state, let email = state.userEmail { + if let existing = self.accounts.first(where: { $0.email == email }) { + self.authStates[existing.id] = state + state.stateChangeDelegate = self + state.errorDelegate = self + self.persistAuthState(for: existing) + sendNotification("notifications_google_account_connected_title".loco(), "notifications_google_account_refreshed_body".loco(email)) + cont.resume(returning: existing) + return + } + + let accountId = UUID().uuidString let account = GoogleAccount(id: accountId, email: email) self.authStates[accountId] = state state.stateChangeDelegate = self @@ -140,8 +150,15 @@ final class GCEventStore: NSObject, } func removeAccount(_ account: GoogleAccount) async { - if let state = authStates[account.id] { - let access = state.lastTokenResponse?.accessToken + 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 state { + let access = state.lastTokenResponse?.accessToken let refresh = state.lastTokenResponse?.refreshToken await withTaskGroup(of: Void.self) { grp in if let acc = access { @@ -155,14 +172,7 @@ final class GCEventStore: NSObject, } } } - 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) } } func signIn(forcePrompt _: Bool) async throws { @@ -350,6 +360,18 @@ final class GCEventStore: NSObject, } } + private func pruneAccountsMissingAuthState() { + let validIds = Set(authStates.keys) + let broken = accounts.filter { !validIds.contains($0.id) } + guard !broken.isEmpty else { return } + + for account in broken { + NSLog("GCEventStore: pruning account %@ with missing auth state", account.email) + Keychain.delete(for: accountKeychainKey(accountId: account.id)) + } + accounts = accounts.filter { validIds.contains($0.id) } + } + // MARK: Networking helper private func fetchJSON(_ url: URL, forAccount accountId: String, retrying: Bool = false) async throws -> [[String: Any]] { diff --git a/MeetingBar/Resources /Localization /en.lproj/Localizable.strings b/MeetingBar/Resources /Localization /en.lproj/Localizable.strings index c7ddee82..a94fcda6 100644 --- a/MeetingBar/Resources /Localization /en.lproj/Localizable.strings +++ b/MeetingBar/Resources /Localization /en.lproj/Localizable.strings @@ -361,3 +361,4 @@ "notifications_google_account_connected_title" = "Google Account connected"; "notifications_google_account_connected_body" = "%@ is connected"; +"notifications_google_account_refreshed_body" = "%@ calendars refreshed"; diff --git a/MeetingBarTests/GoogleAccountTests.swift b/MeetingBarTests/GoogleAccountTests.swift index 800bce41..3f633370 100644 --- a/MeetingBarTests/GoogleAccountTests.swift +++ b/MeetingBarTests/GoogleAccountTests.swift @@ -97,7 +97,7 @@ final class MultiAccountCalendarTests: BaseTestCase { XCTAssertEqual(allIds.count, Set(allIds).count) } - func test_selectedCalendarIDsCleanedOnAccountRemoval() { + func test_selectedCalendarIDsCleanedOnAccountRemoval() async { let accountId = "acc-to-remove" let keptAccountId = "acc-kept" @@ -109,14 +109,16 @@ final class MultiAccountCalendarTests: BaseTestCase { ] let account = GoogleAccount(id: accountId, email: "remove@example.com") - let prefix = "\(account.id):" - Defaults[.selectedCalendarIDs] = Defaults[.selectedCalendarIDs].filter { !$0.hasPrefix(prefix) } + Defaults[.googleAccounts] = [account] + + await GCEventStore.shared.removeAccount(account) XCTAssertEqual(Defaults[.selectedCalendarIDs].count, 2) XCTAssertTrue(Defaults[.selectedCalendarIDs].contains("\(keptAccountId):primary")) XCTAssertTrue(Defaults[.selectedCalendarIDs].contains("unprefixed-calendar")) XCTAssertFalse(Defaults[.selectedCalendarIDs].contains("\(accountId):primary")) XCTAssertFalse(Defaults[.selectedCalendarIDs].contains("\(accountId):meetings")) + XCTAssertTrue(Defaults[.googleAccounts].isEmpty) } func test_calendarIdExtractionWithFirstColon() { From 5144e65f94e91b351afe14356f5da293260a7130 Mon Sep 17 00:00:00 2001 From: vlordier Date: Fri, 3 Apr 2026 18:51:15 +0200 Subject: [PATCH 09/14] fix: show account email in remove confirmation dialog - Button text now reads 'Remove user@example.com' - Message text now includes the specific account email --- MeetingBar/UI/Views/Preferences/CalendarsTab.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/MeetingBar/UI/Views/Preferences/CalendarsTab.swift b/MeetingBar/UI/Views/Preferences/CalendarsTab.swift index 0f199bf5..887dde9d 100644 --- a/MeetingBar/UI/Views/Preferences/CalendarsTab.swift +++ b/MeetingBar/UI/Views/Preferences/CalendarsTab.swift @@ -117,15 +117,15 @@ struct GoogleAccountsSection: View { titleVisibility: .visible, presenting: accountToRemove ) { account in - Button("preferences_calendars_remove_account_action", role: .destructive) { + Button("preferences_calendars_remove_account_action".loco(account.email), role: .destructive) { Task { await GCEventStore.shared.removeAccount(account) try? await eventManager.refreshSources() } } Button("preferences_calendars_cancel", role: .cancel) {} - } message: { _ in - Text("preferences_calendars_remove_account_message") + } message: { account in + Text("preferences_calendars_remove_account_message".loco(account.email)) } } } From dc104e540881ee0d9aee31d4ece7daf3c815de37 Mon Sep 17 00:00:00 2001 From: vlordier Date: Fri, 3 Apr 2026 23:06:36 +0200 Subject: [PATCH 10/14] fix: provide clear error message when Google OAuth credentials missing - Replace force-unwraps with descriptive fatalError messages - Tells users exactly which env var is missing and how to configure it --- .../Core/EventStores/GCEventStore.swift | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/MeetingBar/Core/EventStores/GCEventStore.swift b/MeetingBar/Core/EventStores/GCEventStore.swift index 5780e787..626513c1 100644 --- a/MeetingBar/Core/EventStores/GCEventStore.swift +++ b/MeetingBar/Core/EventStores/GCEventStore.swift @@ -11,9 +11,26 @@ import AppKit import Defaults import Foundation -let googleClientNumber = Bundle.main.object(forInfoDictionaryKey: "GOOGLE_CLIENT_NUMBER") as! String -let googleClientSecret = Bundle.main.object(forInfoDictionaryKey: "GOOGLE_CLIENT_SECRET") as! String -let googleAuthKeychainName = Bundle.main.object(forInfoDictionaryKey: "GOOGLE_AUTH_KEYCHAIN_NAME") as! String +let googleClientNumber: String = { + guard let value = Bundle.main.object(forInfoDictionaryKey: "GOOGLE_CLIENT_NUMBER") as? String, !value.isEmpty else { + fatalError("GOOGLE_CLIENT_NUMBER not configured. Add it to your Xcode scheme's Environment Variables or Info.plist.") + } + return value +}() + +let googleClientSecret: String = { + guard let value = Bundle.main.object(forInfoDictionaryKey: "GOOGLE_CLIENT_SECRET") as? String, !value.isEmpty else { + fatalError("GOOGLE_CLIENT_SECRET not configured. Add it to your Xcode scheme's Environment Variables or Info.plist.") + } + return value +}() + +let googleAuthKeychainName: String = { + guard let value = Bundle.main.object(forInfoDictionaryKey: "GOOGLE_AUTH_KEYCHAIN_NAME") as? String, !value.isEmpty else { + fatalError("GOOGLE_AUTH_KEYCHAIN_NAME not configured. Add it to your Xcode scheme's Environment Variables or Info.plist.") + } + return value +}() extension OIDServiceConfiguration: @unchecked @retroactive Sendable {} From b944ec9e4d4f0620fefd8d8569aee51b1541bb09 Mon Sep 17 00:00:00 2001 From: vlordier Date: Fri, 3 Apr 2026 23:34:27 +0200 Subject: [PATCH 11/14] fix: prevent crash when Google OAuth credentials not configured - 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 --- .../Core/EventStores/GCEventStore.swift | 162 +++++++++++++++--- 1 file changed, 140 insertions(+), 22 deletions(-) diff --git a/MeetingBar/Core/EventStores/GCEventStore.swift b/MeetingBar/Core/EventStores/GCEventStore.swift index 626513c1..a36a1a17 100644 --- a/MeetingBar/Core/EventStores/GCEventStore.swift +++ b/MeetingBar/Core/EventStores/GCEventStore.swift @@ -11,26 +11,31 @@ import AppKit import Defaults import Foundation -let googleClientNumber: String = { - guard let value = Bundle.main.object(forInfoDictionaryKey: "GOOGLE_CLIENT_NUMBER") as? String, !value.isEmpty else { - fatalError("GOOGLE_CLIENT_NUMBER not configured. Add it to your Xcode scheme's Environment Variables or Info.plist.") - } - return value +let googleClientNumber: String? = Bundle.main.object(forInfoDictionaryKey: "GOOGLE_CLIENT_NUMBER") as? String +let googleClientSecret: String? = Bundle.main.object(forInfoDictionaryKey: "GOOGLE_CLIENT_SECRET") as? String +let googleAuthKeychainName: String? = Bundle.main.object(forInfoDictionaryKey: "GOOGLE_AUTH_KEYCHAIN_NAME") as? String + +let isMockMode: Bool = { + #if DEBUG + return ProcessInfo.processInfo.environment["MOCK_GOOGLE_CALENDAR"] == "1" + #else + return false + #endif }() -let googleClientSecret: String = { - guard let value = Bundle.main.object(forInfoDictionaryKey: "GOOGLE_CLIENT_SECRET") as? String, !value.isEmpty else { - fatalError("GOOGLE_CLIENT_SECRET not configured. Add it to your Xcode scheme's Environment Variables or Info.plist.") - } - return value -}() +enum MockError: Error, LocalizedError { + case credentialsMissing + case noMoreMockAccounts -let googleAuthKeychainName: String = { - guard let value = Bundle.main.object(forInfoDictionaryKey: "GOOGLE_AUTH_KEYCHAIN_NAME") as? String, !value.isEmpty else { - fatalError("GOOGLE_AUTH_KEYCHAIN_NAME not configured. Add it to your Xcode scheme's Environment Variables or Info.plist.") + var errorDescription: String? { + switch self { + case .credentialsMissing: + return "Google OAuth credentials not configured. Set GOOGLE_CLIENT_NUMBER and GOOGLE_CLIENT_SECRET in your Xcode scheme, or enable MOCK_GOOGLE_CALENDAR=1 for mock mode." + case .noMoreMockAccounts: + return "All mock accounts are already added." + } } - return value -}() +} extension OIDServiceConfiguration: @unchecked @retroactive Sendable {} @@ -67,10 +72,10 @@ final class GCEventStore: NSObject, // MARK: Static constants private static let kIssuer = "https://accounts.google.com" - private static let kClientID = "\(googleClientNumber).apps.googleusercontent.com" - private static let kClientSecret = googleClientSecret - private static let kRedirectURI = "com.googleusercontent.apps.\(googleClientNumber):/oauthredirect" - private static let legacyKeychainName = googleAuthKeychainName + private static let kClientID = googleClientNumber.map { "\($0).apps.googleusercontent.com" } ?? "" + private static let kClientSecret = googleClientSecret ?? "" + private static let kRedirectURI = googleClientNumber.map { "com.googleusercontent.apps.\($0):/oauthredirect" } ?? "" + private static let legacyKeychainName = googleAuthKeychainName ?? "MeetingBarGoogleAuth" // MARK: Stored properties @MainActor var currentAuthorizationFlow: OIDExternalUserAgentSession? @@ -82,7 +87,10 @@ final class GCEventStore: NSObject, } private var authStates: [String: OIDAuthState] = [:] + private var mockAuthorizedAccounts: Set = [] private var refreshTask: [String: Task] = [:] + private var mockCalendarData: [String: [[String: Any]]] = [:] + private var mockEventData: [String: [[String: Any]]] = [:] // Shared URLSession to leverage connection reuse private static let session: URLSession = { @@ -109,6 +117,14 @@ final class GCEventStore: NSObject, } func addAccount() async throws -> GoogleAccount { + if isMockMode { + return try await addMockAccount() + } + + guard let redirectURL = URL(string: Self.kRedirectURI), !Self.kClientID.isEmpty else { + throw MockError.credentialsMissing + } + let config = try await withCheckedThrowingContinuation { cont in OIDAuthorizationService.discoverConfiguration(forIssuer: URL(string: Self.kIssuer)!) { cfg, err in if let cfg { cont.resume(returning: cfg) } else { cont.resume(throwing: err ?? NSError(domain: "GoogleSignIn", code: -1)) } @@ -126,7 +142,7 @@ final class GCEventStore: NSObject, clientId: Self.kClientID, clientSecret: Self.kClientSecret, scopes: scopes, - redirectURL: URL(string: Self.kRedirectURI)!, + redirectURL: redirectURL, responseType: OIDResponseTypeCode, additionalParameters: ["access_type": "offline"] ) @@ -166,6 +182,75 @@ final class GCEventStore: NSObject, } } + private func addMockAccount() async throws -> GoogleAccount { + let mockEmails = ["personal@example.com", "work@example.com", "dev@example.com"] + let existingEmails = Set(accounts.map(\.email)) + let availableEmails = mockEmails.filter { !existingEmails.contains($0) } + + guard let email = availableEmails.first else { + throw MockError.noMoreMockAccounts + } + + let accountId = UUID().uuidString + let account = GoogleAccount(id: accountId, email: email) + + let mockCalendars: [[String: Any]] = [ + ["id": "\(accountId):primary", "summary": "Primary", "backgroundColor": "#039BE5"], + ["id": "\(accountId):meetings", "summary": "Meetings", "backgroundColor": "#33B679"], + ["id": "\(accountId):personal", "summary": "Personal", "backgroundColor": "#F4511E"], + ["id": "\(accountId):tasks", "summary": "Tasks", "backgroundColor": "#9E69AF"] + ] + + mockCalendarData[accountId] = mockCalendars + + let now = ISO8601DateFormatter().string(from: Date()) + let mockEvents: [[String: Any]] = [ + [ + "id": "\(accountId)-evt-1", + "summary": "Team Standup", + "status": "confirmed", + "updated": now, + "start": ["dateTime": "2024-01-15T09:00:00Z"], + "end": ["dateTime": "2024-01-15T09:30:00Z"], + "conferenceData": [ + "entryPoints": [ + ["entryPointType": "video", "uri": "https://meet.google.com/abc-def"] + ] + ], + "attendees": [ + ["email": email, "displayName": "You", "responseStatus": "accepted", "optional": false, "self": true] + ] + ], + [ + "id": "\(accountId)-evt-2", + "summary": "Lunch Break", + "status": "confirmed", + "updated": now, + "start": ["dateTime": "2024-01-15T12:00:00Z"], + "end": ["dateTime": "2024-01-15T13:00:00Z"] + ], + [ + "id": "\(accountId)-evt-3", + "summary": "Sprint Planning", + "status": "tentative", + "updated": now, + "start": ["dateTime": "2024-01-16T10:00:00Z"], + "end": ["dateTime": "2024-01-16T11:00:00Z"], + "location": "Conference Room A", + "description": "Plan next sprint items" + ] + ] + + mockEventData[accountId] = mockEvents + + mockAuthorizedAccounts.insert(accountId) + accounts.append(account) + + sendNotification("Mock: Google Account connected", "\(email) (mock)") + + return account + } + func removeAccount(_ account: GoogleAccount) async { let state = authStates.removeValue(forKey: account.id) accounts.removeAll { $0.id == account.id } @@ -174,6 +259,9 @@ final class GCEventStore: NSObject, let prefix = "\(account.id):" Defaults[.selectedCalendarIDs] = Defaults[.selectedCalendarIDs].filter { !$0.hasPrefix(prefix) } + mockCalendarData.removeValue(forKey: account.id) + mockEventData.removeValue(forKey: account.id) + if let state { let access = state.lastTokenResponse?.accessToken let refresh = state.lastTokenResponse?.refreshToken @@ -209,6 +297,21 @@ final class GCEventStore: NSObject, var allCalendars: [MBCalendar] = [] for account in accounts { + if isMockMode { + let calendars = (mockCalendarData[account.id] ?? []).compactMap { item -> MBCalendar? in + guard let title = item["summary"] as? String, + let calendarID = item["id"] as? String, + let backgroundColor = item["backgroundColor"] as? String else { return nil } + return MBCalendar(title: title, + id: calendarID, + source: account.email, + email: account.email, + color: hexStringToUIColor(hex: backgroundColor)) + } + allCalendars.append(contentsOf: calendars) + continue + } + guard let state = authStates[account.id], state.isAuthorized else { continue } let url = URL(string: "https://www.googleapis.com/calendar/v3/users/me/calendarList?maxResults=250&showHidden=true")! @@ -236,6 +339,13 @@ final class GCEventStore: NSObject, accountId: String, dateFrom: Date, dateTo: Date) async throws -> [MBEvent] { + if isMockMode { + let events = (mockEventData[accountId] ?? []).compactMap { item -> MBEvent? in + GCParser.event(from: item, calendar: calendar) + } + return events + } + let iso = ISO8601DateFormatter() let timeMin = iso.string(from: dateFrom) let timeMax = iso.string(from: dateTo) @@ -270,6 +380,14 @@ final class GCEventStore: NSObject, } for (accountId, accountCalendars) in calendarsByAccount { + if isMockMode { + for cal in accountCalendars { + let ev = try await getCalendarEventsForDateRange(calendar: cal, accountId: accountId, dateFrom: from, dateTo: to) + result.append(contentsOf: ev) + } + continue + } + guard let state = authStates[accountId], state.isAuthorized else { continue } for cal in accountCalendars { @@ -284,7 +402,7 @@ final class GCEventStore: NSObject, // MARK: - Private helpers private func accountKeychainKey(accountId: String) -> String { - return "\(googleAuthKeychainName)_\(accountId)" + return "\(googleAuthKeychainName ?? "MeetingBarGoogleAuth")_\(accountId)" } private func validAccessToken(for accountId: String, forceRefresh: Bool = false) async throws -> String { From 693f3aead8e4088c338048c2e46ded1aa70fd61e Mon Sep 17 00:00:00 2001 From: vlordier Date: Sat, 4 Apr 2026 09:18:52 +0200 Subject: [PATCH 12/14] refactor: reduce GCParser complexity to pass SwiftLint - 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) --- .../Core/EventStores/GCEventStore.swift | 145 +++++++++--------- 1 file changed, 72 insertions(+), 73 deletions(-) diff --git a/MeetingBar/Core/EventStores/GCEventStore.swift b/MeetingBar/Core/EventStores/GCEventStore.swift index a36a1a17..3b223035 100644 --- a/MeetingBar/Core/EventStores/GCEventStore.swift +++ b/MeetingBar/Core/EventStores/GCEventStore.swift @@ -604,60 +604,14 @@ final class GCEventStore: NSObject, return nil } - let formatter = ISO8601DateFormatter() - let lastModifiedDate = formatter.date(from: item["updated"] as? String ?? "") + let lastModifiedDate = ISO8601DateFormatter().date(from: item["updated"] as? String ?? "") let title = item["summary"] as? String - var status: MBEventStatus - switch item["status"] as? String { - case "confirmed": - status = .confirmed - case "tentative": - status = .tentative - case "cancelled": - status = .canceled - default: - status = .none - } - + let status = parseStatus(item["status"] as? String) let notes = item["description"] as? String let location = item["location"] as? String - - var url: URL? - if let conferenceData = item["conferenceData"] as? [String: Any] { - if let entryPoints = conferenceData["entryPoints"] as? [[String: String]] { - if let videoEntryPoint = entryPoints.first(where: { $0["entryPointType"] == "video" }) { - url = URL(string: videoEntryPoint["uri"] ?? "") - } - } - } - - let organizerRaw = item["organizer"] as? [String: String] - let organizer = MBEventOrganizer(email: organizerRaw?["email"], name: organizerRaw?["name"]) - - var attendees: [MBEventAttendee] = [] - let rawAttendees = item["attendees"] as? [[String: Any]] ?? [] - for jsonAttendee in rawAttendees { - let email = jsonAttendee["email"] as? String - let name = jsonAttendee["displayName"] as? String - let optional = jsonAttendee["optional"] as? Bool ?? false - let isCurrentUser = jsonAttendee["self"] as? Bool ?? false - - var attendeeStatus: MBEventAttendeeStatus - switch jsonAttendee["responseStatus"] as? String { - case "accepted": - attendeeStatus = .accepted - case "declined": - attendeeStatus = .declined - case "tentative": - attendeeStatus = .tentative - case "needsAction": - attendeeStatus = .pending - default: - attendeeStatus = .unknown - } - let attendee = MBEventAttendee(email: email, name: name, status: attendeeStatus, optional: optional, isCurrentUser: isCurrentUser) - attendees.append(attendee) - } + let url = parseVideoURL(item["conferenceData"] as? [String: Any]) + let organizer = parseOrganizer(item["organizer"] as? [String: String]) + let attendees = parseAttendees(item["attendees"] as? [[String: Any]]) guard let itemStart = item["start"] as? [String: String], let itemEnd = item["end"] as? [String: String] @@ -666,25 +620,7 @@ final class GCEventStore: NSObject, return nil } - let startDate: Date - let endDate: Date - let isAllDay: Bool - - if let startDateTime = itemStart["dateTime"], - let endDateTime = itemEnd["dateTime"], - let parsedStart = ISO8601DateFormatter().date(from: startDateTime), - let parsedEnd = ISO8601DateFormatter().date(from: endDateTime) { - startDate = parsedStart - endDate = parsedEnd - isAllDay = false - } else if let startDateStr = itemStart["date"], - let endDateStr = itemEnd["date"], - let parsedStart = DateFormatter.yyyyMMdd.date(from: startDateStr), - let parsedEnd = DateFormatter.yyyyMMdd.date(from: endDateStr) { - startDate = parsedStart - endDate = parsedEnd - isAllDay = true - } else { + guard let dates = parseDates(itemStart, itemEnd) else { NSLog("GCParser: invalid date format for event \(eventID)") return nil } @@ -701,12 +637,75 @@ final class GCEventStore: NSObject, url: url, organizer: organizer, attendees: attendees, - startDate: startDate, - endDate: endDate, - isAllDay: isAllDay, + startDate: dates.start, + endDate: dates.end, + isAllDay: dates.isAllDay, recurrent: recurrent, calendar: calendar ) } } } + +private func parseStatus(_ raw: String?) -> MBEventStatus { + switch raw { + case "confirmed": return .confirmed + case "tentative": return .tentative + case "cancelled": return .canceled + default: return .none + } +} + +private func parseVideoURL(_ conferenceData: [String: Any]?) -> URL? { + guard let entryPoints = conferenceData?["entryPoints"] as? [[String: String]] else { return nil } + guard let video = entryPoints.first(where: { $0["entryPointType"] == "video" }) else { return nil } + return URL(string: video["uri"] ?? "") +} + +private func parseOrganizer(_ raw: [String: String]?) -> MBEventOrganizer { + MBEventOrganizer(email: raw?["email"], name: raw?["name"]) +} + +private func parseAttendees(_ raw: [[String: Any]]?) -> [MBEventAttendee] { + guard let raw else { return [] } + return raw.compactMap { json -> MBEventAttendee? in + guard let email = json["email"] as? String else { return nil } + let name = json["displayName"] as? String + let optional = json["optional"] as? Bool ?? false + let isCurrentUser = json["self"] as? Bool ?? false + let status = parseAttendeeStatus(json["responseStatus"] as? String) + return MBEventAttendee(email: email, name: name, status: status, optional: optional, isCurrentUser: isCurrentUser) + } +} + +private func parseAttendeeStatus(_ raw: String?) -> MBEventAttendeeStatus { + switch raw { + case "accepted": return .accepted + case "declined": return .declined + case "tentative": return .tentative + case "needsAction": return .pending + default: return .unknown + } +} + +private struct ParsedDates { + let start: Date + let end: Date + let isAllDay: Bool +} + +private func parseDates(_ start: [String: String], _ end: [String: String]) -> ParsedDates? { + if let startStr = start["dateTime"], + let endStr = end["dateTime"], + let parsedStart = ISO8601DateFormatter().date(from: startStr), + let parsedEnd = ISO8601DateFormatter().date(from: endStr) { + return ParsedDates(start: parsedStart, end: parsedEnd, isAllDay: false) + } + if let startStr = start["date"], + let endStr = end["date"], + let parsedStart = DateFormatter.yyyyMMdd.date(from: startStr), + let parsedEnd = DateFormatter.yyyyMMdd.date(from: endStr) { + return ParsedDates(start: parsedStart, end: parsedEnd, isAllDay: true) + } + return nil +} From dbcf6d753f3c1e355d9b7e91ccea488297b26e84 Mon Sep 17 00:00:00 2001 From: vlordier Date: Sat, 4 Apr 2026 11:43:23 +0200 Subject: [PATCH 13/14] fix: auto-enable mock mode in debug builds without Google credentials - 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 --- .../Core/EventStores/GCEventStore.swift | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/MeetingBar/Core/EventStores/GCEventStore.swift b/MeetingBar/Core/EventStores/GCEventStore.swift index 3b223035..a892e693 100644 --- a/MeetingBar/Core/EventStores/GCEventStore.swift +++ b/MeetingBar/Core/EventStores/GCEventStore.swift @@ -11,13 +11,29 @@ import AppKit import Defaults import Foundation -let googleClientNumber: String? = Bundle.main.object(forInfoDictionaryKey: "GOOGLE_CLIENT_NUMBER") as? String -let googleClientSecret: String? = Bundle.main.object(forInfoDictionaryKey: "GOOGLE_CLIENT_SECRET") as? String -let googleAuthKeychainName: String? = Bundle.main.object(forInfoDictionaryKey: "GOOGLE_AUTH_KEYCHAIN_NAME") as? String +let googleClientNumber: String? = { + guard let value = Bundle.main.object(forInfoDictionaryKey: "GOOGLE_CLIENT_NUMBER") as? String, + !value.isEmpty, + !value.hasPrefix("$(") else { return nil } + return value +}() +let googleClientSecret: String? = { + guard let value = Bundle.main.object(forInfoDictionaryKey: "GOOGLE_CLIENT_SECRET") as? String, + !value.isEmpty, + !value.hasPrefix("$(") else { return nil } + return value +}() +let googleAuthKeychainName: String? = { + guard let value = Bundle.main.object(forInfoDictionaryKey: "GOOGLE_AUTH_KEYCHAIN_NAME") as? String, + !value.isEmpty, + !value.hasPrefix("$(") else { return nil } + return value +}() let isMockMode: Bool = { #if DEBUG - return ProcessInfo.processInfo.environment["MOCK_GOOGLE_CALENDAR"] == "1" + if ProcessInfo.processInfo.environment["MOCK_GOOGLE_CALENDAR"] == "1" { return true } + return googleClientNumber == nil || googleClientSecret == nil #else return false #endif From 77bbaeb2dcc77837031e9bb3449a4f06abe0d693 Mon Sep 17 00:00:00 2001 From: vlordier Date: Mon, 6 Apr 2026 12:58:55 +0200 Subject: [PATCH 14/14] Fix mock events to use current dates instead of hardcoded 2024 dates --- MeetingBar/Core/EventStores/GCEventStore.swift | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/MeetingBar/Core/EventStores/GCEventStore.swift b/MeetingBar/Core/EventStores/GCEventStore.swift index a892e693..ac1284cf 100644 --- a/MeetingBar/Core/EventStores/GCEventStore.swift +++ b/MeetingBar/Core/EventStores/GCEventStore.swift @@ -219,15 +219,18 @@ final class GCEventStore: NSObject, mockCalendarData[accountId] = mockCalendars - let now = ISO8601DateFormatter().string(from: Date()) + let iso = ISO8601DateFormatter() + let today = Calendar.current.startOfDay(for: Date()) + let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: today)! + let now = iso.string(from: Date()) let mockEvents: [[String: Any]] = [ [ "id": "\(accountId)-evt-1", "summary": "Team Standup", "status": "confirmed", "updated": now, - "start": ["dateTime": "2024-01-15T09:00:00Z"], - "end": ["dateTime": "2024-01-15T09:30:00Z"], + "start": ["dateTime": iso.string(from: today.addingTimeInterval(9 * 3600))], + "end": ["dateTime": iso.string(from: today.addingTimeInterval(9.5 * 3600))], "conferenceData": [ "entryPoints": [ ["entryPointType": "video", "uri": "https://meet.google.com/abc-def"] @@ -242,16 +245,16 @@ final class GCEventStore: NSObject, "summary": "Lunch Break", "status": "confirmed", "updated": now, - "start": ["dateTime": "2024-01-15T12:00:00Z"], - "end": ["dateTime": "2024-01-15T13:00:00Z"] + "start": ["dateTime": iso.string(from: today.addingTimeInterval(12 * 3600))], + "end": ["dateTime": iso.string(from: today.addingTimeInterval(13 * 3600))] ], [ "id": "\(accountId)-evt-3", "summary": "Sprint Planning", "status": "tentative", "updated": now, - "start": ["dateTime": "2024-01-16T10:00:00Z"], - "end": ["dateTime": "2024-01-16T11:00:00Z"], + "start": ["dateTime": iso.string(from: tomorrow.addingTimeInterval(10 * 3600))], + "end": ["dateTime": iso.string(from: tomorrow.addingTimeInterval(11 * 3600))], "location": "Conference Room A", "description": "Plan next sprint items" ]