mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-20 19:21:22 +08:00
feat: open-source 小浣熊阅读 for iOS
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
@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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user