Bladeren bron

Integrate Google OAuth schedule sync for Meet events.

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

Made-with: Cursor
huzaifahayat12 1 week geleden
bovenliggende
commit
08ac3971a8

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

+ 418 - 0
meetings_app/Auth/GoogleOAuthService.swift

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

+ 72 - 0
meetings_app/Auth/KeychainTokenStore.swift

@@ -0,0 +1,72 @@
1
+import Foundation
2
+import Security
3
+
4
+enum KeychainTokenStoreError: Error {
5
+    case unexpectedStatus(OSStatus)
6
+    case missingData
7
+}
8
+
9
+final class KeychainTokenStore {
10
+    private let service: String
11
+    private let account: String
12
+
13
+    init(service: String = Bundle.main.bundleIdentifier ?? "meetings_app",
14
+         account: String = "googleOAuthTokens") {
15
+        self.service = service
16
+        self.account = account
17
+    }
18
+
19
+    func readTokens() throws -> GoogleOAuthTokens? {
20
+        let query: [String: Any] = [
21
+            kSecClass as String: kSecClassGenericPassword,
22
+            kSecAttrService as String: service,
23
+            kSecAttrAccount as String: account,
24
+            kSecMatchLimit as String: kSecMatchLimitOne,
25
+            kSecReturnData as String: true
26
+        ]
27
+
28
+        // Access group / accessibility can be set here if needed later.
29
+        var item: CFTypeRef?
30
+        let status = SecItemCopyMatching(query as CFDictionary, &item)
31
+        if status == errSecItemNotFound { return nil }
32
+        guard status == errSecSuccess else { throw KeychainTokenStoreError.unexpectedStatus(status) }
33
+        guard let data = item as? Data else { throw KeychainTokenStoreError.missingData }
34
+        return try JSONDecoder().decode(GoogleOAuthTokens.self, from: data)
35
+    }
36
+
37
+    func writeTokens(_ tokens: GoogleOAuthTokens) throws {
38
+        let data = try JSONEncoder().encode(tokens)
39
+
40
+        // Upsert.
41
+        let query: [String: Any] = [
42
+            kSecClass as String: kSecClassGenericPassword,
43
+            kSecAttrService as String: service,
44
+            kSecAttrAccount as String: account
45
+        ]
46
+
47
+        let attributes: [String: Any] = [
48
+            kSecValueData as String: data
49
+        ]
50
+
51
+        let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
52
+        if status == errSecSuccess { return }
53
+        if status != errSecItemNotFound { throw KeychainTokenStoreError.unexpectedStatus(status) }
54
+
55
+        var add = query
56
+        add[kSecValueData as String] = data
57
+        let addStatus = SecItemAdd(add as CFDictionary, nil)
58
+        guard addStatus == errSecSuccess else { throw KeychainTokenStoreError.unexpectedStatus(addStatus) }
59
+    }
60
+
61
+    func deleteTokens() throws {
62
+        let query: [String: Any] = [
63
+            kSecClass as String: kSecClassGenericPassword,
64
+            kSecAttrService as String: service,
65
+            kSecAttrAccount as String: account
66
+        ]
67
+        let status = SecItemDelete(query as CFDictionary)
68
+        if status == errSecSuccess || status == errSecItemNotFound { return }
69
+        throw KeychainTokenStoreError.unexpectedStatus(status)
70
+    }
71
+}
72
+

+ 203 - 0
meetings_app/Google/GoogleCalendarClient.swift

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

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

+ 361 - 19
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
@@ -61,6 +62,19 @@ final class ViewController: NSViewController {
61 62
     private weak var meetLinkField: NSTextField?
62 63
     private weak var browseAddressField: NSTextField?
63 64
     private var inAppBrowserWindowController: InAppBrowserWindowController?
65
+    private let googleOAuth = GoogleOAuthService.shared
66
+    private let calendarClient = GoogleCalendarClient()
67
+
68
+    private enum ScheduleFilter: Int {
69
+        case all = 0
70
+        case today = 1
71
+        case week = 2
72
+    }
73
+
74
+    private var scheduleFilter: ScheduleFilter = .all
75
+    private weak var scheduleDateHeadingLabel: NSTextField?
76
+    private weak var scheduleCardsStack: NSStackView?
77
+    private weak var scheduleFilterLabel: NSTextField?
64 78
 
65 79
     /// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
66 80
     private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
@@ -909,8 +923,13 @@ private extension ViewController {
909 923
         contentStack.addArrangedSubview(joinActions)
910 924
         contentStack.setCustomSpacing(26, after: joinActions)
911 925
         contentStack.addArrangedSubview(scheduleHeader())
912
-        contentStack.addArrangedSubview(textLabel("Tuesday, 14 Apr", font: typography.dateHeading, color: palette.textSecondary))
913
-        contentStack.addArrangedSubview(scheduleCardsRow())
926
+
927
+        let dateHeading = textLabel(scheduleInitialHeadingText(), font: typography.dateHeading, color: palette.textSecondary)
928
+        scheduleDateHeadingLabel = dateHeading
929
+        contentStack.addArrangedSubview(dateHeading)
930
+
931
+        let cardsRow = scheduleCardsRow(meetings: [])
932
+        contentStack.addArrangedSubview(cardsRow)
914 933
 
915 934
         panel.addSubview(contentStack)
916 935
 
@@ -920,6 +939,10 @@ private extension ViewController {
920 939
             contentStack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 26)
921 940
         ])
922 941
 
942
+        Task { [weak self] in
943
+            await self?.loadSchedule()
944
+        }
945
+
923 946
         return panel
924 947
     }
925 948
 
@@ -1777,15 +1800,25 @@ private extension ViewController {
1777 1800
         row.addArrangedSubview(spacer)
1778 1801
         spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
1779 1802
 
1780
-        row.addArrangedSubview(iconRoundButton("?", size: 34))
1781
-        row.addArrangedSubview(iconRoundButton("⟳", size: 34))
1803
+        row.addArrangedSubview(iconRoundButton("?", size: 34, onClick: { [weak self] in
1804
+            self?.showScheduleHelp()
1805
+        }))
1806
+        row.addArrangedSubview(iconRoundButton("⟳", size: 34, onClick: { [weak self] in
1807
+            self?.scheduleReloadClicked()
1808
+        }))
1809
+
1810
+        let connectButton = makeSchedulePillButton(title: googleOAuth.loadTokens() == nil ? "Connect" : "Connected")
1811
+        connectButton.target = self
1812
+        connectButton.action = #selector(scheduleConnectButtonPressed(_:))
1813
+        row.addArrangedSubview(connectButton)
1782 1814
 
1783 1815
         let filter = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
1784 1816
         filter.translatesAutoresizingMaskIntoConstraints = false
1785 1817
         filter.widthAnchor.constraint(equalToConstant: 156).isActive = true
1786 1818
         filter.heightAnchor.constraint(equalToConstant: 34).isActive = true
1787 1819
         styleSurface(filter, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
1788
-        let filterText = textLabel("All", font: typography.filterText, color: palette.textSecondary)
1820
+        let filterText = textLabel(scheduleFilterTitle(scheduleFilter), font: typography.filterText, color: palette.textSecondary)
1821
+        scheduleFilterLabel = filterText
1789 1822
         let arrow = textLabel("▾", font: typography.filterArrow, color: palette.textMuted)
1790 1823
         filterText.translatesAutoresizingMaskIntoConstraints = false
1791 1824
         arrow.translatesAutoresizingMaskIntoConstraints = false
@@ -1799,12 +1832,56 @@ private extension ViewController {
1799 1832
             arrow.centerYAnchor.constraint(equalTo: filter.centerYAnchor)
1800 1833
         ])
1801 1834
 
1802
-        row.addArrangedSubview(filter)
1835
+        let filterHit = HoverTrackingView()
1836
+        filterHit.translatesAutoresizingMaskIntoConstraints = false
1837
+        filterHit.addSubview(filter)
1838
+        NSLayoutConstraint.activate([
1839
+            filterHit.widthAnchor.constraint(equalToConstant: 156),
1840
+            filterHit.heightAnchor.constraint(equalToConstant: 34),
1841
+            filter.leadingAnchor.constraint(equalTo: filterHit.leadingAnchor),
1842
+            filter.trailingAnchor.constraint(equalTo: filterHit.trailingAnchor),
1843
+            filter.topAnchor.constraint(equalTo: filterHit.topAnchor),
1844
+            filter.bottomAnchor.constraint(equalTo: filterHit.bottomAnchor)
1845
+        ])
1846
+        filterHit.onClick = { [weak self, weak filterHit] in
1847
+            guard let self, let anchor = filterHit else { return }
1848
+            self.showScheduleFilterMenu(anchor: anchor)
1849
+        }
1850
+
1851
+        row.addArrangedSubview(filterHit)
1803 1852
         row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
1804 1853
         return row
1805 1854
     }
1806 1855
 
1807
-    func scheduleCardsRow() -> NSView {
1856
+    private func makeSchedulePillButton(title: String) -> NSButton {
1857
+        let button = NSButton(title: title, target: nil, action: nil)
1858
+        button.translatesAutoresizingMaskIntoConstraints = false
1859
+        button.isBordered = false
1860
+        button.bezelStyle = .regularSquare
1861
+        button.wantsLayer = true
1862
+        button.layer?.cornerRadius = 8
1863
+        button.layer?.backgroundColor = palette.inputBackground.cgColor
1864
+        button.layer?.borderColor = palette.inputBorder.cgColor
1865
+        button.layer?.borderWidth = 1
1866
+        button.font = typography.filterText
1867
+        button.contentTintColor = palette.textSecondary
1868
+        button.setButtonType(.momentaryChange)
1869
+        button.heightAnchor.constraint(equalToConstant: 34).isActive = true
1870
+        button.widthAnchor.constraint(greaterThanOrEqualToConstant: 104).isActive = true
1871
+        return button
1872
+    }
1873
+
1874
+    func scheduleCardsRow(meetings: [ScheduledMeeting]) -> NSView {
1875
+        let scroll = NSScrollView()
1876
+        scroll.translatesAutoresizingMaskIntoConstraints = false
1877
+        scroll.drawsBackground = false
1878
+        scroll.hasHorizontalScroller = true
1879
+        scroll.hasVerticalScroller = false
1880
+        scroll.horizontalScrollElasticity = .allowed
1881
+        scroll.verticalScrollElasticity = .none
1882
+        scroll.autohidesScrollers = true
1883
+        scroll.borderType = .noBorder
1884
+
1808 1885
         let row = NSStackView()
1809 1886
         row.translatesAutoresizingMaskIntoConstraints = false
1810 1887
         row.orientation = .horizontal
@@ -1813,13 +1890,25 @@ private extension ViewController {
1813 1890
         row.distribution = .fill
1814 1891
         row.setContentHuggingPriority(.defaultHigh, for: .horizontal)
1815 1892
         row.heightAnchor.constraint(equalToConstant: 136).isActive = true
1893
+        scheduleCardsStack = row
1816 1894
 
1817
-        row.addArrangedSubview(scheduleCard())
1818
-        row.addArrangedSubview(scheduleCard())
1819
-        return row
1895
+        scroll.documentView = row
1896
+        scroll.contentView.postsBoundsChangedNotifications = true
1897
+
1898
+        // Ensure the stack view determines content size for horizontal scrolling.
1899
+        NSLayoutConstraint.activate([
1900
+            row.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
1901
+            row.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
1902
+            row.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
1903
+            row.bottomAnchor.constraint(equalTo: scroll.contentView.bottomAnchor),
1904
+            row.heightAnchor.constraint(equalToConstant: 136)
1905
+        ])
1906
+
1907
+        renderScheduleCards(into: row, meetings: meetings)
1908
+        return scroll
1820 1909
     }
1821 1910
 
1822
-    func scheduleCard() -> NSView {
1911
+    func scheduleCard(meeting: ScheduledMeeting) -> NSView {
1823 1912
         let cardWidth: CGFloat = 264
1824 1913
 
1825 1914
         let card = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
@@ -1840,9 +1929,9 @@ private extension ViewController {
1840 1929
             iconText.centerYAnchor.constraint(equalTo: icon.centerYAnchor)
1841 1930
         ])
1842 1931
 
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)
1932
+        let title = textLabel(meeting.title, font: typography.cardTitle, color: palette.textPrimary)
1933
+        let subtitle = textLabel(meeting.subtitle ?? "Google Calendar", font: typography.cardSubtitle, color: palette.textPrimary)
1934
+        let time = textLabel(scheduleTimeText(for: meeting), font: typography.cardTime, color: palette.textSecondary)
1846 1935
 
1847 1936
         card.addSubview(icon)
1848 1937
         card.addSubview(title)
@@ -1864,7 +1953,28 @@ private extension ViewController {
1864 1953
             time.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 4)
1865 1954
         ])
1866 1955
 
1867
-        return card
1956
+        let hit = HoverTrackingView()
1957
+        hit.translatesAutoresizingMaskIntoConstraints = false
1958
+        hit.addSubview(card)
1959
+        NSLayoutConstraint.activate([
1960
+            card.leadingAnchor.constraint(equalTo: hit.leadingAnchor),
1961
+            card.trailingAnchor.constraint(equalTo: hit.trailingAnchor),
1962
+            card.topAnchor.constraint(equalTo: hit.topAnchor),
1963
+            card.bottomAnchor.constraint(equalTo: hit.bottomAnchor)
1964
+        ])
1965
+        hit.onClick = { [weak self] in
1966
+            self?.openMeetingURL(meeting.meetURL)
1967
+        }
1968
+        hit.onHoverChanged = { [weak self] hovering in
1969
+            guard let self else { return }
1970
+            let base = self.palette.sectionCard
1971
+            let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black
1972
+            let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
1973
+            card.layer?.backgroundColor = (hovering ? hover : base).cgColor
1974
+        }
1975
+        hit.onHoverChanged?(false)
1976
+
1977
+        return hit
1868 1978
     }
1869 1979
 }
1870 1980
 
@@ -2375,7 +2485,7 @@ private extension ViewController {
2375 2485
         return button
2376 2486
     }
2377 2487
 
2378
-    func iconRoundButton(_ symbol: String, size: CGFloat) -> NSView {
2488
+    func iconRoundButton(_ symbol: String, size: CGFloat, onClick: (() -> Void)? = nil) -> NSView {
2379 2489
         let button = HoverTrackingView()
2380 2490
         button.wantsLayer = true
2381 2491
         button.layer?.cornerRadius = size / 2
@@ -2399,11 +2509,246 @@ private extension ViewController {
2399 2509
             button.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
2400 2510
         }
2401 2511
         button.onHoverChanged?(false)
2512
+        button.onClick = onClick
2402 2513
 
2403 2514
         return button
2404 2515
     }
2405 2516
 }
2406 2517
 
2518
+// MARK: - Schedule actions (OAuth entry)
2519
+
2520
+private extension ViewController {
2521
+    @objc func scheduleConnectButtonPressed(_ sender: NSButton) {
2522
+        scheduleConnectClicked()
2523
+    }
2524
+
2525
+    private func scheduleInitialHeadingText() -> String {
2526
+        googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "Loading…"
2527
+    }
2528
+
2529
+    private func scheduleFilterTitle(_ filter: ScheduleFilter) -> String {
2530
+        switch filter {
2531
+        case .all: return "All"
2532
+        case .today: return "Today"
2533
+        case .week: return "This week"
2534
+        }
2535
+    }
2536
+
2537
+    private func showScheduleFilterMenu(anchor: NSView) {
2538
+        let menu = NSMenu()
2539
+        let items: [(ScheduleFilter, String)] = [
2540
+            (.all, "All"),
2541
+            (.today, "Today"),
2542
+            (.week, "This week")
2543
+        ]
2544
+        for (filter, title) in items {
2545
+            let item = NSMenuItem(title: title, action: #selector(scheduleFilterSelected(_:)), keyEquivalent: "")
2546
+            item.target = self
2547
+            item.tag = filter.rawValue
2548
+            item.state = (filter == scheduleFilter) ? .on : .off
2549
+            menu.addItem(item)
2550
+        }
2551
+        let loc = NSPoint(x: 8, y: anchor.bounds.height + 2)
2552
+        menu.popUp(positioning: nil, at: loc, in: anchor)
2553
+    }
2554
+
2555
+    @objc func scheduleFilterSelected(_ sender: NSMenuItem) {
2556
+        guard let filter = ScheduleFilter(rawValue: sender.tag) else { return }
2557
+        scheduleFilter = filter
2558
+        scheduleFilterLabel?.stringValue = scheduleFilterTitle(filter)
2559
+        Task { [weak self] in
2560
+            await self?.loadSchedule()
2561
+        }
2562
+    }
2563
+
2564
+    private func scheduleTimeText(for meeting: ScheduledMeeting) -> String {
2565
+        if meeting.isAllDay { return "All day" }
2566
+        let f = DateFormatter()
2567
+        f.locale = Locale.current
2568
+        f.timeZone = TimeZone.current
2569
+        f.dateStyle = .none
2570
+        f.timeStyle = .short
2571
+        return "\(f.string(from: meeting.startDate)) - \(f.string(from: meeting.endDate))"
2572
+    }
2573
+
2574
+    private func scheduleHeadingText(for meetings: [ScheduledMeeting]) -> String {
2575
+        guard let first = meetings.first else {
2576
+            return googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "No upcoming meetings"
2577
+        }
2578
+
2579
+        let day = Calendar.current.startOfDay(for: first.startDate)
2580
+        let f = DateFormatter()
2581
+        f.locale = Locale.current
2582
+        f.timeZone = TimeZone.current
2583
+        f.dateFormat = "EEEE, d MMM"
2584
+        return f.string(from: day)
2585
+    }
2586
+
2587
+    private func openMeetingURL(_ url: URL) {
2588
+        NSWorkspace.shared.open(url)
2589
+    }
2590
+
2591
+    private func renderScheduleCards(into stack: NSStackView, meetings: [ScheduledMeeting]) {
2592
+        stack.arrangedSubviews.forEach { v in
2593
+            stack.removeArrangedSubview(v)
2594
+            v.removeFromSuperview()
2595
+        }
2596
+
2597
+        if meetings.isEmpty {
2598
+            let empty = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
2599
+            empty.translatesAutoresizingMaskIntoConstraints = false
2600
+            empty.widthAnchor.constraint(equalToConstant: 264).isActive = true
2601
+            empty.heightAnchor.constraint(equalToConstant: 136).isActive = true
2602
+            styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
2603
+
2604
+            let label = textLabel(googleOAuth.loadTokens() == nil ? "Connect to load schedule" : "No meetings", font: typography.cardSubtitle, color: palette.textSecondary)
2605
+            label.translatesAutoresizingMaskIntoConstraints = false
2606
+            empty.addSubview(label)
2607
+            NSLayoutConstraint.activate([
2608
+                label.centerXAnchor.constraint(equalTo: empty.centerXAnchor),
2609
+                label.centerYAnchor.constraint(equalTo: empty.centerYAnchor)
2610
+            ])
2611
+            stack.addArrangedSubview(empty)
2612
+            return
2613
+        }
2614
+
2615
+        for meeting in meetings {
2616
+            stack.addArrangedSubview(scheduleCard(meeting: meeting))
2617
+        }
2618
+    }
2619
+
2620
+    private func filteredMeetings(_ meetings: [ScheduledMeeting]) -> [ScheduledMeeting] {
2621
+        switch scheduleFilter {
2622
+        case .all:
2623
+            return meetings
2624
+        case .today:
2625
+            let start = Calendar.current.startOfDay(for: Date())
2626
+            let end = Calendar.current.date(byAdding: .day, value: 1, to: start) ?? start.addingTimeInterval(86400)
2627
+            return meetings.filter { $0.startDate >= start && $0.startDate < end }
2628
+        case .week:
2629
+            let now = Date()
2630
+            let end = Calendar.current.date(byAdding: .day, value: 7, to: now) ?? now.addingTimeInterval(7 * 86400)
2631
+            return meetings.filter { $0.startDate >= now && $0.startDate <= end }
2632
+        }
2633
+    }
2634
+
2635
+    private func loadSchedule() async {
2636
+        do {
2637
+            if googleOAuth.loadTokens() == nil {
2638
+                await MainActor.run {
2639
+                    scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
2640
+                    if let stack = scheduleCardsStack {
2641
+                        renderScheduleCards(into: stack, meetings: [])
2642
+                    }
2643
+                }
2644
+                return
2645
+            }
2646
+
2647
+            let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
2648
+            let meetings = try await calendarClient.fetchUpcomingMeetings(accessToken: token)
2649
+            let filtered = filteredMeetings(meetings)
2650
+
2651
+            await MainActor.run {
2652
+                scheduleDateHeadingLabel?.stringValue = scheduleHeadingText(for: filtered)
2653
+                if let stack = scheduleCardsStack {
2654
+                    renderScheduleCards(into: stack, meetings: filtered)
2655
+                }
2656
+            }
2657
+        } catch {
2658
+            await MainActor.run {
2659
+                scheduleDateHeadingLabel?.stringValue = "Couldn’t load schedule"
2660
+                if let stack = scheduleCardsStack {
2661
+                    renderScheduleCards(into: stack, meetings: [])
2662
+                }
2663
+                showSimpleError("Couldn’t load schedule.", error: error)
2664
+            }
2665
+        }
2666
+    }
2667
+
2668
+    func showScheduleHelp() {
2669
+        let alert = NSAlert()
2670
+        alert.messageText = "Google schedule"
2671
+        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."
2672
+        alert.addButton(withTitle: "OK")
2673
+        alert.runModal()
2674
+    }
2675
+
2676
+    func scheduleReloadClicked() {
2677
+        // Data loading is wired in the Calendar step.
2678
+        // For now, this triggers a sign-in if needed so the next step can fetch events.
2679
+        Task { [weak self] in
2680
+            guard let self else { return }
2681
+            _ = try? await googleOAuth.validAccessToken(presentingWindow: view.window)
2682
+        }
2683
+    }
2684
+
2685
+    func scheduleConnectClicked() {
2686
+        Task { [weak self] in
2687
+            guard let self else { return }
2688
+            do {
2689
+                try await ensureGoogleClientIdConfigured(presentingWindow: view.window)
2690
+                _ = try await googleOAuth.validAccessToken(presentingWindow: view.window)
2691
+                pageCache[.joinMeetings] = nil
2692
+                showSidebarPage(.joinMeetings)
2693
+            } catch {
2694
+                showSimpleError("Couldn’t connect Google account.", error: error)
2695
+            }
2696
+        }
2697
+    }
2698
+
2699
+    @MainActor
2700
+    func ensureGoogleClientIdConfigured(presentingWindow: NSWindow?) async throws {
2701
+        if googleOAuth.configuredClientId() != nil, googleOAuth.configuredClientSecret() != nil { return }
2702
+
2703
+        let alert = NSAlert()
2704
+        alert.messageText = "Enter Google OAuth credentials"
2705
+        alert.informativeText = "Paste the OAuth Client ID and Client Secret from your downloaded Desktop OAuth JSON."
2706
+
2707
+        let accessory = NSStackView()
2708
+        accessory.orientation = .vertical
2709
+        accessory.spacing = 8
2710
+        accessory.alignment = .leading
2711
+
2712
+        let idField = NSTextField(string: googleOAuth.configuredClientId() ?? "")
2713
+        idField.placeholderString = "Client ID (....apps.googleusercontent.com)"
2714
+        idField.frame = NSRect(x: 0, y: 0, width: 460, height: 24)
2715
+
2716
+        let secretField = NSSecureTextField(string: googleOAuth.configuredClientSecret() ?? "")
2717
+        secretField.placeholderString = "Client Secret (GOCSPX-...)"
2718
+        secretField.frame = NSRect(x: 0, y: 0, width: 460, height: 24)
2719
+
2720
+        accessory.addArrangedSubview(idField)
2721
+        accessory.addArrangedSubview(secretField)
2722
+        alert.accessoryView = accessory
2723
+
2724
+        alert.addButton(withTitle: "Save")
2725
+        alert.addButton(withTitle: "Cancel")
2726
+
2727
+        // Keep this synchronous to avoid additional sheet state handling.
2728
+        let response = alert.runModal()
2729
+        if response != .alertFirstButtonReturn { throw GoogleOAuthError.missingClientId }
2730
+
2731
+        let idValue = idField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
2732
+        let secretValue = secretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
2733
+        if idValue.isEmpty { throw GoogleOAuthError.missingClientId }
2734
+        if secretValue.isEmpty { throw GoogleOAuthError.missingClientSecret }
2735
+
2736
+        googleOAuth.setClientIdForTesting(idValue)
2737
+        googleOAuth.setClientSecretForTesting(secretValue)
2738
+    }
2739
+
2740
+    func showSimpleError(_ title: String, error: Error) {
2741
+        DispatchQueue.main.async {
2742
+            let alert = NSAlert()
2743
+            alert.alertStyle = .warning
2744
+            alert.messageText = title
2745
+            alert.informativeText = error.localizedDescription
2746
+            alert.addButton(withTitle: "OK")
2747
+            alert.runModal()
2748
+        }
2749
+    }
2750
+}
2751
+
2407 2752
 private struct Palette {
2408 2753
     let pageBackground: NSColor
2409 2754
     let sidebarBackground: NSColor
@@ -2514,11 +2859,8 @@ private func inAppBrowserURLAllowed(_ url: URL, policy: InAppBrowserURLPolicy) -
2514 2859
 }
2515 2860
 
2516 2861
 private enum InAppBrowserWebKitSupport {
2517
-    static let sharedProcessPool = WKProcessPool()
2518
-
2519 2862
     static func makeWebViewConfiguration() -> WKWebViewConfiguration {
2520 2863
         let config = WKWebViewConfiguration()
2521
-        config.processPool = sharedProcessPool
2522 2864
         config.websiteDataStore = .default()
2523 2865
         config.preferences.javaScriptCanOpenWindowsAutomatically = true
2524 2866
         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>