@preconcurrency import WebKit import SwiftUI struct SourceLoginView: View { let source: BookSource @State private var reloadID = UUID() @State private var status = "打开登录页后,Cookie 会自动同步给搜索与阅读请求。" @State private var isChecking = false var body: some View { VStack(spacing: 0) { HStack(spacing: 10) { Image(systemName: "lock.shield.fill").foregroundStyle(ReadingTheme.accent) Text(status).font(.caption).foregroundStyle(.secondary).lineLimit(2) Spacer() if isChecking { ProgressView().controlSize(.small) } } .padding(.horizontal, 14).padding(.vertical, 10) .background(Color(uiColor: .secondarySystemBackground)) if let request = loginRequest { SourceLoginWebView( request: request, sourceURL: source.bookSourceUrl, sourceID: source.id, loginCheckJavaScript: source.loginCheckJs, reloadID: reloadID, status: $status, isChecking: $isChecking ) } else { ReadingUnavailableView("登录地址无效", systemImage: "link.badge.plus", description: Text(source.loginUrl ?? "书源没有配置登录地址")) } } .navigationTitle("登录 · \(source.bookSourceName)") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("重新载入", systemImage: "arrow.clockwise") { reloadID = UUID() } } } } private var loginRequest: URLRequest? { guard let value = source.loginUrl, let prepared = try? LegadoURLBuilder().prepare( value, baseURL: URL(string: source.bookSourceUrl), defaultHeaders: sourceHeaders ) else { return nil } return prepared.urlRequest(handleCookies: source.enabledCookieJar ?? true) } private var sourceHeaders: [String: String] { guard let value = source.header, let data = value.data(using: .utf8), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] } return object.mapValues { String(describing: $0) } } } private struct SourceLoginWebView: UIViewRepresentable { let request: URLRequest let sourceURL: String let sourceID: String let loginCheckJavaScript: String? let reloadID: UUID @Binding var status: String @Binding var isChecking: Bool func makeCoordinator() -> Coordinator { Coordinator(parent: self) } func makeUIView(context: Context) -> WKWebView { let configuration = WKWebViewConfiguration() configuration.websiteDataStore = .default() let webView = WKWebView(frame: .zero, configuration: configuration) webView.navigationDelegate = context.coordinator context.coordinator.installExistingCookies(in: webView) { webView.load(request) } return webView } func updateUIView(_ webView: WKWebView, context: Context) { context.coordinator.parent = self guard context.coordinator.lastReloadID != reloadID else { return } context.coordinator.lastReloadID = reloadID webView.reload() } final class Coordinator: NSObject, WKNavigationDelegate { var parent: SourceLoginWebView var lastReloadID: UUID init(parent: SourceLoginWebView) { self.parent = parent lastReloadID = parent.reloadID } func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation?) { parent.status = "正在打开登录页…" } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation?) { syncCookies(from: webView) guard let script = parent.loginCheckJavaScript?.trimmingCharacters(in: .whitespacesAndNewlines), !script.isEmpty else { parent.status = "页面已载入;登录后 Cookie 会自动同步。" return } parent.isChecking = true webView.evaluateJavaScript(script) { [weak self] value, error in guard let self else { return } DispatchQueue.main.async { self.parent.isChecking = false if let error { self.parent.status = "登录检测脚本失败:\(error.localizedDescription)" } else { self.parent.status = Self.truthy(value) ? "书源登录检测已通过" : "页面已载入,尚未检测到登录状态" } } } } func webView(_ webView: WKWebView, didFail navigation: WKNavigation?, withError error: Error) { parent.status = "登录页加载失败:\(error.localizedDescription)" } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation?, withError error: Error) { parent.status = "登录页加载失败:\(error.localizedDescription)" } func installExistingCookies(in webView: WKWebView, completion: @escaping () -> Void) { guard let url = URL(string: parent.sourceURL) else { completion() return } let cookies = HTTPCookieStorage.shared.cookies(for: url) ?? [] guard !cookies.isEmpty else { completion() return } let group = DispatchGroup() for cookie in cookies { group.enter() webView.configuration.websiteDataStore.httpCookieStore.setCookie(cookie) { group.leave() } } group.notify(queue: .main, execute: completion) } private func syncCookies(from webView: WKWebView) { webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in cookies.forEach(HTTPCookieStorage.shared.setCookie) guard let url = URL(string: self.parent.sourceURL) else { return } let matching = HTTPCookieStorage.shared.cookies(for: url) ?? [] let header = HTTPCookie.requestHeaderFields(with: matching)["Cookie"] ?? "" Task { await SourceNetworkPolicy.shared.setCookieHeader( header, sourceID: self.parent.sourceID, url: url ) } } } private static func truthy(_ value: Any?) -> Bool { if let value = value as? Bool { return value } if let value = value as? NSNumber { return value.boolValue } guard let text = value.map(String.init(describing:))?.lowercased() else { return false } return !text.isEmpty && !["false", "0", "null", "undefined"].contains(text) } } } final class WKSourceWebViewExecutor: NSObject, SourceWebViewRequestExecuting, @unchecked Sendable { @MainActor func execute(_ prepared: PreparedRequest, source: BookSource, timeout: TimeInterval) async throws -> HTTPResponse { let override = ApplicationSettingsStore.shared.settings.timeoutSeconds return try await BackgroundWebViewRequest(prepared: prepared, source: source, timeout: override > 0 ? Double(override) : timeout).run() } } @MainActor private final class BackgroundWebViewRequest: NSObject, WKNavigationDelegate { private let prepared: PreparedRequest private let source: BookSource private let timeout: TimeInterval private let webView: WKWebView private var continuation: CheckedContinuation? private var responseStatus = 200 private var responseHeaders: [String: String] = [:] private var completed = false private var timeoutTask: Task? init(prepared: PreparedRequest, source: BookSource, timeout: TimeInterval) { self.prepared = prepared self.source = source self.timeout = timeout let configuration = WKWebViewConfiguration() configuration.websiteDataStore = .nonPersistent() webView = WKWebView(frame: .zero, configuration: configuration) super.init() webView.navigationDelegate = self } func run() async throws -> HTTPResponse { try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { continuation in self.continuation = continuation installRequestCookies { [weak self] in self?.startLoading() } } } onCancel: { Task { @MainActor [weak self] in self?.finish(.failure(CancellationError())) } } } private func startLoading() { timeoutTask = Task { [weak self] in let seconds = min(max(self?.timeout ?? 30, 1), 300) try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) guard !Task.isCancelled else { return } await MainActor.run { self?.finish(.failure(URLError(.timedOut))) } } webView.load(prepared.urlRequest(timeout: timeout, handleCookies: true)) } private func installRequestCookies(completion: @escaping () -> Void) { guard let header = prepared.headers.first(where: { $0.key.caseInsensitiveCompare("Cookie") == .orderedSame })?.value, let host = prepared.url.host, !header.isEmpty else { completion() return } let cookies = header.split(separator: ";").compactMap { part -> HTTPCookie? in let pair = part.split(separator: "=", maxSplits: 1).map(String.init) guard pair.count == 2 else { return nil } return HTTPCookie(properties: [ .name: pair[0].trimmingCharacters(in: .whitespaces), .value: pair[1], .domain: host, .path: "/", .secure: prepared.url.scheme == "https" ? "TRUE" : "FALSE" ]) } guard !cookies.isEmpty else { completion(); return } let group = DispatchGroup() for cookie in cookies { group.enter() webView.configuration.websiteDataStore.httpCookieStore.setCookie(cookie) { group.leave() } } group.notify(queue: .main, execute: completion) } func webView( _ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping @MainActor (WKNavigationResponsePolicy) -> Void ) { if let response = navigationResponse.response as? HTTPURLResponse { responseStatus = response.statusCode responseHeaders = response.allHeaderFields.reduce(into: [:]) { result, entry in result[String(describing: entry.key)] = String(describing: entry.value) } } decisionHandler(.allow) } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation?) { Task { [weak self] in guard let self else { return } if prepared.webViewDelayMilliseconds > 0 { try? await Task.sleep(nanoseconds: UInt64(max(0, prepared.webViewDelayMilliseconds)) * 1_000_000) } guard !Task.isCancelled, !completed else { return } if let script = prepared.webJavaScript, !script.isEmpty { do { _ = try await webView.evaluateJavaScript(script) } catch { finish(.failure(error)); return } } do { let html = try await webView.evaluateJavaScript("document.documentElement.outerHTML") as? String ?? "" let cookies = await webView.configuration.websiteDataStore.httpCookieStore.allCookies() cookies.forEach(HTTPCookieStorage.shared.setCookie) let cookieHeader = HTTPCookie.requestHeaderFields(with: cookies)["Cookie"] ?? "" if !cookieHeader.isEmpty { await SourceNetworkPolicy.shared.setCookieHeader(cookieHeader, sourceID: source.id, url: prepared.url) } var headers = responseHeaders if !cookieHeader.isEmpty { headers["Set-Cookie"] = cookieHeader } finish(.success(.init( data: Data(html.utf8), statusCode: responseStatus, headers: headers, finalURL: webView.url ?? prepared.url, requestedCharset: "utf-8" ))) } catch { finish(.failure(error)) } } } func webView(_ webView: WKWebView, didFail navigation: WKNavigation?, withError error: Error) { finish(.failure(error)) } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation?, withError error: Error) { finish(.failure(error)) } private func finish(_ result: Result) { guard !completed else { return } completed = true timeoutTask?.cancel() webView.stopLoading() continuation?.resume(with: result) continuation = nil } } private extension WKHTTPCookieStore { func allCookies() async -> [HTTPCookie] { await withCheckedContinuation { continuation in getAllCookies { continuation.resume(returning: $0) } } } }