Skip to content

Commit cfd89e4

Browse files
committed
Verify accounts via WebView DOM extraction with per-provider status
Account verification previously fetched accountCheckURL via URLSession and parsed the HTML response. Patreon's /settings/basics is now client-rendered, so the __NEXT_DATA__/title fallbacks returned nil and verification kept failing. The pixivFANBOX/SubscribeStar.adult parsers were also fragile to outerHTML quote-style normalization and Cloudflare email-protection nesting. Replace parseAccountInfo(from: Data) on PatronServiceProviding with extractAccountInfo(in: WKWebView), and load each provider's check URL in a real WKWebView so the live DOM is queried: - Patreon: poll the rendered email/name <input> values - pixivFANBOX: read JSON from <meta id="metadata">.content - SubscribeStar.adult: walk .settings_login-label/value, decoding Cloudflare data-cfemail when present Drop NoRedirectDelegate and the URLSession path entirely. Rework SettingsView's verification flow accordingly: collapse the isCheckingLogin / loggedInIdentifiers / accountInfoByIdentifier / accountInfoFetchFailed bag into a single AccountStatus enum keyed by provider (unknown, notSignedIn, verifying(Task), verified, failed), spawn a fresh hidden WKWebView per provider, run verifications concurrently, and cancel in-flight tasks on logout or view dismissal. The hidden WKWebViews are mounted off-screen via ArchiveWebViewRepresentable so rendering actually proceeds (WKWebView needs a window to lay out). Also pin swift-http-structured-headers via Package.resolved.
1 parent 43c1e13 commit cfd89e4

8 files changed

Lines changed: 234 additions & 170 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222
- Added HTML download attribute as media filename fallback before URL path component
2323
- Renamed UI labels for clarity: "Overwrite" → "Replace", "Remove" → "Delete", "Login/Logout" → "Sign In/Sign Out", "Save Directory" → "Save Location"
2424
- Made error strings localizable with String(localized:bundle:)
25+
- Switched account verification to WebView-based DOM extraction with per-provider status and cancellation in Settings
2526

2627
### Fixed
2728

2829
- SubscribeStar.adult post title including extra text after line breaks (e.g. URLs)
30+
- Patreon account verification failing on the client-rendered settings page
2931

3032
## [1.0.0+rc.3] - 2026-03-15
3133

PatronArchiver/Views/SettingsView.swift

Lines changed: 121 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,10 @@ import MessageUI
99
struct 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+
}

PatronArchiverKit/Package.resolved

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

PatronArchiverKit/Sources/PatronArchiverKit/PatronArchiver.swift

Lines changed: 19 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -50,23 +50,29 @@ extension PatronArchiver {
5050
return providerType.isLoggedIn(cookies: cookies)
5151
}
5252

53-
/// Fetches account info by making an HTTP request to the provider's accountCheckURL.
53+
/// Fetches account info by loading the provider's accountCheckURL in the given webView
54+
/// and delegating extraction to the provider.
5455
///
5556
/// - Returns: The account info if successfully fetched, nil otherwise.
56-
public func fetchAccountInfo(for providerType: any PatronServiceProviding.Type) async -> AccountInfo? {
57-
let url = providerType.accountCheckURL
58-
var urlRequest = URLRequest(url: url)
59-
await websiteDataStore.addCookies(to: &urlRequest)
60-
61-
let delegate = NoRedirectDelegate()
62-
guard let (data, response) = try? await urlSession.data(for: urlRequest, delegate: delegate),
63-
let httpResponse = response as? HTTPURLResponse,
64-
httpResponse.statusCode == 200
65-
else {
57+
public func fetchAccountInfo(
58+
for providerType: any PatronServiceProviding.Type,
59+
in webView: WKWebView
60+
) async -> AccountInfo? {
61+
let identifier = providerType.siteIdentifier
62+
do {
63+
let info = try await providerType.extractAccountInfo(in: webView)
64+
Self.logger.info(
65+
"fetchAccountInfo \(identifier, privacy: .public) parsed=\(info != nil, privacy: .public)"
66+
)
67+
return info
68+
} catch is CancellationError {
69+
return nil
70+
} catch {
71+
Self.logger.error(
72+
"fetchAccountInfo error for \(identifier, privacy: .public): \(error.localizedDescription, privacy: .public)"
73+
)
6674
return nil
6775
}
68-
69-
return providerType.parseAccountInfo(from: data)
7076
}
7177
}
7278

@@ -351,13 +357,3 @@ extension PatronArchiver {
351357
}
352358
}
353359

354-
private final class NoRedirectDelegate: NSObject, URLSessionTaskDelegate, Sendable {
355-
func urlSession(
356-
_ session: URLSession,
357-
task: URLSessionTask,
358-
willPerformHTTPRedirection response: HTTPURLResponse,
359-
newRequest request: URLRequest
360-
) async -> URLRequest? {
361-
nil
362-
}
363-
}

0 commit comments

Comments
 (0)