RedditOAuthPanelController.swift 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. import AppKit
  2. import WebKit
  3. @MainActor
  4. final class RedditOAuthPanelController: NSObject {
  5. static let shared = RedditOAuthPanelController()
  6. private var panel: NSPanel?
  7. private var webView: WKWebView?
  8. private var onComplete: (() -> Void)?
  9. private var onAuthError: ((String) -> Void)?
  10. private var finishWorkItem: DispatchWorkItem?
  11. var isPresenting: Bool { panel != nil }
  12. private override init() {
  13. super.init()
  14. }
  15. func completeSignIn(
  16. callbackURL: URL,
  17. parentWindow: NSWindow? = nil,
  18. onAuthError: @escaping (String) -> Void,
  19. onFinished: @escaping () -> Void
  20. ) {
  21. presentPanel(
  22. url: callbackURL,
  23. parentWindow: parentWindow,
  24. onAuthError: onAuthError,
  25. onFinished: onFinished
  26. )
  27. }
  28. func presentLogin(
  29. parentWindow: NSWindow? = nil,
  30. onAuthError: @escaping (String) -> Void,
  31. onFinished: @escaping () -> Void
  32. ) {
  33. presentPanel(
  34. url: RedditWebAuthHelper.redditLoginURL,
  35. parentWindow: parentWindow,
  36. onAuthError: onAuthError,
  37. onFinished: onFinished
  38. )
  39. }
  40. private func presentPanel(
  41. url: URL,
  42. parentWindow: NSWindow? = nil,
  43. onAuthError: @escaping (String) -> Void,
  44. onFinished: @escaping () -> Void
  45. ) {
  46. dismiss()
  47. onComplete = onFinished
  48. self.onAuthError = onAuthError
  49. let panel = NSPanel(
  50. contentRect: NSRect(x: 0, y: 0, width: 520, height: 640),
  51. styleMask: [.titled, .closable, .fullSizeContentView],
  52. backing: .buffered,
  53. defer: false
  54. )
  55. panel.title = "Sign in to Reddit"
  56. panel.isReleasedWhenClosed = false
  57. panel.delegate = self
  58. let webView = WKWebView(
  59. frame: panel.contentView?.bounds ?? .zero,
  60. configuration: RedditWebViewManager.makeConfiguration(includeLoginBridge: false)
  61. )
  62. webView.navigationDelegate = self
  63. webView.uiDelegate = self
  64. webView.autoresizingMask = [.width, .height]
  65. panel.contentView?.addSubview(webView)
  66. self.panel = panel
  67. self.webView = webView
  68. if let parentWindow {
  69. parentWindow.beginSheet(panel)
  70. } else {
  71. panel.center()
  72. panel.level = .floating
  73. panel.makeKeyAndOrderFront(nil)
  74. }
  75. NSApp.activate(ignoringOtherApps: true)
  76. webView.load(URLRequest(url: url))
  77. }
  78. func dismiss() {
  79. finishWorkItem?.cancel()
  80. finishWorkItem = nil
  81. if let panel {
  82. if let parent = panel.sheetParent {
  83. parent.endSheet(panel)
  84. } else {
  85. panel.close()
  86. }
  87. }
  88. panel = nil
  89. webView = nil
  90. onComplete = nil
  91. onAuthError = nil
  92. }
  93. private func finish() {
  94. let completion = onComplete
  95. dismiss()
  96. completion?()
  97. }
  98. private func scheduleFinishIfNeeded(for url: URL) {
  99. guard RedditWebAuthHelper.shouldCompleteOAuthSignIn(for: url) else { return }
  100. finishWorkItem?.cancel()
  101. let workItem = DispatchWorkItem { [weak self] in
  102. self?.finish()
  103. }
  104. finishWorkItem = workItem
  105. DispatchQueue.main.asyncAfter(deadline: .now() + 0.75, execute: workItem)
  106. }
  107. private func beginOAuthSignIn(url: URL, in webView: WKWebView) {
  108. OAuthAuthenticationManager.shared.authenticate(
  109. url: url,
  110. presentationWindow: panel
  111. ) { [weak self] result in
  112. guard let self else { return }
  113. switch result {
  114. case .success(let callbackURL):
  115. webView.load(URLRequest(url: callbackURL))
  116. case .failure(let error as OAuthError) where error == .cancelled:
  117. break
  118. case .failure(let error):
  119. self.onAuthError?(error.localizedDescription)
  120. self.finish()
  121. }
  122. }
  123. }
  124. }
  125. extension RedditOAuthPanelController: NSWindowDelegate {
  126. func windowWillClose(_ notification: Notification) {
  127. guard notification.object as? NSPanel === panel else { return }
  128. finishWorkItem?.cancel()
  129. finishWorkItem = nil
  130. panel = nil
  131. webView = nil
  132. onComplete = nil
  133. onAuthError = nil
  134. }
  135. }
  136. extension RedditOAuthPanelController: WKNavigationDelegate {
  137. func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
  138. guard let url = webView.url else { return }
  139. scheduleFinishIfNeeded(for: url)
  140. }
  141. func webView(
  142. _ webView: WKWebView,
  143. decidePolicyFor navigationAction: WKNavigationAction,
  144. decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
  145. ) {
  146. guard let url = navigationAction.request.url else {
  147. decisionHandler(.allow)
  148. return
  149. }
  150. if RedditWebAuthHelper.isOAuthProviderURL(url) {
  151. decisionHandler(.cancel)
  152. beginOAuthSignIn(url: url, in: webView)
  153. return
  154. }
  155. if RedditWebAuthHelper.shouldCompleteOAuthSignIn(for: url) {
  156. decisionHandler(.allow)
  157. scheduleFinishIfNeeded(for: url)
  158. return
  159. }
  160. decisionHandler(.allow)
  161. }
  162. }
  163. extension RedditOAuthPanelController: WKUIDelegate {
  164. func webView(
  165. _ webView: WKWebView,
  166. createWebViewWith configuration: WKWebViewConfiguration,
  167. for navigationAction: WKNavigationAction,
  168. windowFeatures: WKWindowFeatures
  169. ) -> WKWebView? {
  170. guard let url = navigationAction.request.url else { return nil }
  171. if RedditWebAuthHelper.isOAuthProviderURL(url) {
  172. beginOAuthSignIn(url: url, in: webView)
  173. return nil
  174. }
  175. webView.load(URLRequest(url: url))
  176. return nil
  177. }
  178. }