|
|
@@ -113,7 +113,6 @@ class ViewController: NSViewController {
|
|
|
case settings = "Settings"
|
|
|
}
|
|
|
|
|
|
- private let googleOAuth = GoogleOAuthService.shared
|
|
|
private let zoomOAuth = ZoomOAuthService.shared
|
|
|
private let loginStateKey = "zoom_app.isLoggedIn"
|
|
|
private let darkModeDefaultsKey = "settings.darkModeEnabled"
|
|
|
@@ -344,7 +343,6 @@ class ViewController: NSViewController {
|
|
|
|
|
|
private let rootContainer = NSView()
|
|
|
private var homeView: NSView?
|
|
|
- private weak var googleButton: NSButton?
|
|
|
private weak var timeLabel: NSTextField?
|
|
|
private weak var dateLabel: NSTextField?
|
|
|
private weak var meetingsDayHeaderLabel: NSTextField?
|
|
|
@@ -486,7 +484,7 @@ class ViewController: NSViewController {
|
|
|
private weak var settingsNotificationsBlock: NSStackView?
|
|
|
private weak var settingsPagePrimaryLanguagePopup: NSPopUpButton?
|
|
|
private weak var settingsPageSecondaryLanguagePopup: NSPopUpButton?
|
|
|
- private weak var settingsGoogleActionButton: NSButton?
|
|
|
+ private weak var settingsZoomActionButton: NSButton?
|
|
|
private weak var topBarPremiumButton: NSButton?
|
|
|
private weak var topBarAuthButton: NSButton?
|
|
|
private var topBarAuthButtonWidthConstraint: NSLayoutConstraint?
|
|
|
@@ -953,7 +951,7 @@ class ViewController: NSViewController {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- private func showHomeView(profile: GoogleUserProfile?, refreshMeetings: Bool = true) {
|
|
|
+ private func showHomeView(profile: UserProfile?, refreshMeetings: Bool = true) {
|
|
|
clearMeetingsScrollObserver()
|
|
|
clearMeetingsPageScrollObserver()
|
|
|
removeSearchFieldObserver()
|
|
|
@@ -1029,38 +1027,12 @@ class ViewController: NSViewController {
|
|
|
])
|
|
|
}
|
|
|
|
|
|
- @objc private func googleLoginTapped() {
|
|
|
- guard isSigningIn == false else { return }
|
|
|
- isSigningIn = true
|
|
|
- googleButton?.title = "..."
|
|
|
- googleButton?.isEnabled = false
|
|
|
-
|
|
|
- Task {
|
|
|
- do {
|
|
|
- let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
|
|
|
- let profile = try? await googleOAuth.fetchUserProfile(accessToken: token)
|
|
|
- await MainActor.run {
|
|
|
- self.googleButton?.title = "G"
|
|
|
- self.resetLoginSigningInState()
|
|
|
- self.completeLoginAndRestart(profile: profile)
|
|
|
- }
|
|
|
- } catch {
|
|
|
- await MainActor.run {
|
|
|
- self.resetLoginSigningInState()
|
|
|
- if case GoogleOAuthError.authenticationCancelled = error { return }
|
|
|
- self.showSimpleError("Google login failed", error: error)
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
/// Primary Zoom sign-in: browser OAuth, token refresh, then home with scheduled meetings.
|
|
|
@objc private func zoomPrimarySignInTapped() {
|
|
|
guard isSigningIn == false else { return }
|
|
|
isSigningIn = true
|
|
|
topBarAuthButton?.title = "Signing in..."
|
|
|
topBarAuthButton?.isEnabled = false
|
|
|
- googleButton?.isEnabled = false
|
|
|
|
|
|
Task {
|
|
|
do {
|
|
|
@@ -1072,7 +1044,7 @@ class ViewController: NSViewController {
|
|
|
|
|
|
let zoomToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
|
|
|
let zoomUser = try? await fetchZoomUserProfile(accessToken: zoomToken)
|
|
|
- let profile = zoomUser.map { GoogleUserProfile(name: $0.displayName, email: $0.email, picture: $0.pictureURL) }
|
|
|
+ let profile = zoomUser.map { UserProfile(name: $0.displayName, email: $0.email, picture: $0.pictureURL) }
|
|
|
let profileTimeZone = parseZoomTimeZone(zoomUser?.timezone)
|
|
|
|
|
|
await MainActor.run {
|
|
|
@@ -2930,7 +2902,6 @@ class ViewController: NSViewController {
|
|
|
isLoadingMeetings = false
|
|
|
meetingsRateLimitedUntil = nil
|
|
|
clearMeetingsScrollObserver()
|
|
|
- googleOAuth.clearSavedTokens()
|
|
|
zoomOAuth.clearSavedTokens()
|
|
|
zoomSignedInDisplayName = nil
|
|
|
embeddedZoomPendingWebAuth = nil
|
|
|
@@ -3456,13 +3427,11 @@ class ViewController: NSViewController {
|
|
|
@MainActor
|
|
|
private func resetLoginSigningInState() {
|
|
|
isSigningIn = false
|
|
|
- googleButton?.title = "G"
|
|
|
updateTopBarAuthButton()
|
|
|
- googleButton?.isEnabled = true
|
|
|
}
|
|
|
|
|
|
@MainActor
|
|
|
- private func completeLoginAndRestart(profile: GoogleUserProfile?) {
|
|
|
+ private func completeLoginAndRestart(profile: UserProfile?) {
|
|
|
persistLoggedInState(true)
|
|
|
let trimmedName = profile?.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
zoomSignedInDisplayName = trimmedName.isEmpty ? nil : trimmedName
|
|
|
@@ -3538,8 +3507,8 @@ class ViewController: NSViewController {
|
|
|
private func applyAuthenticationUIState(triggerInitialRefresh: Bool) {
|
|
|
let loggedIn = isUserLoggedIn()
|
|
|
updateTopBarAuthButton()
|
|
|
- settingsGoogleActionButton?.title = loggedIn ? "Sign Out" : "Sign in with Zoom"
|
|
|
- settingsGoogleActionButton?.isEnabled = true
|
|
|
+ settingsZoomActionButton?.title = loggedIn ? "Sign Out" : "Sign in with Zoom"
|
|
|
+ settingsZoomActionButton?.isEnabled = true
|
|
|
updatePremiumLockedSections()
|
|
|
|
|
|
meetingsRefreshTimer?.invalidate()
|
|
|
@@ -4354,7 +4323,7 @@ class ViewController: NSViewController {
|
|
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
|
|
|
- throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed to load Zoom profile")
|
|
|
+ throw ZoomOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed to load Zoom profile")
|
|
|
}
|
|
|
return try JSONDecoder().decode(ZoomUserMeResponse.self, from: data)
|
|
|
}
|
|
|
@@ -4368,7 +4337,7 @@ class ViewController: NSViewController {
|
|
|
var components = URLComponents(string: "https://api.zoom.us/v2/users/me/token")!
|
|
|
components.queryItems = [URLQueryItem(name: "type", value: "zak")]
|
|
|
guard let url = components.url else {
|
|
|
- throw GoogleOAuthError.tokenExchangeFailed("Invalid ZAK request URL")
|
|
|
+ throw ZoomOAuthError.tokenExchangeFailed("Invalid ZAK request URL")
|
|
|
}
|
|
|
var request = URLRequest(url: url)
|
|
|
request.httpMethod = "GET"
|
|
|
@@ -4376,7 +4345,7 @@ class ViewController: NSViewController {
|
|
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
guard let http = response as? HTTPURLResponse else {
|
|
|
- throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom (ZAK)")
|
|
|
+ throw ZoomOAuthError.tokenExchangeFailed("Invalid response from Zoom (ZAK)")
|
|
|
}
|
|
|
if http.statusCode == 429 {
|
|
|
let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
|
|
|
@@ -4388,12 +4357,12 @@ class ViewController: NSViewController {
|
|
|
if raw.localizedCaseInsensitiveContains("does not contain scopes") {
|
|
|
throw ZoomOAuthError.missingRequiredScope(raw)
|
|
|
}
|
|
|
- throw GoogleOAuthError.tokenExchangeFailed(raw)
|
|
|
+ throw ZoomOAuthError.tokenExchangeFailed(raw)
|
|
|
}
|
|
|
let decoded = try JSONDecoder().decode(ZoomZAKTokenResponse.self, from: data)
|
|
|
let trimmed = decoded.token.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
guard trimmed.isEmpty == false else {
|
|
|
- throw GoogleOAuthError.tokenExchangeFailed("Empty ZAK from Zoom")
|
|
|
+ throw ZoomOAuthError.tokenExchangeFailed("Empty ZAK from Zoom")
|
|
|
}
|
|
|
return trimmed
|
|
|
}
|
|
|
@@ -4436,7 +4405,7 @@ class ViewController: NSViewController {
|
|
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
guard let http = response as? HTTPURLResponse else {
|
|
|
- throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom")
|
|
|
+ throw ZoomOAuthError.tokenExchangeFailed("Invalid response from Zoom")
|
|
|
}
|
|
|
if http.statusCode == 429 {
|
|
|
let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
|
|
|
@@ -4448,7 +4417,7 @@ class ViewController: NSViewController {
|
|
|
if raw.localizedCaseInsensitiveContains("does not contain scopes") {
|
|
|
throw ZoomOAuthError.missingRequiredScope(raw)
|
|
|
}
|
|
|
- throw GoogleOAuthError.tokenExchangeFailed(raw)
|
|
|
+ throw ZoomOAuthError.tokenExchangeFailed(raw)
|
|
|
}
|
|
|
return try JSONDecoder().decode(ZoomCreateMeetingAPIResult.self, from: data)
|
|
|
}
|
|
|
@@ -4464,7 +4433,7 @@ class ViewController: NSViewController {
|
|
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
guard let http = response as? HTTPURLResponse else {
|
|
|
- throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom")
|
|
|
+ throw ZoomOAuthError.tokenExchangeFailed("Invalid response from Zoom")
|
|
|
}
|
|
|
if http.statusCode == 429 {
|
|
|
let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
|
|
|
@@ -4476,7 +4445,7 @@ class ViewController: NSViewController {
|
|
|
if raw.localizedCaseInsensitiveContains("does not contain scopes") {
|
|
|
throw ZoomOAuthError.missingRequiredScope(raw)
|
|
|
}
|
|
|
- throw GoogleOAuthError.tokenExchangeFailed(raw)
|
|
|
+ throw ZoomOAuthError.tokenExchangeFailed(raw)
|
|
|
}
|
|
|
return try JSONDecoder().decode(ZoomCreateMeetingAPIResult.self, from: data)
|
|
|
}
|
|
|
@@ -4580,7 +4549,7 @@ class ViewController: NSViewController {
|
|
|
if raw.localizedCaseInsensitiveContains("does not contain scopes") {
|
|
|
throw ZoomOAuthError.missingRequiredScope(raw)
|
|
|
}
|
|
|
- throw GoogleOAuthError.tokenExchangeFailed(raw)
|
|
|
+ throw ZoomOAuthError.tokenExchangeFailed(raw)
|
|
|
}
|
|
|
|
|
|
let decoded = try JSONDecoder().decode(ZoomMeetingsPage.self, from: data)
|
|
|
@@ -4746,7 +4715,7 @@ class ViewController: NSViewController {
|
|
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
guard let http = response as? HTTPURLResponse else {
|
|
|
- throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom (meeting_summaries)")
|
|
|
+ throw ZoomOAuthError.tokenExchangeFailed("Invalid response from Zoom (meeting_summaries)")
|
|
|
}
|
|
|
if http.statusCode == 429 {
|
|
|
let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
|
|
|
@@ -4761,7 +4730,7 @@ class ViewController: NSViewController {
|
|
|
if raw.localizedCaseInsensitiveContains("does not contain scopes") {
|
|
|
throw ZoomOAuthError.missingRequiredScope(raw)
|
|
|
}
|
|
|
- throw GoogleOAuthError.tokenExchangeFailed(raw)
|
|
|
+ throw ZoomOAuthError.tokenExchangeFailed(raw)
|
|
|
}
|
|
|
|
|
|
let decoded = try JSONDecoder().decode(Page.self, from: data)
|
|
|
@@ -4838,7 +4807,7 @@ class ViewController: NSViewController {
|
|
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
guard let http = response as? HTTPURLResponse else {
|
|
|
- throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom (past meetings)")
|
|
|
+ throw ZoomOAuthError.tokenExchangeFailed("Invalid response from Zoom (past meetings)")
|
|
|
}
|
|
|
if http.statusCode == 429 {
|
|
|
let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
|
|
|
@@ -4850,7 +4819,7 @@ class ViewController: NSViewController {
|
|
|
if raw.localizedCaseInsensitiveContains("does not contain scopes") {
|
|
|
throw ZoomOAuthError.missingRequiredScope(raw)
|
|
|
}
|
|
|
- throw GoogleOAuthError.tokenExchangeFailed(raw)
|
|
|
+ throw ZoomOAuthError.tokenExchangeFailed(raw)
|
|
|
}
|
|
|
|
|
|
let decoded = try JSONDecoder().decode(PastMeetingsPage.self, from: data)
|
|
|
@@ -4900,7 +4869,7 @@ class ViewController: NSViewController {
|
|
|
}
|
|
|
let urlString = "https://api.zoom.us/v2/meetings/\(pathSegment)/meeting_summary"
|
|
|
guard let url = URL(string: urlString) else {
|
|
|
- throw GoogleOAuthError.tokenExchangeFailed("Invalid Zoom meeting summary URL")
|
|
|
+ throw ZoomOAuthError.tokenExchangeFailed("Invalid Zoom meeting summary URL")
|
|
|
}
|
|
|
var request = URLRequest(url: url)
|
|
|
request.httpMethod = "GET"
|
|
|
@@ -4908,7 +4877,7 @@ class ViewController: NSViewController {
|
|
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
guard let http = response as? HTTPURLResponse else {
|
|
|
- throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom (meeting_summary)")
|
|
|
+ throw ZoomOAuthError.tokenExchangeFailed("Invalid response from Zoom (meeting_summary)")
|
|
|
}
|
|
|
if http.statusCode == 404 || http.statusCode == 204 { return nil }
|
|
|
if http.statusCode == 429 {
|
|
|
@@ -4921,7 +4890,7 @@ class ViewController: NSViewController {
|
|
|
if raw.localizedCaseInsensitiveContains("does not contain scopes") {
|
|
|
throw ZoomOAuthError.missingRequiredScope(raw)
|
|
|
}
|
|
|
- throw GoogleOAuthError.tokenExchangeFailed(raw)
|
|
|
+ throw ZoomOAuthError.tokenExchangeFailed(raw)
|
|
|
}
|
|
|
|
|
|
let iso = ISO8601DateFormatter()
|
|
|
@@ -4981,10 +4950,10 @@ class ViewController: NSViewController {
|
|
|
|
|
|
let accountTitle = textLabel("Account", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
|
|
|
stack.addArrangedSubview(accountTitle)
|
|
|
- let googleAccountRow = makeSettingsGoogleAccountRow()
|
|
|
- stack.addArrangedSubview(googleAccountRow)
|
|
|
- googleAccountRow.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
|
|
|
- stack.setCustomSpacing(24, after: googleAccountRow)
|
|
|
+ let zoomAccountRow = makeSettingsZoomAccountRow()
|
|
|
+ stack.addArrangedSubview(zoomAccountRow)
|
|
|
+ zoomAccountRow.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
|
|
|
+ stack.setCustomSpacing(24, after: zoomAccountRow)
|
|
|
|
|
|
let appTitle = textLabel("App", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
|
|
|
stack.addArrangedSubview(appTitle)
|
|
|
@@ -5312,25 +5281,25 @@ class ViewController: NSViewController {
|
|
|
return row
|
|
|
}
|
|
|
|
|
|
- private func makeSettingsGoogleAccountRow() -> NSView {
|
|
|
+ private func makeSettingsZoomAccountRow() -> NSView {
|
|
|
let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
|
|
|
row.translatesAutoresizingMaskIntoConstraints = false
|
|
|
styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
|
|
|
|
|
|
let signedIn = isUserLoggedIn()
|
|
|
let titleText = signedIn ? "Zoom account connected" : "Zoom account not connected"
|
|
|
- let subtitleText = signedIn ? "Signed in" : "Sign in to sync your meetings and calendar."
|
|
|
+ let subtitleText = signedIn ? "Signed in" : "Sign in to sync your meetings."
|
|
|
|
|
|
let title = textLabel(titleText, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: primaryText)
|
|
|
let subtitle = textLabel(subtitleText, font: NSFont.systemFont(ofSize: 13, weight: .regular), color: secondaryText)
|
|
|
subtitle.maximumNumberOfLines = 2
|
|
|
subtitle.lineBreakMode = .byTruncatingTail
|
|
|
|
|
|
- let actionButton = NSButton(title: signedIn ? "Sign Out" : "Sign in with Google", target: self, action: #selector(settingsGoogleActionButtonClicked(_:)))
|
|
|
+ let actionButton = NSButton(title: signedIn ? "Sign Out" : "Sign in with Zoom", target: self, action: #selector(settingsZoomActionButtonClicked(_:)))
|
|
|
actionButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
actionButton.bezelStyle = .rounded
|
|
|
actionButton.controlSize = .regular
|
|
|
- settingsGoogleActionButton = actionButton
|
|
|
+ settingsZoomActionButton = actionButton
|
|
|
|
|
|
row.addSubview(title)
|
|
|
row.addSubview(subtitle)
|
|
|
@@ -5393,7 +5362,7 @@ class ViewController: NSViewController {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- @objc private func settingsGoogleActionButtonClicked(_ sender: NSButton) {
|
|
|
+ @objc private func settingsZoomActionButtonClicked(_ sender: NSButton) {
|
|
|
if isUserLoggedIn() {
|
|
|
logoutTapped()
|
|
|
return
|
|
|
@@ -6822,7 +6791,7 @@ class ViewController: NSViewController {
|
|
|
|
|
|
// MARK: - Home UI
|
|
|
|
|
|
- private func makeHomeView(profile: GoogleUserProfile?) -> NSView {
|
|
|
+ private func makeHomeView(profile: UserProfile?) -> NSView {
|
|
|
let root = NSView()
|
|
|
let shell = NSView()
|
|
|
shell.wantsLayer = true
|
|
|
@@ -10727,15 +10696,7 @@ private final class MeetingCardView: NSView {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-struct GoogleOAuthTokens: Codable, Equatable {
|
|
|
- var accessToken: String
|
|
|
- var refreshToken: String?
|
|
|
- var expiresAt: Date
|
|
|
- var scope: String?
|
|
|
- var tokenType: String?
|
|
|
-}
|
|
|
-
|
|
|
-struct GoogleUserProfile: Codable, Equatable {
|
|
|
+struct UserProfile: Codable, Equatable {
|
|
|
var name: String?
|
|
|
var email: String?
|
|
|
var picture: String?
|
|
|
@@ -11027,10 +10988,6 @@ final class ZoomOAuthService: NSObject {
|
|
|
}
|
|
|
await MainActor.run { self.onInteractiveSignInCompleted?() }
|
|
|
return tokens
|
|
|
- } catch GoogleOAuthError.authenticationTimedOut {
|
|
|
- throw ZoomOAuthError.authenticationTimedOut
|
|
|
- } catch GoogleOAuthError.authenticationCancelled {
|
|
|
- throw ZoomOAuthError.authenticationCancelled
|
|
|
} catch {
|
|
|
throw error
|
|
|
}
|
|
|
@@ -11216,192 +11173,12 @@ final class ZoomOAuthService: NSObject {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-enum GoogleOAuthError: Error {
|
|
|
- case missingClientId
|
|
|
- case missingClientSecret
|
|
|
- case invalidCallbackURL
|
|
|
- case missingAuthorizationCode
|
|
|
- case tokenExchangeFailed(String)
|
|
|
- case unableToOpenBrowser
|
|
|
- case authenticationTimedOut
|
|
|
- case authenticationCancelled
|
|
|
-}
|
|
|
-
|
|
|
-final class GoogleOAuthService: NSObject {
|
|
|
- static let shared = GoogleOAuthService()
|
|
|
- private var inAppOAuthWindowController: InAppOAuthWindowController?
|
|
|
- private let clientId = BundledOAuthCredentials.googleClientId
|
|
|
- private let clientSecret = BundledOAuthCredentials.googleClientSecret
|
|
|
- private let requiredCalendarScope = "https://www.googleapis.com/auth/calendar.readonly"
|
|
|
- private let scopes = ["openid", "email", "profile", "https://www.googleapis.com/auth/calendar.readonly"]
|
|
|
- private lazy var tokenStore = KeychainTokenStore(account: "googleOAuthTokens.\(clientId)")
|
|
|
-
|
|
|
- func loadTokens() -> GoogleOAuthTokens? { try? tokenStore.readTokens() }
|
|
|
-
|
|
|
- func clearSavedTokens() {
|
|
|
- tokenStore.clearTokens()
|
|
|
- }
|
|
|
-
|
|
|
- func fetchUserProfile(accessToken: String) async throws -> GoogleUserProfile {
|
|
|
- var request = URLRequest(url: URL(string: "https://openidconnect.googleapis.com/v1/userinfo")!)
|
|
|
- request.httpMethod = "GET"
|
|
|
- request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
|
|
- let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
- guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
|
|
|
- throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
|
|
|
- }
|
|
|
- return try JSONDecoder().decode(GoogleUserProfile.self, from: data)
|
|
|
- }
|
|
|
-
|
|
|
- func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
|
|
|
- if let tokens = try tokenStore.readTokens(),
|
|
|
- tokens.expiresAt.timeIntervalSinceNow > 60,
|
|
|
- tokenHasCalendarScope(tokens.scope) {
|
|
|
- return tokens.accessToken
|
|
|
- }
|
|
|
- let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
|
|
|
- try tokenStore.writeTokens(tokens)
|
|
|
- return tokens.accessToken
|
|
|
- }
|
|
|
-
|
|
|
- private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> GoogleOAuthTokens {
|
|
|
- guard let presentingWindow else { throw GoogleOAuthError.unableToOpenBrowser }
|
|
|
- let oauthWindow = await MainActor.run { makeOAuthWindowController() }
|
|
|
- let codeVerifier = Self.randomURLSafeString(length: 64)
|
|
|
- let codeChallenge = Self.pkceChallenge(for: codeVerifier)
|
|
|
- let state = Self.randomURLSafeString(length: 32)
|
|
|
- // Google OAuth app settings often already include this callback from prior loopback setup.
|
|
|
- let redirectURI = "http://127.0.0.1:8742/oauth2redirect"
|
|
|
-
|
|
|
- var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
|
|
|
- components.queryItems = [
|
|
|
- URLQueryItem(name: "client_id", value: clientId),
|
|
|
- URLQueryItem(name: "redirect_uri", value: redirectURI),
|
|
|
- URLQueryItem(name: "response_type", value: "code"),
|
|
|
- URLQueryItem(name: "scope", value: scopes.joined(separator: " ")),
|
|
|
- URLQueryItem(name: "state", value: state),
|
|
|
- URLQueryItem(name: "code_challenge", value: codeChallenge),
|
|
|
- URLQueryItem(name: "code_challenge_method", value: "S256"),
|
|
|
- URLQueryItem(name: "access_type", value: "offline"),
|
|
|
- URLQueryItem(name: "prompt", value: "consent")
|
|
|
- ]
|
|
|
-
|
|
|
- guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
|
|
|
- let callbackURL = try await oauthWindow.authenticate(
|
|
|
- startURL: authURL,
|
|
|
- callbackURLPrefix: redirectURI,
|
|
|
- presentingWindow: presentingWindow
|
|
|
- )
|
|
|
- let query = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
|
|
|
- guard query?.first(where: { $0.name == "state" })?.value == state else { throw GoogleOAuthError.invalidCallbackURL }
|
|
|
- guard let code = query?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
|
|
|
- throw GoogleOAuthError.missingAuthorizationCode
|
|
|
- }
|
|
|
- return try await exchangeCodeForTokens(code: code, codeVerifier: codeVerifier, redirectURI: redirectURI)
|
|
|
- }
|
|
|
-
|
|
|
- @MainActor
|
|
|
- private func makeOAuthWindowController() -> InAppOAuthWindowController {
|
|
|
- if let existing = inAppOAuthWindowController { return existing }
|
|
|
- let controller = InAppOAuthWindowController(title: "Google Sign-In")
|
|
|
- inAppOAuthWindowController = controller
|
|
|
- return controller
|
|
|
- }
|
|
|
-
|
|
|
- private func exchangeCodeForTokens(code: String, codeVerifier: String, redirectURI: String) async throws -> GoogleOAuthTokens {
|
|
|
- var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
|
|
|
- request.httpMethod = "POST"
|
|
|
- request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
|
|
|
- request.httpBody = Self.formURLEncoded([
|
|
|
- "client_id": clientId,
|
|
|
- "client_secret": clientSecret,
|
|
|
- "code": code,
|
|
|
- "code_verifier": codeVerifier,
|
|
|
- "redirect_uri": redirectURI,
|
|
|
- "grant_type": "authorization_code"
|
|
|
- ])
|
|
|
- let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
- guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
|
|
|
- throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
|
|
|
- }
|
|
|
-
|
|
|
- struct TokenResponse: Decodable {
|
|
|
- let access_token: String
|
|
|
- let expires_in: Double
|
|
|
- let refresh_token: String?
|
|
|
- let scope: String?
|
|
|
- let token_type: String?
|
|
|
- }
|
|
|
-
|
|
|
- let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
|
|
|
- return GoogleOAuthTokens(
|
|
|
- accessToken: decoded.access_token,
|
|
|
- refreshToken: decoded.refresh_token,
|
|
|
- expiresAt: Date().addingTimeInterval(decoded.expires_in),
|
|
|
- scope: decoded.scope,
|
|
|
- tokenType: decoded.token_type
|
|
|
- )
|
|
|
- }
|
|
|
-
|
|
|
- private static func pkceChallenge(for verifier: String) -> String {
|
|
|
- let digest = SHA256.hash(data: Data(verifier.utf8))
|
|
|
- return Data(digest).base64URLEncodedString()
|
|
|
- }
|
|
|
-
|
|
|
- private static func randomURLSafeString(length: Int) -> String {
|
|
|
- var bytes = [UInt8](repeating: 0, count: length)
|
|
|
- _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
|
|
- return Data(bytes).base64URLEncodedString()
|
|
|
- }
|
|
|
-
|
|
|
- private static func formURLEncoded(_ params: [String: String]) -> Data {
|
|
|
- let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
|
|
|
- let pairs = params.map { key, value in
|
|
|
- let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
|
|
|
- let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
|
|
|
- return "\(k)=\(v)"
|
|
|
- }.joined(separator: "&")
|
|
|
- return Data(pairs.utf8)
|
|
|
- }
|
|
|
-
|
|
|
- private func tokenHasCalendarScope(_ scopeValue: String?) -> Bool {
|
|
|
- guard let scopeValue else { return false }
|
|
|
- return scopeValue.split(separator: " ").contains(where: { $0 == Substring(requiredCalendarScope) })
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
private extension Data {
|
|
|
func base64URLEncodedString() -> String {
|
|
|
base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-final class KeychainTokenStore {
|
|
|
- private let defaultsKey: String
|
|
|
- private let defaults: UserDefaults
|
|
|
-
|
|
|
- init(service: String = Bundle.main.bundleIdentifier ?? "zoom_app",
|
|
|
- account: String = "googleOAuthTokens",
|
|
|
- defaults: UserDefaults = .standard) {
|
|
|
- self.defaultsKey = "\(service).\(account)"
|
|
|
- self.defaults = defaults
|
|
|
- }
|
|
|
-
|
|
|
- func readTokens() throws -> GoogleOAuthTokens? {
|
|
|
- guard let data = defaults.data(forKey: defaultsKey) else { return nil }
|
|
|
- return try JSONDecoder().decode(GoogleOAuthTokens.self, from: data)
|
|
|
- }
|
|
|
-
|
|
|
- func writeTokens(_ tokens: GoogleOAuthTokens) throws {
|
|
|
- let data = try JSONEncoder().encode(tokens)
|
|
|
- defaults.set(data, forKey: defaultsKey)
|
|
|
- }
|
|
|
-
|
|
|
- func clearTokens() {
|
|
|
- defaults.removeObject(forKey: defaultsKey)
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
// MARK: - OAuth window (previous sign-in behavior)
|
|
|
|
|
|
@MainActor
|
|
|
@@ -11461,7 +11238,7 @@ private final class InAppOAuthWindowController: NSWindowController, WKNavigation
|
|
|
webView.stopLoading()
|
|
|
if let continuation = callbackContinuation {
|
|
|
callbackContinuation = nil
|
|
|
- continuation.resume(throwing: GoogleOAuthError.authenticationCancelled)
|
|
|
+ continuation.resume(throwing: ZoomOAuthError.authenticationCancelled)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -11479,12 +11256,12 @@ private final class InAppOAuthWindowController: NSWindowController, WKNavigation
|
|
|
do {
|
|
|
return try await withThrowingTaskGroup(of: URL.self) { group in
|
|
|
group.addTask { [weak self] in
|
|
|
- guard let self else { throw GoogleOAuthError.invalidCallbackURL }
|
|
|
+ guard let self else { throw ZoomOAuthError.invalidCallbackURL }
|
|
|
return try await self.awaitCallback()
|
|
|
}
|
|
|
group.addTask {
|
|
|
try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
|
|
|
- throw GoogleOAuthError.authenticationTimedOut
|
|
|
+ throw ZoomOAuthError.authenticationTimedOut
|
|
|
}
|
|
|
let url = try await group.next()!
|
|
|
group.cancelAll()
|
|
|
@@ -11550,21 +11327,6 @@ private final class InAppOAuthWindowController: NSWindowController, WKNavigation
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-extension GoogleOAuthError: LocalizedError {
|
|
|
- var errorDescription: String? {
|
|
|
- switch self {
|
|
|
- case .missingClientId: return "Missing Google OAuth Client ID."
|
|
|
- case .missingClientSecret: return "Missing Google OAuth Client Secret."
|
|
|
- case .invalidCallbackURL: return "Invalid OAuth callback URL."
|
|
|
- case .missingAuthorizationCode: return "Google did not return an authorization code."
|
|
|
- case .tokenExchangeFailed(let details): return "Token exchange failed: \(details)"
|
|
|
- case .unableToOpenBrowser: return "Could not open browser for Google sign-in."
|
|
|
- case .authenticationTimedOut: return "Google sign-in timed out."
|
|
|
- case .authenticationCancelled: return "Sign-in was cancelled."
|
|
|
- }
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
extension ZoomOAuthError: LocalizedError {
|
|
|
var errorDescription: String? {
|
|
|
switch self {
|