8 Commits
Author SHA1 Message Date
Aminark 67bc212ffd feat: 支持 EPUB 阅读并改善规则兼容与阅读稳定性
支持 EPUB 2/3 导入、目录、封面和章节插图,保留本地书管理与阅读进度。
修复 JSON 模板、动态替换与请求参数兼容,以及 RSS 翻页和连续滚动定位。
更新编辑器工具栏和回归测试,发布版本为 0.1.12(build 18)。
2026-10-24 00:58:11 +08:00
Aminark 3322fd71db chore: update AltStore source for v0.1.11 2026-10-22 11:07:21 +08:00
Aminark b28c28ed0e release: prepare v0.1.11 (build 17) 2026-10-22 11:00:04 +08:00
Aminark 45cf5b5482 fix(appearance): 完善深色模式和阅读页面对比度
- 区分按钮填充与图标颜色,统一书架、书源、搜索和阅读设置的选中态
- 调整编辑器语法高亮、键盘工具栏及统计卡片的浅深色显示
- 适配 RSS 正文和登录网页背景,保留书源自定义样式和页面状态
- 补充外观切换、页面对比度和管理页面的回归测试
2026-10-22 10:53:19 +08:00
Aminark bf72ebc894 fix(import): 修复本地 TXT 文件选择与导入
- 书架和文件管理统一使用 TXT 文件选择器,支持大小写扩展名及多选,排除 JSON 书源
- 保留所选文件访问权限至异步导入完成,等待选择器关闭后展示导入确认
- 补充双入口文件选择回归、导入校验和 TXT 测试样本
2026-10-22 10:51:09 +08:00
Aminark 7daa6b5635 feat: 添加 AltStore 应用源及发布维护流程
- 根据已发布 Release 和实际 IPA 生成应用源,包含版本、下载地址、权限和截图
- 校验包标识、版本标签、文件大小与 SHA-256,补充生成脚本测试
- 记录手动更新应用源的维护流程,完善本地自检
- 忽略仅供本机使用的 GitHub 上传说明
2026-10-20 23:35:00 +08:00
Aminark 0915a144a7 fix: 修复书源导入、阅读外观和书签状态
- 修复书源文件选择、访问权限与导入预览流程,支持查看分组内的书源
- 修复背景图片、字体选择与主题切换,限制背景图片解码尺寸
- 根据已保存书签恢复当前位置状态,避免重复添加
- 补充模型、阅读器和文件选择回归测试及测试样本
- 更新应用版本为 0.1.10(build 16)

Fixes #1
2026-10-20 23:30:42 +08:00
Aminark 0bf332441d docs: add LINUX DO community link 2026-10-19 15:17:06 +08:00
80 changed files with 4411 additions and 380 deletions
+1
View File
@@ -4,6 +4,7 @@ DerivedData/
*.xcuserstate
xcuserdata/
reference/
/GITHUB_UPLOAD.local.md
artifacts/
*.xcresult/
+148 -16
View File
@@ -127,20 +127,37 @@ struct ReadingPhotoPicker<Label: View>: View {
var body: some View {
Button { isPresented = true } label: { label }
.sheet(isPresented: $isPresented) {
ReadingPhotoPickerController { result in
isPresented = false
guard let result else { return }
switch result {
case .success(let data): onData(data)
case .failure(let error): onError(error)
}
}
}
.readingPhotoPicker(isPresented: $isPresented, onData: onData, onError: onError)
}
}
private struct ReadingPhotoPickerController: UIViewControllerRepresentable {
private struct ReadingPhotoPickerModifier: ViewModifier {
@Binding var isPresented: Bool
let onData: (Data) -> Void
let onError: (Error) -> Void
@State private var pendingResult: Result<Data, Error>?
func body(content: Content) -> some View {
content.sheet(isPresented: $isPresented, onDismiss: {
let result = pendingResult
pendingResult = nil
switch result {
case .success(let data): onData(data)
case .failure(let error): onError(error)
case nil: break
}
}) {
ReadingPhotoPickerController { result in
// Apply changes after the picker finishes dismissing, so a
// Form update cannot remove its presenter mid-transition.
pendingResult = result
isPresented = false
}
}
}
}
struct ReadingPhotoPickerController: UIViewControllerRepresentable {
let onResult: @MainActor (Result<Data, Error>?) -> Void
func makeCoordinator() -> Coordinator { Coordinator(onResult: onResult) }
@@ -166,22 +183,121 @@ private struct ReadingPhotoPickerController: UIViewControllerRepresentable {
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
guard let provider = results.first?.itemProvider else { onResult(nil); return }
loadImage(from: provider)
}
func loadImage(from provider: NSItemProvider) {
let identifier = provider.registeredTypeIdentifiers.first {
UTType($0)?.conforms(to: .image) == true
} ?? UTType.image.identifier
provider.loadDataRepresentation(forTypeIdentifier: identifier) { [weak self] data, error in
Task { @MainActor in
if let data { self?.onResult(.success(data)) }
else { self?.onResult(.failure(error ?? CocoaError(.fileReadCorruptFile))) }
}
provider.loadDataRepresentation(forTypeIdentifier: identifier, completionHandler: didLoadImageData)
}
// Photos calls back on NSItemProvider-callback-queue. A method reference
// preserves explicit nonisolation in optimized device builds, whereas a
// closure here can infer MainActor and assert before reaching the Task.
nonisolated private func didLoadImageData(_ data: Data?, _ error: (any Error)?) {
let result: Result<Data, Error>
if let data { result = .success(data) }
else { result = .failure(error ?? CocoaError(.fileReadCorruptFile)) }
Task { @MainActor [weak self] in
self?.onResult(result)
}
}
}
}
private struct ReadingLocalBookFileImporterModifier: ViewModifier {
@Binding var isPresented: Bool
let onFiles: ([LocalBookImportFile]) -> Void
let onError: (Error) -> Void
@State private var pickedFiles: [LocalBookImportFile] = []
@State private var pickerHasDismissed = false
func body(content: Content) -> some View {
content.sheet(isPresented: $isPresented, onDismiss: {
pickerHasDismissed = true
deliverFilesWhenReady()
}) {
ReadingLocalBookFilePicker { files in
pickedFiles = files
isPresented = false
deliverFilesWhenReady()
} onCancel: {
pickedFiles = []
isPresented = false
}
.onAppear {
pickedFiles = []
pickerHasDismissed = false
}
}
}
private func deliverFilesWhenReady() {
// Providers may return their copies before or after dismissing Files.
guard pickerHasDismissed, !pickedFiles.isEmpty else { return }
let files = pickedFiles
pickedFiles = []
guard files.allSatisfy({ LocalBookService.supports($0.url) && !$0.url.hasDirectoryPath }) else {
onError(LocalBookError.unsupportedFormat)
return
}
onFiles(files)
}
}
private struct ReadingLocalBookFilePicker: UIViewControllerRepresentable {
let onPick: @MainActor ([LocalBookImportFile]) -> Void
let onCancel: @MainActor () -> Void
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
// Keep source JSON out of the local-book picker.
let epub = UTType(filenameExtension: "epub") ?? UTType(importedAs: "org.idpf.epub-container")
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.plainText, epub], asCopy: true)
picker.allowsMultipleSelection = true
picker.shouldShowFileExtensions = true
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ controller: UIDocumentPickerViewController, context: Context) {}
@MainActor
final class Coordinator: NSObject, UIDocumentPickerDelegate {
let parent: ReadingLocalBookFilePicker
init(parent: ReadingLocalBookFilePicker) { self.parent = parent }
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { parent.onCancel() }
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
parent.onPick(urls.map { LocalBookImportFile(url: $0) })
}
}
}
enum ReadingSheetDetent { case medium, large }
extension View {
func readingLocalBookFileImporter(
isPresented: Binding<Bool>,
onFiles: @escaping ([LocalBookImportFile]) -> Void,
onError: @escaping (Error) -> Void
) -> some View {
modifier(ReadingLocalBookFileImporterModifier(isPresented: isPresented, onFiles: onFiles, onError: onError))
}
func readingPhotoPicker(
isPresented: Binding<Bool>,
onData: @escaping (Data) -> Void,
onError: @escaping (Error) -> Void
) -> some View {
modifier(ReadingPhotoPickerModifier(isPresented: isPresented, onData: onData, onError: onError))
}
@ViewBuilder
func readingNumericTransition() -> some View {
if #available(iOS 16.0, *) { contentTransition(.numericText()) }
@@ -225,6 +341,22 @@ extension View {
}
}
@ViewBuilder
func readingNavigationBarBackground() -> some View {
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI {
toolbarBackground(ReadingTheme.canvas, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
} else {
background(ReadingUIKitConfiguration { controller in
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = UIColor(ReadingTheme.canvas)
controller.navigationController?.navigationBar.standardAppearance = appearance
controller.navigationController?.navigationBar.scrollEdgeAppearance = appearance
})
}
}
@ViewBuilder
func readingTabBarBackground() -> some View {
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI {
+42 -6
View File
@@ -77,6 +77,29 @@ private struct ReadingUnavailableLabelStyle: LabelStyle {
}
extension View {
/// Keep filled controls dark in both appearances, independently of icon tint.
func readingProminentButtonStyle() -> some View {
buttonStyle(.borderedProminent)
.tint(ReadingTheme.accentFill)
.foregroundStyle(ReadingTheme.onAccent)
}
func readingSecondaryButtonStyle() -> some View {
buttonStyle(.bordered)
.tint(ReadingTheme.accentFill)
.foregroundStyle(ReadingTheme.accent)
}
@ViewBuilder
func readingChoiceButtonStyle(isSelected: Bool) -> some View {
if isSelected {
readingProminentButtonStyle()
.accessibilityAddTraits(.isSelected)
} else {
readingSecondaryButtonStyle()
}
}
func readingSearchable(text: Binding<String>, isPresented: Binding<Bool>, prompt: String) -> some View {
modifier(ReadingSearchModifier(text: text, isPresented: isPresented, prompt: prompt))
}
@@ -144,7 +167,11 @@ private struct ReadingSearchModifier: ViewModifier {
}
enum ReadingTheme {
static let accent = adaptive(0x171717, dark: 0xF2F2F2)
static let accent = adaptive(0x171717, dark: 0xDADAE0)
static let accentFill = adaptive(0x171717, dark: 0x414148)
static let emphasisSurface = adaptive(0x171717, dark: 0x29292E)
static let selectionBorder = adaptive(0x171717, dark: 0x777780)
static let toggleTint = Color(uiColor: .systemGreen)
static let accentSoft = adaptive(0xEEEEEF, dark: 0x29292C)
static let coral = Color(red: 0.76, green: 0.48, blue: 0.27)
static let paper = Color(red: 0.965, green: 0.945, blue: 0.895)
@@ -153,7 +180,7 @@ enum ReadingTheme {
static let groupedCanvas = adaptive(0xF3F3F4, dark: 0x1C1C1F)
static let surface = adaptive(0xFFFFFF, dark: 0x232326)
static let separator = adaptive(0xE6E6E8, dark: 0x363639)
static let onAccent = adaptive(0xFFFFFF, dark: 0x171717)
static let onAccent = Color.white
private static func adaptive(_ light: UInt32, dark: UInt32) -> Color {
Color(uiColor: UIColor { traits in
@@ -273,11 +300,20 @@ struct BookCoverArtwork: View {
}
var body: some View {
AsyncImage(url: remoteURL.flatMap(URL.init(string:))) { phase in
if case .success(let image) = phase {
image.resizable().scaledToFill()
Group {
if let remoteURL, remoteURL.hasPrefix("data:image/"),
let comma = remoteURL.firstIndex(of: ","),
let data = Data(base64Encoded: String(remoteURL[remoteURL.index(after: comma)...])),
let image = UIImage(data: data) {
Image(uiImage: image).resizable().scaledToFill()
} else {
placeholder
AsyncImage(url: remoteURL.flatMap(URL.init(string:))) { phase in
if case .success(let image) = phase {
image.resizable().scaledToFill()
} else {
placeholder
}
}
}
}
.frame(width: width, height: height)
+11 -11
View File
@@ -26,9 +26,9 @@ private final class FileManagementModel: ObservableObject {
await reload(library)
}
func importFiles(_ urls: [URL], library: BookLibraryModel) async {
func importFiles(_ files: [LocalBookImportFile], library: BookLibraryModel) async {
await run(library) {
await library.importLocalFiles(urls)
await library.importPickedLocalFiles(files)
errorMessage = library.errorMessage
library.clearError()
}
@@ -126,7 +126,7 @@ struct FileManagementView: View {
Button("完成") { selecting = false; selection.removeAll() }
} else {
Menu {
Button("导入 TXT 文件", systemImage: "square.and.arrow.down") { importsFile = true }
Button("导入 TXT / EPUB", systemImage: "square.and.arrow.down") { importsFile = true }
if tab == 0 {
Button("选择文件", systemImage: "checkmark.circle") { selecting = true }
.disabled(displayedFiles.isEmpty)
@@ -136,6 +136,7 @@ struct FileManagementView: View {
}
}
} label: { Image(systemName: "ellipsis.circle") }
.accessibilityLabel("更多")
}
}
}
@@ -162,11 +163,10 @@ struct FileManagementView: View {
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
}
.readingLocalBookFileImporter(isPresented: $importsFile) { files in
Task { await model.importFiles(files, library: library) }
} onError: { error in
model.errorMessage = error.localizedDescription
}
.alert("重命名文件", isPresented: Binding(get: { renameURL != nil }, set: { if !$0 { renameURL = nil } }), presenting: renameURL) { url in
TextField("文件名", text: $newName)
@@ -177,7 +177,7 @@ struct FileManagementView: View {
Task { await model.run(library) { try await library.renameLocalFile(at: url, to: name) } }
}
} message: { _ in
Text("保留 TXT 格式,书架中的书名、书签和阅读进度不变。")
Text("保留原文件格式,书架中的书名、书签和阅读进度不变。")
}
}
@@ -239,8 +239,8 @@ struct FileManagementView: View {
}
Section("文件") {
if displayedFiles.isEmpty {
ReadingUnavailableView(query.isEmpty ? "还没有本地文件" : "没有匹配的文件", systemImage: "doc.text", description: Text("可从系统“文件”导入 TXT,文件会复制到应用中。"))
Button("导入 TXT 文件", systemImage: "plus") { importsFile = true }
ReadingUnavailableView(query.isEmpty ? "还没有本地文件" : "没有匹配的文件", systemImage: "doc.text", description: Text("可从系统“文件”导入 TXT 或 EPUB,文件会复制到应用中。"))
Button("导入 TXT / EPUB", systemImage: "plus") { importsFile = true }
}
ForEach(displayedFiles) { file in
Group {
@@ -47,7 +47,9 @@ struct ReadingStatisticsView: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(24)
.background(ReadingTheme.accent, in: RoundedRectangle(cornerRadius: 20))
.background(ReadingTheme.emphasisSurface, in: RoundedRectangle(cornerRadius: 20))
.accessibilityElement(children: .contain)
.accessibilityIdentifier("statistics.todayCard")
}
private var overviewGrid: some View {
+2 -2
View File
@@ -194,7 +194,7 @@ struct BookDetailView: View {
.font(.body.weight(.medium))
.frame(maxWidth: .infinity, minHeight: 28)
}
.buttonStyle(.borderedProminent)
.readingProminentButtonStyle()
.disabled(model.chapters.isEmpty)
Button {
if library.contains(model.book) {
@@ -210,7 +210,7 @@ struct BookDetailView: View {
.font(.subheadline)
.frame(maxWidth: dynamicTypeSize.isAccessibilitySize ? .infinity : nil, minHeight: 28)
}
.buttonStyle(.bordered)
.readingSecondaryButtonStyle()
}
@ViewBuilder
+23 -4
View File
@@ -4,6 +4,20 @@ import SwiftUI
import LegadoCore
#endif
final class LocalBookImportFile: @unchecked Sendable {
let url: URL
private let hasSecurityScope: Bool
init(url: URL) {
self.url = url
hasSecurityScope = url.startAccessingSecurityScopedResource()
}
deinit {
if hasSecurityScope { url.stopAccessingSecurityScopedResource() }
}
}
enum AppBookChapterRepository {
static func live() -> any BookChapterRepository {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
@@ -318,10 +332,15 @@ final class BookLibraryModel: ObservableObject {
}
func importLocalFiles(_ urls: [URL]) async {
await importPickedLocalFiles(urls.map { LocalBookImportFile(url: $0) })
}
func importPickedLocalFiles(_ files: [LocalBookImportFile]) async {
// Keep the picker grants alive through the review and asynchronous copy.
defer { withExtendedLifetime(files) {} }
var failures: [String] = []
for url in urls {
let granted = url.startAccessingSecurityScopedResource()
defer { if granted { url.stopAccessingSecurityScopedResource() } }
for file in files {
let url = file.url
do {
let stored = try await localFiles.importFile(from: url)
do {
@@ -341,7 +360,7 @@ final class BookLibraryModel: ObservableObject {
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
let report = try await Task.detached { [localService] in try localService.openFile(at: url) }.value
guard !books.contains(where: { $0.bookUrl == url.absoluteString }) else { return }
guard await persist(books + [report.book]) else {
throw LocalFileError.saveFailed(errorMessage ?? "无法写入数据库")
+6 -2
View File
@@ -33,16 +33,20 @@ struct LocalBookDetailView: View {
ScrollView {
VStack(spacing: 16) {
VStack(spacing: 14) {
BookCoverArtwork(title: book.name, author: book.author, width: 108, height: 154)
BookCoverArtwork(title: book.name, author: book.author, remoteURL: book.customCoverUrl ?? book.coverUrl, width: 108, height: 154)
Text(book.name).font(.title2.bold())
if !book.author.isEmpty { Text(book.author).font(.subheadline).foregroundStyle(.secondary) }
HStack {
StatusPill(text: "本地 TXT", systemImage: "doc.text.fill")
StatusPill(text: "本地 " + (URL(string: book.bookUrl)?.pathExtension.uppercased() ?? "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)
if let intro = book.intro, !intro.isEmpty {
ReadingCard { Text(intro).font(.subheadline).frame(maxWidth: .infinity, alignment: .leading) }
}
ReadingCard {
VStack(alignment: .leading, spacing: 0) {
HStack {
+1 -1
View File
@@ -151,6 +151,6 @@ struct SourceSwitchView: View {
.padding(.vertical, 5)
}
.disabled(model.switchingID != nil)
.accessibilityIdentifier("sourceSwitch.candidate.\(candidate.book.origin)")
.accessibilityIdentifier("sourceSwitch.candidate.\(candidate.id)")
}
}
+1 -1
View File
@@ -155,7 +155,7 @@ struct ExploreView: View {
Text("添加带有发现分类的书源,即可在这里浏览书籍。")
} actions: {
NavigationLink("管理书源") { SourceManagementView() }
.buttonStyle(.borderedProminent)
.readingProminentButtonStyle()
}
} else {
LazyVStack(spacing: 0) {
+69 -31
View File
@@ -10,29 +10,42 @@ final class RSSLibraryModel: ObservableObject {
@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 var sourceRepository: SQLiteRssSourceRepository { get throws { .init(database: try database) } }
private var articleRepository: SQLiteRssArticleRepository { get throws { .init(database: try database) } }
private var starRepository: SQLiteRssStarRepository { get throws { .init(database: try database) } }
private var recordRepository: SQLiteRssReadRecordRepository { get throws { .init(database: try database) } }
private let service: RSSService
private let importer = RSSSourceImporter()
private let database: LegadoDatabase
private let databaseResult: Result<LegadoDatabase, Error>
private var database: LegadoDatabase { get throws { try databaseResult.get() } }
private var nextURL: String?
private var page = 1
private var order: Int64 = 0
private struct Feed: Equatable {
let origin: String
let sort: String
let key: String?
}
private var activeFeed: Feed?
private var articleRequestID = UUID()
private var loadingMoreRequestID: UUID?
private var activeOperations = 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)
databaseResult = .success(database)
self.service = service
}
static func live() -> RSSLibraryModel {
let database = try! LegadoDatabase(url: LegadoDatabase.defaultURL())
return .init(database: database)
private init(databaseError: Error) {
let error = LegadoDatabaseError.open("RSS 数据库无法打开:\(databaseError.localizedDescription)。请检查存储空间后重新启动应用。")
databaseResult = .failure(error)
service = .init()
errorMessage = error.localizedDescription
}
static func live(databaseURL: URL = LegadoDatabase.defaultURL()) -> RSSLibraryModel {
do { return .init(database: try LegadoDatabase(url: databaseURL)) }
catch { return .init(databaseError: error) }
}
func load() async {
@@ -62,15 +75,16 @@ final class RSSLibraryModel: ObservableObject {
func importText(_ text: String) async {
await perform {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
var merged: [RssSource]
if let url = URL(string: trimmed), ["http", "https"].contains(url.scheme?.lowercased() ?? "") {
self.sources = try await self.importRemote(trimmed, existing: self.sources)
merged = 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)
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)
try await self.sourceRepository.save(merged)
self.sources = try await self.sourceRepository.all()
}
}
@@ -130,31 +144,53 @@ final class RSSLibraryModel: ObservableObject {
}
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 feed = Feed(origin: source.sourceUrl, sort: name, key: key)
let requestID = UUID()
articleRequestID = requestID
loadingMoreRequestID = nil
if activeFeed != feed { articles = [] }
activeFeed = feed
nextURL = nil
page = 1
await perform(isCurrent: { self.articleRequestID == requestID }) {
let report = try await self.service.articles(sortName: name, sortURL: url, source: source, page: 1, key: key)
guard self.articleRequestID == requestID else { return }
var nextOrder = Int64(Date().timeIntervalSince1970 * 1_000)
var values = report.result.articles
for index in values.indices { values[index].order = self.order; self.order -= 1 }
for index in values.indices { values[index].order = nextOrder; nextOrder -= 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) }
if source.ruleNextPage?.isEmpty == false { try await self.articleRepository.clearOld(origin: source.sourceUrl, sort: name, before: nextOrder) }
let articles = try await self.articleRepository.articles(origin: source.sourceUrl, sort: name)
guard self.articleRequestID == requestID else { return }
self.order = nextOrder
self.nextURL = report.result.nextURL
self.articles = try await self.articleRepository.articles(origin: source.sourceUrl, sort: name)
self.articles = articles
}
}
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)
guard activeFeed == Feed(origin: source.sourceUrl, sort: name, key: key),
loadingMoreRequestID == nil, let nextURL, !nextURL.isEmpty else { return }
let requestID = articleRequestID
loadingMoreRequestID = requestID
defer { if loadingMoreRequestID == requestID { loadingMoreRequestID = nil } }
let nextPage = page + 1
await perform(isCurrent: { self.articleRequestID == requestID }) {
let report = try await self.service.articles(sortName: name, sortURL: nextURL, source: source, page: nextPage, key: key)
guard self.articleRequestID == requestID else { return }
var nextOrder = self.order
var values = report.result.articles
for index in values.indices { values[index].order = self.order; self.order -= 1 }
for index in values.indices { values[index].order = nextOrder; nextOrder -= 1 }
try await self.articleRepository.append(values)
let articles = try await self.articleRepository.articles(origin: source.sourceUrl, sort: name)
guard self.articleRequestID == requestID else { return }
self.page = nextPage
self.order = nextOrder
self.nextURL = report.result.nextURL
self.articles = try await self.articleRepository.articles(origin: source.sourceUrl, sort: name)
self.articles = articles
}
}
@@ -231,9 +267,11 @@ final class RSSLibraryModel: ObservableObject {
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 func perform(isCurrent: () -> Bool = { true }, _ operation: () async throws -> Void) async {
activeOperations += 1
isWorking = true
defer { activeOperations -= 1; isWorking = activeOperations > 0 }
do { try await operation() } catch { if isCurrent() { errorMessage = error.localizedDescription } }
}
}
+43 -9
View File
@@ -142,7 +142,7 @@ private struct RSSArticleListView: View {
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)
.readingChoiceButtonStyle(isSelected: selectedSort == index)
}}.padding(.horizontal)
}.padding(.vertical, 6)
}
@@ -151,14 +151,20 @@ private struct RSSArticleListView: View {
.overlay { if library.articles.isEmpty, !library.isWorking { ReadingUnavailableView("没有文章", systemImage: "newspaper") } }
.navigationTitle(source.sourceName)
.searchable(text: $searchKey, prompt: "源内搜索")
.onSubmit(of: .search) { Task { await refresh() } }
.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) } } }
ToolbarItem(placement: .bottomBar) {
Button("加载下一页", systemImage: "arrow.down") {
Task { await library.loadMore(source, sortName: activeName, key: searchKey.nilIfEmpty) }
}.disabled(library.isWorking)
}
}
.task { sorts = await RSSSortResolver().resolve(source); await refresh() }
.readingTabBarHidden()
}
private var activeSort: RSSSort { sorts.indices.contains(selectedSort) ? sorts[selectedSort] : .init(name: "", url: source.sourceUrl) }
@@ -435,7 +441,7 @@ private struct RSSDownloadView: View {
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)
Button("选择保存位置", systemImage: "folder") { showsExporter = true }.readingProminentButtonStyle()
}
}.padding().navigationTitle("文件下载").toolbar { ToolbarItem(placement: .cancellationAction) { Button("关闭") { dismiss() } } }
.task(id: url) { await download() }
@@ -464,7 +470,33 @@ private struct RSSBinaryDocument: FileDocument {
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { .init(regularFileWithContents: data) }
}
private struct RSSPhotoView: View {
private struct RSSPhotoToolbar: ViewModifier {
let canSave: Bool
let save: () -> Void
let close: () -> Void
func body(content: Content) -> some View {
if #available(iOS 26.0, *) {
content.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("保存到照片", systemImage: "square.and.arrow.down", action: save).disabled(!canSave)
}.sharedBackgroundVisibility(.hidden)
ToolbarItem(placement: .confirmationAction) {
Button("完成", action: close).buttonStyle(.plain)
}.sharedBackgroundVisibility(.hidden)
}
} else {
content.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("保存到照片", systemImage: "square.and.arrow.down", action: save).disabled(!canSave)
}
ToolbarItem(placement: .confirmationAction) { Button("完成", action: close) }
}
}
}
}
struct RSSPhotoView: View {
@Environment(\.dismiss) private var dismiss
let value: String
let source: RssSource
@@ -480,16 +512,18 @@ private struct RSSPhotoView: View {
else if loading { ProgressView() }
else { ReadingUnavailableView("图片加载失败", systemImage: "photo.badge.exclamationmark") }
}.frame(minWidth: proxy.size.width, minHeight: proxy.size.height)
}.background(.black)
}
.background(.black)
.environment(\.colorScheme, .dark)
.tint(.white)
}
.navigationTitle(source.sourceName).navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) { Button("保存到照片", systemImage: "square.and.arrow.down") { save() }.disabled(image == nil) }
ToolbarItem(placement: .confirmationAction) { Button("完成") { dismiss() } }
}
.readingNavigationBarBackground()
.modifier(RSSPhotoToolbar(canSave: image != nil, save: save, close: { dismiss() }))
.task(id: value) { await load() }
.alert("图片", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) { Button("好") { message = nil } } message: { Text(message ?? "") }
}
.readingTabBarHidden()
}
private func load() async {
+3
View File
@@ -41,6 +41,9 @@ struct RSSWebReaderView: UIViewRepresentable {
controller.addUserScript(.init(source: inject, injectionTime: .atDocumentEnd, forMainFrameOnly: false))
}
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.isOpaque = false
webView.backgroundColor = UIColor(ReadingTheme.canvas)
webView.scrollView.backgroundColor = UIColor(ReadingTheme.canvas)
webView.navigationDelegate = context.coordinator
webView.uiDelegate = context.coordinator
webView.allowsBackForwardNavigationGestures = true
@@ -0,0 +1,74 @@
import SwiftUI
import ImageIO
struct EPUBIllustrationsView: View {
@Environment(\.dismiss) private var dismiss
let book: Book
let chapter: BookChapter
@State private var images: [EPUBIllustration] = []
@State private var isLoading = true
@State private var errorMessage: String?
var body: some View {
ReadingNavigationStack {
ScrollView {
LazyVStack(spacing: 24) {
if isLoading {
ProgressView("正在读取插图…").padding()
} else if let errorMessage {
ReadingUnavailableView("无法读取插图", systemImage: "photo", description: Text(errorMessage))
} else if images.isEmpty {
ReadingUnavailableView("本章没有插图", systemImage: "photo", description: Text(chapter.title))
} else {
ForEach(images) { image in
VStack(spacing: 8) {
EPUBIllustrationImage(data: image.data)
Text("插图 \(image.id)\(image.caption.isEmpty ? "" : " · " + image.caption)")
.font(.caption).foregroundStyle(.secondary)
}
}
}
}.padding()
}
.background(ReadingTheme.canvas)
.navigationTitle("本章插图")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("完成") { dismiss() } } }
.task(id: chapter.id) {
isLoading = true
errorMessage = nil
do {
images = try await Task.detached { try LocalBookService().loadIllustrations(book: book, chapter: chapter) }.value
} catch { errorMessage = error.localizedDescription }
isLoading = false
}
}
}
}
private struct EPUBIllustrationImage: View {
let data: Data
@State private var image: UIImage?
var body: some View {
Group {
if let image {
Image(uiImage: image).resizable().scaledToFit().accessibilityLabel("书籍插图")
} else {
Text("图片无法显示").foregroundStyle(.secondary)
}
}
.task {
// Decode a bounded thumbnail off the main thread, even for huge source dimensions.
image = await Task.detached {
guard let source = CGImageSourceCreateWithData(data as CFData, nil),
let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: 2_048,
kCGImageSourceCreateThumbnailWithTransform: true
] as CFDictionary) else { return Optional<UIImage>.none }
return UIImage(cgImage: thumbnail)
}.value
}
}
}
+26 -7
View File
@@ -1,4 +1,5 @@
import CoreText
import ImageIO
import SwiftUI
import UIKit
@@ -54,25 +55,43 @@ private extension UIColor {
}
enum ReaderBackgroundStore {
static let maximumPixelSize = 2560
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 load(from source: URL = url) -> UIImage? {
guard let data = try? Data(contentsOf: source, options: .mappedIfSafe) else { return nil }
return try? downsample(data)
}
static func save(_ data: Data) throws -> UIImage {
guard let image = UIImage(data: data), let encoded = image.jpegData(compressionQuality: 0.9) else {
static func save(_ data: Data, to target: URL = url) throws -> UIImage {
let image = try downsample(data)
guard 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)
try FileManager.default.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true)
try encoded.write(to: target, options: .atomic)
return image
}
private static func downsample(_ data: Data) throws -> UIImage {
// Decode only the size used by the reader, including backgrounds saved
// by older versions. Full-resolution camera photos can exhaust memory.
guard let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceThumbnailMaxPixelSize: maximumPixelSize
] as CFDictionary) else {
throw CocoaError(.fileReadCorruptFile)
}
return UIImage(cgImage: image)
}
static func remove() { try? FileManager.default.removeItem(at: url) }
}
+20 -11
View File
@@ -129,7 +129,8 @@ struct ReaderScrollView: UIViewRepresentable {
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
top: CGFloat(config.paddingTop), bottom: CGFloat(config.paddingBottom),
viewportHeight: view.bounds.height, scale: view.traitCollection.displayScale
)
let targetY = anchor.flatMap { offset(for: $0, blocks: newBlocks, frames: geometry.frames) }
let correction = targetY.map { $0 - view.contentOffset.y } ?? 0
@@ -277,10 +278,14 @@ struct ReaderScrollView: UIViewRepresentable {
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 }
// The reading position starts below the configured top padding.
// Preserve that reference point when earlier chapters are inserted.
let y = max(view.contentOffset.y + CGFloat(max(parent.configuration.paddingTop, 0)),
layout.frames.first?.minY ?? 0)
let scale = max(view.traitCollection.displayScale, 1)
guard let row = layout.row(at: (y * scale).rounded() / scale) else { return nil }
let block = blocks[row], chapter = block.chapter
let localY = y - layout.frames[row].minY + block.top
let localY = max(y - layout.frames[row].minY, 0) + 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)
@@ -385,17 +390,21 @@ final class ReaderContinuousLayout: UICollectionViewLayout {
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
static func geometry(blocks: [ReaderScrollBlock], width: CGFloat, top: CGFloat, bottom: CGFloat, viewportHeight: CGFloat, scale: CGFloat) -> (frames: [CGRect], size: CGSize) {
let scale = max(scale, 1)
// Keep insertion offsets in whole physical pixels. UIKit rounds scroll
// offsets, so fractional chapter heights otherwise accumulate drift.
var pixels = (top * scale).rounded()
let frames = blocks.map { block -> CGRect in
let frame = CGRect(x: 0, y: y, width: width, height: block.height)
y += block.height
let height = ceil(block.height * scale)
let frame = CGRect(x: 0, y: pixels / scale, width: width, height: height / scale)
pixels += 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))
return (frames, CGSize(width: width, height: max(pixels / scale, finalStart + viewportHeight) + bottom))
}
func install(frames: [CGRect], size: CGSize, correction: CGFloat) {
@@ -412,9 +421,9 @@ final class ReaderContinuousLayout: UICollectionViewLayout {
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 }
if frames[middle].minY <= y { low = middle + 1 } else { high = middle }
}
return min(low, frames.count - 1)
return max(low - 1, 0)
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
+118 -49
View File
@@ -18,6 +18,7 @@ struct ReaderView: View {
@StateObject private var speech = ReaderSpeechController()
@State private var showsTableOfContents = false
@State private var showsBookmarks = false
@State private var illustrationChapter: BookChapter?
@State private var showsAutoReadControls = false
@State private var showsStyleControls = false
@State private var isAutoReading = false
@@ -36,6 +37,7 @@ struct ReaderView: View {
@State private var contentEditError: String?
@State private var backgroundImage: UIImage?
@State private var backgroundError: String?
@State private var showsBackgroundPicker = false
@State private var showsFontImporter = false
@State private var fontError: String?
@State private var readerJumpPosition: Int?
@@ -55,7 +57,7 @@ struct ReaderView: View {
@State private var progressSaveGeneration = 0
@State private var speechStartGeneration = 0
@State private var speechTimerRemaining = 0
@State private var bookmarkSaved = false
@State private var isSavingBookmark = false
@ScaledMetric(relativeTo: .body) private var toolbarIconSize = 21
@AppStorage("textSize") private var fontSize = 20.0
@AppStorage("letterSpacing") private var letterSpacing = 0.1
@@ -256,6 +258,9 @@ struct ReaderView: View {
}
.sheet(isPresented: $showsTableOfContents) { tableOfContents }
.sheet(isPresented: $showsBookmarks) { bookmarksSheet }
.sheet(item: $illustrationChapter) { chapter in
EPUBIllustrationsView(book: book, chapter: chapter)
}
.sheet(isPresented: $showsAutoReadControls) { autoReadControls }
.sheet(isPresented: $showsStyleControls) { styleControls }
.sheet(isPresented: $showsContentSearch) { contentSearchSheet }
@@ -282,19 +287,6 @@ struct ReaderView: View {
.sheet(isPresented: $showsReaderSettings) { readerSettings }
.sheet(isPresented: $showsClickActionSettings) { clickActionSettings }
.sheet(isPresented: $showsContentEditor) { contentEditor }
.fileImporter(
isPresented: $showsFontImporter,
allowedContentTypes: [.font, .data],
allowsMultipleSelection: false
) { result in
fontError = nil
do {
guard let source = try result.get().first else { return }
fontPath = try ReaderFontStore.install(from: source)
} catch {
fontError = error.localizedDescription
}
}
.task(id: model.chapterLoadGeneration) {
isWaitingForNextChapter = false
isWaitingForPreviousChapter = false
@@ -413,7 +405,7 @@ struct ReaderView: View {
.background {
ZStack {
backgroundColor
if themeMode != "3", useBackgroundImage, let backgroundImage {
if let backgroundImage = activeBackgroundImage {
Image(uiImage: backgroundImage)
.resizable()
.scaledToFill()
@@ -504,7 +496,7 @@ struct ReaderView: View {
.accessibilityIdentifier("reader.failureChangeSource")
}
}
.buttonStyle(.bordered)
.readingSecondaryButtonStyle()
}
private func openSourceSwitch() {
@@ -577,7 +569,7 @@ struct ReaderView: View {
pageAnimation: pageAnimation,
isEInkMode: themeMode == "3",
clickActions: clickActions,
backgroundColor: themeMode != "3" && useBackgroundImage && backgroundImage != nil ? .clear : resolvedStyle.background,
backgroundColor: activeBackgroundImage == nil ? resolvedStyle.background : .clear,
foregroundColor: resolvedStyle.foreground,
requestedPosition: pageAnimation == 3 ? readerPosition : model.requestedPosition,
jumpPosition: readerJumpPosition,
@@ -678,6 +670,10 @@ struct ReaderView: View {
return min(max(position, 0), max(length - 1, 0))
}
private var activeBackgroundImage: UIImage? {
themeMode != "3" && useBackgroundImage ? backgroundImage : nil
}
private var backgroundColor: Color {
Color(uiColor: resolvedStyle.background)
}
@@ -706,6 +702,7 @@ struct ReaderView: View {
|| showsReaderMenu
|| showsTableOfContents
|| showsBookmarks
|| illustrationChapter != nil
|| showsAutoReadControls
|| showsStyleControls
|| showsContentSearch
@@ -822,6 +819,7 @@ struct ReaderView: View {
addBookmark()
}
.accessibilityValue(bookmarkSaved ? "已添加" : "添加当前位置")
.disabled(isSavingBookmark)
readerMoreMenu
}
.foregroundStyle(readerChromeTint)
@@ -841,6 +839,12 @@ struct ReaderView: View {
showsReaderMenu = false
showsContentSearch = true
}
if book.origin == "local", URL(string: book.bookUrl)?.pathExtension.lowercased() == "epub" {
Button("本章插图", systemImage: "photo.on.rectangle") {
showsReaderMenu = false
illustrationChapter = model.currentChapter
}
}
Button("书签列表", systemImage: "bookmark") {
showsReaderMenu = false
showsBookmarks = true
@@ -1125,7 +1129,7 @@ struct ReaderView: View {
Image(systemName: "textformat.size.smaller")
.frame(width: 38, height: 32)
}
.buttonStyle(.bordered)
.readingSecondaryButtonStyle()
VStack(spacing: 2) {
Text("字号")
@@ -1142,7 +1146,7 @@ struct ReaderView: View {
Image(systemName: "textformat.size.larger")
.frame(width: 38, height: 32)
}
.buttonStyle(.bordered)
.readingSecondaryButtonStyle()
}
HStack(spacing: 8) {
@@ -1160,11 +1164,13 @@ struct ReaderView: View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(ReaderStylePreset.legadoDefaults) { preset in
let isSelected = resolvedStyle.id == preset.id && activeBackgroundImage == nil
Button {
useBackgroundImage = false
theme = preset.id
} label: {
VStack(spacing: 6) {
RoundedRectangle(cornerRadius: 7)
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color(uiColor: preset.background))
.frame(width: 54, height: 72)
.overlay {
@@ -1173,31 +1179,46 @@ struct ReaderView: View {
.foregroundStyle(Color(uiColor: preset.foreground))
}
.overlay {
RoundedRectangle(cornerRadius: 7)
.stroke(resolvedStyle.id == preset.id ? ReadingTheme.accent : Color.secondary, lineWidth: resolvedStyle.id == preset.id ? 3 : 1)
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(
Color(uiColor: preset.foreground).opacity(isSelected ? 0.4 : 0.12),
lineWidth: isSelected ? 1.25 : 0.75
)
}
Text(preset.name).font(.caption2).foregroundStyle(.primary)
.overlay(alignment: .topTrailing) {
if isSelected {
Image(systemName: "checkmark")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(Color(uiColor: preset.foreground))
.padding(6)
}
}
Text(preset.name)
.font(.caption2)
.foregroundStyle(isSelected ? .primary : .secondary)
}
}
.buttonStyle(.plain)
.accessibilityLabel(preset.name)
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
}
}
ReadingPhotoPicker { data in
Button("选择背景图片", systemImage: "photo") {
backgroundError = nil
do {
backgroundImage = try ReaderBackgroundStore.save(data)
useBackgroundImage = true
} catch {
backgroundError = error.localizedDescription
}
} onError: { error in
backgroundError = error.localizedDescription
} label: {
Label("选择背景图片", systemImage: "photo")
showsBackgroundPicker = true
}
if backgroundImage != nil {
Toggle("使用背景图片", isOn: $useBackgroundImage)
HStack(spacing: 0) {
Text("使用背景图片")
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture { useBackgroundImage.toggle() }
.accessibilityHidden(true)
Toggle("使用背景图片", isOn: $useBackgroundImage)
.labelsHidden()
.fixedSize()
}
Button("删除背景图片", systemImage: "trash", role: .destructive) {
ReaderBackgroundStore.remove()
backgroundImage = nil
@@ -1219,8 +1240,15 @@ struct ReaderView: View {
Text("粗体").tag(1)
Text("细体").tag(2)
}
TextField("段落缩进字符", text: $paragraphIndent)
.textInputAutocapitalization(.never)
VStack(alignment: .leading, spacing: 6) {
Text("段落缩进")
TextField("请输入缩进字符", text: $paragraphIndent)
.textInputAutocapitalization(.never)
.accessibilityLabel("段落缩进字符")
Text("控制每段开头的缩进,默认两个全角空格;清空后不缩进。")
.font(.caption)
.foregroundStyle(.secondary)
}
Button(fontPath.isEmpty ? "选择字体文件" : "更换字体文件", systemImage: "textformat") {
showsFontImporter = true
}
@@ -1268,14 +1296,19 @@ struct ReaderView: View {
Text(choice.title)
.font(.caption2)
}
.foregroundStyle(pageAnimation == choice.value ? Color.white : Color.primary)
.foregroundStyle(pageAnimation == choice.value ? ReadingTheme.onAccent : Color.primary)
.frame(width: 58, height: 54)
.background(
pageAnimation == choice.value ? ReadingTheme.accent : Color.secondary.opacity(0.1),
pageAnimation == choice.value ? ReadingTheme.accentFill : Color.secondary.opacity(0.1),
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(pageAnimation == choice.value ? ReadingTheme.selectionBorder : .clear, lineWidth: 1)
}
}
.buttonStyle(.plain)
.accessibilityAddTraits(pageAnimation == choice.value ? .isSelected : [])
}
}
}
@@ -1335,6 +1368,30 @@ struct ReaderView: View {
}
}
}
.readingPhotoPicker(isPresented: $showsBackgroundPicker) { data in
backgroundError = nil
do {
backgroundImage = try ReaderBackgroundStore.save(data)
useBackgroundImage = true
} catch {
backgroundError = error.localizedDescription
}
} onError: { error in
backgroundError = error.localizedDescription
}
.fileImporter(
isPresented: $showsFontImporter,
allowedContentTypes: [.font, .data],
allowsMultipleSelection: false
) { result in
fontError = nil
do {
guard let source = try result.get().first else { return }
fontPath = try ReaderFontStore.install(from: source)
} catch {
fontError = error.localizedDescription
}
}
.readingPresentationDetents([.medium])
.readingPresentationDragIndicator()
}
@@ -1351,12 +1408,16 @@ struct ReaderView: View {
Button(action: action) {
Text(title)
.font(.caption.weight(.medium))
.foregroundStyle(selected ? Color.white : Color.primary)
.foregroundStyle(selected ? ReadingTheme.onAccent : Color.primary)
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(selected ? ReadingTheme.accent : Color.secondary.opacity(0.1), in: Capsule())
.background(selected ? ReadingTheme.accentFill : Color.secondary.opacity(0.1), in: Capsule())
.overlay {
Capsule().strokeBorder(selected ? ReadingTheme.selectionBorder : .clear, lineWidth: 1)
}
}
.buttonStyle(.plain)
.accessibilityAddTraits(selected ? .isSelected : [])
}
private func valueSlider(
@@ -1525,7 +1586,7 @@ struct ReaderView: View {
Text("正文")
} footer: {
if !model.canEditContent {
Text("本地 TXT 直接来自原文件;为避免破坏章节偏移,本章编辑与网络刷新仅对网络书籍开放。")
Text("本地 TXT / EPUB 直接读取原文件。本章编辑与网络刷新仅对网络书籍开放。")
}
}
@@ -1866,18 +1927,26 @@ struct ReaderView: View {
}
}
private var bookmarkSaved: Bool {
let page = currentPage - 1
let range = pageAnimation != 3 && readerPageRanges.indices.contains(page) ? readerPageRanges[page] : nil
return !model.bookmarks(at: readerPosition, in: activity.bookmarks, pageRange: range).isEmpty
}
private func addBookmark() {
guard let chapter = model.currentChapter else { return }
bookmarkSaved = true
guard !isSavingBookmark, !bookmarkSaved, let chapter = model.currentChapter else { return }
let currentBook = book
let position = readerPosition
let content = model.content
isSavingBookmark = true
Task {
defer { isSavingBookmark = false }
await activity.addBookmark(
book: book,
book: currentBook,
chapter: chapter,
position: readerPosition,
content: model.content
position: position,
content: content
)
try? await Task.sleep(nanoseconds: 1_200_000_000)
bookmarkSaved = false
}
}
+12
View File
@@ -59,6 +59,18 @@ final class ReaderViewModel: ObservableObject {
private var sourceCacheNeedsInvalidation = false
private var pendingSourceContent: ChapterContentReport?
func bookmarks(at position: Int, in bookmarks: [Bookmark], pageRange: NSRange? = nil) -> [Bookmark] {
bookmarks.filter { bookmark in
guard bookmark.bookName == book.name, bookmark.bookAuthor == book.author,
bookmark.resolvedChapterIndex(in: chapters) == currentIndex else { return false }
if bookmark.chapterPosition == max(position, 0) { return true }
if let pageRange, pageRange.length > 0 {
return NSLocationInRange(bookmark.chapterPosition, pageRange)
}
return false
}
}
func prepareContinuousReading(rules: [ReplaceRule]) async {
guard hasLoadedCurrentChapter else { return }
if scrollChapters.isEmpty {
@@ -39,7 +39,7 @@ struct QRCodeImportView: View {
} label: {
Label("从照片选择二维码", systemImage: "photo")
.frame(maxWidth: .infinity)
}.buttonStyle(.borderedProminent).disabled(isReading)
}.readingProminentButtonStyle().disabled(isReading)
Text("支持 HTTP(S) 链接、Legado 导入深链和 JSON 内容。识别后进入导入流程。")
.font(.footnote).foregroundStyle(.secondary)
if values.count > 1 {
@@ -3,6 +3,26 @@ import UIKit
struct SyntaxCodeEditor: UIViewRepresentable {
enum Language { case json, rule, javascript, regex }
// Syntax colors are text, so they need more contrast than system icon tints.
private enum Palette {
static let keyword = adaptive(0x7A2BA6, dark: 0xD5A5F5)
static let number = adaptive(0x895900, dark: 0xE6B66A)
static let operation = adaptive(0xAD285F, dark: 0xF49AB8)
static let punctuation = adaptive(0x14666E, dark: 0x83CED8)
static let string = adaptive(0x2D6B39, dark: 0x8CCF96)
static let key = adaptive(0x1E5EB2, dark: 0x93BFFF)
static let comment = adaptive(0x64646B, dark: 0xA4A4AE)
private static func adaptive(_ light: UInt32, dark: UInt32) -> UIColor {
UIColor { traits in
let hex = traits.userInterfaceStyle == .dark ? dark : light
return UIColor(red: CGFloat((hex >> 16) & 0xff) / 255,
green: CGFloat((hex >> 8) & 0xff) / 255,
blue: CGFloat(hex & 0xff) / 255, alpha: 1)
}
}
}
@Binding var text: String
var language: Language = .rule
@@ -13,6 +33,7 @@ struct SyntaxCodeEditor: UIViewRepresentable {
view.delegate = context.coordinator
view.font = .monospacedSystemFont(ofSize: 14, weight: .regular)
view.backgroundColor = .secondarySystemGroupedBackground
view.tintColor = UIColor(ReadingTheme.accent)
view.textContainerInset = UIEdgeInsets(top: 14, left: 12, bottom: 20, right: 12)
view.autocorrectionType = .no
view.autocapitalizationType = .none
@@ -24,6 +45,7 @@ struct SyntaxCodeEditor: UIViewRepresentable {
view.accessibilityIdentifier = "syntaxCodeEditor"
if #available(iOS 16.0, *) { view.isFindInteractionEnabled = true }
let bar = UIToolbar()
bar.tintColor = UIColor(ReadingTheme.accent)
bar.sizeToFit()
bar.items = [
UIBarButtonItem(image: UIImage(systemName: "magnifyingglass"), primaryAction: UIAction(title: "", subtitle: nil) { [weak view, coordinator = context.coordinator] _ in
@@ -37,7 +59,12 @@ struct SyntaxCodeEditor: UIViewRepresentable {
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
bar.items?.last?.accessibilityIdentifier = "syntaxCodeEditor.done"
if #available(iOS 26.0, *) {
view.inputAccessoryView = CodeInputAccessory(items: bar.items ?? [])
} else {
view.inputAccessoryView = bar
}
view.text = text
context.coordinator.highlight(view)
return view
@@ -126,12 +153,12 @@ struct SyntaxCodeEditor: UIViewRepresentable {
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),
(#"\b(?:true|false|null|undefined|return|var|let|const|if|else|function|new|for|while|try|catch|throw|await|async)\b"#, Palette.keyword),
(#"\b\d+(?:\.\d+)?\b"#, Palette.number),
(#"@(?:css|xpath|json|js|put|get):?|\$\.|&&|\|\||%%|##|\{\{|\}\}"#, Palette.operation),
(#"[{}\[\]()]"#, Palette.punctuation),
(#""(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`"#, Palette.string),
(#""(?:\\.|[^"\\])*"(?=\s*:)"#, Palette.key),
]
for (pattern, color) in tokens {
guard let regex = try? NSRegularExpression(pattern: pattern) else { continue }
@@ -143,7 +170,7 @@ struct SyntaxCodeEditor: UIViewRepresentable {
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 let result { storage.addAttribute(.foregroundColor, value: Palette.operation, range: result.range) }
}
}
}
@@ -151,7 +178,7 @@ struct SyntaxCodeEditor: UIViewRepresentable {
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) }
if let result { storage.addAttribute(.foregroundColor, value: Palette.comment, range: result.range) }
}
}
}
@@ -162,6 +189,66 @@ struct SyntaxCodeEditor: UIViewRepresentable {
}
}
// Keep Done visible while allowing the editing shortcuts to scroll on narrow keyboards.
private final class CodeInputAccessory: UIInputView {
init(items: [UIBarButtonItem]) {
super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 44), inputViewStyle: .keyboard)
autoresizingMask = [.flexibleWidth]
backgroundColor = .secondarySystemGroupedBackground
let scroll = UIScrollView()
scroll.showsHorizontalScrollIndicator = false
scroll.delaysContentTouches = false
scroll.translatesAutoresizingMaskIntoConstraints = false
let stack = UIStackView()
stack.axis = .horizontal
stack.spacing = 4
stack.translatesAutoresizingMaskIntoConstraints = false
let actions = items.filter { $0.primaryAction != nil }
var done: UIButton?
for (index, item) in actions.enumerated() {
let button = UIButton(type: .system)
if let action = item.primaryAction { button.addAction(action, for: .touchUpInside) }
button.setImage(item.image, for: .normal)
button.setTitle(item.title, for: .normal)
button.tintColor = UIColor(ReadingTheme.accent)
button.titleLabel?.font = .systemFont(ofSize: 16)
button.accessibilityIdentifier = item.accessibilityIdentifier
button.accessibilityLabel = item.accessibilityLabel
button.translatesAutoresizingMaskIntoConstraints = false
if index == actions.count - 1 {
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
done = button
addSubview(button)
} else {
if index < 3 { button.accessibilityLabel = ["查找与替换", "撤销", "重做"][index] }
stack.addArrangedSubview(button)
button.widthAnchor.constraint(equalToConstant: 36).isActive = true
}
}
addSubview(scroll)
scroll.addSubview(stack)
guard let done else { return }
NSLayoutConstraint.activate([
done.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
done.widthAnchor.constraint(equalToConstant: 60),
done.topAnchor.constraint(equalTo: topAnchor),
done.bottomAnchor.constraint(equalTo: bottomAnchor),
scroll.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4),
scroll.trailingAnchor.constraint(equalTo: done.leadingAnchor, constant: -4),
scroll.topAnchor.constraint(equalTo: topAnchor),
scroll.bottomAnchor.constraint(equalTo: bottomAnchor),
stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor),
stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor)
])
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override var intrinsicContentSize: CGSize { CGSize(width: UIView.noIntrinsicMetric, height: 44) }
}
struct RuleCodeFieldView: View {
let title: String
let path: String
@@ -92,9 +92,10 @@ struct ReplaceRuleEditorView: View {
RuleCodeFieldView(title: "匹配内容", path: "pattern", text: $rule.pattern, language: .regex)
} label: { fieldPreview("匹配内容", value: rule.pattern) }
NavigationLink {
RuleCodeFieldView(title: "替换为", path: "replacement", text: $rule.replacement, language: .regex)
RuleCodeFieldView(title: "替换为", path: "replacement", text: $rule.replacement,
language: rule.isRegex && rule.replacement.hasPrefix("@js:") ? .javascript : .regex)
} label: { fieldPreview("替换为(留空即删除)", value: rule.replacement) }
Text("正则替换使用 $1、$2 引用捕获组;普通文本替换按字面匹配。")
Text("正则替换使用 $1、$2 引用捕获组;以 @js: 开头可运行 JavaScript,result 为当前匹配文本,可读取 book 和 chapter。普通文本替换按字面匹配。")
.font(.footnote).foregroundStyle(.secondary)
}
Section("作用范围") {
+2 -1
View File
@@ -64,7 +64,8 @@ final class SearchViewModel: ObservableObject {
pendingSourceIDs = Set(pendingSources.map(\.id))
pagedSources = pendingSources.filter {
let url = $0.searchUrl ?? ""
return url.contains("{{page}}") || url.contains("searchPage") || url.contains("<")
return url.range(of: #"\{\{[^}]*\bpage\b[^}]*\}\}"#, options: .regularExpression) != nil
|| url.contains("searchPage") || url.contains("<")
}
sourceNames = Dictionary(pendingSources.map { ($0.id, $0.bookSourceName) }, uniquingKeysWith: { first, _ in first })
accumulator = .init()
+1
View File
@@ -80,6 +80,7 @@ private struct OpenSourceLicensesView: View {
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")
license("ZIPFoundation", resource: "ZIPFoundation-LICENSE", detail: "MIT License")
}.navigationTitle("开源许可").navigationBarTitleDisplayMode(.inline)
}
+1 -2
View File
@@ -81,8 +81,7 @@ struct SourceDebugView: View {
Label(model.isRunning ? "正在调试…" : "运行完整链路", systemImage: "play.fill")
.font(.headline).frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(ReadingTheme.accent)
.readingProminentButtonStyle()
.disabled(model.isRunning || model.keyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
+3 -4
View File
@@ -44,11 +44,10 @@ struct SourceEditorView: View {
HStack {
ForEach(BookSourceEditorSchema.sections) { section in
Button(section.title) { sectionID = section.id }
.buttonStyle(.bordered)
.tint(sectionID == section.id ? ReadingTheme.accent : .secondary)
.readingChoiceButtonStyle(isSelected: sectionID == section.id)
}
Button("选项") { sectionID = "options" }.buttonStyle(.bordered)
.tint(sectionID == "options" ? ReadingTheme.accent : .secondary)
Button("选项") { sectionID = "options" }
.readingChoiceButtonStyle(isSelected: sectionID == "options")
}.padding(.horizontal)
}.padding(.vertical, 6)
Form {
+36 -8
View File
@@ -4,6 +4,36 @@ import LegadoCore
import Foundation
import SwiftUI
final class SourceImportFile: @unchecked Sendable {
let url: URL
private let hasSecurityScope: Bool
init(url: URL) {
self.url = url
hasSecurityScope = url.startAccessingSecurityScopedResource()
}
deinit {
if hasSecurityScope { url.stopAccessingSecurityScopedResource() }
}
func read() throws -> Data {
var coordinationError: NSError?
var result: Result<Data, Error>?
NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &coordinationError) { readableURL in
result = Result {
guard try readableURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory != true else {
throw BookSourceImportError.invalidDocument("请选择书源 JSON 文件,而不是文件夹。")
}
return try Data(contentsOf: readableURL)
}
}
if let coordinationError { throw coordinationError }
guard let result else { throw CocoaError(.fileReadUnknown) }
return try result.get()
}
}
@MainActor
final class SourceLibraryModel: ObservableObject {
@Published private(set) var sources: [BookSource] = []
@@ -168,16 +198,18 @@ final class SourceLibraryModel: ObservableObject {
}
func previewFiles(_ urls: [URL]) async {
await previewPickedFiles(urls.map { SourceImportFile(url: $0) })
}
func previewPickedFiles(_ files: [SourceImportFile]) 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() } }
for file in files {
let data = try await Task.detached(priority: .userInitiated) { [importer] in
try importer.normalizedData(Data(contentsOf: url))
try importer.normalizedData(file.read())
}.value
var budget = 100
let batch = try await resolveImport(String(decoding: data, as: UTF8.self), depth: 0, visited: [], budget: &budget)
@@ -186,10 +218,6 @@ final class SourceLibraryModel: ObservableObject {
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)
@@ -1,5 +1,6 @@
@preconcurrency import WebKit
import SwiftUI
import UIKit
struct SourceLoginView: View {
let source: BookSource
@@ -74,6 +75,9 @@ private struct SourceLoginWebView: UIViewRepresentable {
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .default()
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.isOpaque = false
webView.backgroundColor = UIColor(ReadingTheme.canvas)
webView.scrollView.backgroundColor = UIColor(ReadingTheme.canvas)
webView.navigationDelegate = context.coordinator
context.coordinator.installExistingCookies(in: webView) { webView.load(request) }
return webView
+121 -24
View File
@@ -5,6 +5,8 @@ import UniformTypeIdentifiers
struct SourceManagementView: View {
@EnvironmentObject private var library: SourceLibraryModel
@State private var showsImporter = false
@State private var pickedImportFiles: [SourceImportFile] = []
@State private var importPickerHasDismissed = true
@State private var showsNewSource = false
@State private var showsQRImporter = false
@State private var showsTextImporter = false
@@ -58,8 +60,8 @@ struct SourceManagementView: View {
} description: {
Text("新建书源,或从文件、链接、JSON、二维码导入。")
} actions: {
Button("导入书源") { showsImporter = true }
.buttonStyle(.borderedProminent)
Button("导入书源", action: presentFileImporter)
.readingProminentButtonStyle()
}
} else {
List {
@@ -142,7 +144,7 @@ struct SourceManagementView: View {
Menu("更多", systemImage: "ellipsis.circle") {
Button("新建书源", systemImage: "plus") { showsNewSource = true }
Button("二维码导入", systemImage: "qrcode.viewfinder") { showsQRImporter = true }
Button("从文件导入", systemImage: "doc.badge.plus") { showsImporter = true }
Button("从文件导入", systemImage: "doc.badge.plus", action: presentFileImporter)
Button("URL 或粘贴 JSON", systemImage: "link.badge.plus") { showsTextImporter = true }
Divider()
NavigationLink {
@@ -206,24 +208,71 @@ struct SourceManagementView: View {
.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)
.sheet(isPresented: $showsImporter, onDismiss: {
importPickerHasDismissed = true
previewPickedFilesWhenReady()
}) {
SourceFileImportPicker { files in
pickedImportFiles = files
showsImporter = false
previewPickedFilesWhenReady()
} onCancel: {
showsImporter = false
}
}
}
private func presentFileImporter() {
pickedImportFiles = []
importPickerHasDismissed = false
showsImporter = true
}
private func previewPickedFilesWhenReady() {
// Import-copy mode can dismiss before returning URLs. Other providers
// return URLs first, so wait for both events instead of assuming order.
guard importPickerHasDismissed, !pickedImportFiles.isEmpty else { return }
let files = pickedImportFiles
pickedImportFiles = []
Task { await library.previewPickedFiles(files) }
}
}
private struct SourceFileImportPicker: UIViewControllerRepresentable {
let onPick: @MainActor ([SourceImportFile]) -> Void
let onCancel: @MainActor () -> Void
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
// Ask the provider to import copies, including JSON files whose provider
// does not support opening documents in place. Multi-select gives Files
// an explicit confirmation action for the selected files.
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.item], asCopy: true)
picker.allowsMultipleSelection = true
picker.shouldShowFileExtensions = true
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ controller: UIDocumentPickerViewController, context: Context) {}
@MainActor
final class Coordinator: NSObject, UIDocumentPickerDelegate {
private let parent: SourceFileImportPicker
init(parent: SourceFileImportPicker) { self.parent = parent }
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
parent.onCancel()
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
// Hold the picker grants before dismissing, until the model has
// finished its coordinated background reads.
parent.onPick(urls.map { SourceImportFile(url: $0) })
}
}
}
private struct SourceTextImportView: View {
@@ -337,16 +386,22 @@ private struct SourceGroupManagerView: View {
}
.disabled(BookSource.parseGroups(newGroup).isEmpty)
}
Text("新分组会分配给当前未分组书源,与 Legado 的分组管理行为一致。")
Text("添加后,所有未分组的书源都会加入该分组。")
.font(.caption).foregroundStyle(.secondary)
}
Section("已有分组") {
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)
NavigationLink {
SourceGroupContentsView(group: group)
} label: {
HStack {
Label(group, systemImage: "folder.fill")
Spacer()
Text("\(library.sources.filter { $0.hasGroup(group) }.count) 个书源")
.foregroundStyle(.secondary)
}
}
Menu {
Button("重命名", systemImage: "pencil") {
renaming = group
@@ -356,8 +411,14 @@ private struct SourceGroupManagerView: View {
Task { await library.deleteGroup(group) }
}
} label: { Image(systemName: "ellipsis.circle") }
.buttonStyle(.borderless)
.accessibilityLabel("\(group)分组操作")
}
}
} header: {
Text("已有分组")
} footer: {
Text("点击分组可查看其中的书源。")
}
}
.navigationTitle("书源分组")
@@ -378,6 +439,42 @@ private struct SourceGroupManagerView: View {
}
}
private struct SourceGroupContentsView: View {
@EnvironmentObject private var library: SourceLibraryModel
let group: String
private var sources: [BookSource] {
library.sources.filter { $0.hasGroup(group) }
}
var body: some View {
List {
Section {
ForEach(sources) { source in
NavigationLink {
SourceDetailView(source: source)
} label: {
SourceRow(source: source) { enabled in
Task { await library.setEnabled(enabled, for: source.id) }
}
}
}
if sources.isEmpty {
Text("该分组暂无书源")
.foregroundStyle(.secondary)
}
} header: {
Text("\(sources.count) 个书源")
}
}
.listStyle(.insetGrouped)
.readingScrollContentBackgroundHidden()
.background(ReadingTheme.groupedCanvas)
.navigationTitle(group)
.navigationBarTitleDisplayMode(.inline)
}
}
private struct SourceBatchGroupView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var library: SourceLibraryModel
+19 -1
View File
@@ -22,6 +22,24 @@
<string>public.data</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key><string>EPUB 电子书</string>
<key>CFBundleTypeRole</key><string>Viewer</string>
<key>LSHandlerRank</key><string>Alternate</string>
<key>LSItemContentTypes</key><array><string>org.idpf.epub-container</string></array>
</dict>
</array>
<key>UTImportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key><string>org.idpf.epub-container</string>
<key>UTTypeDescription</key><string>EPUB 电子书</string>
<key>UTTypeConformsTo</key><array><string>public.data</string></array>
<key>UTTypeTagSpecification</key><dict>
<key>public.filename-extension</key><array><string>epub</string></array>
<key>public.mime-type</key><string>application/epub+zip</string>
</dict>
</dict>
</array>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
@@ -34,7 +52,7 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>0.1.9</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
+10
View File
@@ -52,6 +52,7 @@ struct LegadoApp: App {
await bookLibrary.load()
await replaceRuleLibrary.load()
await readingActivity.load()
await rssLibrary.load()
await ruleSubscriptions.load()
if UserDefaults.standard.bool(forKey: "auto_refresh") {
await bookLibrary.checkForUpdates(sources: sourceLibrary.sources, onlyRead: UserDefaults.standard.bool(forKey: "onlyUpdateRead"))
@@ -60,6 +61,15 @@ struct LegadoApp: App {
}
.onOpenURL { url in
Task {
if url.isFileURL && url.pathExtension.lowercased() == "epub" {
let file = LocalBookImportFile(url: url)
await bookLibrary.load()
await bookLibrary.importPickedLocalFiles([file])
sourceLibrary.notice = .init(kind: bookLibrary.errorMessage == nil ? .success : .error,
title: bookLibrary.errorMessage == nil ? "EPUB 已加入书架" : "导入失败",
detail: bookLibrary.errorMessage ?? url.lastPathComponent)
return
}
await sourceLibrary.load()
if url.scheme?.lowercased() == "legado", url.path.lowercased() == "/replacerule" {
await replaceRuleLibrary.load()
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017-2025 Thomas Zoechling (https://www.peakstep.com)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+34 -34
View File
@@ -50,6 +50,10 @@ struct RootTabView: View {
SearchView(initialKeyword: automationSearchKeyword)
} else if requestedDemoPage == "sources" {
SourceManagementView()
} else if requestedDemoPage == "rss" {
RSSHomeView()
} else if requestedDemoPage == "rss-photo" {
RSSPhotoView(value: "data:image/png;base64,", source: RssSource(sourceUrl: "https://appearance.invalid", sourceName: "图片外观检查"))
} else {
BookshelfView()
}
@@ -99,7 +103,6 @@ struct RootTabView: View {
.tabItem { Label("我的", systemImage: "person.crop.circle") }
.tag(Tab.my)
}
.tint(ReadingTheme.accent)
.readingTabBarBackground()
.preferredColorScheme(themeMode == "2" ? .dark : (themeMode == "1" || themeMode == "3" ? .light : nil))
.transaction { transaction in
@@ -123,6 +126,8 @@ struct RootTabView: View {
dismissButton: .default(Text("好")) { sourceLibrary.notice = nil }
)
}
.tint(ReadingTheme.accent)
.toggleStyle(SwitchToggleStyle(tint: ReadingTheme.toggleTint))
}
private var requestedDemoPage: String? {
@@ -146,7 +151,7 @@ private struct BookshelfView: View {
@EnvironmentObject private var library: BookLibraryModel
@EnvironmentObject private var sources: SourceLibraryModel
@State private var showsLocalImporter = false
@State private var pendingLocalURLs: [URL] = []
@State private var pendingLocalFiles: [LocalBookImportFile] = []
@State private var selectedGroupID = BookGroup.idAll
@State private var showsGroupManager = false
@AppStorage("bookshelfLayout") private var bookshelfLayout = "grid3"
@@ -222,26 +227,17 @@ private struct BookshelfView: View {
}
.background(ReadingTheme.canvas)
.navigationBarHidden(true)
.fileImporter(
isPresented: $showsLocalImporter,
allowedContentTypes: [.text, .plainText, .data],
allowsMultipleSelection: true
) { result in
switch result {
case .success(let urls):
pendingLocalURLs = urls.filter { $0.pathExtension.lowercased() == "txt" }
if pendingLocalURLs.isEmpty, !urls.isEmpty {
library.reportError(LocalBookError.unsupportedFormat)
}
case .failure(let error): library.reportError(error)
}
.readingLocalBookFileImporter(isPresented: $showsLocalImporter) { files in
pendingLocalFiles = files
} onError: { error in
library.reportError(error)
}
.sheet(isPresented: Binding(
get: { !pendingLocalURLs.isEmpty },
set: { if !$0 { pendingLocalURLs = [] } }
get: { !pendingLocalFiles.isEmpty },
set: { if !$0 { pendingLocalFiles = [] } }
)) {
LocalBookImportReviewView(urls: pendingLocalURLs) {
pendingLocalURLs = []
LocalBookImportReviewView(files: pendingLocalFiles) {
pendingLocalFiles = []
}
.environmentObject(library)
}
@@ -453,7 +449,7 @@ private struct BookshelfBookDestination: View {
let source: BookSource?
var body: some View {
if ProcessInfo.processInfo.arguments.contains("-uiDemo") {
if ProcessInfo.processInfo.arguments.contains("-uiDemo"), book.origin != "local" {
ReaderView(demoBook: book, chapters: DemoContent.readerChapters, initialIndex: 0, content: DemoContent.readerText)
} else {
BookshelfReadView(book: book, source: source)
@@ -496,7 +492,7 @@ private struct ContinueReadingCard: View {
.foregroundStyle(ReadingTheme.onAccent)
.padding(.horizontal, 18)
.frame(minHeight: 40)
.background(ReadingTheme.accent, in: Capsule())
.background(ReadingTheme.accentFill, in: Capsule())
.padding(.top, 20)
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -646,15 +642,17 @@ private struct BookGroupSelectView: View {
private struct LocalBookImportReviewView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var library: BookLibraryModel
let urls: [URL]
let files: [LocalBookImportFile]
let onFinish: () -> Void
@State private var selectedPaths: Set<String>
@State private var isImporting = false
init(urls: [URL], onFinish: @escaping () -> Void) {
self.urls = urls
private var urls: [URL] { files.map(\.url) }
init(files: [LocalBookImportFile], onFinish: @escaping () -> Void) {
self.files = files
self.onFinish = onFinish
_selectedPaths = State(initialValue: Set(urls.map(\.path)))
_selectedPaths = State(initialValue: Set(files.map { $0.url.path }))
}
var body: some View {
@@ -690,9 +688,9 @@ private struct LocalBookImportReviewView: View {
ToolbarItem(placement: .confirmationAction) {
Button("导入") {
isImporting = true
let selected = urls.filter { selectedPaths.contains($0.path) }
let selected = files.filter { selectedPaths.contains($0.url.path) }
Task {
await library.importLocalFiles(selected)
await library.importPickedLocalFiles(selected)
finish()
}
}
@@ -726,7 +724,7 @@ private struct EmptyBookshelfHero: View {
}
VStack(spacing: 12) {
Button("导入本地书", action: onImport)
.buttonStyle(.borderedProminent)
.readingProminentButtonStyle()
.controlSize(.large)
NavigationLink("添加书源") { SourceManagementView() }
.font(.subheadline)
@@ -914,6 +912,8 @@ struct SearchView: View {
} label: {
Label("搜索选项", systemImage: "slider.horizontal.3")
}
.accessibilityLabel("搜索选项")
.accessibilityIdentifier("search.options")
}
}
.sheet(isPresented: $showsScope) {
@@ -993,7 +993,7 @@ struct SearchView: View {
Spacer()
if model.isPaused {
Button("继续", systemImage: "play.fill") { model.resume() }
.buttonStyle(.bordered)
.readingSecondaryButtonStyle()
.controlSize(.small)
} else if model.state == .searching {
StatusPill(text: "\(model.completedSourceCount)/\(model.totalSourceCount)", systemImage: "magnifyingglass")
@@ -1044,10 +1044,10 @@ struct SearchView: View {
) {
if model.isPaused {
Button("继续搜索", systemImage: "play.fill") { model.resume() }
.buttonStyle(.borderedProminent).tint(ReadingTheme.accent)
.readingProminentButtonStyle()
}
Button("修改关键词", systemImage: "text.cursor") { searchIsPresented = true }
.buttonStyle(.bordered)
.readingSecondaryButtonStyle()
}
}
@@ -1058,13 +1058,13 @@ struct SearchView: View {
systemImage: "network.slash"
) {
Button("调整范围", systemImage: "scope") { showsScope = true }
.buttonStyle(.borderedProminent).tint(ReadingTheme.accent)
.readingProminentButtonStyle()
NavigationLink {
SourceManagementView()
} label: {
Label("管理书源", systemImage: "network")
}
.buttonStyle(.bordered)
.readingSecondaryButtonStyle()
}
}
@@ -1288,7 +1288,7 @@ private struct SettingsView: View {
.clipped()
.overlay {
if colorScheme == .dark {
Color.black.opacity(0.62)
Color.black.opacity(0.78)
}
}
.overlay(alignment: .bottom) {
+26
View File
@@ -1,5 +1,31 @@
# 更新记录
## 0.1.12(2026-10-24)
- 增加无 DRM EPUB 2/3 导入和阅读,识别元数据、封面、NCX/nav 目录及同页章节锚点,并按书脊顺序保留未列入目录的正文。
- EPUB 沿用阅读进度、书签、朗读和阅读样式;正文中的插图可从「更多 → 本章插图」查看。
- 本地文件管理支持 EPUB 重命名、原格式导出、删除及从系统文件应用打开。
- 改善 JSON 规则模板、候选选择、组合替换和请求参数兼容,修复 JavaScript 数字类型与动态净化替换。
- 修复 RSS 翻页失败后的重试、切换订阅时的过期结果和重复翻页请求。
- 改善连续滚动中的章节插入定位,调整 iOS 26 代码编辑工具栏。
- 应用版本为 `0.1.12`,构建号为 `18`。
## 0.1.11(2026-10-22)
- 修复书架和文件管理中的本地 TXT 选择、导入确认与文件访问权限,支持大小写扩展名和多选,并排除 JSON 书源。
- 完善深色模式下的按钮、阅读菜单、统计卡片和编辑器语法高亮,改善文字与选中态对比度。
- 适配 RSS 正文、登录网页和图片查看页外观,保留书源自定义样式。
- 应用版本为 `0.1.11`,构建号为 `17`。
## 0.1.10(2026-10-20)
- 修复书源文件选择及导入流程,支持 JSON 多选,调整文件访问权限和导入预览展示时机。
- 支持进入书源分组查看书源详情及切换启用状态。
- 修复阅读背景图片与字体选择,限制背景图片解码尺寸,完善图片与预设主题切换。
- 修复重新进入阅读器后的书签状态,避免重复添加。
- 应用版本为 `0.1.10`,构建号为 `16`。
## 0.1.9(2026-10-14)
- 个人中心采用阅读静物头图、暖白圆角卡片和黑金双色图标,适配深色模式。
+38 -6
View File
@@ -6,12 +6,15 @@
objects = {
/* Begin PBXBuildFile section */
DA1000000000000000000002 /* AppearanceContrastTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2000000000000000000002; };
DA1000000000000000000001 /* DarkModeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2000000000000000000001; };
B10000000000000000000050 = {isa = PBXBuildFile; fileRef = B20000000000000000000050; };
B10000000000000000000030 /* ReaderScrollGeometryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000030; };
BC1000000000000000000001 /* WebReaderBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC2000000000000000000001; };
B10000000000000000000029 /* ReaderScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000029 /* ReaderScrollView.swift */; };
F10000000000000000000001 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = F20000000000000000000001 /* SwiftSoup */; };
F10000000000000000000002 /* Kanna in Frameworks */ = {isa = PBXBuildFile; productRef = F20000000000000000000002 /* Kanna */; };
F10000000000000000000003 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = F20000000000000000000003 /* ZIPFoundation */; };
A10000000000000000000001 /* LegadoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000001 /* LegadoApp.swift */; };
A10000000000000000000002 /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000002 /* RootTabView.swift */; };
A10000000000000000000003 /* SourceLibraryModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000003 /* SourceLibraryModel.swift */; };
@@ -121,9 +124,14 @@
AA1100000000000000000008 = {isa = PBXBuildFile; fileRef = AA2200000000000000000008; };
AA1100000000000000000009 = {isa = PBXBuildFile; fileRef = AA2200000000000000000009; };
AA1100000000000000000010 = {isa = PBXBuildFile; fileRef = AA2200000000000000000010; };
EB1000000000000000000001 /* EPUBBook.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB2000000000000000000001; };
EB1000000000000000000002 /* ZIPFoundation-LICENSE.txt in Resources */ = {isa = PBXBuildFile; fileRef = EB2000000000000000000002; };
EB1000000000000000000003 /* EPUBIllustrationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB2000000000000000000003; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
DA2000000000000000000002 /* AppearanceContrastTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests/LegadoReaderTests/AppearanceContrastTests.swift; sourceTree = SOURCE_ROOT; };
DA2000000000000000000001 /* DarkModeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests/LegadoReaderUITests/DarkModeUITests.swift; sourceTree = SOURCE_ROOT; };
B20000000000000000000050 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests/LegadoReaderUITests/ReaderScrollGestureTests.swift; sourceTree = SOURCE_ROOT; };
B20000000000000000000051 = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; path = LegadoReaderUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
B20000000000000000000030 /* ReaderScrollGeometryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests/LegadoReaderTests/ReaderScrollGeometryTests.swift; sourceTree = SOURCE_ROOT; };
@@ -241,6 +249,9 @@
AA2200000000000000000008 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/LegadoCore/Import/QRCodeDecoder.swift; sourceTree = SOURCE_ROOT; };
AA2200000000000000000009 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests/LegadoReaderUITests/RuleEditorUITests.swift; sourceTree = SOURCE_ROOT; };
AA2200000000000000000010 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests/LegadoReaderUITests/LiveReadingFlowUITests.swift; sourceTree = SOURCE_ROOT; };
EB2000000000000000000001 /* EPUBBook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/LegadoCore/LocalBook/EPUBBook.swift; sourceTree = SOURCE_ROOT; };
EB2000000000000000000002 /* ZIPFoundation-LICENSE.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = App/Resources/ZIPFoundation-LICENSE.txt; sourceTree = SOURCE_ROOT; };
EB2000000000000000000003 /* EPUBIllustrationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/Features/Reader/EPUBIllustrationsView.swift; sourceTree = SOURCE_ROOT; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -252,6 +263,7 @@
files = (
F10000000000000000000001 /* SwiftSoup in Frameworks */,
F10000000000000000000002 /* Kanna in Frameworks */,
F10000000000000000000003 /* ZIPFoundation in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -272,6 +284,8 @@
AA2200000000000000000010,
B20000000000000000000030 /* ReaderScrollGeometryTests.swift */,
BC2000000000000000000001 /* WebReaderBridgeTests.swift */,
DA2000000000000000000001 /* DarkModeUITests.swift */,
DA2000000000000000000002 /* AppearanceContrastTests.swift */,
A30000000000000000000001 /* App */,
C30000000000000000000001 /* LegadoCore */,
A20000000000000000000024 /* txtTocRule.json */,
@@ -347,6 +361,7 @@
A20000000000000000000029 /* ReaderSpeechController.swift */,
A20000000000000000000028 /* ReaderAppearance.swift */,
A2000000000000000000002B /* ReaderSupport.swift */,
EB2000000000000000000003 /* EPUBIllustrationsView.swift */,
);
path = Features/Reader;
sourceTree = "<group>";
@@ -521,6 +536,7 @@
children = (
F21000000000000000000002 /* LocalFileService.swift */,
C20000000000000000000018 /* LocalBookService.swift */,
EB2000000000000000000001 /* EPUBBook.swift */,
);
path = LocalBook;
sourceTree = "<group>";
@@ -583,6 +599,7 @@
E20000000000000000000008 /* Legado-LICENSE.txt */,
E20000000000000000000009 /* SwiftSoup-LICENSE.txt */,
E2000000000000000000000A /* Kanna-LICENSE.txt */,
EB2000000000000000000002 /* ZIPFoundation-LICENSE.txt */,
);
path = Resources;
sourceTree = "<group>";
@@ -616,6 +633,7 @@
packageProductDependencies = (
F20000000000000000000001 /* SwiftSoup */,
F20000000000000000000002 /* Kanna */,
F20000000000000000000003 /* ZIPFoundation */,
);
productName = Legado;
productReference = D20000000000000000000001 /* Legado.app */;
@@ -653,6 +671,7 @@
packageReferences = (
F30000000000000000000001 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
F30000000000000000000002 /* XCRemoteSwiftPackageReference "Kanna" */,
F30000000000000000000003 /* XCRemoteSwiftPackageReference "ZIPFoundation" */,
);
productRefGroup = D40000000000000000000002 /* Products */;
projectDirPath = "";
@@ -671,6 +690,7 @@
910000000000000000000008 /* Legado-LICENSE.txt in Resources */,
A10000000000000000000020 /* Assets.xcassets in Resources */,
A1000000000000000000001D /* txtTocRule.json in Resources */,
EB1000000000000000000002 /* ZIPFoundation-LICENSE.txt in Resources */,
A1000000000000000000001E /* httpTTS.json in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -678,8 +698,8 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
B30000000000000000000050 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B10000000000000000000050, AA1100000000000000000009, AA1100000000000000000010); runOnlyForDeploymentPostprocessing = 0; };
B30000000000000000000030 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B10000000000000000000030, BC1000000000000000000001); runOnlyForDeploymentPostprocessing = 0; };
B30000000000000000000050 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B10000000000000000000050, AA1100000000000000000009, AA1100000000000000000010, DA1000000000000000000001); runOnlyForDeploymentPostprocessing = 0; };
B30000000000000000000030 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B10000000000000000000030, BC1000000000000000000001, DA1000000000000000000002); runOnlyForDeploymentPostprocessing = 0; };
D30000000000000000000002 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -764,6 +784,8 @@
C10000000000000000000016 /* BookExploreService.swift in Sources */,
C10000000000000000000017 /* BookSourceDebugger.swift in Sources */,
C10000000000000000000018 /* LocalBookService.swift in Sources */,
EB1000000000000000000003 /* EPUBIllustrationsView.swift in Sources */,
EB1000000000000000000001 /* EPUBBook.swift in Sources */,
C10000000000000000000019 /* ContentSanitizer.swift in Sources */,
C1000000000000000000001A /* ReplaceRuleRepository.swift in Sources */,
C1000000000000000000001B /* ReadingActivityRepository.swift in Sources */,
@@ -858,12 +880,12 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 13;
CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_ASSET_PATHS = "";
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 0.1.9;
MARKETING_VERSION = 0.1.12;
OTHER_LDFLAGS = "$(inherited) -lsqlite3";
PRODUCT_BUNDLE_IDENTIFIER = com.epiboly.legado;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -879,11 +901,11 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 13;
CURRENT_PROJECT_VERSION = 18;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 0.1.9;
MARKETING_VERSION = 0.1.12;
OTHER_LDFLAGS = "$(inherited) -lsqlite3";
PRODUCT_BUNDLE_IDENTIFIER = com.epiboly.legado;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -924,6 +946,11 @@
repositoryURL = "https://github.com/tid-kijyun/Kanna.git";
requirement = { kind = exactVersion; version = 6.1.0; };
};
F30000000000000000000003 /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/weichsel/ZIPFoundation.git";
requirement = { kind = exactVersion; version = 0.9.20; };
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@@ -937,6 +964,11 @@
package = F30000000000000000000002 /* XCRemoteSwiftPackageReference "Kanna" */;
productName = Kanna;
};
F20000000000000000000003 /* ZIPFoundation */ = {
isa = XCSwiftPackageProductDependency;
package = F30000000000000000000003 /* XCRemoteSwiftPackageReference "ZIPFoundation" */;
productName = ZIPFoundation;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = D70000000000000000000001 /* Project object */;
@@ -1,5 +1,5 @@
{
"originHash" : "5cf2eb5dea5b10523b3afc86a164d9a3315a7269a1aefdfe8c91f0530c3f3522",
"originHash" : "a20ba014518540ad01fe4db62ea950304db8c0b8eb880b62b87a5f1d19569a3c",
"pins" : [
{
"identity" : "kanna",
@@ -18,6 +18,15 @@
"revision" : "49dcadd93161f4a44b4994d3a3e8de9f085aface",
"version" : "2.13.5"
}
},
{
"identity" : "zipfoundation",
"kind" : "remoteSourceControl",
"location" : "https://github.com/weichsel/ZIPFoundation.git",
"state" : {
"revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d",
"version" : "0.9.20"
}
}
],
"version" : 3
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme LastUpgradeVersion="2610" version="1.7">
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES"><BuildActionEntries><BuildActionEntry buildForTesting="YES" buildForRunning="NO" buildForProfiling="NO" buildForArchiving="NO" buildForAnalyzing="YES"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="B50000000000000000000050" BuildableName="LegadoReaderUITests.xctest" BlueprintName="LegadoReaderUITests" ReferencedContainer="container:Legado.xcodeproj"/></BuildActionEntry></BuildActionEntries></BuildAction>
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="NO">
<Testables><TestableReference skipped="NO"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="B50000000000000000000050" BuildableName="LegadoReaderUITests.xctest" BlueprintName="LegadoReaderUITests" ReferencedContainer="container:Legado.xcodeproj"/><SelectedTests><Test Identifier="RuleEditorUITests/testRSSSearchSubmissionPaginationAndFavorites()"/></SelectedTests></TestableReference></Testables>
<EnvironmentVariables><EnvironmentVariable key="LEGADO_RSS_UI_FIXTURE_URL" value="http://127.0.0.1:18763/source.json" isEnabled="YES"/></EnvironmentVariables>
</TestAction>
</Scheme>
+10 -1
View File
@@ -1,5 +1,5 @@
{
"originHash" : "7f0bb8624f715c1c036934addf347a13f5c877af397c008dd678bb519c74d937",
"originHash" : "9d25b5f7ff3ac25767fea9b5152f5607bdb185299d3ebda18cd9b9255a772731",
"pins" : [
{
"identity" : "kanna",
@@ -18,6 +18,15 @@
"revision" : "49dcadd93161f4a44b4994d3a3e8de9f085aface",
"version" : "2.13.5"
}
},
{
"identity" : "zipfoundation",
"kind" : "remoteSourceControl",
"location" : "https://github.com/weichsel/ZIPFoundation.git",
"state" : {
"revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d",
"version" : "0.9.20"
}
}
],
"version" : 3
+7 -5
View File
@@ -12,14 +12,16 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/scinfu/SwiftSoup.git", exact: "2.13.5"),
.package(url: "https://github.com/tid-kijyun/Kanna.git", exact: "6.1.0")
.package(url: "https://github.com/tid-kijyun/Kanna.git", exact: "6.1.0"),
.package(url: "https://github.com/weichsel/ZIPFoundation.git", exact: "0.9.20")
],
targets: [
.target(
name: "LegadoCore",
dependencies: [
.product(name: "SwiftSoup", package: "SwiftSoup"),
.product(name: "Kanna", package: "Kanna")
.product(name: "Kanna", package: "Kanna"),
.product(name: "ZIPFoundation", package: "ZIPFoundation")
],
linkerSettings: [
.linkedFramework("JavaScriptCore"),
@@ -29,7 +31,7 @@ let package = Package(
),
.testTarget(
name: "LegadoCoreTests",
dependencies: ["LegadoCore"],
dependencies: ["LegadoCore", .product(name: "ZIPFoundation", package: "ZIPFoundation")],
resources: [.copy("Fixtures")]
),
.target(
@@ -42,7 +44,7 @@ let package = Package(
"Rules/ReplaceRuleManagementView.swift", "Rules/ReplaceRuleEditorView.swift", "Rules/RuleSubscriptionView.swift", "Rules/TxtTocRuleManagementView.swift", "Rules/Editor",
"Books/BookDetailView.swift", "Books/LocalBookDetailView.swift", "Books/SourceSwitchView.swift",
"Reader/ReaderAppearance.swift", "Reader/ReaderSpeechController.swift", "Reader/ReaderSupport.swift",
"Reader/ReaderTextView.swift", "Reader/ReaderScrollView.swift", "Reader/ReaderView.swift"
"Reader/EPUBIllustrationsView.swift", "Reader/ReaderTextView.swift", "Reader/ReaderScrollView.swift", "Reader/ReaderView.swift"
],
sources: [
"Sources/SourceLibraryModel.swift", "Rules/ReplaceRuleLibraryModel.swift", "Search/SearchViewModel.swift",
@@ -52,7 +54,7 @@ let package = Package(
),
.testTarget(
name: "LegadoAppModelsTests",
dependencies: ["LegadoAppModels", "LegadoCore"]
dependencies: ["LegadoAppModels", "LegadoCore", .product(name: "ZIPFoundation", package: "ZIPFoundation")]
)
]
)
+10 -3
View File
@@ -20,7 +20,7 @@
项目仍在边用边改,书源兼容和阅读体验会继续完善。
**小浣熊阅读不提供书源或在线书库。** 书源需由用户自行从网络公开分享获取,或自行编写后导入;也可以直接阅读本地 TXT。
**小浣熊阅读不提供书源或在线书库。** 书源需由用户自行从网络公开分享获取,或自行编写后导入;也可以直接阅读本地 TXT / EPUB。
## 下载安装
@@ -42,7 +42,7 @@
| 快速搜书 | 多书源并发搜索,结果陆续展示;重复搜索可先显示有效缓存,同书多源合并 |
| 书架管理 | 列表、网格、分组、章节更新、换源和本机阅读进度 |
| 阅读排版 | 字号、间距、边距、日夜主题、滚动与分页阅读、目录和书签 |
| 本地 TXT | 导入本地文件、自定义目录规则和章节阅读 |
| 本地 TXT / EPUB | TXT 自定义目录规则;EPUB 2/3 目录、封面、分章阅读与插图查看 |
| 语音朗读 | 系统朗读与 HTTP TTS 配置 |
| 阅读工具 | 替换净化、阅读记录、时长统计和缓存管理 |
| RSS 订阅 | 订阅源管理与内容阅读 |
@@ -85,9 +85,12 @@
1. 自行准备你有权使用的阅读书源,安装后进入「我的 → 书源管理」导入。
2. 在书架点击搜索,输入书名或作者,打开书籍详情后开始阅读。
3. 将书籍加入书架,方便下次继续阅读;也可以从书架直接导入本地 TXT。
3. 将书籍加入书架,方便下次继续阅读;也可以从书架直接导入本地 TXT 或 EPUB。
4. 在阅读页调整排版、主题、书签和朗读设置。
EPUB 导入入口:书架 → 更多 → 本地书,或「我的 → 文件管理 → 导入 TXT / EPUB」。支持无 DRM 的 EPUB 2/3,识别书名、作者、封面、NCX/nav 目录和章节锚点。正文使用现有阅读器重新排版,支持阅读进度、书签、朗读和样式设置;插图从阅读菜单「更多 → 本章插图」查看。暂不还原原书 CSS、固定版式和交互内容。
应用支持 `legado://` 规则导入链接。来源网站改版或停止服务时,书源规则可能需要更新。
## 书源说明
@@ -119,3 +122,7 @@
第三方材料的版权与许可见 [第三方声明](THIRD_PARTY_NOTICES.md)。
请遵守书源网站的使用规则和内容授权,支持正版阅读。
## 🔗 友情链接
[LINUX DO](https://linux.do/) —— 真诚分享、友好讨论的技术社区,本项目的交流与反馈也发布于此
+10 -4
View File
@@ -115,9 +115,9 @@ public struct ContentSanitizer: Sendable {
var diagnostics: [RuleDiagnostic] = []
for rule in ordered where rule.scopeTitle && applies(rule, to: book) {
let result = apply(rule, to: title)
let result = apply(rule, to: title, book: book, chapter: chapter)
diagnostics += result.diagnostics
if result.value != title {
if result.value != title, !result.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
title = result.value
effective.append(rule.id)
}
@@ -146,7 +146,7 @@ public struct ContentSanitizer: Sendable {
}
for rule in ordered where rule.scopeContent && applies(rule, to: book) {
let result = apply(rule, to: body)
let result = apply(rule, to: body, book: book, chapter: chapter)
diagnostics += result.diagnostics
if result.value != body {
body = result.value
@@ -172,10 +172,16 @@ public struct ContentSanitizer: Sendable {
return (!book.name.isEmpty && scope.contains(book.name)) || (!book.origin.isEmpty && scope.contains(book.origin))
}
private func apply(_ rule: ReplaceRule, to input: String) -> (value: String, diagnostics: [RuleDiagnostic]) {
private func apply(_ rule: ReplaceRule, to input: String, book: Book, chapter: BookChapter) -> (value: String, diagnostics: [RuleDiagnostic]) {
guard !rule.pattern.isEmpty else { return (input, []) }
do {
if rule.isRegex {
if rule.replacement.hasPrefix("@js:") {
let evaluate = JavaScriptRuleEvaluator().replacementEvaluator(
script: String(rule.replacement.dropFirst(4)), book: book, chapter: chapter)
return (try regex.replacingStrict(pattern: rule.pattern, in: input,
timeoutMilliseconds: rule.timeoutMillisecond, replacement: evaluate), [])
}
return (try regex.replacingStrict(pattern: rule.pattern, in: input, with: rule.replacement, timeoutMilliseconds: rule.timeoutMillisecond), [])
}
return (input.replacingOccurrences(of: rule.pattern, with: rule.replacement), [])
+399
View File
@@ -0,0 +1,399 @@
import Foundation
import ImageIO
import SwiftSoup
import ZIPFoundation
public enum EPUBError: Error, LocalizedError, Sendable {
case invalidArchive, invalidPackage, missingResource(String), encrypted, resourceTooLarge, invalidChapter
public var errorDescription: String? {
switch self {
case .invalidArchive: "EPUB 文件已损坏或不是有效的压缩包"
case .invalidPackage: "EPUB 缺少有效的书籍信息或阅读顺序"
case .missingResource(let path): "EPUB 中缺少文件:\(path)"
case .encrypted: "此 EPUB 的正文或图片已加密,暂不支持读取"
case .resourceTooLarge: "EPUB 单个章节或资源过大,无法安全读取"
case .invalidChapter: "EPUB 章节或目录锚点无效,请重新打开书籍"
}
}
}
public struct EPUBIllustration: Identifiable, Sendable {
public let id: Int
public let caption: String
public let data: Data
}
/// Reads resources on demand without unpacking untrusted archive paths to disk.
/// Navigation follows the spine; EPUB 2 NCX and EPUB 3 nav supply chapter labels/anchors.
struct EPUBBook {
private struct Item {
let id: String
let path: String
let mediaType: String
let properties: Set<String>
}
private struct Reference {
let path: String
let fragment: String?
let title: String
}
private let archive: Archive
private let entries: [String: Entry]
private let package: EPUBXMLNode
private let packagePath: String
private let manifest: [Item]
private let spine: [Item]
private let encryptedPaths: Set<String>
private static let resourceLimit = 32 * 1_024 * 1_024
init(url: URL) throws {
let archive: Archive
do { archive = try Archive(url: url, accessMode: .read) }
catch { throw EPUBError.invalidArchive }
var entries: [String: Entry] = [:]
for entry in archive {
guard entries.count < 50_000 else { throw EPUBError.resourceTooLarge }
// Directories and symlinks are never used as book resources.
guard entry.type == .file else { continue }
guard Self.safePath(entry.path), entries[entry.path] == nil else { throw EPUBError.invalidArchive }
entries[entry.path] = entry
}
self.archive = archive
self.entries = entries
let container = try EPUBXMLNode.parse(Self.read("META-INF/container.xml", archive: archive, entries: entries))
guard let rootfile = container.descendants("rootfile").first,
let fullPath = rootfile.attributes["full-path"],
let path = Self.resolve(fullPath, relativeTo: "")?.path else { throw EPUBError.invalidPackage }
packagePath = path
package = try EPUBXMLNode.parse(Self.read(path, archive: archive, entries: entries))
guard package.name == "package", let manifestNode = package.children.first(where: { $0.name == "manifest" }),
let spineNode = package.children.first(where: { $0.name == "spine" }) else { throw EPUBError.invalidPackage }
manifest = manifestNode.children.compactMap { node in
guard node.name == "item", let id = node.attributes["id"], let href = node.attributes["href"],
let ref = Self.resolve(href, relativeTo: path) else { return nil }
return Item(id: id, path: ref.path, mediaType: node.attributes["media-type"] ?? "",
properties: Set((node.attributes["properties"] ?? "").split(whereSeparator: \.isWhitespace).map(String.init)))
}
let items = manifest
let readable = spineNode.children.filter { $0.name == "itemref" && $0.attributes["linear"] != "no" }
spine = try readable.map { node in
guard let item = items.first(where: { $0.id == node.attributes["idref"] }),
["application/xhtml+xml", "text/html"].contains(item.mediaType), entries[item.path] != nil else {
throw EPUBError.invalidPackage
}
return item
}
guard !spine.isEmpty else { throw EPUBError.invalidPackage }
if entries["META-INF/encryption.xml"] != nil {
let encryption = try EPUBXMLNode.parse(Self.read("META-INF/encryption.xml", archive: archive, entries: entries))
encryptedPaths = Set(encryption.descendants("CipherReference").compactMap {
$0.attributes["URI"].flatMap { Self.resolve($0, relativeTo: "")?.path }
})
} else { encryptedPaths = [] }
// Font obfuscation is common and does not prevent text reading.
guard !spine.contains(where: { encryptedPaths.contains($0.path) }) else { throw EPUBError.encrypted }
}
func report(url: URL, originalName: String) throws -> LocalBookImportReport {
let metadata = package.children.first { $0.name == "metadata" }
let title = metadata?.descendants("title").first?.text.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let authors = metadata?.descendants("creator").map { $0.text.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } ?? []
var book = Book(bookUrl: url.absoluteString, name: title.isEmpty ? (originalName as NSString).deletingPathExtension : title,
author: authors.joined(separator: "、"), origin: "local")
book.originName = originalName
book.intro = MetadataTextSanitizer().clean(metadata?.descendants("description").first?.text)
book.canUpdate = false
var configuration = BookReadConfig()
configuration.useReplaceRule = false
book.readConfig = configuration
let coverID = metadata?.descendants("meta").first { $0.attributes["name"] == "cover" }?.attributes["content"]
if let cover = manifest.first(where: { $0.properties.contains("cover-image") }) ?? manifest.first(where: { $0.id == coverID }),
["image/jpeg", "image/png", "image/gif", "image/webp"].contains(cover.mediaType),
let entry = entries[cover.path], entry.uncompressedSize <= 2 * 1_024 * 1_024,
let data = try? read(cover.path), let thumbnail = Self.coverThumbnail(data) {
// A bounded embedded thumbnail survives file renames, backups and cache eviction.
book.coverUrl = "data:image/png;base64,\(thumbnail.base64EncodedString())"
}
let chapters = try chapters(book: book)
book.totalChapterNum = chapters.count
book.latestChapterTitle = chapters.last?.title
return .init(book: book, chapters: chapters)
}
func chapters(book: Book) throws -> [BookChapter] {
let references = navigation()
var chapters: [BookChapter] = []
for item in spine where !item.properties.contains("nav") {
let document = try document(item.path)
guard let body = document.body() else { continue }
let elements = try body.getAllElements().array()
var positions: [String: Int] = [:]
for (index, element) in elements.enumerated() {
for key in [element.id(), (try? element.attr("name")) ?? ""] where !key.isEmpty && positions[key] == nil {
positions[key] = index
}
}
var seen = Set<String>()
let refs = references.filter { $0.path == item.path }
.filter { ref in
guard ref.fragment == nil || positions[ref.fragment!] != nil else { return false }
return seen.insert(ref.fragment ?? "").inserted
}
.sorted { (positions[$0.fragment ?? ""] ?? -1) < (positions[$1.fragment ?? ""] ?? -1) }
let documentTitle = try document.title().trimmingCharacters(in: .whitespacesAndNewlines)
let heading = try body.select("h1,h2,h3").first()?.text() ?? ""
let fallback = !documentTitle.isEmpty ? documentTitle : (!heading.isEmpty ? heading : "第\(chapters.count + 1)章")
var boundaries = refs
if boundaries.isEmpty {
boundaries = [Reference(path: item.path, fragment: nil, title: fallback)]
} else if let first = boundaries.first, let fragment = first.fragment {
let prefix = try render(body: body, from: nil, to: fragment).text
if !prefix.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
boundaries.insert(Reference(path: item.path, fragment: nil, title: fallback), at: 0)
}
}
for (index, ref) in boundaries.enumerated() {
var chapter = BookChapter(url: Self.referenceURL(path: item.path, fragment: ref.fragment),
title: ref.title.isEmpty ? fallback : ref.title, bookUrl: book.bookUrl, index: chapters.count)
chapter.resourceUrl = item.path
chapter.startFragmentId = ref.fragment
chapter.endFragmentId = boundaries.indices.contains(index + 1) ? boundaries[index + 1].fragment : nil
chapters.append(chapter)
}
}
guard !chapters.isEmpty else { throw EPUBError.invalidPackage }
return chapters
}
func content(chapter: BookChapter) throws -> ChapterContentReport {
let (body, _) = try body(chapter: chapter)
let value = try render(body: body, from: chapter.startFragmentId, to: chapter.endFragmentId)
return .init(title: chapter.title, content: value.text, pagesVisited: 1)
}
func illustrations(chapter: BookChapter) throws -> [EPUBIllustration] {
let (body, path) = try body(chapter: chapter)
let value = try render(body: body, from: chapter.startFragmentId, to: chapter.endFragmentId)
var totalBytes = 0
return try value.images.enumerated().map { index, image in
guard let ref = Self.resolve(image.src, relativeTo: path),
let item = manifest.first(where: { $0.path == ref.path }), item.mediaType.hasPrefix("image/") else {
throw EPUBError.missingResource(image.src)
}
let data = try read(ref.path)
totalBytes += data.count
guard totalBytes <= Self.resourceLimit else { throw EPUBError.resourceTooLarge }
return EPUBIllustration(id: index + 1, caption: image.alt, data: data)
}
}
private func body(chapter: BookChapter) throws -> (Element, String) {
guard let path = chapter.resourceUrl, spine.contains(where: { $0.path == path }),
let body = try document(path).body() else { throw EPUBError.invalidChapter }
return (body, path)
}
private func document(_ path: String) throws -> Document {
let data = try read(path)
let text: String
if data.starts(with: [0xff, 0xfe]) || data.starts(with: [0xfe, 0xff]), let utf16 = String(data: data, encoding: .utf16) {
text = utf16
} else if let utf8 = String(data: data, encoding: .utf8) { text = utf8 }
else { throw EPUBError.invalidPackage }
return try SwiftSoup.parse(text)
}
private func navigation() -> [Reference] {
if let nav = manifest.first(where: { $0.properties.contains("nav") }), let doc = try? document(nav.path),
let nodes = try? doc.select("nav").array() {
let toc = nodes.first { node in
let type = (try? node.attr("epub:type")) ?? ""
return type.split(whereSeparator: \.isWhitespace).contains("toc") || (try? node.attr("role")) == "doc-toc"
}
if let links = try? toc?.select("a[href]").array() {
let refs = links.compactMap { element -> Reference? in
guard let href = try? element.attr("href"), let ref = Self.resolve(href, relativeTo: nav.path) else { return nil }
return Reference(path: ref.path, fragment: ref.fragment, title: (try? element.text()) ?? "")
}
if !refs.isEmpty { return refs }
}
}
let tocID = package.children.first { $0.name == "spine" }?.attributes["toc"]
if let ncx = manifest.first(where: { $0.id == tocID }) ?? manifest.first(where: { $0.mediaType == "application/x-dtbncx+xml" }),
let data = try? read(ncx.path), let root = try? EPUBXMLNode.parse(data) {
return root.descendants("navPoint").compactMap { node in
guard let href = node.children.first(where: { $0.name == "content" })?.attributes["src"],
let ref = Self.resolve(href, relativeTo: ncx.path) else { return nil }
return Reference(path: ref.path, fragment: ref.fragment,
title: node.children.first(where: { $0.name == "navLabel" })?.text.trimmingCharacters(in: .whitespacesAndNewlines) ?? "")
}
}
return []
}
/// Walk DOM boundaries rather than slicing serialized HTML: anchors can be nested
/// in different parents, and all text after the end anchor belongs to the next chapter.
private func render(body: Element, from start: String?, to end: String?) throws -> (text: String, images: [(src: String, alt: String)]) {
var active = start == nil
var foundStart = start == nil
var foundEnd = end == nil
var finished = false
var output = ""
var images: [(src: String, alt: String)] = []
let blocks: Set<String> = ["p", "div", "section", "article", "li", "ul", "ol", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "pre", "tr", "hr"]
func visit(_ node: Node, depth: Int) throws {
guard depth < 512 else { throw EPUBError.invalidPackage }
guard !finished else { return }
guard let element = node as? Element else {
if active, let text = node as? TextNode {
output += text.getWholeText().replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
}
return
}
let id = element.id()
let name = try element.attr("name")
if let end, id == end || name == end { active = false; finished = true; foundEnd = true; return }
if let start, id == start || name == start { active = true; foundStart = true }
let tag = element.tagName().lowercased()
let style = try element.attr("style").lowercased().replacingOccurrences(of: #"\s+"#, with: "", options: .regularExpression)
if ["script", "style", "noscript", "rt", "rp", "title"].contains(tag) || element.hasAttr("hidden") || style.contains("display:none") { return }
if active, blocks.contains(tag) || tag == "br" { output += "\n" }
if active, tag == "img" || tag == "image" {
var src = try element.attr(tag == "img" ? "src" : "xlink:href")
if src.isEmpty, tag == "image" { src = try element.attr("href") }
let alt = try element.attr("alt")
if !src.isEmpty {
images.append((src, alt))
output += "\n【插图 \(images.count)\(alt.isEmpty ? "" : ":" + alt)】\n"
}
}
for child in element.getChildNodes() { try visit(child, depth: depth + 1) }
if active, blocks.contains(tag) { output += "\n" }
if active, tag == "td" || tag == "th" { output += " " }
}
try visit(body, depth: 0)
guard foundStart && foundEnd else { throw EPUBError.invalidChapter }
let text = output.components(separatedBy: .newlines).map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }.joined(separator: "\n")
return (text, images)
}
private func read(_ path: String) throws -> Data {
guard !encryptedPaths.contains(path) else { throw EPUBError.encrypted }
return try Self.read(path, archive: archive, entries: entries)
}
private static func read(_ path: String, archive: Archive, entries: [String: Entry]) throws -> Data {
guard let entry = entries[path] else { throw EPUBError.missingResource(path) }
guard entry.uncompressedSize <= resourceLimit else { throw EPUBError.resourceTooLarge }
var result = Data()
let checksum = try archive.extract(entry) { chunk in
guard result.count + chunk.count <= resourceLimit else { throw EPUBError.resourceTooLarge }
result.append(chunk)
}
guard checksum == entry.checksum, result.count == entry.uncompressedSize else { throw EPUBError.invalidArchive }
return result
}
private static func coverThumbnail(_ data: Data) -> Data? {
guard let source = CGImageSourceCreateWithData(data as CFData, nil),
let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: 512,
kCGImageSourceCreateThumbnailWithTransform: true
] as CFDictionary) else { return nil }
let output = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(output, "public.png" as CFString, 1, nil) else { return nil }
CGImageDestinationAddImage(destination, image, nil)
guard CGImageDestinationFinalize(destination) else { return nil }
return output as Data
}
private static func safePath(_ path: String) -> Bool {
!path.isEmpty && !path.hasPrefix("/") && !path.contains("\\") && !path.contains("\0") && !path.split(separator: "/").contains("..")
}
private static func resolve(_ href: String, relativeTo base: String) -> (path: String, fragment: String?)? {
guard !href.hasPrefix("/"), !href.contains("\\"),
URLComponents(string: href)?.scheme == nil else { return nil }
let parts = href.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)
guard let pathPart = String(parts[0]).removingPercentEncoding else { return nil }
var components = pathPart.isEmpty ? base.split(separator: "/").map(String.init) : base.split(separator: "/").dropLast().map(String.init)
if !pathPart.isEmpty {
for part in pathPart.split(separator: "/") {
if part == "." { continue }
if part == ".." { guard !components.isEmpty else { return nil }; components.removeLast() }
else { components.append(String(part)) }
}
}
let path = components.joined(separator: "/")
guard safePath(path), !pathPart.hasPrefix("/") else { return nil }
let fragment = parts.count > 1 ? String(parts[1]).removingPercentEncoding : nil
return (path, fragment?.isEmpty == false ? fragment : nil)
}
private static func referenceURL(path: String, fragment: String?) -> String {
var url = URLComponents()
url.scheme = "epub"
url.host = "book"
url.path = "/" + path
url.fragment = fragment
return url.string ?? path
}
}
/// Small namespace-independent XML tree for package metadata and navigation.
private final class EPUBXMLNode: NSObject, XMLParserDelegate {
var name = ""
var attributes: [String: String] = [:]
var children: [EPUBXMLNode] = []
var text = ""
private var stack: [EPUBXMLNode] = []
private var count = 0
func descendants(_ name: String) -> [EPUBXMLNode] {
children.flatMap { ($0.name == name ? [$0] : []) + $0.descendants(name) }
}
static func parse(_ data: Data) throws -> EPUBXMLNode {
let delegate = EPUBXMLNode()
let parser = XMLParser(data: data)
parser.shouldResolveExternalEntities = false
parser.delegate = delegate
guard parser.parse(), let root = delegate.children.first else { throw EPUBError.invalidPackage }
return root
}
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String]) {
count += 1
guard stack.count < 128, count <= 50_000 else { parser.abortParsing(); return }
let node = EPUBXMLNode()
node.name = elementName.split(separator: ":").last.map(String.init) ?? elementName
node.attributes = attributeDict
(stack.last ?? self).children.append(node)
stack.append(node)
}
func parser(_ parser: XMLParser, foundCharacters string: String) {
for node in stack {
guard node.text.utf8.count + string.utf8.count <= 4 * 1_024 * 1_024 else { parser.abortParsing(); return }
node.text += string
}
}
func parser(_ parser: XMLParser, foundInternalEntityDeclarationWithName name: String, value: String?) {
parser.abortParsing()
}
func parser(_ parser: XMLParser, foundExternalEntityDeclarationWithName name: String, publicID: String?, systemID: String?) {
parser.abortParsing()
}
func parser(_ parser: XMLParser, foundCDATA CDATABlock: Data) {
if let value = String(data: CDATABlock, encoding: .utf8) { self.parser(parser, foundCharacters: value) }
}
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
_ = stack.popLast()
}
}
@@ -10,7 +10,7 @@ public enum LocalBookError: Error, LocalizedError, Sendable {
public var errorDescription: String? {
switch self {
case .unsupportedFormat: "当前仅支持 TXT 本地书"
case .unsupportedFormat: "当前支持 TXT 和 EPUB 本地书"
case .oversized: "本地书超过当前配置允许的大小"
case .unreadableEncoding: "无法识别 TXT 编码"
case .missingFile: "本地书文件已丢失"
@@ -104,6 +104,49 @@ public struct LocalBookService: Sendable {
LocalBookService(tocRules: TxtTocRuleDefaults.load())
}
public static func supports(_ url: URL) -> Bool {
url.isFileURL && ["txt", "epub"].contains(url.pathExtension.lowercased())
}
public func openFile(at url: URL) throws -> LocalBookImportReport {
guard Self.supports(url) else { throw LocalBookError.unsupportedFormat }
if url.pathExtension.lowercased() == "epub" {
try validateFile(url)
return try EPUBBook(url: url).report(url: url, originalName: url.lastPathComponent)
}
return try openTextFile(at: url)
}
public func importFile(at source: URL, into directory: URL) throws -> LocalBookImportReport {
guard Self.supports(source) else { throw LocalBookError.unsupportedFormat }
try validateFile(source)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let destination = directory.appendingPathComponent(UUID().uuidString + "." + source.pathExtension.lowercased())
try FileManager.default.copyItem(at: source, to: destination)
do {
if source.pathExtension.lowercased() == "epub" {
return try EPUBBook(url: destination).report(url: destination, originalName: source.lastPathComponent)
}
return try textFileReport(data: Data(contentsOf: destination, options: .mappedIfSafe), sourceURL: source, destination: destination)
} catch {
try? FileManager.default.removeItem(at: destination)
throw error
}
}
public func loadIllustrations(book: Book, chapter: BookChapter) throws -> [EPUBIllustration] {
guard let url = URL(string: book.bookUrl), url.isFileURL, url.pathExtension.lowercased() == "epub" else { return [] }
try validateFile(url)
return try EPUBBook(url: url).illustrations(chapter: chapter)
}
private func validateFile(_ url: URL) throws {
guard FileManager.default.fileExists(atPath: url.path) else { throw LocalBookError.missingFile }
let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey])
guard values.isRegularFile == true else { throw LocalBookError.missingFile }
guard Int64(values.fileSize ?? 0) <= maximumFileSize else { throw LocalBookError.oversized }
}
public func importTextFile(at sourceURL: URL, into directory: URL) throws -> LocalBookImportReport {
guard sourceURL.pathExtension.lowercased() == "txt" else { throw LocalBookError.unsupportedFormat }
let values = try sourceURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
@@ -181,6 +224,10 @@ public struct LocalBookService: Sendable {
}
public func loadChapters(book: Book) throws -> ChapterListReport {
if let url = URL(string: book.bookUrl), url.isFileURL, url.pathExtension.lowercased() == "epub" {
try validateFile(url)
return try .init(chapters: EPUBBook(url: url).chapters(book: book))
}
let decoded = try read(book)
let selectionVolumeState = TxtVolumeState()
let probeText = textProbe(decoded.data, encoding: decoded.encoding)
@@ -201,6 +248,10 @@ public struct LocalBookService: Sendable {
}
public func loadContent(book: Book, chapter: BookChapter) throws -> ChapterContentReport {
if let url = URL(string: book.bookUrl), url.isFileURL, url.pathExtension.lowercased() == "epub" {
try validateFile(url)
return try EPUBBook(url: url).content(chapter: chapter)
}
guard let url = URL(string: book.bookUrl), url.isFileURL,
FileManager.default.fileExists(atPath: url.path) else { throw LocalBookError.missingFile }
let fileSize = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
@@ -32,7 +32,7 @@ public struct StagedLocalFile: Sendable {
public actor LocalFileService {
public let directory: URL
public init(directory: URL) { self.directory = directory.standardizedFileURL }
public init(directory: URL) { self.directory = URL(fileURLWithPath: directory.path, isDirectory: true).standardizedFileURL }
public func list() throws -> [LocalFileEntry] {
guard FileManager.default.fileExists(atPath: directory.path) else { return [] }
@@ -41,13 +41,13 @@ public actor LocalFileService {
options: [.skipsHiddenFiles]
).compactMap { url in
let values = try url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey, .contentModificationDateKey])
guard values.isRegularFile == true, values.isSymbolicLink != true, url.pathExtension.lowercased() == "txt" else { return nil }
guard values.isRegularFile == true, values.isSymbolicLink != true, LocalBookService.supports(url) else { return nil }
return .init(url: url, bytes: Int64(values.fileSize ?? 0), modified: values.contentModificationDate)
}.sorted { $0.url.lastPathComponent.localizedStandardCompare($1.url.lastPathComponent) == .orderedAscending }
}
public func importFile(from source: URL) throws -> URL {
guard source.pathExtension.lowercased() == "txt" else { throw LocalBookError.unsupportedFormat }
guard LocalBookService.supports(source) else { throw LocalBookError.unsupportedFormat }
let values = try source.resourceValues(forKeys: [.isRegularFileKey])
guard values.isRegularFile == true else { throw LocalFileError.missingFile }
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
@@ -55,7 +55,7 @@ public actor LocalFileService {
var target = directory.appendingPathComponent(source.lastPathComponent)
var suffix = 2
while FileManager.default.fileExists(atPath: target.path) {
target = directory.appendingPathComponent("\(stem) (\(suffix)).txt")
target = directory.appendingPathComponent("\(stem) (\(suffix)).\(source.pathExtension)")
suffix += 1
}
try FileManager.default.copyItem(at: source, to: target)
@@ -69,7 +69,8 @@ public actor LocalFileService {
!trimmed.contains(where: { $0 == "/" || $0 == "\\" || $0.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) }) else {
throw LocalFileError.invalidName
}
let filename = trimmed.lowercased().hasSuffix(".txt") ? trimmed : trimmed + ".txt"
let suffix = "." + source.pathExtension
let filename = trimmed.lowercased().hasSuffix(suffix.lowercased()) ? trimmed : trimmed + suffix
guard filename.utf8.count <= 255 else { throw LocalFileError.invalidName }
let target = directory.appendingPathComponent(filename)
if source.standardizedFileURL == target.standardizedFileURL { return source }
@@ -83,7 +84,7 @@ public actor LocalFileService {
guard FileManager.default.fileExists(atPath: source.path) else { return nil }
let trash = directory.appendingPathComponent(".Trash", isDirectory: true)
try FileManager.default.createDirectory(at: trash, withIntermediateDirectories: true)
let staged = trash.appendingPathComponent(UUID().uuidString + ".txt")
let staged = trash.appendingPathComponent(UUID().uuidString + "." + source.pathExtension)
try FileManager.default.moveItem(at: source, to: staged)
return .init(original: source, staged: staged)
}
@@ -104,7 +105,7 @@ public actor LocalFileService {
}
private func validate(_ url: URL, allowMissing: Bool = false) throws {
guard url.isFileURL, url.pathExtension.lowercased() == "txt",
guard url.isFileURL, LocalBookService.supports(url),
url.standardizedFileURL.deletingLastPathComponent() == directory,
url.resolvingSymlinksInPath().deletingLastPathComponent() == directory.resolvingSymlinksInPath() else {
throw LocalFileError.outsideLibrary
@@ -131,7 +132,7 @@ public actor BookFileExporter {
}
public func originalFile(_ source: URL, filename: String) throws -> BookFileExport {
let target = try destination(filename: filename)
let target = try destination(filename: filename, extension: source.pathExtension.lowercased())
do {
try FileManager.default.copyItem(at: source, to: target)
return .init(url: target, cachedChapters: 0, totalChapters: 0)
@@ -178,11 +179,11 @@ public actor BookFileExporter {
try FileManager.default.removeItem(at: export.url.deletingLastPathComponent())
}
private func destination(filename: String) throws -> URL {
private func destination(filename: String, extension fileExtension: String = "txt") throws -> URL {
let folder = directory.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
let safe = filename.components(separatedBy: CharacterSet(charactersIn: "/\\:").union(.controlCharacters)).joined(separator: "_")
let stem = String(safe.replacingOccurrences(of: ".txt", with: "", options: [.caseInsensitive, .anchored, .backwards]).prefix(70))
return folder.appendingPathComponent((stem.isEmpty ? "书籍" : stem) + ".txt")
let stem = String(safe.replacingOccurrences(of: "." + fileExtension, with: "", options: [.caseInsensitive, .anchored, .backwards]).prefix(70))
return folder.appendingPathComponent((stem.isEmpty ? "书籍" : stem) + "." + fileExtension)
}
}
@@ -548,17 +548,32 @@ public struct LegadoURLBuilder: Sendable {
case "{", ",":
output.append(character)
expectingKey = true
case ":":
output.append(character)
case ":", "=":
// Gson also accepts = and => between an object key and value.
// Quoted strings are consumed above, so Cookie/URL values stay intact.
output.append(":")
if character == "=", peek() == ">" { index += 1 }
expectingKey = false
if quoteUnquotedValues {
do {
var start = index
while start < characters.count, characters[start].isWhitespace { start += 1 }
if start < characters.count, !["\"", "'", "{", "[", "}", ","].contains(characters[start]) {
var end = start
while end < characters.count, characters[end] != ",", characters[end] != "}" { end += 1 }
output.append(escapedJSONString(String(characters[start..<end]).trimmingCharacters(in: .whitespacesAndNewlines)))
index = end
if quoteUnquotedValues {
// @put values are rules, including unquoted JSONPath expressions.
while end < characters.count, characters[end] != ",", characters[end] != "}" { end += 1 }
} else {
while end < characters.count, !characters[end].isWhitespace,
![",", "}", "]", "{", "[", ":", ";", "=", "/", "#", "\\"].contains(characters[end]) { end += 1 }
}
if end > start {
let token = String(characters[start..<end]).trimmingCharacters(in: .whitespacesAndNewlines)
let literal = token.lowercased()
let isLiteral = ["true", "false", "null"].contains(literal)
|| token.range(of: #"^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$"#, options: .regularExpression) != nil
output.append(!quoteUnquotedValues && isLiteral ? literal : escapedJSONString(token))
index = end
}
}
}
case "}", "]":
@@ -580,7 +595,7 @@ public struct LegadoURLBuilder: Sendable {
while peekPosition < characters.count, characters[peekPosition].isWhitespace {
peekPosition += 1
}
if peekPosition < characters.count, characters[peekPosition] == ":" {
if peekPosition < characters.count, [":", "="].contains(characters[peekPosition]) {
output.removeLast()
output.append(escapedJSONString(name))
index = position
@@ -611,7 +626,7 @@ public struct LegadoURLBuilder: Sendable {
let parsed: JSONValue
if case .string(let source) = value {
do {
parsed = try JSONValue.decode(data: Data(source.utf8))
parsed = try Self.decodeLenientJSON(source)
} catch {
throw RuleEngineError.malformedRule(rule: rule, detail: "headers JSON 无效")
}
@@ -34,6 +34,12 @@ public struct RSSReadingPolicy: Sendable {
public struct RSSHTMLProcessor: Sendable {
public static let defaultStyle = "img{max-width:100% !important; width:auto; height:auto;}video{object-fit:fill; max-width:100% !important; width:auto; height:auto;}body{word-wrap:break-word; height:auto;max-width: 100%; width:auto;}"
// Keep source CSS later in the cascade so intentional source styling wins.
public static let appearanceStyle = """
:root{color-scheme:light dark;}
body{background-color:#fcfcfc;color:#171717;}
@media(prefers-color-scheme:dark){body{background-color:#111113;color:#f2f2f2;}a{color:#9ecbff;}}
"""
public init() {}
public func contentHTML(_ content: String, style: String?) -> String {
@@ -53,7 +59,7 @@ public struct RSSHTMLProcessor: Sendable {
}
private func injectStyle(into content: String, style: String) -> String {
let tag = "<style>\(style)</style>"
let tag = "<style>\(Self.appearanceStyle)</style><style>\(style)</style>"
if let range = content.range(of: "</style>") {
return String(content[..<range.upperBound]) + tag + String(content[range.upperBound...])
}
@@ -84,6 +84,13 @@ public struct RSSRuleParser: Sendable {
private func flatten(_ values: [JSONValue]) -> [JSONValue] {
values.flatMap { value in
if case .array(let items) = value { return items }
// Text rules serialize selected JSON arrays. Expand them before
// evaluating article fields, just as for an already parsed array.
if case .string(let text) = value,
text.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("["),
let items = try? JSONDecoder().decode([JSONValue].self, from: Data(text.utf8)) {
return items
}
return [value]
}
}
@@ -17,6 +17,48 @@ public struct JSONPathEvaluator: Sendable {
public init() {}
// Legado's JSON rules also allow literal templates such as /book/{$.id}.
// Balance braces and quoted strings instead of stopping at the first }.
func evaluateRule(_ rule: String, in root: JSONValue) throws -> [JSONValue] {
var cursor = rule.startIndex
var output = ""
var foundTemplate = false
while let opening = rule.range(of: "{$.", range: cursor..<rule.endIndex) {
foundTemplate = true
output += rule[cursor..<opening.lowerBound]
let start = rule.index(after: opening.lowerBound)
var end = start
var depth = 1
var quote: Character?
var escaped = false
while end < rule.endIndex {
let character = rule[end]
if escaped { escaped = false }
else if character == "\\" { escaped = true }
else if let activeQuote = quote {
if character == activeQuote { quote = nil }
} else if character == "\"" || character == "'" { quote = character }
else if character == "{" { depth += 1 }
else if character == "}" {
depth -= 1
if depth == 0 { break }
}
end = rule.index(after: end)
}
guard end < rule.endIndex else {
throw RuleEngineError.malformedRule(rule: rule, detail: "JSONPath 模板大括号未闭合")
}
let values = try evaluate(path: String(rule[start..<end]), in: root)
output += RuleEvaluation(values: values).strings.joined(separator: "\n")
cursor = rule.index(after: end)
}
if foundTemplate {
output += rule[cursor...]
return [.string(output)]
}
return try evaluate(path: rule.hasPrefix("$") ? rule : "$." + rule, in: root)
}
public func evaluate(path: String, in root: JSONValue) throws -> [JSONValue] {
let segments = try parse(path)
var current = [root]
@@ -59,7 +101,10 @@ public struct JSONPathEvaluator: Sendable {
guard next < rule.endIndex else {
throw RuleEngineError.malformedRule(rule: path, detail: "JSONPath 不能以点结尾")
}
if rule[next] == "." {
if rule[next] == "[" {
// Legacy sources use $.[*] as well as $[*].
index = next
} else if rule[next] == "." {
index = rule.index(after: next)
let parsed = try parseDotSelector(rule, from: index, original: path)
segments.append(.recursive(parsed.selector))
@@ -84,7 +84,8 @@ public extension JSONValue {
if value is NSNull { return .null }
if let value = value as? JSONValue { return value }
if let value = value as? String { return .string(value) }
if let value = value as? Bool { return .bool(value) }
// NSNumber(0/1) also casts to Bool. Inspect its CF type first so
// JavaScript page arithmetic and numeric IDs remain numbers.
if let value = value as? NSNumber {
if CFGetTypeID(value) == CFBooleanGetTypeID() { return .bool(value.boolValue) }
return .number(value.doubleValue)
@@ -208,6 +208,33 @@ public struct JavaScriptRuleEvaluator: Sendable {
script: String,
result: JSONValue,
executionContext: RuleExecutionContext
) throws -> JavaScriptRuleResult {
try evaluate(script: script, result: result, executionContext: executionContext,
virtualMachine: JSVirtualMachine(), stringifyResult: false)
}
/// A rule shares java.get/put storage, but each match gets fresh JS globals.
/// Keep this closure on the synchronous replacement call's thread.
func replacementEvaluator(script: String, book: Book, chapter: BookChapter) -> (String) throws -> String {
var virtualMachine: JSVirtualMachine?
var executionContext = RuleExecutionContext(book: book, chapter: chapter)
return { match in
if virtualMachine == nil { virtualMachine = JSVirtualMachine() }
let result = try autoreleasepool {
try evaluate(script: script, result: .string(match), executionContext: executionContext,
virtualMachine: virtualMachine!, stringifyResult: true)
}
executionContext.variables = result.variables
return result.value.stringValue
}
}
private func evaluate(
script: String,
result: JSONValue,
executionContext: RuleExecutionContext,
virtualMachine: JSVirtualMachine,
stringifyResult: Bool
) throws -> JavaScriptRuleResult {
guard script.utf8.count <= maximumScriptLength else {
throw RuleEngineError.resourceLimit(rule: script, detail: "JavaScript 规则超过长度上限")
@@ -215,8 +242,7 @@ public struct JavaScriptRuleEvaluator: Sendable {
try rejectUnboundedExecution(script)
let box = JSVariableBox(executionContext.variables)
let vm = JSVirtualMachine()
guard let context = JSContext(virtualMachine: vm) else {
guard let context = JSContext(virtualMachine: virtualMachine) else {
throw RuleEngineError.invalidInput(rule: script, expected: "无法创建 JavaScriptCore 上下文")
}
var exceptionMessage: String?
@@ -271,7 +297,28 @@ public struct JavaScriptRuleEvaluator: Sendable {
let normalized = evaluated.flatMap {
context.objectForKeyedSubscript("__legadoUnboxString")?.call(withArguments: [$0])
}
let value = JSONValue.bridge(normalized?.toObject())
let value: JSONValue
if stringifyResult {
// Rhino unwraps undefined to null before RegexExtensions.kt calls
// toString(). Preserve strings directly, rather than JSON encoding.
if let evaluated, !evaluated.isUndefined, !evaluated.isNull {
let text = evaluated.toString() ?? "null"
if evaluated.isNumber {
// The reference runs Rhino in interpreted mode, yielding JVM Doubles.
let number = evaluated.toDouble()
value = .string(number.isFinite ? String(number) : text)
} else {
value = .string(text)
}
} else {
value = .string("null")
}
} else {
value = JSONValue.bridge(normalized?.toObject())
}
if let exceptionMessage {
throw RuleEngineError.malformedRule(rule: script, detail: "JavaScript 执行失败:\(exceptionMessage)")
}
guard value.stringValue.utf8.count <= maximumResultLength else {
throw RuleEngineError.resourceLimit(rule: script, detail: "JavaScript 结果超过长度上限")
}
@@ -974,53 +1021,48 @@ public struct JavaScriptRuleEvaluator: Sendable {
return resolveJavaScriptRuleURL(direct.stringValue, enabled: isURL, context: executionContext)
}
let plan = try RuleExpressionParser().parse(trimmedRule)
let jsonContent: JSONValue?
if case .string(let text) = content { jsonContent = try? JSONValue.decode(data: Data(text.utf8)) }
else { jsonContent = content }
let plan = try RuleExpressionParser().parse(trimmedRule, inputLooksLikeJSON: jsonContent != nil)
var branchResults: [[String]] = []
for branch in plan.branches {
var values: [String]
switch branch.mode {
case .css:
values = try HTMLRuleEvaluator().extractCSS(
branch.expression,
html: content.stringValue,
baseURL: executionContext.baseURL,
isFragment: executionContext.isHTMLFragment
)
case .xpath:
values = try HTMLRuleEvaluator().extractXPath(branch.expression, html: content.stringValue, isFragment: executionContext.isHTMLFragment)
case .jsonPath:
let json: JSONValue
if case .string(let text) = content {
json = try JSONValue.decode(data: Data(text.utf8))
} else {
json = content
}
let path = branch.expression.hasPrefix("$") ? branch.expression : "." + branch.expression
let normalizedPath = path.hasPrefix("$") ? path : "$" + path
values = try JSONPathEvaluator().evaluate(path: normalizedPath, in: json)
.map(\.stringValue)
case .regex:
values = try RegexEvaluator().captureGroups(pattern: branch.expression, input: content.stringValue)
.map { $0.count > 1 ? $0.dropFirst().joined() : $0.first ?? "" }
case .javaScript:
values = [try evaluate(
script: branch.expression,
result: content,
executionContext: executionContext
).value.stringValue]
case .webJavaScript:
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "java.getString WebView JavaScript")
}
if let pattern = branch.replacePattern, !pattern.isEmpty {
values = try values.map {
try RegexEvaluator().replacing(
pattern: pattern,
in: $0,
with: branch.replacement,
firstOnly: branch.replaceFirst
if branch.expression.isEmpty {
values = [content.stringValue]
} else {
switch branch.mode {
case .css:
values = try HTMLRuleEvaluator().extractCSS(
branch.expression,
html: content.stringValue,
baseURL: executionContext.baseURL,
isFragment: executionContext.isHTMLFragment
)
case .xpath:
values = try HTMLRuleEvaluator().extractXPath(branch.expression, html: content.stringValue, isFragment: executionContext.isHTMLFragment)
case .jsonPath:
let json: JSONValue
if case .string(let text) = content {
json = try JSONValue.decode(data: Data(text.utf8))
} else {
json = content
}
values = RuleEvaluation(values: try JSONPathEvaluator().evaluateRule(branch.expression, in: json)).strings
case .regex:
values = try RegexEvaluator().captureGroups(pattern: branch.expression, input: content.stringValue)
.map { $0.count > 1 ? $0.dropFirst().joined() : $0.first ?? "" }
case .javaScript:
values = [try evaluate(
script: branch.expression,
result: content,
executionContext: executionContext
).value.stringValue]
case .webJavaScript:
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "java.getString WebView JavaScript")
}
}
if plan.combination == .fallback, values.joined(separator: "\n").isEmpty { values = [] }
branchResults.append(values)
if plan.combination == .fallback, !values.isEmpty { break }
}
@@ -1037,7 +1079,11 @@ public struct JavaScriptRuleEvaluator: Sendable {
branchResults.compactMap { $0.indices.contains(index) ? $0[index] : nil }
}
}
return resolveJavaScriptRuleURL(combined.joined(separator: "\n"), enabled: isURL, context: executionContext)
var result = combined.joined(separator: "\n")
if let branch = plan.branches.first, let pattern = branch.replacePattern, !pattern.isEmpty {
result = try RegexEvaluator().replacing(pattern: pattern, in: result, with: branch.replacement, firstOnly: branch.replaceFirst)
}
return resolveJavaScriptRuleURL(result, enabled: isURL, context: executionContext)
}
private func resolveJavaScriptRuleURL(
@@ -112,6 +112,25 @@ public struct RegexEvaluator: Sendable {
public func replacingStrict(pattern: String, in input: String, with replacement: String,
timeoutMilliseconds: Int64 = 3_000) throws -> String {
try replacingStrict(pattern: pattern, in: input, timeoutMilliseconds: timeoutMilliseconds) { expression, match in
expression.replacementString(for: match, in: input, offset: 0, template: replacement)
}
}
/// Legado's JS replacement still expands capture references, while quoting
/// backslashes returned by the script (quoteReplacementJs in the reference).
func replacingStrict(pattern: String, in input: String, timeoutMilliseconds: Int64 = 3_000,
replacement: (String) throws -> String) throws -> String {
let original = input as NSString
return try replacingStrict(pattern: pattern, in: input, timeoutMilliseconds: timeoutMilliseconds) { expression, match in
let template = try replacement(original.substring(with: match.range))
.replacingOccurrences(of: "\\", with: "\\\\")
return expression.replacementString(for: match, in: input, offset: 0, template: template)
}
}
private func replacingStrict(pattern: String, in input: String, timeoutMilliseconds: Int64,
replacement: (NSRegularExpression, NSTextCheckingResult) throws -> String) throws -> String {
guard pattern.count <= limits.maximumPatternLength, input.utf16.count <= limits.maximumInputLength,
!hasObviousNestedQuantifier(pattern) else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则超过资源限制或包含高风险嵌套量词")
@@ -145,30 +164,51 @@ public struct RegexEvaluator: Sendable {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则替换超时(\(milliseconds) ms)")
}
let untouched = original.substring(with: NSRange(location: offset, length: match.range.location - offset))
let replaced = expression.replacementString(for: match, in: input, offset: 0, template: replacement)
output += untouched + replaced
let replaced = try replacement(expression, match)
guard DispatchTime.now().uptimeNanoseconds < deadline else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则替换超时(\(milliseconds) ms)")
}
outputLength += untouched.utf16.count + replaced.utf16.count
offset = NSMaxRange(match.range)
guard outputLength <= limits.maximumInputLength * 2 else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "替换结果超过长度限制")
}
output += untouched + replaced
}
output += original.substring(from: offset)
let tail = original.substring(from: offset)
guard outputLength + tail.utf16.count <= limits.maximumInputLength * 2 else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "替换结果超过长度限制")
}
output += tail
return output
}
private func hasObviousNestedQuantifier(_ pattern: String) -> Bool {
let risky = #"\(((?:[^()\\]|\\.)*[+*](?:[^()\\]|\\.)*)\)([+*]|\{[^}]+\})"#
guard let expression = try? NSRegularExpression(pattern: risky) else { return false }
return expression.matches(in: pattern, range: NSRange(pattern.startIndex..., in: pattern)).contains { match in
guard let innerRange = Range(match.range(at: 1), in: pattern),
let outerRange = Range(match.range(at: 2), in: pattern) else { return true }
let inner = String(pattern[innerRange])
let outer = String(pattern[outerRange])
if outer.range(of: #"^\{[1-9]\}$"#, options: .regularExpression) != nil { return false }
if !pattern.contains("(?s"),
inner.range(of: #"^(?:\\n[^\[\]()*+?\\]*\.\*|\.\+\\n)$"#, options: .regularExpression) != nil { return false }
return true
// Reject clear cases such as (a+)+ and (\w*)*. A '+' inside a character
// class/escape is not a quantifier, and bounded or delimited repeated
// groups are not proof of catastrophic backtracking. Other expressions
// remain protected by enumerateMatches' progress/deadline checks.
let atom = #"^(?:\?:)?(?:\\[dDsSwW]|\\[^pPkKgG0-9]|\[(?:\\.|[^\]\\])*\]|[^\[\]()\\?*+{}|^$])(?:[+*]|\{[0-9]+,\})\??$"#
guard let repeatedAtom = try? NSRegularExpression(pattern: atom),
let unbounded = try? NSRegularExpression(pattern: #"^(?:[+*]|\{[0-9]+,\})(?!\+)"#) else { return false }
var groups: [String.Index] = []
var classDepth = 0
var escaped = false
for index in pattern.indices {
let character = pattern[index]
if escaped { escaped = false; continue }
if character == "\\" { escaped = true; continue }
if character == "[" { classDepth += 1; continue }
if character == "]", classDepth > 0 { classDepth -= 1; continue }
guard classDepth == 0 else { continue }
if character == "(" { groups.append(pattern.index(after: index)) }
if character == ")", let start = groups.popLast() {
let inner = String(pattern[start..<index])
let suffix = String(pattern[pattern.index(after: index)...])
if repeatedAtom.firstMatch(in: inner, range: NSRange(inner.startIndex..., in: inner)) != nil,
unbounded.firstMatch(in: suffix, range: NSRange(suffix.startIndex..., in: suffix)) != nil { return true }
}
}
return false
}
}
+33 -34
View File
@@ -57,11 +57,11 @@ public struct RuleEngine: Sendable {
return literalEvaluation(rendered.rule, context: rendered.context)
}
if rendered.context.variables != context.variables {
var result = await evaluatePrepared(rendered.rule, in: input, context: rendered.context)
var result = await evaluatePrepared(rendered.rule, in: input, listMode: listMode, context: rendered.context)
result.variables = rendered.context.variables.merging(result.variables) { _, new in new }
return result
}
return await evaluatePrepared(rendered.rule, in: input, context: rendered.context)
return await evaluatePrepared(rendered.rule, in: input, listMode: listMode, context: rendered.context)
} catch let error as RuleEngineError {
return .init(diagnostics: [error.diagnostic], variables: context.variables)
} catch {
@@ -72,6 +72,7 @@ public struct RuleEngine: Sendable {
private func evaluatePrepared(
_ rule: String,
in input: JSONValue,
listMode: Bool,
context: RuleExecutionContext
) async -> RuleEvaluation {
do {
@@ -80,7 +81,8 @@ public struct RuleEngine: Sendable {
var diagnostics: [RuleDiagnostic] = []
for branch in plan.branches {
do {
let values = try await evaluate(branch, in: input, context: context)
var values = try await evaluate(branch, in: input, context: context)
if plan.combination == .fallback, isEmpty(values, listMode: listMode) { values = [] }
results.append(values)
if plan.combination == .fallback, !values.isEmpty { break }
} catch let error as RuleEngineError {
@@ -97,7 +99,7 @@ public struct RuleEngine: Sendable {
}
}
return .init(
values: combine(results, with: plan.combination),
values: try applyingReplacement(to: combine(results, with: plan.combination), plan: plan, listMode: listMode),
diagnostics: diagnostics,
variables: context.variables
)
@@ -174,6 +176,11 @@ public struct RuleEngine: Sendable {
listMode: Bool,
context: RuleExecutionContext
) async -> RuleEvaluation {
if let root = try? JSONValue.decode(data: Data(input.utf8)),
let plan = try? parser.parse(rule, inputLooksLikeJSON: true),
plan.branches.allSatisfy({ $0.mode == .jsonPath }) {
return await evaluatePrepared(rule, in: root, listMode: listMode, context: context)
}
if rule.hasPrefix(":"), rule.count > 1 {
do {
let patterns = rule.dropFirst().components(separatedBy: "&&").filter { !$0.isEmpty }
@@ -217,7 +224,7 @@ public struct RuleEngine: Sendable {
}
}
return .init(
values: combine(results, with: plan.combination),
values: try applyingReplacement(to: combine(results, with: plan.combination), plan: plan, listMode: listMode),
diagnostics: diagnostics,
variables: context.variables
)
@@ -241,15 +248,11 @@ public struct RuleEngine: Sendable {
try Task.checkCancellation()
let rawValues: [JSONValue]
if branch.expression.isEmpty {
return try [JSONValue.string(regex.replacing(
pattern: branch.replacePattern ?? "", in: input.stringValue,
with: branch.replacement, firstOnly: branch.replaceFirst
))]
return [input]
}
switch branch.mode {
case .jsonPath:
let path = branch.expression.hasPrefix("$") ? branch.expression : "$.\(branch.expression)"
rawValues = try jsonPath.evaluate(path: path, in: input)
rawValues = try jsonPath.evaluateRule(branch.expression, in: input)
case .regex:
let matches = try regex.captureGroups(pattern: branch.expression, input: input.stringValue)
rawValues = matches.map { .array($0.map(JSONValue.string)) }
@@ -266,15 +269,7 @@ public struct RuleEngine: Sendable {
case .webJavaScript:
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "WKWebView JavaScript")
}
guard let pattern = branch.replacePattern, !pattern.isEmpty else { return rawValues }
return try rawValues.map {
.string(try regex.replacing(
pattern: pattern,
in: $0.stringValue,
with: branch.replacement,
firstOnly: branch.replaceFirst
))
}
return rawValues
}
private func evaluateTextBranch(
@@ -287,10 +282,7 @@ public struct RuleEngine: Sendable {
try Task.checkCancellation()
let raw: [String]
if branch.expression.isEmpty {
return try [regex.replacing(
pattern: branch.replacePattern ?? "", in: input,
with: branch.replacement, firstOnly: branch.replaceFirst
)]
return [input]
}
switch branch.mode {
case .css:
@@ -308,8 +300,7 @@ public struct RuleEngine: Sendable {
} catch {
throw RuleEngineError.invalidInput(rule: branch.expression, expected: "JSON")
}
let path = branch.expression.hasPrefix("$") ? branch.expression : "$.\(branch.expression)"
raw = try jsonPath.evaluate(path: path, in: root).map(\.stringValue)
raw = try jsonPath.evaluateRule(branch.expression, in: root).map(\.stringValue)
case .regex:
raw = try regex.captureGroups(pattern: branch.expression, input: input).map { captures in
captures.count > 1 ? captures.dropFirst().joined() : captures.first ?? ""
@@ -323,14 +314,22 @@ public struct RuleEngine: Sendable {
case .webJavaScript:
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "WKWebView JavaScript")
}
guard let pattern = branch.replacePattern, !pattern.isEmpty else { return raw }
return try raw.map {
try regex.replacing(
pattern: pattern,
in: $0,
with: branch.replacement,
firstOnly: branch.replaceFirst
)
return raw
}
private func isEmpty(_ values: [JSONValue], listMode: Bool) -> Bool {
let result = RuleEvaluation(values: values)
return listMode
? result.listItems(rule: "").filter { $0 != .null }.isEmpty
: result.strings.joined(separator: "\n").isEmpty
}
private func applyingReplacement(to values: [JSONValue], plan: RulePlan, listMode: Bool) throws -> [JSONValue] {
guard let branch = plan.branches.first, let pattern = branch.replacePattern, !pattern.isEmpty else { return values }
let strings = RuleEvaluation(values: values).strings
let inputs = listMode ? strings : [strings.joined(separator: "\n")]
return try inputs.map {
.string(try regex.replacing(pattern: pattern, in: $0, with: branch.replacement, firstOnly: branch.replaceFirst))
}
}
@@ -52,8 +52,12 @@ public struct RuleExpressionParser: Sendable {
guard !normalized.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw RuleEngineError.malformedRule(rule: source, detail: "规则不能为空")
}
let split = try splitAtTopLevel(normalized)
let branches = try split.parts.map { try parseBranch($0, inputLooksLikeJSON: inputLooksLikeJSON) }
// ## belongs to the whole selector stage. Regex/replacement text may
// contain quotes, brackets and && that are not selector operators.
let replacementStart = normalized.range(of: "##")?.lowerBound ?? normalized.endIndex
let split = try splitAtTopLevel(String(normalized[..<replacementStart]))
let suffix = String(normalized[replacementStart...])
let branches = try split.parts.map { try parseBranch($0 + suffix, inputLooksLikeJSON: inputLooksLikeJSON) }
return .init(source: source, combination: split.combination, branches: branches)
}
+6
View File
@@ -21,6 +21,12 @@
- 用途:XPath 解析。
- 许可:MIT,完整版权及许可见 [Kanna-LICENSE.txt](App/Resources/Kanna-LICENSE.txt)。
## ZIPFoundation
- 项目:<https://github.com/weichsel/ZIPFoundation>,固定版本 `0.9.20`。
- 用途:按需读取 EPUB ZIP 容器与校验资源 CRC。
- 许可:MIT,完整版权及许可见 [ZIPFoundation-LICENSE.txt](App/Resources/ZIPFoundation-LICENSE.txt)。
## 测试数据与演示内容
测试中的规则样本仅用于兼容验证,不打包为 App 默认书源。具体说明见 [测试样本说明](Tests/LegadoCoreTests/Fixtures/README.md)。演示模式使用虚构书籍元数据和项目内演示文本;发布截图不使用个人书架、登录信息或付费章节。
@@ -0,0 +1,81 @@
import Foundation
import Testing
import ZIPFoundation
import LegadoCore
@testable import LegadoAppModels
@MainActor
struct EPUBReadingRegressionTests {
private func fixture(at root: URL) throws -> URL {
let url = root.appendingPathComponent("示例.epub")
let archive = try Archive(url: url, accessMode: .create)
let files = [
("mimetype", "application/epub+zip"),
("META-INF/container.xml", #"<container><rootfiles><rootfile full-path="book.opf"/></rootfiles></container>"#),
("book.opf", #"<package><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>回归书籍</dc:title><dc:creator>测试作者</dc:creator></metadata><manifest><item id="first" href="first.xhtml" media-type="application/xhtml+xml"/><item id="last" href="last.xhtml" media-type="application/xhtml+xml"/></manifest><spine><itemref idref="first"/><itemref idref="last"/></spine></package>"#),
("first.xhtml", "<html><head><title>第一章</title></head><body><h1>第一章</h1><p>第一章测试正文</p></body></html>"),
("last.xhtml", "<html><head><title>第二章</title></head><body><h1>第二章</h1><p>第二章测试正文</p></body></html>")
]
for (path, value) in files {
let data = Data(value.utf8)
try archive.addEntry(with: path, type: .file, uncompressedSize: Int64(data.count), compressionMethod: .deflate) { position, size in
data.subdata(in: Int(position)..<Int(position) + size)
}
}
return url
}
@Test func importedEPUBReadsNavigatesAndResumesAfterRenamingAndReloadingShelf() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let url = try fixture(at: root)
let repository = InMemoryBookRepository()
let managed = root.appendingPathComponent("managed", isDirectory: true)
let library = BookLibraryModel(repository: repository, localDirectory: managed, chapterRepository: InMemoryBookChapterRepository())
await library.load()
await library.importLocalFiles([url])
#expect(library.errorMessage == nil)
let book = try #require(library.books.first)
let shelfReader = BookshelfReadViewModel()
await shelfReader.load(book: book, source: nil)
#expect(shelfReader.errorMessage == nil && shelfReader.chapters.count == 2)
let reader = ReaderViewModel(book: book, chapters: shelfReader.chapters, initialIndex: 0, cache: .init(directory: root.appendingPathComponent("cache")))
#expect(!reader.canEditContent && !reader.usesReplaceRules)
await reader.load(rules: [])
#expect(reader.content == "第一章测试正文")
await reader.prepareContinuousReading(rules: [])
#expect(reader.scrollChapters.map(\.content) == ["第一章测试正文", "第二章测试正文"])
reader.goNext()
await reader.load(rules: [])
#expect(reader.content == "第二章测试正文")
await library.updateProgress(for: book, chapter: try #require(reader.currentChapter), position: 4)
try await library.renameLocalFile(at: URL(string: book.bookUrl)!, to: "改名")
let reloaded = BookLibraryModel(repository: repository, localDirectory: managed, chapterRepository: InMemoryBookChapterRepository())
await reloaded.load()
let saved = try #require(reloaded.books.first)
#expect(saved.name == "回归书籍" && saved.originName == "改名.epub")
#expect(saved.durChapterIndex == 1 && saved.durChapterPos == 4)
let chapters = try LocalBookService().loadChapters(book: saved).chapters
let resumed = ReaderViewModel(book: saved, chapters: chapters, initialIndex: saved.durChapterIndex)
await resumed.load(rules: [])
#expect(resumed.currentIndex == 1 && resumed.requestedPosition == 4)
#expect(resumed.content == "第二章测试正文")
#expect(try await reloaded.localFiles.list().count == 1)
}
@Test func corruptEPUBImportRollsBackFileAndContinuesOtherImports() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let good = try fixture(at: root)
let bad = root.appendingPathComponent("损坏.epub")
try Data("bad epub".utf8).write(to: bad)
let library = BookLibraryModel(repository: InMemoryBookRepository(), localDirectory: root.appendingPathComponent("managed", isDirectory: true), chapterRepository: InMemoryBookChapterRepository())
await library.load()
await library.importLocalFiles([bad, good])
#expect(library.books.count == 1)
#expect(library.errorMessage?.contains("损坏.epub") == true)
#expect(try await library.localFiles.list().map { $0.url.lastPathComponent } == ["示例.epub"])
}
}
@@ -5,6 +5,38 @@ import LegadoCore
@MainActor
struct LocalFileManagementTests {
@Test func pickedTXTImportsButJSONFilesCannotBecomeLocalBooks() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let inputs = [
("小说.TXT", "第一章 开始\n本地小说的正文"),
("书源.json", #"{"bookSourceName":"书源"}"#),
("大写书源.JSON", #"{"bookSourceName":"书源"}"#)
]
let files = try inputs.map { name, text in
let url = root.appendingPathComponent(name)
try Data(text.utf8).write(to: url)
return LocalBookImportFile(url: url)
}
let model = BookLibraryModel(repository: InMemoryBookRepository(),
localDirectory: root.appendingPathComponent("managed"),
chapterRepository: InMemoryBookChapterRepository())
await model.load()
await model.importPickedLocalFiles(files)
let book = try #require(model.books.first)
#expect(model.books.count == 1)
#expect(book.origin == "local")
#expect(book.originName == "小说.TXT")
#expect(model.errorMessage?.contains("书源.json") == true)
#expect(model.errorMessage?.contains("大写书源.JSON") == true)
#expect(try await model.localFiles.list().count == 1)
let imported = try #require(URL(string: book.bookUrl))
#expect(try Data(contentsOf: imported) == Data(inputs[0].1.utf8))
}
private func fixture(fails: Bool = false) async throws -> (URL, Book, BookLibraryModel) {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
@@ -207,6 +207,30 @@ struct ReaderRegressionTests {
#expect(bookmark.resolvedChapterIndex(in: chapters) == nil)
}
@Test func bookmarkSelectionUsesSavedPositionAndCurrentPageAfterReopening() {
let book = Book(bookUrl: "demo://bookmarks", name: "书", author: "作者")
let chapters = (0..<2).map { BookChapter(url: "demo://\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) }
var saved = Bookmark(time: 1, bookName: book.name, bookAuthor: book.author)
saved.chapterIndex = 0
saved.chapterName = chapters[0].title
saved.chapterPosition = 40
var otherBook = saved
otherBook.bookAuthor = "其他作者"
var otherChapter = saved
otherChapter.chapterIndex = 1
otherChapter.chapterName = chapters[1].title
let bookmarks = [saved, otherBook, otherChapter]
let reopened = ReaderViewModel(demoBook: book, chapters: chapters, initialIndex: 0, content: "正文")
#expect(reopened.bookmarks(at: 40, in: bookmarks) == [saved])
#expect(reopened.bookmarks(at: 0, in: bookmarks, pageRange: NSRange(location: 0, length: 80)) == [saved])
#expect(reopened.bookmarks(at: 80, in: bookmarks, pageRange: NSRange(location: 80, length: 80)).isEmpty)
#expect(reopened.bookmarks(at: 41, in: bookmarks).isEmpty)
#expect(reopened.bookmarks(at: 40, in: []).isEmpty)
reopened.select(index: 1)
#expect(reopened.bookmarks(at: 40, in: bookmarks) == [otherChapter])
}
@Test func continuousReadingExtendsPastThreeChaptersWithoutNavigating() async {
let book = Book(bookUrl: "demo://continuous", name: "书")
let chapters = (0..<20).map { BookChapter(url: "demo://\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) }
@@ -25,6 +25,51 @@ private actor ImportFixtureTransport: HTTPTransport {
@MainActor
struct RuleLibraryRegressionTests {
@Test func pickedJSONFilesAreReadTogetherAndReviewedBeforeImport() async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let inputs = [
("书源.json", #"{"bookSourceUrl":"https://picked-json.test","bookSourceName":"JSON 书源"}"#),
("大写扩展名.JSON", #"[{"bookSourceUrl":"https://picked-uppercase.test","bookSourceName":"大写扩展名"}]"#),
("书源.txt", #"{"bookSourceUrl":"https://picked-text.test","bookSourceName":"文本扩展名"}"#)
]
let files = try inputs.map { name, content in
let url = directory.appendingPathComponent(name)
try Data(content.utf8).write(to: url)
return SourceImportFile(url: url)
}
let model = SourceLibraryModel.demo(sources: [])
await model.previewPickedFiles(files)
#expect(model.notice == nil)
#expect(!model.isWorking)
#expect(model.pendingImport?.validCount == 3)
#expect(model.sources.isEmpty)
let selectedIDs = Set(try #require(model.pendingImport).items.map(\.id))
await model.commitPendingImport(selectedIDs: selectedIDs, keepOriginalName: false, keepOriginalGroup: false,
keepOriginalEnabled: false, customGroup: nil, addCustomGroup: true)
#expect(model.sources.count == 3)
#expect(model.pendingImport == nil)
}
@Test func missingOrDirectoryImportReportsFailureWithoutChangingSources() async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let original = BookSource(bookSourceUrl: "https://existing-source.test", bookSourceName: "原有书源")
for url in [directory, directory.appendingPathComponent("missing.json")] {
let model = SourceLibraryModel.demo(sources: [original])
await model.load()
await model.previewFiles([url])
#expect(model.notice?.kind == .error)
#expect(model.pendingImport == nil)
#expect(model.sources == [original])
#expect(!model.isWorking)
}
}
@Test func largeImportKeepsTheMainActorResponsive() async throws {
let comment = String(repeating: "规则说明", count: 1_024)
let entries = (0..<1_000).map { index in
@@ -5,6 +5,26 @@ import LegadoCore
@MainActor
struct SearchRegressionTests {
@Test func arithmeticPageExpressionCanLoadTheSecondPage() async throws {
let transport = GatedModelSearchTransport()
await transport.release()
let model = model(transport: transport, repository: InMemorySearchBookRepository())
defer { model.cancel(reset: true) }
var source = source("formula")
source.searchUrl = #"/search,{"method":"POST","body":"keyword={{key}}&page={{page-1}}"}"#
model.search(keyword: "书", sources: [source])
try await waitUntil { model.state == .finished }
#expect(model.hasMore)
model.loadMore()
try await waitUntil { !model.isLoadingMore }
let requests = await transport.requests()
#expect(requests.count == 2)
#expect(requests.compactMap(\.httpBody).map { String(decoding: $0, as: UTF8.self) } == [
"keyword=%E4%B9%A6&page=0", "keyword=%E4%B9%A6&page=1"
])
#expect(!model.hasMore)
}
@Test func cachedResultsAppearBeforeNetworkAndFreshResultsReplaceTheirMetadata() async throws {
let source = source("slow")
let cached = SearchBook(bookURL: source.id + "/cached", origin: source.id, originName: "源", type: 0, name: "书", author: "作者")
@@ -0,0 +1,89 @@
import Foundation
import Testing
@testable import LegadoCore
// Opt-in only: point LEGADO_SOURCE_PROBE_FILE at a downloaded JSON source pack.
// No remote source or response is included in the application or normal test run.
struct BookSourceRemoteProbe {
@Test(.enabled(if: ProcessInfo.processInfo.environment["LEGADO_SOURCE_CHAIN_FILE"] != nil))
func sampleReadingPipelines() async throws {
let path = try #require(ProcessInfo.processInfo.environment["LEGADO_SOURCE_CHAIN_FILE"])
let sources = try BookSourceImporter().decode(Data(contentsOf: URL(fileURLWithPath: path)))
var reports: [BookSourceDebugReport] = []
let transport = ProbeTransport()
let keyword = ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_KEYWORD"] ?? "斗罗大陆"
for source in sources {
var report = await BookSourceDebugger(transport: transport).run(source: source, keyword: keyword)
report.contentPreview = nil
reports.append(report)
print("SOURCE_CHAIN \(source.bookSourceName) " + String(decoding: try JSONEncoder().encode(report), as: UTF8.self))
}
if let output = ProcessInfo.processInfo.environment["LEGADO_SOURCE_CHAIN_OUTPUT"] {
try JSONEncoder().encode(reports).write(to: URL(fileURLWithPath: output))
try JSONEncoder().encode(await transport.records()).write(to: URL(fileURLWithPath: output + ".requests.json"))
}
#expect(reports.count == sources.count)
}
@Test(.enabled(if: ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_FILE"] != nil))
func sampleSearches() async throws {
guard let path = ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_FILE"] else { return }
let sources = try BookSourceImporter().decode(Data(contentsOf: URL(fileURLWithPath: path)))
let transport = ProbeTransport()
let service = BookSearchService(transport: transport, networkPolicy: .init(), history: .init())
let keyword = ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_KEYWORD"] ?? "斗罗大陆"
let page = Int(ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_PAGE"] ?? "1") ?? 1
var rows: [[String: JSONValue]] = []
for await outcome in service.searchOutcomes(keyword: keyword, page: page, sources: sources, maximumConcurrentSources: 3, includeDisabled: true) {
let source = try #require(sources.first { $0.id == outcome.sourceID })
let row: [String: JSONValue] = [
"name": .string(source.bookSourceName), "source": .string(source.id),
"count": .number(Double(outcome.books.count)),
"books": .array(outcome.books.prefix(3).map { .object([
"name": .string($0.name), "url": .string($0.bookURL), "author": .string($0.author),
"cover": .string($0.coverURL ?? ""), "wordCount": .string($0.wordCount ?? "")
]) }),
"diagnostics": .array(outcome.diagnostics.map { .object(["code": .string($0.code), "message": .string($0.message), "rule": .string($0.rule)]) })
]
rows.append(row)
print("SOURCE_PROBE " + JSONValue.object(row).stringValue)
}
let report = JSONValue.object(["keyword": .string(keyword), "page": .number(Double(page)), "outcomes": .array(rows.map(JSONValue.object)), "requests": .array(await transport.records())])
if let output = ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_OUTPUT"] {
try JSONEncoder().encode(report).write(to: URL(fileURLWithPath: output))
}
#expect(rows.count == Set(sources.map(\.id)).count)
}
}
private actor ProbeTransport: HTTPTransport {
private var values: [JSONValue] = []
private let transport = URLSessionHTTPTransport()
func data(for request: URLRequest) async throws -> HTTPResponse {
do {
let response = try await transport.data(for: request)
var record: [String: JSONValue] = [
"url": .string(request.url?.absoluteString ?? ""),
"finalURL": .string(response.finalURL.absoluteString),
"status": .number(Double(response.statusCode)),
"bytes": .number(Double(response.data.count)),
"preview": .string(String(response.decodedText.prefix(240)))
]
if let directory = ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_SNAPSHOT_DIR"] {
let folder = URL(fileURLWithPath: directory, isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
let file = folder.appendingPathComponent(UUID().uuidString + ".response")
try response.data.write(to: file)
record["snapshot"] = .string(file.path)
}
values.append(.object(record))
return response
} catch {
values.append(.object(["url": .string(request.url?.absoluteString ?? ""), "error": .string(error.localizedDescription)]))
throw error
}
}
func records() -> [JSONValue] { values }
}
+193
View File
@@ -0,0 +1,193 @@
import Foundation
import Testing
import ZIPFoundation
@testable import LegadoCore
struct EPUBBookTests {
private let service = LocalBookService()
private let png = Data(base64Encoded: "iVBORw0KGgoAAAANSUhEUgAAAEAAAABgCAIAAAAip+O/AAABxklEQVR4nNXCA3YjAABF0becsW12bNdIGptt7Nq2bXeMjc06/j2XI8UG6RwtMUrnWGmtdI6XmaRzotwsnZMVFumcqrRK53SVTTpnqu3SOVvjkM45g1M6540u6VyodUvnoskjnUtmr3QuW3zSuWL1S+eqLSCda/agdK47QtK54QxL56arTjq33PXSue2JSOeONyqdu76YdO7549K5H0hI50EwKZ2HoZR0isJp6Tyqy0jncX1WOk8iOek8jeal8yxWkM7zeIN0XiQapfMy2SSdV6lm6bxOt0jnTaZVOm+zbdJ5l2uXzvt8h3Q+FDql87GhSzqfGrul87mpRzpfmnulU9zSJ52S1n7plLYNSKesfVA65R1D0qnoHJZOZdeIdKq6R6VT3TMmnZrecekY+iakY+yflE7twJR0TIPT0jEPzUjHMjwrHevInHRso/PSsY8tSMcxvigd58SSdFyTy9JxT61IxzO9Kh3vzJp0fLPr0vHPbUgnML8pneDClnRCi9vSCS/tSKdueVc69St70oms7ksnunYgndj6oXTiG1+lk9j8Jp3k1nfppLZ/SCe981M6md1f0snu/ZZObv+PdPIHf6VTOPwn/T/zbe3/96qaXAAAAABJRU5ErkJggg==")!
private func archive(_ files: [(String, Data)]) throws -> URL {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appendingPathComponent("示例.EPUB")
let archive = try Archive(url: url, accessMode: .create)
for (path, data) in files {
try archive.addEntry(with: path, type: .file, uncompressedSize: Int64(data.count), compressionMethod: path == "mimetype" ? .none : .deflate) { position, size in
data.subdata(in: Int(position)..<Int(position) + size)
}
}
return url
}
private func fixture(epub3: Bool = true, navigation: Bool = true, extras: [(String, Data)] = [], chapter: String? = nil) throws -> URL {
let navItem = epub3
? #"<item id="nav" href="目录/nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>"#
: #"<item id="nav" href="目录/toc.ncx" media-type="application/x-dtbncx+xml"/>"#
let package = """
<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="\(epub3 ? "3.0" : "2.0")">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>测试书籍</dc:title><dc:creator>作者甲</dc:creator><dc:creator>作者乙</dc:creator>
<dc:description>&lt;p&gt;测试简介 &amp;amp; 内容&lt;/p&gt;</dc:description>
<meta name="cover" content="cover"/>
</metadata>
<manifest>
<item id="last" href="Text/end.xhtml" media-type="application/xhtml+xml"/>
\(navigation ? navItem : "")
<item id="main" href="Text/中文%20章节.xhtml" media-type="application/xhtml+xml"/>
<item id="intro" href="Text/intro.xhtml" media-type="application/xhtml+xml"/>
<item id="cover" href="Images/封面.png" media-type="image/png" properties="cover-image"/>
<item id="extra" href="Text/extra.xhtml" media-type="application/xhtml+xml"/>
</manifest>
<spine toc="nav"><itemref idref="intro"/><itemref idref="main"/><itemref idref="extra"/><itemref idref="last"/></spine>
</package>
"""
let body = chapter ?? """
<html><head><title>正文</title><style>不要显示样式</style></head><body>
<p>锚点前的引言</p><section><h1 id="one">第一章</h1><p>甲 &amp; 乙<em>相遇</em>。</p>
<script>不要显示脚本</script><p style="display: none">隐藏内容</p>
<p><ruby>字<rt>zi</rt><rp>(zi)</rp></ruby>形</p><img src="../Images/封面.png" alt="风景"/></section>
<section><div><h1 id="two">第二章</h1></div><p>第二章正文</p></section>
</body></html>
"""
let nav = """
<html xmlns:epub="http://www.idpf.org/2007/ops"><body>
<nav epub:type="landmarks"><a href="../Text/end.xhtml">错误地标</a></nav>
<nav epub:type="toc"><ol><li><span>第一卷</span><ol>
<li><a href="../Text/中文%20章节.xhtml#one">第一章</a></li>
<li><a href="../Text/中文%20章节.xhtml#two">第二章</a></li></ol></li>
<li><a href="../Text/end.xhtml">尾声</a></li></ol></nav></body></html>
"""
let ncx = """
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/"><navMap>
<navPoint><navLabel><text>第一卷</text></navLabel>
<navPoint><navLabel><text>第一章</text></navLabel><content src="../Text/中文%20章节.xhtml#one"/></navPoint>
<navPoint><navLabel><text>第二章</text></navLabel><content src="../Text/中文%20章节.xhtml#two"/></navPoint>
</navPoint><navPoint><navLabel><text>尾声</text></navLabel><content src="../Text/end.xhtml"/></navPoint>
</navMap></ncx>
"""
var files: [(String, Data)] = [
("mimetype", Data("application/epub+zip".utf8)),
("META-INF/container.xml", Data(#"<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OPS/book.opf" media-type="application/oebps-package+xml"/></rootfiles></container>"#.utf8)),
("OPS/book.opf", Data(package.utf8)),
("OPS/Text/intro.xhtml", Data("<html><head><title>序言</title></head><body><p>开篇内容</p></body></html>".utf8)),
("OPS/Text/中文 章节.xhtml", Data(body.utf8)),
("OPS/Text/extra.xhtml", Data("<html><body><h1>未列入目录</h1><p>不应遗漏正文</p></body></html>".utf8)),
("OPS/Text/end.xhtml", Data("<html><body><p>完结正文</p></body></html>".utf8)),
("OPS/Images/封面.png", png)
]
if navigation { files.append((epub3 ? "OPS/目录/nav.xhtml" : "OPS/目录/toc.ncx", Data((epub3 ? nav : ncx).utf8))) }
return try archive(files + extras)
}
@Test(arguments: [true, false]) func readsEPUB2And3MetadataSpineAnchorsAndIllustrations(epub3: Bool) throws {
let url = try fixture(epub3: epub3)
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let report = try service.openFile(at: url)
#expect(report.book.name == "测试书籍")
#expect(report.book.author == "作者甲、作者乙")
#expect(report.book.intro == "测试简介 & 内容")
#expect(report.book.coverUrl?.hasPrefix("data:image/png;base64,") == true)
#expect(report.book.readConfig?.useReplaceRule == false)
#expect(!report.book.canUpdate)
#expect(report.chapters.map(\.title) == ["序言", "正文", "第一章", "第二章", "未列入目录", "尾声"])
#expect(report.chapters.map(\.index) == Array(0..<6))
#expect(Set(report.chapters.map(\.id)).count == 6)
#expect(try service.loadChapters(book: report.book).chapters == report.chapters)
let contents = try report.chapters.map { try service.loadContent(book: report.book, chapter: $0).content }
#expect(contents[0] == "开篇内容")
#expect(contents[1] == "锚点前的引言")
#expect(contents[2] == "第一章\n甲 & 乙相遇。\n字形\n【插图 1:风景】")
#expect(contents[3] == "第二章\n第二章正文")
#expect(contents[4].contains("不应遗漏正文"))
#expect(contents[5] == "完结正文")
let images = try service.loadIllustrations(book: report.book, chapter: report.chapters[2])
#expect(images.count == 1 && images.first?.data == png && images.first?.caption == "风景")
#expect(try service.loadIllustrations(book: report.book, chapter: report.chapters[3]).isEmpty)
}
@Test func fallsBackToSpineWithoutNavigationAndDoesNotUseManifestOrder() throws {
let url = try fixture(navigation: false)
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let report = try service.openFile(at: url)
#expect(report.chapters.count == 4)
#expect(report.chapters.first?.title == "序言")
let body = try service.loadContent(book: report.book, chapter: report.chapters[1]).content
#expect(body.contains("锚点前的引言") && body.contains("第一章") && body.contains("第二章正文"))
}
@Test func importsCopyPreservingMetadataAndOriginalFile() throws {
let url = try fixture()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let report = try service.importFile(at: url, into: url.deletingLastPathComponent().appendingPathComponent("managed"))
let destination = try #require(URL(string: report.book.bookUrl))
#expect(destination != url && destination.pathExtension == "epub")
#expect(report.book.originName == "示例.EPUB")
#expect(try Data(contentsOf: destination) == Data(contentsOf: url))
#expect(try service.loadContent(book: report.book, chapter: report.chapters[2]).content.contains("甲 & 乙"))
}
@Test func fileManagementPreservesEPUBOnRenameExportAndRemoval() async throws {
let url = try fixture()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let root = url.deletingLastPathComponent()
let files = LocalFileService(directory: root.appendingPathComponent("managed"))
let first = try await files.importFile(from: url)
let second = try await files.importFile(from: url)
#expect(second.lastPathComponent == "示例 (2).EPUB")
let renamed = try await files.rename(first, to: "新名字")
#expect(renamed.pathExtension == "EPUB")
#expect(try await files.list().count == 2)
let exporter = BookFileExporter(directory: root.appendingPathComponent("exports"))
let export = try await exporter.originalFile(renamed, filename: "测试书籍.epub")
#expect(export.url.lastPathComponent == "测试书籍.epub")
#expect(try Data(contentsOf: export.url) == Data(contentsOf: url))
let staged = try #require(try await files.stageRemoval(renamed))
try await files.restore(staged)
#expect(try service.openFile(at: renamed).book.name == "测试书籍")
let removed = try #require(try await files.stageRemoval(renamed))
try await files.finishRemoval(removed)
#expect(try await files.list().count == 1)
}
@Test func rejectsCorruptionTraversalEncryptionAndOversizeWithoutLeavingImportedFiles() throws {
let good = try fixture()
defer { try? FileManager.default.removeItem(at: good.deletingLastPathComponent()) }
#expect(throws: LocalBookError.self) { try LocalBookService(maximumFileSize: 1).openFile(at: good) }
let broken = good.deletingLastPathComponent().appendingPathComponent("损坏.epub")
try Data("不是压缩包".utf8).write(to: broken)
let managed = good.deletingLastPathComponent().appendingPathComponent("managed")
#expect(throws: EPUBError.self) { try service.importFile(at: broken, into: managed) }
#expect(try FileManager.default.contentsOfDirectory(atPath: managed.path).isEmpty)
let malicious = try fixture(extras: [("../escape", Data("outside".utf8))])
defer { try? FileManager.default.removeItem(at: malicious.deletingLastPathComponent()) }
#expect(throws: EPUBError.self) { try service.openFile(at: malicious) }
let encrypted = try fixture(extras: [("META-INF/encryption.xml", Data(#"<encryption><EncryptedData><CipherData><CipherReference URI="OPS/Text/中文%20章节.xhtml"/></CipherData></EncryptedData></encryption>"#.utf8))])
defer { try? FileManager.default.removeItem(at: encrypted.deletingLastPathComponent()) }
#expect(throws: EPUBError.self) { try service.openFile(at: encrypted) }
let fontOnly = try fixture(extras: [("META-INF/encryption.xml", Data(#"<encryption><EncryptedData><CipherData><CipherReference URI="OPS/Fonts/font.otf"/></CipherData></EncryptedData></encryption>"#.utf8))])
defer { try? FileManager.default.removeItem(at: fontOnly.deletingLastPathComponent()) }
#expect(try service.openFile(at: fontOnly).chapters.count == 6)
}
@Test func rejectsForgedChapterResourceAndMissingAnchors() throws {
let url = try fixture()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let report = try service.openFile(at: url)
var chapter = report.chapters[2]
chapter.resourceUrl = "../../outside"
#expect(throws: EPUBError.self) { try service.loadContent(book: report.book, chapter: chapter) }
chapter = report.chapters[2]
chapter.startFragmentId = "missing"
#expect(throws: EPUBError.self) { try service.loadContent(book: report.book, chapter: chapter) }
}
@Test func ignoresBadNavigationAnchorsAndOrdersValidAnchorsByDocumentPosition() throws {
let url = try fixture(chapter: "<html><body><h1 id='two'>第二章</h1><p>先读</p><h1 id='one'>第一章</h1><p>后读</p></body></html>")
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let report = try service.openFile(at: url)
#expect(report.chapters.map(\.title) == ["序言", "第二章", "第一章", "未列入目录", "尾声"])
#expect(try service.loadContent(book: report.book, chapter: report.chapters[1]).content == "第二章\n先读")
}
}
@@ -393,6 +393,20 @@ struct LegadoDatabaseTests {
#expect(report.result.nextURL == "https://rss.example/list?page=1")
}
@Test func rssJSONListWithoutWildcardExtractsIndividualArticles() async throws {
var source = RssSource(sourceUrl: "https://rss.example", sourceName: "JSON 订阅")
source.ruleArticles = "$.items"
source.ruleTitle = "$.title"
source.ruleLink = "$.link"
let report = try await RSSRuleParser().parse(
sortName: "全部", sortURL: "https://rss.example/feed", redirectURL: "https://rss.example/feed",
body: #"{"items":[{"title":"第一篇","link":"/one"},{"title":"第二篇","link":"/two"}]}"#,
source: source
)
#expect(report.result.articles.map(\.title) == ["第一篇", "第二篇"])
#expect(report.result.articles.map(\.link) == ["https://rss.example/one", "https://rss.example/two"])
}
@Test func rssSourceImporterSupportsObjectArrayManifestAndUpdateSelection() throws {
let importer = RSSSourceImporter()
let object = try importer.decode(Data(#"{"sourceUrl":"https://one","sourceName":"一"}"#.utf8))
@@ -440,7 +454,7 @@ struct LegadoDatabaseTests {
@Test func rssHTMLAndResourcePoliciesApplyRulesInOrder() {
let processor = RSSHTMLProcessor()
#expect(processor.contentHTML("<head></head><body>x</body>", style: "body{color:red}").contains("<style>body{color:red}</style></head>"))
#expect(processor.contentHTML("<p>x</p>", style: nil).hasPrefix("<style>\(RSSHTMLProcessor.defaultStyle)</style>"))
#expect(processor.contentHTML("<p>x</p>", style: nil).hasPrefix("<style>\(RSSHTMLProcessor.appearanceStyle)</style><style>\(RSSHTMLProcessor.defaultStyle)</style>"))
var source = RssSource(sourceUrl: "https://rss", sourceName: "源")
source.startJs = "window.ready=true"; source.startStyle = "p{font-size:20px}"
let start = processor.startHTML("<body><p>x</p></body>", source: source)
@@ -0,0 +1,141 @@
import Foundation
import Testing
@testable import LegadoCore
struct ReplaceRuleExecutionTests {
private func process(_ content: String, rules: [ReplaceRule], title: String = "第十二章") -> ContentProcessingReport {
let book = Book(bookUrl: "book", name: "测试书", origin: "https://source.test")
let chapter = BookChapter(url: "chapter", title: title, bookUrl: book.bookUrl, index: 11)
return ContentSanitizer().process(book: book, chapter: chapter, content: content, rules: rules,
removeDuplicateTitle: false, resegment: false)
}
@Test func evaluatesJavaScriptForEveryMatchIncludingZeroWidthMatches() {
let width = ReplaceRule(id: 1, name: "全角", pattern: "[A-Z0-9]",
replacement: "@js:String.fromCharCode(result.charCodeAt(0) - 65248)")
var punctuation = ReplaceRule(id: 2, name: "标点", pattern: #"(?<=说道)(?=“)|\.{3,}"#,
replacement: #"@js:if (result === '') { ':' } else { '…' }"#)
punctuation.sortOrder = 1
let result = process("ABC123\n他说道“等待...”", rules: [punctuation, width])
#expect(result.content == "ABC123\n他说道:“等待…”")
#expect(result.effectiveRuleIDs == [1, 2])
#expect(result.diagnostics.isEmpty)
}
@Test func bindsBookAndChapterAndSharesOnlyJavaVariablesBetweenMatches() {
let rule = ReplaceRule(id: 1, name: "上下文", pattern: "[ab]", replacement: """
@js:n = typeof n === 'undefined' ? 1 : n + 1;
java.put('count', String(Number(java.get('count') || 0) + 1));
[book.name, book.getName(), chapter.title, chapter.getIndex(), result, n, java.get('count')].join(':')
""")
let report = process("a b", rules: [rule])
#expect(report.content == "测试书:测试书:第十二章:11:a:1:1 测试书:测试书:第十二章:11:b:1:2")
#expect(report.diagnostics.isEmpty)
// A new application of the same rule must not inherit another chapter's variables.
#expect(process("a", rules: [rule]).content == "测试书:测试书:第十二章:11:a:1:1")
}
@Test(arguments: [
(#"'$1'"#, "x"),
(#"String.fromCharCode(92) + 'n'"#, #"\n"#),
("null", "null"),
("undefined", "null"),
("['a', 'b']", "a,b"),
("123", "123.0"),
("false", "false")
])
func convertsScriptCompletionAndPreservesReplacementEscaping(_ sample: (String, String)) {
let rule = ReplaceRule(id: 1, name: "返回值", pattern: "(x)", replacement: "@js:" + sample.0)
let report = process("x", rules: [rule])
#expect(report.content == sample.1)
#expect(report.diagnostics.isEmpty)
}
@Test func failedScriptDiscardsAllPartialReplacementsAndLaterRulesStillRun() {
let failing = ReplaceRule(id: 1, name: "失败", pattern: "[ab]",
replacement: "@js:if (result === 'b') { throw new Error('bad match') }; 'changed'")
var following = ReplaceRule(id: 2, name: "后续", pattern: "tail", replacement: "后续成功")
following.sortOrder = 1
let report = process("ab tail", rules: [failing, following])
#expect(report.content == "ab 后续成功")
#expect(report.effectiveRuleIDs == [2])
#expect(report.diagnostics.count == 1)
#expect(report.diagnostics.first?.code == "rule.malformed")
}
@Test func skipsDisabledExcludedAndUnmatchedScriptsAndKeepsLiteralReplacementsLiteral() {
var excluded = ReplaceRule(id: 1, name: "排除", pattern: "x", replacement: "@js:throw new Error('excluded')")
excluded.excludeScope = "测试书"
var disabled = excluded
disabled.id = 2
disabled.excludeScope = nil
disabled.isEnabled = false
let unmatched = ReplaceRule(id: 3, name: "未匹配", pattern: "z", replacement: "@js:throw new Error('unmatched')")
var literal = ReplaceRule(id: 4, name: "普通文本", pattern: "x", replacement: "@js:'literal'")
literal.isRegex = false
let report = process("x", rules: [excluded, disabled, unmatched, literal])
#expect(report.content == "@js:'literal'")
#expect(report.diagnostics.isEmpty)
}
@Test func preservesLastNonblankTitleButAllowsDeletingBody() {
var rename = ReplaceRule(id: 1, name: "标题", pattern: "十二", replacement: "12")
rename.scopeTitle = true
rename.scopeContent = false
var erase = ReplaceRule(id: 2, name: "空白", pattern: "^.+$", replacement: "@js:'  \\n'")
erase.scopeTitle = true
erase.sortOrder = 1
let report = process("正文", rules: [erase, rename])
#expect(report.title == "第12章")
#expect(report.content == "  \n")
#expect(report.effectiveRuleIDs == [1, 2])
erase.replacement = ""
erase.scopeContent = false
let titleOnly = process("正文", rules: [erase])
#expect(titleOnly.title == "第十二章")
#expect(titleOnly.effectiveRuleIDs.isEmpty)
}
@Test(arguments: [
(#"(?:\n[^\n]+){0,15}$"#, "正文\nPS:说明\n感谢支持", "正文"),
(#"^(?:[^\n\w]\w+){0,2}"#, ",第一章,开始\n正文", "\n正文"),
(#""((?:[\w,。]|‘\w+’|\b[%\+\-])+)["]"#, ""你好,世界。"", "“你好,世界。”"),
(#"(?:[+*]|\\)+"#, #"+*\"#, "")
])
func acceptsBoundedDelimitedAndEscapedQuantifiers(_ sample: (String, String, String)) throws {
let replacement = sample.0.hasPrefix(""") ? "“$1”" : ""
#expect(try RegexEvaluator().replacingStrict(pattern: sample.0, in: sample.1, with: replacement) == sample.2)
}
@Test(arguments: ["(a+)+$", #"(\w*)*"#, "(?:[ab]+){2,}"])
func retainsChecksForClearlyNestedUnboundedRepetition(_ pattern: String) {
#expect(throws: RuleEngineError.self) {
try RegexEvaluator().replacingStrict(pattern: pattern, in: "aaa!", with: "changed")
}
}
@Test func dynamicReplacementRespectsMatchOutputAndWholeOperationTimeLimits() throws {
let limited = RegexEvaluator(limits: .init(maximumInputLength: 4, maximumMatches: 2))
#expect(throws: RuleEngineError.self) {
try limited.replacingStrict(pattern: "a", in: "aaa", replacement: { _ in "b" })
}
#expect(try limited.replacingStrict(pattern: "a", in: "aa", replacement: { _ in "b" }) == "bb")
// Include untouched tail in the output limit, not only replaced spans.
#expect(throws: RuleEngineError.self) {
try limited.replacingStrict(pattern: "a", in: "abcd", replacement: { _ in "1234567" })
}
#expect(throws: RuleEngineError.self) {
try limited.replacingStrict(pattern: "a", in: "a", timeoutMilliseconds: 1, replacement: { _ in
Thread.sleep(forTimeInterval: 0.02)
return "b"
})
}
}
@Test func rejectsKnownUnboundedJavaScriptAndReportsErrorWithoutChangingInput() {
let rule = ReplaceRule(id: 1, name: "循环", pattern: "x", replacement: "@js:while(true){}")
let report = process("x", rules: [rule])
#expect(report.content == "x")
#expect(report.diagnostics.first?.code == "rule.resource_limit")
}
}
@@ -0,0 +1,89 @@
import Foundation
import Testing
@testable import LegadoCore
// The payloads and host below are synthetic and never use network access.
struct SampledRequestRegressionTests {
@Test func equalsSeparatedSourceHeaderReachesSearchTransport() async throws {
var source = source()
source.header = #"{"type"="app"}"#
source.searchUrl = #"/search,{"method":"POST","body":"keyword={{key}}&page={{page-1}}"}"#
let transport = SampledRequestTransport()
let result = await BookSearchService(transport: transport).search(keyword: "斗罗大陆", page: 1, source: source)
#expect(result.diagnostics.isEmpty)
#expect(result.books.count == 1)
let request = try #require(await transport.requests().first)
#expect(request.value(forHTTPHeaderField: "type") == "app")
#expect(request.httpBody.map { String(decoding: $0, as: UTF8.self) } == "keyword=%E6%96%97%E7%BD%97%E5%A4%A7%E9%99%86&page=0")
}
@Test func stringEncodedLenientHeadersReachSearchTransport() async throws {
var source = source()
source.searchUrl = #"/search?word={{key}},{"headers":"{os:'pc'}"}"#
let transport = SampledRequestTransport()
let result = await BookSearchService(transport: transport).search(keyword: "斗罗大陆", page: 1, source: source)
#expect(result.diagnostics.isEmpty)
#expect(result.books.count == 1)
let request = try #require(await transport.requests().first)
#expect(request.value(forHTTPHeaderField: "os") == "pc")
}
@Test func unquotedKeywordInObjectBodyPreservesJSONValueTypes() async throws {
var source = source()
source.searchUrl = #"/search,{"method":"POST","body":{"keyword":{{key}},"page_index":{{page}},"page_size":20,"enabled":true,"cursor":null}}"#
let transport = SampledRequestTransport()
let result = await BookSearchService(transport: transport).search(keyword: "斗罗大陆", page: 1, source: source)
#expect(result.diagnostics.isEmpty)
#expect(result.books.count == 1)
let request = try #require(await transport.requests().first)
let body = try JSONValue.decode(data: #require(request.httpBody))
#expect(body["keyword"] == .string("斗罗大陆"))
#expect(body["page_index"] == .number(1))
#expect(body["page_size"] == .number(20))
#expect(body["enabled"] == .bool(true))
#expect(body["cursor"] == .null)
#expect(request.value(forHTTPHeaderField: "Content-Type")?.hasPrefix("application/json") == true)
}
@Test func lenientSeparatorsDoNotRewriteQuotedCookieOrURLValues() throws {
let value = try LegadoURLBuilder.decodeLenientJSON(#"{type=>app, 'Cookie':'token=a=b; mode=pc', referer:'https://fixture.invalid/?a=b', retry:2, enabled:false, empty:null}"#)
#expect(value["type"] == .string("app"))
#expect(value["Cookie"] == .string("token=a=b; mode=pc"))
#expect(value["referer"] == .string("https://fixture.invalid/?a=b"))
#expect(value["retry"] == .number(2))
#expect(value["enabled"] == .bool(false))
#expect(value["empty"] == .null)
}
@Test func unquotedWebViewFlagUsedByAudioSourceParses() throws {
let request = try LegadoURLBuilder().prepare("https://request.fixture.invalid/audio,{webView:“true”}")
#expect(request.useWebView)
}
@Test func lenientValuesKeepCommentsAndPutRuleExpressionsWorking() throws {
let options = try LegadoURLBuilder.decodeLenientJSON("{retry: 2 /* comment */, enabled: FALSE, word: 小说}")
#expect(options["retry"] == .number(2))
#expect(options["enabled"] == .bool(false))
#expect(options["word"] == .string("小说"))
let put = try LegadoURLBuilder.decodeLenientJSON("{id:$.books[0].id, name:name}", quoteUnquotedValues: true)
#expect(put["id"] == .string("$.books[0].id"))
#expect(put["name"] == .string("name"))
#expect(throws: (any Error).self) { try LegadoURLBuilder.decodeLenientJSON("{broken:}") }
}
private func source() -> BookSource {
var source = BookSource(bookSourceUrl: "https://request.fixture.invalid", bookSourceName: "请求兼容测试")
source.ruleSearch = .init(bookList: "$.books", name: "$.name", bookUrl: "$.url")
return source
}
}
private actor SampledRequestTransport: HTTPTransport {
private var values: [URLRequest] = []
func data(for request: URLRequest) async throws -> HTTPResponse {
values.append(request)
return .init(data: Data(#"{"books":[{"name":"测试书","url":"/book/42"}]}"#.utf8),
statusCode: 200, headers: [:], finalURL: try #require(request.url))
}
func requests() -> [URLRequest] { values }
}
@@ -0,0 +1,104 @@
import Foundation
import Testing
@testable import LegadoCore
// All responses and hosts are synthetic; these tests never contact source websites.
struct SampledSourceRegressionTests {
@Test func JavaScriptNumbersZeroAndOneRemainNumeric() throws {
let evaluator = JavaScriptRuleEvaluator()
let value = try evaluator.evaluate(script: "({zero: 0, one: 1, yes: true, no: false, list: [0, 1, true]})", result: .null, executionContext: .init()).value
#expect(value["zero"] == .number(0))
#expect(value["one"] == .number(1))
#expect(value["yes"] == .bool(true))
#expect(value["no"] == .bool(false))
#expect(value["list"] == .array([.number(0), .number(1), .bool(true)]))
}
@Test func JavaScriptGetStringUsesTheSameJSONTemplateAndReplacementSemantics() async throws {
let input = try JSONValue.decode(data: Data(#"{"id":42,"name":"测试小说","alternate":"备用小说"}"#.utf8))
let value = await RuleEngine().evaluate("@js:java.getString('/book/{$.id}') + ':' + java.getString('$.name||$.alternate##小说')", in: input)
#expect(value.diagnostics.isEmpty)
#expect(value.strings == ["/book/42:测试"])
}
@Test func legacyJSONTemplatesKeepEachSearchBookURLDistinct() async throws {
var source = BookSource(bookSourceUrl: "https://fixture.invalid", bookSourceName: "旧式 JSON 模板")
source.searchUrl = "/search?word={{key}}"
source.ruleSearch = .init(bookList: "data.bookList", name: "name", bookUrl: "/pc/book/{$.id}/catalog")
let outcome = await BookSearchService(transport: SampledResponseTransport(body:
#"{"data":{"bookList":[{"id":42,"name":"测试甲"},{"id":43,"name":"测试乙"}]}}"#
)).search(keyword: "测试", page: 1, source: source)
#expect(outcome.diagnostics.isEmpty)
#expect(outcome.books.map(\.bookURL) == ["https://fixture.invalid/pc/book/42/catalog", "https://fixture.invalid/pc/book/43/catalog"])
}
@Test func legacyJSONRootWildcardAndRecursiveURLTemplate() async throws {
var source = BookSource(bookSourceUrl: "https://fixture.invalid", bookSourceName: "旧式 JSON 列表")
source.searchUrl = "/search?word={{key}}"
source.ruleSearch = .init(bookList: "$.[*]", name: "$..name", bookUrl: "https://fixture.invalid{$..url}")
let outcome = await BookSearchService(transport: SampledResponseTransport(body:
#"[{"name":"测试甲","url":"/book/42"},{"name":"测试乙","url":"/book/43"}]"#
)).search(keyword: "测试", page: 1, source: source)
#expect(outcome.diagnostics.isEmpty)
#expect(outcome.books.map(\.bookURL) == ["https://fixture.invalid/book/42", "https://fixture.invalid/book/43"])
}
@Test func legacyJSONTemplatesRespectQuotedBracesAndReplacement() async throws {
let input = try JSONValue.decode(data: Data(#"{"id":42,"words":1200,"meta":{"label}":"玄幻"},"tags":["甲","乙"]}"#.utf8))
let value = await RuleEngine().evaluate(#"{$.meta['label}']}/{$.words}字/{$.tags[*]}##1200##1.2千"#, in: input)
#expect(value.diagnostics.isEmpty)
#expect(value.strings == ["玄幻/1.2千字/甲\n乙"])
let fromText = await RuleEngine().evaluate("/book/{$.id}", inText: input.stringValue)
#expect(fromText.diagnostics.isEmpty)
#expect(fromText.strings == ["/book/42"])
}
@Test func JSONFallbackSkipsEmptyArraysAndEmptyNames() async throws {
let input = try JSONValue.decode(data: Data(#"{"empty":[],"books":[{"name":"备用书"}],"name":"","alternate":"备用书","zero":0}"#.utf8))
for useText in [false, true] {
let list = if useText {
await RuleEngine().evaluate("$.empty||$.books", inText: input.stringValue, listMode: true)
} else {
await RuleEngine().evaluate("$.empty||$.books", in: input, listMode: true)
}
#expect(list.diagnostics.isEmpty)
#expect(list.listItems(rule: "$.empty||$.books").count == 1)
let name = await RuleEngine().evaluate("$.name||$.alternate", in: input)
#expect(name.strings == ["备用书"])
let zero = await RuleEngine().evaluate("$.zero||$.alternate", in: input)
#expect(zero.strings == ["0"])
}
}
@Test func replacementAppliesToTheCombinedSelectorResult() async throws {
// Search names in the sampled fallback sources and multi-part metadata
// use one trailing ## expression for the entire selector combination.
let input = try JSONValue.decode(data: Data(#"{"name":"测试小说","fallback":"备用小说","a":"A1","b":"B2"}"#.utf8))
let fallback = await RuleEngine().evaluate("$.name||$.fallback##小说", in: input)
#expect(fallback.strings == ["测试"])
let combined = await RuleEngine().evaluate("$.a&&$.b##[0-9]", in: input)
#expect(combined.strings.joined(separator: "\n") == "A\nB")
let anchored = await RuleEngine().evaluate(#"$.a&&$.b##^A1\nB2$##整段"#, in: input)
#expect(anchored.strings == ["整段"])
let list = await RuleEngine().evaluate("$.a&&$.b##[0-9]", in: input, listMode: true)
#expect(list.strings == ["A", "B"])
let html = await RuleEngine().evaluate("h1@text||h2@text##小说", inText: "<h1>测试小说</h1>")
#expect(html.strings == ["测试"])
}
@Test func replacementTextIsNotParsedAsSelectorSyntax() async {
let result = await RuleEngine().evaluate(#"h1@text##["']|[()]"#, inText: "<h1>书名(测试)'</h1>")
#expect(result.diagnostics.isEmpty)
#expect(result.strings == ["书名测试"])
let replacement = await RuleEngine().evaluate("h1@text##书##A&&B", inText: "<h1>书</h1>")
#expect(replacement.diagnostics.isEmpty)
#expect(replacement.strings == ["A&&B"])
}
}
private struct SampledResponseTransport: HTTPTransport {
let body: String
func data(for request: URLRequest) async throws -> HTTPResponse {
.init(data: Data(body.utf8), statusCode: 200, headers: [:], finalURL: try #require(request.url))
}
}
@@ -0,0 +1,86 @@
import SwiftUI
import UIKit
import WebKit
import XCTest
@testable import Legado
@MainActor
final class AppearanceContrastTests: XCTestCase {
func testLoginWebViewUsesAdaptiveBackgroundBeforeContentArrives() async throws {
var source = BookSource(bookSourceUrl: "https://appearance.invalid", bookSourceName: "外观测试")
source.loginUrl = "about:blank"
let controller = UIHostingController(rootView: SourceLoginView(source: source))
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
window.overrideUserInterfaceStyle = .dark
window.rootViewController = controller
window.makeKeyAndVisible()
defer { window.isHidden = true }
func webView(in view: UIView) -> WKWebView? {
if let view = view as? WKWebView { return view }
return view.subviews.lazy.compactMap { webView(in: $0) }.first
}
window.layoutIfNeeded()
for _ in 0..<20 {
if webView(in: controller.view) != nil { break }
try await Task.sleep(nanoseconds: 50_000_000)
}
let web = try XCTUnwrap(webView(in: controller.view))
XCTAssertFalse(web.isOpaque)
for style in [UIUserInterfaceStyle.dark, .light] {
window.overrideUserInterfaceStyle = style
window.layoutIfNeeded()
let traits = UITraitCollection(userInterfaceStyle: style)
let background = try XCTUnwrap(web.backgroundColor).resolvedColor(with: traits)
let scroll = try XCTUnwrap(web.scrollView.backgroundColor).resolvedColor(with: traits)
if style == .dark {
XCTAssertLessThan(luminance(background), 0.15)
XCTAssertLessThan(luminance(scroll), 0.15)
} else {
XCTAssertGreaterThan(luminance(background), 0.6)
XCTAssertGreaterThan(luminance(scroll), 0.6)
}
}
}
func testSyntaxTokensHaveReadableContrastInBaseAndElevatedAppearances() {
let samples: [(SyntaxCodeEditor.Language, String)] = [
(.json, #"{"title":"正文","count":12,"enabled":true,"empty":null}"#),
(.rule, "@css:div && $.title {{key}} ##"),
(.regex, #"\d+.*(word){2,4}"#),
(.javascript, "// comment\nconst value = 'text'; return 123; /* comment */")
]
for style in [UIUserInterfaceStyle.light, .dark] {
for level in [UIUserInterfaceLevel.base, .elevated] {
let traits = UITraitCollection(traitsFrom: [
UITraitCollection(userInterfaceStyle: style),
UITraitCollection(userInterfaceLevel: level)
])
for (language, text) in samples {
let editor = SyntaxCodeEditor(text: .constant(text), language: language)
let view = UITextView()
view.overrideUserInterfaceStyle = style
view.text = text
editor.makeCoordinator().highlight(view)
let background = UIColor.secondarySystemGroupedBackground.resolvedColor(with: traits)
view.textStorage.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: view.textStorage.length)) { color, _, _ in
guard let color = color as? UIColor else { return XCTFail("Missing syntax color") }
let foreground = color.resolvedColor(with: traits)
let values = [luminance(foreground), luminance(background)].sorted()
XCTAssertGreaterThanOrEqual((values[1] + 0.05) / (values[0] + 0.05), 4.5,
"Low contrast for \(language), \(style), \(level)")
}
}
}
}
}
private func luminance(_ color: UIColor) -> Double {
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
XCTAssertTrue(color.getRed(&r, green: &g, blue: &b, alpha: &a))
func linear(_ value: CGFloat) -> Double {
let value = Double(value)
return value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4)
}
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b)
}
}
@@ -1,8 +1,161 @@
import ImageIO
import SwiftUI
import UIKit
import XCTest
@testable import Legado
private final class BackgroundPhotoProvider: NSItemProvider, @unchecked Sendable {
let photoData: Data?
let photoError: (any Error)?
init(data: Data?, error: (any Error)? = nil) {
photoData = data
photoError = error
super.init()
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override var registeredTypeIdentifiers: [String] { ["public.png"] }
override func loadDataRepresentation(
forTypeIdentifier typeIdentifier: String,
completionHandler: @escaping @Sendable (Data?, (any Error)?) -> Void
) -> Progress {
DispatchQueue.global(qos: .userInitiated).async { [photoData, photoError] in
dispatchPrecondition(condition: .notOnQueue(.main))
completionHandler(photoData, photoError)
}
return Progress(totalUnitCount: 1)
}
}
@MainActor
final class ReadingPhotoPickerTests: XCTestCase {
func testPhotoDataReturnedOnBackgroundQueueReachesMainActor() async throws {
let expected = Data("photo-provider-data".utf8)
let provider = BackgroundPhotoProvider(data: expected)
let received = expectation(description: "Photo data delivered on main actor")
let coordinator = ReadingPhotoPickerController.Coordinator { result in
XCTAssertTrue(Thread.isMainThread)
XCTAssertEqual(try? result?.get(), expected)
received.fulfill()
}
coordinator.loadImage(from: provider)
await fulfillment(of: [received], timeout: 5)
withExtendedLifetime(coordinator) {}
}
func testPhotoProviderFailureOnBackgroundQueueReachesMainActor() async throws {
let errors: [(any Error)?] = [CocoaError(.fileReadNoPermission), nil]
for error in errors {
let provider = BackgroundPhotoProvider(data: nil, error: error)
let received = expectation(description: "Photo error delivered on main actor")
let coordinator = ReadingPhotoPickerController.Coordinator { result in
XCTAssertTrue(Thread.isMainThread)
guard case .failure(let failure)? = result else {
XCTFail("Expected an import error")
received.fulfill()
return
}
XCTAssertEqual((failure as NSError).domain, NSCocoaErrorDomain)
XCTAssertEqual((failure as NSError).code, (error as NSError?)?.code ?? NSFileReadCorruptFileError)
received.fulfill()
}
coordinator.loadImage(from: provider)
await fulfillment(of: [received], timeout: 5)
withExtendedLifetime(coordinator) {}
}
}
}
@MainActor
final class ReaderBackgroundStoreTests: XCTestCase {
func testLargeBackgroundIsDownsampledOnImportAndLegacyReload() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let target = directory.appendingPathComponent("background.jpg")
let format = UIGraphicsImageRendererFormat()
format.scale = 1
let data = UIGraphicsImageRenderer(size: CGSize(width: 4096, height: 3072), format: format).jpegData(withCompressionQuality: 0.9) { context in
UIColor.red.setFill()
context.fill(CGRect(x: 0, y: 0, width: 4096, height: 3072))
}
for _ in 0..<3 {
let imported = try ReaderBackgroundStore.save(data, to: target)
XCTAssertEqual(imported.size.width, CGFloat(ReaderBackgroundStore.maximumPixelSize))
XCTAssertEqual(imported.size.height / imported.size.width, 0.75, accuracy: 0.001)
let restored = try XCTUnwrap(ReaderBackgroundStore.load(from: target))
XCTAssertEqual(restored.size, imported.size)
}
try data.write(to: target)
let legacy = try XCTUnwrap(ReaderBackgroundStore.load(from: target))
XCTAssertEqual(legacy.size.width, CGFloat(ReaderBackgroundStore.maximumPixelSize))
}
func testCorruptReplacementPreservesExistingBackground() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let target = directory.appendingPathComponent("background.jpg")
let image = UIGraphicsImageRenderer(size: CGSize(width: 40, height: 60)).image { context in
UIColor.blue.setFill()
context.fill(CGRect(x: 0, y: 0, width: 40, height: 60))
}
_ = try ReaderBackgroundStore.save(try XCTUnwrap(image.pngData()), to: target)
let saved = try Data(contentsOf: target)
XCTAssertThrowsError(try ReaderBackgroundStore.save(Data("not an image".utf8), to: target))
XCTAssertEqual(try Data(contentsOf: target), saved)
XCTAssertNotNil(ReaderBackgroundStore.load(from: target))
}
func testBackgroundPreservesPhotoOrientation() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let image = UIGraphicsImageRenderer(size: CGSize(width: 120, height: 60)).image { context in
UIColor.green.setFill()
context.fill(CGRect(x: 0, y: 0, width: 120, height: 60))
}
let data = NSMutableData()
let destination = try XCTUnwrap(CGImageDestinationCreateWithData(data, "public.jpeg" as CFString, 1, nil))
CGImageDestinationAddImage(destination, try XCTUnwrap(image.cgImage), [kCGImagePropertyOrientation: 6] as CFDictionary)
XCTAssertTrue(CGImageDestinationFinalize(destination))
let saved = try ReaderBackgroundStore.save(data as Data, to: directory.appendingPathComponent("background.jpg"))
XCTAssertEqual(saved.imageOrientation, .up)
XCTAssertEqual(saved.size.height / saved.size.width, 2, accuracy: 0.001)
}
}
@MainActor
final class ReaderBookmarkPersistenceTests: XCTestCase {
func testBookmarkSurvivesNewActivityAndReaderInstances() async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let repository = FileReadingActivityRepository(
bookmarksURL: directory.appendingPathComponent("bookmarks.json"),
recordsURL: directory.appendingPathComponent("records.json")
)
let book = Book(bookUrl: "demo://saved-bookmark", name: "书", author: "作者")
let chapters = [BookChapter(url: "demo://chapter", title: "第一章", bookUrl: book.bookUrl, index: 0)]
let original = ReadingActivityModel(repository: repository, deviceID: "test")
await original.load()
await original.addBookmark(book: book, chapter: chapters[0], position: 40, content: String(repeating: "正文", count: 100))
XCTAssertNil(original.errorMessage)
let restored = ReadingActivityModel(repository: repository, deviceID: "test")
await restored.load()
let reader = ReaderViewModel(demoBook: book, chapters: chapters, initialIndex: 0, content: "正文")
XCTAssertEqual(reader.bookmarks(at: 40, in: restored.bookmarks).count, 1)
XCTAssertTrue(reader.bookmarks(at: 80, in: restored.bookmarks).isEmpty)
await restored.removeBookmark(try XCTUnwrap(restored.bookmarks.first))
XCTAssertTrue(reader.bookmarks(at: 40, in: restored.bookmarks).isEmpty)
}
}
@MainActor
final class ReaderScrollGeometryTests: XCTestCase {
func testPrependingAndRecyclingStayWithinOnePhysicalPixel() async throws {
@@ -93,6 +246,54 @@ final class ReaderScrollGeometryTests: XCTestCase {
XCTAssertEqual(fixture.view.contentOffset.y, layout.frames[row].minY, accuracy: 1 / fixture.window.screen.scale)
}
func testRepeatedShortChapterPrependingDoesNotAccumulateOffsetDrift() async throws {
func short(_ index: Int) -> ReaderScrollChapter {
.init(id: index, title: "第\(index + 1)章 · 连续阅读", content: "短章正文,继续向下可以自然读到下一章。")
}
let fixture = host(chapters: (20...22).map(short), active: 20)
defer { fixture.coordinator.displayLink?.invalidate(); fixture.window.isHidden = true }
for first in stride(from: 19, through: 0, by: -1) {
fixture.coordinator.parent = surface(chapters: (first...22).map(short), active: 20)
fixture.coordinator.update(fixture.view)
try await Task.sleep(nanoseconds: 10_000_000)
}
let layout = fixture.view.collectionViewLayout as! ReaderContinuousLayout
let row = try XCTUnwrap(fixture.coordinator.blocks.firstIndex { $0.chapter.chapter.id == 20 })
XCTAssertEqual(fixture.view.contentOffset.y, layout.frames[row].minY,
accuracy: 1 / fixture.window.screen.scale)
XCTAssertTrue(fixture.view.accessibilityValue?.contains("chapter=20 ") == true,
fixture.view.accessibilityValue ?? "Missing viewport report")
}
func testFractionalPixelBoundariesBelongToTheFollowingRow() {
let layout = ReaderContinuousLayout()
let frames = (0..<100).map { CGRect(x: 0, y: CGFloat($0 * 5) / 3, width: 390, height: 5.0 / 3) }
layout.install(frames: frames, size: CGSize(width: 390, height: 500.0 / 3), correction: 0)
for index in frames.indices {
XCTAssertEqual(layout.row(at: frames[index].minY), index)
}
}
func testPrependingPreservesTheChapterBelowTopPadding() async throws {
func short(_ index: Int) -> ReaderScrollChapter {
.init(id: index, title: "短章\(index)", content: "短章正文")
}
let fixture = host(chapters: [short(20)], active: 20)
defer { fixture.coordinator.displayLink?.invalidate(); fixture.window.isHidden = true }
// A short initial chapter can settle at offset zero with its heading
// still below the reading area's top padding.
fixture.view.setContentOffset(.zero, animated: false)
fixture.coordinator.parent = surface(chapters: (19...21).map(short), active: 20)
fixture.coordinator.update(fixture.view)
try await Task.sleep(nanoseconds: 10_000_000)
let layout = fixture.view.collectionViewLayout as! ReaderContinuousLayout
let row = try XCTUnwrap(fixture.coordinator.blocks.firstIndex { $0.chapter.chapter.id == 20 })
XCTAssertEqual(layout.frames[row].minY - fixture.view.contentOffset.y, 16,
accuracy: 1 / fixture.window.screen.scale)
XCTAssertTrue(fixture.view.accessibilityValue?.contains("chapter=20 ") == true,
fixture.view.accessibilityValue ?? "Missing viewport report")
}
private func chapter(_ index: Int) -> ReaderScrollChapter {
.init(id: index, title: "第\(index)章", content: String(repeating: "正文包含中文、英文 words 与家庭表情👨‍👩‍👧‍👦,用于验证连续阅读的排版和位置。\n", count: 100))
}
@@ -116,7 +317,7 @@ final class ReaderScrollGeometryTests: XCTestCase {
.init(configuration: configuration(fontSize: fontSize), chapters: chapters,
resetID: 0, activeIndex: active, loadingAhead: false, loadingBehind: false,
onNeedChapters: { _ in }, onPositionChanged: { _, _ in },
onRetainChapters: { _ in }, onRetry: { _ in })
onRetainChapters: { _ in })
}
private func configuration(fontSize: Double = 20) -> ReaderTextView {
@@ -5,6 +5,56 @@ import XCTest
@MainActor
final class WebReaderBridgeTests: XCTestCase {
func testGeneratedRSSContentFollowsAppearanceWithoutReloading() async throws {
let source = RssSource(sourceUrl: "https://appearance-regression.test", sourceName: "测试")
let loaded = expectation(description: "HTML loaded")
let surface = RSSWebReaderView(
mode: .html(content: "<body><p>订阅正文</p><a href='#'>链接</a><button>操作</button></body>", baseURL: nil),
source: source, onTextChange: { _ in loaded.fulfill() }
)
let coordinator = surface.makeCoordinator()
let webView = WKWebView(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
webView.navigationDelegate = coordinator
let window = UIWindow(frame: webView.frame)
let controller = UIViewController()
controller.overrideUserInterfaceStyle = .dark
controller.view.addSubview(webView)
window.rootViewController = controller
window.makeKeyAndVisible()
defer {
RSSWebReaderView.dismantleUIView(webView, coordinator: coordinator)
window.isHidden = true
}
coordinator.installRulesAndLoad(mode: surface.mode, in: webView)
await fulfillment(of: [loaded], timeout: 20)
let appearanceScript = """
[matchMedia('(prefers-color-scheme:dark)').matches,
getComputedStyle(document.body).backgroundColor,
getComputedStyle(document.body).color,
getComputedStyle(document.querySelector('a')).color,
getComputedStyle(document.documentElement).colorScheme].map(String).join('|')
"""
let dark = try await webView.evaluateJavaScript(appearanceScript)
XCTAssertEqual(dark as? String, "true|rgb(17, 17, 19)|rgb(242, 242, 242)|rgb(158, 203, 255)|light dark")
_ = try await webView.evaluateJavaScript("window.appearanceMarker = 'preserved'")
controller.overrideUserInterfaceStyle = .light
window.layoutIfNeeded()
XCTAssertEqual(webView.traitCollection.userInterfaceStyle, .light)
var light = try await webView.evaluateJavaScript(appearanceScript)
for _ in 0..<50 {
if (light as? String)?.hasPrefix("false|") == true { break }
try await Task.sleep(nanoseconds: 100_000_000)
light = try await webView.evaluateJavaScript(appearanceScript)
}
XCTAssertEqual(light as? String, "false|rgb(252, 252, 252)|rgb(23, 23, 23)|rgb(0, 0, 238)|light dark")
let marker = try await webView.evaluateJavaScript("window.appearanceMarker")
XCTAssertEqual(marker as? String, "preserved")
// A source may intentionally override the generated article palette.
_ = try await webView.evaluateJavaScript("document.head.insertAdjacentHTML('beforeend', '<style>body{color:rgb(120,30,40)}</style>')")
let custom = try await webView.evaluateJavaScript("getComputedStyle(document.body).color")
XCTAssertEqual(custom as? String, "rgb(120, 30, 40)")
}
func testPageJavaScriptCanStoreVariablesAndEvaluateContentRules() async throws {
let source = RssSource(sourceUrl: "https://bridge-regression.test/\(UUID().uuidString)", sourceName: "测试")
let loaded = expectation(description: "HTML loaded")
@@ -38,3 +88,159 @@ final class WebReaderBridgeTests: XCTestCase {
XCTAssertEqual(result as? String, "已保存|规则正文")
}
}
private actor RSSPaginationTransport: HTTPTransport {
private var requests: [URL] = []
private var shouldFailSecondPage = true
func data(for request: URLRequest) async throws -> HTTPResponse {
let url = try XCTUnwrap(request.url)
requests.append(url)
if url.host == "unavailable.test" { throw URLError(.cannotConnectToHost) }
let page = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?
.first { $0.name == "page" }?.value ?? "1"
if page == "2", shouldFailSecondPage {
shouldFailSecondPage = false
throw URLError(.networkConnectionLost)
}
let body = "{\"items\":[{\"title\":\"Page \(page)\",\"link\":\"https://feed.test/article/\(page)\"}]}"
return .init(data: Data(body.utf8), statusCode: 200, headers: [:], finalURL: url)
}
func requestedPages() -> [String] {
requests.compactMap { URLComponents(url: $0, resolvingAgainstBaseURL: false)?.queryItems?.first { $0.name == "page" }?.value }
}
func requestCount() -> Int { requests.count }
}
private actor RSSDelayedTransport: HTTPTransport {
let pausedHost: String
let pausedPage: String
private var requestTotal = 0
private var pending: CheckedContinuation<Void, Never>?
init(host: String, page: String) { pausedHost = host; pausedPage = page }
func data(for request: URLRequest) async throws -> HTTPResponse {
let url = try XCTUnwrap(request.url)
let page = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?
.first { $0.name == "page" }?.value ?? "1"
requestTotal += 1
if url.host == pausedHost, page == pausedPage {
await withCheckedContinuation { continuation in
pending = continuation
}
}
let body = "{\"items\":[{\"title\":\"Page \(page)\",\"link\":\"https://feed.test/article/\(page)\"}]}"
return .init(data: Data(body.utf8), statusCode: 200, headers: [:], finalURL: url)
}
func waitUntilPaused() async throws {
for _ in 0..<500 {
if pending != nil { return }
try await Task.sleep(nanoseconds: 10_000_000)
}
throw URLError(.timedOut)
}
func resume() { pending?.resume(); pending = nil }
func requestCount() -> Int { requestTotal }
}
@MainActor
final class RSSLibraryRegressionTests: XCTestCase {
func testUnavailableDatabaseDoesNotCrashOrPublishUnsavedImports() async throws {
let blocked = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try Data("existing file".utf8).write(to: blocked)
defer { try? FileManager.default.removeItem(at: blocked) }
let model = RSSLibraryModel.live(databaseURL: blocked.appendingPathComponent("rss.sqlite3"))
XCTAssertNotNil(model.errorMessage)
await model.load()
XCTAssertNotNil(model.errorMessage)
let saved = await model.save(source(), replacing: nil)
XCTAssertFalse(saved)
await model.importText(#"{"sourceUrl":"https://feed.test","sourceName":"未保存的订阅"}"#)
XCTAssertTrue(model.sources.isEmpty)
XCTAssertFalse(model.isWorking)
XCTAssertEqual(try Data(contentsOf: blocked), Data("existing file".utf8))
}
private func source(host: String = "feed.test") -> RssSource {
var source = RssSource(sourceUrl: "https://\(host)", sourceName: host)
source.sortUrl = "https://\(host)/list?page={{page}}"
source.ruleArticles = "$.items"
source.ruleTitle = "$.title"
source.ruleLink = "$.link"
source.ruleNextPage = "PAGE"
return source
}
func testFailedNextPageRetriesTheSamePageWithoutSkippingArticles() async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let database = try LegadoDatabase(url: directory.appendingPathComponent("rss.sqlite3"))
let transport = RSSPaginationTransport()
let model = RSSLibraryModel(database: database, service: RSSService(transport: transport))
let feed = source()
await model.refresh(feed)
XCTAssertEqual(model.articles.map(\.title), ["Page 1"])
await model.loadMore(feed)
XCTAssertNotNil(model.errorMessage)
XCTAssertEqual(model.articles.map(\.title), ["Page 1"])
await model.loadMore(feed)
let pages = await transport.requestedPages()
XCTAssertEqual(pages, ["1", "2", "2"])
XCTAssertEqual(model.articles.map(\.title), ["Page 1", "Page 2"])
}
func testFailedSourceChangeDoesNotReusePreviousSourcesArticlesOrNextPage() async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let database = try LegadoDatabase(url: directory.appendingPathComponent("rss.sqlite3"))
let transport = RSSPaginationTransport()
let model = RSSLibraryModel(database: database, service: RSSService(transport: transport))
await model.refresh(source())
let unavailable = source(host: "unavailable.test")
await model.refresh(unavailable)
XCTAssertTrue(model.articles.isEmpty)
let count = await transport.requestCount()
await model.loadMore(unavailable)
let afterLoadMore = await transport.requestCount()
XCTAssertEqual(afterLoadMore, count)
}
func testSlowPreviousSourceCannotReplaceTheCurrentFeed() async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let database = try LegadoDatabase(url: directory.appendingPathComponent("rss.sqlite3"))
let transport = RSSDelayedTransport(host: "slow.test", page: "1")
let model = RSSLibraryModel(database: database, service: RSSService(transport: transport))
let slow = Task { await model.refresh(source(host: "slow.test")) }
try await transport.waitUntilPaused()
await model.refresh(source())
XCTAssertTrue(model.isWorking)
await transport.resume()
await slow.value
XCTAssertEqual(model.articles.map(\.origin), ["https://feed.test"])
XCTAssertFalse(model.isWorking)
}
func testRepeatedLoadMoreWhileWaitingRequestsOnlyOnePage() async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let database = try LegadoDatabase(url: directory.appendingPathComponent("rss.sqlite3"))
let transport = RSSDelayedTransport(host: "feed.test", page: "2")
let model = RSSLibraryModel(database: database, service: RSSService(transport: transport))
let feed = source()
await model.refresh(feed)
let pending = Task { await model.loadMore(feed) }
try await transport.waitUntilPaused()
await model.loadMore(feed)
let count = await transport.requestCount()
XCTAssertEqual(count, 2)
await transport.resume()
await pending.value
XCTAssertEqual(model.articles.map(\.title), ["Page 1", "Page 2"])
}
}
@@ -0,0 +1,329 @@
import UIKit
import XCTest
@MainActor
final class DarkModeUITests: XCTestCase {
func testPrimaryActionsKeepDarkFillInBothAppearances() throws {
continueAfterFailure = false
for theme in ["2", "1"] {
let app = launch(["-uiDemoBookDetail", "-themeMode", theme])
let read = app.buttons.matching(NSPredicate(format: "label IN %@", ["开始阅读", "继续阅读"])).firstMatch
XCTAssertTrue(read.waitForExistence(timeout: 20))
XCTAssertTrue(read.isEnabled)
try assertReadableDarkFill(read, in: app)
attach(app, name: "Book detail theme \(theme)")
app.terminate()
}
}
func testNightReaderSelectionsKeepDarkFillAndReadableLabels() throws {
continueAfterFailure = false
let app = launch([
"-uiDemoReader", "-uiDemoReaderStyle", "-themeMode", "2",
"-readerTheme", "night", "-readerUseBackgroundImage", "NO",
"-lineSpacingExtra", "12", "-pageAnim", "0"
])
XCTAssertTrue(app.navigationBars["阅读样式"].waitForExistence(timeout: 20))
// Expand the sheet so the controls can be inspected without clipping.
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.48))
.press(forDuration: 0.1, thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.12)))
let standard = app.buttons["标准"]
XCTAssertTrue(standard.waitForExistence(timeout: 5))
try assertReadableDarkFill(standard, in: app)
attach(app, name: "Night reader spacing selection")
let cover = app.buttons["覆盖"]
for _ in 0..<16 {
if cover.exists && cover.isHittable && cover.frame.maxY < app.frame.height - 50 { break }
app.swipeUp()
}
XCTAssertTrue(cover.exists && cover.isHittable)
try assertReadableDarkFill(cover, in: app)
attach(app, name: "Night reader animation selection")
}
func testDarkAppearanceAcrossTabsAndPresentedEditors() throws {
continueAfterFailure = false
let app = launch(["-themeMode", "2"])
XCTAssertTrue(app.tabBars.buttons["书架"].waitForExistence(timeout: 20))
for (tab, heading) in [("书架", "阅读,让时间有了形状"), ("发现", "下一本好书,在这里遇见"),
("统计", "每一页,都算数"), ("我的", "按照你的方式阅读")] {
app.tabBars.buttons[tab].tap()
XCTAssertTrue(app.staticTexts[heading].waitForExistence(timeout: 5))
if tab == "统计" {
let today = app.otherElements["statistics.todayCard"]
XCTAssertTrue(today.waitForExistence(timeout: 5))
try assertReadableDarkFill(today, in: app)
}
attach(app, name: "Dark tab \(tab)")
}
app.buttons["profile.globe"].tap()
XCTAssertTrue(app.navigationBars["书源"].waitForExistence(timeout: 10))
attach(app, name: "Dark source library")
app.buttons["更多"].firstMatch.tap()
app.buttons["新建书源"].tap()
XCTAssertTrue(app.navigationBars["新建书源"].waitForExistence(timeout: 10))
let basic = app.buttons["基础"]
try assertReadableDarkFill(basic, in: app)
let login = app.buttons["登录"]
login.tap()
try assertReadableDarkFill(login, in: app)
attach(app, name: "Dark source category selection")
attach(app, name: "Dark presented source editor")
app.segmentedControls.buttons["JSON"].tap()
XCTAssertTrue(app.textViews["syntaxCodeEditor"].waitForExistence(timeout: 5))
attach(app, name: "Dark JSON editor")
app.textViews["syntaxCodeEditor"].tap()
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
attach(app, name: "Dark editor keyboard and accessory toolbar")
let visibleKeyboard = app.keyboards.firstMatch.frame.intersection(app.frame)
if !visibleKeyboard.isNull && visibleKeyboard.width > 20 && visibleKeyboard.height > 20 {
let keyboard = try luminances(app.keyboards.firstMatch, in: app)
XCTAssertLessThan(keyboard[keyboard.count / 2], 0.15)
}
let accessory = app.buttons["syntaxCodeEditor.done"]
XCTAssertTrue(accessory.exists && accessory.isHittable)
try assertReadableDarkFill(accessory, in: app)
app.buttons["@"].tap()
XCTAssertTrue((app.textViews["syntaxCodeEditor"].value as? String)?.contains("@") == true)
accessory.tap()
let dismissed = expectation(for: NSPredicate { _, _ in !accessory.exists || !accessory.isHittable }, evaluatedWith: nil)
wait(for: [dismissed], timeout: 5)
}
func testDarkSettingsRulesAndManagementPages() throws {
continueAfterFailure = false
let app = launch(["-uiDemoTab", "settings", "-themeMode", "2"])
let routes: [(String, String, String?)] = [
("textformat.size", "阅读设置", nil),
("waveform", "在线朗读引擎", "新建"),
("bookmark", "书签", nil),
("clock", "阅读记录", nil),
("link", "规则订阅", "新建"),
("text.badge.checkmark", "净化规则", "新建规则"),
("list.bullet.rectangle", "TXT目录规则", "新建"),
("folder", "文件管理", nil),
("gearshape", "其他设置", nil),
("info.circle", "关于", nil)
]
for (icon, title, create) in routes {
let entry = app.buttons["profile.\(icon)"]
scrollTo(entry, in: app)
entry.tap()
XCTAssertTrue(app.navigationBars[title].waitForExistence(timeout: 10))
try captureDarkPage(app, name: "Management \(title)")
if let create {
app.buttons[create].firstMatch.tap()
XCTAssertTrue(app.buttons["取消"].waitForExistence(timeout: 5))
try captureDarkPage(app, name: "Editor \(title)")
if title == "净化规则" {
app.segmentedControls.buttons["测试规则"].tap()
try captureDarkPage(app, name: "Rule test form")
}
app.buttons["取消"].tap()
}
if title == "其他设置" {
for (entry, destination) in [("网络与缓存设置", "网络与缓存"), ("章节缓存管理", "缓存管理"), ("日志与诊断", "日志与诊断")] {
let link = app.buttons[entry]
scrollTo(link, in: app)
link.tap()
XCTAssertTrue(app.navigationBars[destination].waitForExistence(timeout: 5))
try captureDarkPage(app, name: "Settings \(destination)")
app.navigationBars.firstMatch.buttons.element(boundBy: 0).tap()
}
}
if title == "关于" {
let license = app.buttons["开源许可"]
scrollTo(license, in: app)
license.tap()
XCTAssertTrue(app.navigationBars["开源许可"].waitForExistence(timeout: 5))
try captureDarkPage(app, name: "Licenses")
app.navigationBars.firstMatch.buttons.element(boundBy: 0).tap()
}
app.navigationBars.firstMatch.buttons.element(boundBy: 0).tap()
XCTAssertTrue(app.scrollViews["profile.scroll"].waitForExistence(timeout: 5))
}
}
func testDarkSearchFiltersAndSourceImportSheets() throws {
continueAfterFailure = false
let app = launch(["-uiDemoTab", "search", "-themeMode", "2", "-uiAutomationSearch", "深色外观检查"])
let cancelSearch = app.buttons.matching(NSPredicate(format: "label IN %@", ["取消", "Cancel"])).firstMatch
if cancelSearch.waitForExistence(timeout: 3) { cancelSearch.tap() }
XCTAssertTrue(app.buttons["search.options"].waitForExistence(timeout: 10))
app.buttons["search.options"].tap()
app.buttons["分组或书源"].tap()
XCTAssertTrue(app.navigationBars["分组或书源"].waitForExistence(timeout: 5))
try captureDarkPage(app, name: "Search group filter")
app.segmentedControls.buttons["书源"].tap()
try captureDarkPage(app, name: "Search source filter")
app.buttons["取消"].tap()
app.terminate()
app.launchArguments = ["-uiDemo", "-uiDemoTab", "sources", "-themeMode", "2"]
app.launch()
XCTAssertTrue(app.navigationBars["书源"].waitForExistence(timeout: 10))
app.buttons["排序"].tap()
XCTAssertTrue(app.buttons["倒序"].waitForExistence(timeout: 5))
try assertReadableDarkFill(app.buttons["倒序"], in: app)
attach(app, name: "Source sorting menu")
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)).tap()
app.buttons["更多"].firstMatch.tap()
app.buttons["URL 或粘贴 JSON"].tap()
XCTAssertTrue(app.buttons["取消"].waitForExistence(timeout: 5))
try captureDarkPage(app, name: "Source text import")
}
func testDarkRSSAndBlackPhotoViewerInLightAppearance() throws {
continueAfterFailure = false
let app = launch(["-uiDemoTab", "rss", "-themeMode", "2"])
XCTAssertTrue(app.navigationBars["RSS"].waitForExistence(timeout: 10))
for tab in ["订阅", "收藏", "记录"] {
app.segmentedControls.buttons[tab].tap()
try captureDarkPage(app, name: "RSS \(tab)")
}
app.segmentedControls.buttons["订阅"].tap()
app.buttons["RSS操作"].tap()
app.buttons["新建RSS源"].tap()
XCTAssertTrue(app.navigationBars["新建RSS源"].waitForExistence(timeout: 5))
try captureDarkPage(app, name: "RSS source editor")
app.terminate()
app.launchArguments = ["-uiDemo", "-uiDemoTab", "rss-photo", "-themeMode", "1"]
app.launch()
XCTAssertTrue(app.alerts["图片"].waitForExistence(timeout: 10))
app.alerts["图片"].buttons["好"].tap()
let error = app.staticTexts["图片加载失败"]
XCTAssertTrue(error.waitForExistence(timeout: 5))
try assertReadableDarkFill(error, in: app)
let done = app.navigationBars.buttons["完成"]
XCTAssertTrue(done.exists && done.isEnabled)
attach(app, name: "Photo viewer navigation and content")
let toolbar = try luminances(done, in: app)
XCTAssertGreaterThanOrEqual((toolbar[toolbar.count * 98 / 100] + 0.05) / (toolbar[toolbar.count * 2 / 100] + 0.05), 4.5)
attach(app, name: "Readable photo error on black canvas")
}
func testNightReaderMenusAndSettings() throws {
continueAfterFailure = false
let app = launch(["-uiDemoReader", "-uiDemoReaderMenu", "-readerTheme", "night", "-themeMode", "2", "-readerUseBackgroundImage", "NO"])
XCTAssertTrue(app.buttons["目录"].waitForExistence(timeout: 15))
try captureDarkPage(app, name: "Night reader menu")
app.buttons["目录"].tap()
XCTAssertTrue(app.navigationBars["目录"].waitForExistence(timeout: 5))
try captureDarkPage(app, name: "Night table of contents")
app.buttons["关闭"].tap()
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
app.buttons["设置"].tap()
XCTAssertTrue(app.navigationBars["阅读设置"].waitForExistence(timeout: 5))
try captureDarkPage(app, name: "Night reader settings")
let click = app.buttons["点击区域设置"]
scrollTo(click, in: app)
click.tap()
XCTAssertTrue(app.navigationBars["点击区域设置"].waitForExistence(timeout: 5))
try captureDarkPage(app, name: "Night click action grid")
}
func testAppearanceMenuRestoresSystemAndUpdatesVisiblePage() throws {
continueAfterFailure = false
// Launch arguments override writes to UserDefaults; leave themeMode mutable here.
let app = launch(["-uiDemoTab", "settings"])
let appearance = app.buttons["profile.appearance"]
XCTAssertTrue(appearance.waitForExistence(timeout: 10))
appearance.tap()
let system = app.buttons["跟随系统"]
XCTAssertTrue(system.waitForExistence(timeout: 3))
system.tap()
let followsSystem = expectation(for: NSPredicate(format: "value == %@", "跟随系统"), evaluatedWith: appearance)
wait(for: [followsSystem], timeout: 5)
let initial = try luminances(app, in: app)
let systemIsDark = initial[initial.count / 2] < 0.15
for (choice, expectsDark) in [("深色", true), ("浅色", false), ("跟随系统", systemIsDark)] {
appearance.tap()
let option = app.buttons[choice]
if !option.waitForExistence(timeout: 2), app.buttons["外观"].exists { app.buttons["外观"].tap() }
XCTAssertTrue(option.waitForExistence(timeout: 3))
option.tap()
let changed = expectation(for: NSPredicate(format: "value == %@", choice), evaluatedWith: appearance)
wait(for: [changed], timeout: 5)
let values = try luminances(app, in: app)
let median = values[values.count / 2]
if expectsDark { XCTAssertLessThan(median, 0.15) }
else { XCTAssertGreaterThan(median, 0.6) }
attach(app, name: "Live appearance \(choice)")
}
}
private func scrollTo(_ element: XCUIElement, in app: XCUIApplication) {
for _ in 0..<10 {
if element.exists && element.isHittable { return }
if element.exists && element.frame.maxY < app.frame.height / 2 {
app.swipeDown()
} else {
app.swipeUp()
}
}
XCTAssertTrue(element.exists && element.isHittable)
}
private func captureDarkPage(_ app: XCUIApplication, name: String) throws {
let values = try luminances(app, in: app)
XCTAssertLessThan(values[values.count / 2], 0.15, "Unexpected light page or sheet: \(name)")
attach(app, name: name)
}
// Check both the rendered label contrast and the fill's actual brightness.
// A readable white surface must still fail the dark appearance regression.
private func assertReadableDarkFill(_ element: XCUIElement, in app: XCUIApplication,
file: StaticString = #filePath, line: UInt = #line) throws {
let luminances = try luminances(element, in: app, file: file, line: line)
let low = luminances[luminances.count * 2 / 100]
let high = luminances[luminances.count * 98 / 100]
XCTAssertGreaterThanOrEqual((high + 0.05) / (low + 0.05), 4.5,
"Rendered label has insufficient contrast", file: file, line: line)
XCTAssertLessThan(luminances[luminances.count / 2], 0.15,
"Filled control or card is too bright for the dark palette", file: file, line: line)
}
private func luminances(_ element: XCUIElement, in app: XCUIApplication,
file: StaticString = #filePath, line: UInt = #line) throws -> [Double] {
let image = try XCTUnwrap(app.screenshot().image.cgImage, file: file, line: line)
let scaleX = CGFloat(image.width) / app.frame.width
let scaleY = CGFloat(image.height) / app.frame.height
let bounds = element.frame.intersection(app.frame).insetBy(dx: 8, dy: 8)
let rect = CGRect(x: bounds.minX * scaleX, y: bounds.minY * scaleY,
width: bounds.width * scaleX, height: bounds.height * scaleY).integral
let crop = try XCTUnwrap(image.cropping(to: rect), file: file, line: line)
var bytes = [UInt8](repeating: 0, count: crop.width * crop.height * 4)
try bytes.withUnsafeMutableBytes { buffer in
let context = try XCTUnwrap(CGContext(
data: buffer.baseAddress, width: crop.width, height: crop.height,
bitsPerComponent: 8, bytesPerRow: crop.width * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue
), file: file, line: line)
context.draw(crop, in: CGRect(x: 0, y: 0, width: crop.width, height: crop.height))
}
func linear(_ component: UInt8) -> Double {
let value = Double(component) / 255
return value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4)
}
let sampleStride = crop.width * crop.height > 250_000 ? 64 : 4
return stride(from: 0, to: bytes.count, by: sampleStride).map {
0.2126 * linear(bytes[$0]) + 0.7152 * linear(bytes[$0 + 1]) + 0.0722 * linear(bytes[$0 + 2])
}.sorted()
}
private func launch(_ arguments: [String]) -> XCUIApplication {
let app = XCUIApplication()
app.launchArguments = ["-uiDemo"] + arguments
app.launch()
return app
}
private func attach(_ app: XCUIApplication, name: String) {
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}
}
@@ -108,7 +108,10 @@ final class LiveReadingFlowUITests: XCTestCase {
showReaderMenu()
app.buttons["换源"].tap()
XCTAssertTrue(app.navigationBars["为《极道天魔》换源"].waitForExistence(timeout: 10))
let replacement = app.buttons["sourceSwitch.candidate." + replacementID]
let replacement = app.buttons.matching(NSPredicate(
format: "identifier BEGINSWITH %@ AND label CONTAINS %@",
"sourceSwitch.candidate." + replacementID + "|", "滚开"
)).firstMatch
XCTAssertTrue(replacement.waitForExistence(timeout: 240))
attach("07-source-candidates")
replacement.tap()
@@ -1,7 +1,185 @@
import XCTest
import UIKit
@MainActor
final class ReaderScrollGestureTests: XCTestCase {
func testBackgroundSwitchRestoresPresetWithoutDeletingPhoto() throws {
try exerciseBackgroundSwitch(readingMode: "0")
}
func testScrollBackgroundSwitchRestoresPresetWithoutDeletingPhoto() throws {
try exerciseBackgroundSwitch(readingMode: "3")
}
private func exerciseBackgroundSwitch(readingMode: String) throws {
continueAfterFailure = false
let app = launchAppearance(readingMode: readingMode)
let choose = app.buttons["选择背景图片"]
scrollToAppearanceElement(choose, in: app)
app.buttons["微信读书"].tap()
choose.tap()
let photo = app.images["PXGGridLayout-Info"].firstMatch
XCTAssertTrue(photo.waitForExistence(timeout: 10))
photo.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch
let dismissed = expectation(for: NSPredicate { _, _ in !cancel.exists }, evaluatedWith: nil)
wait(for: [dismissed], timeout: 15)
let useBackground = app.switches["使用背景图片"]
scrollToAppearanceElement(useBackground, in: app)
XCTAssertEqual(useBackground.value as? String, "1")
app.buttons["完成"].tap()
let imageColor = try backgroundRGB(in: app)
func openAppearance() {
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
app.buttons["外观"].tap()
XCTAssertTrue(app.navigationBars["阅读样式"].waitForExistence(timeout: 5))
scrollToAppearanceElement(useBackground, in: app)
}
openAppearance()
attachScreenshot(app)
app.coordinate(withNormalizedOffset: CGVector(
dx: 0.25, dy: useBackground.frame.midY / app.frame.height
)).tap()
XCTAssertEqual(useBackground.value as? String, "0")
XCTAssertTrue(app.buttons["删除背景图片"].exists)
app.buttons["完成"].tap()
XCTAssertEqual(try backgroundRGB(in: app), [192, 237, 198])
openAppearance()
useBackground.tap()
XCTAssertEqual(useBackground.value as? String, "1")
app.buttons["完成"].tap()
XCTAssertEqual(try backgroundRGB(in: app), imageColor)
openAppearance()
app.buttons["预设2"].tap()
XCTAssertEqual(useBackground.value as? String, "0")
app.buttons["完成"].tap()
XCTAssertEqual(try backgroundRGB(in: app), [221, 192, 144])
}
private func backgroundRGB(in app: XCUIApplication) throws -> [Int] {
let image = try XCTUnwrap(app.screenshot().image.cgImage)
let pixel = try XCTUnwrap(image.cropping(to: CGRect(
x: image.width / 50, y: image.height * 28 / 100, width: 1, height: 1
)))
var bytes = [UInt8](repeating: 0, count: 4)
try bytes.withUnsafeMutableBytes { buffer in
let context = try XCTUnwrap(CGContext(
data: buffer.baseAddress, width: 1, height: 1, bitsPerComponent: 8,
bytesPerRow: 4, space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue
))
context.draw(pixel, in: CGRect(x: 0, y: 0, width: 1, height: 1))
}
return bytes.prefix(3).map(Int.init)
}
func testBackgroundPickerCanBeOpenedRepeatedly() throws {
continueAfterFailure = false
let app = launchAppearance()
for _ in 0..<3 {
let choose = app.buttons["选择背景图片"]
scrollToAppearanceElement(choose, in: app)
choose.tap()
let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch
XCTAssertTrue(cancel.waitForExistence(timeout: 10))
cancel.tap()
XCTAssertTrue(app.navigationBars["阅读样式"].waitForExistence(timeout: 5))
}
}
func testBackgroundPhotoCanBeSelectedAndReplaced() throws {
try exerciseBackgroundPhotoSelection(usesLegacyUI: false)
}
func testLegacyBackgroundPhotoCanBeSelectedAndReplaced() throws {
try exerciseBackgroundPhotoSelection(usesLegacyUI: true)
}
private func exerciseBackgroundPhotoSelection(usesLegacyUI: Bool) throws {
continueAfterFailure = false
let app = launchAppearance(usesLegacyUI: usesLegacyUI)
for _ in 0..<2 {
let choose = app.buttons["选择背景图片"]
scrollToAppearanceElement(choose, in: app)
choose.tap()
let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch
XCTAssertTrue(cancel.waitForExistence(timeout: 10))
let photo = app.images["PXGGridLayout-Info"].firstMatch
XCTAssertTrue(photo.waitForExistence(timeout: 5), "Seed a demo image with simctl addmedia before running this test")
photo.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
let dismissed = expectation(for: NSPredicate { _, _ in !cancel.exists }, evaluatedWith: nil)
wait(for: [dismissed], timeout: 15)
XCTAssertTrue(app.navigationBars["阅读样式"].waitForExistence(timeout: 15))
let useBackground = app.switches["使用背景图片"]
scrollToAppearanceElement(useBackground, in: app)
XCTAssertEqual(useBackground.value as? String, "1")
}
}
func testFontPickerCanBePresentedAboveAppearanceAndReopened() throws {
continueAfterFailure = false
let app = launchAppearance()
for _ in 0..<2 {
let choose = app.buttons["选择字体文件"]
scrollToAppearanceElement(choose, in: app)
XCTAssertTrue(app.staticTexts["段落缩进"].exists)
choose.tap()
let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch
XCTAssertTrue(cancel.waitForExistence(timeout: 10))
cancel.tap()
XCTAssertTrue(app.navigationBars["阅读样式"].waitForExistence(timeout: 5))
}
}
func testBookmarkSelectionSurvivesLeavingAndReenteringReader() throws {
continueAfterFailure = false
let app = XCUIApplication()
app.launchArguments = ["-uiDemo", "-pageAnim", "0", "-themeMode", "1", "-textSize", "28"]
app.launch()
let book = app.staticTexts["山海入梦来"].firstMatch
XCTAssertTrue(book.waitForExistence(timeout: 20))
book.tap()
XCTAssertTrue(app.textViews["reader.text"].waitForExistence(timeout: 20))
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
let bookmark = app.buttons["书签"]
XCTAssertTrue(bookmark.waitForExistence(timeout: 5))
XCTAssertEqual(bookmark.value as? String, "添加当前位置")
bookmark.tap()
app.buttons["返回"].tap()
XCTAssertTrue(book.waitForExistence(timeout: 10))
book.tap()
XCTAssertTrue(app.textViews["reader.text"].waitForExistence(timeout: 10))
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
XCTAssertTrue(bookmark.waitForExistence(timeout: 5))
XCTAssertEqual(bookmark.value as? String, "已添加")
app.buttons["更多"].tap()
app.buttons["书签列表"].tap()
XCTAssertTrue(app.navigationBars["书签"].waitForExistence(timeout: 5))
XCTAssertTrue(app.staticTexts["第一章 · 雨夜"].firstMatch.exists)
}
private func launchAppearance(readingMode: String = "0", usesLegacyUI: Bool = false) -> XCUIApplication {
let app = XCUIApplication()
app.launchArguments = ["-uiDemo", "-uiDemoReader", "-uiDemoReaderStyle", "-pageAnim", readingMode, "-themeMode", "1", "-textFont", ""]
if usesLegacyUI { app.launchArguments.append("-uiLegacyCompatibility") }
app.launch()
XCTAssertTrue(app.navigationBars["阅读样式"].waitForExistence(timeout: 20))
return app
}
private func scrollToAppearanceElement(_ element: XCUIElement, in app: XCUIApplication) {
for _ in 0..<12 {
if element.exists && element.isHittable { return }
let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.88))
let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.72))
start.press(forDuration: 0.05, thenDragTo: end)
}
XCTAssertTrue(element.exists && element.isHittable)
}
func testFailedChaptersAllowRetryMenuAndSourceSwitchInBothReadingModes() throws {
continueAfterFailure = false
for mode in ["3", "0"] {
@@ -176,8 +354,13 @@ final class ReaderScrollGestureTests: XCTestCase {
app.launch()
let reader = app.collectionViews["reader.continuous"]
XCTAssertTrue(reader.waitForExistence(timeout: 20))
var lastInitialState = "<not reported>"
let ready = expectation(for: NSPredicate { _, _ in
let text = reader.value as? String ?? ""
let text = reader.value as? String ?? "<missing>"
if text != lastInitialState {
print("[ContinuousReaderInitial] \(text)")
lastInitialState = text
}
return text.contains("chapter=20 ") && !text.contains("chapters=1 ")
}, evaluatedWith: nil)
wait(for: [ready], timeout: 20)
@@ -2,6 +2,233 @@ import XCTest
@MainActor
final class RuleEditorUITests: XCTestCase {
func testRSSSearchSubmissionPaginationAndFavorites() throws {
guard let fixture = ProcessInfo.processInfo.environment["LEGADO_RSS_UI_FIXTURE_URL"] else {
throw XCTSkip("Set LEGADO_RSS_UI_FIXTURE_URL to the local RSS fixture source URL")
}
continueAfterFailure = false
let app = XCUIApplication()
app.launchArguments = ["-uiDemo", "-uiDemoTab", "rss", "-themeMode", "1"]
app.launch()
XCTAssertTrue(app.navigationBars["RSS"].waitForExistence(timeout: 20))
app.buttons["RSS操作"].tap()
app.buttons["URL或粘贴JSON"].tap()
let editor = app.textViews.firstMatch
XCTAssertTrue(editor.waitForExistence(timeout: 5))
editor.tap()
editor.typeText(fixture)
app.navigationBars["导入RSS源"].buttons["导入"].tap()
let source = app.staticTexts["RSS 全流程测试"]
XCTAssertTrue(source.waitForExistence(timeout: 10))
source.tap()
XCTAssertTrue(app.staticTexts["首页文章"].waitForExistence(timeout: 10))
let search = app.searchFields["源内搜索"]
if !search.exists { app.swipeDown() }
XCTAssertTrue(search.waitForExistence(timeout: 5))
search.tap()
search.typeText("needle\n")
let result = app.staticTexts["搜索结果 needle 第1页"]
XCTAssertTrue(result.waitForExistence(timeout: 10))
let nextPage = app.buttons["加载下一页"]
let ready = expectation(for: NSPredicate { _, _ in nextPage.exists && nextPage.isEnabled }, evaluatedWith: nil)
wait(for: [ready], timeout: 10)
nextPage.tap()
XCTAssertTrue(app.staticTexts["搜索结果 needle 第2页"].waitForExistence(timeout: 10))
result.tap()
XCTAssertTrue(app.navigationBars["文章"].waitForExistence(timeout: 5))
if app.buttons["取消收藏"].exists { app.buttons["取消收藏"].tap() }
XCTAssertTrue(app.buttons["收藏"].waitForExistence(timeout: 5))
app.buttons["收藏"].tap()
XCTAssertTrue(app.buttons["取消收藏"].waitForExistence(timeout: 5))
app.navigationBars["文章"].buttons.element(boundBy: 0).tap()
let cancelSearch = app.navigationBars["RSS 全流程测试"].buttons.matching(NSPredicate(
format: "label IN %@", ["Cancel", "取消", "Close", "关闭"]
)).firstMatch
if cancelSearch.exists { cancelSearch.tap() }
app.navigationBars["RSS 全流程测试"].buttons["RSS"].tap()
XCTAssertTrue(app.navigationBars["RSS"].waitForExistence(timeout: 5))
app.segmentedControls.buttons["收藏"].tap()
XCTAssertTrue(result.waitForExistence(timeout: 5))
attach(app, name: "RSS search article saved to favorites")
app.terminate()
app.launch()
XCTAssertTrue(app.staticTexts["RSS 全流程测试"].waitForExistence(timeout: 10))
app.segmentedControls.buttons["收藏"].tap()
XCTAssertTrue(result.waitForExistence(timeout: 5))
}
func testBookshelfFilePickerImportsTXTAndEPUBAndDisablesJSON() throws {
try exerciseLocalBookImport(fromFileManagement: false)
}
func testFileManagementFilePickerImportsTXTAndEPUBAndDisablesJSON() throws {
try exerciseLocalBookImport(fromFileManagement: true)
}
private func exerciseLocalBookImport(fromFileManagement: Bool) throws {
continueAfterFailure = false
let app = XCUIApplication()
app.launchArguments = ["-uiDemo", "-themeMode", "1", "-bookshelfLayout", "grid3", "-pageAnim", "0"]
if fromFileManagement { app.launchArguments += ["-uiDemoTab", "settings", "-uiDemoFiles"] }
app.launch()
let importTitle = fromFileManagement ? "导入 TXT / EPUB" : "本地书"
let more = fromFileManagement
? app.navigationBars["文件管理"].buttons["更多"].firstMatch
: app.buttons["更多"].firstMatch
XCTAssertTrue(more.waitForExistence(timeout: 20))
more.tap()
app.buttons[importTitle].firstMatch.tap()
let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch
XCTAssertTrue(cancel.waitForExistence(timeout: 10))
cancel.tap()
let closed = expectation(for: NSPredicate { _, _ in !cancel.exists }, evaluatedWith: nil)
wait(for: [closed], timeout: 10)
more.tap()
app.buttons[importTitle].firstMatch.tap()
XCTAssertTrue(cancel.waitForExistence(timeout: 10))
let pickerFiles = app.collectionViews["File View"].cells
let txt = pickerFiles.matching(NSPredicate(format: "label BEGINSWITH %@", "TXT-Book")).firstMatch
let fixtureJSON = pickerFiles.matching(NSPredicate(format: "label BEGINSWITH %@", "JSON-Source")).firstMatch
let fixtures = pickerFiles.matching(NSPredicate(format: "label BEGINSWITH %@", "书源导入测试")).firstMatch
// The file-management page underneath the picker can also contain TXT.
// JSON exists only inside our fixture folder in the actual picker.
if !fixtureJSON.waitForExistence(timeout: 3) {
if !fixtures.exists {
let browse = app.buttons.matching(NSPredicate(format: "label IN %@", ["Browse", "浏览", "Locations", "位置"])).firstMatch
if browse.exists { browse.tap() }
if !fixtures.waitForExistence(timeout: 2) {
let local = app.descendants(matching: .any).matching(NSPredicate(
format: "label CONTAINS %@ OR label CONTAINS %@ OR label == %@", "我的 iPhone", "我的iPhone", "On My iPhone"
)).firstMatch
XCTAssertTrue(local.waitForExistence(timeout: 5))
local.tap()
}
}
XCTAssertTrue(fixtures.waitForExistence(timeout: 10), "Run sh scripts/prepare-source-import-fixture.sh <simulator-UUID>")
fixtures.tap()
}
XCTAssertTrue(txt.waitForExistence(timeout: 10))
let open = app.buttons.matching(NSPredicate(format: "label IN %@", ["Open", "打开", "Import", "导入"])).firstMatch
for name in ["JSON-Source", "JSON-Uppercase"] {
let json = pickerFiles.matching(NSPredicate(format: "label BEGINSWITH %@", name)).firstMatch
XCTAssertTrue(json.waitForExistence(timeout: 5))
XCTAssertFalse(json.isEnabled)
json.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
XCTAssertFalse(open.exists && open.isEnabled, "JSON must not be selectable as a local book")
}
txt.tap()
let uppercase = pickerFiles.matching(NSPredicate(format: "label BEGINSWITH %@", "TXT-Uppercase")).firstMatch
XCTAssertTrue(uppercase.waitForExistence(timeout: 5))
uppercase.tap()
let epub = pickerFiles.matching(NSPredicate(format: "label BEGINSWITH %@", "EPUB-Book")).firstMatch
XCTAssertTrue(epub.waitForExistence(timeout: 5))
XCTAssertTrue(epub.isEnabled)
epub.tap()
XCTAssertTrue(open.waitForExistence(timeout: 5))
XCTAssertTrue(open.isEnabled)
attach(app, name: "TXT selectable and JSON excluded")
open.tap()
if fromFileManagement {
XCTAssertTrue(app.navigationBars["文件管理"].waitForExistence(timeout: 15))
app.tabBars.buttons["书架"].tap()
} else {
XCTAssertTrue(app.navigationBars["导入本地书"].waitForExistence(timeout: 15))
XCTAssertFalse(app.staticTexts["JSON-Source.json"].exists)
XCTAssertFalse(app.staticTexts["JSON-Uppercase.JSON"].exists)
XCTAssertTrue(app.staticTexts["TXT-Book.txt"].waitForExistence(timeout: 10), app.debugDescription)
XCTAssertTrue(app.staticTexts["TXT-Uppercase.TXT"].waitForExistence(timeout: 10), app.debugDescription)
app.buttons["导入"].tap()
}
// Repeated runs preserve managed files and correctly add a numeric suffix.
XCTAssertTrue(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "TXT-Book")).firstMatch.waitForExistence(timeout: 15))
XCTAssertTrue(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "TXT-Uppercase")).firstMatch.waitForExistence(timeout: 15))
let epubBook = app.staticTexts["EPUB 导入测试"].firstMatch
XCTAssertTrue(epubBook.waitForExistence(timeout: 15))
attach(app, name: "Local TXT and EPUB books imported onto shelf")
epubBook.tap()
let reader = app.textViews["reader.text"]
XCTAssertTrue(reader.waitForExistence(timeout: 15))
XCTAssertTrue((reader.value as? String)?.contains("这是 EPUB 导入后的第一章正文") == true)
reader.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
app.buttons["更多"].firstMatch.tap()
app.buttons["本章插图"].tap()
XCTAssertTrue(app.navigationBars["本章插图"].waitForExistence(timeout: 5))
XCTAssertTrue(app.images["书籍插图"].waitForExistence(timeout: 10))
app.buttons["完成"].tap()
reader.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
app.buttons["目录"].tap()
app.staticTexts["第二章 EPUB"].firstMatch.tap()
let loaded = expectation(for: NSPredicate { _, _ in
(reader.value as? String)?.contains("目录锚点跳转成功") == true
}, evaluatedWith: nil)
wait(for: [loaded], timeout: 10)
attach(app, name: "EPUB second chapter after TOC navigation")
}
func testJSONFilePickerOffersOpenAndImportsSelectedFiles() throws {
continueAfterFailure = false
let app = XCUIApplication()
app.launchArguments = ["-uiDemo", "-uiDemoTab", "sources"]
app.launch()
XCTAssertTrue(app.navigationBars["书源"].waitForExistence(timeout: 20))
app.buttons["更多"].firstMatch.tap()
app.buttons["从文件导入"].tap()
let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch
XCTAssertTrue(cancel.waitForExistence(timeout: 10))
cancel.tap()
let pickerClosed = expectation(for: NSPredicate { _, _ in
!cancel.exists && app.navigationBars["书源"].buttons["更多"].isHittable
}, evaluatedWith: nil)
wait(for: [pickerClosed], timeout: 10)
app.buttons["更多"].firstMatch.tap()
app.buttons["从文件导入"].tap()
XCTAssertTrue(cancel.waitForExistence(timeout: 10))
let json = app.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", "JSON-Source")).firstMatch
let fixtures = app.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", "书源导入测试")).firstMatch
if !json.waitForExistence(timeout: 3) {
if !fixtures.exists {
let browse = app.buttons.matching(NSPredicate(format: "label IN %@", ["Browse", "浏览", "Locations", "位置"])).firstMatch
if browse.exists { browse.tap() }
if !fixtures.waitForExistence(timeout: 2) {
let local = app.descendants(matching: .any).matching(NSPredicate(
format: "label CONTAINS %@ OR label CONTAINS %@ OR label == %@", "我的 iPhone", "我的iPhone", "On My iPhone"
)).firstMatch
if !local.waitForExistence(timeout: 5) { print("[SourceFilePicker] \(app.debugDescription)") }
XCTAssertTrue(local.exists)
local.tap()
}
}
XCTAssertTrue(fixtures.waitForExistence(timeout: 10), "Run sh scripts/prepare-source-import-fixture.sh <simulator-UUID> before this test")
fixtures.tap()
}
XCTAssertTrue(json.waitForExistence(timeout: 10))
json.tap()
let uppercase = app.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", "JSON-Uppercase")).firstMatch
XCTAssertTrue(uppercase.waitForExistence(timeout: 5))
uppercase.tap()
let open = app.buttons.matching(NSPredicate(format: "label IN %@", ["Open", "打开", "Import", "导入"])).firstMatch
XCTAssertTrue(open.waitForExistence(timeout: 5))
XCTAssertTrue(open.isEnabled)
attach(app, name: "JSON files selected with Open available")
open.tap()
let reviewAppeared = app.navigationBars["书源导入预检"].waitForExistence(timeout: 15)
if !reviewAppeared {
print("[SourceFilePickerAfterOpen] \(app.debugDescription)")
attach(app, name: "File picker after Open")
}
XCTAssertTrue(reviewAppeared)
XCTAssertTrue(app.buttons["导入"].isEnabled)
attach(app, name: "JSON import review after file picker dismissal")
app.buttons["导入"].tap()
if app.alerts.firstMatch.waitForExistence(timeout: 5) { app.alerts.buttons["好"].tap() }
XCTAssertTrue(app.staticTexts["文件导入 JSON 测试"].waitForExistence(timeout: 10), app.debugDescription)
XCTAssertTrue(app.staticTexts["文件导入大写扩展名测试"].exists)
}
func testSourceFormJSONSwitchKeepsHighlightedTextAndSaves() throws {
try exerciseSourceEditor(usesLegacyUI: false)
}
+57
View File
@@ -41,6 +41,23 @@ sh scripts/self-check.sh
阅读器测试在 Xcode 选择 `LegadoReaderTests` / `LegadoReaderUITests` Scheme 和具体模拟器运行。联网书源测试需要显式配置对应环境变量,默认核心回归主要采用自造响应,不能将其通过解读为所有线上书源均可用。
书源和本地 TXT / EPUB 文件选择回归需要先在已启动的测试模拟器中准备文件共享应用(将下方 UUID 换成实际值):
```sh
LEGADO_TEST_DEVICE="模拟器 UUID"
sh scripts/prepare-source-import-fixture.sh "$LEGADO_TEST_DEVICE"
xcodebuild -project Legado.xcodeproj -scheme LegadoReaderUITests \
-destination "platform=iOS Simulator,id=$LEGADO_TEST_DEVICE" \
-only-testing:LegadoReaderUITests/RuleEditorUITests/testJSONFilePickerOffersOpenAndImportsSelectedFiles \
-only-testing:LegadoReaderUITests/RuleEditorUITests/testBookshelfFilePickerImportsTXTAndEPUBAndDisablesJSON \
-only-testing:LegadoReaderUITests/RuleEditorUITests/testFileManagementFilePickerImportsTXTAndEPUBAndDisablesJSON \
-collect-test-diagnostics never CODE_SIGNING_ALLOWED=NO test
```
独立测试应用提供 JSON 书源和 `.txt`、`.TXT`、`.epub` 小说样本,不会被打包进正式 App。此回归检查取消后重新打开、书源 JSON 多选、本地 TXT / EPUB 可选且 JSON 不可选、导入确认、最终保存、EPUB 正文、插图与目录跳转。
RSS 的导入、源内搜索、分页、正文、收藏以及重启恢复可通过本地自造响应回归,避免依赖第三方网站。先在一个终端运行 `python3 scripts/fixtures/rss-flow-server.py`,再在 Xcode 选择 `LegadoRSSFlowUITests` Scheme 和模拟器运行;也可用 `xcodebuild -project Legado.xcodeproj -scheme LegadoRSSFlowUITests -destination 'platform=iOS Simulator,id=模拟器UUID' CODE_SIGNING_ALLOWED=NO test`。该服务仅监听本机 `127.0.0.1:18763`,测试结束后按 Ctrl-C 停止。普通 UI Scheme 默认跳过此用例。
## 兼容性验证
最低部署版本为 iOS 15.0。`App/Compatibility.swift` 为导航、分享、照片选择和弹窗提供兼容实现;新增系统 API 时需保留最低版本路径。调试构建可用 `-uiLegacyCompatibility` 启动参数在新系统上执行旧版界面分支,对应回归位于 `Tests/LegadoReaderUITests/`。这些测试不能替代 iOS 15 真机或模拟器实测。
@@ -62,3 +79,43 @@ python3 scripts/export-open-source.py /tmp/legado-ios-open-source
```
目标目录必须尚不存在。导出后检查内容,再用独立仓库管理;`.gitignore` 不会自动清理已经跟踪的文件。
## 维护应用源
`docs/altstore.json` 是 AltStore Classic、SideStore 和 LiveContainer 共用的应用源,固定地址为:
```text
https://raw.githubusercontent.com/Aminark/legado-ios/main/docs/altstore.json
```
源仅列出最新正式版,同时提供 `versions` 和旧客户端使用的顶层版本字段。版本号、构建号、最低 iOS 版本和隐私权限从实际 IPA 的 `Info.plist` 读取,下载地址、发布日期和更新说明来自 GitHub Release。目前由维护者在发布正式版后手动运行生成脚本并提交源文件。
1. 新版发布时,先在 Release 草稿中上传唯一的未签名 `.ipa`,再发布正式版。
2. 下载 GitHub 标记的 latest 正式版元数据及 IPA,运行下方生成与测试命令,检查 `docs/altstore.json` 差异后提交到 `main`。
3. 源文件发布后,客户端刷新源即可获取该版本的下载与更新入口。只提交源码或上传未发布的草稿不会更新应用源。
本地生成(需要 Python 3.9+;获取 Release 元数据时使用已登录的 GitHub CLI):
```sh
source_update_dir=$(mktemp -d)
gh api repos/Aminark/legado-ios/releases/latest > "$source_update_dir/release.json"
source_release_tag=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["tag_name"])' "$source_update_dir/release.json")
gh release download "$source_release_tag" --repo Aminark/legado-ios \
--pattern '*.ipa' --dir "$source_update_dir"
python3 scripts/update-altstore-source.py \
--ipa "$source_update_dir/legado-ios-v0.1.12-build18-unsigned.ipa" \
--release "$source_update_dir/release.json"
python3 -m unittest discover -s scripts/tests -v
```
请将示例 IPA 文件名换成实际下载的附件名。脚本会核对包标识、Release 标签与 IPA 版本、文件大小,以及 Release 提供的 SHA-256(如有),避免源指向与本地检查不一致的包。当前生成器针对本项目无扩展、无额外 entitlement 的未签名 IPA;改用签名包、增加扩展或 entitlement 时,需要同步完善权限提取,生成器会拒绝带签名资源、描述文件或扩展的 IPA。
发布后检查固定地址可匿名访问、图标和截图能正常加载,并下载源中 IPA 再核对大小、摘要和版本。客户端的添加源、安装、更新与签名流程需分别实测;源文件校验通过不代表已完成真机安装验证。
格式说明:[AltStore 源规范](https://faq.altstore.io/developers/make-a-source)、[SideStore 源字段](https://sidestore.io/sidestore-source-types/interfaces/Source.html)、[LiveContainer 源用法](https://livecontainer.github.io/docs/guides/sources)。
## EPUB 回归
`swift test --disable-sandbox --filter EPUB` 覆盖 EPUB 2/3 元数据、中文资源路径、同页锚点、无目录降级、书脊顺序、插图、损坏与加密文件、重命名导出、导入失败回滚和阅读进度恢复。样本均由测试自行生成,不依赖网络或第三方书籍。
解析器按需读取 ZIP 资源,不解压到磁盘;单资源上限为 32 MiB,正文阅读不执行脚本或加载外部资源。字体混淆不影响文字阅读,加密正文会显示明确错误。原书 CSS、固定版式和交互内容不在当前重排阅读模式范围内。
+63
View File
@@ -0,0 +1,63 @@
{
"name": "小浣熊阅读",
"identifier": "com.epiboly.legado.source",
"subtitle": "小浣熊阅读官方应用源",
"description": "小浣熊阅读的最新正式版,供 AltStore Classic、SideStore 和 LiveContainer 添加。",
"sourceURL": "https://raw.githubusercontent.com/Aminark/legado-ios/main/docs/altstore.json",
"website": "https://github.com/Aminark/legado-ios",
"iconURL": "https://raw.githubusercontent.com/Aminark/legado-ios/main/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png",
"tintColor": "#315C50",
"apps": [
{
"name": "小浣熊阅读",
"bundleIdentifier": "com.epiboly.legado",
"developerName": "Aminark",
"subtitle": "搜书快一点,读书舒服一点。",
"localizedDescription": "一款优先兼容阅读书源的开源 iOS 阅读器,支持书架、搜索、阅读排版、本地 TXT、语音朗读和 RSS。应用不提供书源或在线书库,书源需自行导入。",
"iconURL": "https://raw.githubusercontent.com/Aminark/legado-ios/main/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png",
"tintColor": "#315C50",
"category": "utilities",
"screenshots": [
{
"imageURL": "https://raw.githubusercontent.com/Aminark/legado-ios/main/docs/images/bookshelf.png",
"width": 1206,
"height": 2622
},
{
"imageURL": "https://raw.githubusercontent.com/Aminark/legado-ios/main/docs/images/search.png",
"width": 1206,
"height": 2622
},
{
"imageURL": "https://raw.githubusercontent.com/Aminark/legado-ios/main/docs/images/reader.png",
"width": 1206,
"height": 2622
}
],
"versions": [
{
"version": "0.1.11",
"buildVersion": "17",
"date": "2026-10-22T03:06:37Z",
"localizedDescription": "小浣熊阅读 **0.1.11**,构建号 **17**,支持 **iOS / iPadOS 15.0 及以上**。\n\n## 更新内容\n\n- 修复书架和文件管理中的本地 TXT 文件选择及导入流程,支持 `.txt`、`.TXT` 多选,排除 JSON 书源文件。\n- 保留所选文件访问权限至导入完成,调整取消后重新选择和导入确认的展示流程。\n- 完善深色模式下的主按钮、选中态、阅读菜单、统计卡片和管理页面,改善文字对比度。\n- 调整规则编辑器的语法高亮、光标和工具栏外观。\n- 适配 RSS 正文、登录网页和图片查看页的背景与导航操作,保留书源自定义样式和页面状态。\n\n## 下载安装\n\n下载附件 **`legado-ios-v0.1.11-build17-unsigned.ipa`**,使用自己的签名工具安装。支持 TrollStore 的设备可自行尝试安装;本项目暂不提供 App Store 或 TestFlight 分发。\n\n`Source code (zip / tar.gz)` 是源码压缩包,不能直接安装。`SHA256SUMS` 可用于核对 IPA 完整性。\n\n## 验证与兼容\n\n本次功能代码已通过 300 项核心与页面模型测试、15 项 iOS 测试及 11 个相关 UI 回归用例。IPA 从对应源码重新进行 iPhoneOS Release 构建。\n\nUI 回归在 iOS 18.3 模拟器完成;iOS 15 真机仍未覆盖。如有兼容问题,请在 [Issues](https://github.com/Aminark/legado-ios/issues) 反馈版本、设备、系统及复现步骤。\n\n**小浣熊阅读不提供、内置或分发网络书源,也不提供在线书库;书源需自行导入。**\n",
"downloadURL": "https://github.com/Aminark/legado-ios/releases/download/v0.1.11/legado-ios-v0.1.11-build17-unsigned.ipa",
"size": 12424247,
"minOSVersion": "15.0"
}
],
"version": "0.1.11",
"versionDate": "2026-10-22T03:06:37Z",
"versionDescription": "小浣熊阅读 **0.1.11**,构建号 **17**,支持 **iOS / iPadOS 15.0 及以上**。\n\n## 更新内容\n\n- 修复书架和文件管理中的本地 TXT 文件选择及导入流程,支持 `.txt`、`.TXT` 多选,排除 JSON 书源文件。\n- 保留所选文件访问权限至导入完成,调整取消后重新选择和导入确认的展示流程。\n- 完善深色模式下的主按钮、选中态、阅读菜单、统计卡片和管理页面,改善文字对比度。\n- 调整规则编辑器的语法高亮、光标和工具栏外观。\n- 适配 RSS 正文、登录网页和图片查看页的背景与导航操作,保留书源自定义样式和页面状态。\n\n## 下载安装\n\n下载附件 **`legado-ios-v0.1.11-build17-unsigned.ipa`**,使用自己的签名工具安装。支持 TrollStore 的设备可自行尝试安装;本项目暂不提供 App Store 或 TestFlight 分发。\n\n`Source code (zip / tar.gz)` 是源码压缩包,不能直接安装。`SHA256SUMS` 可用于核对 IPA 完整性。\n\n## 验证与兼容\n\n本次功能代码已通过 300 项核心与页面模型测试、15 项 iOS 测试及 11 个相关 UI 回归用例。IPA 从对应源码重新进行 iPhoneOS Release 构建。\n\nUI 回归在 iOS 18.3 模拟器完成;iOS 15 真机仍未覆盖。如有兼容问题,请在 [Issues](https://github.com/Aminark/legado-ios/issues) 反馈版本、设备、系统及复现步骤。\n\n**小浣熊阅读不提供、内置或分发网络书源,也不提供在线书库;书源需自行导入。**\n",
"downloadURL": "https://github.com/Aminark/legado-ios/releases/download/v0.1.11/legado-ios-v0.1.11-build17-unsigned.ipa",
"size": 12424247,
"appPermissions": {
"entitlements": [],
"privacy": {
"NSCameraUsageDescription": "用于扫描书源和净化规则的导入二维码。",
"NSPhotoLibraryAddUsageDescription": "用于保存阅读页面中的图片到照片图库。"
}
}
}
],
"news": []
}
@@ -0,0 +1,41 @@
import UIKit
// Standalone simulator-only file provider; never bundled with Legado.
@main
@MainActor
final class FixtureAppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let fixtures = [
("JSON-Source.json", "https://file-import.test/json", "文件导入 JSON 测试"),
("JSON-Uppercase.JSON", "https://file-import.test/uppercase", "文件导入大写扩展名测试"),
("JSON-Text.txt", "https://file-import.test/text", "文件导入文本扩展名测试")
]
for (name, url, title) in fixtures {
let source = [["bookSourceUrl": url, "bookSourceName": title]]
if let data = try? JSONSerialization.data(withJSONObject: source) {
try? data.write(to: documents.appendingPathComponent(name), options: .atomic)
}
}
for name in ["TXT-Book.txt", "TXT-Uppercase.TXT"] {
try? Data("第一章 开始\n这是用于验证本地 TXT 导入的小说正文。\n第二章 后续\n正文继续。".utf8)
.write(to: documents.appendingPathComponent(name), options: .atomic)
}
if let epub = Bundle.main.url(forResource: "EPUB-Book", withExtension: "epub"),
let data = try? Data(contentsOf: epub) {
try? data.write(to: documents.appendingPathComponent("EPUB-Book.epub"), options: .atomic)
}
let controller = UIViewController()
controller.view.backgroundColor = .systemBackground
let label = UILabel()
label.text = "书源导入测试文件已准备好"
label.frame = CGRect(x: 20, y: 100, width: 350, height: 80)
controller.view.addSubview(label)
window = UIWindow(frame: UIScreen.main.bounds)
window?.rootViewController = controller
window?.makeKeyAndVisible()
return true
}
}
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env python3
"""Generate an original EPUB 3 fixture for Files import and reader UI tests."""
import base64
import sys
import zipfile
from pathlib import Path
output = Path(sys.argv[1])
output.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(output, "w") as archive:
archive.writestr("mimetype", "application/epub+zip", compress_type=zipfile.ZIP_STORED)
files = {
"META-INF/container.xml": '<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OPS/book.opf"/></rootfiles></container>',
"OPS/book.opf": '''<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="id">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="id">legado-ui-epub</dc:identifier><dc:title>EPUB 导入测试</dc:title><dc:creator>测试作者</dc:creator><dc:language>zh</dc:language><meta property="dcterms:modified">2026-10-23T00:00:00Z</meta></metadata>
<manifest><item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/><item id="body" href="chapter.xhtml" media-type="application/xhtml+xml"/><item id="image" href="cover.png" media-type="image/png" properties="cover-image"/></manifest>
<spine><itemref idref="body"/></spine></package>''',
"OPS/nav.xhtml": '''<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops"><head><title>目录</title></head><body><nav epub:type="toc"><ol><li><a href="chapter.xhtml#one">第一章 EPUB</a></li><li><a href="chapter.xhtml#two">第二章 EPUB</a></li></ol></nav></body></html>''',
"OPS/chapter.xhtml": '''<html xmlns="http://www.w3.org/1999/xhtml"><head><title>EPUB 正文</title></head><body><section><h1 id="one">第一章 EPUB</h1><p>这是 EPUB 导入后的第一章正文。</p><img src="cover.png" alt="测试插图"/></section><section><h1 id="two">第二章 EPUB</h1><p>这是 EPUB 的第二章正文,目录锚点跳转成功。</p></section></body></html>''',
}
for name, contents in files.items():
archive.writestr(name, contents.encode("utf-8"), compress_type=zipfile.ZIP_DEFLATED)
archive.writestr("OPS/cover.png", base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAEAAAABgCAIAAAAip+O/AAABxklEQVR4nNXCA3YjAABF0becsW12bNdIGptt7Nq2bXeMjc06/j2XI8UG6RwtMUrnWGmtdI6XmaRzotwsnZMVFumcqrRK53SVTTpnqu3SOVvjkM45g1M6540u6VyodUvnoskjnUtmr3QuW3zSuWL1S+eqLSCda/agdK47QtK54QxL56arTjq33PXSue2JSOeONyqdu76YdO7549K5H0hI50EwKZ2HoZR0isJp6Tyqy0jncX1WOk8iOek8jeal8yxWkM7zeIN0XiQapfMy2SSdV6lm6bxOt0jnTaZVOm+zbdJ5l2uXzvt8h3Q+FDql87GhSzqfGrul87mpRzpfmnulU9zSJ52S1n7plLYNSKesfVA65R1D0qnoHJZOZdeIdKq6R6VT3TMmnZrecekY+iakY+yflE7twJR0TIPT0jEPzUjHMjwrHevInHRso/PSsY8tSMcxvigd58SSdFyTy9JxT61IxzO9Kh3vzJp0fLPr0vHPbUgnML8pneDClnRCi9vSCS/tSKdueVc69St70oms7ksnunYgndj6oXTiG1+lk9j8Jp3k1nfppLZ/SCe981M6md1f0snu/ZZObv+PdPIHf6VTOPwn/T/zbe3/96qaXAAAAABJRU5ErkJggg=="))
+44
View File
@@ -0,0 +1,44 @@
"""Local, synthetic RSS responses for the LegadoRSSFlowUITests scheme."""
import http.server
import json
import urllib.parse
BASE_URL = "http://127.0.0.1:18763"
class Fixture(http.server.BaseHTTPRequestHandler):
def do_GET(self):
parsed = urllib.parse.urlsplit(self.path)
query = urllib.parse.parse_qs(parsed.query)
key = query.get("key", [""])[0]
page = query.get("page", ["1"])[0]
if parsed.path == "/source.json":
payload = {
"sourceUrl": BASE_URL,
"sourceName": "RSS 全流程测试",
"sortUrl": "全部::" + BASE_URL + "/feed?page={{page}}",
"searchUrl": BASE_URL + "/feed?key={{key}}&page={{page}}",
"ruleArticles": "$.items",
"ruleTitle": "$.title",
"ruleLink": "$.link",
"ruleDescription": "$.description",
"ruleNextPage": "PAGE",
}
else:
title = f"搜索结果 {key} 第{page}页" if key else "首页文章"
payload = {"items": [{
"title": title,
"link": BASE_URL + "/article/" + key + "/" + page,
"description": "<p>这是用于全流程测试的自造订阅正文。</p>",
}]}
body = json.dumps(payload, ensure_ascii=False).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
if __name__ == "__main__":
http.server.ThreadingHTTPServer(("127.0.0.1", 18763), Fixture).serve_forever()
+46
View File
@@ -0,0 +1,46 @@
#!/bin/sh
set -eu
if [ "$#" -ne 1 ]; then
printf 'Usage: sh scripts/prepare-source-import-fixture.sh <booted-simulator-UUID>\n' >&2
exit 2
fi
legado_fixture_device="$1"
legado_fixture_repo="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
legado_fixture_build="$(mktemp -d "${TMPDIR:-/tmp}/legado-source-import.XXXXXX")"
trap 'rm -rf "$legado_fixture_build"' EXIT HUP INT TERM
legado_fixture_app="$legado_fixture_build/SourceImportFixtures.app"
legado_fixture_sdk="$(xcrun --sdk iphonesimulator --show-sdk-path)"
legado_fixture_arch="$(uname -m)"
mkdir -p "$legado_fixture_app"
cat > "$legado_fixture_app/Info.plist" <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleIdentifier</key><string>com.epiboly.legado.source-import-fixtures</string>
<key>CFBundleExecutable</key><string>SourceImportFixtures</string>
<key>CFBundleName</key><string>SourceImportFixtures</string>
<key>CFBundleDisplayName</key><string>书源导入测试</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleVersion</key><string>1</string>
<key>CFBundleShortVersionString</key><string>1.0</string>
<key>MinimumOSVersion</key><string>15.0</string>
<key>LSRequiresIPhoneOS</key><true/>
<key>UIFileSharingEnabled</key><true/>
<key>LSSupportsOpeningDocumentsInPlace</key><true/>
<key>UILaunchScreen</key><dict/>
<key>UISupportedInterfaceOrientations</key><array><string>UIInterfaceOrientationPortrait</string></array>
</dict></plist>
PLIST
xcrun --sdk iphonesimulator swiftc -parse-as-library -swift-version 6 \
-sdk "$legado_fixture_sdk" -target "$legado_fixture_arch-apple-ios15.0-simulator" \
-module-cache-path "$legado_fixture_repo/DerivedData/ModuleCache.noindex" \
"$legado_fixture_repo/scripts/fixtures/SourceImportFixture.swift" \
-o "$legado_fixture_app/SourceImportFixtures"
python3 "$legado_fixture_repo/scripts/fixtures/make-epub.py" "$legado_fixture_app/EPUB-Book.epub"
xcrun simctl install "$legado_fixture_device" "$legado_fixture_app"
xcrun simctl launch --terminate-running-process "$legado_fixture_device" com.epiboly.legado.source-import-fixtures
printf 'JSON, TXT and EPUB import fixtures are ready in Files > On My iPhone > 书源导入测试.\n'
+3 -1
View File
@@ -7,9 +7,11 @@ cd "$workspace_dir"
derived_data_path=${LEGADO_DERIVED_DATA_PATH:-"$workspace_dir/DerivedData"}
source_packages_path=${LEGADO_SOURCE_PACKAGES_PATH:-"$derived_data_path/SourcePackages"}
echo "[1/5] Validate bundled resources"
echo "[1/5] Validate bundled resources and application source"
python3 -m json.tool App/Resources/txtTocRule.json >/dev/null
python3 -m json.tool App/Resources/httpTTS.json >/dev/null
python3 -m json.tool docs/altstore.json >/dev/null
python3 -m unittest discover -s scripts/tests -v
echo "[2/5] Check project and Swift formatting hazards"
plutil -lint Legado.xcodeproj/project.pbxproj >/dev/null
+131
View File
@@ -0,0 +1,131 @@
import hashlib
import importlib.util
import plistlib
import tempfile
import unittest
import zipfile
from pathlib import Path
spec = importlib.util.spec_from_file_location("altstore_source", Path(__file__).parents[1] / "update-altstore-source.py")
altstore_source = importlib.util.module_from_spec(spec)
spec.loader.exec_module(altstore_source)
class AltStoreSourceTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.ipa = Path(temporary.name) / "reader-unsigned.ipa"
self.info = {
"CFBundleIdentifier": "com.epiboly.legado",
"CFBundleName": "Reader",
"CFBundleDisplayName": "小浣熊阅读",
"CFBundleShortVersionString": "1.2.3",
"CFBundleVersion": "42",
"MinimumOSVersion": "15.0",
"NSCameraUsageDescription": "扫描导入二维码",
"NSPhotoLibraryAddUsageDescription": "保存图片",
"NSAppTransportSecurity": {"NSAllowsArbitraryLoads": True},
}
self.release = {
"tag_name": "v1.2.3",
"published_at": "2026-10-19T01:02:03Z",
"draft": False,
"prerelease": False,
"body": "更新阅读体验。",
}
self.write_ipa()
def write_ipa(self, extra=None):
with zipfile.ZipFile(self.ipa, "w") as archive:
archive.writestr("Payload/Reader.app/Info.plist", plistlib.dumps(self.info, fmt=plistlib.FMT_BINARY))
if extra:
archive.writestr(extra, b"fixture")
self.release["assets"] = [{
"name": self.ipa.name,
"state": "uploaded",
"size": self.ipa.stat().st_size,
"digest": "sha256:" + hashlib.sha256(self.ipa.read_bytes()).hexdigest(),
"browser_download_url": "https://github.com/Aminark/legado-ios/releases/download/v1.2.3/reader-unsigned.ipa",
}]
def generate(self):
return altstore_source.make_source(self.ipa, self.release, "Aminark/legado-ios", "main")
def test_metadata_permissions_and_legacy_fields_match_ipa(self):
source = self.generate()
self.assertEqual(source["sourceURL"], "https://raw.githubusercontent.com/Aminark/legado-ios/main/docs/altstore.json")
app = source["apps"][0]
version = app["versions"][0]
self.assertEqual(app["bundleIdentifier"], self.info["CFBundleIdentifier"])
self.assertEqual(version["version"], "1.2.3")
self.assertEqual(version["buildVersion"], "42")
self.assertEqual(version["minOSVersion"], "15.0")
self.assertEqual(version["date"], self.release["published_at"])
for key in ("version", "size", "downloadURL"):
self.assertEqual(app[key], version[key])
self.assertEqual(app["appPermissions"], {
"entitlements": [],
"privacy": {
"NSCameraUsageDescription": "扫描导入二维码",
"NSPhotoLibraryAddUsageDescription": "保存图片",
},
})
def test_rejects_drafts_and_prereleases(self):
for key in ("draft", "prerelease"):
with self.subTest(key=key):
self.release[key] = True
with self.assertRaisesRegex(ValueError, "published stable"):
self.generate()
self.release[key] = False
def test_rejects_wrong_release_asset(self):
for key, value in (("size", 1), ("digest", "sha256:wrong"),
("browser_download_url", "https://example.com/wrong.ipa"),
("name", "another.ipa"), ("state", "new")):
with self.subTest(key=key):
self.write_ipa()
self.release["assets"][0][key] = value
with self.assertRaises(ValueError):
self.generate()
def test_older_github_metadata_without_digest_is_supported(self):
del self.release["assets"][0]["digest"]
self.assertEqual(self.generate()["apps"][0]["version"], "1.2.3")
def test_rejects_packages_needing_an_entitlement_audit(self):
for extra in ("Payload/Reader.app/_CodeSignature/CodeResources",
"Payload/Reader.app/embedded.mobileprovision",
"Payload/Reader.app/PlugIns/Widget.appex/Info.plist"):
with self.subTest(extra=extra):
self.write_ipa(extra)
with self.assertRaisesRegex(ValueError, "entitlement audit"):
self.generate()
def test_rejects_unresolved_build_metadata(self):
self.info["CFBundleVersion"] = "$(CURRENT_PROJECT_VERSION)"
self.write_ipa()
with self.assertRaisesRegex(ValueError, "CFBundleVersion"):
self.generate()
def test_rejects_release_and_package_mismatches(self):
self.info["CFBundleIdentifier"] = "com.example.another-app"
self.write_ipa()
with self.assertRaisesRegex(ValueError, "bundle identifier"):
self.generate()
self.info["CFBundleIdentifier"] = "com.epiboly.legado"
self.info["CFBundleShortVersionString"] = "1.2.4"
self.write_ipa()
with self.assertRaisesRegex(ValueError, "Release tag"):
self.generate()
def test_rejects_publication_dates_without_a_timezone(self):
self.release["published_at"] = "2026-10-19T01:02:03"
with self.assertRaisesRegex(ValueError, "timezone"):
self.generate()
if __name__ == "__main__":
unittest.main()
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Generate the AltStore/SideStore source from a published release's unsigned IPA."""
import argparse
import hashlib
import json
import plistlib
import re
import zipfile
from datetime import datetime
from pathlib import Path
from urllib.parse import quote
def make_source(ipa: Path, release: dict, repository: str, branch: str) -> dict:
if release.get("draft") or release.get("prerelease"):
raise ValueError("Only published stable releases belong in this source")
date = release["published_at"]
if datetime.fromisoformat(date.replace("Z", "+00:00")).tzinfo is None:
raise ValueError("Release publication date must include a timezone")
assets = [asset for asset in release["assets"] if asset["name"] == ipa.name]
if len(assets) != 1 or assets[0].get("state") != "uploaded":
raise ValueError("IPA must match exactly one uploaded release asset")
asset = assets[0]
if ipa.stat().st_size != asset["size"]:
raise ValueError("IPA size differs from the published release asset")
digest = asset.get("digest")
if digest and digest != "sha256:" + hashlib.sha256(ipa.read_bytes()).hexdigest():
raise ValueError("IPA checksum differs from the published release asset")
with zipfile.ZipFile(ipa) as archive:
names = archive.namelist()
info_paths = [name for name in names if re.fullmatch(r"Payload/[^/]+\.app/Info\.plist", name)]
if len(info_paths) != 1:
raise ValueError("Expected exactly one app in Payload")
if any("/_CodeSignature/" in name or name.endswith("/embedded.mobileprovision")
or ".appex/" in name for name in names):
raise ValueError("Use an unsigned IPA without extensions; signed apps need an entitlement audit")
info = plistlib.loads(archive.read(info_paths[0]))
for key in ("CFBundleIdentifier", "CFBundleShortVersionString", "CFBundleVersion", "MinimumOSVersion"):
if not isinstance(info.get(key), str) or not info[key] or "$(" in info[key]:
raise ValueError(f"IPA has an invalid or unresolved {key}")
if info["CFBundleIdentifier"] != "com.epiboly.legado":
raise ValueError("IPA bundle identifier does not belong to this app")
if release["tag_name"] != "v" + info["CFBundleShortVersionString"]:
raise ValueError("Release tag does not match the IPA version")
website = f"https://github.com/{repository}"
download_url = f"{website}/releases/download/{quote(release['tag_name'], safe='')}/{quote(ipa.name, safe='')}"
if asset["browser_download_url"] != download_url:
raise ValueError("Release asset URL does not match the repository, tag and IPA name")
raw_url = f"https://raw.githubusercontent.com/{repository}/{quote(branch, safe='')}"
icon_url = f"{raw_url}/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png"
version = {
"version": info["CFBundleShortVersionString"],
"buildVersion": info["CFBundleVersion"],
"date": date,
"localizedDescription": release.get("body") or release.get("name") or release["tag_name"],
"downloadURL": download_url,
"size": asset["size"],
"minOSVersion": info["MinimumOSVersion"],
}
return {
"name": "小浣熊阅读",
"identifier": "com.epiboly.legado.source",
"subtitle": "小浣熊阅读官方应用源",
"description": "小浣熊阅读的最新正式版,供 AltStore Classic、SideStore 和 LiveContainer 添加。",
"sourceURL": f"{raw_url}/docs/altstore.json",
"website": website,
"iconURL": icon_url,
"tintColor": "#315C50",
"apps": [{
"name": info.get("CFBundleDisplayName") or info["CFBundleName"],
"bundleIdentifier": info["CFBundleIdentifier"],
"developerName": repository.split("/")[0],
"subtitle": "搜书快一点,读书舒服一点。",
"localizedDescription": "一款优先兼容阅读书源的开源 iOS 阅读器,支持书架、搜索、阅读排版、本地 TXT、语音朗读和 RSS。应用不提供书源或在线书库,书源需自行导入。",
"iconURL": icon_url,
"tintColor": "#315C50",
"category": "utilities",
"screenshots": [
{"imageURL": f"{raw_url}/docs/images/{name}.png", "width": 1206, "height": 2622}
for name in ("bookshelf", "search", "reader")
],
"versions": [version],
"version": version["version"],
"versionDate": date,
"versionDescription": version["localizedDescription"],
"downloadURL": download_url,
"size": asset["size"],
"appPermissions": {
"entitlements": [],
"privacy": {key: value for key, value in info.items()
if key.startswith("NS") and "UsageDescription" in key},
},
}],
"news": [],
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--ipa", required=True, type=Path)
parser.add_argument("--release", required=True, type=Path, help="GitHub REST release JSON")
parser.add_argument("--repository", default="Aminark/legado-ios")
parser.add_argument("--branch", default="main", help="Branch hosting the source and images")
parser.add_argument("--output", type=Path, default=Path("docs/altstore.json"))
args = parser.parse_args()
try:
source = make_source(args.ipa, json.loads(args.release.read_text(encoding="utf-8")),
args.repository, args.branch)
except (ValueError, KeyError, OSError, zipfile.BadZipFile) as error:
parser.error(str(error))
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(source, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"Generated {args.output} from {args.ipa.name}")
if __name__ == "__main__":
main()