mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
## Summary Opening an invite from Safari can deliver the login request while iOS still considers Buzz inactive. `ASWebAuthenticationSession.start()` then returns false, showing a connection error until the user retries. Wait for app activation before starting the retained session, with a ten-second timeout and cancellation cleanup. Extract the existing browser-auth bridge from the size-limited AppDelegate and add one native journey test through its Flutter method channel. URL validation, callback fencing, and normal active-app login retain their existing behavior. Temporary diagnostic logging and generated dependency changes are excluded. ### Related issue No duplicate found in the open handoff/authentication search. Related invite-consent work: #8601; this fixes the subsequent iOS authentication presentation race. ### Testing - Focused `RunnerTests/BrowserAuthBridgeTests` passed on an iPhone 18 Pro / iOS 27 simulator. Covers inactive handoff, cancellation before activation, retry, duplicate activation notifications, and immediate active-app start. - A local build reproduced the original inactive-app `start=false` failure; the fixed build opened authentication on the first background-to-app attempt. The same fixed build subsequently completed an existing-account staging flow. - Mutation check passed in isolated source, Git metadata, and build outputs: clean control passed; bypassing the production activation wait failed the inactive-handoff assertion; restored source passed again, with its SHA-256 verified against the committed file. - Self-review found no blocking issue. CI and final review remain pending. Mandatory pre-commit and pre-push hooks passed, including mobile analysis and the hook-run Flutter suite. No full local suite was manually invoked. - Human acceptance: from Safari, tap **Accept invite in Buzz** and confirm sign-in opens on the first attempt without **Try again**. Final acceptance of this PR remains pending. The separately reported callback failure may have involved production rather than staging. That cause is unconfirmed and is outside this change. Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
111 lines
4.3 KiB
Swift
111 lines
4.3 KiB
Swift
import AuthenticationServices
|
|
import Flutter
|
|
import UIKit
|
|
|
|
/// Keeps the authentication callback scoped to the system browser session.
|
|
final class BuzzBrowserAuthBridge: NSObject, ASWebAuthenticationPresentationContextProviding {
|
|
private let channel: FlutterMethodChannel
|
|
private var session: ASWebAuthenticationSession?
|
|
private var pending: FlutterResult?
|
|
private var attemptID: UUID?
|
|
|
|
private let isActive: () -> Bool
|
|
private let startSession: (ASWebAuthenticationSession) -> Bool
|
|
private var activationObserver: NSObjectProtocol?
|
|
private var activationTimeout: DispatchWorkItem?
|
|
|
|
init(
|
|
messenger: FlutterBinaryMessenger,
|
|
isActive: @escaping () -> Bool = { UIApplication.shared.applicationState == .active },
|
|
startSession: @escaping (ASWebAuthenticationSession) -> Bool = { $0.start() }
|
|
) {
|
|
self.isActive = isActive
|
|
self.startSession = startSession
|
|
channel = FlutterMethodChannel(name: "buzz/browser_auth", binaryMessenger: messenger)
|
|
super.init()
|
|
channel.setMethodCallHandler { [weak self] call, result in
|
|
guard let self else { return }
|
|
if call.method == "cancel" {
|
|
self.session?.cancel()
|
|
self.finish(nil, code: "cancelled")
|
|
result(nil)
|
|
return
|
|
}
|
|
guard call.method == "start" else { result(FlutterMethodNotImplemented); return }
|
|
guard self.session == nil,
|
|
let args = call.arguments as? [String: String],
|
|
let raw = args["url"], let url = URL(string: raw),
|
|
url.scheme == "https",
|
|
["app.builderlab.xyz", "app.builderlabstaging.xyz"].contains(url.host ?? ""),
|
|
url.path == "/api/goose/v1/auth/login", url.user == nil, url.password == nil,
|
|
url.port == nil, url.fragment == nil
|
|
else { result(FlutterError(code: "unavailable", message: nil, details: nil)); return }
|
|
self.pending = result
|
|
let attemptID = UUID()
|
|
self.attemptID = attemptID
|
|
let session = ASWebAuthenticationSession(url: url, callbackURLScheme: "xyz.block.buzz.mobile") { [weak self] callback, error in
|
|
DispatchQueue.main.async {
|
|
guard self?.attemptID == attemptID else { return }
|
|
let cancelled = (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin
|
|
self?.finish(callback?.absoluteString, code: cancelled ? "cancelled" : "unavailable")
|
|
}
|
|
}
|
|
session.presentationContextProvider = self
|
|
session.prefersEphemeralWebBrowserSession = false
|
|
#if DEBUG
|
|
session.prefersEphemeralWebBrowserSession = args["ephemeral"] == "true"
|
|
#endif
|
|
self.session = session
|
|
self.startWhenActive(attemptID)
|
|
|
|
}
|
|
}
|
|
|
|
// Safari delivers a deep link before UIKit finishes activating our app.
|
|
// ASWebAuthenticationSession.start() rejects presentation during that handoff.
|
|
private func startWhenActive(_ id: UUID) {
|
|
guard attemptID == id, let session else { return }
|
|
if isActive() {
|
|
clearActivationWait()
|
|
if !startSession(session) { finish(nil, code: "unavailable") }
|
|
return
|
|
}
|
|
activationObserver = NotificationCenter.default.addObserver(
|
|
forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
|
|
) { [weak self] _ in
|
|
guard let self, self.attemptID == id, self.isActive() else { return }
|
|
self.startWhenActive(id)
|
|
}
|
|
let timeout = DispatchWorkItem { [weak self] in
|
|
guard let self, self.attemptID == id else { return }
|
|
self.finish(nil, code: "unavailable")
|
|
}
|
|
activationTimeout = timeout
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 10, execute: timeout)
|
|
}
|
|
|
|
private func clearActivationWait() {
|
|
if let activationObserver { NotificationCenter.default.removeObserver(activationObserver) }
|
|
activationObserver = nil
|
|
activationTimeout?.cancel()
|
|
activationTimeout = nil
|
|
}
|
|
|
|
deinit { clearActivationWait() }
|
|
|
|
private func finish(_ callback: String?, code: String) {
|
|
clearActivationWait()
|
|
let result = pending
|
|
attemptID = nil
|
|
pending = nil
|
|
session = nil
|
|
if let callback { result?(callback) }
|
|
else { result?(FlutterError(code: code, message: nil, details: nil)) }
|
|
}
|
|
|
|
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
|
UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
|
.flatMap { $0.windows }.first(where: { $0.isKeyWindow }) ?? ASPresentationAnchor()
|
|
}
|
|
}
|