| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614 |
- //
- // IndeedJobBrowserWindowController.swift
- // App for Indeed
- //
- import Cocoa
- import WebKit
- /// Indeed job listing and apply flow in a `WKWebView`, embedded in the dashboard main panel or hosted in a window.
- final class IndeedJobBrowserViewController: NSViewController, WKNavigationDelegate, WKUIDelegate {
- /// Shared pool so Indeed / Cloudflare cookies persist across embedded browser sessions.
- private static let sharedProcessPool = WKProcessPool()
- /// When set, a leading **Home** control calls this so the host can hide the embedded browser (same-window UX).
- var onDismissEmbedded: (() -> Void)?
- private let webView: WKWebView = {
- let configuration = WKWebViewConfiguration()
- configuration.processPool = IndeedJobBrowserViewController.sharedProcessPool
- configuration.websiteDataStore = .default()
- configuration.preferences.javaScriptCanOpenWindowsAutomatically = true
- if #available(macOS 11.0, *) {
- configuration.defaultWebpagePreferences.allowsContentJavaScript = true
- }
- return WKWebView(frame: .zero, configuration: configuration)
- }()
- private var pendingURL: URL?
- /// Set when the user starts Google sign-in from Indeed; cleared after one `prompt=select_account` rewrite.
- private var pendingGoogleAccountPicker = false
- private let backButton = NSButton()
- private let forwardButton = NSButton()
- private let reloadButton = NSButton()
- private let dismissEmbeddedButton = NSButton()
- private let toolbarContainer = NSView()
- private var appearanceObserver: NSObjectProtocol?
- private var languageObserver: NSObjectProtocol?
- override func loadView() {
- view = NSView(frame: NSRect(x: 0, y: 0, width: 920, height: 720))
- }
- override func viewDidLoad() {
- super.viewDidLoad()
- webView.translatesAutoresizingMaskIntoConstraints = false
- webView.navigationDelegate = self
- webView.uiDelegate = self
- webView.customUserAgent = Self.desktopSafariLikeUserAgent
- configureSymbolToolbarButton(backButton, symbolName: "chevron.backward", action: #selector(goBack))
- configureSymbolToolbarButton(forwardButton, symbolName: "chevron.forward", action: #selector(goForward))
- configureSymbolToolbarButton(reloadButton, symbolName: "arrow.clockwise", action: #selector(reload))
- configureSymbolToolbarButton(
- dismissEmbeddedButton,
- symbolName: "house.fill",
- action: #selector(dismissEmbedded),
- toolTipKey: "Return to the previous screen"
- )
- toolbarContainer.translatesAutoresizingMaskIntoConstraints = false
- toolbarContainer.wantsLayer = true
- let barStack: NSStackView
- if onDismissEmbedded != nil {
- barStack = NSStackView(views: [dismissEmbeddedButton, backButton, forwardButton, reloadButton, NSView()])
- } else {
- barStack = NSStackView(views: [backButton, forwardButton, reloadButton, NSView()])
- }
- barStack.orientation = .horizontal
- barStack.spacing = 8
- barStack.alignment = .centerY
- barStack.distribution = .fill
- barStack.translatesAutoresizingMaskIntoConstraints = false
- toolbarContainer.addSubview(barStack)
- view.addSubview(toolbarContainer)
- view.addSubview(webView)
- var layoutConstraints: [NSLayoutConstraint] = [
- toolbarContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- toolbarContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
- toolbarContainer.topAnchor.constraint(equalTo: view.topAnchor),
- toolbarContainer.heightAnchor.constraint(equalToConstant: 48),
- barStack.leadingAnchor.constraint(equalTo: toolbarContainer.leadingAnchor, constant: 12),
- barStack.trailingAnchor.constraint(equalTo: toolbarContainer.trailingAnchor, constant: -12),
- barStack.centerYAnchor.constraint(equalTo: toolbarContainer.centerYAnchor),
- backButton.widthAnchor.constraint(equalToConstant: 32),
- backButton.heightAnchor.constraint(equalToConstant: 28),
- forwardButton.widthAnchor.constraint(equalToConstant: 32),
- forwardButton.heightAnchor.constraint(equalToConstant: 28),
- reloadButton.widthAnchor.constraint(equalToConstant: 32),
- reloadButton.heightAnchor.constraint(equalToConstant: 28),
- webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
- webView.topAnchor.constraint(equalTo: toolbarContainer.bottomAnchor),
- webView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
- ]
- if onDismissEmbedded != nil {
- layoutConstraints.append(contentsOf: [
- dismissEmbeddedButton.widthAnchor.constraint(equalToConstant: 32),
- dismissEmbeddedButton.heightAnchor.constraint(equalToConstant: 28)
- ])
- }
- NSLayoutConstraint.activate(layoutConstraints)
- applyLocalizedStrings()
- updateNavigationButtons()
- applyCurrentAppearance()
- appearanceObserver = NotificationCenter.default.addObserver(
- forName: AppAppearanceManager.didChangeNotification,
- object: nil,
- queue: .main
- ) { [weak self] _ in
- self?.applyCurrentAppearance()
- }
- languageObserver = NotificationCenter.default.addObserver(
- forName: AppLanguageManager.didChangeNotification,
- object: nil,
- queue: .main
- ) { [weak self] _ in
- self?.applyLocalizedStrings()
- }
- if let pendingURL {
- webView.load(URLRequest(url: pendingURL))
- self.pendingURL = nil
- }
- }
- deinit {
- if let appearanceObserver {
- NotificationCenter.default.removeObserver(appearanceObserver)
- }
- if let languageObserver {
- NotificationCenter.default.removeObserver(languageObserver)
- }
- }
- func loadPage(_ url: URL) {
- if isViewLoaded {
- webView.load(URLRequest(url: url))
- } else {
- pendingURL = url
- }
- updateNavigationButtons()
- }
- /// Adds this controller as a child of `parent` and pins `view` to `host` (used by the dashboard main panel).
- func embed(in host: NSView, parent: NSViewController) {
- parent.addChild(self)
- view.translatesAutoresizingMaskIntoConstraints = false
- host.addSubview(view)
- NSLayoutConstraint.activate([
- view.leadingAnchor.constraint(equalTo: host.leadingAnchor),
- view.trailingAnchor.constraint(equalTo: host.trailingAnchor),
- view.topAnchor.constraint(equalTo: host.topAnchor),
- view.bottomAnchor.constraint(equalTo: host.bottomAnchor)
- ])
- }
- private func applyHomeToolbarButtonStyle(to button: NSButton) {
- button.bezelStyle = .rounded
- button.isBordered = true
- }
- private func configureSymbolToolbarButton(
- _ button: NSButton,
- symbolName: String,
- action: Selector,
- toolTipKey: String? = nil
- ) {
- button.translatesAutoresizingMaskIntoConstraints = false
- applyHomeToolbarButtonStyle(to: button)
- button.imagePosition = .imageOnly
- button.title = ""
- button.target = self
- button.action = action
- if let toolTipKey {
- button.toolTip = L(toolTipKey)
- }
- updateSymbolToolbarButtonImage(button, symbolName: symbolName, accessibilityLabelKey: toolTipKey)
- }
- private func updateSymbolToolbarButtonImage(
- _ button: NSButton,
- symbolName: String,
- accessibilityLabelKey: String? = nil
- ) {
- let label = accessibilityLabelKey.map { L($0) }
- button.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: label)
- }
- private func applyCurrentAppearance() {
- toolbarContainer.layer?.backgroundColor = AppDashboardTheme.chromeBackground.cgColor
- let labelColor = AppDashboardTheme.primaryText
- dismissEmbeddedButton.contentTintColor = labelColor
- reloadButton.contentTintColor = labelColor
- updateNavigationButtons()
- }
- private func applyLocalizedStrings() {
- dismissEmbeddedButton.toolTip = L("Return to the previous screen")
- updateSymbolToolbarButtonImage(
- dismissEmbeddedButton,
- symbolName: "house.fill",
- accessibilityLabelKey: "Return to the previous screen"
- )
- updateSymbolToolbarButtonImage(backButton, symbolName: "chevron.backward")
- updateSymbolToolbarButtonImage(forwardButton, symbolName: "chevron.forward")
- updateSymbolToolbarButtonImage(reloadButton, symbolName: "arrow.clockwise")
- }
- private func updateNavigationButtons() {
- // Keep buttons enabled so the rounded chrome matches Home; dim only the chevrons when unavailable.
- backButton.isEnabled = true
- forwardButton.isEnabled = true
- let active = AppDashboardTheme.primaryText
- let inactive = AppDashboardTheme.secondaryText
- backButton.contentTintColor = webView.canGoBack ? active : inactive
- forwardButton.contentTintColor = webView.canGoForward ? active : inactive
- }
- @objc private func goBack() {
- guard webView.canGoBack else { return }
- webView.goBack()
- }
- @objc private func goForward() {
- guard webView.canGoForward else { return }
- webView.goForward()
- }
- @objc private func reload() {
- webView.reload()
- }
- @objc private func dismissEmbedded() {
- onDismissEmbedded?()
- }
- func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
- if let host = webView.url?.host?.lowercased(), IndeedWebBrowsingPolicy.isIndeedHost(host) {
- pendingGoogleAccountPicker = false
- }
- updateNavigationButtons()
- }
- func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
- updateNavigationButtons()
- }
- func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
- guard IndeedWebBrowsingPolicy.allows(navigationAction: navigationAction) else {
- decisionHandler(.cancel)
- return
- }
- if let url = navigationAction.request.url,
- IndeedWebBrowsingPolicy.shouldOpenInSystemBrowser(url: url) {
- NSWorkspace.shared.open(url)
- decisionHandler(.cancel)
- return
- }
- noteGoogleSignInStartedFromIndeed(navigationAction)
- if let url = navigationAction.request.url,
- applyGoogleAccountPickerIfNeeded(to: url, in: webView) {
- decisionHandler(.cancel)
- return
- }
- decisionHandler(.allow)
- }
- /// Target=_blank / `window.open` without a frame: Indeed stays in-app; company apply sites open in the default browser.
- func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
- guard navigationAction.targetFrame == nil,
- IndeedWebBrowsingPolicy.allows(navigationAction: navigationAction) else {
- return nil
- }
- if let url = navigationAction.request.url,
- IndeedWebBrowsingPolicy.shouldOpenInSystemBrowser(url: url) {
- NSWorkspace.shared.open(url)
- return nil
- }
- noteGoogleSignInStartedFromIndeed(navigationAction)
- if let url = navigationAction.request.url,
- applyGoogleAccountPickerIfNeeded(to: url, in: webView) {
- return nil
- }
- webView.load(navigationAction.request)
- return nil
- }
- private func noteGoogleSignInStartedFromIndeed(_ navigationAction: WKNavigationAction) {
- guard let destination = navigationAction.request.url else { return }
- let sourceHost = navigationAction.sourceFrame.request.url?.host?.lowercased()
- ?? navigationAction.request.mainDocumentURL?.host?.lowercased()
- ?? webView.url?.host?.lowercased()
- guard let sourceHost, IndeedWebBrowsingPolicy.isIndeedHost(sourceHost) else { return }
- guard IndeedWebBrowsingPolicy.isGoogleSignInEntryURL(destination) else { return }
- pendingGoogleAccountPicker = true
- }
- @discardableResult
- private func applyGoogleAccountPickerIfNeeded(to url: URL, in webView: WKWebView) -> Bool {
- guard pendingGoogleAccountPicker,
- IndeedWebBrowsingPolicy.shouldApplyGoogleAccountPicker(to: url) else {
- return false
- }
- pendingGoogleAccountPicker = false
- let pickerURL = IndeedWebBrowsingPolicy.urlAddingGoogleAccountPickerPrompt(url)
- guard pickerURL != url else { return false }
- webView.load(URLRequest(url: pickerURL))
- return true
- }
- func webView(
- _ webView: WKWebView,
- runJavaScriptAlertPanelWithMessage message: String,
- initiatedByFrame frame: WKFrameInfo,
- completionHandler: @escaping () -> Void
- ) {
- let alert = NSAlert()
- alert.messageText = message
- alert.addButton(withTitle: L("OK"))
- alert.runModal()
- completionHandler()
- }
- func webView(
- _ webView: WKWebView,
- runJavaScriptConfirmPanelWithMessage message: String,
- initiatedByFrame frame: WKFrameInfo,
- completionHandler: @escaping (Bool) -> Void
- ) {
- let alert = NSAlert()
- alert.messageText = message
- alert.addButton(withTitle: L("OK"))
- alert.addButton(withTitle: L("Cancel"))
- completionHandler(alert.runModal() == .alertFirstButtonReturn)
- }
- func webView(
- _ webView: WKWebView,
- runJavaScriptTextInputPanelWithPrompt prompt: String,
- defaultText: String?,
- initiatedByFrame frame: WKFrameInfo,
- completionHandler: @escaping (String?) -> Void
- ) {
- let alert = NSAlert()
- alert.messageText = prompt
- alert.addButton(withTitle: L("OK"))
- alert.addButton(withTitle: L("Cancel"))
- let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 280, height: 24))
- field.stringValue = defaultText ?? ""
- alert.accessoryView = field
- guard alert.runModal() == .alertFirstButtonReturn else {
- completionHandler(nil)
- return
- }
- completionHandler(field.stringValue)
- }
- /// Safari-like UA without the app name suffix — Cloudflare / Indeed bot checks reject `App for Indeed/…` in the UA string.
- private static let desktopSafariLikeUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15"
- }
- // MARK: - Embedded browsing policy
- /// Keeps Indeed and apply helpers (Cloudflare, Google sign-in, reCAPTCHA) in-app; opens company career sites in the system browser.
- enum IndeedWebBrowsingPolicy {
- /// Company career sites and other non-Indeed apply links (e.g. “Apply on company site”).
- static func shouldOpenInSystemBrowser(url: URL) -> Bool {
- guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
- return false
- }
- guard let host = url.host?.lowercased() else { return false }
- if shouldRemainInEmbeddedWebView(url: url) { return false }
- if isRestrictedPlatformHost(host) { return false }
- return true
- }
- /// URLs that must load inside the embedded `WKWebView` (not the default browser).
- static func shouldRemainInEmbeddedWebView(url: URL) -> Bool {
- guard let scheme = url.scheme?.lowercased() else { return false }
- if scheme == "about" || scheme == "blob" || scheme == "data" { return true }
- guard scheme == "http" || scheme == "https" else { return false }
- guard let host = url.host?.lowercased() else { return false }
- if isIndeedHost(host) { return true }
- if isAllowedGoogleSignInHost(host, path: url.path) { return true }
- if isGooglePolicyOrLegalPage(host: host, path: url.path) { return true }
- if isGoogleRecaptchaHost(host, path: url.path) { return true }
- if isCloudflareChallengeHost(host) { return true }
- if isHCaptchaHost(host) { return true }
- return false
- }
- static func isIndeedHost(_ host: String) -> Bool {
- if host == "indeed.com" { return true }
- if host.hasPrefix("indeed.") { return true }
- return host.contains(".indeed.")
- }
- static func allows(navigationAction: WKNavigationAction) -> Bool {
- guard let url = navigationAction.request.url else { return true }
- let isMainFrame = navigationAction.targetFrame?.isMainFrame ?? true
- if isMainFrame {
- return allowsMainFrameNavigation(to: url)
- }
- return allowsSubframeNavigation(to: url)
- }
- static func allowsMainFrameNavigation(to url: URL) -> Bool {
- guard let scheme = url.scheme?.lowercased() else { return false }
- if scheme == "about" || scheme == "blob" || scheme == "data" { return true }
- guard scheme == "http" || scheme == "https" else { return false }
- guard let host = url.host?.lowercased() else { return false }
- if shouldRemainInEmbeddedWebView(url: url) { return true }
- if isRestrictedPlatformHost(host) { return false }
- return true
- }
- static func allowsSubframeNavigation(to url: URL) -> Bool {
- guard let scheme = url.scheme?.lowercased() else { return false }
- if scheme == "about" || scheme == "blob" || scheme == "data" { return true }
- guard scheme == "http" || scheme == "https" else { return false }
- guard let host = url.host?.lowercased() else { return false }
- if isYouTubeRelatedHost(host) {
- return false
- }
- if !isGooglePropertyHost(host) {
- return true
- }
- if isAllowedGoogleSignInHost(host, path: url.path) {
- return true
- }
- if isGooglePolicyOrLegalPage(host: host, path: url.path) {
- return true
- }
- if isGoogleRecaptchaHost(host, path: url.path) {
- return true
- }
- if isCloudflareChallengeHost(host) {
- return true
- }
- return isHCaptchaHost(host)
- }
- /// Google, YouTube, and similar — stay in the sign-in flow; never open in the system browser.
- private static func isRestrictedPlatformHost(_ host: String) -> Bool {
- isGooglePropertyHost(host) || isYouTubeRelatedHost(host)
- }
- private static func isYouTubeRelatedHost(_ host: String) -> Bool {
- if host == "youtu.be" { return true }
- if host == "youtube.com" || host.hasSuffix(".youtube.com") { return true }
- if host.contains("youtube") { return true }
- return false
- }
- /// Privacy / Terms / Help links on the Google account chooser (not YouTube, Gmail, or Search).
- private static func isGooglePolicyOrLegalPage(host: String, path: String) -> Bool {
- if host == "policies.google.com" || host == "privacy.google.com" {
- return true
- }
- if host == "support.google.com" {
- let pathLower = path.lowercased()
- return pathLower.contains("/accounts")
- }
- if host == "www.google.com" || host == "google.com" {
- let pathLower = path.lowercased()
- return pathLower.contains("/policies")
- || pathLower.contains("/privacy")
- || pathLower.contains("/terms")
- || pathLower.contains("/intl/")
- }
- return false
- }
- /// Cloudflare Turnstile / bot check during Indeed apply (`challenges.cloudflare.com`, …).
- private static func isCloudflareChallengeHost(_ host: String) -> Bool {
- if host == "cloudflare.com" || host.hasSuffix(".cloudflare.com") { return true }
- return false
- }
- /// hCaptcha widget hosts used on some apply flows.
- private static func isHCaptchaHost(_ host: String) -> Bool {
- host == "hcaptcha.com" || host.hasSuffix(".hcaptcha.com")
- }
- /// Google reCAPTCHA during Indeed Easy Apply (`recaptcha.net`, `google.com/recaptcha`, `gstatic.com`, …).
- private static func isGoogleRecaptchaHost(_ host: String, path: String) -> Bool {
- if host == "recaptcha.net" || host.hasSuffix(".recaptcha.net") {
- return true
- }
- let pathLower = path.lowercased()
- if host == "recaptcha.google.com" || host.hasPrefix("recaptcha.google.") {
- return true
- }
- if host == "google.com" || host.hasSuffix(".google.com"), pathLower.contains("recaptcha") {
- return true
- }
- if host == "gstatic.com" || host.hasSuffix(".gstatic.com") {
- return true
- }
- return false
- }
- private static func isGooglePropertyHost(_ host: String) -> Bool {
- if host == "google.com" || host.hasSuffix(".google.com") { return true }
- if host == "gmail.com" || host.hasSuffix(".gmail.com") { return true }
- if host == "googleusercontent.com" || host.hasSuffix(".googleusercontent.com") { return true }
- if host == "gstatic.com" || host.hasSuffix(".gstatic.com") { return true }
- if host == "youtube.com" || host.hasSuffix(".youtube.com") { return true }
- if host == "blogger.com" || host.hasSuffix(".blogger.com") { return true }
- if host == "withgoogle.com" || host.hasSuffix(".withgoogle.com") { return true }
- return false
- }
- /// Hosts used during “Sign in with Google” — not Help Center, Gmail, Drive, Search, or the apps launcher.
- private static func isAllowedGoogleSignInHost(_ host: String, path: String) -> Bool {
- if host == "accounts.google.com" || host.hasPrefix("accounts.google.") {
- return true
- }
- if host == "signin.google.com" {
- return true
- }
- if host == "oauth.googleusercontent.com" {
- return true
- }
- if host == "googleusercontent.com" || host.hasSuffix(".googleusercontent.com") {
- return true
- }
- if host == "apis.google.com" {
- return true
- }
- if isGooglePolicyOrLegalPage(host: host, path: path) {
- return true
- }
- if host == "myaccount.google.com" {
- return isGoogleSignInRelatedPath(path)
- }
- if host == "www.google.com" || host == "google.com" {
- return isGoogleSignInRelatedPath(path)
- }
- return false
- }
- private static func isGoogleSignInRelatedPath(_ path: String) -> Bool {
- let pathLower = path.lowercased()
- return pathLower.contains("/signin")
- || pathLower.contains("/accounts")
- || pathLower.contains("servicelogin")
- || pathLower.contains("/oauth")
- || pathLower.contains("/gsi")
- || pathLower.contains("/device")
- || pathLower.contains("/security")
- }
- /// First Google OAuth hop after leaving Indeed (`/o/oauth2/.../auth`, account chooser, …).
- static func isGoogleSignInEntryURL(_ url: URL) -> Bool {
- guard let host = url.host?.lowercased(),
- host == "accounts.google.com" || host.hasPrefix("accounts.google.") else {
- return false
- }
- let path = url.path.lowercased()
- if path.contains("/oauth2/") && path.contains("auth") { return true }
- if path.contains("accountchooser") { return true }
- return false
- }
- /// Whether to inject `prompt=select_account` once for this OAuth attempt.
- static func shouldApplyGoogleAccountPicker(to url: URL) -> Bool {
- guard isGoogleSignInEntryURL(url) else { return false }
- let path = url.path.lowercased()
- if path.contains("/signin/oauth/") || path.contains("/signin/v2/") { return false }
- let query = url.query?.lowercased() ?? ""
- if query.contains("select_account") { return false }
- if query.contains("authuser=") { return false }
- return true
- }
- /// Adds `prompt=select_account` so Google shows the account chooser instead of silent sign-in.
- static func urlAddingGoogleAccountPickerPrompt(_ url: URL) -> URL {
- guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url }
- var items = components.queryItems ?? []
- items.removeAll { $0.name.compare("login_hint", options: .caseInsensitive) == .orderedSame }
- items.removeAll { $0.name.compare("hint", options: .caseInsensitive) == .orderedSame }
- if let promptIndex = items.firstIndex(where: { $0.name.compare("prompt", options: .caseInsensitive) == .orderedSame }) {
- let existing = items[promptIndex].value ?? ""
- if !existing.lowercased().contains("select_account") {
- let merged = existing.isEmpty ? "select_account" : "\(existing) select_account"
- items[promptIndex] = URLQueryItem(name: "prompt", value: merged)
- }
- } else {
- items.append(URLQueryItem(name: "prompt", value: "select_account"))
- }
- components.queryItems = items.isEmpty ? nil : items
- return components.url ?? url
- }
- }
|