feat: open-source 小浣熊阅读 for iOS

This commit is contained in:
Aminark
2026-10-14 11:37:36 +08:00
commit ffc9e9d91d
193 changed files with 41382 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
import SwiftUI
struct RSSAddBookView: View {
@EnvironmentObject private var books: BookLibraryModel
@EnvironmentObject private var sources: SourceLibraryModel
let bookURL: String
@State private var resolved: AddBookURLResult?
@State private var existing: (Book, BookSource)?
@State private var errorMessage: String?
var body: some View {
Group {
if let existing {
BookDetailView(book: existing.0, source: existing.1)
} else if let resolved {
BookDetailView(book: resolved.book, source: resolved.source)
} else if let errorMessage {
ReadingUnavailableView("无法添加书籍", systemImage: "books.vertical.circle", description: Text(errorMessage))
} else {
VStack(spacing: 14) {
ProgressView()
Text("正在匹配书源并解析详情…").foregroundStyle(.secondary)
Text(bookURL).font(.caption.monospaced()).foregroundStyle(.tertiary).lineLimit(3)
}.padding()
}
}
.navigationTitle("添加到书架")
.task(id: bookURL) { await load() }
}
private func load() async {
if let book = books.books.first(where: { $0.bookUrl == bookURL }),
let source = sources.sources.first(where: { $0.bookSourceUrl == book.origin }) {
existing = (book, source)
return
}
do {
resolved = try await AddBookURLService().resolve(bookURL: bookURL, sources: sources.sources)
} catch {
errorMessage = error.localizedDescription
}
}
}
+242
View File
@@ -0,0 +1,242 @@
import Foundation
import SwiftUI
@MainActor
final class RSSLibraryModel: ObservableObject {
@Published private(set) var sources: [RssSource] = []
@Published private(set) var articles: [RssArticle] = []
@Published private(set) var stars: [RssStar] = []
@Published private(set) var records: [RssReadRecord] = []
@Published private(set) var isWorking = false
@Published var errorMessage: String?
private let sourceRepository: SQLiteRssSourceRepository
private let articleRepository: SQLiteRssArticleRepository
private let starRepository: SQLiteRssStarRepository
private let recordRepository: SQLiteRssReadRecordRepository
private let service: RSSService
private let importer = RSSSourceImporter()
private let database: LegadoDatabase
private var nextURL: String?
private var page = 1
private var order: Int64 = 0
init(database: LegadoDatabase, service: RSSService = .init()) {
self.database = database
sourceRepository = .init(database: database)
articleRepository = .init(database: database)
starRepository = .init(database: database)
recordRepository = .init(database: database)
self.service = service
}
static func live() -> RSSLibraryModel {
let database = try! LegadoDatabase(url: LegadoDatabase.defaultURL())
return .init(database: database)
}
func load() async {
do {
async let loadedSources = sourceRepository.all()
async let loadedStars = starRepository.all()
async let loadedRecords = recordRepository.all()
(sources, stars, records) = try await (loadedSources, loadedStars, loadedRecords)
} catch { errorMessage = error.localizedDescription }
}
func importFiles(_ urls: [URL]) async {
await perform {
var merged = self.sources
for url in urls {
let granted = url.startAccessingSecurityScopedResource()
defer { if granted { url.stopAccessingSecurityScopedResource() } }
let payload = try self.importer.decode(Data(contentsOf: url))
merged = try self.importer.merge(payload.sources, into: merged)
for remote in payload.subscriptionURLs { merged = try await self.importRemote(remote, existing: merged) }
}
try await self.sourceRepository.save(merged)
self.sources = try await self.sourceRepository.all()
}
}
func importText(_ text: String) async {
await perform {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
if let url = URL(string: trimmed), ["http", "https"].contains(url.scheme?.lowercased() ?? "") {
self.sources = try await self.importRemote(trimmed, existing: self.sources)
} else {
let payload = try self.importer.decode(Data(trimmed.utf8))
var merged = try self.importer.merge(payload.sources, into: self.sources)
for remote in payload.subscriptionURLs { merged = try await self.importRemote(remote, existing: merged) }
self.sources = merged
}
try await self.sourceRepository.save(self.sources)
}
}
func applySilentSubscription(_ data: Data) async throws {
let incoming = try importer.decode(data).sources
var updated = sources
for var source in incoming {
if let index = updated.firstIndex(where: { $0.sourceUrl == source.sourceUrl }) {
guard updated[index].lastUpdateTime < source.lastUpdateTime else { continue }
source.sourceGroup = updated[index].sourceGroup
updated[index] = source
} else { updated.append(source) }
}
try await sourceRepository.save(updated)
sources = try await sourceRepository.all()
}
func setEnabled(_ enabled: Bool, source: RssSource) async {
do { try await sourceRepository.setEnabled(enabled, sourceURL: source.sourceUrl); sources = try await sourceRepository.all() }
catch { errorMessage = error.localizedDescription }
}
func remove(_ source: RssSource) async {
do { try await sourceRepository.remove(sourceURL: source.sourceUrl); sources = try await sourceRepository.all() }
catch { errorMessage = error.localizedDescription }
}
func moveSources(from offsets: IndexSet, to destination: Int) async {
var values = sources
values.move(fromOffsets: offsets, toOffset: destination)
for index in values.indices { values[index].customOrder = index }
do { try await sourceRepository.save(values); sources = try await sourceRepository.all() }
catch { errorMessage = error.localizedDescription }
}
func save(_ value: RssSource, replacing originalURL: String?) async -> Bool {
let name = value.sourceName.trimmingCharacters(in: .whitespacesAndNewlines)
let url = value.sourceUrl.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty, !url.isEmpty else { errorMessage = "源名称和URL不能为空"; return false }
do {
var source = value; source.sourceName = name; source.sourceUrl = url
let original = originalURL.flatMap { key in sources.first { $0.sourceUrl == key } }
let encoder = JSONEncoder()
let oldData = try encoder.encode(original)
let newData = try encoder.encode(source)
if oldData != newData { source.lastUpdateTime = Int64(Date().timeIntervalSince1970 * 1_000) }
if let originalURL, originalURL != source.sourceUrl {
try await starRepository.updateOrigin(from: originalURL, to: source.sourceUrl)
try await articleRepository.updateOrigin(from: originalURL, to: source.sourceUrl)
try await database.deleteSourceVariables(originalURL)
}
var values = sources.filter { $0.sourceUrl != originalURL && $0.sourceUrl != source.sourceUrl }
values.append(source)
try await sourceRepository.save(values)
sources = try await sourceRepository.all()
return true
} catch { errorMessage = error.localizedDescription; return false }
}
func refresh(_ source: RssSource, sortName: String? = nil, sortURL: String? = nil, key: String? = nil) async {
page = 1; order = Int64(Date().timeIntervalSince1970 * 1_000)
let name = sortName ?? source.sourceName
let url = sortURL ?? source.sortUrl?.nilIfBlank ?? source.sourceUrl
await perform {
let report = try await self.service.articles(sortName: name, sortURL: url, source: source, page: 1, key: key)
var values = report.result.articles
for index in values.indices { values[index].order = self.order; self.order -= 1 }
try await self.articleRepository.put(values)
if source.ruleNextPage?.isEmpty == false { try await self.articleRepository.clearOld(origin: source.sourceUrl, sort: name, before: self.order) }
self.nextURL = report.result.nextURL
self.articles = try await self.articleRepository.articles(origin: source.sourceUrl, sort: name)
}
}
func loadMore(_ source: RssSource, sortName: String? = nil, key: String? = nil) async {
guard let nextURL, !nextURL.isEmpty else { return }
page += 1
let name = sortName ?? source.sourceName
await perform {
let report = try await self.service.articles(sortName: name, sortURL: nextURL, source: source, page: self.page, key: key)
var values = report.result.articles
for index in values.indices { values[index].order = self.order; self.order -= 1 }
try await self.articleRepository.append(values)
self.nextURL = report.result.nextURL
self.articles = try await self.articleRepository.articles(origin: source.sourceUrl, sort: name)
}
}
func read(_ article: RssArticle) async {
do {
try await recordRepository.insertIgnoringExisting(article.record())
records = try await recordRepository.all()
if let index = articles.firstIndex(of: article) { articles[index].read = true }
}
catch { errorMessage = error.localizedDescription }
}
func readingMode(for article: RssArticle, source: RssSource) async -> RSSReadingMode {
let policy = RSSReadingPolicy()
if article.type == 0, article.description?.nilIfBlank != nil {
return policy.mode(article: article, source: source)
}
guard let rule = source.ruleContent?.nilIfBlank else {
return policy.mode(article: article, source: source)
}
do {
let content = try await service.content(article: article, rule: rule, source: source)
if article.type == 0 {
var updated = article; updated.description = content
try await articleRepository.put([updated])
if var star = try await starRepository.star(origin: article.origin, link: article.link) {
star.description = content; try await starRepository.save(star); stars = try await starRepository.all()
}
}
return policy.mode(article: article, source: source, resolvedContent: content)
} catch {
errorMessage = error.localizedDescription
if article.type == 0 { return .html(content: "加载正文失败\n\(error.localizedDescription)", baseURL: nil) }
return policy.mode(article: article, source: source)
}
}
func toggleStar(_ article: RssArticle) async {
do {
if try await starRepository.star(origin: article.origin, link: article.link) != nil {
try await starRepository.remove(origin: article.origin, link: article.link)
} else { try await starRepository.save(article.star()) }
stars = try await starRepository.all()
} catch { errorMessage = error.localizedDescription }
}
func isStarred(_ article: RssArticle) -> Bool { stars.contains { $0.origin == article.origin && $0.link == article.link } }
func removeStar(_ value: RssStar) async {
do { try await starRepository.remove(origin: value.origin, link: value.link); stars = try await starRepository.all() }
catch { errorMessage = error.localizedDescription }
}
func saveStar(_ value: RssStar) async {
do { try await starRepository.save(value); stars = try await starRepository.all() }
catch { errorMessage = error.localizedDescription }
}
func clearStars() async {
do { try await starRepository.clear(); stars = [] }
catch { errorMessage = error.localizedDescription }
}
func clearRecords() async {
do { try await recordRepository.clear(); records = [] }
catch { errorMessage = error.localizedDescription }
}
private func importRemote(_ value: String, existing: [RssSource]) async throws -> [RssSource] {
let withoutUA = value.hasSuffix("#requestWithoutUA")
let raw = withoutUA ? String(value.dropLast("#requestWithoutUA".count)) : value
guard let url = URL(string: raw) else { throw URLError(.badURL) }
var request = URLRequest(url: url)
if withoutUA { request.setValue("", forHTTPHeaderField: "User-Agent") }
let (data, response) = try await URLSessionHTTPTransport.shared.download(for: request)
guard (response as? HTTPURLResponse).map({ (200...299).contains($0.statusCode) }) != false else { throw URLError(.badServerResponse) }
let payload = try importer.decode(data)
return try importer.merge(payload.sources, into: existing)
}
private func perform(_ operation: () async throws -> Void) async {
isWorking = true; defer { isWorking = false }
do { try await operation() } catch { errorMessage = error.localizedDescription }
}
}
private extension String {
var nilIfBlank: String? { trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self }
}
+74
View File
@@ -0,0 +1,74 @@
import SwiftUI
@MainActor
private final class RSSSourceDebugModel: ObservableObject {
@Published var input = ""
@Published var running = false
@Published var report: RSSSourceDebugReport?
private let debugger = RSSSourceDebugger()
private var task: Task<Void, Never>?
func run(_ source: RssSource) {
task?.cancel(); running = true; report = nil
task = Task { [debugger, input] in
let result = await debugger.run(source: source, input: input)
guard !Task.isCancelled else { return }
report = result; running = false
}
}
deinit { task?.cancel() }
}
struct RSSSourceDebugView: View {
let source: RssSource
@StateObject private var model = RSSSourceDebugModel()
@State private var rawTitle = ""
@State private var rawText: String?
var body: some View {
List {
Section("调试输入") {
Text(source.sourceName).font(.headline)
TextField("关键词,或 分类::地址", text: $model.input)
.textInputAutocapitalization(.never).disableAutocorrection(true)
.onSubmit { model.run(source) }
Button { model.run(source) } label: { Label(model.running ? "正在调试…" : "运行完整链路", systemImage: "play.fill") }
.disabled(model.running)
}
if model.running { Section { HStack { ProgressView(); Text("正在请求并解析列表与正文") } } }
if let report = model.report {
Section(report.passed ? "调试通过" : "发现兼容问题") {
ForEach(report.steps) { step in
DisclosureGroup {
if step.diagnostics.isEmpty { Text("无规则诊断").foregroundStyle(.secondary) }
ForEach(step.diagnostics) { item in
VStack(alignment: .leading) { Text(item.message); Text(item.code).font(.caption.monospaced()).foregroundStyle(.secondary) }
}
} label: {
Label { VStack(alignment: .leading) { Text(step.stage.title); Text(step.summary).font(.caption).foregroundStyle(.secondary).lineLimit(2) } } icon: { Image(systemName: icon(step.status)).foregroundStyle(color(step.status)) }
}
}
}
if !report.articles.isEmpty {
Section("文章样本") { ForEach(report.articles.prefix(10)) { Text($0.title) } }
}
if let preview = report.contentPreview { Section("正文样本") { Text(preview).textSelection(.enabled) } }
}
}
.navigationTitle("RSS源调试")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu("查看源码", systemImage: "doc.plaintext") {
Button("列表源码") { rawTitle = "列表源码"; rawText = model.report?.listSource }.disabled(model.report?.listSource == nil)
Button("正文源码") { rawTitle = "正文源码"; rawText = model.report?.contentSource }.disabled(model.report?.contentSource == nil)
}
}
}
.sheet(isPresented: Binding(get: { rawText != nil }, set: { if !$0 { rawText = nil } })) {
ReadingNavigationStack { ScrollView { Text(rawText ?? "").font(.caption.monospaced()).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading).padding() }.navigationTitle(rawTitle).toolbar { ToolbarItem(placement: .confirmationAction) { Button("完成") { rawText = nil } } } }
}
}
private func icon(_ status: SourceDebugStatus) -> String { switch status { case .passed: "checkmark.circle.fill"; case .warning: "exclamationmark.triangle.fill"; case .failed: "xmark.circle.fill"; case .skipped: "forward.end.circle" } }
private func color(_ status: SourceDebugStatus) -> Color { switch status { case .passed: .green; case .warning: .orange; case .failed: .red; case .skipped: .secondary } }
}
@@ -0,0 +1,92 @@
import SwiftUI
struct RSSSourceEditorView: View {
@EnvironmentObject private var library: RSSLibraryModel
@Environment(\.dismiss) private var dismiss
@State private var source: RssSource
private let originalURL: String?
init(source: RssSource? = nil) {
let value = source ?? RssSource()
_source = State(initialValue: value)
originalURL = source?.sourceUrl
}
var body: some View {
Form {
Section("基本") {
TextField("名称", text: $source.sourceName)
TextField("URL", text: $source.sourceUrl).textInputAutocapitalization(.never).keyboardType(.URL)
requiredField("图标", \.sourceIcon)
optionalField("分组", \.sourceGroup)
optionalField("注释", \.sourceComment, axis: .vertical)
Toggle("启用", isOn: $source.enabled)
}
Section("请求与登录") {
optionalField("自定义变量说明", \.variableComment, axis: .vertical)
optionalField("JS库", \.jsLib, axis: .vertical)
Toggle("自动保存Cookie", isOn: optionalBool(\.enabledCookieJar, default: true))
optionalField("并发率", \.concurrentRate)
optionalField("请求头", \.header, axis: .vertical)
optionalField("登录地址", \.loginUrl)
optionalField("登录UI", \.loginUi, axis: .vertical)
optionalField("登录检测JS", \.loginCheckJs, axis: .vertical)
optionalField("封面解密JS", \.coverDecodeJs, axis: .vertical)
}
Section("分类与列表规则") {
optionalField("分类URL(名称::URL)", \.sortUrl, axis: .vertical)
Toggle("单URL源", isOn: $source.singleUrl)
Picker("列表样式", selection: $source.articleStyle) { ForEach(0..<5, id: \.self) { Text("样式 \($0)").tag($0) } }
optionalField("列表规则", \.ruleArticles, axis: .vertical)
optionalField("下一页规则", \.ruleNextPage, axis: .vertical)
optionalField("标题规则", \.ruleTitle, axis: .vertical)
optionalField("发布日期规则", \.rulePubDate, axis: .vertical)
optionalField("描述规则", \.ruleDescription, axis: .vertical)
optionalField("图片规则", \.ruleImage, axis: .vertical)
optionalField("链接规则", \.ruleLink, axis: .vertical)
optionalField("正文规则", \.ruleContent, axis: .vertical)
}
Section("WebView规则") {
optionalField("正文URL白名单", \.contentWhitelist, axis: .vertical)
optionalField("正文URL黑名单", \.contentBlacklist, axis: .vertical)
optionalField("跳转URL拦截JS", \.shouldOverrideUrlLoading, axis: .vertical)
optionalField("样式", \.style, axis: .vertical)
Toggle("启用JavaScript", isOn: $source.enableJs)
Toggle("使用Base URL加载", isOn: $source.loadWithBaseUrl)
optionalField("注入JS", \.injectJs, axis: .vertical)
optionalField("预注入JS", \.preloadJs, axis: .vertical)
optionalField("起始HTML", \.startHtml, axis: .vertical)
optionalField("起始样式", \.startStyle, axis: .vertical)
optionalField("起始JS", \.startJs, axis: .vertical)
Toggle("输出网页日志", isOn: $source.showWebLog)
}
Section("其他") {
Stepper("自定义排序:\(source.customOrder)", value: $source.customOrder)
Picker("类型", selection: $source.type) { Text("网页").tag(0); Text("图片").tag(1); Text("视频").tag(2) }
Toggle("启用预加载", isOn: $source.preload)
Toggle("优先加载缓存", isOn: $source.cacheFirst)
optionalField("搜索URL", \.searchUrl, axis: .vertical)
}
}
.navigationTitle(originalURL == nil ? "新建RSS源" : "编辑RSS源")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("保存") { Task { if await library.save(source, replacing: originalURL) { dismiss() } } }
}
}
}
@ViewBuilder private func optionalField(_ title: String, _ keyPath: WritableKeyPath<RssSource, String?>, axis: Axis = .horizontal) -> some View {
ReadingTextField(title, text: optionalText(keyPath), axis: axis).textInputAutocapitalization(.never)
}
@ViewBuilder private func requiredField(_ title: String, _ keyPath: WritableKeyPath<RssSource, String>, axis: Axis = .horizontal) -> some View {
ReadingTextField(title, text: Binding(get: { source[keyPath: keyPath] }, set: { source[keyPath: keyPath] = $0 }), axis: axis).textInputAutocapitalization(.never)
}
private func optionalText(_ keyPath: WritableKeyPath<RssSource, String?>) -> Binding<String> {
Binding(get: { source[keyPath: keyPath] ?? "" }, set: { source[keyPath: keyPath] = $0.isEmpty ? nil : $0 })
}
private func optionalBool(_ keyPath: WritableKeyPath<RssSource, Bool?>, default defaultValue: Bool) -> Binding<Bool> {
Binding(get: { source[keyPath: keyPath] ?? defaultValue }, set: { source[keyPath: keyPath] = $0 })
}
}
+594
View File
@@ -0,0 +1,594 @@
import SwiftUI
import UniformTypeIdentifiers
import AVFoundation
import UIKit
import Photos
struct RSSHomeView: View {
@EnvironmentObject private var library: RSSLibraryModel
@State private var section = 0
@State private var searchText = ""
@State private var showsFiles = false
@State private var showsTextImport = false
@State private var showsExport = false
@State private var confirmsClear = false
@State private var editingStar: RssStar?
var body: some View {
let content = AnyView(VStack(spacing: 0) {
Picker("RSS页面", selection: $section) {
Text("订阅").tag(0); Text("收藏").tag(1); Text("记录").tag(2)
}
.pickerStyle(.segmented).padding()
selectedList
})
let navigation = AnyView(content
.navigationTitle("RSS")
.searchable(text: $searchText, prompt: section == 0 ? "搜索订阅源" : "搜索文章"))
let actions = AnyView(navigation.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu("RSS操作", systemImage: "ellipsis.circle") {
NavigationLink { RSSSourceEditorView() } label: { Label("新建RSS源", systemImage: "plus") }
NavigationLink { RuleSubscriptionView() } label: { Label("规则订阅", systemImage: "link") }
Button("导入文件", systemImage: "doc.badge.plus") { showsFiles = true }
Button("URL或粘贴JSON", systemImage: "link.badge.plus") { showsTextImport = true }
Button("导出全部RSS源", systemImage: "square.and.arrow.up") { showsExport = true }
}
}
ToolbarItem(placement: .primaryAction) { if section == 0 { EditButton() } }
ToolbarItem(placement: .primaryAction) {
if section == 1, !library.stars.isEmpty { Button("清空收藏", systemImage: "trash", role: .destructive) { confirmsClear = true } }
else if section == 2, !library.records.isEmpty { Button("清空记录", systemImage: "trash", role: .destructive) { confirmsClear = true } }
}
})
let importing = AnyView(actions.fileImporter(isPresented: $showsFiles, allowedContentTypes: [.json, .text], allowsMultipleSelection: true) { result in
if case .success(let urls) = result { Task { await library.importFiles(urls) } }
if case .failure(let error) = result { library.errorMessage = error.localizedDescription }
})
let sheets = AnyView(importing
.sheet(isPresented: $showsTextImport) { RSSImportSheet() }
.sheet(item: $editingStar) { value in
RSSStarEditSheet(star: value, save: { updated in
Task { await library.saveStar(updated) }
})
})
let exporting = AnyView(sheets.fileExporter(isPresented: $showsExport, document: RSSExportDocument(sources: library.sources), contentType: UTType.json, defaultFilename: "rssSources") { result in
if case .failure(let error) = result { library.errorMessage = error.localizedDescription }
})
let status = AnyView(exporting
.overlay { if library.isWorking { ProgressView().controlSize(.large) } }
.alert("RSS操作失败", isPresented: Binding(get: { library.errorMessage != nil }, set: { if !$0 { library.errorMessage = nil } })) {
Button("好") { library.errorMessage = nil }
} message: { Text(library.errorMessage ?? "") })
return status.confirmationDialog(section == 1 ? "确定清空全部RSS收藏?" : "确定清空全部RSS阅读记录?", isPresented: $confirmsClear, titleVisibility: Visibility.visible) {
Button("清空", role: .destructive) { Task { if section == 1 { await library.clearStars() } else { await library.clearRecords() } } }
Button("取消", role: .cancel) {}
}
}
@ViewBuilder
private var selectedList: some View {
switch section {
case 1: starList
case 2: recordList
default: sourceList
}
}
private var sourceList: some View {
List {
if filteredSources.isEmpty {
ReadingUnavailableView("还没有RSS源", systemImage: "dot.radiowaves.left.and.right", description: Text("从文件、URL或剪贴板导入 RSS 源。"))
}
ForEach(filteredSources) { source in
NavigationLink { RSSSourceDestinationView(source: source) } label: {
VStack(alignment: .leading, spacing: 4) {
HStack { Text(source.sourceName).font(.headline); Spacer(); if !source.enabled { Text("停用").font(.caption).foregroundStyle(.secondary) } }
Text(source.sourceUrl).font(.caption).foregroundStyle(.secondary).lineLimit(1)
if let group = source.sourceGroup, !group.isEmpty { Text(group).font(.caption2).foregroundStyle(ReadingTheme.accent) }
}.padding(.vertical, 3)
}
.swipeActions(edge: .leading) {
Button(source.enabled ? "停用" : "启用") { Task { await library.setEnabled(!source.enabled, source: source) } }.tint(.orange)
}
.swipeActions {
Button("删除", role: .destructive) { Task { await library.remove(source) } }
}
.contextMenu {
NavigationLink { RSSSourceEditorView(source: source) } label: { Label("编辑", systemImage: "pencil") }
}
.moveDisabled(!searchText.isEmpty)
}
.onMove { offsets, destination in Task { await library.moveSources(from: offsets, to: destination) } }
}.listStyle(.plain)
}
private var starList: some View {
List {
ForEach(filteredStars) { star in
Group {
if let source = library.sources.first(where: { $0.sourceUrl == star.origin }) {
NavigationLink { RSSArticleDetailView(article: star.article, source: source) } label: { RSSArticleRow(article: star.article) }
} else { RSSArticleRow(article: star.article) }
}.swipeActions(edge: .leading) { Button("编辑", systemImage: "pencil") { editingStar = star }.tint(.blue) }
}.onDelete { offsets in
let values = offsets.map { filteredStars[$0] }
Task { for value in values { await library.removeStar(value) } }
}
}.overlay { if filteredStars.isEmpty { ReadingUnavailableView("没有收藏", systemImage: "star") } }.listStyle(.plain)
}
private var recordList: some View {
List(filteredRecords) { record in
if let source = library.sources.first(where: { $0.sourceUrl == record.origin }) {
NavigationLink { RSSArticleDetailView(article: record.article, source: source) } label: { RSSArticleRow(article: record.article) }
} else { RSSArticleRow(article: record.article) }
}.overlay { if filteredRecords.isEmpty { ReadingUnavailableView("没有阅读记录", systemImage: "clock") } }.listStyle(.plain)
}
private var filteredSources: [RssSource] { library.sources.filter { searchText.isEmpty || $0.sourceName.localizedCaseInsensitiveContains(searchText) || $0.sourceUrl.localizedCaseInsensitiveContains(searchText) || ($0.sourceGroup ?? "").localizedCaseInsensitiveContains(searchText) } }
private var filteredStars: [RssStar] { library.stars.filter { searchText.isEmpty || $0.title.localizedCaseInsensitiveContains(searchText) } }
private var filteredRecords: [RssReadRecord] { library.records.filter { searchText.isEmpty || ($0.title ?? "").localizedCaseInsensitiveContains(searchText) } }
}
private struct RSSArticleListView: View {
@EnvironmentObject private var library: RSSLibraryModel
let source: RssSource
@State private var searchKey = ""
@State private var sorts: [RSSSort] = []
@State private var selectedSort = 0
var body: some View {
VStack(spacing: 0) {
if sorts.count > 1 {
ScrollView(.horizontal, showsIndicators: false) {
HStack { ForEach(Array(sorts.enumerated()), id: \.element.id) { index, sort in
Button(sort.name.isEmpty ? "默认" : sort.name) { selectedSort = index; Task { await refresh() } }
.buttonStyle(.bordered).tint(selectedSort == index ? ReadingTheme.accent : .secondary)
}}.padding(.horizontal)
}.padding(.vertical, 6)
}
articleCollection
}
.overlay { if library.articles.isEmpty, !library.isWorking { ReadingUnavailableView("没有文章", systemImage: "newspaper") } }
.navigationTitle(source.sourceName)
.searchable(text: $searchKey, prompt: "源内搜索")
.refreshable { await refresh() }
.toolbar {
ToolbarItem(placement: .primaryAction) { Button("刷新", systemImage: "arrow.clockwise") { Task { await refresh() } } }
ToolbarItem(placement: .primaryAction) { NavigationLink { RSSSourceEditorView(source: source) } label: { Label("编辑", systemImage: "pencil") } }
ToolbarItem(placement: .primaryAction) { NavigationLink { RSSSourceDebugView(source: source) } label: { Label("调试", systemImage: "stethoscope") } }
ToolbarItem(placement: .bottomBar) { Button("加载下一页", systemImage: "arrow.down") { Task { await library.loadMore(source, sortName: activeName, key: searchKey.nilIfEmpty) } } }
}
.task { sorts = await RSSSortResolver().resolve(source); await refresh() }
}
private var activeSort: RSSSort { sorts.indices.contains(selectedSort) ? sorts[selectedSort] : .init(name: "", url: source.sourceUrl) }
private var activeName: String { searchKey.nilIfEmpty == nil ? activeSort.name : "搜索" }
private func refresh() async {
let url = searchKey.nilIfEmpty == nil ? activeSort.url : (source.searchUrl?.nilIfEmpty ?? activeSort.url)
await library.refresh(source, sortName: activeName, sortURL: url, key: searchKey.nilIfEmpty)
}
@ViewBuilder private var articleCollection: some View {
if source.articleStyle == 0 {
List(library.articles) { article in
NavigationLink { RSSArticleDetailView(article: article, source: source) } label: { RSSArticleRow(article: article) }
}.listStyle(.plain)
} else {
ScrollView {
LazyVGrid(columns: articleColumns, spacing: source.articleStyle == 3 ? 20 : 8) {
ForEach(library.articles) { article in
NavigationLink { RSSArticleDetailView(article: article, source: source) } label: {
RSSArticleCard(article: article, style: source.articleStyle)
}.buttonStyle(.plain)
}
}.padding(.horizontal, source.articleStyle == 3 ? 20 : 8).padding(.bottom, 20)
}.refreshable { await refresh() }
}
}
private var articleColumns: [GridItem] {
let count = source.articleStyle == 1 ? 1 : (source.articleStyle == 4 ? 3 : 2)
return Array(repeating: GridItem(.flexible(), spacing: source.articleStyle == 3 ? 20 : 8, alignment: .top), count: count)
}
}
private struct RSSSourceDestinationView: View {
let source: RssSource
var body: some View {
if source.singleUrl {
RSSStandaloneReaderView(source: source, mode: .web(url: source.sourceUrl))
} else if let mode = try? RSSStartPageResolver().resolve(source) {
RSSStandaloneReaderView(source: source, mode: mode)
} else {
RSSArticleListView(source: source)
}
}
}
private struct RSSStandaloneReaderView: View {
let source: RssSource
let mode: RSSReadingMode
@State private var route: RSSNativeRoute?
var body: some View {
RSSWebReaderView(mode: mode, source: source, onAction: handle)
.navigationTitle(source.sourceName).navigationBarTitleDisplayMode(.inline)
.sheet(item: $route) { route in
switch route {
case .photo(let value): RSSPhotoView(value: value, source: source)
case .search(let value): ReadingNavigationStack { SearchView(initialKeyword: value) }
case .rss(let article): ReadingNavigationStack { RSSArticleDetailView(article: article, source: source) }
case .login: ReadingNavigationStack { SourceLoginView(source: source.bookSourceAdapter) }
case .sort(let value): ReadingNavigationStack { RSSArticleListView(source: value) }
case .addBook(let value): ReadingNavigationStack { RSSAddBookView(bookURL: value) }
case .download(let url, let fileName): ReadingNavigationStack { RSSDownloadView(url: url, fileName: fileName, source: source) }
case .start(let mode): ReadingNavigationStack { RSSStandaloneReaderView(source: source, mode: mode) }
}
}
}
private func handle(_ action: RSSWebAction) {
switch action {
case .showPhoto(let value): route = .photo(value)
case .searchBook(let key, _): route = .search(key)
case .addBook(let value): route = .addBook(value)
case .download(let url, let fileName): route = .download(url: url, fileName: fileName)
case .open(let name, let url, let title, _):
switch name.lowercased() {
case "login": route = .login
case "search": if let value = title ?? url { route = .search(value) }
case "sort": var value = source; value.sortUrl = url; route = .sort(value)
case "rss":
if let url { route = .rss(.init(origin: source.sourceUrl, sort: "", title: title ?? source.sourceName, link: url)) }
else if source.singleUrl { route = .start(.web(url: source.sourceUrl)) }
else if let mode = try? RSSStartPageResolver().resolve(source) { route = .start(mode) }
else { route = .sort(source) }
default: if let url, let target = URL(string: url) { UIApplication.shared.open(target) }
}
}
}
}
private struct RSSArticleRow: View {
let article: RssArticle
var body: some View {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 5) {
Text(article.title).font(.headline).foregroundStyle(article.read ? .secondary : .primary)
if let description = article.description, !description.isEmpty { Text(description).font(.caption).foregroundStyle(.secondary).lineLimit(2) }
if let date = article.pubDate, !date.isEmpty { Text(date).font(.caption2).foregroundStyle(.tertiary) }
}
Spacer()
if article.type == 1 { Image(systemName: "photo") }
else if article.type == 2 { Image(systemName: "play.rectangle") }
}.padding(.vertical, 3)
}
}
private struct RSSArticleCard: View {
let article: RssArticle
let style: Int
var body: some View {
VStack(alignment: .leading, spacing: 0) {
articleImage
Text(article.title)
.font(style == 1 || style == 3 ? .subheadline.bold() : .caption)
.foregroundStyle(article.read ? .secondary : .primary)
.lineLimit(style == 3 ? 9 : 2)
.padding(.horizontal, style == 1 ? 12 : 6).padding(.top, 10)
if let date = article.pubDate, !date.isEmpty {
Text(date).font(.caption2).foregroundStyle(.secondary).lineLimit(style == 3 ? 39 : 1)
.padding(.horizontal, style == 1 ? 12 : 6).padding(.top, 8).padding(.bottom, 10)
} else { Spacer().frame(height: 10) }
}
.background(style == 3 ? Color(.secondarySystemBackground) : .clear)
.clipShape(RoundedRectangle(cornerRadius: style == 3 ? 12 : 0, style: .continuous))
.overlay { if style == 3 { RoundedRectangle(cornerRadius: 12).stroke(.primary.opacity(0.08)) } }
}
@ViewBuilder private var articleImage: some View {
let height = imageHeight
if let raw = article.image, let url = URL(string: raw) {
AsyncImage(url: url) { phase in
if let image = phase.image { image.resizable().scaledToFill() }
else if phase.error != nil { placeholder }
else { ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) }
}.frame(maxWidth: .infinity).frame(height: height).clipped()
} else { placeholder.frame(height: height) }
}
private var placeholder: some View {
ZStack { Color(.tertiarySystemFill); Image(systemName: article.type == 2 ? "play.rectangle" : "photo").foregroundStyle(.secondary) }
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var imageHeight: CGFloat {
switch style {
case 1: return 220
case 2: return 272
case 4: return 182
default: return 180
}
}
}
private struct RSSArticleDetailView: View {
@EnvironmentObject private var library: RSSLibraryModel
let article: RssArticle
let source: RssSource
@State private var mode: RSSReadingMode?
@State private var speechText = ""
@State private var reloadID = UUID()
@StateObject private var speech = RSSSpeechController()
@State private var nativeRoute: RSSNativeRoute?
var body: some View {
Group {
if let mode {
RSSWebReaderView(mode: mode, source: source, onTextChange: { speechText = $0 }, onAction: handleWebAction)
.id(reloadID)
} else {
ProgressView("正在加载…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.navigationTitle("文章")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(library.isStarred(article) ? "取消收藏" : "收藏", systemImage: library.isStarred(article) ? "star.fill" : "star") { Task { await library.toggleStar(article) } }
}
ToolbarItem(placement: .primaryAction) {
if let url = URL(string: article.link) { ReadingShareLink(item: url) }
}
ToolbarItem(placement: .primaryAction) {
Menu("阅读操作", systemImage: "ellipsis.circle") {
Button(speech.isSpeaking ? "停止朗读" : "朗读正文", systemImage: speech.isSpeaking ? "stop.fill" : "speaker.wave.2.fill") {
speech.isSpeaking ? speech.stop() : speech.speak(speechText)
}.disabled(speechText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
Button("刷新", systemImage: "arrow.clockwise") { reloadID = UUID() }
if let url = URL(string: article.link) { Link("浏览器打开", destination: url) }
if source.loginUrl?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
NavigationLink { SourceLoginView(source: source.bookSourceAdapter) } label: { Label("源登录", systemImage: "person.badge.key") }
}
NavigationLink { RSSSourceEditorView(source: source) } label: { Label("编辑源", systemImage: "pencil") }
NavigationLink { RSSSourceDebugView(source: source) } label: { Label("调试源", systemImage: "stethoscope") }
}
}
}
.task(id: article.link) {
await library.read(article)
mode = await library.readingMode(for: article, source: source)
}
.sheet(item: $nativeRoute) { route in
switch route {
case .photo(let value): RSSPhotoView(value: value, source: source)
case .search(let key): ReadingNavigationStack { SearchView(initialKeyword: key) }
case .rss(let target): ReadingNavigationStack { RSSArticleDetailView(article: target, source: source) }
case .login: ReadingNavigationStack { SourceLoginView(source: source.bookSourceAdapter) }
case .sort(let target): ReadingNavigationStack { RSSArticleListView(source: target) }
case .addBook(let value): ReadingNavigationStack { RSSAddBookView(bookURL: value) }
case .download(let url, let fileName): ReadingNavigationStack { RSSDownloadView(url: url, fileName: fileName, source: source) }
case .start(let value): ReadingNavigationStack { RSSStandaloneReaderView(source: source, mode: value) }
}
}
}
private func handleWebAction(_ action: RSSWebAction) {
switch action {
case .showPhoto(let value): nativeRoute = .photo(value)
case .searchBook(let key, _): nativeRoute = .search(key)
case .addBook(let value): nativeRoute = .addBook(value)
case .open(let name, let url, let title, _):
switch name.lowercased() {
case "search": if let key = title ?? url { nativeRoute = .search(key) }
case "login": nativeRoute = .login
case "sort":
var target = source
if let url {
if let title, let data = try? JSONSerialization.data(withJSONObject: [title: url]) { target.sortUrl = String(decoding: data, as: UTF8.self) }
else { target.sortUrl = url }
}
nativeRoute = .sort(target)
case "rss":
if let url { nativeRoute = .rss(.init(origin: source.sourceUrl, sort: "", title: title ?? source.sourceName, link: url)) }
else if source.singleUrl { nativeRoute = .start(.web(url: source.sourceUrl)) }
else if let mode = try? RSSStartPageResolver().resolve(source) { nativeRoute = .start(mode) }
else { nativeRoute = .sort(source) }
default: if let url, let target = URL(string: url) { UIApplication.shared.open(target) }
}
case .download(let url, let fileName): nativeRoute = .download(url: url, fileName: fileName)
}
}
}
private enum RSSNativeRoute: Identifiable {
case photo(String), search(String), rss(RssArticle), login, sort(RssSource), addBook(String), download(url: String, fileName: String), start(RSSReadingMode)
var id: String {
switch self {
case .photo(let value): return "photo:\(value)"
case .search(let value): return "search:\(value)"
case .rss(let value): return "rss:\(value.id)"
case .login: return "login"
case .sort(let value): return "sort:\(value.sourceUrl):\(value.sortUrl ?? "")"
case .addBook(let value): return "addBook:\(value)"
case .download(let url, _): return "download:\(url)"
case .start(let mode): return "start:\(String(describing: mode))"
}
}
}
private struct RSSDownloadView: View {
@Environment(\.dismiss) private var dismiss
let url: String
let fileName: String
let source: RssSource
@State private var data = Data()
@State private var loading = true
@State private var showsExporter = false
@State private var errorMessage: String?
var body: some View {
VStack(spacing: 16) {
if loading { ProgressView("正在下载…") }
else if let errorMessage { ReadingUnavailableView("下载失败", systemImage: "exclamationmark.arrow.triangle.2.circlepath", description: Text(errorMessage)) }
else {
Image(systemName: "doc.badge.arrow.up").font(.system(size: 48)).foregroundStyle(ReadingTheme.accent)
Text(fileName).font(.headline)
Text(ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file)).foregroundStyle(.secondary)
Button("选择保存位置", systemImage: "folder") { showsExporter = true }.buttonStyle(.borderedProminent)
}
}.padding().navigationTitle("文件下载").toolbar { ToolbarItem(placement: .cancellationAction) { Button("关闭") { dismiss() } } }
.task(id: url) { await download() }
.fileExporter(isPresented: $showsExporter, document: RSSBinaryDocument(data: data), contentType: .data, defaultFilename: fileName) { result in if case .failure(let error) = result { errorMessage = error.localizedDescription } }
}
private func download() async {
do {
guard let target = URL(string: url) else { throw URLError(.badURL) }
var request = URLRequest(url: target)
if let headers = try? BookSourceHeaderResolver().resolve(source.bookSourceAdapter) { for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) } }
let cookie = LegadoCookieStore.shared.cookieHeader(for: url)
if !cookie.isEmpty { request.setValue(cookie, forHTTPHeaderField: "Cookie") }
let (value, response) = try await URLSessionHTTPTransport.shared.download(for: request)
if let response = response as? HTTPURLResponse, !(200...299).contains(response.statusCode) { throw URLError(.badServerResponse) }
data = value; loading = false; showsExporter = true
} catch { errorMessage = error.localizedDescription; loading = false }
}
}
private struct RSSBinaryDocument: FileDocument {
static var readableContentTypes: [UTType] { [.data] }
let data: Data
init(data: Data) { self.data = data }
init(configuration: ReadConfiguration) throws { data = configuration.file.regularFileContents ?? Data() }
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { .init(regularFileWithContents: data) }
}
private struct RSSPhotoView: View {
@Environment(\.dismiss) private var dismiss
let value: String
let source: RssSource
@State private var image: UIImage?
@State private var loading = true
@State private var message: String?
var body: some View {
ReadingNavigationStack {
GeometryReader { proxy in
ScrollView([.horizontal, .vertical]) {
Group {
if let image { Image(uiImage: image).resizable().scaledToFit() }
else if loading { ProgressView() }
else { ReadingUnavailableView("图片加载失败", systemImage: "photo.badge.exclamationmark") }
}.frame(minWidth: proxy.size.width, minHeight: proxy.size.height)
}.background(.black)
}
.navigationTitle(source.sourceName).navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) { Button("保存到照片", systemImage: "square.and.arrow.down") { save() }.disabled(image == nil) }
ToolbarItem(placement: .confirmationAction) { Button("完成") { dismiss() } }
}
.task(id: value) { await load() }
.alert("图片", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) { Button("好") { message = nil } } message: { Text(message ?? "") }
}
}
private func load() async {
do {
let data: Data
if value.lowercased().hasPrefix("data:"), let comma = value.firstIndex(of: ",") {
let metadata = value[..<comma].lowercased()
let payload = String(value[value.index(after: comma)...])
data = metadata.contains(";base64") ? (Data(base64Encoded: payload) ?? Data()) : Data((payload.removingPercentEncoding ?? payload).utf8)
} else {
guard let url = URL(string: value) else { throw URLError(.badURL) }
var request = URLRequest(url: url)
if let headers = try? BookSourceHeaderResolver().resolve(source.bookSourceAdapter) { for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) } }
let cookie = LegadoCookieStore.shared.cookieHeader(for: value)
if !cookie.isEmpty { request.setValue(cookie, forHTTPHeaderField: "Cookie") }
(data, _) = try await URLSessionHTTPTransport.shared.download(for: request)
}
guard let decoded = UIImage(data: data) else { throw URLError(.cannotDecodeContentData) }
image = decoded; loading = false
} catch { loading = false; message = "加载图片失败:\(error.localizedDescription)" }
}
private func save() {
guard let image else { return }
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
guard status == .authorized || status == .limited else { DispatchQueue.main.async { message = "没有照片写入权限" }; return }
PHPhotoLibrary.shared().performChanges { PHAssetChangeRequest.creationRequestForAsset(from: image) } completionHandler: { success, error in
DispatchQueue.main.async { message = success ? "保存成功" : "保存失败:\(error?.localizedDescription ?? "未知错误")" }
}
}
}
}
@MainActor
private final class RSSSpeechController: NSObject, ObservableObject, AVSpeechSynthesizerDelegate {
@Published private(set) var isSpeaking = false
private let synthesizer = AVSpeechSynthesizer()
override init() { super.init(); synthesizer.delegate = self }
func speak(_ text: String) {
let value = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return }
synthesizer.stopSpeaking(at: .immediate)
let utterance = AVSpeechUtterance(string: value)
utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN")
synthesizer.speak(utterance)
isSpeaking = true
}
func stop() { synthesizer.stopSpeaking(at: .immediate); isSpeaking = false }
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { Task { @MainActor in self.isSpeaking = false } }
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { Task { @MainActor in self.isSpeaking = false } }
}
private struct RSSImportSheet: View {
@EnvironmentObject private var library: RSSLibraryModel
@Environment(\.dismiss) private var dismiss
@State private var text = ""
var body: some View {
ReadingNavigationStack {
TextEditor(text: $text).font(.system(.body, design: .monospaced)).padding()
.navigationTitle("导入RSS源")
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
ToolbarItem(placement: .confirmationAction) { Button("导入") { Task { await library.importText(text); dismiss() } }.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) }
}
}
}
}
private struct RSSExportDocument: FileDocument {
static var readableContentTypes: [UTType] { [.json] }
let data: Data
init(sources: [RssSource]) {
let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
data = (try? encoder.encode(sources)) ?? Data("[]".utf8)
}
init(configuration: ReadConfiguration) throws { data = configuration.file.regularFileContents ?? Data() }
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { FileWrapper(regularFileWithContents: data) }
}
private struct RSSStarEditSheet: View {
@Environment(\.dismiss) private var dismiss
@State private var value: RssStar
let save: (RssStar) -> Void
init(star: RssStar, save: @escaping (RssStar) -> Void) { _value = State(initialValue: star); self.save = save }
var body: some View {
ReadingNavigationStack {
Form {
TextField("标题", text: $value.title)
TextField("分组", text: $value.group)
ReadingLabeledContent("链接", value: value.link).textSelection(.enabled)
}.navigationTitle("编辑RSS收藏").toolbar {
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
ToolbarItem(placement: .confirmationAction) { Button("保存") { save(value); dismiss() }.disabled(value.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) }
}
}
}
}
private extension String {
var nilIfEmpty: String? { isEmpty ? nil : self }
}
+318
View File
@@ -0,0 +1,318 @@
import SwiftUI
@preconcurrency import WebKit
import UIKit
enum RSSWebAction {
case showPhoto(String)
case searchBook(key: String, scope: String?)
case addBook(String)
case open(name: String, url: String?, title: String?, origin: String?)
case download(url: String, fileName: String)
}
struct RSSWebReaderView: UIViewRepresentable {
let mode: RSSReadingMode
let source: RssSource
let onTextChange: (String) -> Void
let onAction: (RSSWebAction) -> Void
init(mode: RSSReadingMode, source: RssSource, onTextChange: @escaping (String) -> Void = { _ in }, onAction: @escaping (RSSWebAction) -> Void = { _ in }) {
self.mode = mode
self.source = source
self.onTextChange = onTextChange
self.onAction = onAction
}
func makeCoordinator() -> Coordinator { Coordinator(source: source, onTextChange: onTextChange, onAction: onAction) }
func makeUIView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .default()
configuration.allowsInlineMediaPlayback = true
configuration.mediaTypesRequiringUserActionForPlayback = []
configuration.defaultWebpagePreferences.allowsContentJavaScript = source.enableJs
let controller = configuration.userContentController
controller.add(context.coordinator, name: "legado")
controller.addUserScript(.init(source: context.coordinator.bridgeScript, injectionTime: .atDocumentStart, forMainFrameOnly: false))
if let preload = source.preloadJs?.trimmedNonEmpty {
controller.addUserScript(.init(source: preload, injectionTime: .atDocumentStart, forMainFrameOnly: false))
}
if let inject = source.injectJs?.trimmedNonEmpty {
controller.addUserScript(.init(source: inject, injectionTime: .atDocumentEnd, forMainFrameOnly: false))
}
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.navigationDelegate = context.coordinator
webView.uiDelegate = context.coordinator
webView.allowsBackForwardNavigationGestures = true
webView.scrollView.contentInsetAdjustmentBehavior = .automatic
context.coordinator.installRulesAndLoad(mode: mode, in: webView)
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
context.coordinator.update(source: source)
context.coordinator.onTextChange = onTextChange
context.coordinator.onAction = onAction
context.coordinator.loadIfChanged(mode: mode, in: webView)
}
static func dismantleUIView(_ webView: WKWebView, coordinator: Coordinator) {
webView.stopLoading()
webView.configuration.userContentController.removeScriptMessageHandler(forName: "legado")
webView.navigationDelegate = nil
webView.uiDelegate = nil
}
@MainActor final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler {
private var source: RssSource
var onTextChange: (String) -> Void
var onAction: (RSSWebAction) -> Void
private var signature = ""
private var rulesInstalled = false
private let htmlProcessor = RSSHTMLProcessor()
private var analyzeContent: JSONValue = .string("")
private var analyzeBaseURL = ""
private var analyzeRedirectURL: String?
private var analyzeVariables: [String: String] = [:]
init(source: RssSource, onTextChange: @escaping (String) -> Void, onAction: @escaping (RSSWebAction) -> Void) { self.source = source; self.onTextChange = onTextChange; self.onAction = onAction }
func update(source: RssSource) { self.source = source }
var bridgeScript: String {
let sourceJSON = (try? JSONEncoder().encode(source)).map { String(decoding: $0, as: UTF8.self) } ?? "{}"
return """
(() => {
const native = (action, payload) => window.webkit.messageHandlers.legado.postMessage(Object.assign({action}, payload || {}));
const get = key => window.prompt('__legado_get__', String(key)) || '';
const put = (key, value) => { value = String(value ?? ''); native('put', {key:String(key), value}); return value; };
const ruleCall = payload => {
const result = window.prompt('__legado_rule__', JSON.stringify(payload));
if (result == null || result === '') return null;
try { return JSON.parse(result); } catch (_) { return result; }
};
const analyzer = {
setContent(content, baseUrl) { if (content == null) throw new Error('内容不可空(Content cannot be null)'); ruleCall({op:'setContent', content, baseUrl:baseUrl == null ? null : String(baseUrl)}); return analyzer; },
setBaseUrl(baseUrl) { ruleCall({op:'setBaseUrl', baseUrl:baseUrl == null ? null : String(baseUrl)}); return analyzer; },
setRedirectUrl(url) { return ruleCall({op:'setRedirectUrl', url:String(url)}); },
getStringList(rule, content, isUrl) { return ruleCall({op:'getStringList', rule:rule == null ? null : String(rule), content:content === undefined ? null : content, hasContent:content !== undefined, isUrl:!!isUrl}); },
getString(rule, content, isUrl) { return ruleCall({op:'getString', rule:rule == null ? null : String(rule), content:content === undefined ? null : content, hasContent:content !== undefined && typeof content !== 'boolean', unescape:typeof content === 'boolean' ? content : false, isUrl:!!isUrl}); },
getElement(rule) { return ruleCall({op:'getElement', rule:String(rule)}); },
getElements(rule) { return ruleCall({op:'getElements', rule:String(rule)}); }
};
window.java = window.java || {};
java.get = get; java.put = put;
java.getSource = () => window.source; java.getTag = () => \(Self.jsString(source.sourceName));
java.showPhoto = src => native('showPhoto', {url:String(src)});
java.addBook = url => native('addBook', {url:String(url)});
java.searchBook = (key, scope) => native('searchBook', {key:String(key), scope:scope == null ? null : String(scope)});
java.open = (name, url, title, origin) => native('open', {name:String(name), url:url == null ? null : String(url), title:title == null ? null : String(title), origin:origin == null ? null : String(origin)});
java.setContent = analyzer.setContent; java.setBaseUrl = analyzer.setBaseUrl; java.setRedirectUrl = analyzer.setRedirectUrl;
java.getString = analyzer.getString; java.getStringList = analyzer.getStringList; java.getElement = analyzer.getElement; java.getElements = analyzer.getElements;
window.source = Object.assign(\(sourceJSON), {
getKey: () => \(Self.jsString(source.sourceUrl)),
getTag: () => \(Self.jsString(source.sourceName)),
get, put
});
document.addEventListener('contextmenu', event => {
const image = event.target && event.target.closest ? event.target.closest('img') : null;
if (!image || !image.src) return;
event.preventDefault();
native('showPhoto', {url:String(image.currentSrc || image.src)});
}, true);
})();
"""
}
func installRulesAndLoad(mode: RSSReadingMode, in webView: WKWebView) {
let policy = RSSResourcePolicy(blacklist: source.contentBlacklist, whitelist: source.contentWhitelist)
guard let json = policy.contentBlockerJSON() else {
rulesInstalled = true; loadIfChanged(mode: mode, in: webView); return
}
WKContentRuleListStore.default().compileContentRuleList(forIdentifier: "rss-\(source.sourceUrl.hashValue)", encodedContentRuleList: json) { [weak self, weak webView] list, _ in
DispatchQueue.main.async {
guard let self, let webView else { return }
if let list { webView.configuration.userContentController.add(list) }
self.rulesInstalled = true
self.loadIfChanged(mode: mode, in: webView)
}
}
}
func loadIfChanged(mode: RSSReadingMode, in webView: WKWebView) {
guard rulesInstalled else { return }
let newSignature = String(describing: mode)
guard signature != newSignature else { return }
signature = newSignature
switch mode {
case .html(let content, let baseURL):
webView.loadHTMLString(htmlProcessor.contentHTML(content, style: source.style), baseURL: baseURL.flatMap(URL.init(string:)))
case .startHTML(let content, let baseURL):
webView.loadHTMLString(htmlProcessor.startHTML(content, source: source), baseURL: baseURL.flatMap(URL.init(string:)))
case .web(let value): load(value, in: webView)
case .image(let value):
let escaped = Self.htmlEscape(value)
webView.loadHTMLString(htmlProcessor.contentHTML("<body><img src=\"\(escaped)\"></body>", style: source.style), baseURL: URL(string: value))
case .video(let value):
let escaped = Self.htmlEscape(value)
webView.loadHTMLString(htmlProcessor.contentHTML("<body><video src=\"\(escaped)\" controls autoplay playsinline></video></body>", style: source.style), baseURL: URL(string: value))
}
}
private func load(_ value: String, in webView: WKWebView) {
guard let url = URL(string: value) else { return }
var request = URLRequest(url: url, cachePolicy: source.cacheFirst ? .returnCacheDataElseLoad : .useProtocolCachePolicy, timeoutInterval: 180)
if let headers = try? BookSourceHeaderResolver().resolve(source.bookSourceAdapter) {
for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) }
webView.customUserAgent = headers.first { $0.key.caseInsensitiveCompare("User-Agent") == .orderedSame }?.value
}
if webView.customUserAgent == nil { webView.customUserAgent = ApplicationSettingsStore.shared.settings.effectiveUserAgent }
request = ApplicationSettingsStore.shared.settings.configure(request)
let cookie = LegadoCookieStore.shared.cookieHeader(for: value)
if !cookie.isEmpty { request.setValue(cookie, forHTTPHeaderField: "Cookie") }
webView.load(request)
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
guard let body = message.body as? [String: Any], let action = body["action"] as? String else { return }
switch action {
case "put":
if let key = body["key"] as? String { BookSourceRuntimeStore.shared.put(sourceID: source.sourceUrl, key: key, value: String(describing: body["value"] ?? "")) }
case "showPhoto":
if let raw = body["url"] as? String { DispatchQueue.main.async { self.onAction(.showPhoto(raw)) } }
case "searchBook":
if let key = body["key"] as? String { DispatchQueue.main.async { self.onAction(.searchBook(key: key, scope: body["scope"] as? String)) } }
case "addBook":
if let raw = body["url"] as? String { DispatchQueue.main.async { self.onAction(.addBook(raw)) } }
case "open":
let name = body["name"] as? String ?? ""
DispatchQueue.main.async { self.onAction(.open(name: name, url: body["url"] as? String, title: body["title"] as? String, origin: body["origin"] as? String)) }
default: break
}
}
func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping @MainActor @Sendable (String?) -> Void) {
if prompt == "__legado_get__" {
completionHandler(BookSourceRuntimeStore.shared.get(sourceID: source.sourceUrl, key: defaultText ?? ""))
} else if prompt == "__legado_rule__" {
handleRulePrompt(defaultText, completionHandler: completionHandler)
} else { completionHandler(defaultText) }
}
private func handleRulePrompt(_ payload: String?, completionHandler: @escaping @MainActor @Sendable (String?) -> Void) {
guard let payload, let data = payload.data(using: .utf8), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let operation = object["op"] as? String else { completionHandler("null"); return }
switch operation {
case "setContent":
analyzeContent = JSONValue.bridge(object["content"])
if let base = object["baseUrl"] as? String, !base.isEmpty { analyzeBaseURL = base }
completionHandler("true")
case "setBaseUrl":
if let base = object["baseUrl"] as? String, !base.isEmpty { analyzeBaseURL = base }
completionHandler("true")
case "setRedirectUrl":
let value = object["url"] as? String ?? ""
if !value.lowercased().hasPrefix("data:"), URL(string: value) != nil { analyzeRedirectURL = value }
completionHandler(Self.jsonString(analyzeRedirectURL))
default:
let rule = object["rule"] as? String ?? ""
guard !rule.isEmpty else { completionHandler(operation == "getStringList" || operation == "getElements" ? "null" : "\"\""); return }
let input = (object["hasContent"] as? Bool) == true ? JSONValue.bridge(object["content"]) : analyzeContent
let baseURL = analyzeRedirectURL ?? analyzeBaseURL
let context = RuleExecutionContext(variables: analyzeVariables, baseURL: baseURL, source: source.bookSourceAdapter)
Task {
let evaluation: RuleEvaluation
switch input {
case .string(let text):
evaluation = await RuleEngine().evaluate(rule, inText: text, baseURL: baseURL, listMode: operation == "getStringList" || operation == "getElements", context: context)
default:
evaluation = await RuleEngine().evaluate(rule, in: input, context: context)
}
self.analyzeVariables = evaluation.variables
let isURL = object["isUrl"] as? Bool == true
let values = evaluation.values.map { value -> JSONValue in
guard isURL else { return value }
let raw = value.stringValue
return .string(URL(string: raw, relativeTo: URL(string: baseURL))?.absoluteURL.absoluteString ?? raw)
}
let response: Any
switch operation {
case "getStringList": response = values.map(\.stringValue)
case "getElement": response = values.first?.foundationObject ?? NSNull()
case "getElements": response = values.map(\.foundationObject)
default:
var value = values.map(\.stringValue).joined(separator: "\n")
if object["unescape"] as? Bool == true { value = Self.unescapeHTML(value) }
response = value
}
completionHandler(Self.json(response))
}
}
}
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) {
guard let url = navigationAction.request.url else { decisionHandler(.cancel); return }
let scheme = url.scheme?.lowercased() ?? ""
if !["http", "https", "file", "data", "about"].contains(scheme) {
UIApplication.shared.open(url); decisionHandler(.cancel); return
}
if navigationAction.navigationType == .linkActivated, shouldOverride(url.absoluteString) {
decisionHandler(.cancel); return
}
decisionHandler(.allow)
}
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping @MainActor @Sendable (WKNavigationResponsePolicy) -> Void) {
guard let response = navigationResponse.response as? HTTPURLResponse, let url = response.url else { decisionHandler(.allow); return }
let disposition = response.value(forHTTPHeaderField: "Content-Disposition")?.lowercased() ?? ""
if disposition.contains("attachment") || !navigationResponse.canShowMIMEType {
let name = response.suggestedFilename ?? url.lastPathComponent.nilIfBlank ?? "download"
DispatchQueue.main.async { self.onAction(.download(url: url.absoluteString, fileName: name)) }
decisionHandler(.cancel)
} else { decisionHandler(.allow) }
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
webView.evaluateJavaScript("document.body ? document.body.innerText : ''") { [weak self] value, _ in
guard let text = value as? String else { return }
DispatchQueue.main.async { self?.onTextChange(text) }
}
}
private func shouldOverride(_ url: String) -> Bool {
guard let script = source.shouldOverrideUrlLoading?.trimmedNonEmpty else { return false }
let quoted = Self.jsString(url)
let expression = "(function(url){return (\(script));})(\(quoted))"
let body = "(function(url){\(script)})(\(quoted))"
let evaluator = JavaScriptRuleEvaluator()
let context = RuleExecutionContext(variables: ["url": url], baseURL: url, source: source.bookSourceAdapter)
let result = (try? evaluator.evaluate(script: expression, result: .string(url), executionContext: context))
?? (try? evaluator.evaluate(script: body, result: .string(url), executionContext: context))
let value = result?.value.stringValue.lowercased() ?? ""
return ["true", "1", "yes"].contains(value)
}
private static func jsString(_ value: String) -> String {
let data = try? JSONEncoder().encode(value)
return data.map { String(decoding: $0, as: UTF8.self) } ?? "\"\""
}
private static func htmlEscape(_ value: String) -> String {
value.replacingOccurrences(of: "&", with: "&amp;").replacingOccurrences(of: "\"", with: "&quot;").replacingOccurrences(of: "<", with: "&lt;")
}
private static func json(_ value: Any) -> String {
guard JSONSerialization.isValidJSONObject([value]), let data = try? JSONSerialization.data(withJSONObject: value, options: .fragmentsAllowed) else { return "null" }
return String(decoding: data, as: UTF8.self)
}
private static func jsonString(_ value: String?) -> String { value.map { json($0) } ?? "null" }
private static func unescapeHTML(_ value: String) -> String {
guard let data = "<span>\(value)</span>".data(using: .utf8), let attributed = try? NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue], documentAttributes: nil) else { return value }
return attributed.string
}
}
}
private extension String {
var trimmedNonEmpty: String? {
let value = trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
var nilIfBlank: String? { trimmedNonEmpty }
}