Przeglądaj źródła

Merge branch 'meating-schdule-task'

huzaifahayat12 1 tydzień temu
rodzic
commit
94368ddf7f

+ 46 - 0
Info.plist

@@ -0,0 +1,46 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+<plist version="1.0">
4
+<dict>
5
+	<key>CFBundleDevelopmentRegion</key>
6
+	<string>$(DEVELOPMENT_LANGUAGE)</string>
7
+	<key>CFBundleDisplayName</key>
8
+	<string>$(PRODUCT_NAME)</string>
9
+	<key>CFBundleExecutable</key>
10
+	<string>$(EXECUTABLE_NAME)</string>
11
+	<key>CFBundleIdentifier</key>
12
+	<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
13
+	<key>CFBundleInfoDictionaryVersion</key>
14
+	<string>6.0</string>
15
+	<key>CFBundleName</key>
16
+	<string>$(PRODUCT_NAME)</string>
17
+	<key>CFBundlePackageType</key>
18
+	<string>APPL</string>
19
+	<key>CFBundleShortVersionString</key>
20
+	<string>$(MARKETING_VERSION)</string>
21
+	<key>CFBundleVersion</key>
22
+	<string>$(CURRENT_PROJECT_VERSION)</string>
23
+	<key>LSMinimumSystemVersion</key>
24
+	<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
25
+	<key>NSMainStoryboardFile</key>
26
+	<string>Main</string>
27
+	<key>NSPrincipalClass</key>
28
+	<string>NSApplication</string>
29
+	<key>NSCameraUsageDescription</key>
30
+	<string>Camera is used for video meetings you open inside this app.</string>
31
+	<key>NSMicrophoneUsageDescription</key>
32
+	<string>Microphone is used for audio in meetings you open inside this app.</string>
33
+
34
+	<key>CFBundleURLTypes</key>
35
+	<array>
36
+		<dict>
37
+			<key>CFBundleURLName</key>
38
+			<string>GoogleOAuthRedirect</string>
39
+			<key>CFBundleURLSchemes</key>
40
+			<array>
41
+				<string>meetingsapp.oauth</string>
42
+			</array>
43
+		</dict>
44
+	</array>
45
+</dict>
46
+</plist>

+ 4 - 12
meetings_app.xcodeproj/project.pbxproj

@@ -254,12 +254,8 @@
254 254
 				CURRENT_PROJECT_VERSION = 1;
255 255
 				ENABLE_APP_SANDBOX = YES;
256 256
 				ENABLE_USER_SELECTED_FILES = readonly;
257
-				GENERATE_INFOPLIST_FILE = YES;
258
-				INFOPLIST_KEY_NSCameraUsageDescription = "Camera is used for video meetings you open inside this app.";
259
-				INFOPLIST_KEY_NSHumanReadableCopyright = "";
260
-				INFOPLIST_KEY_NSMainStoryboardFile = Main;
261
-				INFOPLIST_KEY_NSMicrophoneUsageDescription = "Microphone is used for audio in meetings you open inside this app.";
262
-				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
257
+				GENERATE_INFOPLIST_FILE = NO;
258
+				INFOPLIST_FILE = Info.plist;
263 259
 				LD_RUNPATH_SEARCH_PATHS = (
264 260
 					"$(inherited)",
265 261
 					"@executable_path/../Frameworks",
@@ -288,12 +284,8 @@
288 284
 				CURRENT_PROJECT_VERSION = 1;
289 285
 				ENABLE_APP_SANDBOX = YES;
290 286
 				ENABLE_USER_SELECTED_FILES = readonly;
291
-				GENERATE_INFOPLIST_FILE = YES;
292
-				INFOPLIST_KEY_NSCameraUsageDescription = "Camera is used for video meetings you open inside this app.";
293
-				INFOPLIST_KEY_NSHumanReadableCopyright = "";
294
-				INFOPLIST_KEY_NSMainStoryboardFile = Main;
295
-				INFOPLIST_KEY_NSMicrophoneUsageDescription = "Microphone is used for audio in meetings you open inside this app.";
296
-				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
287
+				GENERATE_INFOPLIST_FILE = NO;
288
+				INFOPLIST_FILE = Info.plist;
297 289
 				LD_RUNPATH_SEARCH_PATHS = (
298 290
 					"$(inherited)",
299 291
 					"@executable_path/../Frameworks",

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

@@ -0,0 +1,27 @@
1
+{
2
+  "images" : [
3
+    {
4
+      "filename" : "google_g.png",
5
+      "idiom" : "universal",
6
+      "scale" : "1x"
7
+    },
8
+    {
9
+      "filename" : "google_g@2x.png",
10
+      "idiom" : "universal",
11
+      "scale" : "2x"
12
+    },
13
+    {
14
+      "filename" : "google_g@3x.png",
15
+      "idiom" : "universal",
16
+      "scale" : "3x"
17
+    }
18
+  ],
19
+  "info" : {
20
+    "author" : "xcode",
21
+    "version" : 1
22
+  },
23
+  "properties" : {
24
+    "preserves-vector-representation" : false,
25
+    "template-rendering-intent" : "original"
26
+  }
27
+}

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 @@
1
+import Foundation
2
+import CryptoKit
3
+import AppKit
4
+import Network
5
+
6
+struct GoogleOAuthTokens: Codable, Equatable {
7
+    var accessToken: String
8
+    var refreshToken: String?
9
+    var expiresAt: Date
10
+    var scope: String?
11
+    var tokenType: String?
12
+}
13
+
14
+struct GoogleUserProfile: Codable, Equatable {
15
+    var name: String?
16
+    var email: String?
17
+    var picture: String?
18
+}
19
+
20
+enum GoogleOAuthError: Error {
21
+    case missingClientId
22
+    case missingClientSecret
23
+    case invalidCallbackURL
24
+    case missingAuthorizationCode
25
+    case tokenExchangeFailed(String)
26
+    case unableToOpenBrowser
27
+    case authenticationTimedOut
28
+    case noStoredTokens
29
+}
30
+
31
+final class GoogleOAuthService: NSObject {
32
+    static let shared = GoogleOAuthService()
33
+
34
+    // Stored in UserDefaults so you can configure without rebuilding.
35
+    // Put your OAuth Desktop client ID here (from Google Cloud Console).
36
+    private let clientIdDefaultsKey = "google.oauth.clientId"
37
+    private let clientSecretDefaultsKey = "google.oauth.clientSecret"
38
+    private let bundledClientId = "824412072260-m9g5g6mlemnb0o079rtuqnh0e1unmelc.apps.googleusercontent.com"
39
+    private let bundledClientSecret = "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"
40
+
41
+    // Calendar is needed for schedule. Profile/email make login feel complete in-app.
42
+    private let scopes = [
43
+        "openid",
44
+        "email",
45
+        "profile",
46
+        "https://www.googleapis.com/auth/calendar.readonly"
47
+    ]
48
+
49
+    private let tokenStore = KeychainTokenStore()
50
+    private override init() {}
51
+
52
+    func configuredClientId() -> String? {
53
+        let value = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?.trimmingCharacters(in: .whitespacesAndNewlines)
54
+        if let value, value.isEmpty == false { return value }
55
+        return bundledClientId
56
+    }
57
+
58
+    func setClientIdForTesting(_ clientId: String) {
59
+        UserDefaults.standard.set(clientId, forKey: clientIdDefaultsKey)
60
+    }
61
+
62
+    func configuredClientSecret() -> String? {
63
+        let value = UserDefaults.standard.string(forKey: clientSecretDefaultsKey)?.trimmingCharacters(in: .whitespacesAndNewlines)
64
+        if let value, value.isEmpty == false { return value }
65
+        return bundledClientSecret
66
+    }
67
+
68
+    func setClientSecretForTesting(_ clientSecret: String) {
69
+        UserDefaults.standard.set(clientSecret, forKey: clientSecretDefaultsKey)
70
+    }
71
+
72
+    func signOut() throws {
73
+        try tokenStore.deleteTokens()
74
+    }
75
+
76
+    func loadTokens() -> GoogleOAuthTokens? {
77
+        try? tokenStore.readTokens()
78
+    }
79
+
80
+    func fetchUserProfile(accessToken: String) async throws -> GoogleUserProfile {
81
+        var request = URLRequest(url: URL(string: "https://openidconnect.googleapis.com/v1/userinfo")!)
82
+        request.httpMethod = "GET"
83
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
84
+
85
+        let (data, response) = try await URLSession.shared.data(for: request)
86
+        guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
87
+            let details = String(data: data, encoding: .utf8) ?? "HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1)"
88
+            throw GoogleOAuthError.tokenExchangeFailed(details)
89
+        }
90
+        return try JSONDecoder().decode(GoogleUserProfile.self, from: data)
91
+    }
92
+
93
+    func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
94
+        if var tokens = try tokenStore.readTokens() {
95
+            if tokens.expiresAt.timeIntervalSinceNow > 60 {
96
+                return tokens.accessToken
97
+            }
98
+            if let refreshed = try await refreshTokens(tokens) {
99
+                tokens = refreshed
100
+                try tokenStore.writeTokens(tokens)
101
+                return tokens.accessToken
102
+            }
103
+        }
104
+
105
+        let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
106
+        try tokenStore.writeTokens(tokens)
107
+        return tokens.accessToken
108
+    }
109
+
110
+    // MARK: - Interactive sign-in (Authorization Code + PKCE)
111
+
112
+    private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> GoogleOAuthTokens {
113
+        _ = presentingWindow
114
+        guard let clientId = configuredClientId() else { throw GoogleOAuthError.missingClientId }
115
+        guard let clientSecret = configuredClientSecret() else { throw GoogleOAuthError.missingClientSecret }
116
+        let codeVerifier = Self.randomURLSafeString(length: 64)
117
+        let codeChallenge = Self.pkceChallenge(for: codeVerifier)
118
+        let state = Self.randomURLSafeString(length: 32)
119
+
120
+        let loopback = try await OAuthLoopbackServer.start()
121
+        defer { loopback.stop() }
122
+        let redirectURI = loopback.redirectURI
123
+
124
+        var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
125
+        components.queryItems = [
126
+            URLQueryItem(name: "client_id", value: clientId),
127
+            URLQueryItem(name: "redirect_uri", value: redirectURI),
128
+            URLQueryItem(name: "response_type", value: "code"),
129
+            URLQueryItem(name: "scope", value: scopes.joined(separator: " ")),
130
+            URLQueryItem(name: "state", value: state),
131
+            URLQueryItem(name: "code_challenge", value: codeChallenge),
132
+            URLQueryItem(name: "code_challenge_method", value: "S256"),
133
+            URLQueryItem(name: "access_type", value: "offline"),
134
+            URLQueryItem(name: "prompt", value: "consent")
135
+        ]
136
+
137
+        guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
138
+        guard NSWorkspace.shared.open(authURL) else { throw GoogleOAuthError.unableToOpenBrowser }
139
+        let callbackURL = try await loopback.waitForCallback()
140
+
141
+        guard let returnedState = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
142
+            .queryItems?.first(where: { $0.name == "state" })?.value,
143
+              returnedState == state else {
144
+            throw GoogleOAuthError.invalidCallbackURL
145
+        }
146
+
147
+        guard let code = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
148
+            .queryItems?.first(where: { $0.name == "code" })?.value,
149
+              code.isEmpty == false else {
150
+            throw GoogleOAuthError.missingAuthorizationCode
151
+        }
152
+
153
+        return try await exchangeCodeForTokens(
154
+            code: code,
155
+            codeVerifier: codeVerifier,
156
+            redirectURI: redirectURI,
157
+            clientId: clientId,
158
+            clientSecret: clientSecret
159
+        )
160
+    }
161
+
162
+    private func exchangeCodeForTokens(code: String, codeVerifier: String, redirectURI: String, clientId: String, clientSecret: String) async throws -> GoogleOAuthTokens {
163
+        var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
164
+        request.httpMethod = "POST"
165
+        request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
166
+        request.httpBody = Self.formURLEncoded([
167
+            "client_id": clientId,
168
+            "client_secret": clientSecret,
169
+            "code": code,
170
+            "code_verifier": codeVerifier,
171
+            "redirect_uri": redirectURI,
172
+            "grant_type": "authorization_code"
173
+        ])
174
+
175
+        let (data, response) = try await URLSession.shared.data(for: request)
176
+        guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
177
+            let details = String(data: data, encoding: .utf8) ?? "HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1)"
178
+            throw GoogleOAuthError.tokenExchangeFailed(details)
179
+        }
180
+
181
+        struct TokenResponse: Decodable {
182
+            let access_token: String
183
+            let expires_in: Double
184
+            let refresh_token: String?
185
+            let scope: String?
186
+            let token_type: String?
187
+        }
188
+
189
+        let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
190
+        return GoogleOAuthTokens(
191
+            accessToken: decoded.access_token,
192
+            refreshToken: decoded.refresh_token,
193
+            expiresAt: Date().addingTimeInterval(decoded.expires_in),
194
+            scope: decoded.scope,
195
+            tokenType: decoded.token_type
196
+        )
197
+    }
198
+
199
+    private func refreshTokens(_ tokens: GoogleOAuthTokens) async throws -> GoogleOAuthTokens? {
200
+        guard let refreshToken = tokens.refreshToken else { return nil }
201
+        guard let clientId = configuredClientId() else { throw GoogleOAuthError.missingClientId }
202
+        guard let clientSecret = configuredClientSecret() else { throw GoogleOAuthError.missingClientSecret }
203
+
204
+        var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
205
+        request.httpMethod = "POST"
206
+        request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
207
+        request.httpBody = Self.formURLEncoded([
208
+            "client_id": clientId,
209
+            "client_secret": clientSecret,
210
+            "refresh_token": refreshToken,
211
+            "grant_type": "refresh_token"
212
+        ])
213
+
214
+        let (data, response) = try await URLSession.shared.data(for: request)
215
+        guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
216
+            return nil
217
+        }
218
+
219
+        struct RefreshResponse: Decodable {
220
+            let access_token: String
221
+            let expires_in: Double
222
+            let scope: String?
223
+            let token_type: String?
224
+        }
225
+
226
+        let decoded = try JSONDecoder().decode(RefreshResponse.self, from: data)
227
+        return GoogleOAuthTokens(
228
+            accessToken: decoded.access_token,
229
+            refreshToken: refreshToken,
230
+            expiresAt: Date().addingTimeInterval(decoded.expires_in),
231
+            scope: decoded.scope ?? tokens.scope,
232
+            tokenType: decoded.token_type ?? tokens.tokenType
233
+        )
234
+    }
235
+
236
+    // MARK: - Helpers
237
+
238
+    private static func pkceChallenge(for verifier: String) -> String {
239
+        let data = Data(verifier.utf8)
240
+        let digest = SHA256.hash(data: data)
241
+        return Data(digest).base64URLEncodedString()
242
+    }
243
+
244
+    private static func randomURLSafeString(length: Int) -> String {
245
+        var bytes = [UInt8](repeating: 0, count: length)
246
+        _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
247
+        return Data(bytes).base64URLEncodedString()
248
+    }
249
+
250
+    private static func formURLEncoded(_ params: [String: String]) -> Data {
251
+        let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
252
+        let pairs = params.map { key, value -> String in
253
+            let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
254
+            let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
255
+            return "\(k)=\(v)"
256
+        }
257
+        .joined(separator: "&")
258
+        return Data(pairs.utf8)
259
+    }
260
+}
261
+
262
+private extension Data {
263
+    func base64URLEncodedString() -> String {
264
+        let s = base64EncodedString()
265
+        return s
266
+            .replacingOccurrences(of: "+", with: "-")
267
+            .replacingOccurrences(of: "/", with: "_")
268
+            .replacingOccurrences(of: "=", with: "")
269
+    }
270
+}
271
+
272
+private final class OAuthLoopbackServer {
273
+    private let queue = DispatchQueue(label: "google.oauth.loopback.server")
274
+    private let listener: NWListener
275
+    private var readyContinuation: CheckedContinuation<Void, Error>?
276
+    private var callbackContinuation: CheckedContinuation<URL, Error>?
277
+    private var callbackURL: URL?
278
+
279
+    private init(listener: NWListener) {
280
+        self.listener = listener
281
+    }
282
+
283
+    static func start() async throws -> OAuthLoopbackServer {
284
+        let listener = try NWListener(using: .tcp, on: .any)
285
+        let server = OAuthLoopbackServer(listener: listener)
286
+        try await server.startListening()
287
+        return server
288
+    }
289
+
290
+    var redirectURI: String {
291
+        let port = listener.port?.rawValue ?? 0
292
+        return "http://127.0.0.1:\(port)/oauth2redirect"
293
+    }
294
+
295
+    func waitForCallback(timeoutSeconds: Double = 120) async throws -> URL {
296
+        try await withThrowingTaskGroup(of: URL.self) { group in
297
+            group.addTask { [weak self] in
298
+                guard let self else { throw GoogleOAuthError.invalidCallbackURL }
299
+                return try await self.awaitCallback()
300
+            }
301
+            group.addTask {
302
+                try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
303
+                throw GoogleOAuthError.authenticationTimedOut
304
+            }
305
+
306
+            let url = try await group.next()!
307
+            group.cancelAll()
308
+            return url
309
+        }
310
+    }
311
+
312
+    func stop() {
313
+        queue.async {
314
+            self.listener.cancel()
315
+            if let callbackContinuation = self.callbackContinuation {
316
+                self.callbackContinuation = nil
317
+                callbackContinuation.resume(throwing: GoogleOAuthError.authenticationTimedOut)
318
+            }
319
+        }
320
+    }
321
+
322
+    private func startListening() async throws {
323
+        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
324
+            queue.async {
325
+                self.readyContinuation = continuation
326
+                self.listener.stateUpdateHandler = { [weak self] state in
327
+                    guard let self else { return }
328
+                    switch state {
329
+                    case .ready:
330
+                        if let readyContinuation = self.readyContinuation {
331
+                            self.readyContinuation = nil
332
+                            readyContinuation.resume()
333
+                        }
334
+                    case .failed(let error):
335
+                        if let readyContinuation = self.readyContinuation {
336
+                            self.readyContinuation = nil
337
+                            readyContinuation.resume(throwing: error)
338
+                        }
339
+                    case .cancelled:
340
+                        if let readyContinuation = self.readyContinuation {
341
+                            self.readyContinuation = nil
342
+                            readyContinuation.resume(throwing: GoogleOAuthError.invalidCallbackURL)
343
+                        }
344
+                    default:
345
+                        break
346
+                    }
347
+                }
348
+                self.listener.newConnectionHandler = { [weak self] connection in
349
+                    self?.handle(connection: connection)
350
+                }
351
+                self.listener.start(queue: self.queue)
352
+            }
353
+        }
354
+    }
355
+
356
+    private func awaitCallback() async throws -> URL {
357
+        if let callbackURL { return callbackURL }
358
+        return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
359
+            queue.async {
360
+                if let callbackURL = self.callbackURL {
361
+                    continuation.resume(returning: callbackURL)
362
+                    return
363
+                }
364
+                self.callbackContinuation = continuation
365
+            }
366
+        }
367
+    }
368
+
369
+    private func handle(connection: NWConnection) {
370
+        connection.start(queue: queue)
371
+        connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { [weak self] data, _, _, _ in
372
+            guard let self else { return }
373
+            let requestLine = data
374
+                .flatMap { String(data: $0, encoding: .utf8) }?
375
+                .split(separator: "\r\n", omittingEmptySubsequences: false)
376
+                .first
377
+                .map(String.init)
378
+
379
+            var parsedURL: URL?
380
+            if let requestLine {
381
+                let parts = requestLine.split(separator: " ")
382
+                if parts.count >= 2 {
383
+                    let pathAndQuery = String(parts[1])
384
+                    parsedURL = URL(string: "http://127.0.0.1\(pathAndQuery)")
385
+                }
386
+            }
387
+
388
+            self.sendHTTPResponse(connection: connection, success: parsedURL != nil)
389
+
390
+            if let parsedURL {
391
+                self.callbackURL = parsedURL
392
+                if let continuation = self.callbackContinuation {
393
+                    self.callbackContinuation = nil
394
+                    continuation.resume(returning: parsedURL)
395
+                }
396
+                self.listener.cancel()
397
+            }
398
+            connection.cancel()
399
+        }
400
+    }
401
+
402
+    private func sendHTTPResponse(connection: NWConnection, success: Bool) {
403
+        let body = success
404
+            ? "<html><body><h3>Authentication complete</h3><p>You can return to the app.</p></body></html>"
405
+            : "<html><body><h3>Authentication failed</h3></body></html>"
406
+        let response = """
407
+        HTTP/1.1 200 OK\r
408
+        Content-Type: text/html; charset=utf-8\r
409
+        Content-Length: \(body.utf8.count)\r
410
+        Connection: close\r
411
+        \r
412
+        \(body)
413
+        """
414
+        connection.send(content: Data(response.utf8), completion: .contentProcessed { _ in })
415
+    }
416
+}
417
+
418
+extension GoogleOAuthError: LocalizedError {
419
+    var errorDescription: String? {
420
+        switch self {
421
+        case .missingClientId:
422
+            return "Missing Google OAuth Client ID."
423
+        case .missingClientSecret:
424
+            return "Missing Google OAuth Client Secret."
425
+        case .invalidCallbackURL:
426
+            return "Invalid OAuth callback URL."
427
+        case .missingAuthorizationCode:
428
+            return "Google did not return an authorization code."
429
+        case .tokenExchangeFailed(let details):
430
+            return "Token exchange failed: \(details)"
431
+        case .unableToOpenBrowser:
432
+            return "Could not open browser for Google sign-in."
433
+        case .authenticationTimedOut:
434
+            return "Google sign-in timed out."
435
+        case .noStoredTokens:
436
+            return "No stored Google tokens found."
437
+        }
438
+    }
439
+}
440
+

+ 30 - 0
meetings_app/Auth/KeychainTokenStore.swift

@@ -0,0 +1,30 @@
1
+import Foundation
2
+
3
+/// Keeps the existing API surface while storing OAuth tokens in UserDefaults.
4
+/// This avoids macOS keychain unlock prompts during development/test runs.
5
+final class KeychainTokenStore {
6
+    private let defaultsKey: String
7
+    private let defaults: UserDefaults
8
+
9
+    init(service: String = Bundle.main.bundleIdentifier ?? "meetings_app",
10
+         account: String = "googleOAuthTokens",
11
+         defaults: UserDefaults = .standard) {
12
+        self.defaultsKey = "\(service).\(account)"
13
+        self.defaults = defaults
14
+    }
15
+
16
+    func readTokens() throws -> GoogleOAuthTokens? {
17
+        guard let data = defaults.data(forKey: defaultsKey) else { return nil }
18
+        return try JSONDecoder().decode(GoogleOAuthTokens.self, from: data)
19
+    }
20
+
21
+    func writeTokens(_ tokens: GoogleOAuthTokens) throws {
22
+        let data = try JSONEncoder().encode(tokens)
23
+        defaults.set(data, forKey: defaultsKey)
24
+    }
25
+
26
+    func deleteTokens() throws {
27
+        defaults.removeObject(forKey: defaultsKey)
28
+    }
29
+}
30
+

+ 221 - 0
meetings_app/Google/GoogleCalendarClient.swift

@@ -0,0 +1,221 @@
1
+import Foundation
2
+
3
+enum GoogleCalendarClientError: Error {
4
+    case invalidResponse
5
+    case httpStatus(Int, String)
6
+    case decodeFailed(String)
7
+}
8
+
9
+final class GoogleCalendarClient {
10
+    struct Options: Sendable {
11
+        var daysAhead: Int
12
+        var maxResults: Int
13
+        var includeNonMeetEvents: Bool
14
+
15
+        init(daysAhead: Int = 180, maxResults: Int = 200, includeNonMeetEvents: Bool = true) {
16
+            self.daysAhead = daysAhead
17
+            self.maxResults = maxResults
18
+            self.includeNonMeetEvents = includeNonMeetEvents
19
+        }
20
+    }
21
+
22
+    private let session: URLSession
23
+
24
+    init(session: URLSession = .shared) {
25
+        self.session = session
26
+    }
27
+
28
+    func fetchUpcomingMeetings(accessToken: String) async throws -> [ScheduledMeeting] {
29
+        try await fetchUpcomingMeetings(accessToken: accessToken, options: Options())
30
+    }
31
+
32
+    func fetchUpcomingMeetings(accessToken: String, options: Options) async throws -> [ScheduledMeeting] {
33
+        let now = Date()
34
+        let end = Calendar.current.date(byAdding: .day, value: max(1, options.daysAhead), to: now) ?? now.addingTimeInterval(180 * 24 * 60 * 60)
35
+        let formatter = ISO8601DateFormatter()
36
+        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
37
+        let totalLimit = max(1, options.maxResults)
38
+        let pageSize = min(250, totalLimit)
39
+        var nextPageToken: String?
40
+        var meetings: [ScheduledMeeting] = []
41
+
42
+        repeat {
43
+            var components = URLComponents(string: "https://www.googleapis.com/calendar/v3/calendars/primary/events")!
44
+            var queryItems = [
45
+                URLQueryItem(name: "timeMin", value: formatter.string(from: now)),
46
+                URLQueryItem(name: "timeMax", value: formatter.string(from: end)),
47
+                URLQueryItem(name: "singleEvents", value: "true"),
48
+                URLQueryItem(name: "orderBy", value: "startTime"),
49
+                URLQueryItem(name: "maxResults", value: String(pageSize)),
50
+                URLQueryItem(name: "conferenceDataVersion", value: "1")
51
+            ]
52
+            if let nextPageToken {
53
+                queryItems.append(URLQueryItem(name: "pageToken", value: nextPageToken))
54
+            }
55
+            components.queryItems = queryItems
56
+
57
+            var request = URLRequest(url: components.url!)
58
+            request.httpMethod = "GET"
59
+            request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
60
+
61
+            let (data, response) = try await session.data(for: request)
62
+            guard let http = response as? HTTPURLResponse else { throw GoogleCalendarClientError.invalidResponse }
63
+            guard (200..<300).contains(http.statusCode) else {
64
+                let body = String(data: data, encoding: .utf8) ?? "<no body>"
65
+                throw GoogleCalendarClientError.httpStatus(http.statusCode, body)
66
+            }
67
+
68
+            let decoded: EventsList
69
+            do {
70
+                decoded = try JSONDecoder().decode(EventsList.self, from: data)
71
+            } catch {
72
+                let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
73
+                throw GoogleCalendarClientError.decodeFailed(raw)
74
+            }
75
+
76
+            let pageMeetings: [ScheduledMeeting] = decoded.items.compactMap { item in
77
+            let title = item.summary?.trimmingCharacters(in: .whitespacesAndNewlines)
78
+            let subtitle = item.organizer?.displayName ?? item.organizer?.email
79
+
80
+            guard let start = item.start?.resolvedDate,
81
+                  let end = item.end?.resolvedDate else { return nil }
82
+
83
+            let isAllDay = item.start?.date != nil
84
+
85
+            let meetURL: URL? = {
86
+                if let hangout = item.hangoutLink, let u = URL(string: hangout) { return u }
87
+                let entry = item.conferenceData?.entryPoints?.first(where: { $0.entryPointType == "video" && ($0.uri?.contains("meet.google.com") ?? false) })
88
+                if let uri = entry?.uri, let u = URL(string: uri) { return u }
89
+                if options.includeNonMeetEvents, let htmlLink = item.htmlLink, let u = URL(string: htmlLink) { return u }
90
+                return nil
91
+            }()
92
+
93
+            if meetURL == nil, options.includeNonMeetEvents == false { return nil }
94
+            guard let meetURL else { return nil }
95
+
96
+            return ScheduledMeeting(
97
+                id: item.id ?? UUID().uuidString,
98
+                title: (title?.isEmpty == false) ? title! : "Untitled meeting",
99
+                subtitle: subtitle,
100
+                startDate: start,
101
+                endDate: end,
102
+                meetURL: meetURL,
103
+                isAllDay: isAllDay
104
+            )
105
+            }
106
+
107
+            meetings.append(contentsOf: pageMeetings)
108
+            nextPageToken = decoded.nextPageToken
109
+        } while nextPageToken != nil && meetings.count < totalLimit
110
+
111
+        if meetings.count > totalLimit {
112
+            meetings = Array(meetings.prefix(totalLimit))
113
+        }
114
+        return meetings
115
+    }
116
+}
117
+
118
+extension GoogleCalendarClientError: LocalizedError {
119
+    var errorDescription: String? {
120
+        switch self {
121
+        case .invalidResponse:
122
+            return "Google Calendar returned an invalid response."
123
+        case let .httpStatus(status, body):
124
+            return "Google Calendar API error (\(status)): \(body)"
125
+        case let .decodeFailed(raw):
126
+            return "Failed to parse Google Calendar events: \(raw)"
127
+        }
128
+    }
129
+}
130
+
131
+// MARK: - Calendar API models
132
+
133
+private struct EventsList: Decodable {
134
+    let items: [EventItem]
135
+    let nextPageToken: String?
136
+}
137
+
138
+private struct EventItem: Decodable {
139
+    let id: String?
140
+    let summary: String?
141
+    let hangoutLink: String?
142
+    let htmlLink: String?
143
+    let organizer: Organizer?
144
+    let start: EventDateTime?
145
+    let end: EventDateTime?
146
+    let conferenceData: ConferenceData?
147
+}
148
+
149
+private struct Organizer: Decodable {
150
+    let displayName: String?
151
+    let email: String?
152
+}
153
+
154
+private struct EventDateTime: Decodable {
155
+    let dateTime: String?
156
+    let date: String?
157
+    let timeZone: String?
158
+
159
+    var resolvedDate: Date? {
160
+        if let dateTime, let parsed = Self.parseDateTime(dateTime, timeZone: timeZone) {
161
+            return parsed
162
+        }
163
+        if let date, let parsed = DateFormatter.googleAllDay.date(from: date) {
164
+            return parsed
165
+        }
166
+        return nil
167
+    }
168
+
169
+    private static func parseDateTime(_ raw: String, timeZone: String?) -> Date? {
170
+        if let dt = ISO8601DateFormatter.fractional.date(from: raw) ?? ISO8601DateFormatter.nonFractional.date(from: raw) {
171
+            return dt
172
+        }
173
+
174
+        // Some Calendar payloads provide dateTime without explicit zone and separate timeZone field.
175
+        let formatter = DateFormatter()
176
+        formatter.calendar = Calendar(identifier: .gregorian)
177
+        formatter.locale = Locale(identifier: "en_US_POSIX")
178
+        formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
179
+        if let timeZone, let tz = TimeZone(identifier: timeZone) {
180
+            formatter.timeZone = tz
181
+        } else {
182
+            formatter.timeZone = TimeZone.current
183
+        }
184
+        return formatter.date(from: raw)
185
+    }
186
+}
187
+
188
+private struct ConferenceData: Decodable {
189
+    let entryPoints: [EntryPoint]?
190
+}
191
+
192
+private struct EntryPoint: Decodable {
193
+    let entryPointType: String?
194
+    let uri: String?
195
+}
196
+
197
+private extension ISO8601DateFormatter {
198
+    static let fractional: ISO8601DateFormatter = {
199
+        let f = ISO8601DateFormatter()
200
+        f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
201
+        return f
202
+    }()
203
+
204
+    static let nonFractional: ISO8601DateFormatter = {
205
+        let f = ISO8601DateFormatter()
206
+        f.formatOptions = [.withInternetDateTime]
207
+        return f
208
+    }()
209
+}
210
+
211
+private extension DateFormatter {
212
+    static let googleAllDay: DateFormatter = {
213
+        let f = DateFormatter()
214
+        f.calendar = Calendar(identifier: .gregorian)
215
+        f.locale = Locale(identifier: "en_US_POSIX")
216
+        f.timeZone = TimeZone(secondsFromGMT: 0)
217
+        f.dateFormat = "yyyy-MM-dd"
218
+        return f
219
+    }()
220
+}
221
+

+ 50 - 0
meetings_app/Google/GoogleMeetClient.swift

@@ -0,0 +1,50 @@
1
+import Foundation
2
+
3
+enum GoogleMeetClientError: Error {
4
+    case invalidResponse
5
+    case httpStatus(Int)
6
+}
7
+
8
+/// Thin Meet REST API wrapper.
9
+/// Note: Meet REST API is best used for conferences/participants/artifacts, while scheduling comes from Calendar.
10
+final class GoogleMeetClient {
11
+    private let session: URLSession
12
+
13
+    init(session: URLSession = .shared) {
14
+        self.session = session
15
+    }
16
+
17
+    /// Lists conference records for a given meeting space resource name.
18
+    /// This is intentionally minimal scaffolding for phase 2 enrichment.
19
+    func listConferenceRecords(accessToken: String, spaceResourceName: String, pageSize: Int = 10) async throws -> [ConferenceRecord] {
20
+        var components = URLComponents(string: "https://meet.googleapis.com/v2/\(spaceResourceName)/conferenceRecords")!
21
+        components.queryItems = [
22
+            URLQueryItem(name: "pageSize", value: String(pageSize))
23
+        ]
24
+
25
+        var request = URLRequest(url: components.url!)
26
+        request.httpMethod = "GET"
27
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
28
+
29
+        let (data, response) = try await session.data(for: request)
30
+        guard let http = response as? HTTPURLResponse else { throw GoogleMeetClientError.invalidResponse }
31
+        guard (200..<300).contains(http.statusCode) else { throw GoogleMeetClientError.httpStatus(http.statusCode) }
32
+
33
+        let decoded = try JSONDecoder().decode(ListConferenceRecordsResponse.self, from: data)
34
+        return decoded.conferenceRecords ?? []
35
+    }
36
+}
37
+
38
+// MARK: - Minimal models (v2)
39
+
40
+struct ConferenceRecord: Decodable, Equatable {
41
+    let name: String?
42
+    let startTime: Date?
43
+    let endTime: Date?
44
+}
45
+
46
+private struct ListConferenceRecordsResponse: Decodable {
47
+    let conferenceRecords: [ConferenceRecord]?
48
+    let nextPageToken: String?
49
+}
50
+

+ 12 - 0
meetings_app/Models/ScheduledMeeting.swift

@@ -0,0 +1,12 @@
1
+import Foundation
2
+
3
+struct ScheduledMeeting: Identifiable, Equatable {
4
+    let id: String
5
+    let title: String
6
+    let subtitle: String?
7
+    let startDate: Date
8
+    let endDate: Date
9
+    let meetURL: URL
10
+    let isAllDay: Bool
11
+}
12
+

+ 769 - 57
meetings_app/ViewController.swift

@@ -7,6 +7,7 @@
7 7
 
8 8
 import Cocoa
9 9
 import WebKit
10
+import AuthenticationServices
10 11
 
11 12
 private enum SidebarPage: Int {
12 13
     case joinMeetings = 0
@@ -37,6 +38,12 @@ private enum PremiumPlan: Int {
37 38
 }
38 39
 
39 40
 final class ViewController: NSViewController {
41
+    private struct GoogleProfileDisplay {
42
+        let name: String
43
+        let email: String
44
+        let pictureURL: URL?
45
+    }
46
+
40 47
     private var palette = Palette(isDarkMode: true)
41 48
     private let typography = Typography()
42 49
     private let launchContentSize = NSSize(width: 920, height: 690)
@@ -61,6 +68,27 @@ final class ViewController: NSViewController {
61 68
     private weak var meetLinkField: NSTextField?
62 69
     private weak var browseAddressField: NSTextField?
63 70
     private var inAppBrowserWindowController: InAppBrowserWindowController?
71
+    private let googleOAuth = GoogleOAuthService.shared
72
+    private let calendarClient = GoogleCalendarClient()
73
+
74
+    private enum ScheduleFilter: Int {
75
+        case all = 0
76
+        case today = 1
77
+        case week = 2
78
+    }
79
+
80
+    private var scheduleFilter: ScheduleFilter = .all
81
+    private weak var scheduleDateHeadingLabel: NSTextField?
82
+    private weak var scheduleCardsStack: NSStackView?
83
+    private weak var scheduleCardsScrollView: NSScrollView?
84
+    private weak var scheduleScrollLeftButton: NSView?
85
+    private weak var scheduleScrollRightButton: NSView?
86
+    private weak var scheduleFilterDropdown: NSPopUpButton?
87
+    private weak var scheduleGoogleAuthButton: NSButton?
88
+    private var scheduleGoogleAuthButtonWidthConstraint: NSLayoutConstraint?
89
+    private var scheduleGoogleAuthHovering = false
90
+    private var scheduleCurrentProfile: GoogleProfileDisplay?
91
+    private var scheduleProfileImageTask: Task<Void, Never>?
64 92
 
65 93
     /// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
66 94
     private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
@@ -903,14 +931,24 @@ private extension ViewController {
903 931
         contentStack.spacing = 14
904 932
         contentStack.alignment = .leading
905 933
 
934
+        contentStack.addArrangedSubview(scheduleTopAuthRow())
935
+        if let authRow = contentStack.arrangedSubviews.last {
936
+            contentStack.setCustomSpacing(20, after: authRow)
937
+        }
938
+
906 939
         let joinActions = meetJoinActionsRow()
907 940
         contentStack.addArrangedSubview(textLabel("Join Meetings", font: typography.pageTitle, color: palette.textPrimary))
908 941
         contentStack.addArrangedSubview(meetJoinSectionRow())
909 942
         contentStack.addArrangedSubview(joinActions)
910 943
         contentStack.setCustomSpacing(26, after: joinActions)
911 944
         contentStack.addArrangedSubview(scheduleHeader())
912
-        contentStack.addArrangedSubview(textLabel("Tuesday, 14 Apr", font: typography.dateHeading, color: palette.textSecondary))
913
-        contentStack.addArrangedSubview(scheduleCardsRow())
945
+
946
+        let dateHeading = textLabel(scheduleInitialHeadingText(), font: typography.dateHeading, color: palette.textSecondary)
947
+        scheduleDateHeadingLabel = dateHeading
948
+        contentStack.addArrangedSubview(dateHeading)
949
+
950
+        let cardsRow = scheduleCardsRow(meetings: [])
951
+        contentStack.addArrangedSubview(cardsRow)
914 952
 
915 953
         panel.addSubview(contentStack)
916 954
 
@@ -920,6 +958,10 @@ private extension ViewController {
920 958
             contentStack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 26)
921 959
         ])
922 960
 
961
+        Task { [weak self] in
962
+            await self?.loadSchedule()
963
+        }
964
+
923 965
         return panel
924 966
     }
925 967
 
@@ -1777,94 +1819,317 @@ private extension ViewController {
1777 1819
         row.addArrangedSubview(spacer)
1778 1820
         spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
1779 1821
 
1780
-        row.addArrangedSubview(iconRoundButton("?", size: 34))
1781
-        row.addArrangedSubview(iconRoundButton("⟳", size: 34))
1782
-
1783
-        let filter = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
1784
-        filter.translatesAutoresizingMaskIntoConstraints = false
1785
-        filter.widthAnchor.constraint(equalToConstant: 156).isActive = true
1786
-        filter.heightAnchor.constraint(equalToConstant: 34).isActive = true
1787
-        styleSurface(filter, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
1788
-        let filterText = textLabel("All", font: typography.filterText, color: palette.textSecondary)
1789
-        let arrow = textLabel("▾", font: typography.filterArrow, color: palette.textMuted)
1790
-        filterText.translatesAutoresizingMaskIntoConstraints = false
1791
-        arrow.translatesAutoresizingMaskIntoConstraints = false
1792
-        filter.addSubview(filterText)
1793
-        filter.addSubview(arrow)
1822
+        row.addArrangedSubview(makeScheduleRefreshButton())
1794 1823
 
1795
-        NSLayoutConstraint.activate([
1796
-            filterText.leadingAnchor.constraint(equalTo: filter.leadingAnchor, constant: 12),
1797
-            filterText.centerYAnchor.constraint(equalTo: filter.centerYAnchor),
1798
-            arrow.trailingAnchor.constraint(equalTo: filter.trailingAnchor, constant: -10),
1799
-            arrow.centerYAnchor.constraint(equalTo: filter.centerYAnchor)
1800
-        ])
1801
-
1802
-        row.addArrangedSubview(filter)
1824
+        row.addArrangedSubview(makeScheduleFilterDropdown())
1803 1825
         row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
1804 1826
         return row
1805 1827
     }
1806 1828
 
1807
-    func scheduleCardsRow() -> NSView {
1829
+    private func scheduleTopAuthRow() -> NSView {
1808 1830
         let row = NSStackView()
1809 1831
         row.translatesAutoresizingMaskIntoConstraints = false
1810 1832
         row.orientation = .horizontal
1833
+        row.alignment = .centerY
1811 1834
         row.spacing = 10
1835
+
1836
+        let spacer = NSView()
1837
+        spacer.translatesAutoresizingMaskIntoConstraints = false
1838
+        row.addArrangedSubview(spacer)
1839
+        spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
1840
+
1841
+        let authButton = makeGoogleAuthButton()
1842
+        scheduleGoogleAuthButton = authButton
1843
+        updateGoogleAuthButtonTitle()
1844
+        row.addArrangedSubview(authButton)
1845
+
1846
+        row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
1847
+        return row
1848
+    }
1849
+
1850
+    private func makeScheduleFilterDropdown() -> NSPopUpButton {
1851
+        let button = NSPopUpButton(frame: .zero, pullsDown: false)
1852
+        button.translatesAutoresizingMaskIntoConstraints = false
1853
+        button.autoenablesItems = false
1854
+        button.wantsLayer = true
1855
+        button.layer?.cornerRadius = 8
1856
+        button.layer?.backgroundColor = palette.inputBackground.cgColor
1857
+        button.layer?.borderColor = palette.inputBorder.cgColor
1858
+        button.layer?.borderWidth = 1
1859
+        button.font = typography.filterText
1860
+        button.contentTintColor = palette.textSecondary
1861
+        button.target = self
1862
+        button.action = #selector(scheduleFilterDropdownChanged(_:))
1863
+        button.heightAnchor.constraint(equalToConstant: 34).isActive = true
1864
+        button.widthAnchor.constraint(equalToConstant: 156).isActive = true
1865
+
1866
+        button.removeAllItems()
1867
+        button.addItems(withTitles: ["All", "Today", "This week"])
1868
+        button.selectItem(at: scheduleFilter.rawValue)
1869
+
1870
+        if let menu = button.menu {
1871
+            for (index, item) in menu.items.enumerated() {
1872
+                item.tag = index
1873
+            }
1874
+        }
1875
+
1876
+        scheduleFilterDropdown = button
1877
+        return button
1878
+    }
1879
+
1880
+    private func makeSchedulePillButton(title: String) -> NSButton {
1881
+        let button = NSButton(title: title, target: nil, action: nil)
1882
+        button.translatesAutoresizingMaskIntoConstraints = false
1883
+        button.isBordered = false
1884
+        button.bezelStyle = .regularSquare
1885
+        button.wantsLayer = true
1886
+        button.layer?.cornerRadius = 8
1887
+        button.layer?.backgroundColor = palette.inputBackground.cgColor
1888
+        button.layer?.borderColor = palette.inputBorder.cgColor
1889
+        button.layer?.borderWidth = 1
1890
+        button.font = typography.filterText
1891
+        button.contentTintColor = palette.textSecondary
1892
+        button.setButtonType(.momentaryChange)
1893
+        button.heightAnchor.constraint(equalToConstant: 34).isActive = true
1894
+        button.widthAnchor.constraint(greaterThanOrEqualToConstant: 132).isActive = true
1895
+        return button
1896
+    }
1897
+
1898
+    private func makeGoogleAuthButton() -> NSButton {
1899
+        let button = HoverButton(title: "", target: self, action: #selector(scheduleConnectButtonPressed(_:)))
1900
+        button.translatesAutoresizingMaskIntoConstraints = false
1901
+        button.isBordered = false
1902
+        button.bezelStyle = .regularSquare
1903
+        button.wantsLayer = true
1904
+        button.layer?.cornerRadius = 21
1905
+        button.layer?.borderWidth = 1
1906
+        button.font = NSFont.systemFont(ofSize: 14, weight: .semibold)
1907
+        button.imagePosition = .imageLeading
1908
+        button.alignment = .center
1909
+        button.imageHugsTitle = true
1910
+        button.lineBreakMode = .byTruncatingTail
1911
+        button.contentTintColor = palette.textPrimary
1912
+        button.imageScaling = .scaleNone
1913
+        button.heightAnchor.constraint(equalToConstant: 42).isActive = true
1914
+        let widthConstraint = button.widthAnchor.constraint(equalToConstant: 248)
1915
+        widthConstraint.isActive = true
1916
+        scheduleGoogleAuthButtonWidthConstraint = widthConstraint
1917
+        button.onHoverChanged = { [weak self] hovering in
1918
+            self?.scheduleGoogleAuthHovering = hovering
1919
+            self?.applyGoogleAuthButtonSurface()
1920
+        }
1921
+        button.onHoverChanged?(false)
1922
+        return button
1923
+    }
1924
+
1925
+    private func makeScheduleRefreshButton() -> NSButton {
1926
+        let button = NSButton(title: "", target: self, action: #selector(scheduleReloadButtonPressed(_:)))
1927
+        button.translatesAutoresizingMaskIntoConstraints = false
1928
+        button.isBordered = false
1929
+        button.bezelStyle = .regularSquare
1930
+        button.wantsLayer = true
1931
+        button.layer?.cornerRadius = 21
1932
+        button.layer?.backgroundColor = palette.inputBackground.cgColor
1933
+        button.layer?.borderColor = palette.inputBorder.cgColor
1934
+        button.layer?.borderWidth = 1
1935
+        button.setButtonType(.momentaryChange)
1936
+        button.contentTintColor = palette.textSecondary
1937
+        button.image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh meetings")
1938
+        button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
1939
+        button.imagePosition = .imageOnly
1940
+        button.imageScaling = .scaleProportionallyDown
1941
+        button.focusRingType = .none
1942
+        button.heightAnchor.constraint(equalToConstant: 42).isActive = true
1943
+        button.widthAnchor.constraint(equalToConstant: 42).isActive = true
1944
+        return button
1945
+    }
1946
+
1947
+    func scheduleCardsRow(meetings: [ScheduledMeeting]) -> NSView {
1948
+        let cardWidth: CGFloat = 240
1949
+        let cardsPerViewport: CGFloat = 3
1950
+        let viewportWidth = (cardWidth * cardsPerViewport) + (12 * (cardsPerViewport - 1))
1951
+
1952
+        let wrapper = NSStackView()
1953
+        wrapper.translatesAutoresizingMaskIntoConstraints = false
1954
+        wrapper.orientation = .horizontal
1955
+        wrapper.alignment = .centerY
1956
+        wrapper.spacing = 10
1957
+        let leftButton = makeScheduleScrollButton(systemSymbol: "chevron.left", action: #selector(scheduleScrollLeftPressed(_:)))
1958
+        scheduleScrollLeftButton = leftButton
1959
+        wrapper.addArrangedSubview(leftButton)
1960
+
1961
+        let scroll = NSScrollView()
1962
+        scheduleCardsScrollView = scroll
1963
+        scroll.translatesAutoresizingMaskIntoConstraints = false
1964
+        scroll.drawsBackground = false
1965
+        scroll.hasHorizontalScroller = false
1966
+        scroll.hasVerticalScroller = false
1967
+        scroll.horizontalScrollElasticity = .allowed
1968
+        scroll.verticalScrollElasticity = .none
1969
+        scroll.autohidesScrollers = false
1970
+        scroll.borderType = .noBorder
1971
+        scroll.heightAnchor.constraint(equalToConstant: 150).isActive = true
1972
+        scroll.widthAnchor.constraint(equalToConstant: viewportWidth).isActive = true
1973
+
1974
+        let row = NSStackView()
1975
+        row.translatesAutoresizingMaskIntoConstraints = false
1976
+        row.orientation = .horizontal
1977
+        row.spacing = 12
1812 1978
         row.alignment = .top
1813
-        row.distribution = .fill
1979
+        row.distribution = .gravityAreas
1814 1980
         row.setContentHuggingPriority(.defaultHigh, for: .horizontal)
1815
-        row.heightAnchor.constraint(equalToConstant: 136).isActive = true
1981
+        row.heightAnchor.constraint(equalToConstant: 150).isActive = true
1982
+        scheduleCardsStack = row
1816 1983
 
1817
-        row.addArrangedSubview(scheduleCard())
1818
-        row.addArrangedSubview(scheduleCard())
1819
-        return row
1984
+        scroll.documentView = row
1985
+        scroll.contentView.postsBoundsChangedNotifications = true
1986
+
1987
+        // Ensure the stack view determines content size for horizontal scrolling.
1988
+        NSLayoutConstraint.activate([
1989
+            row.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
1990
+            row.trailingAnchor.constraint(greaterThanOrEqualTo: scroll.contentView.trailingAnchor),
1991
+            row.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
1992
+            row.bottomAnchor.constraint(equalTo: scroll.contentView.bottomAnchor),
1993
+            row.heightAnchor.constraint(equalToConstant: 150)
1994
+        ])
1995
+
1996
+        renderScheduleCards(into: row, meetings: meetings)
1997
+        wrapper.addArrangedSubview(scroll)
1998
+        let rightButton = makeScheduleScrollButton(systemSymbol: "chevron.right", action: #selector(scheduleScrollRightPressed(_:)))
1999
+        scheduleScrollRightButton = rightButton
2000
+        wrapper.addArrangedSubview(rightButton)
2001
+        scroll.setContentHuggingPriority(.defaultLow, for: .horizontal)
2002
+        scroll.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
2003
+        wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
2004
+        return wrapper
1820 2005
     }
1821 2006
 
1822
-    func scheduleCard() -> NSView {
1823
-        let cardWidth: CGFloat = 264
2007
+    func scheduleCard(meeting: ScheduledMeeting) -> NSView {
2008
+        let cardWidth: CGFloat = 240
1824 2009
 
1825
-        let card = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
2010
+        let card = roundedContainer(cornerRadius: 12, color: palette.sectionCard)
1826 2011
         styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: true)
1827 2012
         card.translatesAutoresizingMaskIntoConstraints = false
1828 2013
         card.widthAnchor.constraint(equalToConstant: cardWidth).isActive = true
1829
-        card.heightAnchor.constraint(equalToConstant: 136).isActive = true
2014
+        card.heightAnchor.constraint(equalToConstant: 150).isActive = true
2015
+        card.setContentHuggingPriority(.required, for: .horizontal)
2016
+        card.setContentCompressionResistancePriority(.required, for: .horizontal)
1830 2017
 
1831
-        let icon = roundedContainer(cornerRadius: 5, color: palette.meetingBadge)
2018
+        let icon = roundedContainer(cornerRadius: 8, color: palette.meetingBadge)
1832 2019
         icon.translatesAutoresizingMaskIntoConstraints = false
1833
-        icon.widthAnchor.constraint(equalToConstant: 22).isActive = true
1834
-        icon.heightAnchor.constraint(equalToConstant: 22).isActive = true
1835
-        let iconText = textLabel("••", font: typography.cardIcon, color: .white)
1836
-        iconText.translatesAutoresizingMaskIntoConstraints = false
1837
-        icon.addSubview(iconText)
2020
+        icon.widthAnchor.constraint(equalToConstant: 28).isActive = true
2021
+        icon.heightAnchor.constraint(equalToConstant: 28).isActive = true
2022
+        let iconView = NSImageView()
2023
+        iconView.translatesAutoresizingMaskIntoConstraints = false
2024
+        iconView.image = NSImage(systemSymbolName: "video.circle.fill", accessibilityDescription: "Meeting")
2025
+        iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
2026
+        iconView.contentTintColor = .white
2027
+        icon.addSubview(iconView)
1838 2028
         NSLayoutConstraint.activate([
1839
-            iconText.centerXAnchor.constraint(equalTo: icon.centerXAnchor),
1840
-            iconText.centerYAnchor.constraint(equalTo: icon.centerYAnchor)
2029
+            iconView.centerXAnchor.constraint(equalTo: icon.centerXAnchor),
2030
+            iconView.centerYAnchor.constraint(equalTo: icon.centerYAnchor)
1841 2031
         ])
1842 2032
 
1843
-        let title = textLabel("General Meeting", font: typography.cardTitle, color: palette.textPrimary)
1844
-        let subtitle = textLabel("Baisakhi", font: typography.cardSubtitle, color: palette.textPrimary)
1845
-        let time = textLabel("12:00 AM - 11:59 PM", font: typography.cardTime, color: palette.textSecondary)
2033
+        let title = textLabel(meeting.title, font: typography.cardTitle, color: palette.textPrimary)
2034
+        title.lineBreakMode = .byTruncatingTail
2035
+        title.maximumNumberOfLines = 1
2036
+        title.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
2037
+        let subtitle = textLabel(meeting.subtitle ?? "Google Calendar", font: typography.cardSubtitle, color: palette.textPrimary)
2038
+        let time = textLabel(scheduleTimeText(for: meeting), font: typography.cardTime, color: palette.textSecondary)
2039
+        let duration = textLabel(scheduleDurationText(for: meeting), font: NSFont.systemFont(ofSize: 11, weight: .medium), color: palette.textMuted)
2040
+        let dayChip = roundedContainer(cornerRadius: 7, color: palette.inputBackground)
2041
+        dayChip.translatesAutoresizingMaskIntoConstraints = false
2042
+        dayChip.layer?.borderWidth = 1
2043
+        dayChip.layer?.borderColor = palette.inputBorder.withAlphaComponent(0.8).cgColor
2044
+        let dayText = textLabel(scheduleDayText(for: meeting), font: NSFont.systemFont(ofSize: 11, weight: .semibold), color: palette.textSecondary)
2045
+        dayText.translatesAutoresizingMaskIntoConstraints = false
2046
+        dayChip.addSubview(dayText)
2047
+        NSLayoutConstraint.activate([
2048
+            dayText.leadingAnchor.constraint(equalTo: dayChip.leadingAnchor, constant: 8),
2049
+            dayText.trailingAnchor.constraint(equalTo: dayChip.trailingAnchor, constant: -8),
2050
+            dayText.topAnchor.constraint(equalTo: dayChip.topAnchor, constant: 4),
2051
+            dayText.bottomAnchor.constraint(equalTo: dayChip.bottomAnchor, constant: -4)
2052
+        ])
1846 2053
 
1847 2054
         card.addSubview(icon)
2055
+        card.addSubview(dayChip)
1848 2056
         card.addSubview(title)
1849 2057
         card.addSubview(subtitle)
1850 2058
         card.addSubview(time)
2059
+        card.addSubview(duration)
1851 2060
 
1852 2061
         NSLayoutConstraint.activate([
1853 2062
             icon.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
1854 2063
             icon.topAnchor.constraint(equalTo: card.topAnchor, constant: 10),
1855 2064
 
2065
+            dayChip.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -10),
2066
+            dayChip.centerYAnchor.constraint(equalTo: icon.centerYAnchor),
2067
+
1856 2068
             title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 6),
1857 2069
             title.centerYAnchor.constraint(equalTo: icon.centerYAnchor),
1858
-            title.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
2070
+            title.trailingAnchor.constraint(lessThanOrEqualTo: dayChip.leadingAnchor, constant: -8),
2071
+            title.widthAnchor.constraint(lessThanOrEqualToConstant: 130),
1859 2072
 
1860 2073
             subtitle.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
1861
-            subtitle.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 7),
2074
+            subtitle.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 10),
2075
+            subtitle.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
1862 2076
 
1863 2077
             time.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
1864
-            time.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 4)
2078
+            time.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 5),
2079
+            time.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
2080
+
2081
+            duration.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
2082
+            duration.topAnchor.constraint(equalTo: time.bottomAnchor, constant: 4),
2083
+            duration.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10)
1865 2084
         ])
1866 2085
 
1867
-        return card
2086
+        let hit = HoverButton(title: "", target: self, action: #selector(scheduleCardButtonPressed(_:)))
2087
+        hit.translatesAutoresizingMaskIntoConstraints = false
2088
+        hit.isBordered = false
2089
+        hit.bezelStyle = .regularSquare
2090
+        hit.identifier = NSUserInterfaceItemIdentifier(meeting.meetURL.absoluteString)
2091
+        hit.widthAnchor.constraint(equalToConstant: cardWidth).isActive = true
2092
+        hit.heightAnchor.constraint(equalToConstant: 150).isActive = true
2093
+        hit.setContentHuggingPriority(.required, for: .horizontal)
2094
+        hit.setContentCompressionResistancePriority(.required, for: .horizontal)
2095
+        hit.addSubview(card)
2096
+        NSLayoutConstraint.activate([
2097
+            card.leadingAnchor.constraint(equalTo: hit.leadingAnchor),
2098
+            card.trailingAnchor.constraint(equalTo: hit.trailingAnchor),
2099
+            card.topAnchor.constraint(equalTo: hit.topAnchor),
2100
+            card.bottomAnchor.constraint(equalTo: hit.bottomAnchor)
2101
+        ])
2102
+        hit.onHoverChanged = { [weak self] hovering in
2103
+            guard let self else { return }
2104
+            let base = self.palette.sectionCard
2105
+            let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black
2106
+            let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
2107
+            card.layer?.backgroundColor = (hovering ? hover : base).cgColor
2108
+        }
2109
+        hit.onHoverChanged?(false)
2110
+
2111
+        return hit
2112
+    }
2113
+
2114
+    private func makeScheduleScrollButton(systemSymbol: String, action: Selector) -> NSButton {
2115
+        let button = NSButton(title: "", target: self, action: action)
2116
+        button.translatesAutoresizingMaskIntoConstraints = false
2117
+        button.isBordered = false
2118
+        button.bezelStyle = .regularSquare
2119
+        button.wantsLayer = true
2120
+        button.layer?.cornerRadius = 16
2121
+        button.layer?.backgroundColor = palette.inputBackground.cgColor
2122
+        button.layer?.borderColor = palette.inputBorder.cgColor
2123
+        button.layer?.borderWidth = 1
2124
+        button.image = NSImage(systemSymbolName: systemSymbol, accessibilityDescription: "Scroll meetings")
2125
+        button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
2126
+        button.imagePosition = .imageOnly
2127
+        button.imageScaling = .scaleProportionallyDown
2128
+        button.contentTintColor = palette.textSecondary
2129
+        button.focusRingType = .none
2130
+        button.heightAnchor.constraint(equalToConstant: 32).isActive = true
2131
+        button.widthAnchor.constraint(equalToConstant: 32).isActive = true
2132
+        return button
1868 2133
     }
1869 2134
 }
1870 2135
 
@@ -2375,7 +2640,7 @@ private extension ViewController {
2375 2640
         return button
2376 2641
     }
2377 2642
 
2378
-    func iconRoundButton(_ symbol: String, size: CGFloat) -> NSView {
2643
+    func iconRoundButton(systemSymbol: String, size: CGFloat, iconPointSize: CGFloat = 16, onClick: (() -> Void)? = nil) -> NSView {
2379 2644
         let button = HoverTrackingView()
2380 2645
         button.wantsLayer = true
2381 2646
         button.layer?.cornerRadius = size / 2
@@ -2385,11 +2650,16 @@ private extension ViewController {
2385 2650
         button.heightAnchor.constraint(equalToConstant: size).isActive = true
2386 2651
         styleSurface(button, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
2387 2652
 
2388
-        let label = textLabel(symbol, font: typography.iconButton, color: palette.textSecondary)
2389
-        button.addSubview(label)
2653
+        let symbolConfig = NSImage.SymbolConfiguration(pointSize: iconPointSize, weight: .semibold)
2654
+        let iconView = NSImageView()
2655
+        iconView.translatesAutoresizingMaskIntoConstraints = false
2656
+        iconView.image = NSImage(systemSymbolName: systemSymbol, accessibilityDescription: "Refresh")
2657
+        iconView.symbolConfiguration = symbolConfig
2658
+        iconView.contentTintColor = palette.textSecondary
2659
+        button.addSubview(iconView)
2390 2660
         NSLayoutConstraint.activate([
2391
-            label.centerXAnchor.constraint(equalTo: button.centerXAnchor),
2392
-            label.centerYAnchor.constraint(equalTo: button.centerYAnchor)
2661
+            iconView.centerXAnchor.constraint(equalTo: button.centerXAnchor),
2662
+            iconView.centerYAnchor.constraint(equalTo: button.centerYAnchor)
2393 2663
         ])
2394 2664
 
2395 2665
         let baseColor = palette.inputBackground
@@ -2399,11 +2669,456 @@ private extension ViewController {
2399 2669
             button.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
2400 2670
         }
2401 2671
         button.onHoverChanged?(false)
2672
+        button.onClick = onClick
2402 2673
 
2403 2674
         return button
2404 2675
     }
2405 2676
 }
2406 2677
 
2678
+// MARK: - Schedule actions (OAuth entry)
2679
+
2680
+private extension ViewController {
2681
+    @objc func scheduleReloadButtonPressed(_ sender: NSButton) {
2682
+        scheduleReloadClicked()
2683
+    }
2684
+
2685
+    @objc func scheduleScrollLeftPressed(_ sender: NSButton) {
2686
+        scrollScheduleCards(direction: -1)
2687
+    }
2688
+
2689
+    @objc func scheduleScrollRightPressed(_ sender: NSButton) {
2690
+        scrollScheduleCards(direction: 1)
2691
+    }
2692
+
2693
+    @objc func scheduleCardButtonPressed(_ sender: NSButton) {
2694
+        guard let raw = sender.identifier?.rawValue,
2695
+              let url = URL(string: raw) else { return }
2696
+        openMeetingURL(url)
2697
+    }
2698
+
2699
+    @objc func scheduleConnectButtonPressed(_ sender: NSButton) {
2700
+        scheduleConnectClicked()
2701
+    }
2702
+
2703
+    private func scheduleInitialHeadingText() -> String {
2704
+        googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "Loading…"
2705
+    }
2706
+
2707
+    @objc func scheduleFilterDropdownChanged(_ sender: NSPopUpButton) {
2708
+        guard let selectedItem = sender.selectedItem,
2709
+              let filter = ScheduleFilter(rawValue: selectedItem.tag) else { return }
2710
+        applyScheduleFilter(filter)
2711
+    }
2712
+
2713
+    private func applyScheduleFilter(_ filter: ScheduleFilter) {
2714
+        scheduleFilter = filter
2715
+        scheduleFilterDropdown?.selectItem(at: filter.rawValue)
2716
+        Task { [weak self] in
2717
+            await self?.loadSchedule()
2718
+        }
2719
+    }
2720
+
2721
+    private func scheduleTimeText(for meeting: ScheduledMeeting) -> String {
2722
+        if meeting.isAllDay { return "All day" }
2723
+        let f = DateFormatter()
2724
+        f.locale = Locale.current
2725
+        f.timeZone = TimeZone.current
2726
+        f.dateStyle = .none
2727
+        f.timeStyle = .short
2728
+        return "\(f.string(from: meeting.startDate)) - \(f.string(from: meeting.endDate))"
2729
+    }
2730
+
2731
+    private func scheduleDayText(for meeting: ScheduledMeeting) -> String {
2732
+        let f = DateFormatter()
2733
+        f.locale = Locale.current
2734
+        f.timeZone = TimeZone.current
2735
+        f.dateFormat = "EEE, d MMM"
2736
+        return f.string(from: meeting.startDate)
2737
+    }
2738
+
2739
+    private func scheduleDurationText(for meeting: ScheduledMeeting) -> String {
2740
+        if meeting.isAllDay { return "Duration: all day" }
2741
+        let duration = max(0, meeting.endDate.timeIntervalSince(meeting.startDate))
2742
+        let totalMinutes = Int(duration / 60)
2743
+        let hours = totalMinutes / 60
2744
+        let minutes = totalMinutes % 60
2745
+        if hours > 0, minutes > 0 { return "Duration: \(hours)h \(minutes)m" }
2746
+        if hours > 0 { return "Duration: \(hours)h" }
2747
+        return "Duration: \(minutes)m"
2748
+    }
2749
+
2750
+    private func scheduleHeadingText(for meetings: [ScheduledMeeting]) -> String {
2751
+        guard let first = meetings.first else {
2752
+            return googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "No upcoming meetings"
2753
+        }
2754
+
2755
+        let day = Calendar.current.startOfDay(for: first.startDate)
2756
+        let f = DateFormatter()
2757
+        f.locale = Locale.current
2758
+        f.timeZone = TimeZone.current
2759
+        f.dateFormat = "EEEE, d MMM"
2760
+        return f.string(from: day)
2761
+    }
2762
+
2763
+    private func openMeetingURL(_ url: URL) {
2764
+        NSWorkspace.shared.open(url)
2765
+    }
2766
+
2767
+    private func renderScheduleCards(into stack: NSStackView, meetings: [ScheduledMeeting]) {
2768
+        let shouldShowScrollControls = meetings.count > 3
2769
+        scheduleScrollLeftButton?.isHidden = !shouldShowScrollControls
2770
+        scheduleScrollRightButton?.isHidden = !shouldShowScrollControls
2771
+        scheduleCardsScrollView?.contentView.setBoundsOrigin(.zero)
2772
+        if let scroll = scheduleCardsScrollView {
2773
+            scroll.reflectScrolledClipView(scroll.contentView)
2774
+        }
2775
+
2776
+        stack.arrangedSubviews.forEach { v in
2777
+            stack.removeArrangedSubview(v)
2778
+            v.removeFromSuperview()
2779
+        }
2780
+
2781
+        if meetings.isEmpty {
2782
+            let empty = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
2783
+            empty.translatesAutoresizingMaskIntoConstraints = false
2784
+            empty.widthAnchor.constraint(equalToConstant: 240).isActive = true
2785
+            empty.heightAnchor.constraint(equalToConstant: 150).isActive = true
2786
+            styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
2787
+
2788
+            let label = textLabel(googleOAuth.loadTokens() == nil ? "Connect to load schedule" : "No meetings", font: typography.cardSubtitle, color: palette.textSecondary)
2789
+            label.translatesAutoresizingMaskIntoConstraints = false
2790
+            empty.addSubview(label)
2791
+            NSLayoutConstraint.activate([
2792
+                label.centerXAnchor.constraint(equalTo: empty.centerXAnchor),
2793
+                label.centerYAnchor.constraint(equalTo: empty.centerYAnchor)
2794
+            ])
2795
+            stack.addArrangedSubview(empty)
2796
+            return
2797
+        }
2798
+
2799
+        for meeting in meetings {
2800
+            stack.addArrangedSubview(scheduleCard(meeting: meeting))
2801
+        }
2802
+    }
2803
+
2804
+    private func filteredMeetings(_ meetings: [ScheduledMeeting]) -> [ScheduledMeeting] {
2805
+        switch scheduleFilter {
2806
+        case .all:
2807
+            return meetings
2808
+        case .today:
2809
+            let start = Calendar.current.startOfDay(for: Date())
2810
+            let end = Calendar.current.date(byAdding: .day, value: 1, to: start) ?? start.addingTimeInterval(86400)
2811
+            return meetings.filter { $0.startDate >= start && $0.startDate < end }
2812
+        case .week:
2813
+            let now = Date()
2814
+            let end = Calendar.current.date(byAdding: .day, value: 7, to: now) ?? now.addingTimeInterval(7 * 86400)
2815
+            return meetings.filter { $0.startDate >= now && $0.startDate <= end }
2816
+        }
2817
+    }
2818
+
2819
+    private func scrollScheduleCards(direction: Int) {
2820
+        guard let scroll = scheduleCardsScrollView else { return }
2821
+        let contentBounds = scroll.contentView.bounds
2822
+        let step = max(220, contentBounds.width * 0.7)
2823
+        let proposedX = contentBounds.origin.x + (CGFloat(direction) * step)
2824
+        let maxX = max(0, scroll.documentView?.bounds.width ?? 0 - contentBounds.width)
2825
+        let nextX = min(max(0, proposedX), maxX)
2826
+        scroll.contentView.animator().setBoundsOrigin(NSPoint(x: nextX, y: 0))
2827
+        scroll.reflectScrolledClipView(scroll.contentView)
2828
+    }
2829
+
2830
+    private func loadSchedule() async {
2831
+        do {
2832
+            if googleOAuth.loadTokens() == nil {
2833
+                await MainActor.run {
2834
+                    updateGoogleAuthButtonTitle()
2835
+                    applyGoogleProfile(nil)
2836
+                    scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
2837
+                    if let stack = scheduleCardsStack {
2838
+                        renderScheduleCards(into: stack, meetings: [])
2839
+                    }
2840
+                }
2841
+                return
2842
+            }
2843
+
2844
+            let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
2845
+            let profile = try? await googleOAuth.fetchUserProfile(accessToken: token)
2846
+            let meetings = try await calendarClient.fetchUpcomingMeetings(accessToken: token)
2847
+            let filtered = filteredMeetings(meetings)
2848
+
2849
+            await MainActor.run {
2850
+                updateGoogleAuthButtonTitle()
2851
+                applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
2852
+                scheduleDateHeadingLabel?.stringValue = scheduleHeadingText(for: filtered)
2853
+                if let stack = scheduleCardsStack {
2854
+                    renderScheduleCards(into: stack, meetings: filtered)
2855
+                }
2856
+            }
2857
+        } catch {
2858
+            await MainActor.run {
2859
+                updateGoogleAuthButtonTitle()
2860
+                if googleOAuth.loadTokens() == nil {
2861
+                    applyGoogleProfile(nil)
2862
+                }
2863
+                scheduleDateHeadingLabel?.stringValue = "Couldn’t load schedule"
2864
+                if let stack = scheduleCardsStack {
2865
+                    renderScheduleCards(into: stack, meetings: [])
2866
+                }
2867
+                showSimpleError("Couldn’t load schedule.", error: error)
2868
+            }
2869
+        }
2870
+    }
2871
+
2872
+    func showScheduleHelp() {
2873
+        let alert = NSAlert()
2874
+        alert.messageText = "Google schedule"
2875
+        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."
2876
+        alert.addButton(withTitle: "OK")
2877
+        alert.runModal()
2878
+    }
2879
+
2880
+    func scheduleReloadClicked() {
2881
+        Task { [weak self] in
2882
+            guard let self else { return }
2883
+            do {
2884
+                try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
2885
+                _ = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
2886
+                await MainActor.run {
2887
+                    self.scheduleDateHeadingLabel?.stringValue = "Refreshing…"
2888
+                    self.pageCache[.joinMeetings] = nil
2889
+                    self.showSidebarPage(.joinMeetings)
2890
+                }
2891
+                await self.loadSchedule()
2892
+            } catch {
2893
+                await MainActor.run {
2894
+                    self.showSimpleError("Couldn’t refresh schedule.", error: error)
2895
+                }
2896
+            }
2897
+        }
2898
+    }
2899
+
2900
+    func scheduleConnectClicked() {
2901
+        Task { [weak self] in
2902
+            guard let self else { return }
2903
+            do {
2904
+                if self.googleOAuth.loadTokens() != nil {
2905
+                    await MainActor.run { self.showGoogleAccountMenu() }
2906
+                    return
2907
+                }
2908
+
2909
+                try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
2910
+                let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
2911
+                let profile = try? await self.googleOAuth.fetchUserProfile(accessToken: token)
2912
+                await MainActor.run {
2913
+                    self.updateGoogleAuthButtonTitle()
2914
+                    self.applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
2915
+                    self.pageCache[.joinMeetings] = nil
2916
+                    self.showSidebarPage(.joinMeetings)
2917
+                }
2918
+            } catch {
2919
+                self.showSimpleError("Couldn’t connect Google account.", error: error)
2920
+            }
2921
+        }
2922
+    }
2923
+
2924
+    private func showGoogleAccountMenu() {
2925
+        guard let button = scheduleGoogleAuthButton else { return }
2926
+        let menu = NSMenu()
2927
+
2928
+        let name = scheduleCurrentProfile?.name ?? "Google account"
2929
+        let email = scheduleCurrentProfile?.email ?? "Signed in"
2930
+        let accountItem = NSMenuItem(title: "\(name) (\(email))", action: nil, keyEquivalent: "")
2931
+        accountItem.isEnabled = false
2932
+        menu.addItem(accountItem)
2933
+        menu.addItem(.separator())
2934
+
2935
+        let logoutItem = NSMenuItem(title: "Logout", action: #selector(scheduleLogoutSelected(_:)), keyEquivalent: "")
2936
+        logoutItem.target = self
2937
+        menu.addItem(logoutItem)
2938
+
2939
+        let point = NSPoint(x: 0, y: button.bounds.height + 2)
2940
+        menu.popUp(positioning: nil, at: point, in: button)
2941
+    }
2942
+
2943
+    @objc private func scheduleLogoutSelected(_ sender: NSMenuItem) {
2944
+        do {
2945
+            try googleOAuth.signOut()
2946
+            updateGoogleAuthButtonTitle()
2947
+            applyGoogleProfile(nil)
2948
+            scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
2949
+            if let stack = scheduleCardsStack {
2950
+                renderScheduleCards(into: stack, meetings: [])
2951
+            }
2952
+        } catch {
2953
+            showSimpleError("Couldn’t logout Google account.", error: error)
2954
+        }
2955
+    }
2956
+
2957
+    private func updateGoogleAuthButtonTitle() {
2958
+        let signedIn = (googleOAuth.loadTokens() != nil)
2959
+        guard let button = scheduleGoogleAuthButton else { return }
2960
+
2961
+        let profileName = scheduleCurrentProfile?.name ?? "Google account"
2962
+        let profileEmail = scheduleCurrentProfile?.email ?? "Sign in with Google"
2963
+        let title = signedIn ? "\(profileName)  ·  \(profileEmail)" : "Sign in with Google"
2964
+        let titleFont = NSFont.systemFont(ofSize: 14, weight: .medium)
2965
+        let titleColor = darkModeEnabled ? NSColor(calibratedWhite: 0.96, alpha: 1) : NSColor(calibratedRed: 0.13, green: 0.14, blue: 0.16, alpha: 1)
2966
+        button.attributedTitle = NSAttributedString(string: title, attributes: [
2967
+            .font: titleFont,
2968
+            .foregroundColor: titleColor
2969
+        ])
2970
+        let textWidth = (title as NSString).size(withAttributes: [.font: titleFont]).width
2971
+        let idealWidth = ceil(textWidth + 80) // icon + spacing + side padding
2972
+        scheduleGoogleAuthButtonWidthConstraint?.constant = min(320, max(188, idealWidth))
2973
+
2974
+        if signedIn {
2975
+            let symbol = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
2976
+            button.image = symbol.flatMap { paddedTrailingImage($0, iconSize: NSSize(width: 22, height: 22), trailingPadding: 8) }
2977
+            button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 22, weight: .regular)
2978
+            button.contentTintColor = palette.textPrimary
2979
+        } else {
2980
+            if let g = NSImage(named: "GoogleGLogo") {
2981
+                button.image = paddedTrailingImage(g, iconSize: NSSize(width: 22, height: 22), trailingPadding: 8)
2982
+            } else {
2983
+                button.image = nil
2984
+            }
2985
+            button.contentTintColor = nil
2986
+        }
2987
+        button.contentTintColor = signedIn ? palette.textPrimary : nil
2988
+
2989
+        applyGoogleAuthButtonSurface()
2990
+    }
2991
+
2992
+    private func makeGoogleProfileDisplay(from profile: GoogleUserProfile) -> GoogleProfileDisplay {
2993
+        let cleanedName = profile.name?.trimmingCharacters(in: .whitespacesAndNewlines)
2994
+        let cleanedEmail = profile.email?.trimmingCharacters(in: .whitespacesAndNewlines)
2995
+        return GoogleProfileDisplay(
2996
+            name: (cleanedName?.isEmpty == false ? cleanedName : nil) ?? "Google User",
2997
+            email: (cleanedEmail?.isEmpty == false ? cleanedEmail : nil) ?? "Signed in",
2998
+            pictureURL: profile.picture.flatMap(URL.init(string:))
2999
+        )
3000
+    }
3001
+
3002
+    private func applyGoogleProfile(_ profile: GoogleProfileDisplay?) {
3003
+        scheduleProfileImageTask?.cancel()
3004
+        scheduleProfileImageTask = nil
3005
+        scheduleCurrentProfile = profile
3006
+
3007
+        updateGoogleAuthButtonTitle()
3008
+
3009
+        guard let profile, let pictureURL = profile.pictureURL else { return }
3010
+        scheduleProfileImageTask = Task { [weak self] in
3011
+            do {
3012
+                let (data, _) = try await URLSession.shared.data(from: pictureURL)
3013
+                if Task.isCancelled { return }
3014
+                guard let image = NSImage(data: data) else { return }
3015
+                await MainActor.run {
3016
+                    self?.scheduleGoogleAuthButton?.image = self?.paddedTrailingImage(image, iconSize: NSSize(width: 22, height: 22), trailingPadding: 8)
3017
+                    self?.scheduleGoogleAuthButton?.contentTintColor = nil
3018
+                }
3019
+            } catch {
3020
+                // Keep placeholder avatar if image fetch fails.
3021
+            }
3022
+        }
3023
+    }
3024
+
3025
+    private func resizedImage(_ image: NSImage, to size: NSSize) -> NSImage {
3026
+        let result = NSImage(size: size)
3027
+        result.lockFocus()
3028
+        image.draw(in: NSRect(origin: .zero, size: size),
3029
+                   from: NSRect(origin: .zero, size: image.size),
3030
+                   operation: .copy,
3031
+                   fraction: 1.0)
3032
+        result.unlockFocus()
3033
+        result.isTemplate = false
3034
+        return result
3035
+    }
3036
+
3037
+    private func paddedTrailingImage(_ image: NSImage, iconSize: NSSize, trailingPadding: CGFloat) -> NSImage {
3038
+        let base = resizedImage(image, to: iconSize)
3039
+        let canvas = NSSize(width: iconSize.width + trailingPadding, height: iconSize.height)
3040
+        let result = NSImage(size: canvas)
3041
+        result.lockFocus()
3042
+        base.draw(in: NSRect(x: 0, y: 0, width: iconSize.width, height: iconSize.height),
3043
+                  from: NSRect(origin: .zero, size: base.size),
3044
+                  operation: .copy,
3045
+                  fraction: 1.0)
3046
+        result.unlockFocus()
3047
+        result.isTemplate = false
3048
+        return result
3049
+    }
3050
+
3051
+    private func applyGoogleAuthButtonSurface() {
3052
+        guard let button = scheduleGoogleAuthButton else { return }
3053
+        let isDark = darkModeEnabled
3054
+        let baseBackground = isDark
3055
+            ? NSColor(calibratedRed: 8.0 / 255.0, green: 14.0 / 255.0, blue: 24.0 / 255.0, alpha: 1)
3056
+            : NSColor.white
3057
+        let hoverBlend = isDark ? NSColor.white : NSColor.black
3058
+        let hoverBackground = baseBackground.blended(withFraction: 0.07, of: hoverBlend) ?? baseBackground
3059
+        let baseBorder = isDark
3060
+            ? NSColor(calibratedWhite: 0.50, alpha: 1)
3061
+            : NSColor(calibratedWhite: 0.72, alpha: 1)
3062
+        let hoverBorder = isDark
3063
+            ? NSColor(calibratedWhite: 0.62, alpha: 1)
3064
+            : NSColor(calibratedWhite: 0.56, alpha: 1)
3065
+        button.layer?.backgroundColor = (scheduleGoogleAuthHovering ? hoverBackground : baseBackground).cgColor
3066
+        button.layer?.borderColor = (scheduleGoogleAuthHovering ? hoverBorder : baseBorder).cgColor
3067
+    }
3068
+
3069
+    @MainActor
3070
+    func ensureGoogleClientIdConfigured(presentingWindow: NSWindow?) async throws {
3071
+        if googleOAuth.configuredClientId() != nil, googleOAuth.configuredClientSecret() != nil { return }
3072
+
3073
+        let alert = NSAlert()
3074
+        alert.messageText = "Enter Google OAuth credentials"
3075
+        alert.informativeText = "Paste the OAuth Client ID and Client Secret from your downloaded Desktop OAuth JSON."
3076
+
3077
+        let accessory = NSStackView()
3078
+        accessory.orientation = .vertical
3079
+        accessory.spacing = 8
3080
+        accessory.alignment = .leading
3081
+
3082
+        let idField = NSTextField(string: googleOAuth.configuredClientId() ?? "")
3083
+        idField.placeholderString = "Client ID (....apps.googleusercontent.com)"
3084
+        idField.frame = NSRect(x: 0, y: 0, width: 460, height: 24)
3085
+
3086
+        let secretField = NSSecureTextField(string: googleOAuth.configuredClientSecret() ?? "")
3087
+        secretField.placeholderString = "Client Secret (GOCSPX-...)"
3088
+        secretField.frame = NSRect(x: 0, y: 0, width: 460, height: 24)
3089
+
3090
+        accessory.addArrangedSubview(idField)
3091
+        accessory.addArrangedSubview(secretField)
3092
+        alert.accessoryView = accessory
3093
+
3094
+        alert.addButton(withTitle: "Save")
3095
+        alert.addButton(withTitle: "Cancel")
3096
+
3097
+        // Keep this synchronous to avoid additional sheet state handling.
3098
+        let response = alert.runModal()
3099
+        if response != .alertFirstButtonReturn { throw GoogleOAuthError.missingClientId }
3100
+
3101
+        let idValue = idField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
3102
+        let secretValue = secretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
3103
+        if idValue.isEmpty { throw GoogleOAuthError.missingClientId }
3104
+        if secretValue.isEmpty { throw GoogleOAuthError.missingClientSecret }
3105
+
3106
+        googleOAuth.setClientIdForTesting(idValue)
3107
+        googleOAuth.setClientSecretForTesting(secretValue)
3108
+    }
3109
+
3110
+    func showSimpleError(_ title: String, error: Error) {
3111
+        DispatchQueue.main.async {
3112
+            let alert = NSAlert()
3113
+            alert.alertStyle = .warning
3114
+            alert.messageText = title
3115
+            alert.informativeText = error.localizedDescription
3116
+            alert.addButton(withTitle: "OK")
3117
+            alert.runModal()
3118
+        }
3119
+    }
3120
+}
3121
+
2407 3122
 private struct Palette {
2408 3123
     let pageBackground: NSColor
2409 3124
     let sidebarBackground: NSColor
@@ -2514,11 +3229,8 @@ private func inAppBrowserURLAllowed(_ url: URL, policy: InAppBrowserURLPolicy) -
2514 3229
 }
2515 3230
 
2516 3231
 private enum InAppBrowserWebKitSupport {
2517
-    static let sharedProcessPool = WKProcessPool()
2518
-
2519 3232
     static func makeWebViewConfiguration() -> WKWebViewConfiguration {
2520 3233
         let config = WKWebViewConfiguration()
2521
-        config.processPool = sharedProcessPool
2522 3234
         config.websiteDataStore = .default()
2523 3235
         config.preferences.javaScriptCanOpenWindowsAutomatically = true
2524 3236
         if #available(macOS 12.3, *) {

+ 2 - 0
meetings_app/meetings_app.entitlements

@@ -6,6 +6,8 @@
6 6
 	<true/>
7 7
 	<key>com.apple.security.network.client</key>
8 8
 	<true/>
9
+	<key>com.apple.security.network.server</key>
10
+	<true/>
9 11
 	<key>com.apple.security.device.camera</key>
10 12
 	<true/>
11 13
 	<key>com.apple.security.device.audio-input</key>