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
@@ -0,0 +1,67 @@
import SwiftUI
@MainActor
private final class CacheManagementModel: ObservableObject {
@Published private(set) var entries = 0
@Published private(set) var bytes = 0
@Published private(set) var isWorking = false
@Published var errorMessage: String?
private let cache: ChapterContentCache
init(cache: ChapterContentCache = .live()) { self.cache = cache }
func load() async {
do {
let statistics = try await cache.statistics()
entries = statistics.entries
bytes = statistics.bytes
} catch {
errorMessage = error.localizedDescription
}
}
func clear() async {
isWorking = true
defer { isWorking = false }
do {
try await cache.removeAll()
entries = 0
bytes = 0
} catch {
errorMessage = error.localizedDescription
}
}
}
struct CacheManagementView: View {
@StateObject private var model = CacheManagementModel()
var body: some View {
List {
Section("章节正文") {
ReadingLabeledContent("已缓存章节", value: "\(model.entries)")
ReadingLabeledContent("占用空间", value: ByteCountFormatter.string(fromByteCount: Int64(model.bytes), countStyle: .file))
Text("阅读网络章节后自动落盘,预加载数量可配置。缓存上限可在“其他设置 → 网络与缓存设置”调整,超限时优先清理最久未读取的章节。")
.font(.footnote).foregroundStyle(.secondary)
}
Section {
Button("清空章节缓存", systemImage: "trash", role: .destructive) {
Task { await model.clear() }
}
.disabled(model.entries == 0 || model.isWorking)
}
}
.navigationTitle("缓存管理")
.task { await model.load() }
.overlay { if model.isWorking { ProgressView() } }
.alert("缓存操作失败", isPresented: Binding(
get: { model.errorMessage != nil },
set: { if !$0 { model.errorMessage = nil } }
)) {
Button("好") { model.errorMessage = nil }
} message: {
Text(model.errorMessage ?? "未知错误")
}
}
}
@@ -0,0 +1,342 @@
import SwiftUI
import UniformTypeIdentifiers
import UIKit
@MainActor
private final class FileManagementModel: ObservableObject {
@Published var files: [LocalFileEntry] = []
@Published var isWorking = false
@Published var errorMessage: String?
@Published var readyExport: BookFileExport?
@Published var partialExport: BookFileExport?
private var activeExport: BookFileExport?
private let exporter = BookFileExporter()
func reload(_ library: BookLibraryModel) async {
do { files = try await library.localFiles.list() }
catch { errorMessage = error.localizedDescription }
}
func run(_ library: BookLibraryModel, operation: () async throws -> Void) async {
guard !isWorking else { return }
isWorking = true
defer { isWorking = false }
do { try await operation() }
catch { errorMessage = error.localizedDescription }
await reload(library)
}
func importFiles(_ urls: [URL], library: BookLibraryModel) async {
await run(library) {
await library.importLocalFiles(urls)
errorMessage = library.errorMessage
library.clearError()
}
}
func exportLocal(_ url: URL, filename: String, library: BookLibraryModel) async {
await run(library) {
let file = try await exporter.originalFile(url, filename: filename)
activeExport = file
readyExport = file
}
}
func exportCached(_ book: Book, library: BookLibraryModel) async {
await run(library) {
let chapters = try await AppBookChapterRepository.live().load(bookURL: book.bookUrl)
let file = try await exporter.cachedBook(book, chapters: chapters)
activeExport = file
if file.isPartial { partialExport = file }
else { readyExport = file }
}
}
func finishExport() {
guard let file = activeExport else { return }
activeExport = nil
readyExport = nil
partialExport = nil
Task { try? await exporter.discard(file) }
}
}
struct FileManagementView: View {
@EnvironmentObject private var library: BookLibraryModel
@StateObject private var model = FileManagementModel()
@State private var tab = 0
@State private var query = ""
@State private var sortBySize = false
@State private var importsFile = false
@State private var confirmsPartialExport = false
@State private var selecting = false
@State private var selection = Set<URL>()
@State private var renameURL: URL?
@State private var newName = ""
@State private var pendingDeletion: [URL] = []
private var localBooks: [Book] { library.books.filter { $0.origin == "local" } }
private var networkBooks: [Book] {
library.books.filter { $0.origin != "local" && (query.isEmpty || $0.name.localizedCaseInsensitiveContains(query)) }
}
private func book(for url: URL) -> Book? { localBooks.first { $0.bookUrl == url.absoluteString } }
private func filename(for url: URL) -> String {
guard let book = book(for: url), !book.originName.isEmpty else { return url.lastPathComponent }
return book.originName
}
private var displayedFiles: [LocalFileEntry] {
model.files.filter { query.isEmpty || filename(for: $0.url).localizedCaseInsensitiveContains(query) }
.sorted {
if sortBySize, $0.bytes != $1.bytes { return $0.bytes > $1.bytes }
return filename(for: $0.url).localizedStandardCompare(filename(for: $1.url)) == .orderedAscending
}
}
private var missingBooks: [Book] {
let available = Set(model.files.map(\.url.absoluteString))
return localBooks.filter { !available.contains($0.bookUrl) && (query.isEmpty || $0.name.localizedCaseInsensitiveContains(query)) }
}
private var fileList: some View {
List(selection: $selection) {
Section {
Picker("文件类型", selection: $tab) {
Text("本地文件").tag(0)
Text("缓存正文").tag(1)
}
.pickerStyle(.segmented)
.disabled(selecting)
}
if tab == 0 { localFilesSection }
else { cachedBooksSection }
if !selecting {
Section {
NavigationLink { CacheManagementView() } label: {
Label("章节缓存与空间清理", systemImage: "internaldrive")
}
}
}
}
.listStyle(.insetGrouped)
.environment(\.editMode, .constant(selecting ? .active : .inactive))
.navigationTitle("文件管理")
.searchable(text: $query, prompt: tab == 0 ? "搜索本地文件" : "搜索缓存书籍")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
if selecting {
Button("完成") { selecting = false; selection.removeAll() }
} else {
Menu {
Button("导入 TXT 文件", systemImage: "square.and.arrow.down") { importsFile = true }
if tab == 0 {
Button("选择文件", systemImage: "checkmark.circle") { selecting = true }
.disabled(displayedFiles.isEmpty)
Picker("排序", selection: $sortBySize) {
Text("按名称").tag(false)
Text("按大小").tag(true)
}
}
} label: { Image(systemName: "ellipsis.circle") }
}
}
}
.safeAreaInset(edge: .bottom) {
if selecting {
HStack {
Button(selection.count == displayedFiles.count ? "取消全选" : "全选") {
selection = selection.count == displayedFiles.count ? [] : Set(displayedFiles.map(\.url))
}
Spacer()
Button("删除 \(selection.count) 个文件", role: .destructive) { pendingDeletion = Array(selection) }
.disabled(selection.isEmpty)
}
.padding().background(.bar)
}
}
.disabled(model.isWorking)
.overlay { if model.isWorking { ProgressView("正在处理文件…").padding().background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) } }
.task { await model.reload(library) }
.onChange(of: library.books) { _ in Task { await model.reload(library) } }
.onChange(of: query) { _ in selection.removeAll() }
.refreshable { await model.reload(library) }
}
private var importAndRenameView: some View {
fileList
.fileImporter(isPresented: $importsFile, allowedContentTypes: [.text, .plainText, .data], allowsMultipleSelection: true) { result in
switch result {
case .success(let urls): Task { await model.importFiles(urls, library: library) }
case .failure(let error): model.errorMessage = error.localizedDescription
}
}
.alert("重命名文件", isPresented: Binding(get: { renameURL != nil }, set: { if !$0 { renameURL = nil } }), presenting: renameURL) { url in
TextField("文件名", text: $newName)
Button("取消", role: .cancel) { renameURL = nil }
Button("保存") {
let name = newName
renameURL = nil
Task { await model.run(library) { try await library.renameLocalFile(at: url, to: name) } }
}
} message: { _ in
Text("保留 TXT 格式,书架中的书名、书签和阅读进度不变。")
}
}
private var deletionView: some View {
importAndRenameView
.confirmationDialog("删除 \(pendingDeletion.count) 个文件?", isPresented: Binding(get: { !pendingDeletion.isEmpty }, set: { if !$0 { pendingDeletion = [] } }), titleVisibility: .visible, presenting: pendingDeletion) { urls in
Button("删除文件并移出书架", role: .destructive) {
pendingDeletion = []
Task {
await model.run(library) {
var failures: [String] = []
for url in urls {
do { try await library.deleteLocalFile(at: url) }
catch { failures.append("\(filename(for: url)):\(error.localizedDescription)") }
}
selection.removeAll()
if !failures.isEmpty { model.errorMessage = failures.joined(separator: "\n") }
}
}
}
Button("取消", role: .cancel) { pendingDeletion = [] }
} message: { _ in
Text("将删除应用内文件及对应书架条目,系统“文件”中用于导入的原文件保留。")
}
}
var body: some View {
deletionView
.onChange(of: model.partialExport?.id) { value in confirmsPartialExport = value != nil }
.onChange(of: confirmsPartialExport) { isPresented in
if !isPresented {
Task { @MainActor in
await Task.yield()
if !confirmsPartialExport, model.readyExport == nil { model.finishExport() }
}
}
}
.confirmationDialog("缓存正文不完整", isPresented: $confirmsPartialExport, titleVisibility: .visible, presenting: model.partialExport) { file in
Button("导出已缓存内容") {
model.readyExport = file
model.partialExport = nil
}
Button("取消", role: .cancel) { model.finishExport() }
} message: { file in
Text("已缓存 \(file.cachedChapters)/\(file.totalChapters) 章。导出文件会标记未缓存章节,可先返回详情缓存完整后再导出。")
}
.sheet(item: $model.readyExport, onDismiss: { model.finishExport() }) { file in
FileExportPicker(url: file.url) { model.readyExport = nil }
}
.alert("文件操作失败", isPresented: Binding(get: { model.errorMessage != nil }, set: { if !$0 { model.errorMessage = nil } })) {
Button("好") { model.errorMessage = nil }
} message: { Text(model.errorMessage ?? "未知错误") }
}
@ViewBuilder private var localFilesSection: some View {
Section {
ReadingLabeledContent("本地文件", value: "\(model.files.count) 个")
ReadingLabeledContent("占用空间", value: ByteCountFormatter.string(fromByteCount: model.files.reduce(0) { $0 + $1.bytes }, countStyle: .file))
}
Section("文件") {
if displayedFiles.isEmpty {
ReadingUnavailableView(query.isEmpty ? "还没有本地文件" : "没有匹配的文件", systemImage: "doc.text", description: Text("可从系统“文件”导入 TXT,文件会复制到应用中。"))
Button("导入 TXT 文件", systemImage: "plus") { importsFile = true }
}
ForEach(displayedFiles) { file in
Group {
if selecting { fileLabel(file) }
else if let book = book(for: file.url) {
NavigationLink { BookshelfReadView(book: book, source: nil) } label: { fileLabel(file) }
} else {
Button { Task { await model.run(library) { try await library.addLocalFile(at: file.url) } } } label: { fileLabel(file) }
}
}
.tag(file.url)
.contextMenu {
Button("导出文件", systemImage: "square.and.arrow.up") { export(file) }
Button("重命名", systemImage: "pencil") { renameURL = file.url; newName = filename(for: file.url) }
Button("删除文件", systemImage: "trash", role: .destructive) { pendingDeletion = [file.url] }
}
.swipeActions {
Button("删除", role: .destructive) { pendingDeletion = [file.url] }
Button("导出", systemImage: "square.and.arrow.up") { export(file) }.tint(.blue)
}
}
}
if !missingBooks.isEmpty {
Section("文件已丢失") {
ForEach(missingBooks) { book in
HStack {
Label(book.name, systemImage: "doc.badge.ellipsis")
Spacer()
Button("移出书架", role: .destructive) {
if let url = URL(string: book.bookUrl) { pendingDeletion = [url] }
}.buttonStyle(.borderless)
}
}
}
}
}
private var cachedBooksSection: some View {
Section {
Text("将书架中已缓存的网络正文导出为 TXT,按目录顺序排列。导出过程不下载新章节。")
.font(.footnote).foregroundStyle(.secondary)
if networkBooks.isEmpty {
ReadingUnavailableView("没有可导出的书籍", systemImage: "books.vertical", description: Text("将网络书加入书架并缓存章节后,可在这里导出。"))
}
ForEach(networkBooks) { book in
Button {
Task { await model.exportCached(book, library: library) }
} label: {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(book.name).foregroundStyle(.primary)
Text(book.author.isEmpty ? "导出缓存正文" : book.author).font(.caption).foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "square.and.arrow.up")
}
}
}
} header: { Text("缓存正文") }
}
private func fileLabel(_ file: LocalFileEntry) -> some View {
HStack(spacing: 12) {
Image(systemName: "doc.text").foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 4) {
Text(filename(for: file.url)).foregroundStyle(.primary).lineLimit(2)
HStack(spacing: 8) {
Text(ByteCountFormatter.string(fromByteCount: file.bytes, countStyle: .file))
if let modified = file.modified { Text(modified, style: .date) }
}.font(.caption).foregroundStyle(.secondary)
if let book = book(for: file.url) {
Text("书架:\(book.name)").font(.caption).foregroundStyle(.secondary).lineLimit(1)
} else { Text("点按加入书架").font(.caption).foregroundStyle(.secondary) }
}
}
}
private func export(_ file: LocalFileEntry) {
Task { await model.exportLocal(file.url, filename: filename(for: file.url), library: library) }
}
}
private struct FileExportPicker: UIViewControllerRepresentable {
let url: URL
let onCompletion: () -> Void
func makeCoordinator() -> Coordinator { Coordinator(onCompletion) }
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
let picker = UIDocumentPickerViewController(forExporting: [url], asCopy: true)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ controller: UIDocumentPickerViewController, context: Context) {}
final class Coordinator: NSObject, UIDocumentPickerDelegate {
let completion: () -> Void
init(_ completion: @escaping () -> Void) { self.completion = completion }
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { completion() }
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { completion() }
}
}
@@ -0,0 +1,187 @@
import Foundation
@MainActor
final class ReadingActivityModel: ObservableObject {
@Published private(set) var bookmarks: [Bookmark] = []
@Published private(set) var readRecords: [ReadRecord] = []
@Published private(set) var errorMessage: String?
private let repository: any ReadingActivityRepository
private let service = ReadingActivityService()
private let deviceID: String
private var sessions: [String: Date] = [:]
init(repository: any ReadingActivityRepository, deviceID: String) {
self.repository = repository
self.deviceID = deviceID
}
static func live() -> ReadingActivityModel {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Legado", isDirectory: true)
let key = "legado.reading.device-id"
let defaults = UserDefaults.standard
let identifier = defaults.string(forKey: key) ?? UUID().uuidString
defaults.set(identifier, forKey: key)
let bookmarksURL = base.appendingPathComponent("bookmarks.json")
let recordsURL = base.appendingPathComponent("read-records.json")
let repository: any ReadingActivityRepository
if let database = try? LegadoDatabase(url: base.appendingPathComponent("legado.sqlite3")) {
repository = SQLiteReadingActivityRepository(
database: database,
legacyBookmarksURL: bookmarksURL,
legacyRecordsURL: recordsURL
)
} else {
repository = FileReadingActivityRepository(bookmarksURL: bookmarksURL, recordsURL: recordsURL)
}
return ReadingActivityModel(repository: repository, deviceID: identifier)
}
static func demo(bookmarks: [Bookmark] = [], records: [ReadRecord] = []) -> ReadingActivityModel {
ReadingActivityModel(
repository: InMemoryReadingActivityRepository(bookmarks: bookmarks, records: records),
deviceID: "ios-simulator"
)
}
var summaries: [ReadRecordSummary] { service.summaries(readRecords) }
var statistics: ReadingStatistics { service.statistics(readRecords) }
func summaries(searchKey: String, sortMode: Int) -> [ReadRecordSummary] {
service.displayedSummaries(readRecords, searchKey: searchKey, sortMode: sortMode)
}
func load() async {
do {
async let storedBookmarks = repository.loadBookmarks()
async let storedRecords = repository.loadReadRecords()
bookmarks = service.sortedBookmarks(try await storedBookmarks)
readRecords = try await storedRecords
} catch {
errorMessage = error.localizedDescription
}
}
func bookmarks(for book: Book) -> [Bookmark] {
bookmarks.filter { $0.bookName == book.name && $0.bookAuthor == book.author }
.sorted {
if $0.chapterIndex == $1.chapterIndex { return $0.chapterPosition < $1.chapterPosition }
return $0.chapterIndex < $1.chapterIndex
}
}
func addBookmark(book: Book, chapter: BookChapter, position: Int, content: String) async {
var timestamp = Int64(Date().timeIntervalSince1970 * 1_000)
while bookmarks.contains(where: { $0.time == timestamp }) { timestamp += 1 }
var bookmark = Bookmark(time: timestamp, bookName: book.name, bookAuthor: book.author)
bookmark.chapterIndex = chapter.index
bookmark.chapterPosition = max(position, 0)
bookmark.chapterName = chapter.title
bookmark.bookText = excerpt(content, around: position)
var updated = bookmarks
updated.append(bookmark)
do {
try await repository.saveBookmarks(updated)
bookmarks = service.sortedBookmarks(updated)
} catch {
errorMessage = error.localizedDescription
}
}
func removeBookmark(_ bookmark: Bookmark) async {
let updated = bookmarks.filter { $0.id != bookmark.id }
do {
try await repository.saveBookmarks(updated)
bookmarks = updated
} catch {
errorMessage = error.localizedDescription
}
}
func updateBookmark(_ bookmark: Bookmark) async {
var updated = bookmarks
if let index = updated.firstIndex(where: { $0.id == bookmark.id }) {
updated[index] = bookmark
} else {
updated.append(bookmark)
}
do {
try await repository.saveBookmarks(updated)
bookmarks = service.sortedBookmarks(updated)
} catch {
errorMessage = error.localizedDescription
}
}
func beginReading(_ book: Book, now: Date = Date()) {
sessions[book.bookUrl] = sessions[book.bookUrl] ?? now
}
func checkpoint(_ book: Book, now: Date = Date()) async {
guard let start = sessions[book.bookUrl] else {
sessions[book.bookUrl] = now
return
}
sessions[book.bookUrl] = now
await record(book: book, elapsed: now.timeIntervalSince(start), now: now)
}
func endReading(_ book: Book, now: Date = Date()) async {
guard let start = sessions.removeValue(forKey: book.bookUrl) else { return }
await record(book: book, elapsed: now.timeIntervalSince(start), now: now)
}
func cancelReading(_ book: Book) {
sessions.removeValue(forKey: book.bookUrl)
}
func removeHistory(bookName: String) async {
let updated = readRecords.filter { $0.bookName != bookName }
do {
try await repository.saveReadRecords(updated)
readRecords = updated
} catch {
errorMessage = error.localizedDescription
}
}
func clearHistory() async {
do {
try await repository.saveReadRecords([])
readRecords = []
} catch {
errorMessage = error.localizedDescription
}
}
func clearError() { errorMessage = nil }
private func record(book: Book, elapsed: TimeInterval, now: Date) async {
let milliseconds = Int64(max(elapsed, 0) * 1_000)
guard milliseconds > 0 else { return }
let updated = service.addingReadTime(
milliseconds,
deviceID: deviceID,
bookName: book.name,
now: Int64(now.timeIntervalSince1970 * 1_000),
to: readRecords
)
readRecords = updated
do {
try await repository.saveReadRecords(updated)
} catch {
errorMessage = error.localizedDescription
}
}
private func excerpt(_ content: String, around position: Int) -> String {
guard !content.isEmpty else { return "" }
let center = min(max(position, 0), content.count)
let start = max(center - 24, 0)
let end = min(center + 96, content.count)
let lower = content.index(content.startIndex, offsetBy: start)
let upper = content.index(content.startIndex, offsetBy: end)
return String(content[lower..<upper]).trimmingCharacters(in: .whitespacesAndNewlines)
}
}
@@ -0,0 +1,140 @@
import SwiftUI
struct ReadingHistoryView: View {
@EnvironmentObject private var activity: ReadingActivityModel
@EnvironmentObject private var books: BookLibraryModel
@EnvironmentObject private var sources: SourceLibraryModel
@AppStorage("enableReadRecord") private var enableReadRecord = true
@AppStorage("readRecordSort") private var sortMode = 0
@State private var searchText = ""
@State private var confirmsClear = false
@State private var pendingDelete: ReadRecordSummary?
private var displayedRecords: [ReadRecordSummary] {
activity.summaries(searchKey: searchText, sortMode: sortMode)
}
var body: some View {
Group {
if activity.summaries.isEmpty {
ReadingUnavailableView(
"还没有阅读记录",
systemImage: "clock.arrow.circlepath",
description: Text(enableReadRecord ? "翻页后会按设备累计阅读时长。" : "阅读记录当前已关闭。")
)
} else {
List {
Section {
ReadingLabeledContent("累计阅读", value: duration(activity.summaries.reduce(0) { $0 + $1.readTime }))
ReadingLabeledContent("读过", value: "\(activity.summaries.count) 本")
}
if displayedRecords.isEmpty {
ReadingUnavailableView.search(text: searchText)
} else {
ForEach(displayedRecords) { record in
NavigationLink {
destination(for: record)
} label: {
HStack(spacing: 13) {
Image(systemName: "book.pages.fill")
.foregroundStyle(ReadingTheme.accent)
.frame(width: 38, height: 38)
.background(ReadingTheme.accentSoft.opacity(0.55), in: RoundedRectangle(cornerRadius: 11))
VStack(alignment: .leading, spacing: 4) {
Text(record.bookName).font(.headline)
Text(duration(record.readTime))
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Text(date(record.lastRead)).font(.caption).foregroundStyle(.tertiary)
}
}
.swipeActions {
Button("删除", role: .destructive) { pendingDelete = record }
}
}
}
}
.listStyle(.insetGrouped)
}
}
.navigationTitle("阅读记录")
.searchable(text: $searchText, prompt: "搜索")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu("阅读记录选项", systemImage: "ellipsis.circle") {
Toggle("记录阅读时长", isOn: $enableReadRecord)
Section("排序") {
sortButton("书名", value: 0)
sortButton("阅读时长", value: 1)
sortButton("最后阅读", value: 2)
}
if !activity.summaries.isEmpty {
Button("清空", systemImage: "trash", role: .destructive) { confirmsClear = true }
}
}
}
}
.alert("清空阅读记录", isPresented: $confirmsClear) {
Button("取消", role: .cancel) {}
Button("删除", role: .destructive) { Task { await activity.clearHistory() } }
} message: {
Text("确定删除全部阅读记录吗?")
}
.alert("删除阅读记录", isPresented: Binding(
get: { pendingDelete != nil },
set: { if !$0 { pendingDelete = nil } }
)) {
Button("取消", role: .cancel) { pendingDelete = nil }
Button("删除", role: .destructive) {
guard let record = pendingDelete else { return }
pendingDelete = nil
Task { await activity.removeHistory(bookName: record.bookName) }
}
} message: {
Text("确定删除“\(pendingDelete?.bookName ?? "")”的阅读记录吗?")
}
}
@ViewBuilder private func destination(for record: ReadRecordSummary) -> some View {
if let book = books.books.first(where: { $0.name == record.bookName }) {
BookshelfReadView(
book: book,
source: sources.sources.first(where: { $0.bookSourceUrl == book.origin })
)
} else {
SearchView(initialKeyword: record.bookName)
}
}
@ViewBuilder private func sortButton(_ title: String, value: Int) -> some View {
Button {
sortMode = value
} label: {
if sortMode == value { Label(title, systemImage: "checkmark") }
else { Text(title) }
}
}
private func duration(_ milliseconds: Int64) -> String {
let value = max(milliseconds, 0)
let days = value / 86_400_000
let hours = value % 86_400_000 / 3_600_000
let minutes = value % 3_600_000 / 60_000
let seconds = value % 60_000 / 1_000
var result = ""
if days > 0 { result += "\(days)天" }
if hours > 0 { result += "\(hours)小时" }
if minutes > 0 { result += "\(minutes)分钟" }
if seconds > 0 { result += "\(seconds)秒" }
return result.isEmpty ? "0秒" : result
}
private func date(_ milliseconds: Int64) -> String {
let formatter = DateFormatter()
guard milliseconds > 0 else { return "" }
formatter.locale = .current
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: Date(timeIntervalSince1970: Double(milliseconds) / 1_000))
}
}
@@ -0,0 +1,175 @@
import SwiftUI
struct ReadingStatisticsView: View {
@EnvironmentObject private var activity: ReadingActivityModel
@ScaledMetric(relativeTo: .largeTitle) private var durationFontSize = 42
@ScaledMetric(relativeTo: .caption2) private var chartHeight = 138
private var statistics: ReadingStatistics { activity.statistics }
var body: some View {
ScrollView {
VStack(spacing: 0) {
ReadingPageHeader(title: "阅读统计", subtitle: "每一页,都算数") { EmptyView() }
VStack(spacing: 16) {
todayCard
overviewGrid
weeklyCard
historyLink
}
.padding(.horizontal, 24)
.padding(.bottom, 32)
}
}
.background(ReadingTheme.canvas)
.navigationBarHidden(true)
}
private var todayCard: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline) {
Text("今日阅读")
.font(.subheadline)
Spacer()
Text(Date.now.formatted(.dateTime.locale(Locale(identifier: "zh_CN")).month().day()))
.font(.caption)
}
.foregroundStyle(ReadingTheme.onAccent.opacity(0.7))
Text(duration(statistics.todayReadTime, compact: false))
.font(.system(size: durationFontSize, weight: .medium))
.foregroundStyle(ReadingTheme.onAccent)
.readingNumericTransition()
.minimumScaleFactor(0.7)
.lineLimit(1)
Text(statistics.todayReadTime == 0 ? "今天还没有阅读记录" : "已记录今天的阅读时长")
.font(.caption)
.foregroundStyle(ReadingTheme.onAccent.opacity(0.65))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(24)
.background(ReadingTheme.accent, in: RoundedRectangle(cornerRadius: 20))
}
private var overviewGrid: some View {
ReadingCard {
VStack(spacing: 18) {
HStack(alignment: .top, spacing: 20) {
statisticTile(title: "累计阅读", value: duration(statistics.totalReadTime, compact: true))
statisticTile(title: "阅读书籍", value: "\(statistics.bookCount) 本")
}
Rectangle().fill(ReadingTheme.separator).frame(height: 0.5)
HStack(alignment: .top, spacing: 20) {
statisticTile(
title: "近 7 天",
value: duration(statistics.recentDays.reduce(0) { $0 + $1.readTime }, compact: true)
)
statisticTile(title: "连续阅读", value: "\(statistics.currentStreak) 天")
}
}
}
}
private func statisticTile(title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.font(.title3.weight(.semibold))
.readingNumericTransition()
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(title),\(value)")
}
private var weeklyCard: some View {
ReadingCard {
VStack(alignment: .leading, spacing: 18) {
HStack {
Text("最近 7 天")
.font(.headline)
Spacer()
Text("每日阅读时长")
.font(.caption)
.foregroundStyle(.secondary)
}
HStack(alignment: .bottom, spacing: 10) {
ForEach(statistics.recentDays, id: \.date) { day in
VStack(spacing: 7) {
Text(barLabel(day.readTime))
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.7)
RoundedRectangle(cornerRadius: 3)
.fill(day.readTime > 0 ? ReadingTheme.accent.opacity(Calendar.current.isDateInToday(day.date) ? 1 : 0.22) : ReadingTheme.separator)
.frame(width: 22, height: barHeight(day.readTime))
Text(weekday(day.date))
.font(.caption2)
.foregroundStyle(Calendar.current.isDateInToday(day.date) ? ReadingTheme.accent : .secondary)
}
.frame(maxWidth: .infinity)
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(weekday(day.date)),阅读 \(duration(day.readTime, compact: false))")
}
}
.frame(height: chartHeight, alignment: .bottom)
}
}
}
private var historyLink: some View {
NavigationLink {
ReadingHistoryView()
} label: {
ReadingCard {
HStack(spacing: 12) {
Image(systemName: "clock.arrow.circlepath").font(.title3)
Text("阅读记录").font(.subheadline.weight(.medium))
Spacer()
Image(systemName: "arrow.up.right").font(.subheadline)
}
.foregroundStyle(ReadingTheme.accent)
.frame(minHeight: 24)
.contentShape(Rectangle())
}
}
.buttonStyle(.plain)
}
private func barHeight(_ milliseconds: Int64) -> CGFloat {
let maximum = max(statistics.recentDays.map(\.readTime).max() ?? 0, 1)
guard milliseconds > 0 else { return 2 }
return 12 + 70 * CGFloat(milliseconds) / CGFloat(maximum)
}
private func barLabel(_ milliseconds: Int64) -> String {
guard milliseconds >= 60_000 else { return milliseconds > 0 ? "<1分" : "0" }
if milliseconds < 3_600_000 { return "\(milliseconds / 60_000)分" }
let hours = Double(milliseconds) / 3_600_000
return String(format: "%.1f时", hours)
}
private func weekday(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "EEE"
return formatter.string(from: date)
}
private func duration(_ milliseconds: Int64, compact: Bool) -> String {
let value = max(milliseconds, 0)
let hours = value / 3_600_000
let minutes = value % 3_600_000 / 60_000
if compact {
if hours > 0 { return minutes > 0 ? "\(hours)时\(minutes)分" : "\(hours)小时" }
return "\(minutes)分钟"
}
let seconds = value % 60_000 / 1_000
if hours > 0 { return minutes > 0 ? "\(hours)小时 \(minutes)分钟" : "\(hours)小时" }
if minutes > 0 { return "\(minutes)分钟" }
return "\(seconds)秒"
}
}
+357
View File
@@ -0,0 +1,357 @@
import SwiftUI
struct BookDetailView: View {
@EnvironmentObject private var library: BookLibraryModel
@EnvironmentObject private var sourceLibrary: SourceLibraryModel
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@StateObject private var model: BookDetailViewModel
@State private var showsSourceSwitch = false
@State private var showsRemoveConfirmation = false
@State private var showsTableOfContents = false
init(searchBook: SearchBook, source: BookSource) {
_model = StateObject(wrappedValue: BookDetailViewModel(searchBook: searchBook, source: source))
}
init(book: Book, source: BookSource) {
_model = StateObject(wrappedValue: BookDetailViewModel(book: book, source: source))
}
init(demoBook: Book, source: BookSource, chapters: [BookChapter]) {
_model = StateObject(wrappedValue: BookDetailViewModel(demoBook: demoBook, source: source, chapters: chapters))
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
hero
Divider()
if let intro = MetadataTextSanitizer().clean(model.book.intro), !intro.isEmpty {
VStack(alignment: .leading, spacing: 10) {
Text("简介").font(.headline)
Text(intro).font(.subheadline).foregroundStyle(.secondary).lineSpacing(5)
}
Divider()
}
chapterCard
if model.isCaching || model.cacheCompleted > 0 { cacheProgressCard }
}
.padding(.horizontal, 20)
.padding(.bottom, 30)
}
.background(ReadingTheme.canvas)
.navigationTitle("书籍详情")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button("换源", systemImage: "arrow.triangle.2.circlepath") { showsSourceSwitch = true }
.disabled(sourceLibrary.sources.filter(\.enabled).count < 2)
Button("允许更新", systemImage: model.book.canUpdate ? "checkmark" : "circle") {
model.toggleCanUpdate()
Task { await saveCurrentBook() }
}
Button("刷新", systemImage: "arrow.clockwise") {
Task {
let oldURL = model.book.bookUrl
let wasInLibrary = library.contains(model.book)
await model.refresh(sources: sourceLibrary.sources)
if wasInLibrary, oldURL != model.book.bookUrl {
await library.replace(oldBookURL: oldURL, with: model.book)
} else if wasInLibrary {
await saveCurrentBook()
}
}
}
if model.source.loginUrl?.isEmpty == false {
NavigationLink {
SourceLoginView(source: model.source)
} label: {
Label("登录", systemImage: "person.badge.key")
}
}
ReadingShareLink(item: model.book.bookUrl) {
Label("分享", systemImage: "square.and.arrow.up")
}
Divider()
Button("缓存接下来 10 章", systemImage: "arrow.down.circle") { model.cacheNextTen() }
.disabled(model.chapters.isEmpty || model.isCaching)
Button("缓存剩余章节", systemImage: "square.and.arrow.down.on.square") { model.cacheRemaining() }
.disabled(model.chapters.isEmpty || model.isCaching)
if model.isCaching {
Divider()
Button("停止缓存", systemImage: "stop.circle", role: .destructive) { model.cancelCaching() }
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.task {
model.restoreSourceAfterReading(from: library.books, sources: sourceLibrary.sources)
let oldURL = model.book.bookUrl
let shouldPersistUpdate = library.contains(model.book)
if let stored = library.books.first(where: { $0.bookUrl == oldURL }) {
model.restoreLibraryBook(from: stored, sources: sourceLibrary.sources)
}
await model.load(sources: sourceLibrary.sources)
if shouldPersistUpdate {
if let stored = library.books.first(where: { $0.bookUrl == model.book.bookUrl }) {
model.restoreUserSettings(from: stored)
model.restoreReadingProgress(from: stored)
}
if oldURL != model.book.bookUrl {
await library.replace(oldBookURL: oldURL, with: model.book)
} else {
await saveCurrentBook()
}
}
}
.sheet(isPresented: $showsSourceSwitch) {
ReadingNavigationStack {
SourceSwitchView(book: model.book, sources: sourceLibrary.sources) { report, source in
let oldURL = model.book.bookUrl
model.applySourceSwitch(report, source: source)
Task { await library.replace(oldBookURL: oldURL, with: report.book) }
}
}
}
.sheet(isPresented: $showsTableOfContents) {
BookTableOfContentsView(book: model.book, chapters: model.chapters, source: model.source)
}
.confirmationDialog("从书架移除《\(model.book.name)》?", isPresented: $showsRemoveConfirmation) {
Button("移出书架", role: .destructive) {
Task { await library.remove(bookURL: model.book.bookUrl) }
}
Button("取消", role: .cancel) { }
}
}
private var cacheProgressCard: some View {
ReadingCard {
VStack(alignment: .leading, spacing: 10) {
HStack {
Label(model.isCaching ? "正在缓存章节" : "章节缓存完成", systemImage: model.isCaching ? "arrow.down.circle.fill" : "checkmark.circle.fill")
.font(.headline)
Spacer()
Text("\(model.cacheCompleted)/\(model.cacheTotal)")
.font(.caption.monospacedDigit()).foregroundStyle(.secondary)
}
ProgressView(value: Double(model.cacheCompleted), total: Double(max(model.cacheTotal, 1)))
.tint(ReadingTheme.accent)
if model.isCaching {
Button("停止缓存", role: .destructive) { model.cancelCaching() }
.font(.subheadline)
}
}
}
}
private var hero: some View {
VStack(alignment: .leading, spacing: 20) {
HStack(alignment: .top, spacing: 18) {
BookCoverArtwork(
title: model.book.name,
author: model.book.author,
remoteURL: model.book.coverUrl,
width: 88,
height: 126
)
VStack(alignment: .leading, spacing: 8) {
Text(model.book.name).font(.title2.weight(.semibold)).lineLimit(3)
Text(model.book.author.isEmpty ? "未知作者" : model.book.author)
.font(.subheadline).foregroundStyle(.secondary)
if let kind = model.book.kind, !kind.isEmpty {
Text(kind).font(.caption).foregroundStyle(.secondary)
}
Text(model.source.bookSourceName)
.font(.caption).foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
if let latest = model.book.latestChapterTitle, !latest.isEmpty {
Text("更新至 \(latest)")
.font(.caption).foregroundStyle(.secondary).lineLimit(2)
}
Group {
if dynamicTypeSize.isAccessibilitySize {
VStack(spacing: 12) { readingActions }
} else {
HStack(spacing: 12) { readingActions }
}
}
.controlSize(.large)
}
.padding(.top, 16)
}
@ViewBuilder
private var readingActions: some View {
NavigationLink {
readingDestination
} label: {
Text(model.book.durChapterIndex > 0 || model.book.durChapterPos > 0 ? "继续阅读" : "开始阅读")
.font(.body.weight(.medium))
.frame(maxWidth: .infinity, minHeight: 28)
}
.buttonStyle(.borderedProminent)
.disabled(model.chapters.isEmpty)
Button {
if library.contains(model.book) {
showsRemoveConfirmation = true
} else {
Task { await saveCurrentBook() }
}
} label: {
Label(
library.contains(model.book) ? "已在书架" : "加入书架",
systemImage: library.contains(model.book) ? "checkmark" : "plus"
)
.font(.subheadline)
.frame(maxWidth: dynamicTypeSize.isAccessibilitySize ? .infinity : nil, minHeight: 28)
}
.buttonStyle(.bordered)
}
@ViewBuilder
private var readingDestination: some View {
let index = min(max(model.book.durChapterIndex, 0), max(model.chapters.count - 1, 0))
if ProcessInfo.processInfo.arguments.contains("-uiDemo") {
ReaderView(demoBook: model.book, chapters: model.chapters, initialIndex: index, content: DemoContent.readerText)
} else {
ReaderView(book: model.book, chapters: model.chapters, initialIndex: index, source: model.source)
}
}
private func saveCurrentBook() async {
await library.addOrUpdate(model.book)
if let stored = library.books.first(where: { $0.bookUrl == model.book.bookUrl }) {
model.restoreReadingProgress(from: stored)
}
}
private var chapterCard: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("目录").font(.headline)
Spacer()
Text("\(model.chapters.count) 章").font(.caption).foregroundStyle(.secondary)
}
.padding(.bottom, 12)
if model.state == .loading || model.state == .idle {
HStack { Spacer(); ProgressView("正在解析目录…"); Spacer() }.padding(.vertical, 28)
} else if model.state == .findingSource {
HStack { Spacer(); ProgressView("当前源不可用,正在自动寻找可用源…"); Spacer() }
.padding(.vertical, 28)
} else if model.state == .failed {
ReadingUnavailableView(
"没有找到可用目录",
systemImage: "exclamationmark.arrow.triangle.2.circlepath",
description: Text("已自动尝试其他可搜索书源,可稍后刷新重试或手动换源。")
)
.padding(.vertical, 12)
} else {
ForEach(Array(model.chapters.filter { !$0.isVolume }.prefix(8).enumerated()), id: \.element.id) { offset, chapter in
if offset > 0 { Divider() }
if chapter.isVolume {
Text(chapter.title).font(.subheadline.bold()).foregroundStyle(ReadingTheme.accent)
.padding(.vertical, 12)
} else {
NavigationLink {
ReaderView(
book: model.book,
chapters: model.chapters,
initialIndex: chapter.index,
source: model.source
)
} label: {
HStack(spacing: 12) {
Text(String(format: "%02d", chapter.index + 1))
.font(.caption.monospacedDigit()).foregroundStyle(.tertiary).frame(width: 28)
Text(chapter.title).font(.subheadline).foregroundStyle(.primary).lineLimit(2)
Spacer()
if chapter.isVip { Image(systemName: "lock.fill").font(.caption).foregroundStyle(.orange) }
Image(systemName: "chevron.right").font(.caption.bold()).foregroundStyle(.tertiary)
}
.padding(.vertical, 12)
}
.buttonStyle(.plain)
}
}
if model.chapters.count > 8 {
Divider()
Button {
showsTableOfContents = true
} label: {
Label("查看全部目录", systemImage: "list.bullet")
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
}
}
}
}
}
}
private struct BookTableOfContentsView: View {
@Environment(\.dismiss) private var dismiss
let book: Book
let chapters: [BookChapter]
let source: BookSource
@State private var searchText = ""
@State private var descending = false
private var displayedChapters: [BookChapter] {
let filtered = searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? chapters
: chapters.filter { $0.title.localizedCaseInsensitiveContains(searchText) }
return descending ? Array(filtered.reversed()) : filtered
}
var body: some View {
ReadingNavigationStack {
List(displayedChapters) { chapter in
if chapter.isVolume {
Text(chapter.title).font(.headline).foregroundStyle(ReadingTheme.accent)
} else {
NavigationLink {
ReaderView(book: book, chapters: chapters, initialIndex: chapter.index, source: source)
} label: {
HStack(spacing: 12) {
Text("\(chapter.index + 1)")
.font(.caption.monospacedDigit()).foregroundStyle(.secondary).frame(width: 42, alignment: .trailing)
Text(chapter.title).lineLimit(2)
Spacer()
if chapter.index == book.durChapterIndex {
Image(systemName: "location.fill").foregroundStyle(ReadingTheme.accent)
}
if chapter.isVip { Image(systemName: "lock.fill").foregroundStyle(.orange) }
}
}
}
}
.searchable(text: $searchText, prompt: "搜索目录")
.navigationTitle("目录")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("关闭") { dismiss() } }
ToolbarItem(placement: .topBarTrailing) {
Button(descending ? "正序" : "倒序", systemImage: "arrow.up.arrow.down") {
descending.toggle()
}
}
}
.safeAreaInset(edge: .bottom) {
HStack {
Text(book.durChapterTitle ?? "尚未阅读").lineLimit(1)
Spacer()
Text("\(min(book.durChapterIndex + 1, chapters.count))/\(chapters.count)")
.monospacedDigit().foregroundStyle(.secondary)
}
.font(.caption)
.padding(.horizontal, 16).padding(.vertical, 10)
.background(.bar)
}
}
}
}
@@ -0,0 +1,257 @@
import SwiftUI
#if SWIFT_PACKAGE
import LegadoCore
#endif
@MainActor
final class BookDetailViewModel: ObservableObject {
enum State: Equatable { case idle, loading, findingSource, ready, failed }
@Published private(set) var state: State = .idle
@Published private(set) var book: Book
@Published private(set) var chapters: [BookChapter] = []
@Published private(set) var diagnostics: [RuleDiagnostic] = []
@Published private(set) var isCaching = false
@Published private(set) var cacheCompleted = 0
@Published private(set) var cacheTotal = 0
@Published private(set) var source: BookSource
private var searchBook: SearchBook?
private let service: NetworkBookService
private let updateCoordinator: BookUpdateCoordinator
private let contentCoordinator: ChapterContentCoordinator
private let sourceSwitchService: BookSourceSwitchService
private var cacheTask: Task<Void, Never>?
init(
searchBook: SearchBook,
source: BookSource,
service: NetworkBookService = .init(),
chapterRepository: any BookChapterRepository = AppBookChapterRepository.live()
) {
self.searchBook = searchBook
self.source = source
self.service = service
let chapters = chapterRepository
updateCoordinator = BookUpdateCoordinator(repository: chapters, loader: service)
contentCoordinator = ChapterContentCoordinator(loader: service)
sourceSwitchService = BookSourceSwitchService(
detailLoader: service,
chapterLoader: service,
contentLoader: service,
chapterRepository: chapters
)
var book = Book(
bookUrl: searchBook.bookURL,
name: searchBook.name,
author: searchBook.author,
origin: source.bookSourceUrl
)
book.originName = source.bookSourceName
book.coverUrl = searchBook.coverURL
book.intro = searchBook.intro
book.kind = searchBook.kind
self.book = book
}
init(book: Book, source: BookSource, service: NetworkBookService = .init()) {
searchBook = nil
self.book = book
self.source = source
self.service = service
let chapters = AppBookChapterRepository.live()
updateCoordinator = BookUpdateCoordinator(repository: chapters, loader: service)
contentCoordinator = ChapterContentCoordinator(loader: service)
sourceSwitchService = BookSourceSwitchService(
detailLoader: service,
chapterLoader: service,
contentLoader: service,
chapterRepository: chapters
)
}
init(demoBook: Book, source: BookSource, chapters: [BookChapter]) {
searchBook = nil
book = demoBook
self.source = source
service = .init()
updateCoordinator = .init()
contentCoordinator = .init()
sourceSwitchService = .init()
self.chapters = chapters
state = .ready
isCaching = true
cacheCompleted = 2
cacheTotal = chapters.filter { !$0.isVolume }.count
}
func load(sources: [BookSource] = []) async {
guard state == .idle else { return }
state = .loading
if searchBook == nil {
let stored = await updateCoordinator.storedChapters(bookURL: book.bookUrl)
if !stored.isEmpty {
chapters = stored
state = .ready
}
}
if let searchBook {
let detail = await service.loadDetail(searchBook: searchBook, source: source)
let details = detail.book
book.name = details.name
book.author = details.author
book.originName = details.originName
if !details.tocUrl.isEmpty { book.tocUrl = details.tocUrl }
book.type = details.type
book.kind = details.kind ?? book.kind
book.coverUrl = details.coverUrl ?? book.coverUrl
book.intro = details.intro ?? book.intro
book.charset = details.charset ?? book.charset
book.wordCount = details.wordCount ?? book.wordCount
book.latestChapterTitle = details.latestChapterTitle ?? book.latestChapterTitle
book.originOrder = details.originOrder
book.variable = details.variable ?? book.variable
diagnostics += detail.diagnostics
}
let toc = await updateCoordinator.refresh(book: book, source: source)
if !toc.chapters.isEmpty { chapters = toc.chapters }
diagnostics += toc.diagnostics
book = toc.book
if !chapters.isEmpty {
state = .ready
return
}
state = .findingSource
if let available = await sourceSwitchService.firstAvailableSource(for: book, sources: sources) {
applySourceSwitch(available.report, source: available.source)
} else {
state = .failed
}
}
func cacheNextTen() { startCaching(limit: 10) }
func cacheRemaining() { startCaching(limit: nil) }
func cancelCaching() {
cacheTask?.cancel()
cacheTask = nil
isCaching = false
}
func applySourceSwitch(_ report: BookSourceSwitchReport, source: BookSource) {
cancelCaching()
searchBook = nil
book = report.book
chapters = report.chapters
diagnostics = report.diagnostics
self.source = source
state = .ready
}
func toggleCanUpdate() { book.canUpdate.toggle() }
func restoreLibraryBook(from stored: Book, sources: [BookSource] = []) {
guard state == .idle, stored.bookUrl == book.bookUrl else { return }
if stored.origin != source.id,
let resolved = sources.first(where: { $0.id == stored.origin }) {
source = resolved
searchBook = nil
}
book = stored
book.origin = source.id
book.originName = source.bookSourceName
}
func restoreSourceAfterReading(from books: [Book], sources: [BookSource]) {
guard state == .ready,
let stored = books.first(where: { $0.bookUrl == book.bookUrl })
?? books.first(where: { $0.name == book.name && $0.author == book.author }),
stored.bookUrl != book.bookUrl || stored.origin != book.origin,
let source = sources.first(where: { $0.id == stored.origin }) else { return }
book = stored
self.source = source
searchBook = nil
chapters = []
diagnostics = []
state = .idle
}
func restoreUserSettings(from stored: Book) {
guard stored.bookUrl == book.bookUrl else { return }
book.group = stored.group
book.order = stored.order
book.customTag = stored.customTag
book.customCoverUrl = stored.customCoverUrl
book.customIntro = stored.customIntro
book.canUpdate = stored.canUpdate
book.readConfig = stored.readConfig
book.syncTime = stored.syncTime
}
func restoreReadingProgress(from stored: Book) {
guard stored.bookUrl == book.bookUrl, stored.durChapterTime > book.durChapterTime else { return }
let chapterIndex = stored.durChapterTitle.flatMap { title in
chapters.firstIndex { !$0.isVolume && $0.title == title }
} ?? stored.durChapterIndex
guard chapters.indices.contains(chapterIndex), !chapters[chapterIndex].isVolume else { return }
book.durChapterIndex = chapterIndex
book.durChapterTitle = chapters[chapterIndex].title
book.durChapterPos = stored.durChapterPos
book.durChapterTime = stored.durChapterTime
}
func refresh(sources: [BookSource] = []) async {
cancelCaching()
chapters = []
diagnostics = []
state = .idle
await load(sources: sources)
}
private func startCaching(limit: Int?) {
cancelCaching()
let start = min(max(book.durChapterIndex, 0), max(chapters.count - 1, 0))
let jobs = ChapterCachePlanner.plan(chapters: chapters, startIndex: start, limit: limit)
guard !jobs.isEmpty else { return }
isCaching = true
cacheCompleted = 0
cacheTotal = jobs.count
let coordinator = contentCoordinator
let currentBook = book
let currentSource = source
cacheTask = Task { [weak self] in
await withTaskGroup(of: Bool.self) { group in
var next = 0
func enqueue() {
guard next < jobs.count else { return }
let job = jobs[next]
next += 1
group.addTask {
guard !Task.isCancelled else { return false }
return await coordinator.prefetch(
book: currentBook,
chapter: job.chapter,
source: currentSource,
nextChapterURL: job.nextChapterURL
)
}
}
for _ in 0..<min(3, jobs.count) { enqueue() }
while await group.next() != nil {
guard !Task.isCancelled else {
group.cancelAll()
break
}
self?.cacheCompleted += 1
enqueue()
}
}
guard !Task.isCancelled else { return }
self?.isCaching = false
self?.cacheTask = nil
}
}
}
+429
View File
@@ -0,0 +1,429 @@
import Foundation
import SwiftUI
#if SWIFT_PACKAGE
import LegadoCore
#endif
enum AppBookChapterRepository {
static func live() -> any BookChapterRepository {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Legado", isDirectory: true)
guard let database = try? LegadoDatabase(url: base.appendingPathComponent("legado.sqlite3")) else {
return FileBookChapterRepository.live()
}
return SQLiteBookChapterRepository(
database: database,
legacyDirectory: base.appendingPathComponent("Chapters", isDirectory: true)
)
}
}
@MainActor
final class BookLibraryModel: ObservableObject {
@Published private(set) var books: [Book] = []
@Published private(set) var groups: [BookGroup] = []
@Published private(set) var errorMessage: String?
@Published private(set) var updateMessage: String?
@Published private(set) var isCheckingUpdates = false
private let repository: any BookRepository
private let localDirectory: URL
let localFiles: LocalFileService
private let localService = LocalBookService.live()
private let updateCoordinator: BookUpdateCoordinator
private let chapterRepository: any BookChapterRepository
private let groupRepository: any BookGroupRepository
private let pendingProgressKey = "pendingBookReadingProgress"
private var pendingProgress: [String: BookProgress]
private var bookSaveTask: Task<Bool, Never>?
private var bookSaveGeneration = 0
private var persistedBooks: [Book] = []
init(
repository: any BookRepository,
localDirectory: URL? = nil,
updateCoordinator: BookUpdateCoordinator = .init(),
chapterRepository: any BookChapterRepository = FileBookChapterRepository.live(),
groupRepository: any BookGroupRepository = InMemoryBookGroupRepository()
) {
self.repository = repository
self.localDirectory = localDirectory ?? FileManager.default.temporaryDirectory.appendingPathComponent("LegadoLocalBooks")
localFiles = LocalFileService(directory: self.localDirectory)
self.updateCoordinator = updateCoordinator
self.chapterRepository = chapterRepository
self.groupRepository = groupRepository
pendingProgress = (UserDefaults.standard.data(forKey: pendingProgressKey)
.flatMap { try? JSONDecoder().decode([String: BookProgress].self, from: $0) }) ?? [:]
}
static func live() -> BookLibraryModel {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Legado", isDirectory: true)
let legacyBooks = base.appendingPathComponent("books.json")
guard let database = try? LegadoDatabase(url: base.appendingPathComponent("legado.sqlite3")) else {
return BookLibraryModel(
repository: FileBookRepository(fileURL: legacyBooks),
localDirectory: base.appendingPathComponent("LocalBooks", isDirectory: true),
groupRepository: FileBookGroupRepository(fileURL: base.appendingPathComponent("book-groups.json"))
)
}
let chapters = SQLiteBookChapterRepository(
database: database,
legacyDirectory: base.appendingPathComponent("Chapters", isDirectory: true)
)
return BookLibraryModel(
repository: SQLiteBookRepository(database: database, legacyFileURL: legacyBooks),
localDirectory: base.appendingPathComponent("LocalBooks", isDirectory: true),
updateCoordinator: BookUpdateCoordinator(repository: chapters),
chapterRepository: chapters,
groupRepository: SQLiteBookGroupRepository(
database: database,
legacyFileURL: base.appendingPathComponent("book-groups.json")
)
)
}
static func demo(books: [Book]) -> BookLibraryModel {
BookLibraryModel(repository: InMemoryBookRepository(books: books))
}
func load() async {
do {
async let storedBooks = repository.load()
async let storedGroups = groupRepository.load()
books = try await storedBooks
persistedBooks = books
groups = try await storedGroups
if groups.isEmpty {
groups = [.init(groupId: BookGroup.idAll, groupName: "全部", order: 0)]
try await persistGroups()
}
} catch {
errorMessage = error.localizedDescription
}
}
func contains(_ book: Book) -> Bool {
books.contains { $0.bookUrl == book.bookUrl }
}
func addOrUpdate(_ book: Book) async {
var book = book
if let existing = books.first(where: { $0.bookUrl == book.bookUrl }),
existing.durChapterTime > book.durChapterTime {
applyProgress(BookProgress(book: existing), to: &book)
}
if let pending = pendingProgress.removeValue(forKey: book.bookUrl) {
if pending.durChapterTime > book.durChapterTime {
applyProgress(pending, to: &book)
}
persistPendingProgress()
}
var updated = books
if let index = updated.firstIndex(where: { $0.bookUrl == book.bookUrl }) {
updated[index] = book
} else {
updated.append(book)
}
await persist(updated)
}
func replace(oldBookURL: String, with book: Book) async {
guard books.contains(where: { $0.bookUrl == oldBookURL }) else {
await addOrUpdate(book)
return
}
var updated = books.filter { $0.bookUrl == oldBookURL || $0.bookUrl != book.bookUrl }
guard let index = updated.firstIndex(where: { $0.bookUrl == oldBookURL }) else { return }
updated[index] = book
await persist(updated)
if oldBookURL != book.bookUrl, books.contains(where: { $0.bookUrl == book.bookUrl }) {
try? await chapterRepository.remove(bookURL: oldBookURL)
}
}
func updateProgress(for book: Book, chapter: BookChapter, position: Int = 0) async {
guard let index = books.firstIndex(where: { $0.bookUrl == book.bookUrl }) else {
var reading = book
reading.durChapterIndex = chapter.index
reading.durChapterTitle = chapter.title
reading.durChapterPos = max(position, 0)
reading.durChapterTime = Int64(Date().timeIntervalSince1970 * 1_000)
pendingProgress[book.bookUrl] = BookProgress(book: reading)
persistPendingProgress()
return
}
var updated = books
updated[index].durChapterIndex = chapter.index
updated[index].durChapterTitle = chapter.title
updated[index].durChapterPos = max(position, 0)
updated[index].durChapterTime = Int64(Date().timeIntervalSince1970 * 1_000)
updated[index].lastCheckCount = 0
updated[index].totalChapterNum = max(updated[index].totalChapterNum, chapter.index + 1)
await persist(updated)
}
private func applyProgress(_ progress: BookProgress, to book: inout Book) {
book.durChapterIndex = progress.durChapterIndex
book.durChapterTitle = progress.durChapterTitle
book.durChapterPos = progress.durChapterPos
book.durChapterTime = progress.durChapterTime
}
private func persistPendingProgress() {
if pendingProgress.isEmpty {
UserDefaults.standard.removeObject(forKey: pendingProgressKey)
} else if let data = try? JSONEncoder().encode(pendingProgress) {
UserDefaults.standard.set(data, forKey: pendingProgressKey)
}
}
func checkForUpdates(sources: [BookSource], onlyRead: Bool = false) async {
guard !isCheckingUpdates else { return }
isCheckingUpdates = true
updateMessage = nil
defer { isCheckingUpdates = false }
let sourceByURL = Dictionary(uniqueKeysWithValues: sources.map { ($0.bookSourceUrl, $0) })
let requestedBooks = books.filter { $0.canUpdate && $0.origin != "local" && (!onlyRead || $0.durChapterTime > 0) }
var checked = 0
var added = 0
var failed = 0
for requestedBook in requestedBooks {
guard let source = sourceByURL[requestedBook.origin], source.enabled else {
failed += 1
continue
}
let report = await updateCoordinator.refresh(book: requestedBook, source: source)
checked += 1
if report.refreshed {
// Network requests suspend this actor. Merge the new directory
// into the current shelf, including any progress or edits made
// while the request was running, and never resurrect removals.
guard let index = books.firstIndex(where: {
$0.bookUrl == requestedBook.bookUrl && $0.origin == requestedBook.origin
}) else { continue }
var updated = books
updated[index] = BookUpdateMerger().merge(
book: updated[index],
oldChapters: [],
newChapters: report.chapters,
now: report.book.lastCheckTime
).book
added += report.addedChapterCount
await persist(updated)
} else {
failed += 1
}
}
if checked == 0 && failed == 0 {
updateMessage = "书架里暂时没有可更新的网络书籍。"
} else if failed > 0 {
updateMessage = "已检查 \(checked) 本,发现 \(added) 个新章节;另有 \(failed) 本因书源不可用或解析失败未更新。"
} else {
updateMessage = added > 0 ? "更新完成,共发现 \(added) 个新章节。" : "更新完成,已是最新目录。"
}
}
func remove(at offsets: IndexSet) async {
var updated = books
updated.remove(atOffsets: offsets)
await persist(updated)
}
var visibleGroups: [BookGroup] {
groups.filter(\.show).sorted {
$0.order == $1.order ? $0.groupId < $1.groupId : $0.order < $1.order
}
}
func books(in group: BookGroup) -> [Book] {
switch group.groupId {
case BookGroup.idAll: return books
case BookGroup.idLocal: return books.filter { $0.origin == "local" }
case BookGroup.idNetNone: return books.filter { $0.origin != "local" && $0.group == 0 }
case BookGroup.idLocalNone: return books.filter { $0.origin == "local" && $0.group == 0 }
default:
return group.groupId > 0
? books.filter { ($0.group & group.groupId) != 0 }
: books
}
}
func createGroup(name: String) async {
let name = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty, !groups.contains(where: { $0.groupName == name }) else { return }
let highest = groups.map(\.groupId).filter { $0 > 0 }.max() ?? 0
let nextID: Int64 = highest == 0 ? 1 : highest << 1
groups.append(.init(groupId: nextID, groupName: name, order: groups.count))
do { try await persistGroups() } catch { errorMessage = error.localizedDescription }
}
func renameGroup(_ group: BookGroup, name: String) async {
let name = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty, let index = groups.firstIndex(where: { $0.id == group.id }) else { return }
groups[index].groupName = name
do { try await persistGroups() } catch { errorMessage = error.localizedDescription }
}
func setGroupVisible(_ group: BookGroup, visible: Bool) async {
guard let index = groups.firstIndex(where: { $0.id == group.id }) else { return }
groups[index].show = visible
do { try await persistGroups() } catch { errorMessage = error.localizedDescription }
}
func moveGroups(from offsets: IndexSet, to destination: Int) async {
var editable = groups.filter { $0.groupId > 0 }.sorted { $0.order < $1.order }
editable.move(fromOffsets: offsets, toOffset: destination)
for (order, group) in editable.enumerated() {
if let index = groups.firstIndex(where: { $0.id == group.id }) {
groups[index].order = order + 1
}
}
do { try await persistGroups() } catch { errorMessage = error.localizedDescription }
}
func deleteGroup(_ group: BookGroup) async {
guard group.groupId > 0 else { return }
groups.removeAll { $0.id == group.id }
var updated = books
for index in updated.indices { updated[index].group &= ~group.groupId }
await persist(updated)
do { try await persistGroups() } catch { errorMessage = error.localizedDescription }
}
func setBook(_ book: Book, in group: BookGroup, included: Bool) async {
guard group.groupId > 0, let index = books.firstIndex(where: { $0.id == book.id }) else { return }
var updated = books
if included { updated[index].group |= group.groupId }
else { updated[index].group &= ~group.groupId }
await persist(updated)
}
func setBook(_ book: Book, groupIDs: Set<Int64>) async {
guard let index = books.firstIndex(where: { $0.id == book.id }) else { return }
let customMask = groups.reduce(Int64(0)) { value, group in
group.groupId > 0 ? value | group.groupId : value
}
let selectedMask = groupIDs.reduce(Int64(0), |)
var updated = books
updated[index].group = (updated[index].group & ~customMask) | selectedMask
await persist(updated)
}
func remove(bookURL: String) async {
await persist(books.filter { $0.bookUrl != bookURL })
try? await chapterRepository.remove(bookURL: bookURL)
}
func importLocalFiles(_ urls: [URL]) async {
var failures: [String] = []
for url in urls {
let granted = url.startAccessingSecurityScopedResource()
defer { if granted { url.stopAccessingSecurityScopedResource() } }
do {
let stored = try await localFiles.importFile(from: url)
do {
try await addLocalFile(at: stored)
} catch {
if let staged = try? await localFiles.stageRemoval(stored) {
try? await localFiles.finishRemoval(staged)
}
throw error
}
} catch {
failures.append("\(url.lastPathComponent):\(error.localizedDescription)")
}
}
errorMessage = failures.isEmpty ? nil : failures.joined(separator: "\n")
}
func addLocalFile(at url: URL) async throws {
guard !books.contains(where: { $0.bookUrl == url.absoluteString }) else { return }
let report = try await Task.detached { [localService] in try localService.openTextFile(at: url) }.value
guard !books.contains(where: { $0.bookUrl == url.absoluteString }) else { return }
guard await persist(books + [report.book]) else {
throw LocalFileError.saveFailed(errorMessage ?? "无法写入数据库")
}
}
func renameLocalFile(at url: URL, to name: String) async throws {
let target = try await localFiles.rename(url, to: name)
guard target != url else { return }
var updated = books
for index in updated.indices where updated[index].origin == "local" && updated[index].bookUrl == url.absoluteString {
updated[index].bookUrl = target.absoluteString
updated[index].originName = target.lastPathComponent
}
guard await persist(updated) else {
_ = try await localFiles.rename(target, to: url.lastPathComponent)
throw LocalFileError.saveFailed(errorMessage ?? "无法写入数据库")
}
if let progress = pendingProgress.removeValue(forKey: url.absoluteString) {
pendingProgress[target.absoluteString] = progress
persistPendingProgress()
}
try? await chapterRepository.remove(bookURL: url.absoluteString)
}
func deleteLocalFile(at url: URL) async throws {
let staged: StagedLocalFile?
if url.isFileURL,
books.contains(where: { $0.origin == "local" && $0.bookUrl == url.absoluteString }),
!FileManager.default.fileExists(atPath: url.path) {
// Stale shelf paths can belong to a previous app container. Only
// remove their metadata; existing files still require containment.
staged = nil
} else {
staged = try await localFiles.stageRemoval(url)
}
guard await persist(books.filter { $0.bookUrl != url.absoluteString }) else {
if let staged { try await localFiles.restore(staged) }
throw LocalFileError.saveFailed(errorMessage ?? "无法写入数据库")
}
pendingProgress[url.absoluteString] = nil
persistPendingProgress()
try? await chapterRepository.remove(bookURL: url.absoluteString)
if let staged { try await localFiles.finishRemoval(staged) }
}
func clearError() { errorMessage = nil }
func clearUpdateMessage() { updateMessage = nil }
func reportError(_ error: Error) { errorMessage = error.localizedDescription }
@discardableResult
private func persist(_ updated: [Book]) async -> Bool {
// Publish before suspending so the next mutation sees the latest
// shelf. Serialize disk writes to keep their order identical to it.
books = updated
bookSaveGeneration &+= 1
let generation = bookSaveGeneration
let previousSave = bookSaveTask
let save = Task { @MainActor [weak self, repository] in
_ = await previousSave?.value
do {
try await repository.save(updated)
self?.persistedBooks = updated
if self?.bookSaveGeneration == generation { self?.errorMessage = nil }
return true
} catch {
if let self, bookSaveGeneration == generation {
books = persistedBooks
errorMessage = error.localizedDescription
}
return false
}
}
bookSaveTask = save
let succeeded = await save.value
if bookSaveGeneration == generation { bookSaveTask = nil }
return succeeded
}
private func persistGroups() async throws {
try await groupRepository.save(groups)
}
}
@@ -0,0 +1,64 @@
import SwiftUI
#if SWIFT_PACKAGE
import LegadoCore
#endif
@MainActor
final class BookshelfReadViewModel: ObservableObject {
@Published private(set) var chapters: [BookChapter] = []
@Published private(set) var resolvedBook: Book?
@Published private(set) var isLoading = false
@Published private(set) var errorMessage: String?
private let localService: LocalBookService
private let updateCoordinator: BookUpdateCoordinator
init(
localService: LocalBookService = .init(),
updateCoordinator: BookUpdateCoordinator = .init(repository: AppBookChapterRepository.live())
) {
self.localService = localService
self.updateCoordinator = updateCoordinator
}
func load(book: Book, source: BookSource?) async {
guard chapters.isEmpty, !isLoading else { return }
isLoading = true
defer { isLoading = false }
if book.origin == "local" {
do {
let report = try await Task.detached { [localService] in
try localService.loadChapters(book: book)
}.value
chapters = report.chapters
resolvedBook = book
} catch {
errorMessage = error.localizedDescription
}
return
}
let stored = await updateCoordinator.storedChapters(bookURL: book.bookUrl)
if !stored.isEmpty, matchesReadingProgress(book, chapters: stored) {
chapters = stored
resolvedBook = book
return
}
guard let source else {
errorMessage = "目录与阅读进度不一致,且当前书籍的书源不存在。请先恢复或更换书源。"
return
}
let report = await updateCoordinator.refresh(book: book, source: source)
if report.refreshed, matchesReadingProgress(report.book, chapters: report.chapters) {
chapters = report.chapters
resolvedBook = report.book
} else {
errorMessage = report.diagnostics.first?.message ?? "目录刷新后仍与阅读进度不一致,请稍后重试。"
}
}
private func matchesReadingProgress(_ book: Book, chapters: [BookChapter]) -> Bool {
guard book.totalChapterNum == 0 || book.totalChapterNum == chapters.count else { return false }
guard let title = book.durChapterTitle, !title.isEmpty else { return true }
return chapters.indices.contains(book.durChapterIndex)
&& chapters[book.durChapterIndex].title == title
}
}
@@ -0,0 +1,152 @@
import SwiftUI
@MainActor
final class LocalBookDetailViewModel: ObservableObject {
@Published private(set) var chapters: [BookChapter] = []
@Published private(set) var isLoading = false
@Published private(set) var errorMessage: String?
private let service = LocalBookService.live()
func load(book: Book) async {
guard chapters.isEmpty, !isLoading else { return }
isLoading = true
defer { isLoading = false }
do {
let report = try await Task.detached { [service] in try service.loadChapters(book: book) }.value
chapters = report.chapters
} catch {
errorMessage = error.localizedDescription
}
}
}
struct LocalBookDetailView: View {
@EnvironmentObject private var library: BookLibraryModel
@Environment(\.dismiss) private var dismiss
let book: Book
@StateObject private var model = LocalBookDetailViewModel()
@State private var showsTableOfContents = false
@State private var showsRemoveConfirmation = false
var body: some View {
ScrollView {
VStack(spacing: 16) {
VStack(spacing: 14) {
BookCoverArtwork(title: book.name, author: book.author, width: 108, height: 154)
Text(book.name).font(.title2.bold())
HStack {
StatusPill(text: "本地 TXT", systemImage: "doc.text.fill")
if let charset = book.charset { StatusPill(text: charset, color: ReadingTheme.coral, systemImage: "character") }
}
Text(book.originName).font(.caption).foregroundStyle(.secondary)
}
.padding(.top, 12)
ReadingCard {
VStack(alignment: .leading, spacing: 0) {
HStack {
Label("目录", systemImage: "list.bullet.rectangle.portrait.fill").font(.headline)
Spacer()
Text("\(model.chapters.count) 章").font(.caption).foregroundStyle(.secondary)
}
.padding(.bottom, 12)
if model.isLoading {
HStack { Spacer(); ProgressView("正在识别章节…"); Spacer() }.padding(.vertical, 28)
} else if let error = model.errorMessage {
ReadingUnavailableView("无法读取本地书", systemImage: "doc.badge.ellipsis", description: Text(error))
} else {
ForEach(model.chapters.prefix(8)) { chapter in
Divider()
NavigationLink {
ReaderView(book: book, chapters: model.chapters, initialIndex: chapter.index)
} label: {
HStack(spacing: 12) {
Text(String(format: "%02d", chapter.index + 1))
.font(.caption.monospacedDigit()).foregroundStyle(.tertiary).frame(width: 28)
Text(chapter.title).font(.subheadline).foregroundStyle(.primary).lineLimit(2)
Spacer()
Image(systemName: "chevron.right").font(.caption.bold()).foregroundStyle(.tertiary)
}
.padding(.vertical, 12)
}
}
if model.chapters.count > 8 {
Divider()
Button("查看全部目录", systemImage: "list.bullet") {
showsTableOfContents = true
}
.frame(maxWidth: .infinity).padding(.vertical, 10)
}
}
}
}
}
.padding(.horizontal, 16).padding(.bottom, 30)
}
.background(ReadingTheme.canvas)
.navigationTitle(book.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button("全部目录", systemImage: "list.bullet") { showsTableOfContents = true }
Button("移出书架", systemImage: "trash", role: .destructive) { showsRemoveConfirmation = true }
} label: { Image(systemName: "ellipsis.circle") }
}
}
.task { await model.load(book: book) }
.sheet(isPresented: $showsTableOfContents) {
LocalBookTableOfContentsView(book: book, chapters: model.chapters)
}
.confirmationDialog("从书架移除《\(book.name)》?", isPresented: $showsRemoveConfirmation) {
Button("移出书架", role: .destructive) {
Task { await library.remove(bookURL: book.bookUrl); dismiss() }
}
Button("取消", role: .cancel) {}
}
}
}
private struct LocalBookTableOfContentsView: View {
@Environment(\.dismiss) private var dismiss
let book: Book
let chapters: [BookChapter]
@State private var query = ""
@State private var descending = false
private var displayed: [BookChapter] {
let filtered = query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? chapters
: chapters.filter { $0.title.localizedCaseInsensitiveContains(query) }
return descending ? Array(filtered.reversed()) : filtered
}
var body: some View {
ReadingNavigationStack {
List(displayed) { chapter in
NavigationLink {
ReaderView(book: book, chapters: chapters, initialIndex: chapter.index)
} label: {
HStack {
Text("\(chapter.index + 1)").font(.caption.monospacedDigit()).foregroundStyle(.secondary).frame(width: 42, alignment: .trailing)
Text(chapter.title).lineLimit(2)
Spacer()
if chapter.index == book.durChapterIndex {
Image(systemName: "location.fill").foregroundStyle(ReadingTheme.accent)
}
}
}
}
.searchable(text: $query, prompt: "搜索目录")
.navigationTitle("目录")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("关闭") { dismiss() } }
ToolbarItem(placement: .topBarTrailing) {
Button(descending ? "正序" : "倒序", systemImage: "arrow.up.arrow.down") { descending.toggle() }
}
}
}
}
}
+156
View File
@@ -0,0 +1,156 @@
import SwiftUI
@MainActor
final class SourceSwitchViewModel: ObservableObject {
enum State: Equatable { case idle, searching, ready, empty }
@Published private(set) var state: State = .idle
@Published private(set) var candidates: [BookSourceCandidate] = []
@Published private(set) var switchingID: String?
@Published private(set) var errorMessage: String?
private let book: Book
var bookName: String { book.name }
private let service: BookSourceSwitchService
private var selectionGeneration = 0
init(book: Book, service: BookSourceSwitchService = .init(), candidates: [BookSourceCandidate] = []) {
self.book = book
self.service = service
self.candidates = candidates
state = candidates.isEmpty ? .idle : .ready
}
func load(sources: [BookSource]) async {
guard state == .idle else { return }
state = .searching
for await update in await service.candidateUpdates(for: book, sources: sources) {
guard !Task.isCancelled else { state = candidates.isEmpty ? .idle : .ready; return }
candidates = update
}
guard !Task.isCancelled else { state = candidates.isEmpty ? .idle : .ready; return }
state = candidates.isEmpty ? .empty : .ready
}
func select(_ candidate: BookSourceCandidate, sources: [BookSource]) async -> (BookSourceSwitchReport, BookSource)? {
guard switchingID == nil,
let source = sources.first(where: { $0.bookSourceUrl == candidate.book.origin }) else { return nil }
switchingID = candidate.id
let generation = selectionGeneration
errorMessage = nil
defer { switchingID = nil }
let report = await service.prepareSwitch(current: book, candidate: candidate.book, source: source)
guard generation == selectionGeneration, !Task.isCancelled else { return nil }
guard let report else {
errorMessage = "候选书源无法读取目录或当前章节正文,请选择其他书源。已保留当前书源和阅读位置。"
return nil
}
return (report, source)
}
func clearError() { errorMessage = nil }
func cancelSelection() { selectionGeneration &+= 1 }
}
struct SourceSwitchView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var model: SourceSwitchViewModel
let sources: [BookSource]
let onSwitched: @MainActor (BookSourceSwitchReport, BookSource) -> Void
init(
book: Book,
sources: [BookSource],
demoCandidates: [BookSourceCandidate] = [],
onSwitched: @escaping @MainActor (BookSourceSwitchReport, BookSource) -> Void
) {
self.sources = sources
self.onSwitched = onSwitched
_model = StateObject(wrappedValue: SourceSwitchViewModel(book: book, candidates: demoCandidates))
}
var body: some View {
Group {
if model.candidates.isEmpty && (model.state == .searching || model.state == .idle) {
VStack(spacing: 14) {
ProgressView()
Text("正在从其他书源查找同名作品…").foregroundStyle(.secondary)
}
} else if model.state == .empty {
ReadingUnavailableView(
"没有可用换源",
systemImage: "arrow.triangle.2.circlepath",
description: Text("仅显示书名一致的候选,并优先匹配作者。请确认其他书源已启用且支持搜索。")
)
} else {
List {
if model.state == .searching {
HStack(spacing: 10) {
ProgressView()
Text("仍在查找其他书源,可直接选择已找到的结果")
.font(.caption).foregroundStyle(.secondary)
}
}
ForEach(model.candidates) { candidate in candidateRow(candidate) }
}
.listStyle(.insetGrouped)
.readingScrollContentBackgroundHidden()
.background(ReadingTheme.canvas)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(ReadingTheme.canvas)
.navigationTitle("为《\(model.bookName)》换源")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
}
.task { await model.load(sources: sources) }
.onDisappear { model.cancelSelection() }
.alert("换源失败", isPresented: Binding(
get: { model.errorMessage != nil },
set: { if !$0 { model.clearError() } }
)) {
Button("好") { model.clearError() }
} message: {
Text(model.errorMessage ?? "")
}
}
private func candidateRow(_ candidate: BookSourceCandidate) -> some View {
Button {
Task {
if let (report, source) = await model.select(candidate, sources: sources) {
onSwitched(report, source)
dismiss()
}
}
} label: {
HStack(spacing: 13) {
Image(systemName: "network")
.foregroundStyle(ReadingTheme.accent)
.frame(width: 42, height: 42)
.background(ReadingTheme.accentSoft.opacity(0.55), in: RoundedRectangle(cornerRadius: 13))
VStack(alignment: .leading, spacing: 5) {
Text(candidate.book.originName).font(.headline).foregroundStyle(.primary)
Text(candidate.book.author.isEmpty ? "作者信息缺失" : candidate.book.author)
.font(.subheadline).foregroundStyle(.secondary)
if model.switchingID == candidate.id {
Text("正在验证目录和正文…").font(.caption).foregroundStyle(.secondary)
} else if let latest = candidate.book.latestChapterTitle, !latest.isEmpty {
Text(latest).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
}
Spacer()
if model.switchingID == candidate.id {
ProgressView()
} else {
Image(systemName: "arrow.right.circle.fill").foregroundStyle(ReadingTheme.coral)
}
}
.padding(.vertical, 5)
}
.disabled(model.switchingID != nil)
.accessibilityIdentifier("sourceSwitch.candidate.\(candidate.book.origin)")
}
}
+354
View File
@@ -0,0 +1,354 @@
import SwiftUI
@MainActor
final class ExploreSourceViewModel: ObservableObject {
enum State: Equatable { case idle, loading, ready, empty, failed }
let source: BookSource
@Published private(set) var kinds: [ExploreKind] = []
@Published private(set) var selectedKind: ExploreKind?
@Published private(set) var books: [SearchBook] = []
@Published private(set) var diagnostics: [RuleDiagnostic] = []
@Published private(set) var state: State = .idle
@Published private(set) var isLoadingMore = false
private let service: BookExploreService
private var page = 1
private var canLoadMore = false
private var paginationTask: Task<Void, Never>?
init(source: BookSource, service: BookExploreService = .init()) {
self.source = source
self.service = service
}
deinit { paginationTask?.cancel() }
func load() async {
guard state == .idle else { return }
await reload()
}
func reload() async {
paginationTask?.cancel()
selectedKind = nil
kinds = []
books = []
diagnostics = []
page = 1
canLoadMore = false
state = .loading
let report = await service.kinds(for: source)
guard !Task.isCancelled else { return }
kinds = report.kinds.filter { $0.navigableURL != nil }
diagnostics = report.diagnostics
guard let first = kinds.first else {
state = diagnostics.contains(where: { $0.severity == .error }) ? .failed : .empty
return
}
await select(kind: first)
}
func select(kind: ExploreKind) async {
guard kind.navigableURL != nil else { return }
paginationTask?.cancel()
selectedKind = kind
books = []
diagnostics = []
page = 1
canLoadMore = false
state = .loading
if ProcessInfo.processInfo.arguments.contains("-uiDemo") {
books = DemoContent.exploreBooks
state = .ready
canLoadMore = true
return
}
let outcome = await service.explore(source: source, kind: kind, page: page)
guard !Task.isCancelled, selectedKind?.id == kind.id else { return }
diagnostics = outcome.diagnostics
books = outcome.books
canLoadMore = !outcome.books.isEmpty
state = books.isEmpty ? (outcome.diagnostics.isEmpty ? .empty : .failed) : .ready
}
func loadNextPageIfNeeded(current book: SearchBook) {
guard book.id == books.last?.id,
canLoadMore,
!isLoadingMore,
let kind = selectedKind else { return }
isLoadingMore = true
let nextPage = page + 1
paginationTask = Task { [service, source] in
if ProcessInfo.processInfo.arguments.contains("-uiDemo") {
try? await Task.sleep(nanoseconds: 300_000_000)
guard !Task.isCancelled else { return }
canLoadMore = false
isLoadingMore = false
return
}
let outcome = await service.explore(source: source, kind: kind, page: nextPage)
guard !Task.isCancelled, selectedKind?.id == kind.id else {
isLoadingMore = false
return
}
let knownURLs = Set(books.map(\.bookURL))
books.append(contentsOf: outcome.books.filter { !knownURLs.contains($0.bookURL) })
diagnostics += outcome.diagnostics
page = nextPage
canLoadMore = !outcome.books.isEmpty
isLoadingMore = false
}
}
}
struct ExploreView: View {
@EnvironmentObject private var sourceLibrary: SourceLibraryModel
private var sources: [BookSource] {
sourceLibrary.sources
.filter { $0.enabled && $0.enabledExplore && !($0.exploreUrl?.isEmpty ?? true) }
.sorted {
if $0.customOrder != $1.customOrder { return $0.customOrder < $1.customOrder }
return $0.bookSourceName.localizedStandardCompare($1.bookSourceName) == .orderedAscending
}
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
ReadingPageHeader(title: "发现", subtitle: "下一本好书,在这里遇见") {
NavigationLink { SearchView() } label: {
ReadingToolbarIcon(systemName: "magnifyingglass")
}
.accessibilityLabel("搜索")
}
VStack(alignment: .leading, spacing: 24) {
HStack(spacing: 16) {
Image(systemName: "safari")
.font(.system(size: 30, weight: .ultraLight))
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 6) {
Text("从喜欢的书源开始").font(.headline)
Text("浏览分类与榜单,发现更多故事")
.font(.caption).foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.padding(22)
.frame(maxWidth: .infinity, alignment: .leading)
.background(ReadingTheme.groupedCanvas, in: RoundedRectangle(cornerRadius: 20))
HStack(alignment: .firstTextBaseline) {
Text("分类与榜单").font(.title3.bold())
.accessibilityAddTraits(.isHeader)
Spacer()
Text("\(sources.count)个书源").font(.caption).foregroundStyle(.secondary)
}
if sources.isEmpty {
ReadingUnavailableView {
Label("暂无发现书源", systemImage: "books.vertical")
} description: {
Text("添加带有发现分类的书源,即可在这里浏览书籍。")
} actions: {
NavigationLink("管理书源") { SourceManagementView() }
.buttonStyle(.borderedProminent)
}
} else {
LazyVStack(spacing: 0) {
ForEach(sources) { source in
NavigationLink { ExploreSourceView(source: source) } label: {
HStack(spacing: 12) {
ExploreSourceRow(source: source)
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.caption).foregroundStyle(.tertiary)
}
.padding(.vertical, 8)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if source.id != sources.last?.id {
Rectangle().fill(ReadingTheme.separator).frame(height: 0.5)
.padding(.leading, 58)
}
}
}
}
NavigationLink { SourceManagementView() } label: {
HStack(spacing: 14) {
Image(systemName: "slider.horizontal.3")
.font(.system(size: 20))
.frame(width: 40, height: 44)
VStack(alignment: .leading, spacing: 5) {
Text("管理书源").font(.subheadline.weight(.semibold))
Text("添加与整理阅读来源").font(.caption).foregroundStyle(.secondary)
}
Spacer(minLength: 0)
Image(systemName: "arrow.up.right").font(.subheadline)
}
.foregroundStyle(ReadingTheme.ink)
.padding(18)
.background(ReadingTheme.groupedCanvas, in: RoundedRectangle(cornerRadius: 18))
}
.buttonStyle(.plain)
}
.padding(.horizontal, 24)
.padding(.bottom, 32)
}
}
.background(ReadingTheme.canvas)
.navigationBarHidden(true)
}
}
private struct ExploreSourceRow: View {
let source: BookSource
var body: some View {
HStack(spacing: 12) {
Image(systemName: source.bookSourceType == 1 ? "headphones" : "books.vertical")
.font(.system(size: 20))
.foregroundStyle(ReadingTheme.accent)
.frame(width: 44, height: 48)
.background(ReadingTheme.groupedCanvas, in: RoundedRectangle(cornerRadius: 12))
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text(source.bookSourceName)
.font(.body.weight(.medium))
.foregroundStyle(.primary)
.lineLimit(2)
Text(source.groups.isEmpty ? (URL(string: source.bookSourceUrl)?.host ?? source.bookSourceUrl) : source.groups.joined(separator: " · "))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.padding(.vertical, 7)
.contentShape(Rectangle())
}
}
private struct ExploreSourceView: View {
@StateObject private var model: ExploreSourceViewModel
init(source: BookSource) {
_model = StateObject(wrappedValue: ExploreSourceViewModel(source: source))
}
var body: some View {
ScrollView {
LazyVStack(spacing: 0) {
if model.kinds.count > 1 { kindPicker }
content
if model.isLoadingMore { ProgressView("继续加载…").padding(.vertical, 12) }
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.background(ReadingTheme.canvas)
.navigationTitle(model.source.bookSourceName)
.navigationBarTitleDisplayMode(.inline)
.task { await model.load() }
.refreshable { await model.reload() }
}
private var kindPicker: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(model.kinds) { kind in
Button(kind.title) { Task { await model.select(kind: kind) } }
.font(.subheadline.weight(.medium))
.padding(.horizontal, 12)
.frame(minHeight: 44)
.foregroundStyle(model.selectedKind?.id == kind.id ? Color.primary : .secondary)
.overlay(alignment: .bottom) {
if model.selectedKind?.id == kind.id {
Rectangle().fill(Color.primary).frame(height: 2)
}
}
.accessibilityAddTraits(model.selectedKind?.id == kind.id ? .isSelected : [])
}
}
}
}
@ViewBuilder
private var content: some View {
switch model.state {
case .idle, .loading:
ProgressView("正在加载小说…")
.frame(maxWidth: .infinity)
.padding(.vertical, 80)
case .empty:
ReadingUnavailableView(
"暂无小说",
systemImage: "books.vertical",
description: Text("当前书源或分类暂时没有内容,可以下拉刷新。")
)
.padding(.vertical, 40)
case .failed where model.books.isEmpty:
ReadingUnavailableView(
"加载失败",
systemImage: "exclamationmark.triangle",
description: Text(model.diagnostics.first?.message ?? "书源发现规则解析失败。")
)
.padding(.vertical, 40)
default:
ForEach(model.books) { book in
NavigationLink {
BookDetailView(searchBook: book, source: model.source)
} label: {
ExploreBookRow(book: book)
}
.buttonStyle(.plain)
.onAppear { model.loadNextPageIfNeeded(current: book) }
}
}
}
}
private struct ExploreBookRow: View {
let book: SearchBook
var body: some View {
HStack(alignment: .top, spacing: 13) {
BookCoverArtwork(title: book.name, author: book.author, remoteURL: book.coverURL, width: 52, height: 74)
VStack(alignment: .leading, spacing: 6) {
Text(book.name)
.font(.body.weight(.medium))
.foregroundStyle(ReadingTheme.ink)
.lineLimit(2)
Text(book.author.isEmpty ? "未知作者" : book.author)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
if let kind = book.kind, !kind.isEmpty {
Text(kind)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
if let intro = book.intro, !intro.isEmpty {
Text(intro)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
.padding(.top, 4)
}
.padding(.vertical, 12)
.overlay(alignment: .bottom) {
Rectangle().fill(ReadingTheme.separator).frame(height: 0.5)
.padding(.leading, 65)
}
}
}
+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 }
}
+120
View File
@@ -0,0 +1,120 @@
import CoreText
import SwiftUI
import UIKit
private struct ReaderPositionPreferenceKey: PreferenceKey {
static let defaultValue: [Int: CGFloat] = [:]
static func reduce(value: inout [Int: CGFloat], nextValue: () -> [Int: CGFloat]) {
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
}
}
struct ReaderStylePreset: Identifiable {
let id: String
let name: String
let background: UIColor
let foreground: UIColor
static let legadoDefaults: [ReaderStylePreset] = [
.init(id: "wechat", name: "微信读书", background: UIColor(hex: 0xC0EDC6), foreground: UIColor(hex: 0x0B0B0B)),
.init(id: "preset1", name: "预设1", background: UIColor(hex: 0xFFFFFF), foreground: UIColor(hex: 0x000000)),
.init(id: "preset2", name: "预设2", background: UIColor(hex: 0xDDC090), foreground: UIColor(hex: 0x3E3422)),
.init(id: "preset3", name: "预设3", background: UIColor(hex: 0xC2D8AA), foreground: UIColor(hex: 0x596C44)),
.init(id: "preset4", name: "预设4", background: UIColor(hex: 0xDBB8E2), foreground: UIColor(hex: 0x68516C)),
.init(id: "preset5", name: "预设5", background: UIColor(hex: 0xABCEE0), foreground: UIColor(hex: 0x3D4C54)),
.init(id: "night", name: "夜间", background: UIColor(hex: 0x000000), foreground: UIColor(hex: 0xADADAD))
]
static let eInk = ReaderStylePreset(
id: "eink",
name: "E-Ink",
background: UIColor(hex: 0xFFFFFF),
foreground: UIColor(hex: 0x000000)
)
static func resolve(_ id: String) -> ReaderStylePreset {
let migratedID = switch id {
case "dark": "night"
case "light": "preset1"
case "paper": "preset2"
default: id
}
return legadoDefaults.first(where: { $0.id == migratedID }) ?? legadoDefaults[0]
}
}
private extension UIColor {
convenience init(hex: UInt32) {
self.init(
red: CGFloat((hex >> 16) & 0xff) / 255,
green: CGFloat((hex >> 8) & 0xff) / 255,
blue: CGFloat(hex & 0xff) / 255,
alpha: 1
)
}
}
enum ReaderBackgroundStore {
static var url: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Legado/reader-background.jpg")
}
static func load() -> UIImage? {
guard let data = try? Data(contentsOf: url) else { return nil }
return UIImage(data: data)
}
static func save(_ data: Data) throws -> UIImage {
guard let image = UIImage(data: data), let encoded = image.jpegData(compressionQuality: 0.9) else {
throw CocoaError(.fileReadCorruptFile)
}
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
try encoded.write(to: url, options: .atomic)
return image
}
static func remove() { try? FileManager.default.removeItem(at: url) }
}
enum ReaderFontStore {
static var directory: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Legado/fonts", isDirectory: true)
}
static func install(from source: URL) throws -> String {
let accessing = source.startAccessingSecurityScopedResource()
defer { if accessing { source.stopAccessingSecurityScopedResource() } }
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let target = directory.appendingPathComponent(source.lastPathComponent)
if target != source {
try? FileManager.default.removeItem(at: target)
try FileManager.default.copyItem(at: source, to: target)
}
guard font(at: target.path, size: 20, weight: 0) != nil else {
throw CocoaError(.fileReadCorruptFile)
}
return target.path
}
static func font(at path: String, size: CGFloat, weight: Int) -> UIFont? {
guard !path.isEmpty,
let provider = CGDataProvider(url: URL(fileURLWithPath: path) as CFURL),
let graphicsFont = CGFont(provider) else { return nil }
CTFontManagerRegisterGraphicsFont(graphicsFont, nil)
guard let postScriptName = graphicsFont.postScriptName else { return nil }
let name = postScriptName as String
guard let base = UIFont(name: name, size: size) else { return nil }
if weight == 1,
let descriptor = base.fontDescriptor.withSymbolicTraits(.traitBold) {
return UIFont(descriptor: descriptor, size: size)
}
if weight == 2 {
let descriptor = base.fontDescriptor.addingAttributes([
.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.light.rawValue]
])
return UIFont(descriptor: descriptor, size: size)
}
return base
}
}
+579
View File
@@ -0,0 +1,579 @@
import SwiftUI
import UIKit
struct ReaderScrollView: UIViewRepresentable {
let configuration: ReaderTextView
let chapters: [ReaderScrollChapter]
let resetID: Int
let activeIndex: Int
let loadingAhead: Bool
let loadingBehind: Bool
let onNeedChapters: (Int) -> Void
let onPositionChanged: (Int, Int) -> Void
let onRetainChapters: (Set<Int>) -> Void
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
func makeUIView(context: Context) -> ReaderScrollCollectionView {
context.coordinator.makeView()
}
func updateUIView(_ view: ReaderScrollCollectionView, context: Context) {
context.coordinator.parent = self
context.coordinator.update(view)
}
static func dismantleUIView(_ view: ReaderScrollCollectionView, coordinator: Coordinator) {
coordinator.displayLink?.invalidate()
view.viewportChanged = nil
}
@MainActor
final class Coordinator: NSObject, UICollectionViewDataSource, UICollectionViewDelegate,
UIGestureRecognizerDelegate {
var parent: ReaderScrollView
private(set) var blocks: [ReaderScrollBlock] = []
private var chapterLayouts: [Int: ReaderScrollChapterLayout] = [:]
private weak var view: ReaderScrollCollectionView?
var displayLink: CADisplayLink?
private var lastTimestamp: CFTimeInterval = 0
private var styleKey = ""
private var viewportHeight: CGFloat = 0
private var revision: Int?
private var restored = false
private var jumpKey: String?
private var reporting = false
private var applying = false
private var requestedAhead: Int?
private var requestedBehind: Int?
private var wasLoadingAhead = false
private var wasLoadingBehind = false
private var lastPageKey = ""
private var reachedEnd = false
#if DEBUG
private var decelerationInsertions = 0
private var continuedDecelerations = 0
private var interruptedDecelerations = 0
private var awaitingDecelerationOffset: CGFloat?
#endif
init(parent: ReaderScrollView) { self.parent = parent }
func makeView() -> ReaderScrollCollectionView {
let collection = ReaderScrollCollectionView(frame: .zero, collectionViewLayout: ReaderContinuousLayout())
collection.backgroundColor = parent.configuration.backgroundColor
collection.contentInsetAdjustmentBehavior = .never
collection.alwaysBounceVertical = true
collection.showsVerticalScrollIndicator = false
collection.decelerationRate = .normal
collection.dataSource = self
collection.delegate = self
collection.accessibilityIdentifier = "reader.continuous"
collection.register(ReaderScrollCell.self, forCellWithReuseIdentifier: "text")
let tap = UITapGestureRecognizer(target: self, action: #selector(tapped(_:)))
tap.delegate = self
collection.addGestureRecognizer(tap)
collection.viewportChanged = { [weak self, weak collection] in
guard let self, let collection else { return }
self.update(collection)
}
let link = CADisplayLink(target: self, selector: #selector(step(_:)))
link.add(to: .main, forMode: .common)
displayLink = link
view = collection
return collection
}
func update(_ view: ReaderScrollCollectionView) {
guard !applying, view.bounds.width > 0, view.bounds.height > 0 else { return }
applying = true
defer { applying = false }
let config = parent.configuration
if wasLoadingAhead, !parent.loadingAhead { requestedAhead = nil }
if wasLoadingBehind, !parent.loadingBehind { requestedBehind = nil }
wasLoadingAhead = parent.loadingAhead
wasLoadingBehind = parent.loadingBehind
view.backgroundColor = config.backgroundColor
view.loadingLabel.textColor = config.foregroundColor.withAlphaComponent(0.6)
view.loadingLabel.text = parent.chapters.isEmpty ? "正在加载正文…"
: (parent.loadingAhead || parent.loadingBehind ? "正在加载后续章节…" : nil)
view.positionLoadingLabel()
let reset = revision != parent.resetID
if reset {
revision = parent.resetID
restored = false
jumpKey = nil
requestedAhead = nil
requestedBehind = nil
lastPageKey = ""
chapterLayouts = [:]
}
let anchor = reset ? nil : captureAnchor()
let newStyleKey = config.scrollTypographyKey + "|\(view.bounds.width)"
let styleChanged = styleKey != newStyleKey
let contentChanged = styleChanged || viewportHeight != view.bounds.height || parent.chapters.count != chapterLayouts.count
|| parent.chapters.contains { chapterLayouts[$0.id]?.chapter != $0 }
if contentChanged || reset {
let oldIDs = blocks.map(\.id)
var layouts: [Int: ReaderScrollChapterLayout] = [:]
for chapter in parent.chapters {
if !styleChanged, let cached = chapterLayouts[chapter.id], cached.chapter == chapter {
layouts[chapter.id] = cached
} else {
layouts[chapter.id] = ReaderScrollChapterLayout(
chapter: chapter, configuration: config, width: view.bounds.width
)
}
}
let newBlocks = parent.chapters.flatMap { layouts[$0.id]?.blocks ?? [] }
let layout = view.collectionViewLayout as! ReaderContinuousLayout
let geometry = ReaderContinuousLayout.geometry(
blocks: newBlocks, width: view.bounds.width,
top: CGFloat(config.paddingTop), bottom: CGFloat(config.paddingBottom), viewportHeight: view.bounds.height
)
let targetY = anchor.flatMap { offset(for: $0, blocks: newBlocks, frames: geometry.frames) }
let correction = targetY.map { $0 - view.contentOffset.y } ?? 0
let newIDs = newBlocks.map(\.id)
let oldSet = Set(oldIDs), newSet = Set(newIDs)
let deletions = oldIDs.indices.filter { !newSet.contains(oldIDs[$0]) }.map { IndexPath(item: $0, section: 0) }
let insertions = newIDs.indices.filter { !oldSet.contains(newIDs[$0]) }.map { IndexPath(item: $0, section: 0) }
if !deletions.isEmpty { requestedAhead = nil; requestedBehind = nil }
chapterLayouts = layouts
styleKey = newStyleKey
viewportHeight = view.bounds.height
#if DEBUG
let wasDecelerating = view.isDecelerating
#endif
UIView.performWithoutAnimation {
view.performBatchUpdates {
self.blocks = newBlocks
view.deleteItems(at: deletions)
view.insertItems(at: insertions)
// UIKit applies the geometry and anchor compensation together.
// Setting contentOffset here would cancel an in-flight deceleration.
layout.install(frames: geometry.frames, size: geometry.size, correction: correction)
}
view.layoutIfNeeded()
}
#if DEBUG
if wasDecelerating, !insertions.isEmpty {
decelerationInsertions += 1
if view.isDecelerating { awaitingDecelerationOffset = view.contentOffset.y }
else { interruptedDecelerations += 1 }
}
#endif
for cell in view.visibleCells {
guard let cell = cell as? ReaderScrollCell,
let index = view.indexPath(for: cell)?.item, blocks.indices.contains(index) else { continue }
cell.canvas.block = blocks[index]
}
reachedEnd = false
}
if !restored, let chapter = chapterLayouts[parent.activeIndex] {
restored = true
seek(chapter: chapter.chapter.id, position: config.requestedPosition, includeHeading: config.requestedPosition == 0)
}
if let position = config.jumpPosition {
let key = "\(parent.activeIndex):\(position)"
if jumpKey != key {
jumpKey = key
seek(chapter: parent.activeIndex, position: position, includeHeading: position == 0)
}
} else { jumpKey = nil }
scheduleReport()
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { blocks.count }
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "text", for: indexPath) as! ReaderScrollCell
cell.canvas.block = blocks[indexPath.item]
return cell
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard !applying else { return }
#if DEBUG
if let offset = awaitingDecelerationOffset, scrollView.isDecelerating,
abs(scrollView.contentOffset.y - offset) > 0.5 {
continuedDecelerations += 1
awaitingDecelerationOffset = nil
}
#endif
view?.positionLoadingLabel()
scheduleReport()
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if !decelerate { scheduleReport() }
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { scheduleReport() }
private func scheduleReport() {
guard !reporting else { return }
reporting = true
let scheduledRevision = parent.resetID
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.reporting = false
guard self.parent.resetID == scheduledRevision else { self.scheduleReport(); return }
self.reportViewport()
}
}
private func reportViewport() {
guard let view, restored, let anchor = captureAnchor(),
let chapter = chapterLayouts[anchor.chapter] else { return }
parent.onPositionChanged(anchor.chapter, anchor.position)
let config = parent.configuration
let chapterRows = blocks.indices.filter { blocks[$0].chapter.chapter.id == anchor.chapter }
let layout = view.collectionViewLayout as! ReaderContinuousLayout
if let first = chapterRows.first, let last = chapterRows.last {
let start = layout.frames[first].minY, end = layout.frames[last].maxY
let height = max(view.bounds.height, 1)
let total = max(Int(ceil((end - start) / height)), 1)
let page = min(max(Int(floor((view.contentOffset.y - start) / height)) + 1, 1), total)
if config.currentPage != page { config.currentPage = page }
if config.totalPages != total { config.totalPages = total }
let ranges = chapter.chapter.content.isEmpty ? [] : [NSRange(location: 0, length: chapter.chapter.content.utf16.count)]
if config.pageRanges != ranges { config.pageRanges = ranges }
let key = "\(anchor.chapter):\(page)"
if lastPageKey != key { lastPageKey = key; config.onPageChanged() }
}
let runway = view.bounds.height * 3
if let last = parent.chapters.last, layout.collectionViewContentSize.height - view.bounds.maxY < runway,
requestedAhead != last.id {
requestedAhead = last.id
parent.onNeedChapters(1)
}
if let first = parent.chapters.first, view.contentOffset.y < runway, requestedBehind != first.id {
requestedBehind = first.id
parent.onNeedChapters(-1)
}
if !view.isDragging, !view.isDecelerating, parent.chapters.count > 12 {
let retainedRect = view.bounds.insetBy(dx: 0, dy: -view.bounds.height * 6)
let visible = blocks.indices.filter { layout.frames[$0].intersects(retainedRect) }
let indices = Set(visible.map { blocks[$0].chapter.chapter.id })
let ordered = parent.chapters.map(\.id)
if let lo = ordered.firstIndex(where: { indices.contains($0) }),
let hi = ordered.lastIndex(where: { indices.contains($0) }) {
let keep = Set(ordered[max(lo - 2, 0)...min(hi + 2, ordered.count - 1)])
if keep.count < ordered.count { parent.onRetainChapters(keep) }
}
}
#if DEBUG
view.accessibilityValue = "chapter=\(anchor.chapter) position=\(anchor.position) chapters=\(parent.chapters.count) decelInsertions=\(decelerationInsertions) continued=\(continuedDecelerations) interrupted=\(interruptedDecelerations)"
#endif
}
private struct Anchor {
let chapter: Int
let position: Int
let inHeading: Bool
let screenY: CGFloat
}
private func captureAnchor() -> Anchor? {
guard let view, !blocks.isEmpty else { return nil }
let layout = view.collectionViewLayout as! ReaderContinuousLayout
let y = max(view.contentOffset.y, layout.frames.first?.minY ?? 0)
guard let row = layout.row(at: y) else { return nil }
let block = blocks[row], chapter = block.chapter
let localY = y - layout.frames[row].minY + block.top
let glyph = chapter.manager.glyphIndex(for: CGPoint(x: 0, y: localY + 0.5), in: chapter.container)
let safeGlyph = min(glyph, max(chapter.manager.numberOfGlyphs - 1, 0))
let character = chapter.manager.characterIndexForGlyph(at: safeGlyph)
let lineY = chapter.manager.lineFragmentRect(forGlyphAt: safeGlyph, effectiveRange: nil).minY
return Anchor(
chapter: chapter.chapter.id,
position: min(max(character - chapter.bodyStart, 0), chapter.chapter.content.utf16.count),
inHeading: character < chapter.bodyStart,
screenY: layout.frames[row].minY + lineY - block.top - view.contentOffset.y
)
}
private func offset(for anchor: Anchor, blocks: [ReaderScrollBlock], frames: [CGRect]) -> CGFloat? {
guard let chapter = blocks.first(where: { $0.chapter.chapter.id == anchor.chapter })?.chapter else { return nil }
let character = anchor.inHeading ? 0 : ReaderPositionResolver.renderedLocation(
position: anchor.position, prefixLength: chapter.bodyStart, renderedLength: chapter.storage.length
)
let glyph = chapter.manager.glyphIndexForCharacter(at: character)
guard let row = blocks.firstIndex(where: {
$0.chapter.chapter.id == anchor.chapter && NSLocationInRange(glyph, $0.glyphRange)
}) else { return nil }
let lineY = chapter.manager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil).minY
return frames[row].minY + lineY - blocks[row].top - anchor.screenY
}
private func seek(chapter: Int, position: Int, includeHeading: Bool) {
guard let view else { return }
let layout = view.collectionViewLayout as! ReaderContinuousLayout
let anchor = Anchor(chapter: chapter, position: max(position, 0), inHeading: includeHeading, screenY: 0)
guard let y = offset(for: anchor, blocks: blocks, frames: layout.frames) else { return }
let maximum = max(layout.collectionViewContentSize.height - view.bounds.height, 0)
view.setContentOffset(CGPoint(x: 0, y: min(max(y, 0), maximum)), animated: false)
}
@objc private func tapped(_ gesture: UITapGestureRecognizer) {
guard let view, !view.isDragging, !view.isDecelerating else { return }
let point = gesture.location(in: view)
if let row = (view.collectionViewLayout as? ReaderContinuousLayout)?.row(at: point.y),
blocks[row].chapter.chapter.error != nil {
parent.configuration.onTapAction(0)
return
}
let x = point.x - view.bounds.minX, y = point.y - view.bounds.minY
let column = min(max(Int(x / max(view.bounds.width / 3, 1)), 0), 2)
let row = min(max(Int(y / max(view.bounds.height / 3, 1)), 0), 2)
let index = row * 3 + column
let actions = parent.configuration.clickActions
let action = index == 4 ? 0 : (actions.indices.contains(index) ? actions[index] : 0)
if action != 1, action != 2, action != -1 { parent.configuration.onTapAction(action) }
}
@objc private func step(_ link: CADisplayLink) {
defer { lastTimestamp = link.timestamp }
guard let view, lastTimestamp > 0, parent.configuration.isAutoReading,
!parent.configuration.isAutoReadPaused, !view.isDragging, !view.isDecelerating else { return }
let elapsed = min(link.timestamp - lastTimestamp, 0.1)
let maximum = max(view.contentSize.height - view.bounds.height, 0)
let next = min(view.contentOffset.y + view.bounds.height / CGFloat(max(parent.configuration.secondsPerScreen, 1)) * elapsed, maximum)
if next > view.contentOffset.y { view.setContentOffset(CGPoint(x: 0, y: next), animated: false) }
if next >= maximum - 0.5, !reachedEnd {
reachedEnd = true
parent.configuration.onReachedEnd(parent.configuration.readerPosition)
}
}
}
}
@MainActor
final class ReaderScrollCollectionView: UICollectionView {
var viewportChanged: (() -> Void)?
let loadingLabel = UILabel()
private var viewportSize = CGSize.zero
override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) {
super.init(frame: frame, collectionViewLayout: layout)
loadingLabel.font = .systemFont(ofSize: 12)
loadingLabel.textAlignment = .center
loadingLabel.isUserInteractionEnabled = false
addSubview(loadingLabel)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func layoutSubviews() {
super.layoutSubviews()
positionLoadingLabel()
if viewportSize != bounds.size {
viewportSize = bounds.size
viewportChanged?()
}
}
func positionLoadingLabel() {
loadingLabel.frame = CGRect(x: 0, y: bounds.maxY - 28, width: bounds.width, height: 24)
bringSubviewToFront(loadingLabel)
}
}
@MainActor
final class ReaderContinuousLayout: UICollectionViewLayout {
private(set) var frames: [CGRect] = []
private var size = CGSize.zero
override var collectionViewContentSize: CGSize { size }
static func geometry(blocks: [ReaderScrollBlock], width: CGFloat, top: CGFloat, bottom: CGFloat, viewportHeight: CGFloat) -> (frames: [CGRect], size: CGSize) {
var y = top
let frames = blocks.map { block -> CGRect in
let frame = CGRect(x: 0, y: y, width: width, height: block.height)
y += block.height
return frame
}
let finalChapter = blocks.last?.chapter.chapter.id
let finalStart = blocks.indices.first(where: { blocks[$0].chapter.chapter.id == finalChapter })
.map { frames[$0].minY } ?? top
return (frames, CGSize(width: width, height: max(y, finalStart + viewportHeight) + bottom))
}
func install(frames: [CGRect], size: CGSize, correction: CGFloat) {
let context = UICollectionViewLayoutInvalidationContext()
context.contentOffsetAdjustment = CGPoint(x: 0, y: correction)
context.contentSizeAdjustment = CGSize(width: size.width - self.size.width, height: size.height - self.size.height)
self.frames = frames
self.size = size
invalidateLayout(with: context)
}
func row(at y: CGFloat) -> Int? {
guard !frames.isEmpty else { return nil }
var low = 0, high = frames.count
while low < high {
let middle = (low + high) / 2
if frames[middle].maxY <= y { low = middle + 1 } else { high = middle }
}
return min(low, frames.count - 1)
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let start = row(at: rect.minY) else { return [] }
var result: [UICollectionViewLayoutAttributes] = []
for row in start..<frames.count {
if frames[row].minY > rect.maxY { break }
if let attributes = layoutAttributesForItem(at: IndexPath(item: row, section: 0)) { result.append(attributes) }
}
return result
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
guard frames.indices.contains(indexPath.item) else { return nil }
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attributes.frame = frames[indexPath.item]
return attributes
}
}
@MainActor
struct ReaderScrollBlock {
struct ID: Hashable { let chapter: Int; let firstGlyph: Int }
let chapter: ReaderScrollChapterLayout
let glyphRange: NSRange
let top: CGFloat
let height: CGFloat
var id: ID { .init(chapter: chapter.chapter.id, firstGlyph: glyphRange.location) }
}
@MainActor
final class ReaderScrollChapterLayout {
let chapter: ReaderScrollChapter
let storage: NSTextStorage
let manager = NSLayoutManager()
let container: NSTextContainer
let bodyStart: Int
let left: CGFloat
private var fragments: [(range: NSRange, top: CGFloat, height: CGFloat)] = []
var blocks: [ReaderScrollBlock] {
fragments.map { .init(chapter: self, glyphRange: $0.range, top: $0.top, height: $0.height) }
}
init(chapter: ReaderScrollChapter, configuration: ReaderTextView, width: CGFloat) {
self.chapter = chapter
let typography = ReaderTypography(configuration: configuration)
left = CGFloat(configuration.paddingLeft) + 4
let textWidth = max(width - left - CGFloat(configuration.paddingRight) - 4, 1)
container = NSTextContainer(size: CGSize(width: textWidth, height: .greatestFiniteMagnitude))
container.lineFragmentPadding = 0
let attributed = NSMutableAttributedString(string: "")
if configuration.titleMode != 2, !chapter.title.isEmpty {
attributed.append(NSAttributedString(string: chapter.title + "\n", attributes: typography.headingAttributes))
}
bodyStart = attributed.length
let body = chapter.error.map { "本章加载失败\n\n\($0)\n\n轻点打开阅读菜单。\n" } ?? (chapter.content.isEmpty ? "本章暂无正文\n" : chapter.content)
attributed.append(NSAttributedString(string: body, attributes: typography.bodyAttributes))
storage = NSTextStorage(attributedString: attributed)
storage.addLayoutManager(manager)
manager.addTextContainer(container)
manager.ensureLayout(for: container)
var startGlyph = 0
var top: CGFloat = 0
let count = manager.numberOfGlyphs
manager.enumerateLineFragments(forGlyphRange: NSRange(location: 0, length: count)) { rect, _, _, range, _ in
if rect.minY - top >= 1000, range.location > startGlyph {
self.fragments.append((NSRange(location: startGlyph, length: range.location - startGlyph),
top, rect.minY - top))
startGlyph = range.location
top = rect.minY
}
}
let bottom = manager.usedRect(for: container).maxY + CGFloat(configuration.fontSize * 1.4)
fragments.append((NSRange(location: startGlyph, length: count - startGlyph), top, max(bottom - top, 1)))
}
}
@MainActor
private final class ReaderScrollCell: UICollectionViewCell {
let canvas = ReaderScrollCanvas()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
contentView.backgroundColor = .clear
contentView.addSubview(canvas)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func layoutSubviews() { super.layoutSubviews(); canvas.frame = contentView.bounds }
override func prepareForReuse() { super.prepareForReuse(); canvas.block = nil }
}
@MainActor
private final class ReaderScrollCanvas: UIView {
var block: ReaderScrollBlock? {
didSet {
if let block {
let range = block.chapter.manager.characterRange(forGlyphRange: block.glyphRange, actualGlyphRange: nil)
accessibilityLabel = block.chapter.storage.attributedSubstring(from: range).string
} else { accessibilityLabel = nil }
setNeedsDisplay()
}
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isOpaque = false
isAccessibilityElement = true
accessibilityTraits = .staticText
contentMode = .redraw
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func draw(_ rect: CGRect) {
guard let block else { return }
let origin = CGPoint(x: block.chapter.left, y: -block.top)
block.chapter.manager.drawBackground(forGlyphRange: block.glyphRange, at: origin)
block.chapter.manager.drawGlyphs(forGlyphRange: block.glyphRange, at: origin)
}
}
@MainActor
struct ReaderTypography {
let bodyAttributes: [NSAttributedString.Key: Any]
let headingAttributes: [NSAttributedString.Key: Any]
init(configuration c: ReaderTextView) {
let weight: UIFont.Weight = switch c.fontWeight { case 1: .bold; case 2: .light; default: .regular }
let font = ReaderFontStore.font(at: c.fontPath, size: CGFloat(c.fontSize), weight: c.fontWeight)
?? (c.fontWeight == 0 ? (UIFont(name: "STSongti-SC-Regular", size: CGFloat(c.fontSize))
?? UIFont.systemFont(ofSize: CGFloat(c.fontSize))) : UIFont.systemFont(ofSize: CGFloat(c.fontSize), weight: weight))
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = CGFloat(c.lineSpacing)
paragraph.paragraphSpacing = CGFloat(c.paragraphSpacing)
paragraph.firstLineHeadIndent = (c.paragraphIndent as NSString).size(withAttributes: [
.font: font, .kern: CGFloat(c.letterSpacing * c.fontSize)
]).width
paragraph.alignment = c.fullJustify ? .justified : .natural
var body: [NSAttributedString.Key: Any] = [
.font: font, .kern: CGFloat(c.letterSpacing * c.fontSize),
.foregroundColor: c.foregroundColor, .paragraphStyle: paragraph
]
if c.underlineMode != 0 {
var underline = NSUnderlineStyle.single
if c.underlineMode == 2 { underline.insert(.patternDash) }
body[.underlineStyle] = underline.rawValue
body[.underlineColor] = c.foregroundColor
}
bodyAttributes = body
let headingFont = ReaderFontStore.font(at: c.fontPath, size: CGFloat(max(5, c.fontSize + c.titleSize)), weight: c.fontWeight)
?? UIFont.systemFont(ofSize: CGFloat(max(5, c.fontSize + c.titleSize)), weight: weight)
let heading = NSMutableParagraphStyle()
heading.alignment = c.titleMode == 1 ? .center : .left
heading.paragraphSpacingBefore = CGFloat(c.titleTopSpacing)
heading.paragraphSpacing = CGFloat(c.titleBottomSpacing)
headingAttributes = [.font: headingFont, .foregroundColor: c.foregroundColor, .paragraphStyle: heading]
}
}
extension ReaderTextView {
var scrollTypographyKey: String {
"\(fontSize)|\(fontPath)|\(letterSpacing)|\(lineSpacing)|\(paragraphSpacing)|\(paragraphIndent)|\(paddingLeft)|\(paddingRight)|\(paddingTop)|\(paddingBottom)|\(fontWeight)|\(titleMode)|\(titleSize)|\(titleTopSpacing)|\(titleBottomSpacing)|\(underlineMode)|\(fullJustify)|\(foregroundColor.hash)"
}
}
@@ -0,0 +1,643 @@
@preconcurrency import AVFoundation
@preconcurrency import MediaPlayer
import SwiftUI
import UniformTypeIdentifiers
private final class IncrementalAudioResourceLoader: NSObject, AVAssetResourceLoaderDelegate, @unchecked Sendable {
private let queue = DispatchQueue(label: "io.legado.tts.stream-loader")
private let contentType: String?
private let expectedLength: Int64
private var data = Data()
private var pending: [AVAssetResourceLoadingRequest] = []
private var finished = false
private var failure: Error?
private var consumeTask: Task<Void, Never>?
init(stream: HttpTTSAudioStream) {
contentType = stream.contentType
expectedLength = stream.expectedContentLength ?? 0
super.init()
consumeTask = Task { [weak self] in
do {
for try await chunk in stream.chunks {
guard let self else { return }
self.queue.async { [weak self] in
self?.data.append(chunk)
self?.processPendingRequests()
}
}
self?.queue.async { [weak self] in
self?.finished = true
self?.processPendingRequests()
}
} catch {
self?.queue.async { [weak self] in
self?.failure = error
self?.finished = true
self?.processPendingRequests()
}
}
}
}
deinit { consumeTask?.cancel() }
func cancel() {
consumeTask?.cancel()
queue.async { [weak self] in
guard let self, !finished else { return }
failure = CancellationError()
finished = true
processPendingRequests()
}
}
func install(on asset: AVURLAsset) {
asset.resourceLoader.setDelegate(self, queue: queue)
}
func resourceLoader(
_ resourceLoader: AVAssetResourceLoader,
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
) -> Bool {
pending.append(loadingRequest)
processPendingRequests()
return true
}
func resourceLoader(
_ resourceLoader: AVAssetResourceLoader,
didCancel loadingRequest: AVAssetResourceLoadingRequest
) {
pending.removeAll { $0 === loadingRequest }
}
private func processPendingRequests() {
pending.removeAll { request in
if let info = request.contentInformationRequest {
info.contentType = contentType.flatMap { UTType(mimeType: $0)?.identifier }
info.contentLength = expectedLength
info.isByteRangeAccessSupported = false
}
if let failure {
request.finishLoading(with: failure)
return true
}
guard let dataRequest = request.dataRequest else {
if finished { request.finishLoading(); return true }
return false
}
let offset = max(Int(dataRequest.currentOffset), Int(dataRequest.requestedOffset))
if offset < data.count {
let requestedEnd = Int(dataRequest.requestedOffset) + dataRequest.requestedLength
let end = min(data.count, requestedEnd)
if end > offset { dataRequest.respond(with: data.subdata(in: offset..<end)) }
}
let deliveredEnd = max(Int(dataRequest.currentOffset), Int(dataRequest.requestedOffset))
let requestedEnd = Int(dataRequest.requestedOffset) + dataRequest.requestedLength
if deliveredEnd >= requestedEnd || finished {
request.finishLoading()
return true
}
return false
}
}
}
@MainActor
final class ReaderSpeechController: NSObject, ObservableObject, @preconcurrency AVSpeechSynthesizerDelegate, @preconcurrency AVAudioPlayerDelegate {
@Published private(set) var isSpeaking = false
@Published private(set) var isPaused = false
@Published private(set) var errorMessage: String?
@Published private(set) var completionCount = 0
@Published private(set) var currentSegmentIndex = 0
private let synthesizer = AVSpeechSynthesizer()
private let httpService = HttpTTSService()
private var audioPlayer: AVAudioPlayer?
private var streamPlayer: AVPlayer?
private var streamLoader: IncrementalAudioResourceLoader?
private var streamCompletionToken: NSObjectProtocol?
private var httpTask: Task<Void, Never>?
private var httpPrefetchTasks: [Task<Void, Never>] = []
private var httpSegments: [String] = []
private var httpSegmentIndex = 0
private var httpEngine: HttpTTS?
private var systemSegments: [String] = []
private var systemSegmentIndex = 0
private var segmentIndexOffset = 0
private var followsSystemRate = true
private var speechRate = 5
private var readByPage = false
private var streamsHTTPAudio = true
private var voiceIdentifier = ""
private var continuesAcrossChapters = false
private var notificationTokens: [NSObjectProtocol] = []
nonisolated(unsafe) private var remoteCommandTargets: [(MPRemoteCommand, Any)] = []
private var resumesAfterInterruption = false
private var isChangingSystemSegment = false
private var ignoresAudioFocus = false
private var pausesForPhoneCalls = false
private var mediaButtonPerNext = false
private var bookName = ""
private var chapterTitle = ""
private var author = ""
var onNextChapter: (() -> Void)?
var onPreviousChapter: (() -> Void)?
var onRemoteStop: (() -> Void)?
override init() {
super.init()
synthesizer.delegate = self
observeAudioSession()
installRemoteCommands()
}
deinit {
notificationTokens.forEach(NotificationCenter.default.removeObserver)
remoteCommandTargets.forEach { command, target in command.removeTarget(target) }
}
func toggle(text: String, continuesAcrossChapters: Bool = false) {
if isSpeaking || isPaused {
stop()
return
}
speak(text, continuesAcrossChapters: continuesAcrossChapters)
}
func toggleHTTP(engine: HttpTTS, text: String, continuesAcrossChapters: Bool = false) {
if isSpeaking || isPaused {
stop()
return
}
speakHTTP(engine: engine, text: text, continuesAcrossChapters: continuesAcrossChapters)
}
func stop() {
synthesizer.stopSpeaking(at: .word)
audioPlayer?.stop()
audioPlayer = nil
streamPlayer?.pause()
streamLoader?.cancel()
streamPlayer = nil
streamLoader = nil
if let streamCompletionToken {
NotificationCenter.default.removeObserver(streamCompletionToken)
self.streamCompletionToken = nil
}
httpTask?.cancel()
httpTask = nil
httpPrefetchTasks.forEach { $0.cancel() }
httpPrefetchTasks.removeAll()
httpSegments = []
httpSegmentIndex = 0
httpEngine = nil
systemSegments = []
systemSegmentIndex = 0
segmentIndexOffset = 0
continuesAcrossChapters = false
isSpeaking = false
isPaused = false
updateNowPlaying()
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
func configure(
followsSystemRate: Bool,
speechRate: Int,
readByPage: Bool,
streamsHTTPAudio: Bool = true,
voiceIdentifier: String = "",
ignoresAudioFocus: Bool = false,
pausesForPhoneCalls: Bool = false,
mediaButtonPerNext: Bool = false,
bookName: String = "",
chapterTitle: String = "",
author: String = ""
) {
self.followsSystemRate = followsSystemRate
self.speechRate = min(max(speechRate, 0), 45)
self.readByPage = readByPage
self.streamsHTTPAudio = streamsHTTPAudio
self.voiceIdentifier = voiceIdentifier
self.ignoresAudioFocus = ignoresAudioFocus
self.pausesForPhoneCalls = pausesForPhoneCalls
self.mediaButtonPerNext = mediaButtonPerNext
self.bookName = bookName
self.chapterTitle = chapterTitle
self.author = author
}
func speak(_ text: String, continuesAcrossChapters: Bool = false, segmentIndexOffset: Int = 0) {
stop()
errorMessage = nil
activateAudioSession()
self.continuesAcrossChapters = continuesAcrossChapters
if synthesizer.isSpeaking || synthesizer.isPaused {
synthesizer.stopSpeaking(at: .immediate)
}
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
systemSegments = segments(for: text)
guard !systemSegments.isEmpty else { return }
systemSegmentIndex = 0
self.segmentIndexOffset = max(segmentIndexOffset, 0)
playCurrentSystemSegment()
}
func speakHTTP(
engine: HttpTTS,
text: String,
continuesAcrossChapters: Bool = false,
segmentIndexOffset: Int = 0
) {
stop()
errorMessage = nil
activateAudioSession()
self.continuesAcrossChapters = continuesAcrossChapters
httpEngine = engine
httpSegments = segments(for: text)
guard !httpSegments.isEmpty else { return }
httpSegmentIndex = 0
self.segmentIndexOffset = max(segmentIndexOffset, 0)
isSpeaking = true
playCurrentHTTPSegment()
prefetchHTTP(engine: engine, texts: Array(httpSegments.dropFirst()))
}
func prefetchHTTP(engine: HttpTTS, text: String, limit: Int = 10) {
let values = Array(segments(for: text).prefix(max(limit, 0)))
prefetchHTTP(engine: engine, texts: values)
}
func pauseOrResume(text: String, continuesAcrossChapters: Bool = false) {
if isPaused {
resumePlayback()
} else if isSpeaking {
pausePlayback()
} else {
speak(text, continuesAcrossChapters: continuesAcrossChapters)
}
}
func pauseOrResumeHTTP(engine: HttpTTS, text: String, continuesAcrossChapters: Bool = false) {
if audioPlayer != nil {
pauseOrResume(text: text, continuesAcrossChapters: continuesAcrossChapters)
} else if isSpeaking {
httpTask?.cancel()
isSpeaking = false
isPaused = true
} else if isPaused, httpEngine != nil {
isPaused = false
isSpeaking = true
playCurrentHTTPSegment()
} else {
speakHTTP(engine: engine, text: text, continuesAcrossChapters: continuesAcrossChapters)
}
}
private func segments(for text: String) -> [String] {
let values = readByPage ? [text] : text.components(separatedBy: .newlines)
return values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
}
private func activateAudioSession() {
let session = AVAudioSession.sharedInstance()
try? session.setCategory(.playback, mode: .spokenAudio, options: ignoresAudioFocus ? [.mixWithOthers] : [])
try? session.setActive(true)
}
private func pausePlayback() {
if let audioPlayer, audioPlayer.isPlaying {
audioPlayer.pause()
} else if let streamPlayer {
streamPlayer.pause()
} else if synthesizer.isSpeaking {
synthesizer.pauseSpeaking(at: .word)
} else if httpTask != nil {
httpTask?.cancel()
httpTask = nil
} else {
return
}
isPaused = true
isSpeaking = false
updateNowPlaying()
}
private func resumePlayback() {
activateAudioSession()
if let audioPlayer {
isSpeaking = audioPlayer.play()
} else if let streamPlayer {
streamPlayer.play()
isSpeaking = true
} else if synthesizer.isPaused {
synthesizer.continueSpeaking()
isSpeaking = true
} else if httpEngine != nil, httpSegments.indices.contains(httpSegmentIndex) {
isSpeaking = true
playCurrentHTTPSegment()
} else {
return
}
isPaused = false
updateNowPlaying()
}
private func observeAudioSession() {
let center = NotificationCenter.default
notificationTokens.append(center.addObserver(
forName: AVAudioSession.interruptionNotification,
object: AVAudioSession.sharedInstance(),
queue: .main
) { [weak self] note in
let type = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt
let options = note.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
Task { @MainActor in self?.handleAudioInterruption(type: type, options: options) }
})
notificationTokens.append(center.addObserver(
forName: AVAudioSession.routeChangeNotification,
object: AVAudioSession.sharedInstance(),
queue: .main
) { [weak self] note in
let reason = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt
Task { @MainActor in self?.handleRouteChange(reason: reason) }
})
}
private func handleAudioInterruption(type raw: UInt?, options rawOptions: UInt) {
guard let raw,
let type = AVAudioSession.InterruptionType(rawValue: raw) else { return }
switch type {
case .began:
if ignoresAudioFocus && !pausesForPhoneCalls { return }
resumesAfterInterruption = isSpeaking
if isSpeaking { pausePlayback() }
case .ended:
if ignoresAudioFocus && !pausesForPhoneCalls { return }
let options = AVAudioSession.InterruptionOptions(rawValue: rawOptions)
if resumesAfterInterruption, options.contains(.shouldResume) { resumePlayback() }
resumesAfterInterruption = false
@unknown default:
break
}
}
private func handleRouteChange(reason raw: UInt?) {
guard let raw,
AVAudioSession.RouteChangeReason(rawValue: raw) == .oldDeviceUnavailable else { return }
if isSpeaking { pausePlayback() }
}
private func installRemoteCommands() {
let center = MPRemoteCommandCenter.shared()
center.playCommand.isEnabled = true
center.pauseCommand.isEnabled = true
center.togglePlayPauseCommand.isEnabled = true
center.stopCommand.isEnabled = true
center.nextTrackCommand.isEnabled = true
center.previousTrackCommand.isEnabled = true
addRemoteTarget(center.playCommand) { $0.resumePlayback() }
addRemoteTarget(center.pauseCommand) { $0.pausePlayback() }
addRemoteTarget(center.togglePlayPauseCommand) { controller in
controller.isPaused ? controller.resumePlayback() : controller.pausePlayback()
}
addRemoteTarget(center.stopCommand) { controller in
controller.stop()
controller.onRemoteStop?()
}
addRemoteTarget(center.nextTrackCommand) { $0.skipForward(usesChapterPreference: true) }
addRemoteTarget(center.previousTrackCommand) { $0.skipBackward(usesChapterPreference: true) }
}
private func addRemoteTarget(_ command: MPRemoteCommand, action: @escaping @MainActor (ReaderSpeechController) -> Void) {
let target = command.addTarget { [weak self] _ in
guard self != nil else { return .noSuchContent }
Task { @MainActor [weak self] in
guard let self else { return }
action(self)
}
return .success
}
remoteCommandTargets.append((command, target))
}
func nextSegment() {
guard isSpeaking || isPaused else { return }
skipForward(usesChapterPreference: false)
}
func previousSegment() {
guard isSpeaking || isPaused else { return }
skipBackward(usesChapterPreference: false)
}
private func playCurrentSystemSegment() {
guard systemSegments.indices.contains(systemSegmentIndex) else {
finishNaturally()
return
}
let utterance = AVSpeechUtterance(string: systemSegments[systemSegmentIndex])
utterance.voice = AVSpeechSynthesisVoice(identifier: voiceIdentifier)
?? AVSpeechSynthesisVoice(language: "zh-CN")
let multiplier = followsSystemRate ? 1 : Float(speechRate + 5) / 10
utterance.rate = min(
max(AVSpeechUtteranceDefaultSpeechRate * multiplier, AVSpeechUtteranceMinimumSpeechRate),
AVSpeechUtteranceMaximumSpeechRate
)
utterance.pitchMultiplier = 1
synthesizer.speak(utterance)
currentSegmentIndex = segmentIndexOffset + systemSegmentIndex
isSpeaking = true
isPaused = false
updateNowPlaying()
}
private func skipForward(usesChapterPreference: Bool) {
if usesChapterPreference && mediaButtonPerNext {
onNextChapter?()
} else if httpEngine != nil, httpSegmentIndex + 1 < httpSegments.count {
stopCurrentHTTPPlayback()
httpSegmentIndex += 1
playCurrentHTTPSegment()
} else if !systemSegments.isEmpty, systemSegmentIndex + 1 < systemSegments.count {
changeSystemSegment(to: systemSegmentIndex + 1)
} else {
onNextChapter?()
}
}
private func skipBackward(usesChapterPreference: Bool) {
if usesChapterPreference && mediaButtonPerNext {
onPreviousChapter?()
} else if httpEngine != nil, httpSegmentIndex > 0 {
stopCurrentHTTPPlayback()
httpSegmentIndex -= 1
playCurrentHTTPSegment()
} else if !systemSegments.isEmpty, systemSegmentIndex > 0 {
changeSystemSegment(to: systemSegmentIndex - 1)
} else {
onPreviousChapter?()
}
}
private func stopCurrentHTTPPlayback() {
audioPlayer?.stop()
audioPlayer = nil
streamPlayer?.pause()
streamLoader?.cancel()
streamPlayer = nil
streamLoader = nil
if let streamCompletionToken {
NotificationCenter.default.removeObserver(streamCompletionToken)
self.streamCompletionToken = nil
}
httpTask?.cancel()
httpTask = nil
}
private func changeSystemSegment(to index: Int) {
isChangingSystemSegment = true
synthesizer.stopSpeaking(at: .immediate)
systemSegmentIndex = index
playCurrentSystemSegment()
DispatchQueue.main.async { [weak self] in self?.isChangingSystemSegment = false }
}
private func updateNowPlaying() {
guard isSpeaking || isPaused else {
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
return
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = [
MPMediaItemPropertyTitle: chapterTitle,
MPMediaItemPropertyAlbumTitle: bookName,
MPMediaItemPropertyArtist: author,
MPNowPlayingInfoPropertyPlaybackRate: isSpeaking ? 1 : 0
]
}
private func playCurrentHTTPSegment() {
guard let engine = httpEngine, httpSegments.indices.contains(httpSegmentIndex) else {
stop()
return
}
let text = httpSegments[httpSegmentIndex]
currentSegmentIndex = segmentIndexOffset + httpSegmentIndex
let rate = speechRate + 5
httpTask?.cancel()
httpTask = Task { [weak self] in
guard let self else { return }
do {
if streamsHTTPAudio {
let stream = try await httpService.audioStream(engine: engine, text: text, speechRate: rate)
try Task.checkCancellation()
if stream.isIncremental, stream.expectedContentLength ?? 0 > 0 {
playIncrementalHTTPStream(stream)
} else {
var data = Data()
for try await chunk in stream.chunks { data.append(chunk) }
try Task.checkCancellation()
try playBufferedHTTPAudio(data)
}
} else {
let audio = try await httpService.audio(engine: engine, text: text, speechRate: rate)
try Task.checkCancellation()
try playBufferedHTTPAudio(audio.data)
}
isPaused = false
updateNowPlaying()
if !isSpeaking { throw CocoaError(.fileReadCorruptFile) }
} catch is CancellationError {
return
} catch {
errorMessage = error.localizedDescription
stop()
}
}
}
private func playBufferedHTTPAudio(_ data: Data) throws {
let player = try AVAudioPlayer(data: data)
player.delegate = self
player.prepareToPlay()
audioPlayer = player
isSpeaking = player.play()
}
private func playIncrementalHTTPStream(_ stream: HttpTTSAudioStream) {
let url = URL(string: "legado-tts-stream://audio/\(UUID().uuidString)")!
let asset = AVURLAsset(url: url)
let loader = IncrementalAudioResourceLoader(stream: stream)
loader.install(on: asset)
let item = AVPlayerItem(asset: asset)
let player = AVPlayer(playerItem: item)
streamLoader = loader
streamPlayer = player
streamCompletionToken = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime,
object: item,
queue: .main
) { [weak self] _ in
Task { @MainActor in self?.incrementalPlayerDidFinish() }
}
player.play()
isSpeaking = true
}
private func incrementalPlayerDidFinish() {
stopCurrentHTTPPlayback()
httpSegmentIndex += 1
if httpSegmentIndex < httpSegments.count { playCurrentHTTPSegment() }
else { finishNaturally() }
}
private func prefetchHTTP(engine: HttpTTS, texts: [String]) {
guard !texts.isEmpty else { return }
let rate = speechRate + 5
let task = Task { [weak self] in
guard let self else { return }
for text in texts {
guard !Task.isCancelled else { return }
_ = try? await self.httpService.audio(engine: engine, text: text, speechRate: rate)
}
}
httpPrefetchTasks.append(task)
}
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
audioPlayer = nil
guard flag else {
errorMessage = "HTTP TTS 音频播放失败"
stop()
return
}
httpSegmentIndex += 1
if httpSegmentIndex < httpSegments.count {
playCurrentHTTPSegment()
} else {
finishNaturally()
}
}
private func finishNaturally() {
let shouldContinue = continuesAcrossChapters
stop()
if shouldContinue { completionCount += 1 }
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
guard !isChangingSystemSegment else { return }
systemSegmentIndex += 1
if systemSegmentIndex < systemSegments.count { playCurrentSystemSegment() }
else { finishNaturally() }
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
guard !isChangingSystemSegment else { return }
guard !synthesizer.isSpeaking else { return }
isSpeaking = false
isPaused = false
updateNowPlaying()
}
}
+88
View File
@@ -0,0 +1,88 @@
import SwiftUI
import UIKit
struct ReaderInteractivePopEnabler: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> Controller {
Controller()
}
func updateUIViewController(_ uiViewController: Controller, context: Context) {
uiViewController.enableInteractivePopIfAvailable()
}
static func dismantleUIViewController(_ uiViewController: Controller, coordinator: Void) {
uiViewController.restoreInteractivePopDelegate()
}
final class Controller: UIViewController {
private weak var popGesture: UIGestureRecognizer?
private var previousDelegate: (any UIGestureRecognizerDelegate)?
private var releasedDelegate = false
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
enableInteractivePopIfAvailable()
}
func enableInteractivePopIfAvailable() {
guard let navigationController,
navigationController.viewControllers.count > 1,
let gesture = navigationController.interactivePopGestureRecognizer else { return }
if gesture.delegate != nil {
previousDelegate = gesture.delegate
gesture.delegate = nil
releasedDelegate = true
}
gesture.isEnabled = true
popGesture = gesture
}
func restoreInteractivePopDelegate() {
guard releasedDelegate, let popGesture, popGesture.delegate == nil else { return }
popGesture.delegate = previousDelegate
previousDelegate = nil
releasedDelegate = false
}
}
}
struct ReaderMenuDismissShape: Shape {
var leadingExclusion: CGFloat = 24
func path(in rect: CGRect) -> Path {
Path(CGRect(
x: min(max(leadingExclusion, 0), rect.width),
y: 0,
width: max(rect.width - leadingExclusion, 0),
height: rect.height
))
}
}
@MainActor enum ReaderScreenOrientation {
static private(set) var supportedOrientations: UIInterfaceOrientationMask = .allButUpsideDown
static func apply(_ value: String) {
let mask: UIInterfaceOrientationMask = switch value {
case "1": .portrait
case "2": .landscape
case "3": .all
case "4": .portraitUpsideDown
case "5": .landscapeLeft
default: .allButUpsideDown
}
supportedOrientations = mask
guard let scene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first(where: { $0.activationState == .foregroundActive }) else { return }
if #available(iOS 16.0, *) {
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { _ in }
scene.windows.first?.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
} else {
UIViewController.attemptRotationToDeviceOrientation()
}
}
static func restoreApplicationDefault() { apply("0") }
}
+936
View File
@@ -0,0 +1,936 @@
import SwiftUI
import UIKit
private final class ReaderLayoutTextView: UITextView {
var onLayout: ((UITextView) -> Void)?
private var reportedSize = CGSize.zero
private var reportedContentSize = CGSize.zero
override func layoutSubviews() {
super.layoutSubviews()
guard bounds.width > 0, bounds.height > 0,
bounds.size != reportedSize || contentSize != reportedContentSize else { return }
reportedSize = bounds.size
reportedContentSize = contentSize
onLayout?(self)
}
}
struct ReaderTextView: UIViewRepresentable {
let text: String
let title: String
let previousTitle: String?
let previousText: String
let isPreviousReady: Bool
let continuationTitle: String?
let continuationText: String
let isContinuationReady: Bool
let isVolume: Bool
let fontSize: Double
let fontPath: String
let letterSpacing: Double
let lineSpacing: Double
let paragraphSpacing: Double
let paragraphIndent: String
let paddingLeft: Double
let paddingRight: Double
let paddingTop: Double
let paddingBottom: Double
let headerInset: Double
let footerInset: Double
let fontWeight: Int
let titleMode: Int
let titleSize: Double
let titleTopSpacing: Double
let titleBottomSpacing: Double
let underlineMode: Int
let isSelectable: Bool
let fullJustify: Bool
let pageAnimation: Int
let isEInkMode: Bool
let clickActions: [Int]
let backgroundColor: UIColor
let foregroundColor: UIColor
let requestedPosition: Int
let jumpPosition: Int?
let renderRevision: Int
@Binding var readerPosition: Int
@Binding var pageRanges: [NSRange]
@Binding var currentPage: Int
@Binding var totalPages: Int
let isAutoReading: Bool
let isAutoReadPaused: Bool
let isLoadingNextChapter: Bool
let secondsPerScreen: Double
let onReachedEnd: (Int) -> Void
let onReachedStart: (Int) -> Void
let onEnteredPrevious: (Int) -> Void
let onEnteredContinuation: (Int) -> Void
let onDeferredRender: () -> Void
let onCancelNextLoading: () -> Void
let onPageChanged: () -> Void
let onTapAction: (Int) -> Void
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
func makeUIView(context: Context) -> UITextView {
// Pagination uses NSLayoutManager. Start in TextKit 1 rather than
// switching a TextKit 2 view after its content size has been computed.
let view: ReaderLayoutTextView
if #available(iOS 16.0, *) {
view = ReaderLayoutTextView(usingTextLayoutManager: false)
} else {
view = ReaderLayoutTextView(frame: .zero)
}
view.onLayout = { [weak coordinator = context.coordinator] view in
coordinator?.reportPage(in: view)
coordinator?.reportPageRanges(in: view)
}
view.accessibilityIdentifier = "reader.text"
let effectivePageAnimation = isEInkMode ? 4 : pageAnimation
view.backgroundColor = backgroundColor
view.isOpaque = backgroundColor != .clear
view.delegate = context.coordinator
view.isEditable = false
view.isSelectable = false
view.alwaysBounceVertical = effectivePageAnimation == 3
view.isScrollEnabled = true
view.panGestureRecognizer.isEnabled = effectivePageAnimation == 3
view.textContainer.lineFragmentPadding = 4
view.showsVerticalScrollIndicator = false
view.isPagingEnabled = effectivePageAnimation != 3
view.decelerationRate = effectivePageAnimation == 3 ? .normal : .fast
context.coordinator.installDisplayLink(for: view)
context.coordinator.installGestures(on: view)
context.coordinator.updateInteractionMode(on: view, isScrollMode: effectivePageAnimation == 3)
return view
}
func updateUIView(_ view: UITextView, context: Context) {
context.coordinator.parent = self
let viewportSizeChanged = view.bounds.size != .zero
&& context.coordinator.lastViewportSize != view.bounds.size
if viewportSizeChanged {
context.coordinator.lastViewportSize = view.bounds.size
}
let effectivePageAnimation = isEInkMode ? 4 : pageAnimation
view.isSelectable = false
view.alwaysBounceVertical = effectivePageAnimation == 3
view.isScrollEnabled = true
view.panGestureRecognizer.isEnabled = effectivePageAnimation == 3
view.showsVerticalScrollIndicator = false
view.isPagingEnabled = effectivePageAnimation != 3
view.decelerationRate = effectivePageAnimation == 3 ? .normal : .fast
context.coordinator.updateInteractionMode(on: view, isScrollMode: effectivePageAnimation == 3)
view.backgroundColor = backgroundColor
view.isOpaque = backgroundColor != .clear
view.isSelectable = false
view.selectedRange = NSRange(location: 0, length: 0)
view.resignFirstResponder()
view.textContainerInset = .init(
top: CGFloat(paddingTop + headerInset),
left: CGFloat(paddingLeft),
bottom: CGFloat(paddingBottom + footerInset),
right: CGFloat(paddingRight)
)
let contentKey = text.hashValue
let primaryContentKey = "\(title)|\(text)".hashValue
let showsContinuation = effectivePageAnimation == 3 && isContinuationReady && continuationTitle != nil
let showsPrevious = showsContinuation && isPreviousReady && previousTitle != nil
let adjacentContentKey = "\(showsPrevious)|\(previousTitle ?? "")|\(previousText)|\(showsContinuation)|\(continuationTitle ?? "")|\(continuationText)".hashValue
let displaysTitle = titleMode != 2 || isVolume
let titlePrefix = displaysTitle && !title.isEmpty ? title + "\n" : ""
let styleKey = "\(contentKey)|\(adjacentContentKey)|\(title)|\(isVolume)|\(fontSize)|\(fontPath)|\(letterSpacing)|\(lineSpacing)|\(paragraphSpacing)|\(paragraphIndent)|\(paddingLeft)|\(paddingRight)|\(paddingTop)|\(paddingBottom)|\(headerInset)|\(footerInset)|\(fontWeight)|\(titleMode)|\(titleSize)|\(titleTopSpacing)|\(titleBottomSpacing)|\(underlineMode)|\(isSelectable)|\(fullJustify)|\(effectivePageAnimation)|\(foregroundColor.hash)"
if context.coordinator.styleKey != styleKey {
if context.coordinator.shouldDeferRender(in: view) {
context.coordinator.needsDeferredRender = true
return
}
let primaryContentChanged = context.coordinator.primaryContentKey != primaryContentKey
let visiblePosition = context.coordinator.contentCharacter(at: view.contentOffset, in: view)
context.coordinator.resetAutoReadForPageChange()
context.coordinator.resetPageRangeRetry()
context.coordinator.contentKey = contentKey
context.coordinator.primaryContentKey = primaryContentKey
context.coordinator.styleKey = styleKey
let typography = ReaderTypography(configuration: self)
let bodyAttributes = typography.bodyAttributes
let rendered = NSMutableAttributedString(string: "")
context.coordinator.previousBodyStart = nil
context.coordinator.previousBodyEnd = nil
context.coordinator.continuationSectionStart = nil
context.coordinator.continuationBodyStart = nil
if showsPrevious {
let previousHeading = displaysTitle ? (previousTitle ?? "") + "\n" : ""
rendered.append(NSAttributedString(string: previousHeading, attributes: typography.headingAttributes))
context.coordinator.previousBodyStart = rendered.length
rendered.append(NSAttributedString(
string: previousText.isEmpty ? String(repeating: "\n", count: 80) : previousText,
attributes: bodyAttributes
))
context.coordinator.previousBodyEnd = rendered.length
rendered.append(NSAttributedString(string: "\n\n", attributes: bodyAttributes))
}
context.coordinator.currentSectionStart = rendered.length
if !titlePrefix.isEmpty {
rendered.append(NSAttributedString(string: titlePrefix, attributes: typography.headingAttributes))
}
context.coordinator.renderedPrefixLength = rendered.length
rendered.append(NSAttributedString(string: text, attributes: bodyAttributes))
context.coordinator.currentBodyEnd = rendered.length
if showsContinuation {
rendered.append(NSAttributedString(string: "\n\n", attributes: bodyAttributes))
context.coordinator.continuationSectionStart = rendered.length
let continuationHeading = displaysTitle ? (continuationTitle ?? "") + "\n" : ""
rendered.append(NSAttributedString(string: continuationHeading, attributes: typography.headingAttributes))
context.coordinator.continuationBodyStart = rendered.length
rendered.append(NSAttributedString(
string: continuationText.isEmpty ? String(repeating: "\n", count: 80) : continuationText,
attributes: bodyAttributes
))
}
context.coordinator.isApplyingContent = true
view.attributedText = rendered
context.coordinator.isApplyingContent = false
context.coordinator.scroll(
to: primaryContentChanged ? requestedPosition : visiblePosition,
in: view
)
let generation = context.coordinator.layoutGeneration
DispatchQueue.main.async { [weak coordinator = context.coordinator] in
guard coordinator?.layoutGeneration == generation else { return }
coordinator?.reportPage(in: view)
coordinator?.reportPageRanges(in: view)
}
} else if viewportSizeChanged {
context.coordinator.scroll(to: readerPosition, in: view)
let generation = context.coordinator.layoutGeneration
DispatchQueue.main.async { [weak coordinator = context.coordinator] in
guard coordinator?.layoutGeneration == generation else { return }
coordinator?.reportPage(in: view)
coordinator?.reportPageRanges(in: view)
}
}
if let jumpPosition, context.coordinator.lastJumpPosition != jumpPosition {
context.coordinator.lastJumpPosition = jumpPosition
context.coordinator.scroll(to: jumpPosition, in: view)
} else if jumpPosition == nil {
context.coordinator.lastJumpPosition = nil
}
context.coordinator.updateLoadingFooter(in: view, visible: isLoadingNextChapter)
context.coordinator.updateAutoRead(
isRunning: isAutoReading,
isPaused: isAutoReadPaused,
secondsPerScreen: secondsPerScreen
)
// SwiftUI can reset the bindings after the chapter's TextKit layout has
// already completed. Reconcile them even when the text/style is unchanged.
if view.bounds.width > 0, view.bounds.height > 0 {
context.coordinator.reportPage(in: view)
if pageRanges.isEmpty { context.coordinator.reportPageRanges(in: view) }
}
}
static func dismantleUIView(_ view: UITextView, coordinator: Coordinator) {
coordinator.invalidate()
}
final class Coordinator: NSObject, UITextViewDelegate, UIGestureRecognizerDelegate {
var parent: ReaderTextView
var contentKey = 0
var primaryContentKey = 0
var styleKey = ""
var renderedPrefixLength = 0
var currentSectionStart = 0
var currentBodyEnd = 0
var previousBodyStart: Int?
var previousBodyEnd: Int?
var continuationSectionStart: Int?
var continuationBodyStart: Int?
var lastJumpPosition: Int?
private(set) var layoutGeneration = 0
var lastViewportSize = CGSize.zero
private var isAutoReading = false
private var isAutoReadPaused = false
private var secondsPerScreen = 10.0
private weak var textView: UITextView?
private var displayLink: CADisplayLink?
private var lastTimestamp: CFTimeInterval = 0
private var isUserDragging = false
private var lastDragOffsetY: CGFloat = 0
private var dragDirection = 0
private var didReachEnd = false
private var didReachStart = false
private var didEnterPrevious = false
private var didEnterContinuation = false
private var pendingPreviousPosition: Int?
private var pendingContinuationPosition: Int?
var needsDeferredRender = false
var isApplyingContent = false
private var isTurningPage = false
private var isRenderingAutoPage = false
private var lastReportedPage: Int?
private var autoPageProgress: CGFloat = 0
private var eInkElapsed: CFTimeInterval = 0
private var eInkPageInterval: CFTimeInterval = 10
private var autoPageTarget: CGFloat?
private weak var autoPageOverlay: UIImageView?
private var pageRangeRetryCount = 0
private weak var nextSwipe: UISwipeGestureRecognizer?
private weak var previousSwipe: UISwipeGestureRecognizer?
private weak var readerTap: UITapGestureRecognizer?
private weak var loadingFooter: UIView?
private var didRequestLoadingCancellation = false
init(parent: ReaderTextView) { self.parent = parent }
func installDisplayLink(for view: UITextView) {
textView = view
let link = CADisplayLink(target: self, selector: #selector(step(_:)))
link.add(to: .main, forMode: .common)
displayLink = link
}
func installGestures(on view: UITextView) {
let tap = UITapGestureRecognizer(target: self, action: #selector(tapped(_:)))
tap.delaysTouchesBegan = false
tap.cancelsTouchesInView = false
tap.delegate = self
view.addGestureRecognizer(tap)
readerTap = tap
let next = UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:)))
next.direction = .left
view.addGestureRecognizer(next)
let previous = UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:)))
previous.direction = .right
view.addGestureRecognizer(previous)
nextSwipe = next
previousSwipe = previous
}
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
gestureRecognizer === readerTap || otherGestureRecognizer === readerTap
}
func updateInteractionMode(on view: UITextView, isScrollMode: Bool) {
// The scroll reader owns the vertical pan gesture. Horizontal
// swipe recognizers must be disabled there or UIKit can cancel the
// pan before the user reaches the end of a long chapter.
nextSwipe?.isEnabled = !isScrollMode
previousSwipe?.isEnabled = !isScrollMode
view.panGestureRecognizer.isEnabled = isScrollMode
view.alwaysBounceVertical = isScrollMode
view.isPagingEnabled = !isScrollMode
view.showsVerticalScrollIndicator = false
}
func updateLoadingFooter(in view: UITextView, visible: Bool) {
if !visible {
loadingFooter?.removeFromSuperview()
loadingFooter = nil
view.contentInset.bottom = 0
didRequestLoadingCancellation = false
return
}
let footer: UIView
if let loadingFooter {
footer = loadingFooter
} else {
let container = UIView()
container.isUserInteractionEnabled = false
let indicator = UIActivityIndicatorView(style: .medium)
indicator.color = parent.foregroundColor.withAlphaComponent(0.65)
indicator.startAnimating()
let label = UILabel()
label.text = "正在加载下一章…"
label.textColor = parent.foregroundColor.withAlphaComponent(0.65)
label.font = .systemFont(ofSize: 13)
let stack = UIStackView(arrangedSubviews: [indicator, label])
stack.axis = .horizontal
stack.spacing = 8
stack.alignment = .center
stack.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(stack)
NSLayoutConstraint.activate([
stack.centerXAnchor.constraint(equalTo: container.centerXAnchor),
stack.centerYAnchor.constraint(equalTo: container.centerYAnchor)
])
view.addSubview(container)
loadingFooter = container
footer = container
}
view.contentInset.bottom = 64
footer.frame = CGRect(x: 0, y: view.contentSize.height, width: view.bounds.width, height: 52)
}
func shouldDeferRender(in view: UITextView) -> Bool {
parent.pageAnimation == 3 && (isUserDragging || view.isDragging || view.isDecelerating)
}
func invalidate() {
layoutGeneration &+= 1
resetAutoPage()
displayLink?.invalidate()
displayLink = nil
}
func updateAutoRead(isRunning: Bool, isPaused: Bool, secondsPerScreen: Double) {
let wasRunning = isAutoReading
let wasPaused = isAutoReadPaused
self.isAutoReading = isRunning
isAutoReadPaused = isPaused
self.secondsPerScreen = max(1, secondsPerScreen)
if wasRunning && !isRunning {
resetAutoPage()
} else if !wasRunning && isRunning {
didReachEnd = false
lastTimestamp = 0
eInkPageInterval = self.secondsPerScreen
} else if isPaused || wasPaused != isPaused {
lastTimestamp = 0
}
}
func resetAutoReadForPageChange() {
didReachEnd = false
didReachStart = false
lastReportedPage = nil
didEnterPrevious = false
didEnterContinuation = false
pendingPreviousPosition = nil
pendingContinuationPosition = nil
resetAutoPage()
}
func scroll(to characterPosition: Int, in view: UITextView) {
guard !view.text.isEmpty else { return }
layoutGeneration &+= 1
let generation = layoutGeneration
let prefix = characterPosition == 0 && parent.pageAnimation == 3
? currentSectionStart
: renderedPrefixLength
let location = ReaderPositionResolver.renderedLocation(
position: characterPosition,
prefixLength: prefix,
renderedLength: view.text.utf16.count
)
DispatchQueue.main.async { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
view.layoutManager.ensureLayout(for: view.textContainer)
let glyph = view.layoutManager.glyphIndexForCharacter(at: location)
var point = view.layoutManager.boundingRect(
forGlyphRange: NSRange(location: glyph, length: 1),
in: view.textContainer
).origin
point.y += view.textContainerInset.top
let limits = self.scrollLimits(for: view)
let target = min(
max(point.y - view.adjustedContentInset.top, limits.minimum),
limits.maximum
)
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
}
}
private func readingViewportHeight(for view: UITextView) -> CGFloat {
max(
view.bounds.height
- view.adjustedContentInset.top
- view.adjustedContentInset.bottom
- view.textContainerInset.top
- view.textContainerInset.bottom,
1
)
}
private func scrollLimits(for view: UITextView) -> (minimum: CGFloat, maximum: CGFloat) {
let minimum = -view.adjustedContentInset.top
let maximum = max(
view.contentSize.height - view.bounds.height + view.adjustedContentInset.bottom,
minimum
)
return (minimum, maximum)
}
private func pageMetrics(for view: UITextView) -> (current: Int, total: Int) {
let pageHeight = readingViewportHeight(for: view)
if parent.pageAnimation == 3, currentBodyEnd > currentSectionStart {
view.layoutManager.ensureLayout(for: view.textContainer)
let glyphCount = view.layoutManager.numberOfGlyphs
if glyphCount > 0 {
let first = min(view.layoutManager.glyphIndexForCharacter(at: currentSectionStart), glyphCount - 1)
let last = min(view.layoutManager.glyphIndexForCharacter(at: currentBodyEnd - 1), glyphCount - 1)
let start = view.layoutManager.boundingRect(
forGlyphRange: NSRange(location: first, length: 1), in: view.textContainer
).minY + view.textContainerInset.top - view.adjustedContentInset.top
let end = view.layoutManager.boundingRect(
forGlyphRange: NSRange(location: last, length: 1), in: view.textContainer
).maxY + view.textContainerInset.top - view.adjustedContentInset.top
let total = max(Int(ceil((end - start) / pageHeight)), 1)
let position = max(view.contentOffset.y - start, 0)
return (min(Int(floor(position / pageHeight)) + 1, total), total)
}
}
let limits = scrollLimits(for: view)
let scrollRange = max(limits.maximum - limits.minimum, 0)
let total = max(Int(ceil(scrollRange / pageHeight)) + 1, 1)
guard total > 1 else { return (1, 1) }
let position = min(max(view.contentOffset.y - limits.minimum, 0), scrollRange)
let current = position >= scrollRange - 0.5
? total
: min(Int(floor(position / pageHeight)) + 1, total)
return (current, total)
}
private func renderedCharacter(at offset: CGPoint, in view: UITextView) -> Int {
guard !view.text.isEmpty else { return 0 }
let point = CGPoint(
x: view.textContainerInset.left + 1,
y: offset.y + view.textContainerInset.top + 1
)
let glyphCount = view.layoutManager.numberOfGlyphs
guard glyphCount > 0 else { return 0 }
let glyph = min(view.layoutManager.glyphIndex(for: point, in: view.textContainer), glyphCount - 1)
let character = view.layoutManager.characterIndexForGlyph(at: glyph)
return min(max(character, 0), view.text.utf16.count)
}
func contentCharacter(at offset: CGPoint, in view: UITextView) -> Int {
let character = renderedCharacter(at: offset, in: view)
return min(max(character - renderedPrefixLength, 0), parent.text.utf16.count)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard !isRenderingAutoPage, !isApplyingContent else { return }
guard let view = scrollView as? UITextView, !view.text.isEmpty else { return }
if parent.isLoadingNextChapter, !didRequestLoadingCancellation {
let textBottom = max(
view.contentSize.height - view.bounds.height
+ view.adjustedContentInset.bottom - view.contentInset.bottom,
-view.adjustedContentInset.top
)
if view.contentOffset.y < textBottom - 44 {
didRequestLoadingCancellation = true
DispatchQueue.main.async { [weak self] in self?.parent.onCancelNextLoading() }
}
}
if isUserDragging {
let delta = scrollView.contentOffset.y - lastDragOffsetY
if abs(delta) > 0.5 { dragDirection = delta > 0 ? 1 : -1 }
lastDragOffsetY = scrollView.contentOffset.y
}
let metrics = pageMetrics(for: view)
reportPage(in: view)
if let lastReportedPage, lastReportedPage != metrics.current {
self.lastReportedPage = metrics.current
let generation = layoutGeneration
DispatchQueue.main.async { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
self.parent.onPageChanged()
}
} else if lastReportedPage == nil {
lastReportedPage = metrics.current
}
let contentCharacter = contentCharacter(at: scrollView.contentOffset, in: view)
let renderedCharacter = renderedCharacter(at: scrollView.contentOffset, in: view)
if let previousBodyStart,
let previousBodyEnd,
renderedCharacter < currentSectionStart {
pendingContinuationPosition = nil
let position = min(max(renderedCharacter - previousBodyStart, 0), max(previousBodyEnd - previousBodyStart - 1, 0))
if isUserDragging || view.isDecelerating {
pendingPreviousPosition = position
return
}
guard !didEnterPrevious else { return }
didEnterPrevious = true
let generation = layoutGeneration
DispatchQueue.main.async { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
self.parent.onEnteredPrevious(position)
}
return
}
if let continuationSectionStart,
let continuationBodyStart,
renderedCharacter >= continuationSectionStart {
pendingPreviousPosition = nil
let position = max(renderedCharacter - continuationBodyStart, 0)
if isUserDragging || view.isDecelerating {
pendingContinuationPosition = position
return
}
guard !didEnterContinuation else { return }
didEnterContinuation = true
let generation = layoutGeneration
DispatchQueue.main.async { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
self.parent.onEnteredContinuation(position)
}
return
}
pendingPreviousPosition = nil
pendingContinuationPosition = nil
guard contentCharacter != parent.readerPosition else { return }
let generation = layoutGeneration
DispatchQueue.main.async { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
self.parent.readerPosition = contentCharacter
}
}
func reportPage(in view: UITextView) {
guard view.bounds.width > 0, view.bounds.height > 0 else { return }
let metrics = pageMetrics(for: view)
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("-uiPagingDiagnostics") {
view.accessibilityLabel = "bounds=\(view.bounds.size) content=\(view.contentSize) used=\(view.layoutManager.usedRect(for: view.textContainer)) mode=\(parent.pageAnimation) pages=\(metrics.total)"
}
#endif
guard metrics.current != parent.currentPage || metrics.total != parent.totalPages else { return }
let generation = layoutGeneration
DispatchQueue.main.async { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
self.parent.currentPage = metrics.current
self.parent.totalPages = metrics.total
}
}
func reportPageRanges(in view: UITextView) {
guard view.bounds.width > 0, view.bounds.height > 0 else { return }
if parent.pageAnimation == 3 {
let ranges = parent.text.isEmpty
? []
: [NSRange(location: 0, length: parent.text.utf16.count)]
guard ranges != parent.pageRanges else { return }
DispatchQueue.main.async { [weak self] in self?.parent.pageRanges = ranges }
return
}
guard !view.text.isEmpty else { return }
view.layoutManager.ensureLayout(for: view.textContainer)
let pageHeight = readingViewportHeight(for: view)
let limits = scrollLimits(for: view)
let metrics = pageMetrics(for: view)
var ranges: [NSRange] = []
for page in 0..<metrics.total {
let offset = page == metrics.total - 1
? limits.maximum
: min(limits.minimum + CGFloat(page) * pageHeight, limits.maximum)
let rect = CGRect(
x: 0,
y: max(0, offset + view.textContainerInset.top),
width: max(view.textContainer.size.width, 1),
height: pageHeight
)
let glyphRange = view.layoutManager.glyphRange(
forBoundingRect: rect,
in: view.textContainer
)
guard glyphRange.length > 0 else { continue }
let characterRange = view.layoutManager.characterRange(
forGlyphRange: glyphRange,
actualGlyphRange: nil
)
let start = max(characterRange.location - renderedPrefixLength, 0)
let end = min(
characterRange.location + characterRange.length - renderedPrefixLength,
parent.text.utf16.count
)
guard end > start else { continue }
let range = NSRange(location: start, length: end - start)
if ranges.last?.location == range.location,
ranges.last?.length == range.length { continue }
ranges.append(range)
}
if ranges.isEmpty {
guard pageRangeRetryCount < 10 else { return }
pageRangeRetryCount += 1
let generation = layoutGeneration
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
self.reportPageRanges(in: view)
}
return
}
pageRangeRetryCount = 0
guard ranges != parent.pageRanges else { return }
let generation = layoutGeneration
DispatchQueue.main.async { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
self.parent.pageRanges = ranges
}
}
func resetPageRangeRetry() {
pageRangeRetryCount = 0
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
isUserDragging = true
lastDragOffsetY = scrollView.contentOffset.y
dragDirection = 0
didReachEnd = false
didReachStart = false
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if parent.pageAnimation == 3, let view = scrollView as? UITextView {
triggerChapterBoundaryIfNeeded(in: view)
}
if !decelerate {
isUserDragging = false
flushPendingChapterTransition()
requestDeferredRenderIfNeeded()
}
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
if parent.pageAnimation == 3, let view = scrollView as? UITextView {
triggerChapterBoundaryIfNeeded(in: view)
}
isUserDragging = false
flushPendingChapterTransition()
requestDeferredRenderIfNeeded()
}
private func requestDeferredRenderIfNeeded() {
guard needsDeferredRender else { return }
needsDeferredRender = false
DispatchQueue.main.async { [weak self] in self?.parent.onDeferredRender() }
}
private func flushPendingChapterTransition() {
let previous = pendingPreviousPosition
let continuation = pendingContinuationPosition
pendingPreviousPosition = nil
pendingContinuationPosition = nil
let generation = layoutGeneration
if let previous, !didEnterPrevious {
didEnterPrevious = true
DispatchQueue.main.async { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
self.parent.onEnteredPrevious(previous)
}
} else if let continuation, !didEnterContinuation {
didEnterContinuation = true
DispatchQueue.main.async { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
self.parent.onEnteredContinuation(continuation)
}
}
}
private func triggerChapterBoundaryIfNeeded(in view: UITextView) {
let (minimum, maximum) = scrollLimits(for: view)
let atEnd = view.contentOffset.y >= maximum - 0.5
let atStart = view.contentOffset.y <= minimum + 0.5
if atEnd, dragDirection > 0, continuationSectionStart == nil, !didReachEnd {
didReachEnd = true
UIImpactFeedbackGenerator(style: .light).impactOccurred()
parent.onReachedEnd(contentCharacter(at: view.contentOffset, in: view))
} else if atStart, dragDirection < 0, previousBodyStart == nil, !didReachStart {
didReachStart = true
UIImpactFeedbackGenerator(style: .light).impactOccurred()
parent.onReachedStart(contentCharacter(at: view.contentOffset, in: view))
}
}
@objc private func tapped(_ gesture: UITapGestureRecognizer) {
guard let view = textView, gesture.state == .ended else { return }
let point = gesture.location(in: view)
// UIScrollView changes its bounds origin as content scrolls, and
// gesture locations use that content coordinate space. Convert
// back to viewport coordinates before dividing the visible screen
// into the 3×3 tap grid.
let viewportX = point.x - view.bounds.minX
let viewportY = point.y - view.bounds.minY
let column = min(max(Int(viewportX / max(view.bounds.width / 3, 1)), 0), 2)
let row = min(max(Int(viewportY / max(view.bounds.height / 3, 1)), 0), 2)
let index = row * 3 + column
let action = index == 4
? 0
: (parent.clickActions.indices.contains(index) ? parent.clickActions[index] : 0)
if parent.pageAnimation == 3, action == 1 || action == 2 {
return
}
switch action {
case 1: turnPage(1)
case 2: turnPage(-1)
case -1: break
default: parent.onTapAction(action)
}
}
@objc private func swiped(_ gesture: UISwipeGestureRecognizer) {
turnPage(gesture.direction == .left ? 1 : -1)
}
private func turnPage(_ direction: Int) {
guard !isTurningPage, let view = textView else { return }
if isAutoReading { resetAutoReadForPageChange() }
let pageHeight = readingViewportHeight(for: view)
let (minimum, maximum) = scrollLimits(for: view)
let target = min(max(view.contentOffset.y + CGFloat(direction) * pageHeight, minimum), maximum)
if direction < 0, view.contentOffset.y <= minimum + 0.5 {
parent.onReachedStart(contentCharacter(at: view.contentOffset, in: view))
return
}
if direction > 0, view.contentOffset.y >= maximum - 0.5 {
parent.onReachedEnd(contentCharacter(at: view.contentOffset, in: view))
return
}
isTurningPage = true
let completion: (Bool) -> Void = { [weak self] _ in self?.isTurningPage = false }
switch parent.isEInkMode ? 4 : parent.pageAnimation {
case 4:
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
isTurningPage = false
case 0:
let transition = CATransition()
transition.duration = 0.3
transition.type = direction > 0 ? .reveal : .moveIn
transition.subtype = direction > 0 ? .fromRight : .fromLeft
view.layer.add(transition, forKey: "reader-cover")
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.31) { [weak self] in self?.isTurningPage = false }
case 1:
let transition = CATransition()
transition.duration = 0.3
transition.type = .push
transition.subtype = direction > 0 ? .fromRight : .fromLeft
view.layer.add(transition, forKey: "reader-slide")
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.31) { [weak self] in self?.isTurningPage = false }
case 2:
UIView.transition(
with: view,
duration: 0.38,
options: direction > 0 ? .transitionCurlUp : .transitionCurlDown,
animations: { view.setContentOffset(CGPoint(x: 0, y: target), animated: false) },
completion: completion
)
default:
view.setContentOffset(CGPoint(x: 0, y: target), animated: true)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.32) { [weak self] in self?.isTurningPage = false }
}
}
private func resetAutoPage() {
autoPageProgress = 0
eInkElapsed = 0
eInkPageInterval = secondsPerScreen
autoPageTarget = nil
autoPageOverlay?.removeFromSuperview()
autoPageOverlay = nil
lastTimestamp = 0
}
private func prepareAutoPage(in view: UITextView) -> Bool {
let pageHeight = readingViewportHeight(for: view)
let maximum = scrollLimits(for: view).maximum
guard view.contentOffset.y < maximum - 0.5 else { return false }
let target = min(view.contentOffset.y + pageHeight, maximum)
let originalOffset = view.contentOffset
isRenderingAutoPage = true
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
view.layoutIfNeeded()
let renderer = UIGraphicsImageRenderer(bounds: view.bounds)
let image = renderer.image { context in view.layer.render(in: context.cgContext) }
view.setContentOffset(originalOffset, animated: false)
isRenderingAutoPage = false
let overlay = UIImageView(image: image)
overlay.isUserInteractionEnabled = false
overlay.contentMode = .top
overlay.clipsToBounds = true
overlay.layer.borderColor = view.tintColor.cgColor
overlay.layer.borderWidth = 0
overlay.frame = CGRect(x: 0, y: originalOffset.y, width: view.bounds.width, height: 0)
view.addSubview(overlay)
autoPageOverlay = overlay
autoPageTarget = target
return true
}
private func advancePagedAutoRead(in view: UITextView, elapsed: CFTimeInterval) {
if autoPageOverlay == nil, !prepareAutoPage(in: view) {
guard !didReachEnd else { return }
didReachEnd = true
parent.onReachedEnd(contentCharacter(at: view.contentOffset, in: view))
return
}
didReachEnd = false
let pageHeight = readingViewportHeight(for: view)
autoPageProgress += pageHeight / CGFloat(secondsPerScreen) * CGFloat(elapsed)
let revealedHeight = min(autoPageProgress, pageHeight)
if let overlay = autoPageOverlay {
overlay.frame = CGRect(
x: 0,
y: view.contentOffset.y,
width: view.bounds.width,
height: revealedHeight
)
overlay.layer.borderWidth = revealedHeight > 0 ? 0.5 : 0
}
guard autoPageProgress >= pageHeight, let target = autoPageTarget else { return }
autoPageOverlay?.removeFromSuperview()
autoPageOverlay = nil
autoPageTarget = nil
autoPageProgress = 0
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
}
private func advanceEInkAutoRead(in view: UITextView, elapsed: CFTimeInterval) {
eInkElapsed += elapsed
guard eInkElapsed >= eInkPageInterval else { return }
eInkElapsed = 0
eInkPageInterval = secondsPerScreen
let pageHeight = readingViewportHeight(for: view)
let maximum = scrollLimits(for: view).maximum
guard view.contentOffset.y < maximum - 0.5 else {
guard !didReachEnd else { return }
didReachEnd = true
parent.onReachedEnd(contentCharacter(at: view.contentOffset, in: view))
return
}
didReachEnd = false
let target = min(view.contentOffset.y + pageHeight, maximum)
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
}
@objc private func step(_ link: CADisplayLink) {
guard isAutoReading, !isAutoReadPaused, !isUserDragging, let view = textView else {
lastTimestamp = link.timestamp
return
}
guard lastTimestamp > 0 else {
lastTimestamp = link.timestamp
return
}
let elapsed = link.timestamp - lastTimestamp
lastTimestamp = link.timestamp
if parent.isEInkMode {
advanceEInkAutoRead(in: view, elapsed: elapsed)
return
}
if parent.pageAnimation != 3 {
advancePagedAutoRead(in: view, elapsed: elapsed)
return
}
let maximum = scrollLimits(for: view).maximum
let next = view.contentOffset.y
+ readingViewportHeight(for: view) / CGFloat(secondsPerScreen) * CGFloat(elapsed)
if next >= maximum - 0.5 {
view.setContentOffset(CGPoint(x: 0, y: maximum), animated: false)
guard !didReachEnd else { return }
didReachEnd = true
parent.onReachedEnd(contentCharacter(at: view.contentOffset, in: view))
} else {
didReachEnd = false
view.setContentOffset(CGPoint(x: 0, y: next), animated: false)
}
}
}
}
File diff suppressed because it is too large Load Diff
+611
View File
@@ -0,0 +1,611 @@
import SwiftUI
#if SWIFT_PACKAGE
import LegadoCore
#endif
struct ReaderParagraph: Identifiable, Equatable {
let id: Int
let text: String
}
struct ReaderScrollChapter: Identifiable, Equatable {
let id: Int
let title: String
let content: String
var error: String? = nil
}
enum ReaderPositionResolver {
static func renderedLocation(position: Int, prefixLength: Int, renderedLength: Int) -> Int {
let lastLocation = max(renderedLength, 1) - 1
let prefix = min(max(prefixLength, 0), lastLocation)
return prefix + min(max(position, 0), lastLocation - prefix)
}
}
@MainActor
final class ReaderViewModel: ObservableObject {
@Published private(set) var isLoading = false
@Published private(set) var title: String
@Published private(set) var content = ""
@Published private(set) var diagnostics: [RuleDiagnostic] = []
@Published private(set) var currentIndex: Int
@Published private(set) var chapterLoadGeneration = 0
@Published private(set) var paragraphs: [ReaderParagraph] = []
@Published private(set) var requestedPosition: Int
@Published private(set) var loadedFromCache = false
@Published private(set) var hasLoadedCurrentChapter = false
@Published private(set) var previousTitle = ""
@Published private(set) var previousContent = ""
@Published private(set) var continuationTitle = ""
@Published private(set) var continuationContent = ""
@Published private(set) var isPreviousWindowReady = false
@Published private(set) var isContinuationWindowReady = false
@Published private(set) var scrollChapters: [ReaderScrollChapter] = []
@Published private(set) var scrollRevision = 0
@Published private(set) var isLoadingScrollAhead = false
@Published private(set) var isLoadingScrollBehind = false
@Published private(set) var book: Book
private(set) var chapters: [BookChapter]
private var source: BookSource?
private let localService: LocalBookService
private let sanitizer = ContentSanitizer()
private let contentCoordinator: ChapterContentCoordinator
private var demoChapterContent: String?
private var previousLoadedIndex: Int?
private var continuationLoadedIndex: Int?
@Published private(set) var scrollRetries: Set<Int> = []
private var sourceCacheNeedsInvalidation = false
private var pendingSourceContent: ChapterContentReport?
func prepareContinuousReading(rules: [ReplaceRule]) async {
guard hasLoadedCurrentChapter else { return }
if scrollChapters.isEmpty {
scrollChapters = [scrollChapterForCurrentContent()]
}
async let ahead: Void = extendContinuousReading(direction: 1, rules: rules)
async let behind: Void = extendContinuousReading(direction: -1, rules: rules)
_ = await (ahead, behind)
}
func extendContinuousReading(direction: Int, rules: [ReplaceRule]) async {
let initialEdge = direction > 0 ? scrollChapters.last?.id : scrollChapters.first?.id
guard let initialEdge, readableIndex(from: initialEdge, direction: direction) != nil else { return }
if direction > 0 {
guard !isLoadingScrollAhead else { return }
isLoadingScrollAhead = true
} else {
guard !isLoadingScrollBehind else { return }
isLoadingScrollBehind = true
}
let revision = scrollRevision
defer {
if revision == scrollRevision {
if direction > 0 { isLoadingScrollAhead = false }
else { isLoadingScrollBehind = false }
}
}
for _ in 0..<2 {
let edge = direction > 0 ? scrollChapters.last?.id : scrollChapters.first?.id
guard let edge, let index = readableIndex(from: edge, direction: direction) else { return }
let value = await loadAdjacentChapter(chapters[index], rules: rules)
guard revision == scrollRevision, !Task.isCancelled else { return }
// The viewport may have recycled this edge while the request was
// in flight. Installing its result now would create a chapter gap.
guard scrollChapters.contains(where: { $0.id == edge }) else { return }
let chapter = ReaderScrollChapter(
id: index, title: value.title, content: value.content, error: value.error
)
if !scrollChapters.contains(where: { $0.id == index }) {
scrollChapters.append(chapter)
scrollChapters.sort { $0.id < $1.id }
}
}
}
func retryContinuousChapter(index: Int, rules: [ReplaceRule]) async {
guard chapters.indices.contains(index), !scrollRetries.contains(index) else { return }
let revision = scrollRevision
scrollRetries.insert(index)
defer { if revision == scrollRevision { scrollRetries.remove(index) } }
let value = await loadAdjacentChapter(chapters[index], rules: rules, forceRefresh: true)
guard revision == scrollRevision, !Task.isCancelled,
let slot = scrollChapters.firstIndex(where: { $0.id == index }) else { return }
scrollChapters[slot] = .init(id: index, title: value.title, content: value.content, error: value.error)
if currentIndex == index { setContinuousChapter(scrollChapters[slot]) }
}
@discardableResult
func updateContinuousPosition(chapter index: Int) -> Bool {
guard index != currentIndex,
let chapter = scrollChapters.first(where: { $0.id == index }) else { return false }
setContinuousChapter(chapter)
return true
}
func retainContinuousChapters(_ indices: Set<Int>) {
guard indices.contains(currentIndex) else { return }
scrollChapters.removeAll { !indices.contains($0.id) }
}
private func setContinuousChapter(_ chapter: ReaderScrollChapter) {
currentIndex = chapter.id
title = chapter.title
content = chapter.content
paragraphs = Self.makeParagraphs(chapter.content)
diagnostics = []
hasLoadedCurrentChapter = true
}
private func scrollChapterForCurrentContent() -> ReaderScrollChapter {
.init(id: currentIndex, title: title, content: content,
error: currentChapterError)
}
private func clearContinuousReading() {
scrollRevision &+= 1
scrollChapters = []
isLoadingScrollAhead = false
isLoadingScrollBehind = false
scrollRetries = []
}
init(
book: Book,
chapters: [BookChapter],
initialIndex: Int,
source: BookSource,
service: any ChapterContentLoading = NetworkBookService(),
cache: ChapterContentCache = .live()
) {
let resolvedIndex = Self.validIndex(initialIndex, chapters: chapters)
self.book = book
self.chapters = chapters
currentIndex = resolvedIndex
self.source = source
localService = .init()
contentCoordinator = .init(loader: service, cache: cache)
requestedPosition = resolvedIndex == book.durChapterIndex ? max(book.durChapterPos, 0) : 0
title = chapters.indices.contains(resolvedIndex) ? chapters[resolvedIndex].title : book.name
}
convenience init(demoBook book: Book, chapters: [BookChapter], initialIndex: Int, content: String) {
self.init(book: book, chapters: chapters, initialIndex: initialIndex)
self.content = content
demoChapterContent = content
paragraphs = Self.makeParagraphs(content)
hasLoadedCurrentChapter = true
}
init(
book: Book,
chapters: [BookChapter],
initialIndex: Int,
localService: LocalBookService = .init(),
cache: ChapterContentCache = .live()
) {
let resolvedIndex = Self.validIndex(initialIndex, chapters: chapters)
self.book = book
self.chapters = chapters
currentIndex = resolvedIndex
source = nil
self.localService = localService
contentCoordinator = .init(cache: cache)
requestedPosition = resolvedIndex == book.durChapterIndex ? max(book.durChapterPos, 0) : 0
title = chapters.indices.contains(resolvedIndex) ? chapters[resolvedIndex].title : book.name
}
var currentChapter: BookChapter? { chapters.indices.contains(currentIndex) ? chapters[currentIndex] : nil }
var nextChapter: BookChapter? { nextReadableChapter(after: currentIndex) }
var canGoPrevious: Bool { navigationIndex(direction: -1) != nil }
var canGoNext: Bool { navigationIndex(direction: 1) != nil }
var hasPreviousWindow: Bool { previousLoadedIndex != nil }
var hasContinuationWindow: Bool { continuationLoadedIndex != nil }
var canEditContent: Bool { source != nil }
var usesReplaceRules: Bool { book.readConfig?.useReplaceRule ?? true }
var currentChapterError: String? {
guard hasLoadedCurrentChapter, !isLoading,
content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
currentChapter?.isVolume != true else { return nil }
return scrollChapters.first(where: { $0.id == currentIndex })?.error
?? diagnostics.first(where: { $0.severity == .error })?.message
?? (source != nil ? "当前书源没有返回正文。" : nil)
}
func bookForSourceSwitch(position: Int) -> Book {
var current = book
current.durChapterIndex = currentIndex
current.durChapterTitle = currentChapter?.title
current.durChapterPos = max(position, 0)
current.totalChapterNum = chapters.count
return current
}
func applySourceSwitch(_ report: BookSourceSwitchReport, source: BookSource) {
book = report.book
chapters = report.chapters
self.source = source
demoChapterContent = nil
sourceCacheNeedsInvalidation = true
select(index: Self.validIndex(report.book.durChapterIndex, chapters: chapters), position: report.book.durChapterPos)
pendingSourceContent = report.verifiedContent
}
func load(rules: [ReplaceRule], forceRefresh: Bool = false, prefetchNextChapter: Bool = true) async {
guard (content.isEmpty || forceRefresh), !isLoading else { return }
guard let chapter = currentChapter else { return }
let requestedIndex = currentIndex
let requestedGeneration = chapterLoadGeneration
if forceRefresh {
pendingSourceContent = nil
clearContinuousReading()
content = ""
paragraphs = []
diagnostics = []
hasLoadedCurrentChapter = false
}
isLoading = true
defer {
if requestedGeneration == chapterLoadGeneration { isLoading = false }
}
let report: ChapterContentReport
let fromCache: Bool
if sourceCacheNeedsInvalidation {
await contentCoordinator.invalidate(bookURL: book.bookUrl)
guard requestedGeneration == chapterLoadGeneration, !Task.isCancelled else { return }
sourceCacheNeedsInvalidation = false
}
if let demoChapterContent {
fromCache = true
report = .init(title: chapter.title, content: demoChapterContent)
} else if let verified = pendingSourceContent, let source, !forceRefresh {
pendingSourceContent = nil
fromCache = false
report = verified
await contentCoordinator.saveContent(verified, book: book, chapter: chapter, source: source)
} else if let source {
let nextURL = nextReadableChapter(after: requestedIndex)?.url
let result = await contentCoordinator.load(
book: book,
chapter: chapter,
source: source,
nextChapterURL: nextURL,
forceRefresh: forceRefresh
)
fromCache = result.loadedFromCache
report = result.report
} else {
fromCache = false
do {
report = try await Task.detached { [localService, book, chapter] in
try localService.loadContent(book: book, chapter: chapter)
}.value
} catch {
report = .init(diagnostics: [
.init(severity: .error, code: "local.read", message: error.localizedDescription, rule: book.bookUrl)
])
}
}
guard requestedGeneration == chapterLoadGeneration, !Task.isCancelled else { return }
loadedFromCache = fromCache
if prefetchNextChapter, !fromCache, let source { schedulePreload(after: requestedIndex, source: source) }
hasLoadedCurrentChapter = true
var effectiveChapter = chapter
if let resolvedTitle = report.title { effectiveChapter.title = resolvedTitle }
let processed = sanitizer.process(
book: book,
chapter: effectiveChapter,
content: report.content,
rules: rules
)
title = processed.title
content = processed.content
paragraphs = Self.makeParagraphs(processed.content)
diagnostics = report.diagnostics + processed.diagnostics
}
func prepareAdjacentWindows() {
let previousIndex = navigationIndex(direction: -1)
if previousLoadedIndex != previousIndex {
previousLoadedIndex = previousIndex
previousTitle = previousIndex.map { chapters[$0].title } ?? ""
previousContent = ""
isPreviousWindowReady = false
}
let nextIndex = navigationIndex(direction: 1)
if continuationLoadedIndex != nextIndex {
continuationLoadedIndex = nextIndex
continuationTitle = nextIndex.map { chapters[$0].title } ?? ""
continuationContent = ""
isContinuationWindowReady = false
}
}
func loadAdjacentChapters(rules: [ReplaceRule]) async {
prepareAdjacentWindows()
let requestedIndex = currentIndex
let requestedGeneration = chapterLoadGeneration
let previousIndex = previousLoadedIndex
let nextIndex = continuationLoadedIndex
let needsPrevious = previousIndex != nil && !isPreviousWindowReady
let needsNext = nextIndex != nil && !isContinuationWindowReady
async let previousValue: (title: String, content: String, error: String?)? = needsPrevious
? loadAdjacentChapter(chapters[previousIndex!], rules: rules)
: nil
async let nextValue: (title: String, content: String, error: String?)? = needsNext
? loadAdjacentChapter(chapters[nextIndex!], rules: rules)
: nil
let resolvedNext = await nextValue
guard requestedGeneration == chapterLoadGeneration, requestedIndex == currentIndex, !Task.isCancelled else { return }
if continuationLoadedIndex == nextIndex, needsNext {
continuationTitle = resolvedNext?.title ?? nextIndex.map { chapters[$0].title } ?? ""
continuationContent = resolvedNext?.content ?? ""
isContinuationWindowReady = true
}
let resolvedPrevious = await previousValue
guard requestedGeneration == chapterLoadGeneration, requestedIndex == currentIndex, !Task.isCancelled else { return }
if previousLoadedIndex == previousIndex, needsPrevious {
previousTitle = resolvedPrevious?.title ?? previousIndex.map { chapters[$0].title } ?? ""
previousContent = resolvedPrevious?.content ?? ""
isPreviousWindowReady = true
}
}
private func loadAdjacentChapter(
_ chapter: BookChapter,
rules: [ReplaceRule],
forceRefresh: Bool = false
) async -> (title: String, content: String, error: String?) {
let report: ChapterContentReport
if let demoChapterContent {
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("-uiScrollRegression") {
try? await Task.sleep(nanoseconds: 80_000_000)
}
#endif
report = .init(title: chapter.title, content: demoChapterContent)
} else if let source {
report = await contentCoordinator.load(
book: book,
chapter: chapter,
source: source,
nextChapterURL: nextReadableChapter(after: chapter.index)?.url,
forceRefresh: forceRefresh
).report
} else {
do {
report = try await Task.detached { [localService, book, chapter] in
try localService.loadContent(book: book, chapter: chapter)
}.value
} catch {
return (chapter.title, "", error.localizedDescription)
}
}
var effectiveChapter = chapter
if let resolvedTitle = report.title { effectiveChapter.title = resolvedTitle }
let processed = sanitizer.process(
book: book,
chapter: effectiveChapter,
content: report.content,
rules: rules
)
let error = processed.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? (report.diagnostics + processed.diagnostics).first(where: { $0.severity == .error })?.message
?? (source != nil && !chapter.isVolume ? "当前书源没有返回正文。" : nil)
: nil
return (processed.title, processed.content, error)
}
func saveEditedContent(_ value: String) async throws {
guard let chapter = currentChapter, source != nil else { return }
try await contentCoordinator.saveEditedContent(book: book, chapter: chapter, title: title, content: value)
content = value
paragraphs = Self.makeParagraphs(value)
loadedFromCache = true
if let slot = scrollChapters.firstIndex(where: { $0.id == currentIndex }) {
scrollChapters[slot] = scrollChapterForCurrentContent()
}
}
func nextSpeechContent(rules: [ReplaceRule]) async -> String? {
guard let chapter = nextReadableChapter(after: currentIndex) else { return nil }
let report: ChapterContentReport
if let source {
report = await contentCoordinator.load(
book: book,
chapter: chapter,
source: source,
nextChapterURL: nextReadableChapter(after: chapter.index)?.url
).report
} else {
do {
report = try await Task.detached { [localService, book, chapter] in
try localService.loadContent(book: book, chapter: chapter)
}.value
} catch {
return nil
}
}
var effectiveChapter = chapter
if let resolvedTitle = report.title { effectiveChapter.title = resolvedTitle }
let value = sanitizer.process(
book: book,
chapter: effectiveChapter,
content: report.content,
rules: rules
).content
return value.isEmpty ? nil : value
}
@discardableResult
func toggleReplaceRules() -> Book {
var config = book.readConfig ?? BookReadConfig()
config.useReplaceRule = !usesReplaceRules
book.readConfig = config
content = ""
pendingSourceContent = nil
paragraphs = []
diagnostics = []
loadedFromCache = false
hasLoadedCurrentChapter = false
clearAdjacentChapters()
return book
}
@discardableResult
func useGlobalPageAnimation() -> Book {
var config = book.readConfig ?? BookReadConfig()
config.pageAnim = -1
book.readConfig = config
return book
}
@discardableResult
func setTTSEngine(_ value: String?) -> Book {
var config = book.readConfig ?? BookReadConfig()
config.ttsEngine = value
book.readConfig = config
return book
}
func goPrevious(position: Int = 0) { if let value = navigationIndex(direction: -1) { select(index: value, position: position) } }
func goNext(position: Int = 0) { if let value = navigationIndex(direction: 1) { select(index: value, position: position) } }
func promoteContinuation(position: Int) {
guard let value = navigationIndex(direction: 1), hasContinuationWindow else {
return
}
let oldIndex = currentIndex
let oldTitle = title
let oldContent = content
let promotedContentResolved = isContinuationWindowReady
currentIndex = value
title = continuationTitle.isEmpty ? chapters[value].title : continuationTitle
content = continuationContent
paragraphs = Self.makeParagraphs(continuationContent)
diagnostics = []
requestedPosition = max(position, 0)
loadedFromCache = promotedContentResolved
hasLoadedCurrentChapter = promotedContentResolved
previousLoadedIndex = oldIndex
previousTitle = oldTitle
previousContent = oldContent
isPreviousWindowReady = true
continuationLoadedIndex = nil
continuationTitle = ""
continuationContent = ""
isContinuationWindowReady = false
isLoading = false
chapterLoadGeneration &+= 1
}
func promotePrevious(position: Int) {
guard let value = navigationIndex(direction: -1), hasPreviousWindow else { return }
let oldIndex = currentIndex
let oldTitle = title
let oldContent = content
let promotedContentResolved = isPreviousWindowReady
currentIndex = value
title = previousTitle.isEmpty ? chapters[value].title : previousTitle
content = previousContent
paragraphs = Self.makeParagraphs(previousContent)
diagnostics = []
requestedPosition = max(position, 0)
loadedFromCache = promotedContentResolved
hasLoadedCurrentChapter = promotedContentResolved
continuationLoadedIndex = oldIndex
continuationTitle = oldTitle
continuationContent = oldContent
isContinuationWindowReady = true
previousLoadedIndex = nil
previousTitle = ""
previousContent = ""
isPreviousWindowReady = false
isLoading = false
chapterLoadGeneration &+= 1
}
func select(index: Int, position: Int = 0) {
guard chapters.indices.contains(index), !chapters[index].isVolume else { return }
currentIndex = index
pendingSourceContent = nil
title = chapters[index].title
content = ""
paragraphs = []
diagnostics = []
requestedPosition = max(position, 0)
loadedFromCache = false
hasLoadedCurrentChapter = false
clearAdjacentChapters()
isLoading = false
chapterLoadGeneration &+= 1
}
private func clearAdjacentChapters() {
clearContinuousReading()
previousLoadedIndex = nil
previousTitle = ""
previousContent = ""
isPreviousWindowReady = false
continuationLoadedIndex = nil
continuationTitle = ""
continuationContent = ""
isContinuationWindowReady = false
}
func paragraphID(at position: Int) -> Int? {
paragraphs.last(where: { $0.id <= position })?.id ?? paragraphs.first?.id
}
private func navigationIndex(direction: Int) -> Int? {
readableIndex(from: currentIndex, direction: direction)
}
private func readableIndex(from index: Int, direction: Int) -> Int? {
var value = index + direction
while chapters.indices.contains(value) {
if !chapters[value].isVolume { return value }
value += direction
}
return nil
}
private func nextReadableChapter(after index: Int) -> BookChapter? {
var value = index + 1
while chapters.indices.contains(value) {
if !chapters[value].isVolume { return chapters[value] }
value += 1
}
return nil
}
private func schedulePreload(after index: Int, source: BookSource) {
let count = ApplicationSettingsStore.shared.settings.prefetchChapters
let jobs = ChapterCachePlanner.plan(chapters: chapters, startIndex: index + 1, limit: count)
guard index + 1 < chapters.count, !jobs.isEmpty else { return }
Task { [book, contentCoordinator] in
for job in jobs {
guard !Task.isCancelled else { return }
await contentCoordinator.prefetch(book: book, chapter: job.chapter, source: source, nextChapterURL: job.nextChapterURL)
}
}
}
private static func makeParagraphs(_ content: String) -> [ReaderParagraph] {
var position = 0
var output: [ReaderParagraph] = []
for line in content.components(separatedBy: .newlines) {
if !line.isEmpty { output.append(.init(id: position, text: line)) }
position += line.utf16.count + 1
}
if output.isEmpty, !content.isEmpty { output.append(.init(id: 0, text: content)) }
return output
}
private static func validIndex(_ requested: Int, chapters: [BookChapter]) -> Int {
if chapters.indices.contains(requested), !chapters[requested].isVolume { return requested }
return chapters.firstIndex(where: { !$0.isVolume }) ?? 0
}
}
@@ -0,0 +1,225 @@
import SwiftUI
import AVFoundation
import Vision
import VisionKit
struct QRCodeImportView: View {
@Environment(\.dismiss) private var dismiss
let onValue: (String) -> Void
@State private var cameraAuthorized = false
@State private var cameraMessage = "正在检查相机…"
@State private var values: [String] = []
@State private var errorMessage: String?
@State private var isReading = false
@State private var scanError: String?
@State private var delivered = false
var body: some View {
ReadingNavigationStack {
VStack(spacing: 16) {
if cameraAuthorized, scanError == nil {
cameraScanner
.clipShape(RoundedRectangle(cornerRadius: 18))
.overlay(alignment: .bottom) {
Text("将书源或净化规则二维码放入取景框").font(.footnote)
.padding(10).background(.ultraThinMaterial, in: Capsule()).padding()
}
} else {
ReadingUnavailableView("相机扫描暂不可用", systemImage: "qrcode.viewfinder",
description: Text(scanError ?? cameraMessage))
if AVCaptureDevice.authorizationStatus(for: .video) == .denied {
Link("打开系统设置", destination: URL(string: UIApplication.openSettingsURLString)!)
}
}
if isReading { ProgressView("识别图片中的二维码…") }
ReadingPhotoPicker { data in
readPhoto(data)
} onError: { error in
errorMessage = error.localizedDescription
} label: {
Label("从照片选择二维码", systemImage: "photo")
.frame(maxWidth: .infinity)
}.buttonStyle(.borderedProminent).disabled(isReading)
Text("支持 HTTP(S) 链接、Legado 导入深链和 JSON 内容。识别后进入导入流程。")
.font(.footnote).foregroundStyle(.secondary)
if values.count > 1 {
List(values, id: \.self) { value in
Button { accept(value) } label: { Text(value).font(.caption).lineLimit(3) }
}.frame(maxHeight: 220)
}
}
.padding()
.navigationTitle("二维码导入")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } } }
.task {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized: cameraAuthorized = true
case .notDetermined: cameraAuthorized = await AVCaptureDevice.requestAccess(for: .video)
default: break
}
cameraMessage = cameraAuthorized ? "当前设备不支持实时扫描,请从照片选择二维码。" : "未获得相机权限,仍可从照片选择二维码。"
}
.alert("识别失败", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
Button("好", role: .cancel) {}
} message: { Text(errorMessage ?? "") }
}
}
@ViewBuilder
private var cameraScanner: some View {
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI,
DataScannerViewController.isSupported, DataScannerViewController.isAvailable {
QRScanner { accept($0) } onError: { scanError = $0 }
} else {
LegacyQRScanner { accept($0) } onError: { scanError = $0 }
}
}
private func readPhoto(_ data: Data) {
Task {
isReading = true
defer { isReading = false }
do {
let found = try await Task.detached(priority: .userInitiated) {
try QRCodeDecoder().detect(in: data)
}.value
values = found
if found.count == 1 { accept(found[0]) }
else if found.isEmpty { errorMessage = "图片中没有识别到二维码,请选择清晰的原图。" }
} catch { errorMessage = error.localizedDescription }
}
}
private func accept(_ value: String) {
guard !delivered else { return }
delivered = true
dismiss()
Task { @MainActor in
try? await Task.sleep(nanoseconds: 400_000_000)
onValue(value)
}
}
}
@available(iOS 16.0, *)
private struct QRScanner: UIViewControllerRepresentable {
let onValue: (String) -> Void
let onError: (String) -> Void
func makeCoordinator() -> Coordinator { Coordinator(onValue: onValue) }
func makeUIViewController(context: Context) -> DataScannerViewController {
let scanner = DataScannerViewController(recognizedDataTypes: [.barcode(symbologies: [.qr])],
qualityLevel: .balanced, recognizesMultipleItems: false, isHighFrameRateTrackingEnabled: false,
isPinchToZoomEnabled: true, isGuidanceEnabled: true, isHighlightingEnabled: true)
scanner.delegate = context.coordinator
return scanner
}
func updateUIViewController(_ scanner: DataScannerViewController, context: Context) {
guard !scanner.isScanning else { return }
do { try scanner.startScanning() }
catch { Task { @MainActor in onError(error.localizedDescription) } }
}
static func dismantleUIViewController(_ scanner: DataScannerViewController, coordinator: Coordinator) { scanner.stopScanning() }
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
let onValue: (String) -> Void
init(onValue: @escaping (String) -> Void) { self.onValue = onValue }
func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) {
for item in addedItems {
if case .barcode(let code) = item, let value = code.payloadStringValue {
dataScanner.stopScanning()
onValue(value)
return
}
}
}
}
}
private struct LegacyQRScanner: UIViewControllerRepresentable {
let onValue: (String) -> Void
let onError: (String) -> Void
func makeUIViewController(context: Context) -> Controller {
Controller(onValue: onValue, onError: onError)
}
func updateUIViewController(_ controller: Controller, context: Context) {}
static func dismantleUIViewController(_ controller: Controller, coordinator: Void) {
controller.capture.stop()
}
final class Controller: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
let capture = VideoCapture()
let onValue: (String) -> Void
let onError: (String) -> Void
private var preview: AVCaptureVideoPreviewLayer?
private var didDeliver = false
init(onValue: @escaping (String) -> Void, onError: @escaping (String) -> Void) {
self.onValue = onValue
self.onError = onError
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
do {
guard let device = AVCaptureDevice.default(for: .video) else { throw AVError(.deviceNotConnected) }
let input = try AVCaptureDeviceInput(device: device)
let output = AVCaptureMetadataOutput()
capture.session.beginConfiguration()
defer { capture.session.commitConfiguration() }
guard capture.session.canAddInput(input), capture.session.canAddOutput(output) else {
throw AVError(.deviceInUseByAnotherApplication)
}
capture.session.addInput(input)
capture.session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main)
output.metadataObjectTypes = [.qr]
let preview = AVCaptureVideoPreviewLayer(session: capture.session)
preview.videoGravity = .resizeAspectFill
view.layer.addSublayer(preview)
self.preview = preview
} catch {
let message = error.localizedDescription
DispatchQueue.main.async { self.onError(message) }
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
preview?.frame = view.bounds
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if preview != nil { capture.start() }
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
capture.stop()
}
nonisolated func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput objects: [AVMetadataObject], from connection: AVCaptureConnection) {
guard let value = objects.compactMap({ ($0 as? AVMetadataMachineReadableCodeObject)?.stringValue }).first else { return }
Task { @MainActor [weak self] in
guard let self, !didDeliver else { return }
didDeliver = true
capture.stop()
onValue(value)
}
}
}
final class VideoCapture: @unchecked Sendable {
let session = AVCaptureSession()
private let queue = DispatchQueue(label: "com.epiboly.legado.qr-camera")
func start() { queue.async { self.session.startRunning() } }
func stop() { queue.async { self.session.stopRunning() } }
}
}
@@ -0,0 +1,190 @@
import SwiftUI
import UIKit
struct SyntaxCodeEditor: UIViewRepresentable {
enum Language { case json, rule, javascript, regex }
@Binding var text: String
var language: Language = .rule
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> UITextView {
let view = UITextView()
view.delegate = context.coordinator
view.font = .monospacedSystemFont(ofSize: 14, weight: .regular)
view.backgroundColor = .secondarySystemGroupedBackground
view.textContainerInset = UIEdgeInsets(top: 14, left: 12, bottom: 20, right: 12)
view.autocorrectionType = .no
view.autocapitalizationType = .none
view.smartQuotesType = .no
view.smartDashesType = .no
view.smartInsertDeleteType = .no
view.keyboardDismissMode = .interactive
view.alwaysBounceVertical = true
view.accessibilityIdentifier = "syntaxCodeEditor"
if #available(iOS 16.0, *) { view.isFindInteractionEnabled = true }
let bar = UIToolbar()
bar.sizeToFit()
bar.items = [
UIBarButtonItem(image: UIImage(systemName: "magnifyingglass"), primaryAction: UIAction(title: "", subtitle: nil) { [weak view, coordinator = context.coordinator] _ in
guard let view else { return }
if #available(iOS 16.0, *) { view.findInteraction?.presentFindNavigator(showingReplace: true) }
else { coordinator.presentFindAndReplace(in: view) }
}),
UIBarButtonItem(image: UIImage(systemName: "arrow.uturn.backward"), primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.undoManager?.undo() }),
UIBarButtonItem(image: UIImage(systemName: "arrow.uturn.forward"), primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.undoManager?.redo() }),
] + ["@", "$", "{}", "[]", "##", "\n", "\t"].map { symbol in
UIBarButtonItem(title: symbol == "\n" ? "↵" : symbol == "\t" ? "⇥" : symbol,
primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.insertText(symbol) })
} + [UIBarButtonItem(systemItem: .flexibleSpace), UIBarButtonItem(title: "完成", primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.resignFirstResponder() })]
view.inputAccessoryView = bar
view.text = text
context.coordinator.highlight(view)
return view
}
func updateUIView(_ view: UITextView, context: Context) {
context.coordinator.parent = self
guard view.markedTextRange == nil else { return }
// SwiftUI can render an older binding snapshot while the next native
// keystroke is already in the text view. Wait for acknowledgement of
// the latest local edit instead of replacing it with that snapshot.
if let pending = context.coordinator.pendingLocalText {
guard text == pending else { return }
context.coordinator.pendingLocalText = nil
}
if view.text != text {
let selection = view.selectedRange
view.text = text
view.selectedRange = NSRange(location: min(selection.location, view.text.utf16.count), length: 0)
}
context.coordinator.highlight(view)
}
final class Coordinator: NSObject, UITextViewDelegate {
var parent: SyntaxCodeEditor
private var highlightedText: String?
private var highlightedStyle: UIUserInterfaceStyle?
private var highlightedLanguage: Language?
var pendingLocalText: String?
init(_ parent: SyntaxCodeEditor) { self.parent = parent }
func presentFindAndReplace(in view: UITextView) {
guard var presenter = view.window?.rootViewController else { return }
while let presented = presenter.presentedViewController { presenter = presented }
let alert = UIAlertController(title: "查找与替换", message: nil, preferredStyle: .alert)
alert.addTextField { $0.placeholder = "查找内容" }
alert.addTextField { $0.placeholder = "替换为(可留空)" }
alert.addAction(UIAlertAction(title: "查找下一个", style: .default) { [weak view, weak alert] _ in
guard let view, let query = alert?.textFields?.first?.text, !query.isEmpty else { return }
let text = (view.text ?? "") as NSString
let start = min(NSMaxRange(view.selectedRange), text.length)
var range = text.range(of: query, range: NSRange(location: start, length: text.length - start))
if range.location == NSNotFound { range = text.range(of: query) }
guard range.location != NSNotFound else { return }
view.becomeFirstResponder()
view.selectedRange = range
view.scrollRangeToVisible(range)
})
alert.addAction(UIAlertAction(title: "全部替换", style: .default) { [weak view, weak alert] _ in
guard let view, let fields = alert?.textFields,
let query = fields.first?.text, !query.isEmpty else { return }
let original = view.text ?? ""
let replacement = original.replacingOccurrences(of: query, with: fields.last?.text ?? "")
guard replacement != original else { return }
view.becomeFirstResponder()
view.selectedRange = NSRange(location: 0, length: original.utf16.count)
view.insertText(replacement)
})
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
presenter.present(alert, animated: true)
}
func textViewDidChange(_ view: UITextView) {
guard view.markedTextRange == nil else { return }
pendingLocalText = view.text
parent.text = view.text
highlight(view)
}
func textViewDidEndEditing(_ view: UITextView) {
pendingLocalText = nil
parent.text = view.text
highlight(view)
}
func highlight(_ view: UITextView) {
guard view.markedTextRange == nil,
highlightedText != view.text || highlightedStyle != view.traitCollection.userInterfaceStyle || highlightedLanguage != parent.language else { return }
highlightedText = view.text
highlightedStyle = view.traitCollection.userInterfaceStyle
highlightedLanguage = parent.language
let storage = view.textStorage
let selection = view.selectedRange
let all = NSRange(location: 0, length: storage.length)
storage.beginEditing()
storage.addAttributes([.font: UIFont.monospacedSystemFont(ofSize: 14, weight: .regular), .foregroundColor: UIColor.label], range: all)
if storage.length <= 500_000 {
let tokens: [(String, UIColor)] = [
(#"\b(?:true|false|null|undefined|return|var|let|const|if|else|function|new|for|while|try|catch|throw|await|async)\b"#, .systemPurple),
(#"\b\d+(?:\.\d+)?\b"#, .systemOrange),
(#"@(?:css|xpath|json|js|put|get):?|\$\.|&&|\|\||%%|##|\{\{|\}\}"#, .systemPink),
(#"[{}\[\]()]"#, .systemTeal),
(#""(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`"#, .systemGreen),
(#""(?:\\.|[^"\\])*"(?=\s*:)"#, .systemBlue),
]
for (pattern, color) in tokens {
guard let regex = try? NSRegularExpression(pattern: pattern) else { continue }
regex.enumerateMatches(in: view.text, range: all) { result, _, _ in
if let result { storage.addAttribute(.foregroundColor, value: color, range: result.range) }
}
}
if parent.language == .regex {
for pattern in [#"[.*+?^$|]"#, #"\\[dDsSwWbB0-9]|\{[0-9,]+\}"#] {
let regex = try? NSRegularExpression(pattern: pattern)
regex?.enumerateMatches(in: view.text, range: all) { result, _, _ in
if let result { storage.addAttribute(.foregroundColor, value: UIColor.systemPink, range: result.range) }
}
}
}
if parent.language == .javascript {
let pattern = #"(?m)^\s*//.*$|/\*[\s\S]*?\*/"#
let regex = try? NSRegularExpression(pattern: pattern)
regex?.enumerateMatches(in: view.text, range: all) { result, _, _ in
if let result { storage.addAttribute(.foregroundColor, value: UIColor.secondaryLabel, range: result.range) }
}
}
}
storage.endEditing()
view.selectedRange = selection
view.typingAttributes = [.font: UIFont.monospacedSystemFont(ofSize: 14, weight: .regular), .foregroundColor: UIColor.label]
}
}
}
struct RuleCodeFieldView: View {
let title: String
let path: String
@Binding var text: String
var language: SyntaxCodeEditor.Language = .rule
var help: String?
var body: some View {
VStack(spacing: 0) {
Text(path).font(.caption.monospaced()).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading).padding(12)
if let help {
Text(help).font(.footnote).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 12).padding(.bottom, 10)
}
SyntaxCodeEditor(text: $text, language: language)
HStack {
Text("\(text.components(separatedBy: "\n").count) 行 · \(text.utf16.count) 字符")
Spacer()
if language == .rule { Text("CSS · XPath · JSONPath · JS") }
}.font(.caption).foregroundStyle(.secondary).padding(12)
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
}
}
@@ -0,0 +1,185 @@
import SwiftUI
import UIKit
struct ReplaceRuleEditorView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var library: ReplaceRuleLibraryModel
@State private var rule: ReplaceRule
@State private var mode: Int
@State private var testTitle = "第一章 开始"
@State private var testContent = "在这里粘贴需要净化的正文。"
@State private var testBook = ""
@State private var testSource = ""
@State private var testResult: ReplaceRuleTestResult?
@State private var errorMessage: String?
@State private var isTesting = false
@State private var isSaving = false
@State private var showsDiscard = false
private let original: ReplaceRule
init(rule: ReplaceRule, startsWithTest: Bool = false) {
original = rule
_rule = State(initialValue: rule)
_mode = State(initialValue: startsWithTest ? 1 : 0)
}
var body: some View {
ReadingNavigationStack {
VStack(spacing: 0) {
Picker("模式", selection: $mode) { Text("编辑规则").tag(0); Text("测试规则").tag(1) }
.pickerStyle(.segmented).padding()
Form { if mode == 0 { configuration } else { testForm } }
}
.navigationTitle(rule.name.isEmpty ? "净化规则" : rule.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { if rule != original { showsDiscard = true } else { dismiss() } }
}
ToolbarItem(placement: .topBarTrailing) {
Menu("更多", systemImage: "ellipsis.circle") {
Button("复制规则 JSON", systemImage: "doc.on.doc") { UIPasteboard.general.string = json }
Button("粘贴规则 JSON", systemImage: "doc.on.clipboard") {
do {
guard let text = UIPasteboard.general.string else { return }
var pasted = try JSONDecoder().decode(ReplaceRule.self, from: Data(text.utf8))
pasted.id = rule.id
pasted.sortOrder = rule.sortOrder
rule = pasted
} catch { errorMessage = error.localizedDescription }
}
ReadingShareLink(item: json) { Label("分享规则", systemImage: "square.and.arrow.up") }
}
}
ToolbarItem(placement: .confirmationAction) {
Button(isSaving ? "保存中" : "保存") {
isSaving = true
Task {
let saved = await library.save(rule)
isSaving = false
if saved { dismiss() }
else {
let message = library.notice?.detail ?? "保存失败"
library.notice = nil
errorMessage = message
}
}
}.disabled(isSaving || isTesting)
}
}
.alert("规则提示", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
Button("好", role: .cancel) {}
} message: { Text(errorMessage ?? "") }
.confirmationDialog("放弃未保存的修改?", isPresented: $showsDiscard, titleVisibility: .visible) {
Button("放弃修改", role: .destructive) { dismiss() }
}
.onChange(of: rule) { _ in testResult = nil }
.onChange(of: testTitle + testContent + testBook + testSource) { _ in testResult = nil }
}
.interactiveDismissDisabled(rule != original || isSaving)
}
private var configuration: some View {
Group {
Section("基本信息") {
TextField("名称", text: $rule.name)
TextField("分组", text: optional(\.group))
Toggle("启用", isOn: $rule.isEnabled)
}
Section("匹配与替换") {
Toggle("使用正则表达式", isOn: $rule.isRegex)
NavigationLink {
RuleCodeFieldView(title: "匹配内容", path: "pattern", text: $rule.pattern, language: .regex)
} label: { fieldPreview("匹配内容", value: rule.pattern) }
NavigationLink {
RuleCodeFieldView(title: "替换为", path: "replacement", text: $rule.replacement, language: .regex)
} label: { fieldPreview("替换为(留空即删除)", value: rule.replacement) }
Text("正则替换使用 $1、$2 引用捕获组;普通文本替换按字面匹配。")
.font(.footnote).foregroundStyle(.secondary)
}
Section("作用范围") {
Toggle("章节标题", isOn: $rule.scopeTitle)
Toggle("正文", isOn: $rule.scopeContent)
ReadingTextField("限定书名或书源(留空为全部)", text: optional(\.scope), axis: .vertical)
ReadingTextField("排除书名或书源", text: optional(\.excludeScope), axis: .vertical)
}
Section("执行设置") {
HStack {
Text("超时 (ms)")
TextField("3000", value: $rule.timeoutMillisecond, format: .number.grouping(.never))
.keyboardType(.numbersAndPunctuation).multilineTextAlignment(.trailing)
}
HStack {
Text("排序编号")
TextField("0", value: $rule.sortOrder, format: .number.grouping(.never))
.keyboardType(.numbersAndPunctuation).multilineTextAlignment(.trailing)
}
Text("超时小于等于 0 时使用 3000 ms。单次正则最多运行 60 秒。规则按排序编号依次执行;资源超限或超时会保留原文并报告错误。")
.font(.footnote).foregroundStyle(.secondary)
}
}
}
private var testForm: some View {
Group {
Section("测试上下文") {
TextField("书名(用于作用范围判断)", text: $testBook)
TextField("书源地址(用于作用范围判断)", text: $testSource).textInputAutocapitalization(.never)
TextField("章节标题", text: $testTitle)
NavigationLink {
RuleCodeFieldView(title: "测试正文", path: "测试输入", text: $testContent)
} label: { fieldPreview("正文", value: testContent) }
}
Section {
Button { runTest() } label: {
HStack { Label(isTesting ? "测试中…" : "运行当前草稿", systemImage: "play.fill"); Spacer(); if isTesting { ProgressView() } }
}.disabled(isTesting)
if !rule.isEnabled { Text("规则处于停用状态,结果将保持原文。可在编辑页启用后测试。").font(.footnote).foregroundStyle(.secondary) }
}
if let result = testResult {
Section("测试结果 · \(result.elapsedMilliseconds.formatted(.number.precision(.fractionLength(1)))) ms") {
Label(result.report.effectiveRuleIDs.isEmpty ? "未改变文本(请检查匹配、启用状态及作用范围)" : "规则已生效",
systemImage: result.report.effectiveRuleIDs.isEmpty ? "info.circle" : "checkmark.circle")
ReadingLabeledContent("标题", value: result.report.title)
Text(result.report.content.isEmpty ? "(正文为空)" : result.report.content)
.font(.body.monospaced()).textSelection(.enabled)
}
if !result.report.diagnostics.isEmpty {
Section("执行错误") {
ForEach(result.report.diagnostics) { diagnostic in Text(diagnostic.message).foregroundStyle(.red) }
}
}
}
}
}
private var json: String {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
return (try? encoder.encode(rule)).map { String(decoding: $0, as: UTF8.self) } ?? "{}"
}
private func optional(_ path: WritableKeyPath<ReplaceRule, String?>) -> Binding<String> {
Binding(get: { rule[keyPath: path] ?? "" }, set: { rule[keyPath: path] = $0.isEmpty ? nil : $0 })
}
private func fieldPreview(_ title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 5) {
Text(title)
Text(value.isEmpty ? "未填写" : value).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(3)
}
}
private func runTest() {
let snapshot = rule
let title = testTitle, content = testContent, book = testBook, source = testSource
isTesting = true
testResult = nil
Task {
do {
let result = try await Task.detached(priority: .userInitiated) {
try ReplaceRuleTester().test(rule: snapshot, title: title, content: content, bookName: book, sourceURL: source)
}.value
if rule == snapshot, title == testTitle, content == testContent, book == testBook, source == testSource { testResult = result }
} catch { errorMessage = error.localizedDescription }
isTesting = false
}
}
}
@@ -0,0 +1,141 @@
#if SWIFT_PACKAGE
import LegadoCore
#endif
import Foundation
@MainActor
final class ReplaceRuleLibraryModel: ObservableObject {
@Published private(set) var rules: [ReplaceRule] = []
@Published var notice: Notice?
struct Notice: Identifiable {
enum Kind { case success, error }
let id = UUID()
let kind: Kind
let title: String
let detail: String
}
private let repository: any ReplaceRuleRepository
private let importer = ReplaceRuleImporter()
init(repository: any ReplaceRuleRepository) { self.repository = repository }
static func live() -> ReplaceRuleLibraryModel {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Legado", isDirectory: true)
return ReplaceRuleLibraryModel(repository: FileReplaceRuleRepository(fileURL: base.appendingPathComponent("replace-rules.json")))
}
static func demo() -> ReplaceRuleLibraryModel {
ReplaceRuleLibraryModel(repository: InMemoryReplaceRuleRepository())
}
func load() async {
do { rules = try await repository.load() }
catch { notice = .init(kind: .error, title: "净化规则读取失败", detail: error.localizedDescription) }
}
func importFiles(_ urls: [URL]) async {
do {
var updated = rules
var count = 0
for url in urls {
let granted = url.startAccessingSecurityScopedResource()
defer { if granted { url.stopAccessingSecurityScopedResource() } }
let incoming = try importer.decode(Data(contentsOf: url, options: .mappedIfSafe))
updated = importer.merge(incoming, into: updated)
count += incoming.count
}
try await repository.save(updated)
rules = updated
notice = .init(kind: .success, title: "净化规则已导入", detail: "本次读取 \(count) 条,当前共 \(rules.count) 条。")
} catch {
notice = .init(kind: .error, title: "净化规则导入失败", detail: error.localizedDescription)
}
}
func importRemoteURL(_ value: String) async { await importText(value) }
func importText(_ value: String) async {
do {
let input = try RuleImportInput.parse(value, kind: "replaceRule")
let data: Data
switch input {
case .json(let value): data = Data(value.utf8)
case .remote:
let (body, response) = try await URLSessionHTTPTransport.shared.download(for: input.request()!)
if let response = response as? HTTPURLResponse, !(200...299).contains(response.statusCode) { throw URLError(.badServerResponse) }
data = body
}
let incoming = try importer.decode(data)
let updated = importer.merge(incoming, into: rules)
try await repository.save(updated)
rules = updated
notice = .init(kind: .success, title: "净化规则已导入", detail: "本次读取 \(incoming.count) 条,当前共 \(rules.count) 条。")
} catch { notice = .init(kind: .error, title: "净化规则导入失败", detail: error.localizedDescription) }
}
func newRule(copying original: ReplaceRule? = nil) -> ReplaceRule {
let now = Int64(Date().timeIntervalSince1970 * 1_000)
let maximumID = rules.map(\.id).max() ?? 0
let id = max(now, maximumID < Int64.max ? maximumID + 1 : now)
var rule = original ?? ReplaceRule(id: id, name: "", pattern: "")
rule.id = id
if original != nil { rule.name += " 副本" }
rule.sortOrder = (rules.map(\.sortOrder).filter { $0 != Int.min && $0 < Int.max }.max() ?? -1) + 1
return rule
}
func save(_ rule: ReplaceRule) async -> Bool {
do { try ReplaceRuleValidator.validate(rule) }
catch { notice = .init(kind: .error, title: "规则无效", detail: error.localizedDescription); return false }
return await persist(importer.merge([rule], into: rules))
}
func move(from offsets: IndexSet, to destination: Int) async {
var updated = rules
updated.move(fromOffsets: offsets, toOffset: destination)
for index in updated.indices { updated[index].sortOrder = index }
await persist(updated)
}
func remove(id: Int64) async { await persist(rules.filter { $0.id != id }) }
func applySilentSubscription(_ data: Data) async throws {
let incoming = try importer.decode(data)
let changed = incoming.filter { value in
guard let old = rules.first(where: { $0.id == value.id }) else { return true }
return old.pattern != value.pattern || old.replacement != value.replacement
}
guard !changed.isEmpty else { return }
let updated = importer.merge(changed, into: rules)
try await repository.save(updated)
rules = updated
}
func setEnabled(_ enabled: Bool, for id: Int64) async {
guard let index = rules.firstIndex(where: { $0.id == id }) else { return }
var updated = rules
updated[index].isEnabled = enabled
await persist(updated)
}
func remove(at offsets: IndexSet) async {
var updated = rules
updated.remove(atOffsets: offsets)
await persist(updated)
}
@discardableResult
private func persist(_ updated: [ReplaceRule]) async -> Bool {
do {
try await repository.save(updated)
rules = updated
return true
} catch {
notice = .init(kind: .error, title: "净化规则保存失败", detail: error.localizedDescription)
return false
}
}
}
@@ -0,0 +1,130 @@
import SwiftUI
import UniformTypeIdentifiers
struct ReplaceRuleManagementView: View {
@EnvironmentObject private var library: ReplaceRuleLibraryModel
@State private var showsImporter = false
@State private var showsTextImporter = false
@State private var showsQRImporter = false
@State private var editingRule: ReplaceRule?
@State private var startsWithTest = false
@State private var query = ""
@State private var group: String?
private var filtered: [ReplaceRule] {
library.rules.filter { rule in
(group == nil || rule.group == group) && (query.isEmpty || rule.name.localizedCaseInsensitiveContains(query)
|| rule.pattern.localizedCaseInsensitiveContains(query) || (rule.group?.localizedCaseInsensitiveContains(query) ?? false))
}
}
var body: some View {
List {
if library.rules.isEmpty {
ReadingUnavailableView("还没有净化规则", systemImage: "wand.and.stars", description: Text("新建规则,或导入 Legado 净化规则。"))
Button("新建规则", systemImage: "plus") { edit(library.newRule()) }
} else {
Section {
ReadingLabeledContent("已启用", value: "\(library.rules.filter(\.isEnabled).count) / \(library.rules.count)")
Picker("分组", selection: $group) {
Text("全部").tag(String?.none)
ForEach(Array(Set(library.rules.compactMap(\.group))).sorted(), id: \.self) { Text($0).tag(String?.some($0)) }
}
}
Section {
ForEach(filtered) { rule in
HStack(spacing: 12) {
Button { edit(rule) } label: {
HStack(spacing: 12) {
Image(systemName: rule.isRegex ? "textformat.alt" : "character.cursor.ibeam")
.foregroundStyle(rule.isEnabled ? ReadingTheme.accent : .secondary)
VStack(alignment: .leading, spacing: 4) {
Text(rule.name.isEmpty ? rule.pattern : rule.name).foregroundStyle(.primary).lineLimit(1)
Text(rule.group ?? (rule.scopeTitle && rule.scopeContent ? "标题与正文" : rule.scopeTitle ? "标题" : "正文"))
.font(.caption).foregroundStyle(.secondary)
Text(rule.pattern).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1)
}
Spacer()
}
}.buttonStyle(.plain)
Toggle("启用", isOn: Binding(get: { rule.isEnabled }, set: { value in Task { await library.setEnabled(value, for: rule.id) } }))
.labelsHidden().accessibilityLabel("启用\(rule.name)")
}
.contextMenu {
Button("编辑", systemImage: "pencil") { edit(rule) }
Button("测试", systemImage: "play") { edit(rule, test: true) }
Button("复制", systemImage: "doc.on.doc") { edit(library.newRule(copying: rule)) }
Button("删除", systemImage: "trash", role: .destructive) { Task { await library.remove(id: rule.id) } }
}
}
.onDelete { offsets in
let ids = offsets.compactMap { filtered.indices.contains($0) ? filtered[$0].id : nil }
Task { for id in ids { await library.remove(id: id) } }
}
.onMove { offsets, destination in Task { await library.move(from: offsets, to: destination) } }
.moveDisabled(group != nil || !query.isEmpty)
} footer: { Text("点击规则编辑,长按可复制或测试。拖动排序决定执行顺序。").font(.footnote) }
}
}
.listStyle(.insetGrouped)
.navigationTitle("净化规则")
.searchable(text: $query, prompt: "名称、匹配内容或分组")
.toolbar {
ToolbarItem(placement: .topBarTrailing) { EditButton().disabled(library.rules.isEmpty || group != nil || !query.isEmpty) }
ToolbarItem(placement: .primaryAction) {
Menu("更多", systemImage: "plus") {
Button("新建规则", systemImage: "plus") { edit(library.newRule()) }
Button("从文件导入", systemImage: "doc.badge.plus") { showsImporter = true }
Button("URL 或粘贴 JSON", systemImage: "link.badge.plus") { showsTextImporter = true }
Button("二维码导入", systemImage: "qrcode.viewfinder") { showsQRImporter = true }
ReadingShareLink(item: exportJSON) { Label("导出全部规则", systemImage: "square.and.arrow.up") }
}
}
}
.sheet(item: $editingRule) { ReplaceRuleEditorView(rule: $0, startsWithTest: startsWithTest).environmentObject(library) }
.sheet(isPresented: $showsQRImporter) { QRCodeImportView { value in Task { await library.importText(value) } } }
.sheet(isPresented: $showsTextImporter) { ReplaceRuleTextImportView().environmentObject(library) }
.fileImporter(isPresented: $showsImporter, allowedContentTypes: [.item], allowsMultipleSelection: true) { result in
switch result {
case .success(let urls): Task { await library.importFiles(urls) }
case .failure(let error): library.notice = .init(kind: .error, title: "无法选择文件", detail: error.localizedDescription)
}
}
.alert(item: $library.notice) { notice in
Alert(title: Text(notice.title), message: Text(notice.detail), dismissButton: .default(Text("好")))
}
}
private func edit(_ rule: ReplaceRule, test: Bool = false) { startsWithTest = test; editingRule = rule }
private var exportJSON: String {
let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return (try? encoder.encode(library.rules)).map { String(decoding: $0, as: UTF8.self) } ?? "[]"
}
}
private struct ReplaceRuleTextImportView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var library: ReplaceRuleLibraryModel
@State private var value = ""
var body: some View {
ReadingNavigationStack {
VStack(alignment: .leading, spacing: 12) {
Text("粘贴净化规则 JSON、HTTP(S) 链接或 Legado 净化规则深链。").font(.footnote).foregroundStyle(.secondary)
SyntaxCodeEditor(text: $value, language: .json)
}.padding()
.navigationTitle("导入净化规则").navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
Button("导入") {
let text = value
dismiss()
Task {
try? await Task.sleep(nanoseconds: 400_000_000)
await library.importText(text)
}
}.disabled(value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
}
}
@@ -0,0 +1,162 @@
import SwiftUI
@MainActor
final class RuleSubscriptionModel: ObservableObject {
@Published private(set) var values: [RuleSub] = []
@Published var errorMessage: String?
@Published private(set) var pendingIDs = Set<Int64>()
private let repository: SQLiteConfigurationRepository?
init() {
repository = (try? LegadoDatabase(url: LegadoDatabase.defaultURL())).map { SQLiteConfigurationRepository(database: $0) }
}
func load() async {
guard let repository else { errorMessage = "无法打开规则订阅数据库"; return }
do { values = try await repository.loadRuleSubs().sorted { $0.customOrder < $1.customOrder } }
catch { errorMessage = error.localizedDescription }
}
func save(_ value: RuleSub) async -> Bool {
guard !value.url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { errorMessage = "URL不能为空"; return false }
guard !values.contains(where: { $0.id != value.id && $0.url == value.url }) else { errorMessage = "URL已存在"; return false }
var updated = values
if let index = updated.firstIndex(where: { $0.id == value.id }) { updated[index] = value }
else { var item = value; item.customOrder = (updated.map(\.customOrder).max() ?? -1) + 1; updated.append(item) }
return await persist(updated)
}
func remove(at offsets: IndexSet) async { _ = await persist(values.enumerated().filter { !offsets.contains($0.offset) }.map(\.element)) }
func move(from offsets: IndexSet, to destination: Int) async {
var updated = values; updated.move(fromOffsets: offsets, toOffset: destination)
for index in updated.indices { updated[index].customOrder = index + 1 }
_ = await persist(updated)
}
func checkAutomaticUpdates(bookSources: SourceLibraryModel, rssSources: RSSLibraryModel, replaceRules: ReplaceRuleLibraryModel) async {
let now = Int64(Date().timeIntervalSince1970 * 1_000)
for index in values.indices where values[index].autoUpdate && values[index].type != 1 {
let interval = Int64(max(values[index].updateInterval, 0)) * 3_600_000
guard values[index].update + interval <= now else { continue }
var subscription = values[index]
subscription.update = now
var timestamped = values; timestamped[index] = subscription
guard await persist(timestamped) else { continue }
do {
let data = try await fetch(subscription.url)
if subscription.silentUpdate {
switch subscription.type {
case 1: try await rssSources.applySilentSubscription(data)
case 2: try await replaceRules.applySilentSubscription(data)
default: try await bookSources.applySilentSubscription(data)
}
} else if try hasUpdates(data, type: subscription.type, bookSources: bookSources, rssSources: rssSources, replaceRules: replaceRules) {
pendingIDs.insert(subscription.id)
}
} catch { errorMessage = "\(subscription.name.isEmpty ? subscription.url : subscription.name):\(error.localizedDescription)" }
}
}
func clearPending(_ id: Int64) { pendingIDs.remove(id) }
private func fetch(_ value: String) async throws -> Data {
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)
if let response = response as? HTTPURLResponse, !(200...299).contains(response.statusCode) { throw URLError(.badServerResponse) }
return data
}
private func hasUpdates(_ data: Data, type: Int, bookSources: SourceLibraryModel, rssSources: RSSLibraryModel, replaceRules: ReplaceRuleLibraryModel) throws -> Bool {
switch type {
case 1:
let incoming = try RSSSourceImporter().decode(data).sources
guard incoming.first?.sourceUrl.isEmpty == false else { throw RSSSourceImportError.invalidSource }
return incoming.contains { value in rssSources.sources.first(where: { $0.sourceUrl == value.sourceUrl }).map { $0.lastUpdateTime < value.lastUpdateTime } ?? true }
case 2:
let incoming = try ReplaceRuleImporter().decode(data)
return incoming.contains { value in
guard let old = replaceRules.rules.first(where: { $0.id == value.id }) else { return true }
return old.pattern != value.pattern || old.replacement != value.replacement
}
default:
let incoming = try BookSourceImporter().decode(data)
guard incoming.first?.bookSourceUrl.isEmpty == false else { throw BookSourceImportError.emptyDocument }
return incoming.contains { value in bookSources.sources.first(where: { $0.bookSourceUrl == value.bookSourceUrl }).map { $0.lastUpdateTime < value.lastUpdateTime } ?? true }
}
}
private func persist(_ updated: [RuleSub]) async -> Bool {
guard let repository else { errorMessage = "无法打开规则订阅数据库"; return false }
do { try await repository.saveRuleSubs(updated); values = try await repository.loadRuleSubs().sorted { $0.customOrder < $1.customOrder }; return true }
catch { errorMessage = error.localizedDescription; return false }
}
}
struct RuleSubscriptionView: View {
@EnvironmentObject private var bookSources: SourceLibraryModel
@EnvironmentObject private var replaceRules: ReplaceRuleLibraryModel
@EnvironmentObject private var model: RuleSubscriptionModel
@State private var editing: RuleSub?
private var visibleValues: [RuleSub] { model.values.filter { $0.type != 1 } }
var body: some View {
List {
if visibleValues.isEmpty { ReadingUnavailableView("没有规则订阅", systemImage: "link.badge.plus") }
ForEach(visibleValues) { value in
Button { open(value) } label: {
HStack(spacing: 12) {
Text(typeName(value.type)).font(.caption.bold()).foregroundStyle(ReadingTheme.accent).frame(width: 48)
VStack(alignment: .leading, spacing: 4) { Text(value.name.isEmpty ? value.url : value.name).foregroundStyle(.primary); Text(value.url).font(.caption).foregroundStyle(.secondary).lineLimit(1) }
Spacer()
if value.autoUpdate { Image(systemName: value.silentUpdate ? "arrow.triangle.2.circlepath.circle.fill" : "arrow.triangle.2.circlepath.circle").foregroundStyle(.secondary) }
if model.pendingIDs.contains(value.id) { Text("有更新").font(.caption2).foregroundStyle(.orange) }
}
}.swipeActions(edge: .leading) { Button("编辑", systemImage: "pencil") { editing = value }.tint(.blue) }
}.onDelete { offsets in
let ids = Set(offsets.compactMap { visibleValues.indices.contains($0) ? visibleValues[$0].id : nil })
let sourceOffsets = IndexSet(model.values.indices.filter { ids.contains(model.values[$0].id) })
Task { await model.remove(at: sourceOffsets) }
}
}
.navigationTitle("规则订阅")
.toolbar { ToolbarItem(placement: .primaryAction) { Button("新建", systemImage: "plus") { editing = RuleSub() } }; ToolbarItem(placement: .primaryAction) { EditButton() } }
.task { await model.load() }
.sheet(item: $editing) { value in RuleSubscriptionEditView(value: value) { item in await model.save(item) } }
.alert("规则订阅", isPresented: Binding(get: { model.errorMessage != nil }, set: { if !$0 { model.errorMessage = nil } })) { Button("好") { model.errorMessage = nil } } message: { Text(model.errorMessage ?? "") }
}
private func open(_ value: RuleSub) {
model.clearPending(value.id)
Task {
switch value.type {
case 2: await replaceRules.importRemoteURL(value.url)
default: await bookSources.importRemoteURL(value.url)
}
}
}
private func typeName(_ type: Int) -> String { type == 2 ? "净化" : "书源" }
}
private struct RuleSubscriptionEditView: View {
@Environment(\.dismiss) private var dismiss
@State private var value: RuleSub
@State private var saving = false
let save: (RuleSub) async -> Bool
init(value: RuleSub, save: @escaping (RuleSub) async -> Bool) { _value = State(initialValue: value); self.save = save }
var body: some View {
ReadingNavigationStack {
Form {
Picker("类型", selection: $value.type) { Text("书源").tag(0); Text("净化规则").tag(2) }
TextField("名称", text: $value.name)
ReadingTextField("订阅URL", text: $value.url, axis: .vertical).textInputAutocapitalization(.never).disableAutocorrection(true)
Toggle("自动更新", isOn: $value.autoUpdate)
if value.autoUpdate {
Stepper("更新间隔:\(value.updateInterval) 小时", value: $value.updateInterval, in: 1...720)
Toggle("静默更新", isOn: $value.silentUpdate)
}
}.navigationTitle("规则订阅").toolbar {
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
ToolbarItem(placement: .confirmationAction) { Button(saving ? "保存中…" : "保存") { saving = true; Task { if await save(value) { dismiss() }; saving = false } }.disabled(saving || value.url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) }
}
}
}
}
@@ -0,0 +1,224 @@
import SwiftUI
import UniformTypeIdentifiers
@MainActor
final class TxtTocRuleManagementModel: ObservableObject {
@Published private(set) var rules: [TxtTocRule] = []
@Published var message: String?
private let repository: SQLiteConfigurationRepository?
init() {
repository = (try? LegadoDatabase(url: LegadoDatabase.defaultURL())).map {
SQLiteConfigurationRepository(database: $0)
}
}
func load() async {
guard let repository else { message = "无法打开目录规则数据库"; return }
do {
rules = try await repository.loadTxtTocRules().sorted { $0.serialNumber < $1.serialNumber }
if rules.isEmpty { await importDefaults(report: false) }
}
catch { message = error.localizedDescription }
}
func importDefaults(report: Bool = true) async {
let defaults = TxtTocRuleDefaults.load()
guard !defaults.isEmpty else { message = "应用内置目录规则缺失"; return }
var updated = rules.filter { $0.id >= 0 }
updated.append(contentsOf: defaults)
if await persist(updated), report { message = "已恢复内置目录规则" }
}
func save(_ rule: TxtTocRule) async -> Bool {
guard !rule.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!rule.rule.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || rule.id == -100 else {
message = "名称和正则不能为空"
return false
}
guard rule.rule.isEmpty || (try? NSRegularExpression(pattern: rule.rule, options: .anchorsMatchLines)) != nil else {
message = "目录正则语法错误"
return false
}
var updated = rules
if let index = updated.firstIndex(where: { $0.id == rule.id }) { updated[index] = rule }
else { var value = rule; value.serialNumber = (updated.map(\.serialNumber).max() ?? 0) + 1; updated.append(value) }
return await persist(updated)
}
func toggle(_ rule: TxtTocRule) async {
var value = rule; value.enable.toggle(); _ = await save(value)
}
func remove(at offsets: IndexSet) async {
_ = await persist(rules.enumerated().filter { !offsets.contains($0.offset) }.map(\.element))
}
func move(from offsets: IndexSet, to destination: Int) async {
var updated = rules
updated.move(fromOffsets: offsets, toOffset: destination)
for index in updated.indices { updated[index].serialNumber = index + 1 }
_ = await persist(updated)
}
func importData(_ data: Data) async {
do {
let decoded: [TxtTocRule]
if let values = try? JSONDecoder().decode([TxtTocRule].self, from: data) { decoded = values }
else { decoded = [try JSONDecoder().decode(TxtTocRule.self, from: data)] }
var merged = rules
for value in decoded {
if let index = merged.firstIndex(where: { $0.id == value.id }) { merged[index] = value }
else { merged.append(value) }
}
for index in merged.indices { merged[index].serialNumber = index + 1 }
if await persist(merged) { message = "已导入 \(decoded.count) 条目录规则" }
} catch { message = error.localizedDescription }
}
private func persist(_ values: [TxtTocRule]) async -> Bool {
guard let repository else { message = "无法打开目录规则数据库"; return false }
do {
try await repository.saveTxtTocRules(values)
rules = try await repository.loadTxtTocRules().sorted { $0.serialNumber < $1.serialNumber }
return true
} catch { message = error.localizedDescription; return false }
}
}
struct TxtTocRuleManagementView: View {
@StateObject private var model = TxtTocRuleManagementModel()
@State private var editing: TxtTocRule?
@State private var importing = false
@State private var exporting = false
var body: some View {
List {
if model.rules.isEmpty {
ReadingUnavailableView("没有TXT目录规则", systemImage: "list.bullet.rectangle")
}
ForEach(model.rules) { rule in
Button { editing = rule } label: {
HStack(spacing: 12) {
Image(systemName: rule.enable ? "checkmark.circle.fill" : "circle")
.foregroundStyle(rule.enable ? ReadingTheme.accent : .secondary)
VStack(alignment: .leading, spacing: 4) {
Text(rule.name).foregroundStyle(.primary)
Text(rule.rule).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(2)
}
}
}
.swipeActions(edge: .leading) {
Button(rule.enable ? "停用" : "启用") { Task { await model.toggle(rule) } }
.tint(rule.enable ? .orange : .green)
}
}
.onDelete { offsets in Task { await model.remove(at: offsets) } }
.onMove { offsets, destination in Task { await model.move(from: offsets, to: destination) } }
}
.navigationTitle("TXT目录规则")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("新建", systemImage: "plus") { editing = TxtTocRule() }
}
ToolbarItem(placement: .primaryAction) {
Menu {
Button("导入JSON", systemImage: "square.and.arrow.down") { importing = true }
Button("导出JSON", systemImage: "square.and.arrow.up") { exporting = true }
.disabled(model.rules.isEmpty)
Button("恢复内置规则", systemImage: "arrow.counterclockwise") {
Task { await model.importDefaults() }
}
EditButton()
} label: { Image(systemName: "ellipsis.circle") }
}
}
.task { await model.load() }
.sheet(item: $editing) { value in
TxtTocRuleEditView(value: value) { await model.save($0) }
}
.fileImporter(isPresented: $importing, allowedContentTypes: [.json, .plainText]) { result in
guard case .success(let url) = result else { return }
let granted = url.startAccessingSecurityScopedResource()
defer { if granted { url.stopAccessingSecurityScopedResource() } }
if let data = try? Data(contentsOf: url) { Task { await model.importData(data) } }
}
.fileExporter(
isPresented: $exporting,
document: TxtTocRuleDocument(rules: model.rules),
contentType: .json,
defaultFilename: "txtTocRules.json"
) { result in
if case .failure(let error) = result { model.message = error.localizedDescription }
}
.alert("TXT目录规则", isPresented: Binding(
get: { model.message != nil },
set: { if !$0 { model.message = nil } }
)) { Button("好") { model.message = nil } } message: { Text(model.message ?? "") }
}
}
private struct TxtTocRuleEditView: View {
@Environment(\.dismiss) private var dismiss
@State private var value: TxtTocRule
@State private var sample = ""
@State private var saving = false
let save: (TxtTocRule) async -> Bool
init(value: TxtTocRule, save: @escaping (TxtTocRule) async -> Bool) {
_value = State(initialValue: value)
_sample = State(initialValue: value.example ?? "")
self.save = save
}
private var testResult: String {
guard let expression = try? NSRegularExpression(pattern: value.rule, options: .anchorsMatchLines) else { return "正则语法错误" }
let range = NSRange(sample.startIndex..., in: sample)
let values = expression.matches(in: sample, range: range).prefix(20).compactMap { match -> String? in
guard let range = Range(match.range, in: sample) else { return nil }
return String(sample[range])
}
return values.isEmpty ? "没有匹配" : "匹配 \(values.count) 条:\n" + values.joined(separator: "\n")
}
var body: some View {
ReadingNavigationStack {
Form {
TextField("名称", text: $value.name)
ReadingTextField("目录正则", text: $value.rule, axis: .vertical)
.font(.body.monospaced()).textInputAutocapitalization(.never).disableAutocorrection(true)
ReadingTextField("标题处理JavaScript(可选)", text: Binding(
get: { value.replacement }, set: { value.replacement = $0 }
), axis: .vertical).font(.body.monospaced())
Toggle("启用", isOn: $value.enable)
Section("测试文本") {
TextEditor(text: $sample).frame(minHeight: 130)
Text(testResult).font(.caption.monospaced()).foregroundStyle(.secondary).textSelection(.enabled)
}
}
.navigationTitle("TXT目录规则")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
Button(saving ? "保存中…" : "保存") {
value.example = sample.isEmpty ? nil : sample
saving = true
Task { if await save(value) { dismiss() }; saving = false }
}.disabled(saving)
}
}
}
}
}
private struct TxtTocRuleDocument: FileDocument {
static var readableContentTypes: [UTType] { [.json] }
let rules: [TxtTocRule]
init(rules: [TxtTocRule]) { self.rules = rules }
init(configuration: ReadConfiguration) throws { rules = [] }
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return FileWrapper(regularFileWithContents: try encoder.encode(rules))
}
}
+248
View File
@@ -0,0 +1,248 @@
import Foundation
import SwiftUI
#if SWIFT_PACKAGE
import LegadoCore
#endif
@MainActor
final class SearchViewModel: ObservableObject {
enum State: Equatable { case idle, searching, finished, empty, unavailable }
@Published private(set) var state: State = .idle
@Published private(set) var books: [SearchBook] = []
@Published private(set) var diagnostics: [RuleDiagnostic] = []
@Published private(set) var isLoadingMore = false
@Published private(set) var hasMore = false
@Published private(set) var isPaused = false
@Published private(set) var searchKeywords: [SearchKeyword] = []
@Published private(set) var completedSourceCount = 0
@Published private(set) var totalSourceCount = 0
@Published private(set) var submittedKeyword = ""
@Published private(set) var isShowingCachedResults = false
private let service: BookSearchService
private let keywordRepository: any SearchKeywordRepository
private let searchBookRepository: any SearchBookRepository
private var searchTask: Task<Void, Never>?
private var currentKeyword = ""
private var pagedSources: [BookSource] = []
private var currentPrecision = false
private var page = 1
private var activePage = 1
private var searchGeneration = 0
private var pendingSources: [BookSource] = []
private var pendingSourceIDs = Set<String>()
private var sourceNames: [String: String] = [:]
private var accumulator = BookSearchAccumulator()
private var cachedIdentities = Set<String>()
private var liveIdentities = Set<String>()
private var collectedDiagnostics: [RuleDiagnostic] = []
private var completedCount = 0
private var foundMoreOnActivePage = false
init(service: BookSearchService = .init(),
keywordRepository: any SearchKeywordRepository = SearchKeywordRepositoryFactory.live(),
searchBookRepository: any SearchBookRepository = SearchBookRepositoryFactory.live()) {
self.service = service
self.keywordRepository = keywordRepository
self.searchBookRepository = searchBookRepository
}
deinit { searchTask?.cancel() }
func search(keyword: String, sources: [BookSource], precision: Bool = false) {
let keyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !keyword.isEmpty else { cancel(reset: true); return }
searchTask?.cancel()
searchGeneration &+= 1
currentKeyword = keyword
submittedKeyword = keyword
currentPrecision = precision
page = 1
activePage = 1
pendingSources = eligible(sources)
pendingSourceIDs = Set(pendingSources.map(\.id))
pagedSources = pendingSources.filter {
let url = $0.searchUrl ?? ""
return url.contains("{{page}}") || url.contains("searchPage") || url.contains("<")
}
sourceNames = Dictionary(pendingSources.map { ($0.id, $0.bookSourceName) }, uniquingKeysWith: { first, _ in first })
accumulator = .init()
cachedIdentities = []
liveIdentities = []
collectedDiagnostics = []
completedCount = 0
foundMoreOnActivePage = false
isShowingCachedResults = false
hasMore = true
isPaused = false
isLoadingMore = false
books = []
diagnostics = []
completedSourceCount = 0
totalSourceCount = pendingSources.count
guard totalSourceCount > 0 else { hasMore = false; state = .unavailable; return }
state = .searching
Task { await recordKeyword(keyword) }
startWorker(loadCache: true)
}
private func eligible(_ sources: [BookSource]) -> [BookSource] {
var seen = Set<String>()
return sources.filter { $0.enabled && $0.searchUrl?.isEmpty == false && seen.insert($0.id).inserted }
}
private func startWorker(loadCache: Bool = false) {
let generation = searchGeneration
let requestedPage = activePage
let keyword = currentKeyword
let sources = pendingSources.filter { pendingSourceIDs.contains($0.id) }
searchTask = Task { [service, searchBookRepository] in
var preferred = Set<String>()
if loadCache {
let cached = (try? await searchBookRepository.cachedMatches(keyword: keyword)) ?? []
guard !Task.isCancelled, generation == searchGeneration else { return }
let enabled = Dictionary(sources.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
let now = Date().timeIntervalSince1970 * 1_000
let usable = cached.filter { book in
guard let source = enabled[book.origin] else { return false }
let age = now - Double(book.time)
return age >= 0 && age < 86_400_000 && source.lastUpdateTime <= book.time
}
accumulator.add(usable)
cachedIdentities = Set(usable.map { BookSearchService.mergeIdentity(for: $0) })
preferred = Set(usable.flatMap(\.origins)).intersection(Set(enabled.keys))
publishResults()
}
var lastVisibleUpdate = ProcessInfo.processInfo.systemUptime - 1
for await outcome in service.searchOutcomes(keyword: keyword, page: requestedPage, sources: sources, preferredSourceIDs: preferred) {
guard !Task.isCancelled, generation == searchGeneration else { return }
pendingSourceIDs.remove(outcome.sourceID)
completedCount += 1
collectedDiagnostics += namedDiagnostics(outcome)
for book in outcome.books {
let identity = BookSearchService.mergeIdentity(for: book)
if requestedPage > 1 && !liveIdentities.contains(identity) { foundMoreOnActivePage = true }
liveIdentities.insert(identity)
accumulator.add([book], replacingExisting: cachedIdentities.remove(identity) != nil)
}
let now = ProcessInfo.processInfo.systemUptime
if books.isEmpty || now - lastVisibleUpdate >= 0.25 {
publishResults()
lastVisibleUpdate = now
}
if !outcome.books.isEmpty { try? await searchBookRepository.put(outcome.books) }
}
guard !Task.isCancelled, generation == searchGeneration else { return }
publishResults()
page = requestedPage
hasMore = requestedPage == 1 ? !books.isEmpty && !pagedSources.isEmpty : foundMoreOnActivePage
isLoadingMore = false
state = books.isEmpty ? .empty : .finished
}
}
private func publishResults() {
let values = accumulator.results(keyword: currentKeyword)
let visible = currentPrecision ? values.filter {
$0.name.localizedCaseInsensitiveContains(currentKeyword)
|| $0.author.localizedCaseInsensitiveContains(currentKeyword)
|| $0.kind?.localizedCaseInsensitiveContains(currentKeyword) == true
} : values
if books != visible { books = visible }
if diagnostics != collectedDiagnostics { diagnostics = collectedDiagnostics }
if completedSourceCount != completedCount { completedSourceCount = completedCount }
let cachedOnly = !visible.isEmpty && liveIdentities.isEmpty
if isShowingCachedResults != cachedOnly { isShowingCachedResults = cachedOnly }
}
func loadMore() {
guard state == .finished, hasMore, !isLoadingMore, !isPaused, !currentKeyword.isEmpty else { return }
isLoadingMore = true
searchGeneration &+= 1
activePage = page + 1
pendingSources = eligible(pagedSources)
pendingSourceIDs = Set(pendingSources.map(\.id))
foundMoreOnActivePage = false
startWorker()
}
func cancel(reset: Bool = false) {
searchTask?.cancel()
searchTask = nil
searchGeneration &+= 1
isLoadingMore = false
if reset {
accumulator = .init()
cachedIdentities = []
liveIdentities = []
collectedDiagnostics = []
pendingSources = []
pendingSourceIDs = []
isShowingCachedResults = false
isPaused = false
state = .idle
books = []
diagnostics = []
hasMore = false
completedSourceCount = 0
totalSourceCount = 0
submittedKeyword = ""
} else {
publishResults()
isPaused = true
state = books.isEmpty ? .empty : .finished
}
}
func resume() {
guard isPaused else { return }
isPaused = false
guard !pendingSourceIDs.isEmpty else { loadMore(); return }
if activePage == 1 { state = .searching }
else { isLoadingMore = true }
startWorker()
}
private func namedDiagnostics(_ outcome: SourceSearchOutcome) -> [RuleDiagnostic] {
guard let name = sourceNames[outcome.sourceID] else { return outcome.diagnostics }
return outcome.diagnostics.map {
var value = $0
value.message = "\(name):\(value.message)"
return value
}
}
private func addDiagnostic(_ diagnostic: RuleDiagnostic) {
collectedDiagnostics.append(diagnostic)
diagnostics.append(diagnostic)
}
func loadSearchKeywords() async {
do { searchKeywords = try await keywordRepository.load(order: .lastUseTime) }
catch { addDiagnostic(.init(severity: .warning, code: "search.history.load", message: error.localizedDescription, rule: "SearchKeywordDao.flowByTime")) }
}
func clearSearchKeywords() async {
do {
try await keywordRepository.deleteAll()
searchKeywords = []
UserDefaults.standard.removeObject(forKey: "bookSearchHistory")
} catch { addDiagnostic(.init(severity: .warning, code: "search.history.clear", message: error.localizedDescription, rule: "SearchKeywordDao.deleteAll")) }
}
func deleteSearchKeyword(_ word: String) async {
do {
try await keywordRepository.delete(word)
searchKeywords.removeAll { $0.word == word }
} catch { addDiagnostic(.init(severity: .warning, code: "search.history.delete", message: error.localizedDescription, rule: "SearchKeywordDao.delete")) }
}
private func recordKeyword(_ word: String) async {
do {
_ = try await keywordRepository.record(word, at: Int64(Date().timeIntervalSince1970 * 1_000))
searchKeywords = try await keywordRepository.load(order: .lastUseTime)
UserDefaults.standard.removeObject(forKey: "bookSearchHistory")
} catch { addDiagnostic(.init(severity: .warning, code: "search.history.record", message: error.localizedDescription, rule: "SearchKeywordDao.insert/update")) }
}
}
+96
View File
@@ -0,0 +1,96 @@
import SwiftUI
import UIKit
enum AppIdentity {
static let name = "小浣熊阅读"
static let authorURL = URL(string: "https://github.com/Aminark")!
static let projectURL = URL(string: "https://github.com/Aminark/legado-ios")!
}
struct AboutView: View {
private var name: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? AppIdentity.name }
private var version: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "—" }
private var build: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "—" }
private var diagnosticSummary: String {
"\(name) \(version) (\(build))\niOS \(UIDevice.current.systemVersion)\n数据库版本 \(LegadoDatabase.schemaVersion)"
}
var body: some View {
List {
Section {
VStack(spacing: 12) {
Image("BrandIcon").resizable().scaledToFit()
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 18))
Text(name).font(.title2.weight(.semibold))
Text("快速搜书,自由阅读").font(.subheadline).foregroundStyle(.secondary)
Text("兼容阅读书源的 iOS 阅读应用")
.font(.footnote).foregroundStyle(.secondary)
}.frame(maxWidth: .infinity).padding(.vertical, 16)
ReadingLabeledContent("版本", value: version)
ReadingLabeledContent("构建", value: build)
ReadingLabeledContent("系统", value: "iOS \(UIDevice.current.systemVersion)")
ReadingShareLink(item: diagnosticSummary) { Label("分享版本信息", systemImage: "square.and.arrow.up") }
}
Section {
Link(destination: AppIdentity.authorURL) {
Label("开发者 · Aminark", systemImage: "person.crop.circle")
}
Link(destination: AppIdentity.projectURL) {
Label("项目源码 · legado-ios", systemImage: "chevron.left.forwardslash.chevron.right")
}
ReadingShareLink(item: AppIdentity.projectURL) {
Label("分享项目地址", systemImage: "square.and.arrow.up")
}
ReadingLabeledContent("许可证", value: "GPL-3.0")
ReadingLabeledContent("安装方式", value: "IPA 自行签名")
ReadingLabeledContent("App Store / TestFlight", value: "暂不提供")
} header: {
Text("项目与开发者")
} footer: {
Text("IPA 发布后可从项目 GitHub Releases 获取,并自行签名安装。项目暂不上架 App Store,也不提供 TestFlight。")
}
Section("开源许可") {
NavigationLink("开源许可") { OpenSourceLicensesView() }
}
Section("免责协议") {
Text("小浣熊阅读不提供、内置或分发网络书源,也不提供在线书库。书源由用户自行从网络公开分享获取,或自行编写后导入;在线书籍及订阅内容由对应的第三方网站提供。")
Text("书籍正文、封面、简介等内容的版权归原作者或合法权利人所有。书源可被导入、内容可被访问,不代表已获得版权授权;请仅在获得授权或法律允许的范围内使用,并支持正版阅读。")
Text("第三方书源及其内容不受本应用控制,可能因网站调整、网络环境等原因失效。本应用不保证第三方内容的准确性、完整性、安全性或书源的持续可用性,请自行核实来源。")
Text("用户应对自行导入的书源及使用行为负责,遵守适用法律法规和来源网站的使用规则,不得利用本应用侵犯他人权益、传播违法内容或未经授权复制、分发作品。")
Text("如发现涉嫌侵权内容,请停止访问、删除相关书源及本地缓存,并向内容来源网站反馈。本协议不免除任何一方依法应承担的责任。")
}
.font(.footnote)
.lineSpacing(4)
.textSelection(.enabled)
Section("数据与隐私") {
Text("书架、阅读进度、书源和诊断日志保存在本机。导入书源、阅读在线内容和刷新订阅时,会连接相应来源的网站。")
Text("应用不会自动上传诊断日志;可在日志页面手动导出。")
Text("应用不提供内置网络书源或在线书库。请使用你有权访问的内容与书源。")
}.font(.footnote)
}
.navigationTitle("关于")
.navigationBarTitleDisplayMode(.inline)
}
}
private struct OpenSourceLicensesView: View {
var body: some View {
List {
license("小浣熊阅读 / Legado", resource: "Legado-LICENSE", detail: "GNU GPL v3")
license("SwiftSoup", resource: "SwiftSoup-LICENSE", detail: "MIT License")
license("Kanna", resource: "Kanna-LICENSE", detail: "MIT License")
}.navigationTitle("开源许可").navigationBarTitleDisplayMode(.inline)
}
private func license(_ title: String, resource: String, detail: String) -> some View {
NavigationLink {
ScrollView {
Text((Bundle.main.url(forResource: resource, withExtension: "txt")
.flatMap { try? String(contentsOf: $0, encoding: .utf8) }) ?? "许可文件无法读取")
.font(.system(.footnote, design: .monospaced)).textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading).padding()
}.navigationTitle(title).navigationBarTitleDisplayMode(.inline)
} label: { ReadingLabeledContent(title, value: detail) }
}
}
@@ -0,0 +1,22 @@
import Foundation
import MetricKit
final class AppDiagnostics: NSObject, MXMetricManagerSubscriber, @unchecked Sendable {
static let shared = AppDiagnostics()
func start() {
MXMetricManager.shared.add(self)
DiagnosticLog.shared.record(category: "应用", message: "启动 \(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "")")
}
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
for crash in payload.crashDiagnostics ?? [] {
DiagnosticLog.shared.record(.error, category: "系统诊断", message: "崩溃诊断:\(String(decoding: crash.jsonRepresentation(), as: UTF8.self))")
}
for hang in payload.hangDiagnostics ?? [] {
DiagnosticLog.shared.record(.warning, category: "系统诊断", message: "卡顿诊断:\(String(decoding: hang.jsonRepresentation(), as: UTF8.self))")
}
}
}
}
@@ -0,0 +1,98 @@
import SwiftUI
import UniformTypeIdentifiers
struct DiagnosticLogView: View {
@State private var entries: [DiagnosticLogEntry] = []
@State private var query = ""
@State private var level = "all"
@State private var confirmsClear = false
@State private var exportsLog = false
@State private var exportText = ""
@State private var errorMessage: String?
private var filtered: [DiagnosticLogEntry] {
entries.filter {
(level == "all" || $0.level.rawValue == level)
&& (query.isEmpty || "\($0.category) \($0.message)".localizedCaseInsensitiveContains(query))
}
}
var body: some View {
List {
Section {
Picker("级别", selection: $level) {
Text("全部").tag("all")
Text("信息").tag("info")
Text("警告").tag("warning")
Text("错误").tag("error")
}.pickerStyle(.segmented)
Text("\(entries.count) 条日志 · 下拉刷新")
.font(.footnote).foregroundStyle(.secondary)
} footer: {
Text("在“其他设置”开启记录后,会保存网络请求、JS 输出和运行错误。崩溃诊断由 iOS 延迟提供,可搜索“系统诊断”查看。")
}
if filtered.isEmpty {
ReadingUnavailableView(query.isEmpty ? "暂无日志" : "没有匹配的日志", systemImage: "doc.text.magnifyingglass")
} else {
ForEach(filtered) { entry in
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(entry.category).font(.caption.weight(.semibold))
Text(levelTitle(entry.level)).font(.caption)
.foregroundStyle(entry.level == .error ? .red : .secondary)
Spacer()
Text(entry.date, format: .dateTime.month().day().hour().minute().second())
.font(.caption2).foregroundStyle(.secondary)
}
Text(entry.message).font(.system(.footnote, design: .monospaced)).textSelection(.enabled)
}.padding(.vertical, 3)
}
}
}
.navigationTitle("日志与诊断")
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $query, prompt: "搜索内容或分类")
.task { reload() }
.refreshable { reload() }
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu("日志操作", systemImage: "ellipsis") {
Button("刷新", systemImage: "arrow.clockwise") { reload() }
Button("导出全部日志", systemImage: "square.and.arrow.up") {
exportText = DiagnosticLog.shared.export()
exportsLog = true
}.disabled(entries.isEmpty)
Button("清空日志", systemImage: "trash", role: .destructive) { confirmsClear = true }
.disabled(entries.isEmpty)
}
}
}
.confirmationDialog("清空全部日志?", isPresented: $confirmsClear, titleVisibility: .visible) {
Button("清空日志", role: .destructive) {
do { try DiagnosticLog.shared.clear(); reload() }
catch { errorMessage = error.localizedDescription }
}
}
.fileExporter(isPresented: $exportsLog, document: DiagnosticLogDocument(text: exportText), contentType: .plainText, defaultFilename: "阅读诊断日志") { result in
if case .failure(let error) = result { errorMessage = error.localizedDescription }
}
.alert("日志操作失败", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
Button("好") { errorMessage = nil }
} message: { Text(errorMessage ?? "") }
}
private func reload() { entries = DiagnosticLog.shared.snapshot() }
private func levelTitle(_ level: DiagnosticLogEntry.Level) -> String {
switch level { case .info: "信息"; case .warning: "警告"; case .error: "错误" }
}
}
private struct DiagnosticLogDocument: FileDocument {
static var readableContentTypes: [UTType] { [.plainText] }
var text: String
init(text: String) { self.text = text }
init(configuration: ReadConfiguration) throws {
text = String(decoding: configuration.file.regularFileContents ?? Data(), as: UTF8.self)
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { .init(regularFileWithContents: Data(text.utf8)) }
}
@@ -0,0 +1,179 @@
import SwiftUI
import WebKit
struct OtherSettingsView: View {
@AppStorage("auto_refresh") private var autoRefresh = false
@AppStorage("onlyUpdateRead") private var onlyUpdateRead = false
@AppStorage("defaultHomePage") private var defaultHomePage = "bookshelf"
@State private var settings = ApplicationSettingsStore.shared.settings
@State private var maintenance: Maintenance?
@State private var isWorking = false
@State private var message: String?
private enum Maintenance: String, Identifiable {
case webCache = "清理网页缓存"
case database = "整理数据库"
var id: String { rawValue }
}
var body: some View {
Form {
Section("启动与书架") {
Picker("启动页面", selection: $defaultHomePage) {
Text("书架").tag("bookshelf")
Text("发现").tag("explore")
Text("统计").tag("statistics")
Text("设置").tag("settings")
}
Toggle("启动时更新书架", isOn: $autoRefresh)
Toggle("自动更新仅检查读过的书", isOn: $onlyUpdateRead).disabled(!autoRefresh)
}
Section("网络与存储") {
NavigationLink {
NetworkSettingsView()
} label: { Label("网络与缓存设置", systemImage: "network") }
NavigationLink {
CacheManagementView()
} label: { Label("章节缓存管理", systemImage: "internaldrive") }
Button("清理网页缓存", systemImage: "globe") { maintenance = .webCache }
Button("整理数据库", systemImage: "externaldrive.badge.checkmark") { maintenance = .database }
}
Section {
Toggle("记录运行日志", isOn: Binding(get: { settings.recordLog }, set: { enabled in
var value = ApplicationSettingsStore.shared.settings
value.recordLog = enabled
do { try ApplicationSettingsStore.shared.save(value); settings = value }
catch { message = error.localizedDescription }
}))
NavigationLink {
DiagnosticLogView()
} label: { Label("日志与诊断", systemImage: "doc.text.magnifyingglass") }
} header: { Text("诊断") } footer: {
Text("日志仅保存在本机,最多保留 500 条。网络日志不记录请求正文与 Header,URL 查询参数和常见凭据会隐藏。")
}
}
.navigationTitle("其他设置")
.navigationBarTitleDisplayMode(.inline)
.disabled(isWorking)
.overlay { if isWorking { ProgressView("正在处理…").padding().background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) } }
.onAppear { settings = ApplicationSettingsStore.shared.settings }
.confirmationDialog(maintenance?.rawValue ?? "", isPresented: Binding(
get: { maintenance != nil }, set: { if !$0 { maintenance = nil } }
), titleVisibility: .visible) {
if let operation = maintenance {
Button(operation.rawValue) { Task { await perform(operation) } }
}
Button("取消", role: .cancel) { maintenance = nil }
} message: {
Text(maintenance == .webCache ? "清理网页和图片的临时缓存,保留登录 Cookie、书架和阅读进度。" : "回收数据库中的空闲空间,保留全部书籍、规则和阅读记录。")
}
.alert("操作结果", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) {
Button("好") { message = nil }
} message: { Text(message ?? "") }
}
@MainActor private func perform(_ operation: Maintenance) async {
maintenance = nil
isWorking = true
defer { isWorking = false }
do {
switch operation {
case .webCache:
await WKWebsiteDataStore.default().removeData(ofTypes: [WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache, WKWebsiteDataTypeOfflineWebApplicationCache], modifiedSince: .distantPast)
URLCache.shared.removeAllCachedResponses()
case .database:
let database = try LegadoDatabase(url: LegadoDatabase.defaultURL())
try await database.compact()
}
DiagnosticLog.shared.record(category: "存储", message: "\(operation.rawValue)完成")
message = "\(operation.rawValue)完成"
} catch {
DiagnosticLog.shared.record(.error, category: "存储", message: error.localizedDescription)
message = error.localizedDescription
}
}
}
struct NetworkSettingsView: View {
@Environment(\.dismiss) private var dismiss
@State private var settings = ApplicationSettingsStore.shared.settings
@State private var errorMessage: String?
@State private var isSaving = false
var body: some View {
Form {
Section {
ReadingTextField("留空使用默认浏览器 UA", text: $settings.userAgent, axis: .vertical)
.textInputAutocapitalization(.never).disableAutocorrection(true)
.accessibilityLabel("User-Agent")
} header: { Text("默认 User-Agent") } footer: {
Text("书源或 RSS 单独设置的 UA 优先于此项。")
}
Section {
TextField("例如 http://127.0.0.1:7890", text: $settings.proxy)
.keyboardType(.URL).textInputAutocapitalization(.never).disableAutocorrection(true)
.accessibilityLabel("全局代理")
} header: { Text("全局代理") } footer: {
Text("支持 HTTP、HTTPS 和 SOCKS5 地址,不含账号密码。书源代理优先于全局代理;使用代理时由代理解析域名。")
}
Section {
TextEditor(text: $settings.hosts)
.font(.system(.footnote, design: .monospaced)).frame(minHeight: 130)
.textInputAutocapitalization(.never).disableAutocorrection(true)
.accessibilityLabel("Hosts")
} header: { Text("自定义 Hosts") } footer: {
Text("每行填写 IP地址 域名,例如 192.168.1.2 books.example.com。支持 IPv4、IPv6 和 # 注释。适用于应用内 HTTP/HTTPS 请求;系统网页、登录网页和系统媒体播放器使用系统网络设置。")
}
Section("连接") {
Picker("请求超时", selection: $settings.timeoutSeconds) {
Text("跟随书源").tag(0)
ForEach([5, 10, 15, 30, 60, 120, 180, 300], id: \.self) { Text("\($0) 秒").tag($0) }
}
Stepper("搜索并发数:\(settings.concurrentSources)", value: $settings.concurrentSources, in: 1...64)
Toggle("兼容无效证书的书源", isOn: $settings.ignoreCertificateErrors)
Text("证书兼容开启时允许自签名或过期证书;关闭后由系统验证 HTTPS 证书。")
.font(.footnote).foregroundStyle(.secondary)
}
Section("章节缓存") {
Picker("容量上限", selection: $settings.chapterCacheMB) {
ForEach([16, 32, 64, 128, 256, 512, 1024, 2048, 4096], id: \.self) {
Text($0 >= 1024 ? "\($0 / 1024) GB" : "\($0) MB").tag($0)
}
}
Stepper(settings.prefetchChapters == 0 ? "关闭章节预加载" : "预加载 \(settings.prefetchChapters) 章", value: $settings.prefetchChapters, in: 0...20)
Text("超出上限时清理最久未读取的章节;容量调整在保存时生效。")
.font(.footnote).foregroundStyle(.secondary)
}
Section {
Button("恢复本页默认值") {
let recordLog = settings.recordLog
settings = .init()
settings.recordLog = recordLog
}
}
}
.navigationTitle("网络与缓存")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("保存") { Task { await save() } }.disabled(isSaving)
}
}
.disabled(isSaving)
.alert("保存失败", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
Button("好") { errorMessage = nil }
} message: { Text(errorMessage ?? "") }
}
@MainActor private func save() async {
isSaving = true
defer { isSaving = false }
do {
settings.recordLog = ApplicationSettingsStore.shared.settings.recordLog
try ApplicationSettingsStore.shared.save(settings)
try await ChapterContentCache.live().pruneIfNeeded()
DiagnosticLog.shared.record(category: "设置", message: "网络与缓存设置已保存")
dismiss()
} catch { errorMessage = error.localizedDescription }
}
}
+440
View File
@@ -0,0 +1,440 @@
import SwiftUI
@MainActor
final class SourceDebugViewModel: ObservableObject {
@Published var keyword = ""
@Published private(set) var isRunning = false
@Published private(set) var report: BookSourceDebugReport?
private let debugger: BookSourceDebugger
private var task: Task<Void, Never>?
init(debugger: BookSourceDebugger = .init()) {
self.debugger = debugger
}
deinit { task?.cancel() }
func run(source: BookSource) {
let keyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !keyword.isEmpty else { return }
task?.cancel()
isRunning = true
report = nil
task = Task { [debugger] in
let value = await debugger.run(source: source, keyword: keyword)
guard !Task.isCancelled else { return }
report = value
isRunning = false
}
}
}
struct SourceDebugView: View {
let source: BookSource
@StateObject private var model = SourceDebugViewModel()
var body: some View {
ScrollView {
VStack(spacing: 16) {
introCard
runCard
if model.isRunning { runningCard }
if let report = model.report { reportView(report) }
}
.padding(.horizontal, 16)
.padding(.bottom, 30)
}
.background(ReadingTheme.canvas)
.navigationTitle("书源调试")
.navigationBarTitleDisplayMode(.inline)
}
private var introCard: some View {
ReadingCard {
HStack(spacing: 13) {
Image(systemName: "stethoscope")
.font(.title2).foregroundStyle(ReadingTheme.coral)
.frame(width: 46, height: 46)
.background(ReadingTheme.coral.opacity(0.11), in: RoundedRectangle(cornerRadius: 14))
VStack(alignment: .leading, spacing: 3) {
Text(source.bookSourceName).font(.headline)
Text("依次验证搜索 → 详情 → 目录 → 第一章正文")
.font(.caption).foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
}
.padding(.top, 8)
}
private var runCard: some View {
ReadingCard {
VStack(alignment: .leading, spacing: 12) {
Text("样本关键词").font(.caption.bold()).foregroundStyle(.secondary)
TextField("输入该书源能搜到的书名", text: $model.keyword)
.textFieldStyle(.roundedBorder)
.textInputAutocapitalization(.never)
.submitLabel(.go)
.onSubmit { model.run(source: source) }
Button { model.run(source: source) } label: {
Label(model.isRunning ? "正在调试…" : "运行完整链路", systemImage: "play.fill")
.font(.headline).frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(ReadingTheme.accent)
.disabled(model.isRunning || model.keyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
private var runningCard: some View {
ReadingCard {
HStack(spacing: 12) {
ProgressView()
VStack(alignment: .leading, spacing: 3) {
Text("端到端检查进行中").font(.subheadline.bold())
Text("请求只发送给这个书源,最多读取 5 页目录与正文。")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
}
}
}
@ViewBuilder
private func reportView(_ report: BookSourceDebugReport) -> some View {
ReadingCard {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(report.passed ? "完整链路通过" : "发现兼容问题").font(.headline)
Text(report.passed ? "该样本已成功读取到正文" : "展开失败阶段查看规则诊断")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Image(systemName: report.passed ? "checkmark.seal.fill" : "exclamationmark.triangle.fill")
.font(.title2).foregroundStyle(report.passed ? .green : .orange)
}
}
VStack(spacing: 10) {
ForEach(report.steps) { step in DebugStepCard(step: step) }
}
if let preview = report.contentPreview {
ReadingCard {
VStack(alignment: .leading, spacing: 9) {
Label(report.sampleChapterTitle ?? "正文样本", systemImage: "text.quote")
.font(.headline)
Text(preview).font(.footnote).foregroundStyle(.secondary).lineSpacing(3)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
}
@MainActor
private final class SourceBatchTestModel: ObservableObject {
enum Status: Equatable {
case waiting
case testing
case passed(Int)
case failed(String)
}
@Published private(set) var statuses: [String: Status] = [:]
@Published private(set) var completedCount = 0
@Published private(set) var totalCount = 0
@Published private(set) var isTesting = false
@Published var selectedFailedIDs = Set<String>()
var failedCount: Int {
statuses.values.reduce(into: 0) { count, status in
if case .failed = status { count += 1 }
}
}
private let service: BookSearchService
private var task: Task<Void, Never>?
init(service: BookSearchService = .init()) {
self.service = service
}
deinit { task?.cancel() }
func start(sources: [BookSource], keyword: String) {
stop()
let keyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !keyword.isEmpty, !sources.isEmpty else { return }
statuses = Dictionary(uniqueKeysWithValues: sources.map { ($0.id, .waiting) })
selectedFailedIDs = []
completedCount = 0
totalCount = sources.count
isTesting = true
task = Task { [service] in
for source in sources.prefix(16) { statuses[source.id] = .testing }
var startedCount = min(16, sources.count)
for await outcome in service.searchOutcomes(
keyword: keyword,
sources: sources,
maximumConcurrentSources: ApplicationSettingsStore.shared.settings.concurrentSources,
includeDisabled: true
) {
guard !Task.isCancelled else { return }
completedCount += 1
if outcome.books.isEmpty {
let message = outcome.diagnostics.first(where: { $0.severity == .error })?.message
?? "没有搜索到结果"
statuses[outcome.sourceID] = .failed(message)
selectedFailedIDs.insert(outcome.sourceID)
} else {
statuses[outcome.sourceID] = .passed(outcome.books.count)
}
if startedCount < sources.count {
statuses[sources[startedCount].id] = .testing
startedCount += 1
}
}
guard !Task.isCancelled else { return }
isTesting = false
task = nil
}
}
func stop() {
task?.cancel()
task = nil
isTesting = false
for (id, status) in statuses where status == .testing || status == .waiting {
statuses[id] = .waiting
}
}
func toggleSelection(_ sourceID: String) {
if !selectedFailedIDs.insert(sourceID).inserted { selectedFailedIDs.remove(sourceID) }
}
}
struct SourceBatchTestView: View {
private enum CleanupAction: String, Identifiable {
case disable
case delete
var id: String { rawValue }
}
@EnvironmentObject private var library: SourceLibraryModel
@StateObject private var model = SourceBatchTestModel()
@AppStorage("sourceTestKeyword") private var keyword = "我的"
@State private var includesDisabled = false
@State private var onlyShowsFailed = false
@State private var cleanupAction: CleanupAction?
private var testedSources: [BookSource] {
includesDisabled ? library.sources : library.sources.filter(\.enabled)
}
private var visibleSources: [BookSource] {
guard onlyShowsFailed else { return testedSources }
return testedSources.filter {
if case .failed? = model.statuses[$0.id] { return true }
return false
}
}
var body: some View {
List {
Section {
TextField("校验搜索词", text: $keyword)
.textInputAutocapitalization(.never)
Toggle("包含已停用书源", isOn: $includesDisabled)
.disabled(model.isTesting)
Toggle("只看失败", isOn: $onlyShowsFailed)
} footer: {
Text("快速测试以搜索是否能返回结果为准。部分正常书源可能不收录当前关键词,删除前请复核或更换关键词重测。")
}
if model.totalCount > 0 {
Section {
ProgressView(value: Double(model.completedCount), total: Double(model.totalCount))
HStack {
Text("已完成 \(model.completedCount)/\(model.totalCount)")
Spacer()
Text("失败 \(model.failedCount) · 已选 \(model.selectedFailedIDs.count)")
.foregroundStyle(model.failedCount == 0 ? Color.secondary : Color.red)
}
.font(.subheadline.monospacedDigit())
}
}
Section("书源") {
ForEach(visibleSources) { source in
SourceBatchTestRow(
source: source,
status: model.statuses[source.id] ?? .waiting,
selected: model.selectedFailedIDs.contains(source.id),
onToggle: { model.toggleSelection(source.id) }
)
}
}
}
.navigationTitle("书源测试")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
if model.isTesting {
Button("停止", systemImage: "stop.fill") { model.stop() }
} else {
Button("开始", systemImage: "play.fill") {
model.start(sources: testedSources, keyword: keyword)
}
.disabled(testedSources.isEmpty || keyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
.safeAreaInset(edge: .bottom) {
if !model.isTesting, !model.selectedFailedIDs.isEmpty {
HStack {
Button("停用所选", systemImage: "pause.circle") { cleanupAction = .disable }
Spacer()
Button("删除所选", systemImage: "trash", role: .destructive) { cleanupAction = .delete }
}
.font(.subheadline.weight(.semibold))
.padding(.horizontal, 18).padding(.vertical, 12)
.background(.ultraThinMaterial)
}
}
.alert(item: $cleanupAction) { action in
Alert(
title: Text(action == .delete ? "删除失败书源?" : "停用失败书源?"),
message: Text("将处理已勾选的 \(model.selectedFailedIDs.count) 个书源。搜索不到当前关键词不一定代表书源永久失效。"),
primaryButton: .destructive(Text(action == .delete ? "删除" : "停用")) {
let ids = model.selectedFailedIDs
Task {
if action == .delete { await library.remove(sourceIDs: ids) }
else { await library.setEnabled(false, for: ids) }
model.selectedFailedIDs = []
}
},
secondaryButton: .cancel()
)
}
}
}
private struct SourceBatchTestRow: View {
let source: BookSource
let status: SourceBatchTestModel.Status
let selected: Bool
let onToggle: () -> Void
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
.foregroundStyle(color)
.frame(width: 28)
VStack(alignment: .leading, spacing: 3) {
Text(source.bookSourceName).font(.headline)
Text(detail).font(.caption).foregroundStyle(.secondary).lineLimit(2)
}
Spacer()
if case .failed = status {
Button(action: onToggle) {
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
.font(.title3)
}
.buttonStyle(.plain)
}
}
}
private var detail: String {
switch status {
case .waiting: "等待测试"
case .testing: "正在测试…"
case .passed(let count): "通过,搜索到 \(count) 条结果"
case .failed(let message): message
}
}
private var icon: String {
switch status {
case .waiting: "clock"
case .testing: "arrow.triangle.2.circlepath"
case .passed: "checkmark.circle.fill"
case .failed: "xmark.circle.fill"
}
}
private var color: Color {
switch status {
case .waiting: .secondary
case .testing: ReadingTheme.accent
case .passed: .green
case .failed: .red
}
}
}
private struct DebugStepCard: View {
let step: SourceDebugStep
var body: some View {
DisclosureGroup {
if step.diagnostics.isEmpty {
Text("该阶段没有规则诊断。")
.font(.caption).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 8)
} else {
VStack(alignment: .leading, spacing: 9) {
ForEach(step.diagnostics) { diagnostic in
VStack(alignment: .leading, spacing: 2) {
Text(diagnostic.message).font(.caption)
Text(diagnostic.code).font(.caption2.monospaced()).foregroundStyle(.tertiary)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 8)
}
} label: {
HStack(spacing: 12) {
Image(systemName: icon)
.foregroundStyle(color)
.frame(width: 34, height: 34)
.background(color.opacity(0.1), in: Circle())
VStack(alignment: .leading, spacing: 3) {
Text(step.stage.title).font(.subheadline.bold())
Text(step.summary).font(.caption).foregroundStyle(.secondary).lineLimit(2)
}
Spacer()
if step.elapsedMilliseconds > 0 {
Text("\(step.elapsedMilliseconds) ms")
.font(.caption2.monospacedDigit()).foregroundStyle(.tertiary)
}
}
}
.padding(14)
.background(.background, in: RoundedRectangle(cornerRadius: 17, style: .continuous))
.overlay { RoundedRectangle(cornerRadius: 17, style: .continuous).stroke(.primary.opacity(0.06)) }
}
private var color: Color {
switch step.status {
case .passed: .green
case .warning: .orange
case .failed: .red
case .skipped: .secondary
}
}
private var icon: String {
switch step.status {
case .passed: "checkmark"
case .warning: "exclamationmark"
case .failed: "xmark"
case .skipped: "forward.end"
}
}
}
+213
View File
@@ -0,0 +1,213 @@
import SwiftUI
import UIKit
struct SourceEditorView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var library: SourceLibraryModel
let original: BookSource?
let onSaved: ((BookSource) -> Void)?
@State private var document: BookSourceEditorDocument
@State private var jsonText: String
@State private var mode = 0
@State private var sectionID = "basic"
@State private var errorMessage: String?
@State private var validation: [BookSourceIssue] = []
@State private var debugSource: BookSource?
@State private var isSaving = false
@State private var showsDiscard = false
private let initialJSON: String
init(source: BookSource? = nil, onSaved: ((BookSource) -> Void)? = nil) {
self.onSaved = onSaved
original = source
let doc = try! BookSourceEditorDocument(source: source ?? BookSource(bookSourceUrl: "", bookSourceName: ""))
let json = (try? doc.json()) ?? "{}"
initialJSON = json
_document = State(initialValue: doc)
_jsonText = State(initialValue: json)
}
private var isDirty: Bool { mode == 1 ? jsonText != initialJSON : (try? document.json()) != initialJSON }
private var currentSection: BookSourceEditorSection { BookSourceEditorSchema.sections.first { $0.id == sectionID }! }
var body: some View {
ReadingNavigationStack {
VStack(spacing: 0) {
Picker("编辑模式", selection: Binding(get: { mode }, set: { value in switchMode(value) })) {
Text("字段编辑").tag(0)
Text("JSON").tag(1)
}.pickerStyle(.segmented).padding(.horizontal).padding(.vertical, 8)
if mode == 1 {
SyntaxCodeEditor(text: $jsonText, language: .json)
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(BookSourceEditorSchema.sections) { section in
Button(section.title) { sectionID = section.id }
.buttonStyle(.bordered)
.tint(sectionID == section.id ? ReadingTheme.accent : .secondary)
}
Button("选项") { sectionID = "options" }.buttonStyle(.bordered)
.tint(sectionID == "options" ? ReadingTheme.accent : .secondary)
}.padding(.horizontal)
}.padding(.vertical, 6)
Form {
if sectionID == "options" { options }
else {
Section(currentSection.title) {
ForEach(currentSection.fields) { field in
NavigationLink {
RuleCodeFieldView(title: field.title, path: field.path, text: string(field.path),
language: field.isJavaScript ? .javascript : .rule, help: field.help)
} label: {
VStack(alignment: .leading, spacing: 5) {
Text(field.title)
Text(document.value(at: field.path)?.stringValue.isEmpty == false ? document.value(at: field.path)!.stringValue : field.path)
.font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(2)
}
}
}
}
Section {
Text("点击字段可全屏编辑规则。支持 @css:、XPath、$. JSONPath、@js:、{{key}}、{{page}} 和 ## 替换语法。未识别的扩展字段保留在 JSON 中。")
.font(.footnote).foregroundStyle(.secondary)
}
}
if !validation.isEmpty {
Section("静态检查") {
ForEach(validation) { issue in
Label("\(issue.field):\(issue.message)", systemImage: issue.severity == .error ? "xmark.circle" : "exclamationmark.triangle")
.font(.footnote).foregroundStyle(issue.severity == .error ? .red : .orange)
}
}
}
}
}
}
.navigationTitle(original == nil ? "新建书源" : "编辑书源")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { if isDirty { showsDiscard = true } else { dismiss() } }
}
ToolbarItem(placement: .topBarTrailing) {
Menu("更多", systemImage: "ellipsis.circle") {
Button("格式化 JSON", systemImage: "text.alignleft") { perform { try synchronize(); jsonText = try document.json() } }
Button("校验配置", systemImage: "checkmark.shield") { check() }
Button("调试当前草稿", systemImage: "stethoscope") { perform { try synchronize(); debugSource = try document.source() } }
Button("复制 JSON", systemImage: "doc.on.doc") { perform { try synchronize(); UIPasteboard.general.string = try document.json() } }
Button("粘贴 JSON", systemImage: "doc.on.clipboard") {
perform {
guard let value = UIPasteboard.general.string else { return }
let pasted = try BookSourceEditorDocument(json: value)
_ = try JSONDecoder().decode(BookSource.self, from: Data(pasted.json().utf8))
document = pasted
jsonText = try pasted.json()
}
}
}
}
ToolbarItem(placement: .confirmationAction) {
Button(isSaving ? "保存中" : "保存", action: save).disabled(isSaving)
}
}
.readingNavigationDestination(isPresented: Binding(
get: { debugSource != nil },
set: { if !$0 { debugSource = nil } }
)) {
if let debugSource { SourceDebugView(source: debugSource) }
}
.alert("无法完成操作", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
Button("好", role: .cancel) {}
} message: { Text(errorMessage ?? "") }
.confirmationDialog("放弃未保存的修改?", isPresented: $showsDiscard, titleVisibility: .visible) {
Button("放弃修改", role: .destructive) { dismiss() }
}
}
.interactiveDismissDisabled(isDirty || isSaving)
}
private var options: some View {
Group {
Section("书源类型") {
Picker("类型", selection: Binding(get: { Int(document.value(at: "bookSourceType")?.stringValue ?? "0") ?? 0 },
set: { document.set(.number(Double($0)), at: "bookSourceType") })) {
Text("文本").tag(0); Text("音频").tag(1); Text("图片").tag(2); Text("文件").tag(3); Text("视频").tag(4)
}
}
Section("开关") {
Toggle("启用书源", isOn: boolean("enabled", fallback: true))
Toggle("启用发现", isOn: boolean("enabledExplore", fallback: true))
Toggle("Cookie Jar", isOn: boolean("enabledCookieJar", fallback: true))
Toggle("事件监听", isOn: boolean("eventListener"))
Toggle("定制按钮", isOn: boolean("customButton"))
}
Section("排序与状态") {
ForEach(["customOrder", "weight", "lastUpdateTime", "respondTime"], id: \.self) { key in
HStack {
Text(["customOrder": "排序编号", "weight": "搜索权重", "lastUpdateTime": "更新时间 (ms)", "respondTime": "响应时间 (ms)"][key]!)
TextField(key, text: number(key)).keyboardType(.numbersAndPunctuation).multilineTextAlignment(.trailing)
}
}
}
}
}
private func string(_ path: String) -> Binding<String> {
Binding(get: { document.value(at: path)?.stringValue ?? "" }, set: { document.set($0.isEmpty ? nil : .string($0), at: path) })
}
private func boolean(_ path: String, fallback: Bool = false) -> Binding<Bool> {
Binding(get: { if case .bool(let value) = document.value(at: path) { return value }; return fallback },
set: { document.set(.bool($0), at: path) })
}
private func number(_ path: String) -> Binding<String> {
Binding(get: { document.value(at: path)?.stringValue ?? "0" }, set: { document.set(.string($0), at: path) })
}
private func synchronize() throws { if mode == 1 { document = try BookSourceEditorDocument(json: jsonText) } }
private func switchMode(_ next: Int) {
guard next != mode else { return }
perform {
if next == 0 { try synchronize() }
else { jsonText = try document.json() }
mode = next
}
}
private func perform(_ action: () throws -> Void) {
do { try action() } catch { errorMessage = readable(error) }
}
private func readable(_ error: Error) -> String {
if case DecodingError.typeMismatch(_, let context) = error {
return "\(context.codingPath.map(\.stringValue).joined(separator: ".")):字段类型不正确。\(context.debugDescription)"
}
return error.localizedDescription
}
private func check() {
perform {
try synchronize()
let source = try JSONDecoder().decode(BookSource.self, from: Data(document.json().utf8))
validation = BookSourceValidator.validate(source)
if validation.isEmpty { errorMessage = "静态检查通过。可通过“调试当前草稿”验证实际请求及解析结果。" }
}
}
private func save() {
perform {
try synchronize()
var source = try document.source()
if library.sources.contains(where: { $0.id == source.id && $0.id != original?.id }) {
throw BookSourceImportError.duplicate(source.id)
}
source.lastUpdateTime = Int64(Date().timeIntervalSince1970 * 1_000)
isSaving = true
Task {
let saved = await library.saveSource(source, replacing: original?.id)
isSaving = false
if saved { onSaved?(source); dismiss() }
else {
let message = library.notice?.detail ?? "保存失败"
library.notice = nil
errorMessage = message
}
}
}
}
}
@@ -0,0 +1,434 @@
#if SWIFT_PACKAGE
import LegadoCore
#endif
import Foundation
import SwiftUI
@MainActor
final class SourceLibraryModel: ObservableObject {
@Published private(set) var sources: [BookSource] = []
@Published private(set) var isWorking = false
@Published var notice: Notice?
@Published var pendingImport: PendingImport?
struct PendingImport: Identifiable, Sendable {
struct Item: Identifiable, Sendable {
let source: BookSource
let existing: BookSource?
var id: String { source.id }
var state: String {
guard let existing else { return "新增" }
return source.lastUpdateTime > existing.lastUpdateTime ? "更新" : "已有"
}
}
struct InvalidItem: Identifiable, Sendable {
let id = UUID()
let source: BookSource
let issues: [BookSourceIssue]
}
let id = UUID()
let items: [Item]
let invalidItems: [InvalidItem]
var validCount: Int { items.count }
var invalidCount: Int { invalidItems.count }
var totalCount: Int { validCount + invalidCount }
}
struct Notice: Identifiable {
enum Kind { case success, error }
let id = UUID()
let kind: Kind
let title: String
let detail: String
}
private let repository: any BookSourceRepository
private let importer = BookSourceImporter()
private let importTransport: any HTTPTransport
var groups: [String] {
Array(Set(sources.flatMap(\.groups))).sorted { $0.localizedStandardCompare($1) == .orderedAscending }
}
init(repository: any BookSourceRepository, importTransport: any HTTPTransport = URLSessionHTTPTransport.shared) {
self.repository = repository
self.importTransport = importTransport
}
static func live() -> SourceLibraryModel {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Legado", isDirectory: true)
let legacy = base.appendingPathComponent("book-sources.json")
guard let database = try? LegadoDatabase(url: base.appendingPathComponent("legado.sqlite3")) else {
return SourceLibraryModel(repository: FileBookSourceRepository(fileURL: legacy))
}
return SourceLibraryModel(repository: SQLiteBookSourceRepository(database: database, legacyFileURL: legacy))
}
static func demo(sources: [BookSource]) -> SourceLibraryModel {
SourceLibraryModel(repository: InMemoryBookSourceRepository(sources: sources))
}
func load() async {
guard sources.isEmpty else { return }
isWorking = true
defer { isWorking = false }
do {
sources = try await repository.load()
} catch {
notice = .init(kind: .error, title: "无法读取书源", detail: error.localizedDescription)
}
}
func importFiles(_ urls: [URL]) async {
isWorking = true
defer { isWorking = false }
var combined = sources
var inserted = 0
var replaced = 0
var warnings = 0
do {
for url in urls {
let granted = url.startAccessingSecurityScopedResource()
defer { if granted { url.stopAccessingSecurityScopedResource() } }
let result = try await Task.detached(priority: .userInitiated) { [importer, existing = combined] in
try importer.prepare(data: Data(contentsOf: url), existing: existing, duplicatePolicy: .replace)
}.value
combined = result.sources
inserted += result.inserted
replaced += result.replaced
warnings += result.warnings.count
}
try await repository.save(combined)
sources = combined
notice = .init(
kind: .success,
title: "书源导入完成",
detail: "新增 \(inserted) 个,覆盖 \(replaced) 个,警告 \(warnings) 条"
)
} catch {
notice = .init(kind: .error, title: "书源导入失败", detail: error.localizedDescription)
}
}
func importText(_ text: String) async { await importRemoteURL(text) }
func importRemoteURL(_ value: String) async {
isWorking = true
defer { isWorking = false }
do {
var budget = 100
let batch = try await resolveImport(value, depth: 0, visited: [], budget: &budget)
guard batch.invalidItems.isEmpty else {
throw BookSourceImportError.invalidSources(batch.invalidItems.flatMap(\.issues))
}
try await applyImport(JSONEncoder().encode(batch.sources), successTitle: "书源导入完成")
} catch {
notice = .init(kind: .error, title: "书源导入失败", detail: error.localizedDescription)
}
}
func remove(at offsets: IndexSet) async {
var updated = sources
updated.remove(atOffsets: offsets)
await persist(updated, successTitle: "已删除书源")
}
func remove(sourceIDs: Set<String>) async {
guard !sourceIDs.isEmpty else { return }
await persist(sources.filter { !sourceIDs.contains($0.id) }, successTitle: "已删除 \(sourceIDs.count) 个书源")
}
func setEnabled(_ enabled: Bool, for sourceID: String) async {
var updated = sources
guard let index = updated.firstIndex(where: { $0.id == sourceID }) else { return }
updated[index].enabled = enabled
await persist(updated, successTitle: nil)
}
func applySilentSubscription(_ data: Data) async throws {
let incoming = try await Task.detached(priority: .userInitiated) { [importer] in
try importer.decode(data)
}.value
var updated = sources
for var source in incoming {
if let index = updated.firstIndex(where: { $0.bookSourceUrl == source.bookSourceUrl }) {
guard updated[index].lastUpdateTime < source.lastUpdateTime else { continue }
source.bookSourceGroup = updated[index].bookSourceGroup
updated[index] = source
} else { updated.append(source) }
}
try await repository.save(updated)
sources = updated
}
func previewFiles(_ urls: [URL]) async {
isWorking = true
defer { isWorking = false }
do {
var incoming: [String: BookSource] = [:]
var invalidItems: [PendingImport.InvalidItem] = []
for url in urls {
let granted = url.startAccessingSecurityScopedResource()
defer { if granted { url.stopAccessingSecurityScopedResource() } }
let data = try await Task.detached(priority: .userInitiated) { [importer] in
try importer.normalizedData(Data(contentsOf: url))
}.value
var budget = 100
let batch = try await resolveImport(String(decoding: data, as: UTF8.self), depth: 0, visited: [], budget: &budget)
invalidItems.append(contentsOf: batch.invalidItems)
for source in batch.sources {
incoming[source.id] = source
}
}
// UIDocumentPicker is still dismissing when its completion runs.
// Presenting the review sheet in that same frame is ignored by
// SwiftUI, so stage it after the picker has left the hierarchy.
try? await Task.sleep(nanoseconds: 300_000_000)
stage(Array(incoming.values), invalidItems: invalidItems)
} catch {
notice = .init(kind: .error, title: "书源导入失败", detail: error.localizedDescription)
}
}
func previewText(_ text: String) async { await previewInput(text) }
func previewRemoteURL(_ value: String) async { await previewInput(value) }
func previewInput(_ value: String) async {
isWorking = true
defer { isWorking = false }
do {
var budget = 100
let batch = try await resolveImport(value, depth: 0, visited: [], budget: &budget)
stage(batch.sources, invalidItems: batch.invalidItems)
} catch {
notice = .init(kind: .error, title: "书源导入失败", detail: error.localizedDescription)
}
}
private nonisolated func resolveImport(_ value: String, depth: Int, visited: Set<String>, budget: inout Int) async throws -> (
sources: [BookSource], invalidItems: [PendingImport.InvalidItem]
) {
guard depth < 8 else { throw BookSourceImportError.invalidDocument("订阅清单嵌套过深") }
let input = try RuleImportInput.parse(value)
let data: Data
var visited = visited
switch input {
case .json(let json): data = Data(json.utf8)
case .remote(let url, _):
guard budget > 0 else { throw BookSourceImportError.invalidDocument("订阅清单的总链接数超过 100") }
budget -= 1
guard visited.insert(url.absoluteString).inserted else {
throw BookSourceImportError.invalidDocument("订阅清单出现循环链接")
}
let response = try await importTransport.data(for: input.request()!)
guard (200...299).contains(response.statusCode) else {
throw BookSourceImportError.invalidDocument("服务器返回 HTTP \(response.statusCode)")
}
data = response.data
}
let normalized = try importer.normalizedData(data)
if case .array(let urls) = (try JSONValue.decode(data: normalized))["sourceUrls"] {
guard !urls.isEmpty, urls.count <= 100 else { throw BookSourceImportError.invalidDocument("订阅清单需包含 1–100 个链接") }
var sources: [BookSource] = []
var invalid: [PendingImport.InvalidItem] = []
for item in urls {
guard case .string(let url) = item else { throw BookSourceImportError.invalidDocument("sourceUrls 必须是链接数组") }
let batch = try await resolveImport(url, depth: depth + 1, visited: visited, budget: &budget)
sources += batch.sources
invalid += batch.invalidItems
}
return (sources, invalid)
}
return try validatedSources(from: normalized)
}
func cancelPendingImport() { pendingImport = nil }
func commitPendingImport(
selectedIDs: Set<String>,
keepOriginalName: Bool,
keepOriginalGroup: Bool,
keepOriginalEnabled: Bool,
customGroup: String?,
addCustomGroup: Bool
) async {
guard let pendingImport else { return }
var updated = sources
var inserted = 0
var replaced = 0
for item in pendingImport.items where selectedIDs.contains(item.id) {
var source = item.source
if let existing = item.existing {
if keepOriginalName { source.bookSourceName = existing.bookSourceName }
if keepOriginalGroup { source.bookSourceGroup = existing.bookSourceGroup }
if keepOriginalEnabled {
source.enabled = existing.enabled
source.enabledExplore = existing.enabledExplore
}
source.customOrder = existing.customOrder
}
if let customGroup, !BookSource.parseGroups(customGroup).isEmpty {
if addCustomGroup { source.addGroups(customGroup) }
else { source.bookSourceGroup = BookSource.joinGroups(BookSource.parseGroups(customGroup)) }
}
if let index = updated.firstIndex(where: { $0.id == source.id }) {
updated[index] = source
replaced += 1
} else {
updated.append(source)
inserted += 1
}
}
do {
try await repository.save(updated)
sources = updated
self.pendingImport = nil
notice = .init(
kind: .success,
title: "书源导入完成",
detail: "新增 \(inserted) 个,更新 \(replaced) 个"
)
} catch {
notice = .init(kind: .error, title: "保存失败", detail: error.localizedDescription)
return
}
}
func setExploreEnabled(_ enabled: Bool, for sourceID: String) async {
var updated = sources
guard let index = updated.firstIndex(where: { $0.id == sourceID }) else { return }
updated[index].enabledExplore = enabled
await persist(updated, successTitle: nil)
}
func saveSource(_ source: BookSource, replacing oldSourceID: String? = nil) async -> Bool {
let errors = BookSourceValidator.validate(source).filter { $0.severity == .error }
guard errors.isEmpty else {
notice = .init(kind: .error, title: "书源无效", detail: errors.map(\.message).joined(separator: ";"))
return false
}
let oldSourceID = oldSourceID ?? source.id
var updated = sources
if updated.contains(where: { $0.id == oldSourceID }) {
if source.id != oldSourceID, updated.contains(where: { $0.id == source.id }) {
notice = .init(kind: .error, title: "地址冲突", detail: "该地址已有书源,请使用导入预览选择更新。")
return false
}
guard let replacement = updated.firstIndex(where: { $0.id == oldSourceID }) else { return false }
updated[replacement] = source
} else if let index = updated.firstIndex(where: { $0.id == source.id }) {
updated[index] = source
} else {
updated.append(source)
}
return await persist(updated, successTitle: "书源已保存")
}
func setEnabled(_ enabled: Bool, for sourceIDs: Set<String>) async {
guard !sourceIDs.isEmpty else { return }
var updated = sources
for index in updated.indices where sourceIDs.contains(updated[index].id) {
updated[index].enabled = enabled
}
await persist(updated, successTitle: enabled ? "已批量启用" : "已批量停用")
}
func addGroup(_ group: String, to sourceIDs: Set<String>) async {
let values = BookSource.parseGroups(group)
guard !values.isEmpty, !sourceIDs.isEmpty else { return }
var updated = sources
for index in updated.indices where sourceIDs.contains(updated[index].id) {
updated[index].addGroups(values.joined(separator: ","))
}
await persist(updated, successTitle: "已加入分组 \(values.joined(separator: "、"))")
}
func removeGroup(_ group: String, from sourceIDs: Set<String>) async {
guard !sourceIDs.isEmpty else { return }
var updated = sources
for index in updated.indices where sourceIDs.contains(updated[index].id) {
updated[index].removeGroups(group)
}
await persist(updated, successTitle: "已移出分组")
}
func renameGroup(_ oldValue: String, to newValue: String) async {
guard !BookSource.parseGroups(newValue).isEmpty else { return }
var updated = sources
for index in updated.indices where updated[index].hasGroup(oldValue) {
updated[index].replaceGroup(oldValue, with: newValue)
}
await persist(updated, successTitle: "分组已重命名")
}
func deleteGroup(_ group: String) async {
var updated = sources
for index in updated.indices where updated[index].hasGroup(group) {
updated[index].removeGroups(group)
}
await persist(updated, successTitle: "已删除分组 \(group)")
}
func handleDeepLink(_ url: URL) async {
if url.isFileURL {
await previewFiles([url])
return
}
await previewInput(url.absoluteString)
}
@discardableResult
private func persist(_ updated: [BookSource], successTitle: String?) async -> Bool {
do {
try await repository.save(updated)
sources = updated
if let successTitle {
notice = .init(kind: .success, title: successTitle, detail: "")
}
return true
} catch {
notice = .init(kind: .error, title: "保存失败", detail: error.localizedDescription)
return false
}
}
private func applyImport(_ data: Data, successTitle: String) async throws {
let result = try await Task.detached(priority: .userInitiated) { [importer, existing = sources] in
try importer.prepare(data: data, existing: existing, duplicatePolicy: .replace)
}.value
try await repository.save(result.sources)
sources = result.sources
notice = .init(
kind: .success,
title: successTitle,
detail: "新增 \(result.inserted) 个,覆盖 \(result.replaced) 个,警告 \(result.warnings.count) 条"
)
}
private nonisolated func validatedSources(from data: Data) throws -> (
sources: [BookSource],
invalidItems: [PendingImport.InvalidItem]
) {
let result = try importer.inspect(data)
return (result.sources, result.rejected.map { .init(source: $0.source, issues: $0.issues) })
}
private func stage(
_ incoming: [BookSource],
invalidItems: [PendingImport.InvalidItem] = []
) {
let existingByID = Dictionary(uniqueKeysWithValues: sources.map { ($0.id, $0) })
let unique = incoming.reduce(into: [String: BookSource]()) { $0[$1.id] = $1 }
pendingImport = .init(items: Array(unique.values)
.sorted { $0.bookSourceName.localizedStandardCompare($1.bookSourceName) == .orderedAscending }
.map { .init(source: $0, existing: existingByID[$0.id]) },
invalidItems: invalidItems)
}
}
+324
View File
@@ -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) }
}
}
}
@@ -0,0 +1,695 @@
import SwiftUI
import UIKit
import UniformTypeIdentifiers
struct SourceManagementView: View {
@EnvironmentObject private var library: SourceLibraryModel
@State private var showsImporter = false
@State private var showsNewSource = false
@State private var showsQRImporter = false
@State private var showsTextImporter = false
@State private var showsGroupManager = false
@State private var showsBatchGroups = false
@State private var selectedGroup: String?
@State private var searchText = ""
@State private var isSelecting = false
@State private var selectedSourceIDs = Set<String>()
@AppStorage("bookSourceSort") private var sortMode = "manual"
@AppStorage("bookSourceSortDescending") private var sortDescending = false
private var filteredSources: [BookSource] {
let filtered = library.sources.filter { source in
let matchesGroup: Bool
if let selectedGroup {
matchesGroup = selectedGroup.isEmpty ? source.groups.isEmpty : source.hasGroup(selectedGroup)
} else {
matchesGroup = true
}
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
return matchesGroup && (query.isEmpty
|| source.bookSourceName.localizedCaseInsensitiveContains(query)
|| source.bookSourceUrl.localizedCaseInsensitiveContains(query)
|| source.groups.contains(where: { $0.localizedCaseInsensitiveContains(query) }))
}
guard sortMode != "manual" else { return filtered }
return filtered.sorted { lhs, rhs in
sortDescending ? isOrdered(rhs, before: lhs) : isOrdered(lhs, before: rhs)
}
}
private func isOrdered(_ lhs: BookSource, before rhs: BookSource) -> Bool {
switch sortMode {
case "weight":
return lhs.weight == rhs.weight ? lhs.bookSourceName.localizedStandardCompare(rhs.bookSourceName) == .orderedAscending : lhs.weight < rhs.weight
case "name": return lhs.bookSourceName.localizedStandardCompare(rhs.bookSourceName) == .orderedAscending
case "url": return lhs.bookSourceUrl.localizedStandardCompare(rhs.bookSourceUrl) == .orderedAscending
case "update": return lhs.lastUpdateTime == rhs.lastUpdateTime ? lhs.bookSourceName < rhs.bookSourceName : lhs.lastUpdateTime > rhs.lastUpdateTime
case "respond": return lhs.respondTime == rhs.respondTime ? lhs.bookSourceName < rhs.bookSourceName : lhs.respondTime < rhs.respondTime
case "enabled": return lhs.enabled == rhs.enabled ? lhs.bookSourceName < rhs.bookSourceName : lhs.enabled && !rhs.enabled
default: return lhs.customOrder < rhs.customOrder
}
}
var body: some View {
Group {
if library.sources.isEmpty, !library.isWorking {
ReadingUnavailableView {
Label("还没有书源", systemImage: "network")
} description: {
Text("新建书源,或从文件、链接、JSON、二维码导入。")
} actions: {
Button("导入书源") { showsImporter = true }
.buttonStyle(.borderedProminent)
}
} else {
List {
Section {
Picker("分组", selection: $selectedGroup) {
Text("全部").tag(String?.none)
Text("未分组").tag(String?.some(""))
ForEach(library.groups, id: \.self) { group in
Text(group).tag(String?.some(group))
}
}
}
Section {
ForEach(filteredSources) { source in
if isSelecting {
Button {
if !selectedSourceIDs.insert(source.id).inserted { selectedSourceIDs.remove(source.id) }
} label: {
HStack(spacing: 10) {
Image(systemName: selectedSourceIDs.contains(source.id) ? "checkmark.circle.fill" : "circle")
.font(.title3)
.foregroundStyle(selectedSourceIDs.contains(source.id) ? ReadingTheme.accent : .secondary)
SourceRow(source: source, showsToggle: false) { _ in }
}
}
.buttonStyle(.plain)
} else {
NavigationLink {
SourceDetailView(source: source)
} label: {
SourceRow(source: source) { enabled in
Task { await library.setEnabled(enabled, for: source.id) }
}
}
.contextMenu {
NavigationLink {
SearchView(fixedSource: source)
} label: {
Label("搜索", systemImage: "magnifyingglass")
}
Button(source.enabledExplore ? "停用发现" : "启用发现", systemImage: "safari") {
Task { await library.setExploreEnabled(!source.enabledExplore, for: source.id) }
}
Button("删除", systemImage: "trash", role: .destructive) {
Task { await library.remove(sourceIDs: [source.id]) }
}
}
}
}
.onDelete { offsets in
let ids = Set(offsets.compactMap { filteredSources.indices.contains($0) ? filteredSources[$0].id : nil })
Task { await library.remove(sourceIDs: ids) }
}
} header: {
Text("\(filteredSources.count) 个书源")
} footer: {
Text("共 \(library.sources.count) 个书源,\(library.sources.filter(\.enabled).count) 个已启用")
}
}
.listStyle(.insetGrouped)
.readingScrollContentBackgroundHidden()
.background(ReadingTheme.groupedCanvas)
.overlay {
if library.isWorking { ProgressView() }
}
}
}
.navigationTitle("书源")
.background(ReadingTheme.groupedCanvas)
.searchable(text: $searchText, prompt: "名称、地址或分组")
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button(isSelecting ? "完成" : "选择") {
isSelecting.toggle()
if !isSelecting { selectedSourceIDs.removeAll() }
}
.disabled(library.sources.isEmpty)
}
ToolbarItem(placement: .primaryAction) {
Menu("更多", systemImage: "ellipsis.circle") {
Button("新建书源", systemImage: "plus") { showsNewSource = true }
Button("二维码导入", systemImage: "qrcode.viewfinder") { showsQRImporter = true }
Button("从文件导入", systemImage: "doc.badge.plus") { showsImporter = true }
Button("URL 或粘贴 JSON", systemImage: "link.badge.plus") { showsTextImporter = true }
Divider()
NavigationLink {
SourceBatchTestView()
} label: {
Label("测试书源", systemImage: "checkmark.circle.badge.questionmark")
}
Button("管理分组", systemImage: "folder.badge.gearshape") { showsGroupManager = true }
}
}
ToolbarItem(placement: .topBarTrailing) {
Menu("排序", systemImage: "arrow.up.arrow.down") {
Toggle("倒序", isOn: $sortDescending)
Picker("排序方式", selection: $sortMode) {
Text("手动排序").tag("manual")
Text("自动排序").tag("weight")
Text("按名称").tag("name")
Text("按网址").tag("url")
Text("按更新时间").tag("update")
Text("按响应时间").tag("respond")
Text("按启用状态").tag("enabled")
}
}
}
}
.safeAreaInset(edge: .bottom) {
if isSelecting {
HStack(spacing: 18) {
Button(selectedSourceIDs.count == filteredSources.count ? "取消全选" : "全选") {
if selectedSourceIDs.count == filteredSources.count { selectedSourceIDs.removeAll() }
else { selectedSourceIDs = Set(filteredSources.map(\.id)) }
}
Spacer()
Button("分组", systemImage: "folder") { showsBatchGroups = true }
.disabled(selectedSourceIDs.isEmpty)
Button("启用", systemImage: "checkmark.circle") {
Task { await library.setEnabled(true, for: selectedSourceIDs) }
}
.disabled(selectedSourceIDs.isEmpty)
Button("停用", systemImage: "pause.circle") {
Task { await library.setEnabled(false, for: selectedSourceIDs) }
}
.disabled(selectedSourceIDs.isEmpty)
}
.font(.subheadline.weight(.semibold))
.padding(.horizontal, 18).padding(.vertical, 12)
.background(.ultraThinMaterial)
}
}
.sheet(isPresented: $showsNewSource) { SourceEditorView().environmentObject(library) }
.sheet(isPresented: $showsQRImporter) {
QRCodeImportView { value in Task { await library.previewInput(value) } }
}
.sheet(isPresented: $showsTextImporter) {
SourceTextImportView()
.environmentObject(library)
}
.sheet(isPresented: $showsGroupManager) {
SourceGroupManagerView().environmentObject(library)
}
.sheet(isPresented: $showsBatchGroups) {
SourceBatchGroupView(sourceIDs: selectedSourceIDs).environmentObject(library)
}
.fileImporter(
isPresented: $showsImporter,
allowedContentTypes: [.item],
allowsMultipleSelection: false
) { result in
switch result {
case .success(let urls):
let files = urls.filter { !$0.hasDirectoryPath }
if files.isEmpty {
library.notice = .init(kind: .error, title: "无法选择文件", detail: "请选择书源 JSON 文件,而不是文件夹。")
} else {
Task { await library.previewFiles(files) }
}
case .failure(let error):
library.notice = .init(kind: .error, title: "无法选择文件", detail: error.localizedDescription)
}
}
}
}
private struct SourceTextImportView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var library: SourceLibraryModel
@State private var mode = 0
@State private var value = ""
var body: some View {
ReadingNavigationStack {
Form {
Picker("导入方式", selection: $mode) {
Text("URL").tag(0)
Text("粘贴 JSON").tag(1)
}
.pickerStyle(.segmented)
Section(mode == 0 ? "书源 URL" : "书源 JSON") {
if mode == 0 {
TextField("https://example.com/sources.json", text: $value)
.textInputAutocapitalization(.never)
.keyboardType(.URL)
} else {
SyntaxCodeEditor(text: $value, language: .json)
.frame(minHeight: 220)
}
}
Section {
Text("导入前可预览并选择书源。地址相同的书源将被替换。")
.font(.footnote).foregroundStyle(.secondary)
}
}
.navigationTitle("导入书源")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
Button("导入") {
let input = value
let selectedMode = mode
dismiss()
Task {
try? await Task.sleep(nanoseconds: 350_000_000)
if selectedMode == 0 { await library.previewRemoteURL(input) }
else { await library.previewText(input) }
}
}
.disabled(value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
}
}
private struct SourceRow: View {
let source: BookSource
var showsToggle = true
let onEnabledChanged: @MainActor @Sendable (Bool) -> Void
var body: some View {
HStack(spacing: 12) {
Image(systemName: source.enabled ? "network" : "network.slash")
.font(.system(size: 20))
.frame(width: 28)
.foregroundStyle(.secondary)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text(source.bookSourceName)
.font(.body)
.foregroundStyle(source.enabled ? Color.primary : .secondary)
Text(source.groups.isEmpty ? (URL(string: source.bookSourceUrl)?.host ?? source.bookSourceUrl) : source.groups.joined(separator: " · "))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
if showsToggle {
Toggle("启用", isOn: Binding(
get: { source.enabled },
set: { enabled in
Task { @MainActor in onEnabledChanged(enabled) }
}
))
.labelsHidden()
.tint(.green)
.accessibilityLabel("启用\(source.bookSourceName)")
}
}
.padding(.vertical, 5)
}
}
private struct SourceGroupManagerView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var library: SourceLibraryModel
@State private var newGroup = ""
@State private var renaming: String?
@State private var renamedValue = ""
var body: some View {
ReadingNavigationStack {
List {
Section("新建分组") {
HStack {
TextField("例如:文学", text: $newGroup)
Button("添加") {
let value = newGroup
newGroup = ""
let ungrouped = Set(library.sources.filter { $0.groups.isEmpty }.map(\.id))
Task { await library.addGroup(value, to: ungrouped) }
}
.disabled(BookSource.parseGroups(newGroup).isEmpty)
}
Text("新分组会分配给当前未分组书源,与 Legado 的分组管理行为一致。")
.font(.caption).foregroundStyle(.secondary)
}
Section("已有分组") {
ForEach(library.groups, id: \.self) { group in
HStack {
Label(group, systemImage: "folder.fill")
Spacer()
Text("\(library.sources.filter { $0.hasGroup(group) }.count)")
.foregroundStyle(.secondary)
Menu {
Button("重命名", systemImage: "pencil") {
renaming = group
renamedValue = group
}
Button("删除分组", systemImage: "trash", role: .destructive) {
Task { await library.deleteGroup(group) }
}
} label: { Image(systemName: "ellipsis.circle") }
}
}
}
}
.navigationTitle("书源分组")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("完成") { dismiss() } } }
.alert("重命名分组", isPresented: Binding(
get: { renaming != nil },
set: { if !$0 { renaming = nil } }
)) {
TextField("分组名称", text: $renamedValue)
Button("取消", role: .cancel) { renaming = nil }
Button("保存") {
if let old = renaming { Task { await library.renameGroup(old, to: renamedValue) } }
renaming = nil
}
}
}
}
}
private struct SourceBatchGroupView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var library: SourceLibraryModel
let sourceIDs: Set<String>
@State private var customGroup = ""
var body: some View {
ReadingNavigationStack {
List {
Section("已选择 \(sourceIDs.count) 个书源") {
HStack {
TextField("输入新分组", text: $customGroup)
Button("加入") {
let value = customGroup
customGroup = ""
Task { await library.addGroup(value, to: sourceIDs) }
}
.disabled(BookSource.parseGroups(customGroup).isEmpty)
}
}
Section("加入现有分组") {
ForEach(library.groups, id: \.self) { group in
Button { Task { await library.addGroup(group, to: sourceIDs) } } label: {
Label(group, systemImage: "folder.badge.plus")
}
}
}
Section("从分组移除") {
ForEach(library.groups, id: \.self) { group in
Button(role: .destructive) {
Task { await library.removeGroup(group, from: sourceIDs) }
} label: { Label(group, systemImage: "folder.badge.minus") }
}
}
}
.navigationTitle("批量归组")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("完成") { dismiss() } } }
}
}
}
private struct SourceDetailView: View {
@EnvironmentObject private var library: SourceLibraryModel
let source: BookSource
@State private var showsEditor = false
@State private var editedSourceID: String?
private var current: BookSource { library.sources.first { $0.id == (editedSourceID ?? source.id) } ?? source }
var body: some View {
List {
Section("基本信息") {
ReadingLabeledContent("名称", value: current.bookSourceName)
ReadingLabeledContent("地址", value: current.bookSourceUrl)
ReadingLabeledContent("分组", value: current.bookSourceGroup ?? "未分组")
ReadingLabeledContent("类型", value: String(current.bookSourceType))
}
Section("能力") {
CapabilityRow(title: "搜索", available: current.searchUrl?.isEmpty == false)
CapabilityRow(title: "发现", available: current.exploreUrl?.isEmpty == false)
CapabilityRow(title: "详情", available: current.ruleBookInfo != nil)
CapabilityRow(title: "目录", available: current.ruleToc != nil)
CapabilityRow(title: "正文", available: current.ruleContent != nil)
}
Section("状态") {
Toggle("启用书源", isOn: Binding(
get: { library.sources.first(where: { $0.id == current.id })?.enabled ?? current.enabled },
set: { value in Task { await library.setEnabled(value, for: current.id) } }
))
Toggle("启用发现", isOn: Binding(
get: { library.sources.first(where: { $0.id == current.id })?.enabledExplore ?? current.enabledExplore },
set: { value in Task { await library.setExploreEnabled(value, for: current.id) } }
))
}
Section("兼容检查") {
NavigationLink {
SearchView(fixedSource: current)
} label: {
Label("搜索此书源", systemImage: "magnifyingglass")
}
NavigationLink {
SourceDebugView(source: current)
} label: {
Label("运行端到端调试", systemImage: "stethoscope")
}
Text("使用你输入的关键词,逐步验证搜索、详情、目录和正文规则。")
.font(.footnote).foregroundStyle(.secondary)
}
if current.loginUrl?.isEmpty == false {
Section("账户") {
NavigationLink {
SourceLoginView(source: current)
} label: {
Label("登录此书源", systemImage: "person.badge.key.fill")
}
Text("登录 Cookie 与阅读请求共享;若书源提供检测脚本,会在页面载入后自动验证。")
.font(.footnote).foregroundStyle(.secondary)
}
}
if let comment = current.bookSourceComment, !comment.isEmpty {
Section("说明") { Text(comment) }
}
}
.navigationTitle(current.bookSourceName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("编辑") { showsEditor = true }
}
}
.sheet(isPresented: $showsEditor) {
SourceEditorView(source: current) { editedSourceID = $0.id }.environmentObject(library)
}
}
}
struct SourceImportReviewView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var library: SourceLibraryModel
let pending: SourceLibraryModel.PendingImport
@State private var selectedIDs: Set<String>
@State private var importError: String?
@State private var isImporting = false
@State private var keepOriginalName = false
@State private var keepOriginalGroup = false
@State private var keepOriginalEnabled = false
@State private var showComments = false
@State private var customGroup = ""
@State private var addCustomGroup = true
init(pending: SourceLibraryModel.PendingImport) {
self.pending = pending
_selectedIDs = State(initialValue: Set(pending.items.filter { $0.existing == nil || $0.state == "更新" }.map(\.id)))
}
var body: some View {
ReadingNavigationStack {
List {
Section {
HStack(spacing: 10) {
importCountCard(
title: "有效",
count: pending.validCount,
color: .green,
systemImage: "checkmark.circle.fill"
)
importCountCard(
title: "失效",
count: pending.invalidCount,
color: .red,
systemImage: "xmark.circle.fill"
)
importCountCard(
title: "总计",
count: pending.totalCount,
color: .secondary,
systemImage: "doc.text.fill"
)
}
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
Label("仅有效书源可被选择和导入", systemImage: "checkmark.shield.fill")
.font(.footnote)
.foregroundStyle(.secondary)
}
if !pending.items.isEmpty {
Section("导入设置") {
HStack {
TextField("自定义分组", text: $customGroup)
Toggle("追加", isOn: $addCustomGroup).labelsHidden()
}
Toggle("保留原名称", isOn: $keepOriginalName)
Toggle("保留原分组", isOn: $keepOriginalGroup)
Toggle("保留原启用状态", isOn: $keepOriginalEnabled)
Toggle("显示书源备注", isOn: $showComments)
}
}
Section("有效书源(\(pending.validCount))") {
ForEach(pending.items) { item in
Button {
if !selectedIDs.insert(item.id).inserted { selectedIDs.remove(item.id) }
} label: {
HStack(alignment: .top, spacing: 10) {
Image(systemName: selectedIDs.contains(item.id) ? "checkmark.square.fill" : "square")
.foregroundStyle(selectedIDs.contains(item.id) ? ReadingTheme.accent : .secondary)
VStack(alignment: .leading, spacing: 4) {
Text(item.source.bookSourceName).foregroundStyle(.primary)
HStack {
Text(item.state)
.font(.caption.bold())
.foregroundStyle(item.state == "新增" ? .green : item.state == "更新" ? .orange : .secondary)
Text(item.source.bookSourceUrl)
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
if showComments, let comment = item.source.bookSourceComment, !comment.isEmpty {
Text(comment).font(.caption).foregroundStyle(.secondary)
}
}
}
}
}
}
if !pending.invalidItems.isEmpty {
Section("失效书源(\(pending.invalidCount))") {
ForEach(pending.invalidItems) { item in
HStack(alignment: .top, spacing: 10) {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.red)
VStack(alignment: .leading, spacing: 4) {
Text(item.source.bookSourceName.isEmpty ? "未命名书源" : item.source.bookSourceName)
.foregroundStyle(.primary)
if !item.source.bookSourceUrl.isEmpty {
Text(item.source.bookSourceUrl)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Text(item.issues.map(\.message).joined(separator: ";"))
.font(.caption)
.foregroundStyle(.red)
}
}
}
}
}
}
.navigationTitle("书源导入预检")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") {
library.cancelPendingImport()
dismiss()
}
}
ToolbarItem(placement: .topBarTrailing) {
Menu("选择") {
Button("全选") { selectedIDs = Set(pending.items.map(\.id)) }
Button("取消全选") { selectedIDs.removeAll() }
Button("仅选择新增") {
selectedIDs = Set(pending.items.filter { $0.existing == nil }.map(\.id))
}
Button("仅选择更新") {
selectedIDs = Set(pending.items.filter { $0.existing != nil }.map(\.id))
}
}
.disabled(pending.items.isEmpty)
}
ToolbarItem(placement: .confirmationAction) {
Button("导入") {
let ids = selectedIDs
isImporting = true
Task {
await library.commitPendingImport(
selectedIDs: ids,
keepOriginalName: keepOriginalName,
keepOriginalGroup: keepOriginalGroup,
keepOriginalEnabled: keepOriginalEnabled,
customGroup: customGroup.isEmpty ? nil : customGroup,
addCustomGroup: addCustomGroup
)
isImporting = false
if library.pendingImport == nil { dismiss() }
else {
let message = library.notice?.detail ?? "保存失败,请重试"
library.notice = nil
importError = message
}
}
}
.disabled(selectedIDs.isEmpty || isImporting)
}
}
}
.interactiveDismissDisabled()
.alert("导入保存失败", isPresented: Binding(get: { importError != nil }, set: { if !$0 { importError = nil } })) {
Button("好", role: .cancel) {}
} message: { Text(importError ?? "") }
}
private func importCountCard(
title: String,
count: Int,
color: Color,
systemImage: String
) -> some View {
VStack(spacing: 5) {
Label(title, systemImage: systemImage)
.font(.caption.weight(.semibold))
.foregroundStyle(color)
Text("\(count)")
.font(.title2.bold())
.foregroundStyle(.primary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(color.opacity(0.1), in: RoundedRectangle(cornerRadius: 12))
}
}
private struct CapabilityRow: View {
let title: String
let available: Bool
var body: some View {
ReadingLabeledContent(title) {
Image(systemName: available ? "checkmark.circle.fill" : "minus.circle")
.foregroundStyle(available ? .green : .secondary)
.accessibilityLabel(available ? "已配置" : "未配置")
}
}
}
@@ -0,0 +1,471 @@
import SwiftUI
import UniformTypeIdentifiers
@MainActor
final class HttpTTSLibraryModel: ObservableObject {
@Published private(set) var engines: [HttpTTS] = []
@Published var errorMessage: String?
private func repository() throws -> SQLiteConfigurationRepository {
SQLiteConfigurationRepository(database: try LegadoDatabase(url: LegadoDatabase.defaultURL()))
}
func load() async {
do {
var values = try await repository().loadHttpTTS()
if values.isEmpty { values = try bundledDefaults() }
engines = sorted(values)
if !values.isEmpty { try await repository().saveHttpTTS(engines) }
} catch {
errorMessage = error.localizedDescription
}
}
func save(_ value: HttpTTS) async {
var value = value
value.lastUpdateTime = Int64(Date().timeIntervalSince1970 * 1_000)
var values = engines.filter { $0.id != value.id }
values.append(value)
await persist(values)
}
func delete(at offsets: IndexSet) async {
var values = engines
values.remove(atOffsets: offsets)
await persist(values)
}
func importData(_ data: Data) async {
do {
let decoder = JSONDecoder()
let imported: [HttpTTS]
if let array = try? decoder.decode([HttpTTS].self, from: data) {
imported = array
} else {
imported = [try decoder.decode(HttpTTS.self, from: data)]
}
var byID = Dictionary(uniqueKeysWithValues: engines.map { ($0.id, $0) })
for value in imported { byID[value.id] = value }
await persist(Array(byID.values))
} catch {
errorMessage = error.localizedDescription
}
}
func restoreDefaults() async {
do {
let defaults = try bundledDefaults()
var byID = Dictionary(uniqueKeysWithValues: engines.filter { $0.id >= 0 }.map { ($0.id, $0) })
for value in defaults { byID[value.id] = value }
await persist(Array(byID.values))
} catch {
errorMessage = error.localizedDescription
}
}
func exportData() throws -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
return try encoder.encode(engines)
}
private func bundledDefaults() throws -> [HttpTTS] {
guard let url = Bundle.main.url(forResource: "httpTTS", withExtension: "json") else {
throw CocoaError(.fileNoSuchFile)
}
return try JSONDecoder().decode([HttpTTS].self, from: Data(contentsOf: url))
}
private func persist(_ values: [HttpTTS]) async {
do {
let values = sorted(values)
try await repository().saveHttpTTS(values)
engines = values
} catch {
errorMessage = error.localizedDescription
}
}
private func sorted(_ values: [HttpTTS]) -> [HttpTTS] {
values.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
}
}
struct HttpTTSManagementView: View {
@StateObject private var model = HttpTTSLibraryModel()
@State private var editing: HttpTTS?
@State private var importsFile = false
@State private var exportsFile = false
@State private var exportDocument = HttpTTSJSONDocument(data: Data("[]".utf8))
var body: some View {
List {
ForEach(model.engines) { engine in
Button {
editing = engine
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(engine.name).foregroundStyle(.primary)
Text(engine.url).font(.caption).foregroundStyle(.secondary).lineLimit(2)
}
}
}
.onDelete { offsets in Task { await model.delete(at: offsets) } }
}
.overlay {
if model.engines.isEmpty {
ReadingUnavailableView("没有在线朗读引擎", systemImage: "waveform", description: Text("可恢复内置引擎或导入 HTTP TTS JSON。"))
}
}
.navigationTitle("在线朗读引擎")
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
Button("新建", systemImage: "plus") { editing = HttpTTS() }
Menu("更多", systemImage: "ellipsis.circle") {
Button("导入", systemImage: "square.and.arrow.down") { importsFile = true }
Button("导出", systemImage: "square.and.arrow.up") {
do {
exportDocument = .init(data: try model.exportData())
exportsFile = true
} catch { model.errorMessage = error.localizedDescription }
}
Button("恢复内置引擎", systemImage: "arrow.counterclockwise") {
Task { await model.restoreDefaults() }
}
Button("清除朗读缓存", systemImage: "trash") {
Task { await HttpTTSAudioCache.shared.clear() }
}
}
}
}
.task { await model.load() }
.sheet(item: $editing) { engine in
ReadingNavigationStack {
HttpTTSEditorView(engine: engine) { value in
Task { await model.save(value) }
}
}
}
.fileImporter(isPresented: $importsFile, allowedContentTypes: [.json, .data]) { result in
do {
let url = try result.get()
let accessing = url.startAccessingSecurityScopedResource()
defer { if accessing { url.stopAccessingSecurityScopedResource() } }
let data = try Data(contentsOf: url)
Task { await model.importData(data) }
} catch { model.errorMessage = error.localizedDescription }
}
.fileExporter(
isPresented: $exportsFile,
document: exportDocument,
contentType: .json,
defaultFilename: "httpTTS"
) { result in
if case .failure(let error) = result { model.errorMessage = error.localizedDescription }
}
.alert("在线朗读引擎", isPresented: Binding(
get: { model.errorMessage != nil },
set: { if !$0 { model.errorMessage = nil } }
)) {
Button("确定", role: .cancel) { model.errorMessage = nil }
} message: {
Text(model.errorMessage ?? "")
}
}
}
private struct HttpTTSEditorView: View {
@Environment(\.dismiss) private var dismiss
@State private var engine: HttpTTS
let onSave: (HttpTTS) -> Void
@StateObject private var speech = ReaderSpeechController()
@State private var testText = "这是一段在线朗读引擎测试文本。"
@State private var loginInfo: String
@State private var showsLoginForm = false
init(engine: HttpTTS, onSave: @escaping (HttpTTS) -> Void) {
_engine = State(initialValue: engine)
self.onSave = onSave
_loginInfo = State(initialValue: BookSourceRuntimeStore.shared.get(sourceID: engine.sourceKey, key: "loginInfo"))
}
var body: some View {
Form {
Section("基本") {
TextField("名称", text: $engine.name)
TextEditor(text: $engine.url).frame(minHeight: 110)
TextField("Content-Type 正则", text: optional(\.contentType))
TextField("并发率", text: optional(\.concurrentRate))
Toggle("启用 Cookie", isOn: optionalBool(\.enabledCookieJar))
}
Section("请求与脚本") {
fieldLabel("Header")
TextEditor(text: optional(\.header)).frame(minHeight: 80)
fieldLabel("JS 库")
TextEditor(text: optional(\.jsLib)).frame(minHeight: 100)
fieldLabel("登录地址/脚本")
TextEditor(text: optional(\.loginUrl)).frame(minHeight: 80)
fieldLabel("登录 UI")
TextEditor(text: optional(\.loginUi)).frame(minHeight: 80)
fieldLabel("登录信息 JSON")
TextEditor(text: $loginInfo).frame(minHeight: 80)
if engine.loginUi?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
Button("打开登录表单", systemImage: "person.badge.key") { showsLoginForm = true }
}
fieldLabel("登录检查 JS")
TextEditor(text: optional(\.loginCheckJs)).frame(minHeight: 100)
}
Section("测试") {
TextField("测试文本", text: $testText)
Button(speech.isSpeaking ? "停止测试" : "播放测试", systemImage: speech.isSpeaking ? "stop.fill" : "play.fill") {
speech.configure(followsSystemRate: false, speechRate: 5, readByPage: true, streamsHTTPAudio: true)
speech.toggleHTTP(engine: engine, text: testText)
}
if let error = speech.errorMessage { Text(error).foregroundStyle(.red).font(.footnote) }
}
}
.navigationTitle(engine.name.isEmpty ? "新建在线朗读引擎" : engine.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
Button("保存") {
BookSourceRuntimeStore.shared.put(sourceID: engine.sourceKey, key: "loginInfo", value: loginInfo)
onSave(engine)
dismiss()
}
.disabled(engine.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || engine.url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.sheet(isPresented: $showsLoginForm) {
ReadingNavigationStack {
HttpTTSLoginView(engine: engine) { value in loginInfo = value }
}
}
.onDisappear { speech.stop() }
}
private func fieldLabel(_ value: String) -> some View {
Text(value).font(.subheadline.weight(.medium))
}
private func optional(_ keyPath: WritableKeyPath<HttpTTS, String?>) -> Binding<String> {
Binding(get: { engine[keyPath: keyPath] ?? "" }, set: { engine[keyPath: keyPath] = $0.isEmpty ? nil : $0 })
}
private func optionalBool(_ keyPath: WritableKeyPath<HttpTTS, Bool?>) -> Binding<Bool> {
Binding(get: { engine[keyPath: keyPath] == true }, set: { engine[keyPath: keyPath] = $0 })
}
}
private struct HttpTTSLoginRow: Decodable, Identifiable {
var name = ""
var type = "text"
var action: String?
var chars: [String?]?
var `default`: String?
var viewName: String?
var id: String { "\(name)-\(type)" }
private enum CodingKeys: String, CodingKey { case name, type, action, chars, `default`, viewName }
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
name = try values.decodeIfPresent(String.self, forKey: .name) ?? ""
type = try values.decodeIfPresent(String.self, forKey: .type) ?? "text"
action = try values.decodeIfPresent(String.self, forKey: .action)
chars = try values.decodeIfPresent([String?].self, forKey: .chars)
`default` = try values.decodeIfPresent(String.self, forKey: .default)
viewName = try values.decodeIfPresent(String.self, forKey: .viewName)
}
}
private struct HttpTTSLoginView: View {
@Environment(\.dismiss) private var dismiss
let engine: HttpTTS
let onSave: (String) -> Void
@State private var rows: [HttpTTSLoginRow] = []
@State private var values: [String: String] = [:]
@State private var errorMessage: String?
@State private var isRunning = false
@State private var pendingFieldAction: Task<Void, Never>?
var body: some View {
Form {
if rows.isEmpty, errorMessage == nil {
ProgressView("正在生成登录表单…")
}
ForEach(rows) { row in
switch row.type {
case "password":
SecureField(label(for: row), text: binding(for: row))
.onChange(of: values[row.name]) { _ in scheduleFieldAction(row.action) }
case "select":
Picker(label(for: row), selection: binding(for: row)) {
ForEach(options(for: row), id: \.self) { Text($0).tag($0) }
}
.onChange(of: values[row.name]) { _ in runAction(row.action) }
case "toggle":
Picker(label(for: row), selection: binding(for: row)) {
ForEach(options(for: row), id: \.self) { Text($0).tag($0) }
}
.pickerStyle(.segmented)
.onChange(of: values[row.name]) { _ in runAction(row.action) }
case "button":
Button(label(for: row)) { runAction(row.action) }
default:
TextField(label(for: row), text: binding(for: row))
.onChange(of: values[row.name]) { _ in scheduleFieldAction(row.action) }
}
}
if let errorMessage { Text(errorMessage).foregroundStyle(.red).font(.footnote) }
}
.navigationTitle("登录 · \(engine.name)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
Button(isRunning ? "执行中…" : "登录") { saveAndLogin() }.disabled(isRunning)
}
}
.task { loadRows() }
.onDisappear { pendingFieldAction?.cancel() }
}
private func loadRows() {
do {
values = decodeLoginInfo()
let json = try renderedLoginUI()
rows = try JSONDecoder().decode([HttpTTSLoginRow].self, from: Data(json.utf8))
for row in rows where row.type != "button" && values[row.name] == nil {
values[row.name] = row.default ?? options(for: row).first ?? ""
}
} catch {
errorMessage = error.localizedDescription
}
}
private func renderedLoginUI() throws -> String {
guard var raw = engine.loginUi?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
return "[]"
}
let script: String?
if raw.hasPrefix("@js:") {
script = String(raw.dropFirst(4))
} else if raw.hasPrefix("<js>"), raw.hasSuffix("</js>") {
script = String(raw.dropFirst(4).dropLast(5))
} else {
script = nil
}
if let script {
let result = try JavaScriptRuleEvaluator().evaluate(
script: script,
result: .object(values.mapValues(JSONValue.string)),
executionContext: .init(source: engine.bookSourceAdapter)
)
raw = result.value.stringValue
}
return raw
}
private func decodeLoginInfo() -> [String: String] {
let raw = BookSourceRuntimeStore.shared.get(sourceID: engine.sourceKey, key: "loginInfo")
guard let data = raw.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] }
return object.mapValues { String(describing: $0) }
}
private func binding(for row: HttpTTSLoginRow) -> Binding<String> {
Binding(get: { values[row.name] ?? row.default ?? "" }, set: { values[row.name] = $0 })
}
private func options(for row: HttpTTSLoginRow) -> [String] {
let options = row.chars?.compactMap { $0 } ?? []
return options.isEmpty ? [row.default ?? ""] : options
}
private func label(for row: HttpTTSLoginRow) -> String {
guard let value = row.viewName, value.count >= 2,
value.first == "'", value.last == "'" else { return row.name }
return String(value.dropFirst().dropLast())
}
private func runAction(_ action: String?) {
guard let action, !action.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
isRunning = true
errorMessage = nil
let engine = engine
let values = values
Task { @MainActor in
defer { isRunning = false }
do {
_ = try await Task.detached {
try JavaScriptRuleEvaluator().evaluate(
script: "\(engine.loginUrl ?? "")\n\(action)",
result: .object(values.mapValues(JSONValue.string)),
executionContext: .init(source: engine.bookSourceAdapter)
)
}.value
if engine.loginUi?.hasPrefix("@js:") == true { loadRows() }
} catch {
errorMessage = error.localizedDescription
}
}
}
private func scheduleFieldAction(_ action: String?) {
guard action?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { return }
pendingFieldAction?.cancel()
pendingFieldAction = Task { @MainActor in
do {
try await Task.sleep(nanoseconds: 600_000_000)
runAction(action)
} catch { }
}
}
private func saveAndLogin() {
isRunning = true
errorMessage = nil
do {
let data = try JSONSerialization.data(withJSONObject: values, options: [.sortedKeys])
let raw = String(decoding: data, as: UTF8.self)
BookSourceRuntimeStore.shared.put(sourceID: engine.sourceKey, key: "loginInfo", value: raw)
let engine = engine
let values = values
Task { @MainActor in
do {
if let login = engine.loginUrl?.trimmingCharacters(in: .whitespacesAndNewlines), !login.isEmpty {
_ = try await Task.detached {
try JavaScriptRuleEvaluator().evaluate(
script: "\(login)\nif (typeof login === 'function') login.apply(this);",
result: .object(values.mapValues(JSONValue.string)),
executionContext: .init(source: engine.bookSourceAdapter)
)
}.value
}
onSave(raw)
dismiss()
} catch {
errorMessage = error.localizedDescription
isRunning = false
}
}
} catch {
errorMessage = error.localizedDescription
isRunning = false
}
}
}
private struct HttpTTSJSONDocument: FileDocument {
static var readableContentTypes: [UTType] { [.json] }
var data: Data
init(data: Data) { self.data = data }
init(configuration: ReadConfiguration) throws {
data = configuration.file.regularFileContents ?? Data()
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
FileWrapper(regularFileWithContents: data)
}
}