Przeglądaj źródła

Merge branch 'meating-schdule-task'

huzaifahayat12 3 miesięcy temu
rodzic
commit
94368ddf7f

+ 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",

+ 27 - 0
meetings_app/Assets.xcassets/GoogleGLogo.imageset/Contents.json

@@ -0,0 +1,27 @@
+{
+  "images" : [
+    {
+      "filename" : "google_g.png",
+      "idiom" : "universal",
+      "scale" : "1x"
+    },
+    {
+      "filename" : "google_g@2x.png",
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "filename" : "google_g@3x.png",
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  },
+  "properties" : {
+    "preserves-vector-representation" : false,
+    "template-rendering-intent" : "original"
+  }
+}

BIN
meetings_app/Assets.xcassets/GoogleGLogo.imageset/google_g.png


BIN
meetings_app/Assets.xcassets/GoogleGLogo.imageset/google_g@2x.png


BIN
meetings_app/Assets.xcassets/GoogleGLogo.imageset/google_g@3x.png


+ 440 - 0
meetings_app/Auth/GoogleOAuthService.swift

@@ -0,0 +1,440 @@
+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?
+}
+
+struct GoogleUserProfile: Codable, Equatable {
+    var name: String?
+    var email: String?
+    var picture: 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 is needed for schedule. Profile/email make login feel complete in-app.
+    private let scopes = [
+        "openid",
+        "email",
+        "profile",
+        "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 fetchUserProfile(accessToken: String) async throws -> GoogleUserProfile {
+        var request = URLRequest(url: URL(string: "https://openidconnect.googleapis.com/v1/userinfo")!)
+        request.httpMethod = "GET"
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+
+        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)
+        }
+        return try JSONDecoder().decode(GoogleUserProfile.self, from: data)
+    }
+
+    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."
+        }
+    }
+}
+

+ 30 - 0
meetings_app/Auth/KeychainTokenStore.swift

@@ -0,0 +1,30 @@
+import Foundation
+
+/// Keeps the existing API surface while storing OAuth tokens in UserDefaults.
+/// This avoids macOS keychain unlock prompts during development/test runs.
+final class KeychainTokenStore {
+    private let defaultsKey: String
+    private let defaults: UserDefaults
+
+    init(service: String = Bundle.main.bundleIdentifier ?? "meetings_app",
+         account: String = "googleOAuthTokens",
+         defaults: UserDefaults = .standard) {
+        self.defaultsKey = "\(service).\(account)"
+        self.defaults = defaults
+    }
+
+    func readTokens() throws -> GoogleOAuthTokens? {
+        guard let data = defaults.data(forKey: defaultsKey) else { return nil }
+        return try JSONDecoder().decode(GoogleOAuthTokens.self, from: data)
+    }
+
+    func writeTokens(_ tokens: GoogleOAuthTokens) throws {
+        let data = try JSONEncoder().encode(tokens)
+        defaults.set(data, forKey: defaultsKey)
+    }
+
+    func deleteTokens() throws {
+        defaults.removeObject(forKey: defaultsKey)
+    }
+}
+

+ 221 - 0
meetings_app/Google/GoogleCalendarClient.swift

@@ -0,0 +1,221 @@
+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 = 180, maxResults: Int = 200, includeNonMeetEvents: Bool = true) {
+            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(180 * 24 * 60 * 60)
+        let formatter = ISO8601DateFormatter()
+        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+        let totalLimit = max(1, options.maxResults)
+        let pageSize = min(250, totalLimit)
+        var nextPageToken: String?
+        var meetings: [ScheduledMeeting] = []
+
+        repeat {
+            var components = URLComponents(string: "https://www.googleapis.com/calendar/v3/calendars/primary/events")!
+            var 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(pageSize)),
+                URLQueryItem(name: "conferenceDataVersion", value: "1")
+            ]
+            if let nextPageToken {
+                queryItems.append(URLQueryItem(name: "pageToken", value: nextPageToken))
+            }
+            components.queryItems = queryItems
+
+            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 pageMeetings: [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 }
+                if options.includeNonMeetEvents, let htmlLink = item.htmlLink, let u = URL(string: htmlLink) { 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
+            )
+            }
+
+            meetings.append(contentsOf: pageMeetings)
+            nextPageToken = decoded.nextPageToken
+        } while nextPageToken != nil && meetings.count < totalLimit
+
+        if meetings.count > totalLimit {
+            meetings = Array(meetings.prefix(totalLimit))
+        }
+        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]
+    let nextPageToken: String?
+}
+
+private struct EventItem: Decodable {
+    let id: String?
+    let summary: String?
+    let hangoutLink: String?
+    let htmlLink: 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
+}
+

+ 769 - 57
meetings_app/ViewController.swift

@@ -7,6 +7,7 @@
 
 import Cocoa
 import WebKit
+import AuthenticationServices
 
 private enum SidebarPage: Int {
     case joinMeetings = 0
@@ -37,6 +38,12 @@ private enum PremiumPlan: Int {
 }
 
 final class ViewController: NSViewController {
+    private struct GoogleProfileDisplay {
+        let name: String
+        let email: String
+        let pictureURL: URL?
+    }
+
     private var palette = Palette(isDarkMode: true)
     private let typography = Typography()
     private let launchContentSize = NSSize(width: 920, height: 690)
@@ -61,6 +68,27 @@ 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 scheduleCardsScrollView: NSScrollView?
+    private weak var scheduleScrollLeftButton: NSView?
+    private weak var scheduleScrollRightButton: NSView?
+    private weak var scheduleFilterDropdown: NSPopUpButton?
+    private weak var scheduleGoogleAuthButton: NSButton?
+    private var scheduleGoogleAuthButtonWidthConstraint: NSLayoutConstraint?
+    private var scheduleGoogleAuthHovering = false
+    private var scheduleCurrentProfile: GoogleProfileDisplay?
+    private var scheduleProfileImageTask: Task<Void, Never>?
 
     /// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
     private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
@@ -903,14 +931,24 @@ private extension ViewController {
         contentStack.spacing = 14
         contentStack.alignment = .leading
 
+        contentStack.addArrangedSubview(scheduleTopAuthRow())
+        if let authRow = contentStack.arrangedSubviews.last {
+            contentStack.setCustomSpacing(20, after: authRow)
+        }
+
         let joinActions = meetJoinActionsRow()
         contentStack.addArrangedSubview(textLabel("Join Meetings", font: typography.pageTitle, color: palette.textPrimary))
         contentStack.addArrangedSubview(meetJoinSectionRow())
         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 +958,10 @@ private extension ViewController {
             contentStack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 26)
         ])
 
+        Task { [weak self] in
+            await self?.loadSchedule()
+        }
+
         return panel
     }
 
@@ -1777,94 +1819,317 @@ private extension ViewController {
         row.addArrangedSubview(spacer)
         spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
 
-        row.addArrangedSubview(iconRoundButton("?", size: 34))
-        row.addArrangedSubview(iconRoundButton("⟳", size: 34))
-
-        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 arrow = textLabel("▾", font: typography.filterArrow, color: palette.textMuted)
-        filterText.translatesAutoresizingMaskIntoConstraints = false
-        arrow.translatesAutoresizingMaskIntoConstraints = false
-        filter.addSubview(filterText)
-        filter.addSubview(arrow)
+        row.addArrangedSubview(makeScheduleRefreshButton())
 
-        NSLayoutConstraint.activate([
-            filterText.leadingAnchor.constraint(equalTo: filter.leadingAnchor, constant: 12),
-            filterText.centerYAnchor.constraint(equalTo: filter.centerYAnchor),
-            arrow.trailingAnchor.constraint(equalTo: filter.trailingAnchor, constant: -10),
-            arrow.centerYAnchor.constraint(equalTo: filter.centerYAnchor)
-        ])
-
-        row.addArrangedSubview(filter)
+        row.addArrangedSubview(makeScheduleFilterDropdown())
         row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
         return row
     }
 
-    func scheduleCardsRow() -> NSView {
+    private func scheduleTopAuthRow() -> NSView {
         let row = NSStackView()
         row.translatesAutoresizingMaskIntoConstraints = false
         row.orientation = .horizontal
+        row.alignment = .centerY
         row.spacing = 10
+
+        let spacer = NSView()
+        spacer.translatesAutoresizingMaskIntoConstraints = false
+        row.addArrangedSubview(spacer)
+        spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
+
+        let authButton = makeGoogleAuthButton()
+        scheduleGoogleAuthButton = authButton
+        updateGoogleAuthButtonTitle()
+        row.addArrangedSubview(authButton)
+
+        row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
+        return row
+    }
+
+    private func makeScheduleFilterDropdown() -> NSPopUpButton {
+        let button = NSPopUpButton(frame: .zero, pullsDown: false)
+        button.translatesAutoresizingMaskIntoConstraints = false
+        button.autoenablesItems = false
+        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.target = self
+        button.action = #selector(scheduleFilterDropdownChanged(_:))
+        button.heightAnchor.constraint(equalToConstant: 34).isActive = true
+        button.widthAnchor.constraint(equalToConstant: 156).isActive = true
+
+        button.removeAllItems()
+        button.addItems(withTitles: ["All", "Today", "This week"])
+        button.selectItem(at: scheduleFilter.rawValue)
+
+        if let menu = button.menu {
+            for (index, item) in menu.items.enumerated() {
+                item.tag = index
+            }
+        }
+
+        scheduleFilterDropdown = button
+        return button
+    }
+
+    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: 132).isActive = true
+        return button
+    }
+
+    private func makeGoogleAuthButton() -> NSButton {
+        let button = HoverButton(title: "", target: self, action: #selector(scheduleConnectButtonPressed(_:)))
+        button.translatesAutoresizingMaskIntoConstraints = false
+        button.isBordered = false
+        button.bezelStyle = .regularSquare
+        button.wantsLayer = true
+        button.layer?.cornerRadius = 21
+        button.layer?.borderWidth = 1
+        button.font = NSFont.systemFont(ofSize: 14, weight: .semibold)
+        button.imagePosition = .imageLeading
+        button.alignment = .center
+        button.imageHugsTitle = true
+        button.lineBreakMode = .byTruncatingTail
+        button.contentTintColor = palette.textPrimary
+        button.imageScaling = .scaleNone
+        button.heightAnchor.constraint(equalToConstant: 42).isActive = true
+        let widthConstraint = button.widthAnchor.constraint(equalToConstant: 248)
+        widthConstraint.isActive = true
+        scheduleGoogleAuthButtonWidthConstraint = widthConstraint
+        button.onHoverChanged = { [weak self] hovering in
+            self?.scheduleGoogleAuthHovering = hovering
+            self?.applyGoogleAuthButtonSurface()
+        }
+        button.onHoverChanged?(false)
+        return button
+    }
+
+    private func makeScheduleRefreshButton() -> NSButton {
+        let button = NSButton(title: "", target: self, action: #selector(scheduleReloadButtonPressed(_:)))
+        button.translatesAutoresizingMaskIntoConstraints = false
+        button.isBordered = false
+        button.bezelStyle = .regularSquare
+        button.wantsLayer = true
+        button.layer?.cornerRadius = 21
+        button.layer?.backgroundColor = palette.inputBackground.cgColor
+        button.layer?.borderColor = palette.inputBorder.cgColor
+        button.layer?.borderWidth = 1
+        button.setButtonType(.momentaryChange)
+        button.contentTintColor = palette.textSecondary
+        button.image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh meetings")
+        button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
+        button.imagePosition = .imageOnly
+        button.imageScaling = .scaleProportionallyDown
+        button.focusRingType = .none
+        button.heightAnchor.constraint(equalToConstant: 42).isActive = true
+        button.widthAnchor.constraint(equalToConstant: 42).isActive = true
+        return button
+    }
+
+    func scheduleCardsRow(meetings: [ScheduledMeeting]) -> NSView {
+        let cardWidth: CGFloat = 240
+        let cardsPerViewport: CGFloat = 3
+        let viewportWidth = (cardWidth * cardsPerViewport) + (12 * (cardsPerViewport - 1))
+
+        let wrapper = NSStackView()
+        wrapper.translatesAutoresizingMaskIntoConstraints = false
+        wrapper.orientation = .horizontal
+        wrapper.alignment = .centerY
+        wrapper.spacing = 10
+        let leftButton = makeScheduleScrollButton(systemSymbol: "chevron.left", action: #selector(scheduleScrollLeftPressed(_:)))
+        scheduleScrollLeftButton = leftButton
+        wrapper.addArrangedSubview(leftButton)
+
+        let scroll = NSScrollView()
+        scheduleCardsScrollView = scroll
+        scroll.translatesAutoresizingMaskIntoConstraints = false
+        scroll.drawsBackground = false
+        scroll.hasHorizontalScroller = false
+        scroll.hasVerticalScroller = false
+        scroll.horizontalScrollElasticity = .allowed
+        scroll.verticalScrollElasticity = .none
+        scroll.autohidesScrollers = false
+        scroll.borderType = .noBorder
+        scroll.heightAnchor.constraint(equalToConstant: 150).isActive = true
+        scroll.widthAnchor.constraint(equalToConstant: viewportWidth).isActive = true
+
+        let row = NSStackView()
+        row.translatesAutoresizingMaskIntoConstraints = false
+        row.orientation = .horizontal
+        row.spacing = 12
         row.alignment = .top
-        row.distribution = .fill
+        row.distribution = .gravityAreas
         row.setContentHuggingPriority(.defaultHigh, for: .horizontal)
-        row.heightAnchor.constraint(equalToConstant: 136).isActive = true
+        row.heightAnchor.constraint(equalToConstant: 150).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(greaterThanOrEqualTo: scroll.contentView.trailingAnchor),
+            row.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
+            row.bottomAnchor.constraint(equalTo: scroll.contentView.bottomAnchor),
+            row.heightAnchor.constraint(equalToConstant: 150)
+        ])
+
+        renderScheduleCards(into: row, meetings: meetings)
+        wrapper.addArrangedSubview(scroll)
+        let rightButton = makeScheduleScrollButton(systemSymbol: "chevron.right", action: #selector(scheduleScrollRightPressed(_:)))
+        scheduleScrollRightButton = rightButton
+        wrapper.addArrangedSubview(rightButton)
+        scroll.setContentHuggingPriority(.defaultLow, for: .horizontal)
+        scroll.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+        wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
+        return wrapper
     }
 
-    func scheduleCard() -> NSView {
-        let cardWidth: CGFloat = 264
+    func scheduleCard(meeting: ScheduledMeeting) -> NSView {
+        let cardWidth: CGFloat = 240
 
-        let card = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
+        let card = roundedContainer(cornerRadius: 12, color: palette.sectionCard)
         styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: true)
         card.translatesAutoresizingMaskIntoConstraints = false
         card.widthAnchor.constraint(equalToConstant: cardWidth).isActive = true
-        card.heightAnchor.constraint(equalToConstant: 136).isActive = true
+        card.heightAnchor.constraint(equalToConstant: 150).isActive = true
+        card.setContentHuggingPriority(.required, for: .horizontal)
+        card.setContentCompressionResistancePriority(.required, for: .horizontal)
 
-        let icon = roundedContainer(cornerRadius: 5, color: palette.meetingBadge)
+        let icon = roundedContainer(cornerRadius: 8, color: palette.meetingBadge)
         icon.translatesAutoresizingMaskIntoConstraints = false
-        icon.widthAnchor.constraint(equalToConstant: 22).isActive = true
-        icon.heightAnchor.constraint(equalToConstant: 22).isActive = true
-        let iconText = textLabel("••", font: typography.cardIcon, color: .white)
-        iconText.translatesAutoresizingMaskIntoConstraints = false
-        icon.addSubview(iconText)
+        icon.widthAnchor.constraint(equalToConstant: 28).isActive = true
+        icon.heightAnchor.constraint(equalToConstant: 28).isActive = true
+        let iconView = NSImageView()
+        iconView.translatesAutoresizingMaskIntoConstraints = false
+        iconView.image = NSImage(systemSymbolName: "video.circle.fill", accessibilityDescription: "Meeting")
+        iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
+        iconView.contentTintColor = .white
+        icon.addSubview(iconView)
         NSLayoutConstraint.activate([
-            iconText.centerXAnchor.constraint(equalTo: icon.centerXAnchor),
-            iconText.centerYAnchor.constraint(equalTo: icon.centerYAnchor)
+            iconView.centerXAnchor.constraint(equalTo: icon.centerXAnchor),
+            iconView.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)
+        title.lineBreakMode = .byTruncatingTail
+        title.maximumNumberOfLines = 1
+        title.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+        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)
+        let duration = textLabel(scheduleDurationText(for: meeting), font: NSFont.systemFont(ofSize: 11, weight: .medium), color: palette.textMuted)
+        let dayChip = roundedContainer(cornerRadius: 7, color: palette.inputBackground)
+        dayChip.translatesAutoresizingMaskIntoConstraints = false
+        dayChip.layer?.borderWidth = 1
+        dayChip.layer?.borderColor = palette.inputBorder.withAlphaComponent(0.8).cgColor
+        let dayText = textLabel(scheduleDayText(for: meeting), font: NSFont.systemFont(ofSize: 11, weight: .semibold), color: palette.textSecondary)
+        dayText.translatesAutoresizingMaskIntoConstraints = false
+        dayChip.addSubview(dayText)
+        NSLayoutConstraint.activate([
+            dayText.leadingAnchor.constraint(equalTo: dayChip.leadingAnchor, constant: 8),
+            dayText.trailingAnchor.constraint(equalTo: dayChip.trailingAnchor, constant: -8),
+            dayText.topAnchor.constraint(equalTo: dayChip.topAnchor, constant: 4),
+            dayText.bottomAnchor.constraint(equalTo: dayChip.bottomAnchor, constant: -4)
+        ])
 
         card.addSubview(icon)
+        card.addSubview(dayChip)
         card.addSubview(title)
         card.addSubview(subtitle)
         card.addSubview(time)
+        card.addSubview(duration)
 
         NSLayoutConstraint.activate([
             icon.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
             icon.topAnchor.constraint(equalTo: card.topAnchor, constant: 10),
 
+            dayChip.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -10),
+            dayChip.centerYAnchor.constraint(equalTo: icon.centerYAnchor),
+
             title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 6),
             title.centerYAnchor.constraint(equalTo: icon.centerYAnchor),
-            title.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
+            title.trailingAnchor.constraint(lessThanOrEqualTo: dayChip.leadingAnchor, constant: -8),
+            title.widthAnchor.constraint(lessThanOrEqualToConstant: 130),
 
             subtitle.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
-            subtitle.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 7),
+            subtitle.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 10),
+            subtitle.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
 
             time.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
-            time.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 4)
+            time.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 5),
+            time.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
+
+            duration.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
+            duration.topAnchor.constraint(equalTo: time.bottomAnchor, constant: 4),
+            duration.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10)
         ])
 
-        return card
+        let hit = HoverButton(title: "", target: self, action: #selector(scheduleCardButtonPressed(_:)))
+        hit.translatesAutoresizingMaskIntoConstraints = false
+        hit.isBordered = false
+        hit.bezelStyle = .regularSquare
+        hit.identifier = NSUserInterfaceItemIdentifier(meeting.meetURL.absoluteString)
+        hit.widthAnchor.constraint(equalToConstant: cardWidth).isActive = true
+        hit.heightAnchor.constraint(equalToConstant: 150).isActive = true
+        hit.setContentHuggingPriority(.required, for: .horizontal)
+        hit.setContentCompressionResistancePriority(.required, for: .horizontal)
+        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.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
+    }
+
+    private func makeScheduleScrollButton(systemSymbol: String, action: Selector) -> NSButton {
+        let button = NSButton(title: "", target: self, action: action)
+        button.translatesAutoresizingMaskIntoConstraints = false
+        button.isBordered = false
+        button.bezelStyle = .regularSquare
+        button.wantsLayer = true
+        button.layer?.cornerRadius = 16
+        button.layer?.backgroundColor = palette.inputBackground.cgColor
+        button.layer?.borderColor = palette.inputBorder.cgColor
+        button.layer?.borderWidth = 1
+        button.image = NSImage(systemSymbolName: systemSymbol, accessibilityDescription: "Scroll meetings")
+        button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
+        button.imagePosition = .imageOnly
+        button.imageScaling = .scaleProportionallyDown
+        button.contentTintColor = palette.textSecondary
+        button.focusRingType = .none
+        button.heightAnchor.constraint(equalToConstant: 32).isActive = true
+        button.widthAnchor.constraint(equalToConstant: 32).isActive = true
+        return button
     }
 }
 
@@ -2375,7 +2640,7 @@ private extension ViewController {
         return button
     }
 
-    func iconRoundButton(_ symbol: String, size: CGFloat) -> NSView {
+    func iconRoundButton(systemSymbol: String, size: CGFloat, iconPointSize: CGFloat = 16, onClick: (() -> Void)? = nil) -> NSView {
         let button = HoverTrackingView()
         button.wantsLayer = true
         button.layer?.cornerRadius = size / 2
@@ -2385,11 +2650,16 @@ private extension ViewController {
         button.heightAnchor.constraint(equalToConstant: size).isActive = true
         styleSurface(button, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
 
-        let label = textLabel(symbol, font: typography.iconButton, color: palette.textSecondary)
-        button.addSubview(label)
+        let symbolConfig = NSImage.SymbolConfiguration(pointSize: iconPointSize, weight: .semibold)
+        let iconView = NSImageView()
+        iconView.translatesAutoresizingMaskIntoConstraints = false
+        iconView.image = NSImage(systemSymbolName: systemSymbol, accessibilityDescription: "Refresh")
+        iconView.symbolConfiguration = symbolConfig
+        iconView.contentTintColor = palette.textSecondary
+        button.addSubview(iconView)
         NSLayoutConstraint.activate([
-            label.centerXAnchor.constraint(equalTo: button.centerXAnchor),
-            label.centerYAnchor.constraint(equalTo: button.centerYAnchor)
+            iconView.centerXAnchor.constraint(equalTo: button.centerXAnchor),
+            iconView.centerYAnchor.constraint(equalTo: button.centerYAnchor)
         ])
 
         let baseColor = palette.inputBackground
@@ -2399,11 +2669,456 @@ 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 scheduleReloadButtonPressed(_ sender: NSButton) {
+        scheduleReloadClicked()
+    }
+
+    @objc func scheduleScrollLeftPressed(_ sender: NSButton) {
+        scrollScheduleCards(direction: -1)
+    }
+
+    @objc func scheduleScrollRightPressed(_ sender: NSButton) {
+        scrollScheduleCards(direction: 1)
+    }
+
+    @objc func scheduleCardButtonPressed(_ sender: NSButton) {
+        guard let raw = sender.identifier?.rawValue,
+              let url = URL(string: raw) else { return }
+        openMeetingURL(url)
+    }
+
+    @objc func scheduleConnectButtonPressed(_ sender: NSButton) {
+        scheduleConnectClicked()
+    }
+
+    private func scheduleInitialHeadingText() -> String {
+        googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "Loading…"
+    }
+
+    @objc func scheduleFilterDropdownChanged(_ sender: NSPopUpButton) {
+        guard let selectedItem = sender.selectedItem,
+              let filter = ScheduleFilter(rawValue: selectedItem.tag) else { return }
+        applyScheduleFilter(filter)
+    }
+
+    private func applyScheduleFilter(_ filter: ScheduleFilter) {
+        scheduleFilter = filter
+        scheduleFilterDropdown?.selectItem(at: filter.rawValue)
+        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 scheduleDayText(for meeting: ScheduledMeeting) -> String {
+        let f = DateFormatter()
+        f.locale = Locale.current
+        f.timeZone = TimeZone.current
+        f.dateFormat = "EEE, d MMM"
+        return f.string(from: meeting.startDate)
+    }
+
+    private func scheduleDurationText(for meeting: ScheduledMeeting) -> String {
+        if meeting.isAllDay { return "Duration: all day" }
+        let duration = max(0, meeting.endDate.timeIntervalSince(meeting.startDate))
+        let totalMinutes = Int(duration / 60)
+        let hours = totalMinutes / 60
+        let minutes = totalMinutes % 60
+        if hours > 0, minutes > 0 { return "Duration: \(hours)h \(minutes)m" }
+        if hours > 0 { return "Duration: \(hours)h" }
+        return "Duration: \(minutes)m"
+    }
+
+    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]) {
+        let shouldShowScrollControls = meetings.count > 3
+        scheduleScrollLeftButton?.isHidden = !shouldShowScrollControls
+        scheduleScrollRightButton?.isHidden = !shouldShowScrollControls
+        scheduleCardsScrollView?.contentView.setBoundsOrigin(.zero)
+        if let scroll = scheduleCardsScrollView {
+            scroll.reflectScrolledClipView(scroll.contentView)
+        }
+
+        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: 240).isActive = true
+            empty.heightAnchor.constraint(equalToConstant: 150).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 scrollScheduleCards(direction: Int) {
+        guard let scroll = scheduleCardsScrollView else { return }
+        let contentBounds = scroll.contentView.bounds
+        let step = max(220, contentBounds.width * 0.7)
+        let proposedX = contentBounds.origin.x + (CGFloat(direction) * step)
+        let maxX = max(0, scroll.documentView?.bounds.width ?? 0 - contentBounds.width)
+        let nextX = min(max(0, proposedX), maxX)
+        scroll.contentView.animator().setBoundsOrigin(NSPoint(x: nextX, y: 0))
+        scroll.reflectScrolledClipView(scroll.contentView)
+    }
+
+    private func loadSchedule() async {
+        do {
+            if googleOAuth.loadTokens() == nil {
+                await MainActor.run {
+                    updateGoogleAuthButtonTitle()
+                    applyGoogleProfile(nil)
+                    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 profile = try? await googleOAuth.fetchUserProfile(accessToken: token)
+            let meetings = try await calendarClient.fetchUpcomingMeetings(accessToken: token)
+            let filtered = filteredMeetings(meetings)
+
+            await MainActor.run {
+                updateGoogleAuthButtonTitle()
+                applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
+                scheduleDateHeadingLabel?.stringValue = scheduleHeadingText(for: filtered)
+                if let stack = scheduleCardsStack {
+                    renderScheduleCards(into: stack, meetings: filtered)
+                }
+            }
+        } catch {
+            await MainActor.run {
+                updateGoogleAuthButtonTitle()
+                if googleOAuth.loadTokens() == nil {
+                    applyGoogleProfile(nil)
+                }
+                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() {
+        Task { [weak self] in
+            guard let self else { return }
+            do {
+                try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
+                _ = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
+                await MainActor.run {
+                    self.scheduleDateHeadingLabel?.stringValue = "Refreshing…"
+                    self.pageCache[.joinMeetings] = nil
+                    self.showSidebarPage(.joinMeetings)
+                }
+                await self.loadSchedule()
+            } catch {
+                await MainActor.run {
+                    self.showSimpleError("Couldn’t refresh schedule.", error: error)
+                }
+            }
+        }
+    }
+
+    func scheduleConnectClicked() {
+        Task { [weak self] in
+            guard let self else { return }
+            do {
+                if self.googleOAuth.loadTokens() != nil {
+                    await MainActor.run { self.showGoogleAccountMenu() }
+                    return
+                }
+
+                try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
+                let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
+                let profile = try? await self.googleOAuth.fetchUserProfile(accessToken: token)
+                await MainActor.run {
+                    self.updateGoogleAuthButtonTitle()
+                    self.applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
+                    self.pageCache[.joinMeetings] = nil
+                    self.showSidebarPage(.joinMeetings)
+                }
+            } catch {
+                self.showSimpleError("Couldn’t connect Google account.", error: error)
+            }
+        }
+    }
+
+    private func showGoogleAccountMenu() {
+        guard let button = scheduleGoogleAuthButton else { return }
+        let menu = NSMenu()
+
+        let name = scheduleCurrentProfile?.name ?? "Google account"
+        let email = scheduleCurrentProfile?.email ?? "Signed in"
+        let accountItem = NSMenuItem(title: "\(name) (\(email))", action: nil, keyEquivalent: "")
+        accountItem.isEnabled = false
+        menu.addItem(accountItem)
+        menu.addItem(.separator())
+
+        let logoutItem = NSMenuItem(title: "Logout", action: #selector(scheduleLogoutSelected(_:)), keyEquivalent: "")
+        logoutItem.target = self
+        menu.addItem(logoutItem)
+
+        let point = NSPoint(x: 0, y: button.bounds.height + 2)
+        menu.popUp(positioning: nil, at: point, in: button)
+    }
+
+    @objc private func scheduleLogoutSelected(_ sender: NSMenuItem) {
+        do {
+            try googleOAuth.signOut()
+            updateGoogleAuthButtonTitle()
+            applyGoogleProfile(nil)
+            scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
+            if let stack = scheduleCardsStack {
+                renderScheduleCards(into: stack, meetings: [])
+            }
+        } catch {
+            showSimpleError("Couldn’t logout Google account.", error: error)
+        }
+    }
+
+    private func updateGoogleAuthButtonTitle() {
+        let signedIn = (googleOAuth.loadTokens() != nil)
+        guard let button = scheduleGoogleAuthButton else { return }
+
+        let profileName = scheduleCurrentProfile?.name ?? "Google account"
+        let profileEmail = scheduleCurrentProfile?.email ?? "Sign in with Google"
+        let title = signedIn ? "\(profileName)  ·  \(profileEmail)" : "Sign in with Google"
+        let titleFont = NSFont.systemFont(ofSize: 14, weight: .medium)
+        let titleColor = darkModeEnabled ? NSColor(calibratedWhite: 0.96, alpha: 1) : NSColor(calibratedRed: 0.13, green: 0.14, blue: 0.16, alpha: 1)
+        button.attributedTitle = NSAttributedString(string: title, attributes: [
+            .font: titleFont,
+            .foregroundColor: titleColor
+        ])
+        let textWidth = (title as NSString).size(withAttributes: [.font: titleFont]).width
+        let idealWidth = ceil(textWidth + 80) // icon + spacing + side padding
+        scheduleGoogleAuthButtonWidthConstraint?.constant = min(320, max(188, idealWidth))
+
+        if signedIn {
+            let symbol = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
+            button.image = symbol.flatMap { paddedTrailingImage($0, iconSize: NSSize(width: 22, height: 22), trailingPadding: 8) }
+            button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 22, weight: .regular)
+            button.contentTintColor = palette.textPrimary
+        } else {
+            if let g = NSImage(named: "GoogleGLogo") {
+                button.image = paddedTrailingImage(g, iconSize: NSSize(width: 22, height: 22), trailingPadding: 8)
+            } else {
+                button.image = nil
+            }
+            button.contentTintColor = nil
+        }
+        button.contentTintColor = signedIn ? palette.textPrimary : nil
+
+        applyGoogleAuthButtonSurface()
+    }
+
+    private func makeGoogleProfileDisplay(from profile: GoogleUserProfile) -> GoogleProfileDisplay {
+        let cleanedName = profile.name?.trimmingCharacters(in: .whitespacesAndNewlines)
+        let cleanedEmail = profile.email?.trimmingCharacters(in: .whitespacesAndNewlines)
+        return GoogleProfileDisplay(
+            name: (cleanedName?.isEmpty == false ? cleanedName : nil) ?? "Google User",
+            email: (cleanedEmail?.isEmpty == false ? cleanedEmail : nil) ?? "Signed in",
+            pictureURL: profile.picture.flatMap(URL.init(string:))
+        )
+    }
+
+    private func applyGoogleProfile(_ profile: GoogleProfileDisplay?) {
+        scheduleProfileImageTask?.cancel()
+        scheduleProfileImageTask = nil
+        scheduleCurrentProfile = profile
+
+        updateGoogleAuthButtonTitle()
+
+        guard let profile, let pictureURL = profile.pictureURL else { return }
+        scheduleProfileImageTask = Task { [weak self] in
+            do {
+                let (data, _) = try await URLSession.shared.data(from: pictureURL)
+                if Task.isCancelled { return }
+                guard let image = NSImage(data: data) else { return }
+                await MainActor.run {
+                    self?.scheduleGoogleAuthButton?.image = self?.paddedTrailingImage(image, iconSize: NSSize(width: 22, height: 22), trailingPadding: 8)
+                    self?.scheduleGoogleAuthButton?.contentTintColor = nil
+                }
+            } catch {
+                // Keep placeholder avatar if image fetch fails.
+            }
+        }
+    }
+
+    private func resizedImage(_ image: NSImage, to size: NSSize) -> NSImage {
+        let result = NSImage(size: size)
+        result.lockFocus()
+        image.draw(in: NSRect(origin: .zero, size: size),
+                   from: NSRect(origin: .zero, size: image.size),
+                   operation: .copy,
+                   fraction: 1.0)
+        result.unlockFocus()
+        result.isTemplate = false
+        return result
+    }
+
+    private func paddedTrailingImage(_ image: NSImage, iconSize: NSSize, trailingPadding: CGFloat) -> NSImage {
+        let base = resizedImage(image, to: iconSize)
+        let canvas = NSSize(width: iconSize.width + trailingPadding, height: iconSize.height)
+        let result = NSImage(size: canvas)
+        result.lockFocus()
+        base.draw(in: NSRect(x: 0, y: 0, width: iconSize.width, height: iconSize.height),
+                  from: NSRect(origin: .zero, size: base.size),
+                  operation: .copy,
+                  fraction: 1.0)
+        result.unlockFocus()
+        result.isTemplate = false
+        return result
+    }
+
+    private func applyGoogleAuthButtonSurface() {
+        guard let button = scheduleGoogleAuthButton else { return }
+        let isDark = darkModeEnabled
+        let baseBackground = isDark
+            ? NSColor(calibratedRed: 8.0 / 255.0, green: 14.0 / 255.0, blue: 24.0 / 255.0, alpha: 1)
+            : NSColor.white
+        let hoverBlend = isDark ? NSColor.white : NSColor.black
+        let hoverBackground = baseBackground.blended(withFraction: 0.07, of: hoverBlend) ?? baseBackground
+        let baseBorder = isDark
+            ? NSColor(calibratedWhite: 0.50, alpha: 1)
+            : NSColor(calibratedWhite: 0.72, alpha: 1)
+        let hoverBorder = isDark
+            ? NSColor(calibratedWhite: 0.62, alpha: 1)
+            : NSColor(calibratedWhite: 0.56, alpha: 1)
+        button.layer?.backgroundColor = (scheduleGoogleAuthHovering ? hoverBackground : baseBackground).cgColor
+        button.layer?.borderColor = (scheduleGoogleAuthHovering ? hoverBorder : baseBorder).cgColor
+    }
+
+    @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 +3229,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>