@@ -9,12 +9,10 @@ import MessageUI
99struct SettingsView : View {
1010 @Bindable private var patronArchiver : PatronArchiver
1111
12+ @State private var verificationWebViews : [ String : WKWebView ] = [ : ]
1213 @State private var isPickingFolder = false
1314 @State private var loginEntry : SiteEntry ?
14- @State private var loggedInIdentifiers : Set < String > = [ ]
15- @State private var accountInfoByIdentifier : [ String : AccountInfo ] = [ : ]
16- @State private var accountInfoFetchFailed : Set < String > = [ ]
17- @State private var isCheckingLogin = false
15+ @State private var accountStatuses : [ String : AccountStatus ] = [ : ]
1816
1917 #if os(iOS)
2018 @Environment ( \. openURL) private var openURL
@@ -45,40 +43,64 @@ struct SettingsView: View {
4543 self . patronArchiver = patronArchiver
4644 }
4745
46+ @ViewBuilder
47+ private var verificationWebViewArea : some View {
48+ let renderSize = patronArchiver. renderSize
49+
50+ ZStack {
51+ ForEach ( verificationWebViews. keys. sorted ( ) , id: \. self) { identifier in
52+ if let webView = verificationWebViews [ identifier] {
53+ ArchiveWebViewRepresentable ( webView: webView)
54+ . frame ( width: renderSize. width, height: renderSize. height)
55+ . scaleEffect (
56+ 1.0 / max( renderSize. width, renderSize. height) ,
57+ anchor: . topLeading
58+ )
59+ . frame ( width: 1 , height: 1 , alignment: . topLeading)
60+ . opacity ( 0.01 )
61+ . allowsHitTesting ( false )
62+ }
63+ }
64+ }
65+ }
66+
4867 var body : some View {
4968 Form {
5069 Section ( " Accounts " ) {
5170 ForEach ( siteEntries) { entry in
71+ let status = accountStatuses [ entry. identifier] ?? . unknown
5272 HStack {
5373 Label ( entry. identifier, systemImage: " globe " )
5474 Spacer ( )
55- if isCheckingLogin {
75+ switch status {
76+ case . unknown, . verifying:
5677 ProgressView ( )
5778 #if os(macOS)
5879 . controlSize( . small)
5980 #endif
60- } else if loggedInIdentifiers. contains ( entry. identifier) {
61- if let info = accountInfoByIdentifier [ entry. identifier] {
62- Text ( info. displayName)
63- . foregroundStyle ( . secondary)
64- } else if accountInfoFetchFailed. contains ( entry. identifier) {
65- Text ( " Verification failed " )
66- . foregroundStyle ( . red)
67- }
68- } else {
81+ case . notSignedIn:
6982 Text ( " Not signed in " )
7083 . foregroundStyle ( . tertiary)
84+ case . verified( let info) :
85+ Text ( info. displayName)
86+ . foregroundStyle ( . secondary)
87+ case . verificationFailed:
88+ Text ( " Verification failed " )
89+ . foregroundStyle ( . red)
7190 }
72- if loggedInIdentifiers. contains ( entry. identifier) {
91+ switch status {
92+ case . notSignedIn:
93+ Button ( " Sign In " ) {
94+ loginEntry = entry
95+ }
96+ case . verified, . verificationFailed, . verifying:
7397 Button ( " Sign Out " ) {
7498 Task {
7599 await logout ( for: entry)
76100 }
77101 }
78- } else {
79- Button ( " Sign In " ) {
80- loginEntry = entry
81- }
102+ case . unknown:
103+ EmptyView ( )
82104 }
83105 }
84106 }
@@ -171,13 +193,23 @@ struct SettingsView: View {
171193 #endif
172194 }
173195 . formStyle ( . grouped)
196+ . background {
197+ verificationWebViewArea
198+ }
174199 #if os(macOS)
175200 . frame( width: 450 )
176201 . padding ( )
177202 #endif
178203 . task {
179204 await checkAllLoginStatus ( )
180205 }
206+ . onDisappear {
207+ for status in accountStatuses. values {
208+ if case . verifying( let task) = status {
209+ task. cancel ( )
210+ }
211+ }
212+ }
181213 . sheet ( item: $loginEntry) { entry in
182214 NavigationStack {
183215 LoginWebView (
@@ -221,56 +253,82 @@ struct SettingsView: View {
221253 }
222254
223255 private func checkAllLoginStatus( ) async {
224- isCheckingLogin = true
225- defer { isCheckingLogin = false }
226-
227256 let providerTypes = PatronServiceManager . allProviderTypes
228257
229- // 1. Fast cookie-based login check
258+ // 1. Fast cookie-based login check (concurrent)
230259 await withTaskGroup ( of: ( String, Bool) . self) { group in
231260 for providerType in providerTypes {
232261 let identifier = providerType. siteIdentifier
233262 group. addTask {
234- let loggedIn = await patronArchiver. isLoggedIn (
235- for: providerType
236- )
263+ let loggedIn = await patronArchiver. isLoggedIn ( for: providerType)
237264 return ( identifier, loggedIn)
238265 }
239266 }
240267 for await (identifier, loggedIn) in group {
241- if loggedIn {
242- loggedInIdentifiers. insert ( identifier)
243- } else {
244- loggedInIdentifiers. remove ( identifier)
268+ if !loggedIn {
269+ accountStatuses [ identifier] = . notSignedIn
245270 }
246271 }
247272 }
248273
249- // 2. Fetch account info for logged-in providers
250- await withTaskGroup ( of: ( String, AccountInfo? ) . self) { group in
251- for providerType in providerTypes {
252- let identifier = providerType. siteIdentifier
253- guard loggedInIdentifiers. contains ( identifier) else { continue }
254- group. addTask {
255- let info = await patronArchiver. fetchAccountInfo (
256- for: providerType
257- )
258- return ( identifier, info)
274+ // 2. Verify logged-in providers (concurrent, fresh WKWebView per provider).
275+ for providerType in providerTypes {
276+ let identifier = providerType. siteIdentifier
277+ if case . notSignedIn = accountStatuses [ identifier] ?? . unknown { continue }
278+ verify ( providerType)
279+ }
280+ }
281+
282+ private func verify( _ providerType: any PatronServiceProviding . Type ) {
283+ let identifier = providerType. siteIdentifier
284+
285+ if case . verifying( let oldTask) = accountStatuses [ identifier] {
286+ oldTask. cancel ( )
287+ }
288+
289+ let configuration = WKWebViewConfiguration ( )
290+ configuration. websiteDataStore = patronArchiver. websiteDataStore
291+ configuration. defaultWebpagePreferences. preferredContentMode = . desktop
292+ let webView = WKWebView (
293+ frame: CGRect ( origin: . zero, size: patronArchiver. renderSize) ,
294+ configuration: configuration
295+ )
296+ verificationWebViews [ identifier] = webView
297+
298+ let task = Task {
299+ defer {
300+ if verificationWebViews [ identifier] === webView {
301+ verificationWebViews [ identifier] = nil
259302 }
260303 }
261- for await (identifier, info) in group {
262- if let info {
263- accountInfoByIdentifier [ identifier] = info
264- accountInfoFetchFailed. remove ( identifier)
265- } else {
266- accountInfoByIdentifier. removeValue ( forKey: identifier)
267- accountInfoFetchFailed. insert ( identifier)
304+
305+ // WKWebView only renders when attached to the window — wait for layout.
306+ // `window` is flagged unsafe under strict memory safety; reading it on the main actor is fine.
307+ for _ in 0 ..< 100 {
308+ if unsafe webView. window != nil { break }
309+ do {
310+ try await Task . sleep ( for: . milliseconds( 50 ) )
311+ } catch {
312+ return
268313 }
269314 }
315+
316+ let info = await patronArchiver. fetchAccountInfo (
317+ for: providerType,
318+ in: webView
319+ )
320+ if !Task. isCancelled {
321+ accountStatuses [ identifier] = info. map ( AccountStatus . verified) ?? . verificationFailed
322+ }
270323 }
324+ accountStatuses [ identifier] = . verifying( task)
271325 }
272326
273327 private func logout( for entry: SiteEntry ) async {
328+ if case . verifying( let task) = accountStatuses [ entry. identifier] {
329+ task. cancel ( )
330+ }
331+
274332 let dataStore = patronArchiver. websiteDataStore
275333 let cookieStore = dataStore. httpCookieStore
276334 let allCookies = await cookieStore. allCookies ( )
@@ -289,31 +347,22 @@ struct SettingsView: View {
289347 }
290348 }
291349
292- loggedInIdentifiers. remove ( entry. identifier)
293- accountInfoByIdentifier. removeValue ( forKey: entry. identifier)
294- accountInfoFetchFailed. remove ( entry. identifier)
350+ accountStatuses [ entry. identifier] = . notSignedIn
295351 }
296352
297353 private func checkLoginStatus( for providerType: any PatronServiceProviding . Type ) async {
298354 let identifier = providerType. siteIdentifier
299355
300356 let loggedIn = await patronArchiver. isLoggedIn ( for: providerType)
301-
302- if loggedIn {
303- loggedInIdentifiers. insert ( identifier)
304- let info = await patronArchiver. fetchAccountInfo ( for: providerType)
305- if let info {
306- accountInfoByIdentifier [ identifier] = info
307- accountInfoFetchFailed. remove ( identifier)
308- } else {
309- accountInfoByIdentifier. removeValue ( forKey: identifier)
310- accountInfoFetchFailed. insert ( identifier)
357+ guard loggedIn else {
358+ if case . verifying( let task) = accountStatuses [ identifier] {
359+ task. cancel ( )
311360 }
312- } else {
313- loggedInIdentifiers. remove ( identifier)
314- accountInfoByIdentifier. removeValue ( forKey: identifier)
315- accountInfoFetchFailed. remove ( identifier)
361+ accountStatuses [ identifier] = . notSignedIn
362+ return
316363 }
364+
365+ verify ( providerType)
317366 }
318367}
319368
@@ -327,3 +376,11 @@ private struct SiteEntry: Identifiable, Equatable {
327376 lhs. identifier == rhs. identifier
328377 }
329378}
379+
380+ private enum AccountStatus {
381+ case unknown
382+ case notSignedIn
383+ case verifying( Task < Void , Never > )
384+ case verified( AccountInfo )
385+ case verificationFailed
386+ }
0 commit comments