|
|
@@ -10,6 +10,12 @@ import CryptoKit
|
|
|
import StoreKit
|
|
|
import WebKit
|
|
|
|
|
|
+/// Default main window **content** size; OAuth sign-in uses the same dimensions and aligns to this content area.
|
|
|
+private enum AppWindowMetrics {
|
|
|
+ static let defaultContentWidth: CGFloat = 1020
|
|
|
+ static let defaultContentHeight: CGFloat = 690
|
|
|
+}
|
|
|
+
|
|
|
class ViewController: NSViewController {
|
|
|
enum HomeSection: String {
|
|
|
case home = "Home"
|
|
|
@@ -532,7 +538,9 @@ class ViewController: NSViewController {
|
|
|
override func viewDidAppear() {
|
|
|
super.viewDidAppear()
|
|
|
if let window = view.window {
|
|
|
- window.setContentSize(NSSize(width: 1020, height: 690))
|
|
|
+ window.setContentSize(
|
|
|
+ NSSize(width: AppWindowMetrics.defaultContentWidth, height: AppWindowMetrics.defaultContentHeight)
|
|
|
+ )
|
|
|
applyWindowBackgroundForCurrentTheme(window)
|
|
|
// Use full-size content view so custom top chrome sits in the titlebar region.
|
|
|
window.titleVisibility = .hidden
|
|
|
@@ -676,9 +684,8 @@ class ViewController: NSViewController {
|
|
|
}
|
|
|
} catch {
|
|
|
await MainActor.run {
|
|
|
- self.isSigningIn = false
|
|
|
- self.googleButton?.title = "G"
|
|
|
- self.googleButton?.isEnabled = true
|
|
|
+ self.resetLoginSigningInState()
|
|
|
+ if case GoogleOAuthError.authenticationCancelled = error { return }
|
|
|
self.showSimpleError("Google login failed", error: error)
|
|
|
}
|
|
|
}
|
|
|
@@ -714,6 +721,7 @@ class ViewController: NSViewController {
|
|
|
} catch {
|
|
|
await MainActor.run {
|
|
|
self.resetLoginSigningInState()
|
|
|
+ if case ZoomOAuthError.authenticationCancelled = error { return }
|
|
|
self.showSimpleError("Zoom sign-in failed", error: error)
|
|
|
}
|
|
|
}
|
|
|
@@ -1023,7 +1031,8 @@ class ViewController: NSViewController {
|
|
|
await MainActor.run { self.scheduleSubmitButton?.isEnabled = true }
|
|
|
return
|
|
|
}
|
|
|
- let token = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
|
|
|
+ let oauthParent = await MainActor.run { self.scheduleMeetingWindow ?? self.view.window }
|
|
|
+ let token = try await zoomOAuth.validAccessToken(presentingWindow: oauthParent)
|
|
|
let result = try await createZoomMeeting(
|
|
|
accessToken: token,
|
|
|
topic: topic,
|
|
|
@@ -1063,6 +1072,8 @@ class ViewController: NSViewController {
|
|
|
let seconds = max(retryAfter ?? 300, 30)
|
|
|
let minutes = Int(ceil(Double(seconds) / 60.0))
|
|
|
self.showSimpleAlert(title: "Rate limited", message: "Zoom asked to wait before scheduling again. Try in about \(minutes) min.")
|
|
|
+ } else if case ZoomOAuthError.authenticationCancelled = error {
|
|
|
+ // User closed the sign-in window; no alert.
|
|
|
} else {
|
|
|
self.showSimpleError("Could not schedule", error: error)
|
|
|
}
|
|
|
@@ -2046,6 +2057,7 @@ class ViewController: NSViewController {
|
|
|
@MainActor
|
|
|
private func resetLoginSigningInState() {
|
|
|
isSigningIn = false
|
|
|
+ googleButton?.title = "G"
|
|
|
updateTopBarAuthButton()
|
|
|
googleButton?.isEnabled = true
|
|
|
}
|
|
|
@@ -6598,6 +6610,7 @@ enum ZoomOAuthError: Error {
|
|
|
case rateLimited(retryAfterSeconds: Int?)
|
|
|
case unableToOpenBrowser
|
|
|
case authenticationTimedOut
|
|
|
+ case authenticationCancelled
|
|
|
}
|
|
|
|
|
|
final class ZoomOAuthTokenStore {
|
|
|
@@ -6690,7 +6703,7 @@ final class ZoomOAuthService: NSObject {
|
|
|
}
|
|
|
|
|
|
private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> ZoomOAuthTokens {
|
|
|
- _ = presentingWindow
|
|
|
+ guard let presentingWindow else { throw ZoomOAuthError.unableToOpenBrowser }
|
|
|
let oauthWindow = await MainActor.run { makeOAuthWindowController() }
|
|
|
guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
|
|
|
guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
|
|
|
@@ -6709,7 +6722,11 @@ final class ZoomOAuthService: NSObject {
|
|
|
]
|
|
|
guard let authURL = components.url else { throw ZoomOAuthError.invalidCallbackURL }
|
|
|
do {
|
|
|
- let callbackURL = try await oauthWindow.authenticate(startURL: authURL, callbackURLPrefix: redirectURI)
|
|
|
+ let callbackURL = try await oauthWindow.authenticate(
|
|
|
+ startURL: authURL,
|
|
|
+ callbackURLPrefix: redirectURI,
|
|
|
+ presentingWindow: presentingWindow
|
|
|
+ )
|
|
|
let queryItems = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
|
|
|
guard queryItems?.first(where: { $0.name == "state" })?.value == state else { throw ZoomOAuthError.invalidCallbackURL }
|
|
|
guard let code = queryItems?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
|
|
|
@@ -6718,6 +6735,8 @@ final class ZoomOAuthService: NSObject {
|
|
|
return try await exchangeCodeForTokens(code: code, redirectURI: redirectURI, clientId: clientId, clientSecret: clientSecret)
|
|
|
} catch GoogleOAuthError.authenticationTimedOut {
|
|
|
throw ZoomOAuthError.authenticationTimedOut
|
|
|
+ } catch GoogleOAuthError.authenticationCancelled {
|
|
|
+ throw ZoomOAuthError.authenticationCancelled
|
|
|
} catch {
|
|
|
throw error
|
|
|
}
|
|
|
@@ -6841,6 +6860,7 @@ enum GoogleOAuthError: Error {
|
|
|
case tokenExchangeFailed(String)
|
|
|
case unableToOpenBrowser
|
|
|
case authenticationTimedOut
|
|
|
+ case authenticationCancelled
|
|
|
}
|
|
|
|
|
|
final class GoogleOAuthService: NSObject {
|
|
|
@@ -6881,7 +6901,7 @@ final class GoogleOAuthService: NSObject {
|
|
|
}
|
|
|
|
|
|
private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> GoogleOAuthTokens {
|
|
|
- _ = presentingWindow
|
|
|
+ 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)
|
|
|
@@ -6903,7 +6923,11 @@ final class GoogleOAuthService: NSObject {
|
|
|
]
|
|
|
|
|
|
guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
|
|
|
- let callbackURL = try await oauthWindow.authenticate(startURL: authURL, callbackURLPrefix: redirectURI)
|
|
|
+ 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 {
|
|
|
@@ -7029,56 +7053,114 @@ private final class OAuthWebViewContainerView: NSView {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+/// Separate titled window matching the app’s default content size (see `AppWindowMetrics`); when a parent window is provided, the OAuth content area is aligned to the parent’s content rect.
|
|
|
@MainActor
|
|
|
-private final class InAppOAuthWindowController: NSWindowController, WKNavigationDelegate {
|
|
|
+private final class InAppOAuthWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
|
|
|
private let webView: WKWebView
|
|
|
private var callbackPrefix: String?
|
|
|
private var callbackContinuation: CheckedContinuation<URL, Error>?
|
|
|
+ private weak var parentWindowForChild: NSWindow?
|
|
|
+ /// When true, `windowWillClose` does not treat the close as user cancellation (we closed after success/timeout/error).
|
|
|
+ private var suppressUserCloseCancellation = false
|
|
|
|
|
|
init(title: String) {
|
|
|
let configuration = WKWebViewConfiguration()
|
|
|
configuration.websiteDataStore = .nonPersistent()
|
|
|
self.webView = WKWebView(frame: .zero, configuration: configuration)
|
|
|
let container = OAuthWebViewContainerView(webView: webView)
|
|
|
- let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 980, height: 760), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false)
|
|
|
+ let contentSize = NSSize(width: AppWindowMetrics.defaultContentWidth, height: AppWindowMetrics.defaultContentHeight)
|
|
|
+ let window = NSWindow(contentRect: NSRect(origin: .zero, size: contentSize), styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false)
|
|
|
window.title = title
|
|
|
window.contentView = container
|
|
|
+ window.contentMinSize = contentSize
|
|
|
+ window.contentMaxSize = contentSize
|
|
|
+ window.isReleasedWhenClosed = false
|
|
|
super.init(window: window)
|
|
|
+ window.delegate = self
|
|
|
webView.navigationDelegate = self
|
|
|
}
|
|
|
|
|
|
@available(*, unavailable) required init?(coder: NSCoder) { nil }
|
|
|
|
|
|
- func authenticate(startURL: URL, callbackURLPrefix: String, timeoutSeconds: Double = 120) async throws -> URL {
|
|
|
+ func windowWillClose(_ notification: Notification) {
|
|
|
+ guard suppressUserCloseCancellation == false else { return }
|
|
|
+ removeFromParentIfNeeded()
|
|
|
+ webView.stopLoading()
|
|
|
+ if let continuation = callbackContinuation {
|
|
|
+ callbackContinuation = nil
|
|
|
+ continuation.resume(throwing: GoogleOAuthError.authenticationCancelled)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ func authenticate(startURL: URL, callbackURLPrefix: String, presentingWindow: NSWindow?, timeoutSeconds: Double = 120) async throws -> URL {
|
|
|
self.callbackPrefix = callbackURLPrefix
|
|
|
+ parentWindowForChild = presentingWindow
|
|
|
+ applyFixedOAuthFrame(presentingWindow: presentingWindow)
|
|
|
+ if let parent = presentingWindow, let w = window {
|
|
|
+ parent.addChildWindow(w, ordered: .above)
|
|
|
+ }
|
|
|
showWindow(nil)
|
|
|
window?.makeKeyAndOrderFront(nil)
|
|
|
- window?.center()
|
|
|
webView.load(URLRequest(url: startURL))
|
|
|
|
|
|
- return try await withThrowingTaskGroup(of: URL.self) { group in
|
|
|
- group.addTask { [weak self] in
|
|
|
- guard let self else { throw GoogleOAuthError.invalidCallbackURL }
|
|
|
- return try await self.awaitCallback()
|
|
|
- }
|
|
|
- group.addTask {
|
|
|
- try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
|
|
|
- throw GoogleOAuthError.authenticationTimedOut
|
|
|
+ do {
|
|
|
+ return try await withThrowingTaskGroup(of: URL.self) { group in
|
|
|
+ group.addTask { [weak self] in
|
|
|
+ guard let self else { throw GoogleOAuthError.invalidCallbackURL }
|
|
|
+ return try await self.awaitCallback()
|
|
|
+ }
|
|
|
+ group.addTask {
|
|
|
+ try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
|
|
|
+ throw GoogleOAuthError.authenticationTimedOut
|
|
|
+ }
|
|
|
+ let url = try await group.next()!
|
|
|
+ group.cancelAll()
|
|
|
+ self.closeOAuthWindowAfterFlow()
|
|
|
+ return url
|
|
|
}
|
|
|
- let url = try await group.next()!
|
|
|
- group.cancelAll()
|
|
|
- self.webView.stopLoading()
|
|
|
- self.close()
|
|
|
- return url
|
|
|
+ } catch {
|
|
|
+ self.closeOAuthWindowAfterFlow()
|
|
|
+ throw error
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ private func closeOAuthWindowAfterFlow() {
|
|
|
+ suppressUserCloseCancellation = true
|
|
|
+ webView.stopLoading()
|
|
|
+ removeFromParentIfNeeded()
|
|
|
+ close()
|
|
|
+ suppressUserCloseCancellation = false
|
|
|
+ }
|
|
|
+
|
|
|
private func awaitCallback() async throws -> URL {
|
|
|
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
|
|
|
callbackContinuation = continuation
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ private func removeFromParentIfNeeded() {
|
|
|
+ if let parent = parentWindowForChild, let w = window {
|
|
|
+ parent.removeChildWindow(w)
|
|
|
+ }
|
|
|
+ parentWindowForChild = nil
|
|
|
+ }
|
|
|
+
|
|
|
+ private func applyFixedOAuthFrame(presentingWindow: NSWindow?) {
|
|
|
+ guard let window else { return }
|
|
|
+ if let parent = presentingWindow {
|
|
|
+ let parentContent = parent.contentRect(forFrameRect: parent.frame)
|
|
|
+ let oauthFrame = window.frameRect(forContentRect: parentContent)
|
|
|
+ window.setFrame(oauthFrame, display: true)
|
|
|
+ } else {
|
|
|
+ let contentSize = NSSize(width: AppWindowMetrics.defaultContentWidth, height: AppWindowMetrics.defaultContentHeight)
|
|
|
+ window.setContentSize(contentSize)
|
|
|
+ window.center()
|
|
|
+ }
|
|
|
+ let lockedContentSize = window.contentRect(forFrameRect: window.frame).size
|
|
|
+ window.contentMinSize = lockedContentSize
|
|
|
+ window.contentMaxSize = lockedContentSize
|
|
|
+ }
|
|
|
+
|
|
|
func webView(_ webView: WKWebView,
|
|
|
decidePolicyFor navigationAction: WKNavigationAction,
|
|
|
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
|
|
@@ -7105,6 +7187,7 @@ extension GoogleOAuthError: LocalizedError {
|
|
|
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."
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
@@ -7133,6 +7216,8 @@ extension ZoomOAuthError: LocalizedError {
|
|
|
return "Could not open the system browser for Zoom sign-in."
|
|
|
case .authenticationTimedOut:
|
|
|
return "Zoom sign-in timed out waiting for the browser redirect."
|
|
|
+ case .authenticationCancelled:
|
|
|
+ return "Zoom sign-in was cancelled."
|
|
|
}
|
|
|
}
|
|
|
}
|