浏览代码

Integrate Google OAuth schedule sync for Meet events.

Replace static Schedule cards with Google Calendar-backed meetings, adding OAuth token handling, loopback auth callback, and improved API error visibility to make meeting sync debuggable.

Made-with: Cursor
huzaifahayat12 3 月之前
父节点
当前提交
08ac3971a8

+ 46 - 0
Info.plist

@@ -0,0 +1,46 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>CFBundleDevelopmentRegion</key>
+	<string>$(DEVELOPMENT_LANGUAGE)</string>
+	<key>CFBundleDisplayName</key>
+	<string>$(PRODUCT_NAME)</string>
+	<key>CFBundleExecutable</key>
+	<string>$(EXECUTABLE_NAME)</string>
+	<key>CFBundleIdentifier</key>
+	<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+	<key>CFBundleInfoDictionaryVersion</key>
+	<string>6.0</string>
+	<key>CFBundleName</key>
+	<string>$(PRODUCT_NAME)</string>
+	<key>CFBundlePackageType</key>
+	<string>APPL</string>
+	<key>CFBundleShortVersionString</key>
+	<string>$(MARKETING_VERSION)</string>
+	<key>CFBundleVersion</key>
+	<string>$(CURRENT_PROJECT_VERSION)</string>
+	<key>LSMinimumSystemVersion</key>
+	<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
+	<key>NSMainStoryboardFile</key>
+	<string>Main</string>
+	<key>NSPrincipalClass</key>
+	<string>NSApplication</string>
+	<key>NSCameraUsageDescription</key>
+	<string>Camera is used for video meetings you open inside this app.</string>
+	<key>NSMicrophoneUsageDescription</key>
+	<string>Microphone is used for audio in meetings you open inside this app.</string>
+
+	<key>CFBundleURLTypes</key>
+	<array>
+		<dict>
+			<key>CFBundleURLName</key>
+			<string>GoogleOAuthRedirect</string>
+			<key>CFBundleURLSchemes</key>
+			<array>
+				<string>meetingsapp.oauth</string>
+			</array>
+		</dict>
+	</array>
+</dict>
+</plist>

+ 4 - 12
meetings_app.xcodeproj/project.pbxproj

@@ -254,12 +254,8 @@
 				CURRENT_PROJECT_VERSION = 1;
 				ENABLE_APP_SANDBOX = YES;
 				ENABLE_USER_SELECTED_FILES = readonly;
-				GENERATE_INFOPLIST_FILE = YES;
-				INFOPLIST_KEY_NSCameraUsageDescription = "Camera is used for video meetings you open inside this app.";
-				INFOPLIST_KEY_NSHumanReadableCopyright = "";
-				INFOPLIST_KEY_NSMainStoryboardFile = Main;
-				INFOPLIST_KEY_NSMicrophoneUsageDescription = "Microphone is used for audio in meetings you open inside this app.";
-				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
+				GENERATE_INFOPLIST_FILE = NO;
+				INFOPLIST_FILE = Info.plist;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/../Frameworks",
@@ -288,12 +284,8 @@
 				CURRENT_PROJECT_VERSION = 1;
 				ENABLE_APP_SANDBOX = YES;
 				ENABLE_USER_SELECTED_FILES = readonly;
-				GENERATE_INFOPLIST_FILE = YES;
-				INFOPLIST_KEY_NSCameraUsageDescription = "Camera is used for video meetings you open inside this app.";
-				INFOPLIST_KEY_NSHumanReadableCopyright = "";
-				INFOPLIST_KEY_NSMainStoryboardFile = Main;
-				INFOPLIST_KEY_NSMicrophoneUsageDescription = "Microphone is used for audio in meetings you open inside this app.";
-				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
+				GENERATE_INFOPLIST_FILE = NO;
+				INFOPLIST_FILE = Info.plist;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/../Frameworks",

+ 418 - 0
meetings_app/Auth/GoogleOAuthService.swift

@@ -0,0 +1,418 @@
+import Foundation
+import CryptoKit
+import AppKit
+import Network
+
+struct GoogleOAuthTokens: Codable, Equatable {
+    var accessToken: String
+    var refreshToken: String?
+    var expiresAt: Date
+    var scope: String?
+    var tokenType: String?
+}
+
+enum GoogleOAuthError: Error {
+    case missingClientId
+    case missingClientSecret
+    case invalidCallbackURL
+    case missingAuthorizationCode
+    case tokenExchangeFailed(String)
+    case unableToOpenBrowser
+    case authenticationTimedOut
+    case noStoredTokens
+}
+
+final class GoogleOAuthService: NSObject {
+    static let shared = GoogleOAuthService()
+
+    // Stored in UserDefaults so you can configure without rebuilding.
+    // Put your OAuth Desktop client ID here (from Google Cloud Console).
+    private let clientIdDefaultsKey = "google.oauth.clientId"
+    private let clientSecretDefaultsKey = "google.oauth.clientSecret"
+    private let bundledClientId = "824412072260-m9g5g6mlemnb0o079rtuqnh0e1unmelc.apps.googleusercontent.com"
+    private let bundledClientSecret = "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"
+
+    // Calendar readonly is needed for the Schedule list. Meet scopes can be added later when needed.
+    private let scopes = [
+        "https://www.googleapis.com/auth/calendar.readonly"
+    ]
+
+    private let tokenStore = KeychainTokenStore()
+    private override init() {}
+
+    func configuredClientId() -> String? {
+        let value = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?.trimmingCharacters(in: .whitespacesAndNewlines)
+        if let value, value.isEmpty == false { return value }
+        return bundledClientId
+    }
+
+    func setClientIdForTesting(_ clientId: String) {
+        UserDefaults.standard.set(clientId, forKey: clientIdDefaultsKey)
+    }
+
+    func configuredClientSecret() -> String? {
+        let value = UserDefaults.standard.string(forKey: clientSecretDefaultsKey)?.trimmingCharacters(in: .whitespacesAndNewlines)
+        if let value, value.isEmpty == false { return value }
+        return bundledClientSecret
+    }
+
+    func setClientSecretForTesting(_ clientSecret: String) {
+        UserDefaults.standard.set(clientSecret, forKey: clientSecretDefaultsKey)
+    }
+
+    func signOut() throws {
+        try tokenStore.deleteTokens()
+    }
+
+    func loadTokens() -> GoogleOAuthTokens? {
+        try? tokenStore.readTokens()
+    }
+
+    func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
+        if var tokens = try tokenStore.readTokens() {
+            if tokens.expiresAt.timeIntervalSinceNow > 60 {
+                return tokens.accessToken
+            }
+            if let refreshed = try await refreshTokens(tokens) {
+                tokens = refreshed
+                try tokenStore.writeTokens(tokens)
+                return tokens.accessToken
+            }
+        }
+
+        let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
+        try tokenStore.writeTokens(tokens)
+        return tokens.accessToken
+    }
+
+    // MARK: - Interactive sign-in (Authorization Code + PKCE)
+
+    private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> GoogleOAuthTokens {
+        _ = presentingWindow
+        guard let clientId = configuredClientId() else { throw GoogleOAuthError.missingClientId }
+        guard let clientSecret = configuredClientSecret() else { throw GoogleOAuthError.missingClientSecret }
+        let codeVerifier = Self.randomURLSafeString(length: 64)
+        let codeChallenge = Self.pkceChallenge(for: codeVerifier)
+        let state = Self.randomURLSafeString(length: 32)
+
+        let loopback = try await OAuthLoopbackServer.start()
+        defer { loopback.stop() }
+        let redirectURI = loopback.redirectURI
+
+        var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
+        components.queryItems = [
+            URLQueryItem(name: "client_id", value: clientId),
+            URLQueryItem(name: "redirect_uri", value: redirectURI),
+            URLQueryItem(name: "response_type", value: "code"),
+            URLQueryItem(name: "scope", value: scopes.joined(separator: " ")),
+            URLQueryItem(name: "state", value: state),
+            URLQueryItem(name: "code_challenge", value: codeChallenge),
+            URLQueryItem(name: "code_challenge_method", value: "S256"),
+            URLQueryItem(name: "access_type", value: "offline"),
+            URLQueryItem(name: "prompt", value: "consent")
+        ]
+
+        guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
+        guard NSWorkspace.shared.open(authURL) else { throw GoogleOAuthError.unableToOpenBrowser }
+        let callbackURL = try await loopback.waitForCallback()
+
+        guard let returnedState = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
+            .queryItems?.first(where: { $0.name == "state" })?.value,
+              returnedState == state else {
+            throw GoogleOAuthError.invalidCallbackURL
+        }
+
+        guard let code = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
+            .queryItems?.first(where: { $0.name == "code" })?.value,
+              code.isEmpty == false else {
+            throw GoogleOAuthError.missingAuthorizationCode
+        }
+
+        return try await exchangeCodeForTokens(
+            code: code,
+            codeVerifier: codeVerifier,
+            redirectURI: redirectURI,
+            clientId: clientId,
+            clientSecret: clientSecret
+        )
+    }
+
+    private func exchangeCodeForTokens(code: String, codeVerifier: String, redirectURI: String, clientId: String, clientSecret: String) async throws -> GoogleOAuthTokens {
+        var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
+        request.httpMethod = "POST"
+        request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
+        request.httpBody = Self.formURLEncoded([
+            "client_id": clientId,
+            "client_secret": clientSecret,
+            "code": code,
+            "code_verifier": codeVerifier,
+            "redirect_uri": redirectURI,
+            "grant_type": "authorization_code"
+        ])
+
+        let (data, response) = try await URLSession.shared.data(for: request)
+        guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
+            let details = String(data: data, encoding: .utf8) ?? "HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1)"
+            throw GoogleOAuthError.tokenExchangeFailed(details)
+        }
+
+        struct TokenResponse: Decodable {
+            let access_token: String
+            let expires_in: Double
+            let refresh_token: String?
+            let scope: String?
+            let token_type: String?
+        }
+
+        let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
+        return GoogleOAuthTokens(
+            accessToken: decoded.access_token,
+            refreshToken: decoded.refresh_token,
+            expiresAt: Date().addingTimeInterval(decoded.expires_in),
+            scope: decoded.scope,
+            tokenType: decoded.token_type
+        )
+    }
+
+    private func refreshTokens(_ tokens: GoogleOAuthTokens) async throws -> GoogleOAuthTokens? {
+        guard let refreshToken = tokens.refreshToken else { return nil }
+        guard let clientId = configuredClientId() else { throw GoogleOAuthError.missingClientId }
+        guard let clientSecret = configuredClientSecret() else { throw GoogleOAuthError.missingClientSecret }
+
+        var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
+        request.httpMethod = "POST"
+        request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
+        request.httpBody = Self.formURLEncoded([
+            "client_id": clientId,
+            "client_secret": clientSecret,
+            "refresh_token": refreshToken,
+            "grant_type": "refresh_token"
+        ])
+
+        let (data, response) = try await URLSession.shared.data(for: request)
+        guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
+            return nil
+        }
+
+        struct RefreshResponse: Decodable {
+            let access_token: String
+            let expires_in: Double
+            let scope: String?
+            let token_type: String?
+        }
+
+        let decoded = try JSONDecoder().decode(RefreshResponse.self, from: data)
+        return GoogleOAuthTokens(
+            accessToken: decoded.access_token,
+            refreshToken: refreshToken,
+            expiresAt: Date().addingTimeInterval(decoded.expires_in),
+            scope: decoded.scope ?? tokens.scope,
+            tokenType: decoded.token_type ?? tokens.tokenType
+        )
+    }
+
+    // MARK: - Helpers
+
+    private static func pkceChallenge(for verifier: String) -> String {
+        let data = Data(verifier.utf8)
+        let digest = SHA256.hash(data: data)
+        return Data(digest).base64URLEncodedString()
+    }
+
+    private static func randomURLSafeString(length: Int) -> String {
+        var bytes = [UInt8](repeating: 0, count: length)
+        _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
+        return Data(bytes).base64URLEncodedString()
+    }
+
+    private static func formURLEncoded(_ params: [String: String]) -> Data {
+        let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
+        let pairs = params.map { key, value -> String in
+            let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
+            let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
+            return "\(k)=\(v)"
+        }
+        .joined(separator: "&")
+        return Data(pairs.utf8)
+    }
+}
+
+private extension Data {
+    func base64URLEncodedString() -> String {
+        let s = base64EncodedString()
+        return s
+            .replacingOccurrences(of: "+", with: "-")
+            .replacingOccurrences(of: "/", with: "_")
+            .replacingOccurrences(of: "=", with: "")
+    }
+}
+
+private final class OAuthLoopbackServer {
+    private let queue = DispatchQueue(label: "google.oauth.loopback.server")
+    private let listener: NWListener
+    private var readyContinuation: CheckedContinuation<Void, Error>?
+    private var callbackContinuation: CheckedContinuation<URL, Error>?
+    private var callbackURL: URL?
+
+    private init(listener: NWListener) {
+        self.listener = listener
+    }
+
+    static func start() async throws -> OAuthLoopbackServer {
+        let listener = try NWListener(using: .tcp, on: .any)
+        let server = OAuthLoopbackServer(listener: listener)
+        try await server.startListening()
+        return server
+    }
+
+    var redirectURI: String {
+        let port = listener.port?.rawValue ?? 0
+        return "http://127.0.0.1:\(port)/oauth2redirect"
+    }
+
+    func waitForCallback(timeoutSeconds: Double = 120) async throws -> URL {
+        try await withThrowingTaskGroup(of: URL.self) { group in
+            group.addTask { [weak self] in
+                guard let self else { throw GoogleOAuthError.invalidCallbackURL }
+                return try await self.awaitCallback()
+            }
+            group.addTask {
+                try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
+                throw GoogleOAuthError.authenticationTimedOut
+            }
+
+            let url = try await group.next()!
+            group.cancelAll()
+            return url
+        }
+    }
+
+    func stop() {
+        queue.async {
+            self.listener.cancel()
+            if let callbackContinuation = self.callbackContinuation {
+                self.callbackContinuation = nil
+                callbackContinuation.resume(throwing: GoogleOAuthError.authenticationTimedOut)
+            }
+        }
+    }
+
+    private func startListening() async throws {
+        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
+            queue.async {
+                self.readyContinuation = continuation
+                self.listener.stateUpdateHandler = { [weak self] state in
+                    guard let self else { return }
+                    switch state {
+                    case .ready:
+                        if let readyContinuation = self.readyContinuation {
+                            self.readyContinuation = nil
+                            readyContinuation.resume()
+                        }
+                    case .failed(let error):
+                        if let readyContinuation = self.readyContinuation {
+                            self.readyContinuation = nil
+                            readyContinuation.resume(throwing: error)
+                        }
+                    case .cancelled:
+                        if let readyContinuation = self.readyContinuation {
+                            self.readyContinuation = nil
+                            readyContinuation.resume(throwing: GoogleOAuthError.invalidCallbackURL)
+                        }
+                    default:
+                        break
+                    }
+                }
+                self.listener.newConnectionHandler = { [weak self] connection in
+                    self?.handle(connection: connection)
+                }
+                self.listener.start(queue: self.queue)
+            }
+        }
+    }
+
+    private func awaitCallback() async throws -> URL {
+        if let callbackURL { return callbackURL }
+        return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
+            queue.async {
+                if let callbackURL = self.callbackURL {
+                    continuation.resume(returning: callbackURL)
+                    return
+                }
+                self.callbackContinuation = continuation
+            }
+        }
+    }
+
+    private func handle(connection: NWConnection) {
+        connection.start(queue: queue)
+        connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { [weak self] data, _, _, _ in
+            guard let self else { return }
+            let requestLine = data
+                .flatMap { String(data: $0, encoding: .utf8) }?
+                .split(separator: "\r\n", omittingEmptySubsequences: false)
+                .first
+                .map(String.init)
+
+            var parsedURL: URL?
+            if let requestLine {
+                let parts = requestLine.split(separator: " ")
+                if parts.count >= 2 {
+                    let pathAndQuery = String(parts[1])
+                    parsedURL = URL(string: "http://127.0.0.1\(pathAndQuery)")
+                }
+            }
+
+            self.sendHTTPResponse(connection: connection, success: parsedURL != nil)
+
+            if let parsedURL {
+                self.callbackURL = parsedURL
+                if let continuation = self.callbackContinuation {
+                    self.callbackContinuation = nil
+                    continuation.resume(returning: parsedURL)
+                }
+                self.listener.cancel()
+            }
+            connection.cancel()
+        }
+    }
+
+    private func sendHTTPResponse(connection: NWConnection, success: Bool) {
+        let body = success
+            ? "<html><body><h3>Authentication complete</h3><p>You can return to the app.</p></body></html>"
+            : "<html><body><h3>Authentication failed</h3></body></html>"
+        let response = """
+        HTTP/1.1 200 OK\r
+        Content-Type: text/html; charset=utf-8\r
+        Content-Length: \(body.utf8.count)\r
+        Connection: close\r
+        \r
+        \(body)
+        """
+        connection.send(content: Data(response.utf8), completion: .contentProcessed { _ in })
+    }
+}
+
+extension GoogleOAuthError: LocalizedError {
+    var errorDescription: String? {
+        switch self {
+        case .missingClientId:
+            return "Missing Google OAuth Client ID."
+        case .missingClientSecret:
+            return "Missing Google OAuth Client Secret."
+        case .invalidCallbackURL:
+            return "Invalid OAuth callback URL."
+        case .missingAuthorizationCode:
+            return "Google did not return an authorization code."
+        case .tokenExchangeFailed(let details):
+            return "Token exchange failed: \(details)"
+        case .unableToOpenBrowser:
+            return "Could not open browser for Google sign-in."
+        case .authenticationTimedOut:
+            return "Google sign-in timed out."
+        case .noStoredTokens:
+            return "No stored Google tokens found."
+        }
+    }
+}
+

+ 72 - 0
meetings_app/Auth/KeychainTokenStore.swift

@@ -0,0 +1,72 @@
+import Foundation
+import Security
+
+enum KeychainTokenStoreError: Error {
+    case unexpectedStatus(OSStatus)
+    case missingData
+}
+
+final class KeychainTokenStore {
+    private let service: String
+    private let account: String
+
+    init(service: String = Bundle.main.bundleIdentifier ?? "meetings_app",
+         account: String = "googleOAuthTokens") {
+        self.service = service
+        self.account = account
+    }
+
+    func readTokens() throws -> GoogleOAuthTokens? {
+        let query: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: service,
+            kSecAttrAccount as String: account,
+            kSecMatchLimit as String: kSecMatchLimitOne,
+            kSecReturnData as String: true
+        ]
+
+        // Access group / accessibility can be set here if needed later.
+        var item: CFTypeRef?
+        let status = SecItemCopyMatching(query as CFDictionary, &item)
+        if status == errSecItemNotFound { return nil }
+        guard status == errSecSuccess else { throw KeychainTokenStoreError.unexpectedStatus(status) }
+        guard let data = item as? Data else { throw KeychainTokenStoreError.missingData }
+        return try JSONDecoder().decode(GoogleOAuthTokens.self, from: data)
+    }
+
+    func writeTokens(_ tokens: GoogleOAuthTokens) throws {
+        let data = try JSONEncoder().encode(tokens)
+
+        // Upsert.
+        let query: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: service,
+            kSecAttrAccount as String: account
+        ]
+
+        let attributes: [String: Any] = [
+            kSecValueData as String: data
+        ]
+
+        let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
+        if status == errSecSuccess { return }
+        if status != errSecItemNotFound { throw KeychainTokenStoreError.unexpectedStatus(status) }
+
+        var add = query
+        add[kSecValueData as String] = data
+        let addStatus = SecItemAdd(add as CFDictionary, nil)
+        guard addStatus == errSecSuccess else { throw KeychainTokenStoreError.unexpectedStatus(addStatus) }
+    }
+
+    func deleteTokens() throws {
+        let query: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: service,
+            kSecAttrAccount as String: account
+        ]
+        let status = SecItemDelete(query as CFDictionary)
+        if status == errSecSuccess || status == errSecItemNotFound { return }
+        throw KeychainTokenStoreError.unexpectedStatus(status)
+    }
+}
+

+ 203 - 0
meetings_app/Google/GoogleCalendarClient.swift

@@ -0,0 +1,203 @@
+import Foundation
+
+enum GoogleCalendarClientError: Error {
+    case invalidResponse
+    case httpStatus(Int, String)
+    case decodeFailed(String)
+}
+
+final class GoogleCalendarClient {
+    struct Options: Sendable {
+        var daysAhead: Int
+        var maxResults: Int
+        var includeNonMeetEvents: Bool
+
+        init(daysAhead: Int = 7, maxResults: Int = 25, includeNonMeetEvents: Bool = false) {
+            self.daysAhead = daysAhead
+            self.maxResults = maxResults
+            self.includeNonMeetEvents = includeNonMeetEvents
+        }
+    }
+
+    private let session: URLSession
+
+    init(session: URLSession = .shared) {
+        self.session = session
+    }
+
+    func fetchUpcomingMeetings(accessToken: String) async throws -> [ScheduledMeeting] {
+        try await fetchUpcomingMeetings(accessToken: accessToken, options: Options())
+    }
+
+    func fetchUpcomingMeetings(accessToken: String, options: Options) async throws -> [ScheduledMeeting] {
+        let now = Date()
+        let end = Calendar.current.date(byAdding: .day, value: max(1, options.daysAhead), to: now) ?? now.addingTimeInterval(7 * 24 * 60 * 60)
+
+        var components = URLComponents(string: "https://www.googleapis.com/calendar/v3/calendars/primary/events")!
+        let formatter = ISO8601DateFormatter()
+        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+
+        components.queryItems = [
+            URLQueryItem(name: "timeMin", value: formatter.string(from: now)),
+            URLQueryItem(name: "timeMax", value: formatter.string(from: end)),
+            URLQueryItem(name: "singleEvents", value: "true"),
+            URLQueryItem(name: "orderBy", value: "startTime"),
+            URLQueryItem(name: "maxResults", value: String(options.maxResults)),
+            URLQueryItem(name: "conferenceDataVersion", value: "1")
+        ]
+
+        var request = URLRequest(url: components.url!)
+        request.httpMethod = "GET"
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+
+        let (data, response) = try await session.data(for: request)
+        guard let http = response as? HTTPURLResponse else { throw GoogleCalendarClientError.invalidResponse }
+        guard (200..<300).contains(http.statusCode) else {
+            let body = String(data: data, encoding: .utf8) ?? "<no body>"
+            throw GoogleCalendarClientError.httpStatus(http.statusCode, body)
+        }
+
+        let decoded: EventsList
+        do {
+            decoded = try JSONDecoder().decode(EventsList.self, from: data)
+        } catch {
+            let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
+            throw GoogleCalendarClientError.decodeFailed(raw)
+        }
+
+        let meetings: [ScheduledMeeting] = decoded.items.compactMap { item in
+            let title = item.summary?.trimmingCharacters(in: .whitespacesAndNewlines)
+            let subtitle = item.organizer?.displayName ?? item.organizer?.email
+
+            guard let start = item.start?.resolvedDate,
+                  let end = item.end?.resolvedDate else { return nil }
+
+            let isAllDay = item.start?.date != nil
+
+            let meetURL: URL? = {
+                if let hangout = item.hangoutLink, let u = URL(string: hangout) { return u }
+                let entry = item.conferenceData?.entryPoints?.first(where: { $0.entryPointType == "video" && ($0.uri?.contains("meet.google.com") ?? false) })
+                if let uri = entry?.uri, let u = URL(string: uri) { return u }
+                return nil
+            }()
+
+            if meetURL == nil, options.includeNonMeetEvents == false { return nil }
+            guard let meetURL else { return nil }
+
+            return ScheduledMeeting(
+                id: item.id ?? UUID().uuidString,
+                title: (title?.isEmpty == false) ? title! : "Untitled meeting",
+                subtitle: subtitle,
+                startDate: start,
+                endDate: end,
+                meetURL: meetURL,
+                isAllDay: isAllDay
+            )
+        }
+
+        return meetings
+    }
+}
+
+extension GoogleCalendarClientError: LocalizedError {
+    var errorDescription: String? {
+        switch self {
+        case .invalidResponse:
+            return "Google Calendar returned an invalid response."
+        case let .httpStatus(status, body):
+            return "Google Calendar API error (\(status)): \(body)"
+        case let .decodeFailed(raw):
+            return "Failed to parse Google Calendar events: \(raw)"
+        }
+    }
+}
+
+// MARK: - Calendar API models
+
+private struct EventsList: Decodable {
+    let items: [EventItem]
+}
+
+private struct EventItem: Decodable {
+    let id: String?
+    let summary: String?
+    let hangoutLink: String?
+    let organizer: Organizer?
+    let start: EventDateTime?
+    let end: EventDateTime?
+    let conferenceData: ConferenceData?
+}
+
+private struct Organizer: Decodable {
+    let displayName: String?
+    let email: String?
+}
+
+private struct EventDateTime: Decodable {
+    let dateTime: String?
+    let date: String?
+    let timeZone: String?
+
+    var resolvedDate: Date? {
+        if let dateTime, let parsed = Self.parseDateTime(dateTime, timeZone: timeZone) {
+            return parsed
+        }
+        if let date, let parsed = DateFormatter.googleAllDay.date(from: date) {
+            return parsed
+        }
+        return nil
+    }
+
+    private static func parseDateTime(_ raw: String, timeZone: String?) -> Date? {
+        if let dt = ISO8601DateFormatter.fractional.date(from: raw) ?? ISO8601DateFormatter.nonFractional.date(from: raw) {
+            return dt
+        }
+
+        // Some Calendar payloads provide dateTime without explicit zone and separate timeZone field.
+        let formatter = DateFormatter()
+        formatter.calendar = Calendar(identifier: .gregorian)
+        formatter.locale = Locale(identifier: "en_US_POSIX")
+        formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
+        if let timeZone, let tz = TimeZone(identifier: timeZone) {
+            formatter.timeZone = tz
+        } else {
+            formatter.timeZone = TimeZone.current
+        }
+        return formatter.date(from: raw)
+    }
+}
+
+private struct ConferenceData: Decodable {
+    let entryPoints: [EntryPoint]?
+}
+
+private struct EntryPoint: Decodable {
+    let entryPointType: String?
+    let uri: String?
+}
+
+private extension ISO8601DateFormatter {
+    static let fractional: ISO8601DateFormatter = {
+        let f = ISO8601DateFormatter()
+        f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+        return f
+    }()
+
+    static let nonFractional: ISO8601DateFormatter = {
+        let f = ISO8601DateFormatter()
+        f.formatOptions = [.withInternetDateTime]
+        return f
+    }()
+}
+
+private extension DateFormatter {
+    static let googleAllDay: DateFormatter = {
+        let f = DateFormatter()
+        f.calendar = Calendar(identifier: .gregorian)
+        f.locale = Locale(identifier: "en_US_POSIX")
+        f.timeZone = TimeZone(secondsFromGMT: 0)
+        f.dateFormat = "yyyy-MM-dd"
+        return f
+    }()
+}
+

+ 50 - 0
meetings_app/Google/GoogleMeetClient.swift

@@ -0,0 +1,50 @@
+import Foundation
+
+enum GoogleMeetClientError: Error {
+    case invalidResponse
+    case httpStatus(Int)
+}
+
+/// Thin Meet REST API wrapper.
+/// Note: Meet REST API is best used for conferences/participants/artifacts, while scheduling comes from Calendar.
+final class GoogleMeetClient {
+    private let session: URLSession
+
+    init(session: URLSession = .shared) {
+        self.session = session
+    }
+
+    /// Lists conference records for a given meeting space resource name.
+    /// This is intentionally minimal scaffolding for phase 2 enrichment.
+    func listConferenceRecords(accessToken: String, spaceResourceName: String, pageSize: Int = 10) async throws -> [ConferenceRecord] {
+        var components = URLComponents(string: "https://meet.googleapis.com/v2/\(spaceResourceName)/conferenceRecords")!
+        components.queryItems = [
+            URLQueryItem(name: "pageSize", value: String(pageSize))
+        ]
+
+        var request = URLRequest(url: components.url!)
+        request.httpMethod = "GET"
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+
+        let (data, response) = try await session.data(for: request)
+        guard let http = response as? HTTPURLResponse else { throw GoogleMeetClientError.invalidResponse }
+        guard (200..<300).contains(http.statusCode) else { throw GoogleMeetClientError.httpStatus(http.statusCode) }
+
+        let decoded = try JSONDecoder().decode(ListConferenceRecordsResponse.self, from: data)
+        return decoded.conferenceRecords ?? []
+    }
+}
+
+// MARK: - Minimal models (v2)
+
+struct ConferenceRecord: Decodable, Equatable {
+    let name: String?
+    let startTime: Date?
+    let endTime: Date?
+}
+
+private struct ListConferenceRecordsResponse: Decodable {
+    let conferenceRecords: [ConferenceRecord]?
+    let nextPageToken: String?
+}
+

+ 12 - 0
meetings_app/Models/ScheduledMeeting.swift

@@ -0,0 +1,12 @@
+import Foundation
+
+struct ScheduledMeeting: Identifiable, Equatable {
+    let id: String
+    let title: String
+    let subtitle: String?
+    let startDate: Date
+    let endDate: Date
+    let meetURL: URL
+    let isAllDay: Bool
+}
+

+ 361 - 19
meetings_app/ViewController.swift

@@ -7,6 +7,7 @@
 
 import Cocoa
 import WebKit
+import AuthenticationServices
 
 private enum SidebarPage: Int {
     case joinMeetings = 0
@@ -61,6 +62,19 @@ final class ViewController: NSViewController {
     private weak var meetLinkField: NSTextField?
     private weak var browseAddressField: NSTextField?
     private var inAppBrowserWindowController: InAppBrowserWindowController?
+    private let googleOAuth = GoogleOAuthService.shared
+    private let calendarClient = GoogleCalendarClient()
+
+    private enum ScheduleFilter: Int {
+        case all = 0
+        case today = 1
+        case week = 2
+    }
+
+    private var scheduleFilter: ScheduleFilter = .all
+    private weak var scheduleDateHeadingLabel: NSTextField?
+    private weak var scheduleCardsStack: NSStackView?
+    private weak var scheduleFilterLabel: NSTextField?
 
     /// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
     private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
@@ -909,8 +923,13 @@ private extension ViewController {
         contentStack.addArrangedSubview(joinActions)
         contentStack.setCustomSpacing(26, after: joinActions)
         contentStack.addArrangedSubview(scheduleHeader())
-        contentStack.addArrangedSubview(textLabel("Tuesday, 14 Apr", font: typography.dateHeading, color: palette.textSecondary))
-        contentStack.addArrangedSubview(scheduleCardsRow())
+
+        let dateHeading = textLabel(scheduleInitialHeadingText(), font: typography.dateHeading, color: palette.textSecondary)
+        scheduleDateHeadingLabel = dateHeading
+        contentStack.addArrangedSubview(dateHeading)
+
+        let cardsRow = scheduleCardsRow(meetings: [])
+        contentStack.addArrangedSubview(cardsRow)
 
         panel.addSubview(contentStack)
 
@@ -920,6 +939,10 @@ private extension ViewController {
             contentStack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 26)
         ])
 
+        Task { [weak self] in
+            await self?.loadSchedule()
+        }
+
         return panel
     }
 
@@ -1777,15 +1800,25 @@ private extension ViewController {
         row.addArrangedSubview(spacer)
         spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
 
-        row.addArrangedSubview(iconRoundButton("?", size: 34))
-        row.addArrangedSubview(iconRoundButton("⟳", size: 34))
+        row.addArrangedSubview(iconRoundButton("?", size: 34, onClick: { [weak self] in
+            self?.showScheduleHelp()
+        }))
+        row.addArrangedSubview(iconRoundButton("⟳", size: 34, onClick: { [weak self] in
+            self?.scheduleReloadClicked()
+        }))
+
+        let connectButton = makeSchedulePillButton(title: googleOAuth.loadTokens() == nil ? "Connect" : "Connected")
+        connectButton.target = self
+        connectButton.action = #selector(scheduleConnectButtonPressed(_:))
+        row.addArrangedSubview(connectButton)
 
         let filter = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
         filter.translatesAutoresizingMaskIntoConstraints = false
         filter.widthAnchor.constraint(equalToConstant: 156).isActive = true
         filter.heightAnchor.constraint(equalToConstant: 34).isActive = true
         styleSurface(filter, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
-        let filterText = textLabel("All", font: typography.filterText, color: palette.textSecondary)
+        let filterText = textLabel(scheduleFilterTitle(scheduleFilter), font: typography.filterText, color: palette.textSecondary)
+        scheduleFilterLabel = filterText
         let arrow = textLabel("▾", font: typography.filterArrow, color: palette.textMuted)
         filterText.translatesAutoresizingMaskIntoConstraints = false
         arrow.translatesAutoresizingMaskIntoConstraints = false
@@ -1799,12 +1832,56 @@ private extension ViewController {
             arrow.centerYAnchor.constraint(equalTo: filter.centerYAnchor)
         ])
 
-        row.addArrangedSubview(filter)
+        let filterHit = HoverTrackingView()
+        filterHit.translatesAutoresizingMaskIntoConstraints = false
+        filterHit.addSubview(filter)
+        NSLayoutConstraint.activate([
+            filterHit.widthAnchor.constraint(equalToConstant: 156),
+            filterHit.heightAnchor.constraint(equalToConstant: 34),
+            filter.leadingAnchor.constraint(equalTo: filterHit.leadingAnchor),
+            filter.trailingAnchor.constraint(equalTo: filterHit.trailingAnchor),
+            filter.topAnchor.constraint(equalTo: filterHit.topAnchor),
+            filter.bottomAnchor.constraint(equalTo: filterHit.bottomAnchor)
+        ])
+        filterHit.onClick = { [weak self, weak filterHit] in
+            guard let self, let anchor = filterHit else { return }
+            self.showScheduleFilterMenu(anchor: anchor)
+        }
+
+        row.addArrangedSubview(filterHit)
         row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
         return row
     }
 
-    func scheduleCardsRow() -> NSView {
+    private func makeSchedulePillButton(title: String) -> NSButton {
+        let button = NSButton(title: title, target: nil, action: nil)
+        button.translatesAutoresizingMaskIntoConstraints = false
+        button.isBordered = false
+        button.bezelStyle = .regularSquare
+        button.wantsLayer = true
+        button.layer?.cornerRadius = 8
+        button.layer?.backgroundColor = palette.inputBackground.cgColor
+        button.layer?.borderColor = palette.inputBorder.cgColor
+        button.layer?.borderWidth = 1
+        button.font = typography.filterText
+        button.contentTintColor = palette.textSecondary
+        button.setButtonType(.momentaryChange)
+        button.heightAnchor.constraint(equalToConstant: 34).isActive = true
+        button.widthAnchor.constraint(greaterThanOrEqualToConstant: 104).isActive = true
+        return button
+    }
+
+    func scheduleCardsRow(meetings: [ScheduledMeeting]) -> NSView {
+        let scroll = NSScrollView()
+        scroll.translatesAutoresizingMaskIntoConstraints = false
+        scroll.drawsBackground = false
+        scroll.hasHorizontalScroller = true
+        scroll.hasVerticalScroller = false
+        scroll.horizontalScrollElasticity = .allowed
+        scroll.verticalScrollElasticity = .none
+        scroll.autohidesScrollers = true
+        scroll.borderType = .noBorder
+
         let row = NSStackView()
         row.translatesAutoresizingMaskIntoConstraints = false
         row.orientation = .horizontal
@@ -1813,13 +1890,25 @@ private extension ViewController {
         row.distribution = .fill
         row.setContentHuggingPriority(.defaultHigh, for: .horizontal)
         row.heightAnchor.constraint(equalToConstant: 136).isActive = true
+        scheduleCardsStack = row
 
-        row.addArrangedSubview(scheduleCard())
-        row.addArrangedSubview(scheduleCard())
-        return row
+        scroll.documentView = row
+        scroll.contentView.postsBoundsChangedNotifications = true
+
+        // Ensure the stack view determines content size for horizontal scrolling.
+        NSLayoutConstraint.activate([
+            row.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
+            row.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
+            row.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
+            row.bottomAnchor.constraint(equalTo: scroll.contentView.bottomAnchor),
+            row.heightAnchor.constraint(equalToConstant: 136)
+        ])
+
+        renderScheduleCards(into: row, meetings: meetings)
+        return scroll
     }
 
-    func scheduleCard() -> NSView {
+    func scheduleCard(meeting: ScheduledMeeting) -> NSView {
         let cardWidth: CGFloat = 264
 
         let card = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
@@ -1840,9 +1929,9 @@ private extension ViewController {
             iconText.centerYAnchor.constraint(equalTo: icon.centerYAnchor)
         ])
 
-        let title = textLabel("General Meeting", font: typography.cardTitle, color: palette.textPrimary)
-        let subtitle = textLabel("Baisakhi", font: typography.cardSubtitle, color: palette.textPrimary)
-        let time = textLabel("12:00 AM - 11:59 PM", font: typography.cardTime, color: palette.textSecondary)
+        let title = textLabel(meeting.title, font: typography.cardTitle, color: palette.textPrimary)
+        let subtitle = textLabel(meeting.subtitle ?? "Google Calendar", font: typography.cardSubtitle, color: palette.textPrimary)
+        let time = textLabel(scheduleTimeText(for: meeting), font: typography.cardTime, color: palette.textSecondary)
 
         card.addSubview(icon)
         card.addSubview(title)
@@ -1864,7 +1953,28 @@ private extension ViewController {
             time.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 4)
         ])
 
-        return card
+        let hit = HoverTrackingView()
+        hit.translatesAutoresizingMaskIntoConstraints = false
+        hit.addSubview(card)
+        NSLayoutConstraint.activate([
+            card.leadingAnchor.constraint(equalTo: hit.leadingAnchor),
+            card.trailingAnchor.constraint(equalTo: hit.trailingAnchor),
+            card.topAnchor.constraint(equalTo: hit.topAnchor),
+            card.bottomAnchor.constraint(equalTo: hit.bottomAnchor)
+        ])
+        hit.onClick = { [weak self] in
+            self?.openMeetingURL(meeting.meetURL)
+        }
+        hit.onHoverChanged = { [weak self] hovering in
+            guard let self else { return }
+            let base = self.palette.sectionCard
+            let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black
+            let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
+            card.layer?.backgroundColor = (hovering ? hover : base).cgColor
+        }
+        hit.onHoverChanged?(false)
+
+        return hit
     }
 }
 
@@ -2375,7 +2485,7 @@ private extension ViewController {
         return button
     }
 
-    func iconRoundButton(_ symbol: String, size: CGFloat) -> NSView {
+    func iconRoundButton(_ symbol: String, size: CGFloat, onClick: (() -> Void)? = nil) -> NSView {
         let button = HoverTrackingView()
         button.wantsLayer = true
         button.layer?.cornerRadius = size / 2
@@ -2399,11 +2509,246 @@ private extension ViewController {
             button.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
         }
         button.onHoverChanged?(false)
+        button.onClick = onClick
 
         return button
     }
 }
 
+// MARK: - Schedule actions (OAuth entry)
+
+private extension ViewController {
+    @objc func scheduleConnectButtonPressed(_ sender: NSButton) {
+        scheduleConnectClicked()
+    }
+
+    private func scheduleInitialHeadingText() -> String {
+        googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "Loading…"
+    }
+
+    private func scheduleFilterTitle(_ filter: ScheduleFilter) -> String {
+        switch filter {
+        case .all: return "All"
+        case .today: return "Today"
+        case .week: return "This week"
+        }
+    }
+
+    private func showScheduleFilterMenu(anchor: NSView) {
+        let menu = NSMenu()
+        let items: [(ScheduleFilter, String)] = [
+            (.all, "All"),
+            (.today, "Today"),
+            (.week, "This week")
+        ]
+        for (filter, title) in items {
+            let item = NSMenuItem(title: title, action: #selector(scheduleFilterSelected(_:)), keyEquivalent: "")
+            item.target = self
+            item.tag = filter.rawValue
+            item.state = (filter == scheduleFilter) ? .on : .off
+            menu.addItem(item)
+        }
+        let loc = NSPoint(x: 8, y: anchor.bounds.height + 2)
+        menu.popUp(positioning: nil, at: loc, in: anchor)
+    }
+
+    @objc func scheduleFilterSelected(_ sender: NSMenuItem) {
+        guard let filter = ScheduleFilter(rawValue: sender.tag) else { return }
+        scheduleFilter = filter
+        scheduleFilterLabel?.stringValue = scheduleFilterTitle(filter)
+        Task { [weak self] in
+            await self?.loadSchedule()
+        }
+    }
+
+    private func scheduleTimeText(for meeting: ScheduledMeeting) -> String {
+        if meeting.isAllDay { return "All day" }
+        let f = DateFormatter()
+        f.locale = Locale.current
+        f.timeZone = TimeZone.current
+        f.dateStyle = .none
+        f.timeStyle = .short
+        return "\(f.string(from: meeting.startDate)) - \(f.string(from: meeting.endDate))"
+    }
+
+    private func scheduleHeadingText(for meetings: [ScheduledMeeting]) -> String {
+        guard let first = meetings.first else {
+            return googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "No upcoming meetings"
+        }
+
+        let day = Calendar.current.startOfDay(for: first.startDate)
+        let f = DateFormatter()
+        f.locale = Locale.current
+        f.timeZone = TimeZone.current
+        f.dateFormat = "EEEE, d MMM"
+        return f.string(from: day)
+    }
+
+    private func openMeetingURL(_ url: URL) {
+        NSWorkspace.shared.open(url)
+    }
+
+    private func renderScheduleCards(into stack: NSStackView, meetings: [ScheduledMeeting]) {
+        stack.arrangedSubviews.forEach { v in
+            stack.removeArrangedSubview(v)
+            v.removeFromSuperview()
+        }
+
+        if meetings.isEmpty {
+            let empty = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
+            empty.translatesAutoresizingMaskIntoConstraints = false
+            empty.widthAnchor.constraint(equalToConstant: 264).isActive = true
+            empty.heightAnchor.constraint(equalToConstant: 136).isActive = true
+            styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
+
+            let label = textLabel(googleOAuth.loadTokens() == nil ? "Connect to load schedule" : "No meetings", font: typography.cardSubtitle, color: palette.textSecondary)
+            label.translatesAutoresizingMaskIntoConstraints = false
+            empty.addSubview(label)
+            NSLayoutConstraint.activate([
+                label.centerXAnchor.constraint(equalTo: empty.centerXAnchor),
+                label.centerYAnchor.constraint(equalTo: empty.centerYAnchor)
+            ])
+            stack.addArrangedSubview(empty)
+            return
+        }
+
+        for meeting in meetings {
+            stack.addArrangedSubview(scheduleCard(meeting: meeting))
+        }
+    }
+
+    private func filteredMeetings(_ meetings: [ScheduledMeeting]) -> [ScheduledMeeting] {
+        switch scheduleFilter {
+        case .all:
+            return meetings
+        case .today:
+            let start = Calendar.current.startOfDay(for: Date())
+            let end = Calendar.current.date(byAdding: .day, value: 1, to: start) ?? start.addingTimeInterval(86400)
+            return meetings.filter { $0.startDate >= start && $0.startDate < end }
+        case .week:
+            let now = Date()
+            let end = Calendar.current.date(byAdding: .day, value: 7, to: now) ?? now.addingTimeInterval(7 * 86400)
+            return meetings.filter { $0.startDate >= now && $0.startDate <= end }
+        }
+    }
+
+    private func loadSchedule() async {
+        do {
+            if googleOAuth.loadTokens() == nil {
+                await MainActor.run {
+                    scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
+                    if let stack = scheduleCardsStack {
+                        renderScheduleCards(into: stack, meetings: [])
+                    }
+                }
+                return
+            }
+
+            let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
+            let meetings = try await calendarClient.fetchUpcomingMeetings(accessToken: token)
+            let filtered = filteredMeetings(meetings)
+
+            await MainActor.run {
+                scheduleDateHeadingLabel?.stringValue = scheduleHeadingText(for: filtered)
+                if let stack = scheduleCardsStack {
+                    renderScheduleCards(into: stack, meetings: filtered)
+                }
+            }
+        } catch {
+            await MainActor.run {
+                scheduleDateHeadingLabel?.stringValue = "Couldn’t load schedule"
+                if let stack = scheduleCardsStack {
+                    renderScheduleCards(into: stack, meetings: [])
+                }
+                showSimpleError("Couldn’t load schedule.", error: error)
+            }
+        }
+    }
+
+    func showScheduleHelp() {
+        let alert = NSAlert()
+        alert.messageText = "Google schedule"
+        alert.informativeText = "To show scheduled meetings, connect your Google account. You’ll need a Google OAuth client ID (Desktop) and a redirect URI matching the app’s callback scheme."
+        alert.addButton(withTitle: "OK")
+        alert.runModal()
+    }
+
+    func scheduleReloadClicked() {
+        // Data loading is wired in the Calendar step.
+        // For now, this triggers a sign-in if needed so the next step can fetch events.
+        Task { [weak self] in
+            guard let self else { return }
+            _ = try? await googleOAuth.validAccessToken(presentingWindow: view.window)
+        }
+    }
+
+    func scheduleConnectClicked() {
+        Task { [weak self] in
+            guard let self else { return }
+            do {
+                try await ensureGoogleClientIdConfigured(presentingWindow: view.window)
+                _ = try await googleOAuth.validAccessToken(presentingWindow: view.window)
+                pageCache[.joinMeetings] = nil
+                showSidebarPage(.joinMeetings)
+            } catch {
+                showSimpleError("Couldn’t connect Google account.", error: error)
+            }
+        }
+    }
+
+    @MainActor
+    func ensureGoogleClientIdConfigured(presentingWindow: NSWindow?) async throws {
+        if googleOAuth.configuredClientId() != nil, googleOAuth.configuredClientSecret() != nil { return }
+
+        let alert = NSAlert()
+        alert.messageText = "Enter Google OAuth credentials"
+        alert.informativeText = "Paste the OAuth Client ID and Client Secret from your downloaded Desktop OAuth JSON."
+
+        let accessory = NSStackView()
+        accessory.orientation = .vertical
+        accessory.spacing = 8
+        accessory.alignment = .leading
+
+        let idField = NSTextField(string: googleOAuth.configuredClientId() ?? "")
+        idField.placeholderString = "Client ID (....apps.googleusercontent.com)"
+        idField.frame = NSRect(x: 0, y: 0, width: 460, height: 24)
+
+        let secretField = NSSecureTextField(string: googleOAuth.configuredClientSecret() ?? "")
+        secretField.placeholderString = "Client Secret (GOCSPX-...)"
+        secretField.frame = NSRect(x: 0, y: 0, width: 460, height: 24)
+
+        accessory.addArrangedSubview(idField)
+        accessory.addArrangedSubview(secretField)
+        alert.accessoryView = accessory
+
+        alert.addButton(withTitle: "Save")
+        alert.addButton(withTitle: "Cancel")
+
+        // Keep this synchronous to avoid additional sheet state handling.
+        let response = alert.runModal()
+        if response != .alertFirstButtonReturn { throw GoogleOAuthError.missingClientId }
+
+        let idValue = idField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
+        let secretValue = secretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
+        if idValue.isEmpty { throw GoogleOAuthError.missingClientId }
+        if secretValue.isEmpty { throw GoogleOAuthError.missingClientSecret }
+
+        googleOAuth.setClientIdForTesting(idValue)
+        googleOAuth.setClientSecretForTesting(secretValue)
+    }
+
+    func showSimpleError(_ title: String, error: Error) {
+        DispatchQueue.main.async {
+            let alert = NSAlert()
+            alert.alertStyle = .warning
+            alert.messageText = title
+            alert.informativeText = error.localizedDescription
+            alert.addButton(withTitle: "OK")
+            alert.runModal()
+        }
+    }
+}
+
 private struct Palette {
     let pageBackground: NSColor
     let sidebarBackground: NSColor
@@ -2514,11 +2859,8 @@ private func inAppBrowserURLAllowed(_ url: URL, policy: InAppBrowserURLPolicy) -
 }
 
 private enum InAppBrowserWebKitSupport {
-    static let sharedProcessPool = WKProcessPool()
-
     static func makeWebViewConfiguration() -> WKWebViewConfiguration {
         let config = WKWebViewConfiguration()
-        config.processPool = sharedProcessPool
         config.websiteDataStore = .default()
         config.preferences.javaScriptCanOpenWindowsAutomatically = true
         if #available(macOS 12.3, *) {

+ 2 - 0
meetings_app/meetings_app.entitlements

@@ -6,6 +6,8 @@
 	<true/>
 	<key>com.apple.security.network.client</key>
 	<true/>
+	<key>com.apple.security.network.server</key>
+	<true/>
 	<key>com.apple.security.device.camera</key>
 	<true/>
 	<key>com.apple.security.device.audio-input</key>