mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-20 19:21:22 +08:00
325 lines
13 KiB
Swift
325 lines
13 KiB
Swift
@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<HTTPResponse, Error>?
|
|
private var responseStatus = 200
|
|
private var responseHeaders: [String: String] = [:]
|
|
private var completed = false
|
|
private var timeoutTask: Task<Void, Never>?
|
|
|
|
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<HTTPResponse, Error>) {
|
|
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) }
|
|
}
|
|
}
|
|
}
|