6 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
75 changed files with 3512 additions and 287 deletions
+1
View File
@@ -4,6 +4,7 @@ DerivedData/
*.xcuserstate *.xcuserstate
xcuserdata/ xcuserdata/
reference/ reference/
/GITHUB_UPLOAD.local.md
artifacts/ artifacts/
*.xcresult/ *.xcresult/
+96
View File
@@ -207,9 +207,89 @@ struct ReadingPhotoPickerController: UIViewControllerRepresentable {
} }
} }
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 } enum ReadingSheetDetent { case medium, large }
extension View { 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( func readingPhotoPicker(
isPresented: Binding<Bool>, isPresented: Binding<Bool>,
onData: @escaping (Data) -> Void, onData: @escaping (Data) -> Void,
@@ -261,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 @ViewBuilder
func readingTabBarBackground() -> some View { func readingTabBarBackground() -> some View {
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI { if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI {
+38 -2
View File
@@ -77,6 +77,29 @@ private struct ReadingUnavailableLabelStyle: LabelStyle {
} }
extension View { 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 { func readingSearchable(text: Binding<String>, isPresented: Binding<Bool>, prompt: String) -> some View {
modifier(ReadingSearchModifier(text: text, isPresented: isPresented, prompt: prompt)) modifier(ReadingSearchModifier(text: text, isPresented: isPresented, prompt: prompt))
} }
@@ -144,7 +167,11 @@ private struct ReadingSearchModifier: ViewModifier {
} }
enum ReadingTheme { 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 accentSoft = adaptive(0xEEEEEF, dark: 0x29292C)
static let coral = Color(red: 0.76, green: 0.48, blue: 0.27) 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) 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 groupedCanvas = adaptive(0xF3F3F4, dark: 0x1C1C1F)
static let surface = adaptive(0xFFFFFF, dark: 0x232326) static let surface = adaptive(0xFFFFFF, dark: 0x232326)
static let separator = adaptive(0xE6E6E8, dark: 0x363639) 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 { private static func adaptive(_ light: UInt32, dark: UInt32) -> Color {
Color(uiColor: UIColor { traits in Color(uiColor: UIColor { traits in
@@ -273,6 +300,13 @@ struct BookCoverArtwork: View {
} }
var body: some View { var body: some View {
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 {
AsyncImage(url: remoteURL.flatMap(URL.init(string:))) { phase in AsyncImage(url: remoteURL.flatMap(URL.init(string:))) { phase in
if case .success(let image) = phase { if case .success(let image) = phase {
image.resizable().scaledToFill() image.resizable().scaledToFill()
@@ -280,6 +314,8 @@ struct BookCoverArtwork: View {
placeholder placeholder
} }
} }
}
}
.frame(width: width, height: height) .frame(width: width, height: height)
.clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))
.overlay(alignment: .leading) { .overlay(alignment: .leading) {
+11 -11
View File
@@ -26,9 +26,9 @@ private final class FileManagementModel: ObservableObject {
await reload(library) await reload(library)
} }
func importFiles(_ urls: [URL], library: BookLibraryModel) async { func importFiles(_ files: [LocalBookImportFile], library: BookLibraryModel) async {
await run(library) { await run(library) {
await library.importLocalFiles(urls) await library.importPickedLocalFiles(files)
errorMessage = library.errorMessage errorMessage = library.errorMessage
library.clearError() library.clearError()
} }
@@ -126,7 +126,7 @@ struct FileManagementView: View {
Button("完成") { selecting = false; selection.removeAll() } Button("完成") { selecting = false; selection.removeAll() }
} else { } else {
Menu { Menu {
Button("导入 TXT 文件", systemImage: "square.and.arrow.down") { importsFile = true } Button("导入 TXT / EPUB", systemImage: "square.and.arrow.down") { importsFile = true }
if tab == 0 { if tab == 0 {
Button("选择文件", systemImage: "checkmark.circle") { selecting = true } Button("选择文件", systemImage: "checkmark.circle") { selecting = true }
.disabled(displayedFiles.isEmpty) .disabled(displayedFiles.isEmpty)
@@ -136,6 +136,7 @@ struct FileManagementView: View {
} }
} }
} label: { Image(systemName: "ellipsis.circle") } } label: { Image(systemName: "ellipsis.circle") }
.accessibilityLabel("更多")
} }
} }
} }
@@ -162,11 +163,10 @@ struct FileManagementView: View {
private var importAndRenameView: some View { private var importAndRenameView: some View {
fileList fileList
.fileImporter(isPresented: $importsFile, allowedContentTypes: [.text, .plainText, .data], allowsMultipleSelection: true) { result in .readingLocalBookFileImporter(isPresented: $importsFile) { files in
switch result { Task { await model.importFiles(files, library: library) }
case .success(let urls): Task { await model.importFiles(urls, library: library) } } onError: { error in
case .failure(let error): model.errorMessage = error.localizedDescription model.errorMessage = error.localizedDescription
}
} }
.alert("重命名文件", isPresented: Binding(get: { renameURL != nil }, set: { if !$0 { renameURL = nil } }), presenting: renameURL) { url in .alert("重命名文件", isPresented: Binding(get: { renameURL != nil }, set: { if !$0 { renameURL = nil } }), presenting: renameURL) { url in
TextField("文件名", text: $newName) TextField("文件名", text: $newName)
@@ -177,7 +177,7 @@ struct FileManagementView: View {
Task { await model.run(library) { try await library.renameLocalFile(at: url, to: name) } } Task { await model.run(library) { try await library.renameLocalFile(at: url, to: name) } }
} }
} message: { _ in } message: { _ in
Text("保留 TXT 格式,书架中的书名、书签和阅读进度不变。") Text("保留原文件格式,书架中的书名、书签和阅读进度不变。")
} }
} }
@@ -239,8 +239,8 @@ struct FileManagementView: View {
} }
Section("文件") { Section("文件") {
if displayedFiles.isEmpty { if displayedFiles.isEmpty {
ReadingUnavailableView(query.isEmpty ? "还没有本地文件" : "没有匹配的文件", systemImage: "doc.text", description: Text("可从系统“文件”导入 TXT,文件会复制到应用中。")) ReadingUnavailableView(query.isEmpty ? "还没有本地文件" : "没有匹配的文件", systemImage: "doc.text", description: Text("可从系统“文件”导入 TXT 或 EPUB,文件会复制到应用中。"))
Button("导入 TXT 文件", systemImage: "plus") { importsFile = true } Button("导入 TXT / EPUB", systemImage: "plus") { importsFile = true }
} }
ForEach(displayedFiles) { file in ForEach(displayedFiles) { file in
Group { Group {
@@ -47,7 +47,9 @@ struct ReadingStatisticsView: View {
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(24) .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 { private var overviewGrid: some View {
+2 -2
View File
@@ -194,7 +194,7 @@ struct BookDetailView: View {
.font(.body.weight(.medium)) .font(.body.weight(.medium))
.frame(maxWidth: .infinity, minHeight: 28) .frame(maxWidth: .infinity, minHeight: 28)
} }
.buttonStyle(.borderedProminent) .readingProminentButtonStyle()
.disabled(model.chapters.isEmpty) .disabled(model.chapters.isEmpty)
Button { Button {
if library.contains(model.book) { if library.contains(model.book) {
@@ -210,7 +210,7 @@ struct BookDetailView: View {
.font(.subheadline) .font(.subheadline)
.frame(maxWidth: dynamicTypeSize.isAccessibilitySize ? .infinity : nil, minHeight: 28) .frame(maxWidth: dynamicTypeSize.isAccessibilitySize ? .infinity : nil, minHeight: 28)
} }
.buttonStyle(.bordered) .readingSecondaryButtonStyle()
} }
@ViewBuilder @ViewBuilder
+23 -4
View File
@@ -4,6 +4,20 @@ import SwiftUI
import LegadoCore import LegadoCore
#endif #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 { enum AppBookChapterRepository {
static func live() -> any BookChapterRepository { static func live() -> any BookChapterRepository {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
@@ -318,10 +332,15 @@ final class BookLibraryModel: ObservableObject {
} }
func importLocalFiles(_ urls: [URL]) async { 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] = [] var failures: [String] = []
for url in urls { for file in files {
let granted = url.startAccessingSecurityScopedResource() let url = file.url
defer { if granted { url.stopAccessingSecurityScopedResource() } }
do { do {
let stored = try await localFiles.importFile(from: url) let stored = try await localFiles.importFile(from: url)
do { do {
@@ -341,7 +360,7 @@ final class BookLibraryModel: ObservableObject {
func addLocalFile(at url: URL) async throws { func addLocalFile(at url: URL) async throws {
guard !books.contains(where: { $0.bookUrl == url.absoluteString }) else { return } 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 !books.contains(where: { $0.bookUrl == url.absoluteString }) else { return }
guard await persist(books + [report.book]) else { guard await persist(books + [report.book]) else {
throw LocalFileError.saveFailed(errorMessage ?? "无法写入数据库") throw LocalFileError.saveFailed(errorMessage ?? "无法写入数据库")
+6 -2
View File
@@ -33,16 +33,20 @@ struct LocalBookDetailView: View {
ScrollView { ScrollView {
VStack(spacing: 16) { VStack(spacing: 16) {
VStack(spacing: 14) { 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()) Text(book.name).font(.title2.bold())
if !book.author.isEmpty { Text(book.author).font(.subheadline).foregroundStyle(.secondary) }
HStack { 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") } if let charset = book.charset { StatusPill(text: charset, color: ReadingTheme.coral, systemImage: "character") }
} }
Text(book.originName).font(.caption).foregroundStyle(.secondary) Text(book.originName).font(.caption).foregroundStyle(.secondary)
} }
.padding(.top, 12) .padding(.top, 12)
if let intro = book.intro, !intro.isEmpty {
ReadingCard { Text(intro).font(.subheadline).frame(maxWidth: .infinity, alignment: .leading) }
}
ReadingCard { ReadingCard {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
HStack { HStack {
+1 -1
View File
@@ -151,6 +151,6 @@ struct SourceSwitchView: View {
.padding(.vertical, 5) .padding(.vertical, 5)
} }
.disabled(model.switchingID != nil) .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("添加带有发现分类的书源,即可在这里浏览书籍。") Text("添加带有发现分类的书源,即可在这里浏览书籍。")
} actions: { } actions: {
NavigationLink("管理书源") { SourceManagementView() } NavigationLink("管理书源") { SourceManagementView() }
.buttonStyle(.borderedProminent) .readingProminentButtonStyle()
} }
} else { } else {
LazyVStack(spacing: 0) { LazyVStack(spacing: 0) {
+69 -31
View File
@@ -10,29 +10,42 @@ final class RSSLibraryModel: ObservableObject {
@Published private(set) var isWorking = false @Published private(set) var isWorking = false
@Published var errorMessage: String? @Published var errorMessage: String?
private let sourceRepository: SQLiteRssSourceRepository private var sourceRepository: SQLiteRssSourceRepository { get throws { .init(database: try database) } }
private let articleRepository: SQLiteRssArticleRepository private var articleRepository: SQLiteRssArticleRepository { get throws { .init(database: try database) } }
private let starRepository: SQLiteRssStarRepository private var starRepository: SQLiteRssStarRepository { get throws { .init(database: try database) } }
private let recordRepository: SQLiteRssReadRecordRepository private var recordRepository: SQLiteRssReadRecordRepository { get throws { .init(database: try database) } }
private let service: RSSService private let service: RSSService
private let importer = RSSSourceImporter() 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 nextURL: String?
private var page = 1 private var page = 1
private var order: Int64 = 0 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()) { init(database: LegadoDatabase, service: RSSService = .init()) {
self.database = database databaseResult = .success(database)
sourceRepository = .init(database: database)
articleRepository = .init(database: database)
starRepository = .init(database: database)
recordRepository = .init(database: database)
self.service = service self.service = service
} }
static func live() -> RSSLibraryModel { private init(databaseError: Error) {
let database = try! LegadoDatabase(url: LegadoDatabase.defaultURL()) let error = LegadoDatabaseError.open("RSS 数据库无法打开:\(databaseError.localizedDescription)。请检查存储空间后重新启动应用。")
return .init(database: database) 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 { func load() async {
@@ -62,15 +75,16 @@ final class RSSLibraryModel: ObservableObject {
func importText(_ text: String) async { func importText(_ text: String) async {
await perform { await perform {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
var merged: [RssSource]
if let url = URL(string: trimmed), ["http", "https"].contains(url.scheme?.lowercased() ?? "") { 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 { } else {
let payload = try self.importer.decode(Data(trimmed.utf8)) 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) } 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 { 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 name = sortName ?? source.sourceName
let url = sortURL ?? source.sortUrl?.nilIfBlank ?? source.sourceUrl 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) 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 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) 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.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 { 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 let name = sortName ?? source.sourceName
await perform { guard activeFeed == Feed(origin: source.sourceUrl, sort: name, key: key),
let report = try await self.service.articles(sortName: name, sortURL: nextURL, source: source, page: self.page, 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 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) 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.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) let payload = try importer.decode(data)
return try importer.merge(payload.sources, into: existing) return try importer.merge(payload.sources, into: existing)
} }
private func perform(_ operation: () async throws -> Void) async { private func perform(isCurrent: () -> Bool = { true }, _ operation: () async throws -> Void) async {
isWorking = true; defer { isWorking = false } activeOperations += 1
do { try await operation() } catch { errorMessage = error.localizedDescription } 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) { ScrollView(.horizontal, showsIndicators: false) {
HStack { ForEach(Array(sorts.enumerated()), id: \.element.id) { index, sort in HStack { ForEach(Array(sorts.enumerated()), id: \.element.id) { index, sort in
Button(sort.name.isEmpty ? "默认" : sort.name) { selectedSort = index; Task { await refresh() } } 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(.horizontal)
}.padding(.vertical, 6) }.padding(.vertical, 6)
} }
@@ -151,14 +151,20 @@ private struct RSSArticleListView: View {
.overlay { if library.articles.isEmpty, !library.isWorking { ReadingUnavailableView("没有文章", systemImage: "newspaper") } } .overlay { if library.articles.isEmpty, !library.isWorking { ReadingUnavailableView("没有文章", systemImage: "newspaper") } }
.navigationTitle(source.sourceName) .navigationTitle(source.sourceName)
.searchable(text: $searchKey, prompt: "源内搜索") .searchable(text: $searchKey, prompt: "源内搜索")
.onSubmit(of: .search) { Task { await refresh() } }
.refreshable { await refresh() } .refreshable { await refresh() }
.toolbar { .toolbar {
ToolbarItem(placement: .primaryAction) { Button("刷新", systemImage: "arrow.clockwise") { Task { await refresh() } } } 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 { RSSSourceEditorView(source: source) } label: { Label("编辑", systemImage: "pencil") } }
ToolbarItem(placement: .primaryAction) { NavigationLink { RSSSourceDebugView(source: source) } label: { Label("调试", systemImage: "stethoscope") } } 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() } .task { sorts = await RSSSortResolver().resolve(source); await refresh() }
.readingTabBarHidden()
} }
private var activeSort: RSSSort { sorts.indices.contains(selectedSort) ? sorts[selectedSort] : .init(name: "", url: source.sourceUrl) } 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) Image(systemName: "doc.badge.arrow.up").font(.system(size: 48)).foregroundStyle(ReadingTheme.accent)
Text(fileName).font(.headline) Text(fileName).font(.headline)
Text(ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file)).foregroundStyle(.secondary) 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() } } } }.padding().navigationTitle("文件下载").toolbar { ToolbarItem(placement: .cancellationAction) { Button("关闭") { dismiss() } } }
.task(id: url) { await download() } .task(id: url) { await download() }
@@ -464,7 +470,33 @@ private struct RSSBinaryDocument: FileDocument {
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { .init(regularFileWithContents: data) } 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 @Environment(\.dismiss) private var dismiss
let value: String let value: String
let source: RssSource let source: RssSource
@@ -480,16 +512,18 @@ private struct RSSPhotoView: View {
else if loading { ProgressView() } else if loading { ProgressView() }
else { ReadingUnavailableView("图片加载失败", systemImage: "photo.badge.exclamationmark") } else { ReadingUnavailableView("图片加载失败", systemImage: "photo.badge.exclamationmark") }
}.frame(minWidth: proxy.size.width, minHeight: proxy.size.height) }.frame(minWidth: proxy.size.width, minHeight: proxy.size.height)
}.background(.black) }
.background(.black)
.environment(\.colorScheme, .dark)
.tint(.white)
} }
.navigationTitle(source.sourceName).navigationBarTitleDisplayMode(.inline) .navigationTitle(source.sourceName).navigationBarTitleDisplayMode(.inline)
.toolbar { .readingNavigationBarBackground()
ToolbarItem(placement: .primaryAction) { Button("保存到照片", systemImage: "square.and.arrow.down") { save() }.disabled(image == nil) } .modifier(RSSPhotoToolbar(canSave: image != nil, save: save, close: { dismiss() }))
ToolbarItem(placement: .confirmationAction) { Button("完成") { dismiss() } }
}
.task(id: value) { await load() } .task(id: value) { await load() }
.alert("图片", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) { Button("好") { message = nil } } message: { Text(message ?? "") } .alert("图片", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) { Button("好") { message = nil } } message: { Text(message ?? "") }
} }
.readingTabBarHidden()
} }
private func load() async { private func load() async {
+3
View File
@@ -41,6 +41,9 @@ struct RSSWebReaderView: UIViewRepresentable {
controller.addUserScript(.init(source: inject, injectionTime: .atDocumentEnd, forMainFrameOnly: false)) controller.addUserScript(.init(source: inject, injectionTime: .atDocumentEnd, forMainFrameOnly: false))
} }
let webView = WKWebView(frame: .zero, configuration: configuration) 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.navigationDelegate = context.coordinator
webView.uiDelegate = context.coordinator webView.uiDelegate = context.coordinator
webView.allowsBackForwardNavigationGestures = true 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
}
}
}
+20 -11
View File
@@ -129,7 +129,8 @@ struct ReaderScrollView: UIViewRepresentable {
let layout = view.collectionViewLayout as! ReaderContinuousLayout let layout = view.collectionViewLayout as! ReaderContinuousLayout
let geometry = ReaderContinuousLayout.geometry( let geometry = ReaderContinuousLayout.geometry(
blocks: newBlocks, width: view.bounds.width, 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 targetY = anchor.flatMap { offset(for: $0, blocks: newBlocks, frames: geometry.frames) }
let correction = targetY.map { $0 - view.contentOffset.y } ?? 0 let correction = targetY.map { $0 - view.contentOffset.y } ?? 0
@@ -277,10 +278,14 @@ struct ReaderScrollView: UIViewRepresentable {
private func captureAnchor() -> Anchor? { private func captureAnchor() -> Anchor? {
guard let view, !blocks.isEmpty else { return nil } guard let view, !blocks.isEmpty else { return nil }
let layout = view.collectionViewLayout as! ReaderContinuousLayout let layout = view.collectionViewLayout as! ReaderContinuousLayout
let y = max(view.contentOffset.y, layout.frames.first?.minY ?? 0) // The reading position starts below the configured top padding.
guard let row = layout.row(at: y) else { return nil } // 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 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 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 safeGlyph = min(glyph, max(chapter.manager.numberOfGlyphs - 1, 0))
let character = chapter.manager.characterIndexForGlyph(at: safeGlyph) let character = chapter.manager.characterIndexForGlyph(at: safeGlyph)
@@ -385,17 +390,21 @@ final class ReaderContinuousLayout: UICollectionViewLayout {
private var size = CGSize.zero private var size = CGSize.zero
override var collectionViewContentSize: CGSize { size } override var collectionViewContentSize: CGSize { size }
static func geometry(blocks: [ReaderScrollBlock], width: CGFloat, top: CGFloat, bottom: CGFloat, viewportHeight: CGFloat) -> (frames: [CGRect], size: CGSize) { static func geometry(blocks: [ReaderScrollBlock], width: CGFloat, top: CGFloat, bottom: CGFloat, viewportHeight: CGFloat, scale: CGFloat) -> (frames: [CGRect], size: CGSize) {
var y = top 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 frames = blocks.map { block -> CGRect in
let frame = CGRect(x: 0, y: y, width: width, height: block.height) let height = ceil(block.height * scale)
y += block.height let frame = CGRect(x: 0, y: pixels / scale, width: width, height: height / scale)
pixels += height
return frame return frame
} }
let finalChapter = blocks.last?.chapter.chapter.id let finalChapter = blocks.last?.chapter.chapter.id
let finalStart = blocks.indices.first(where: { blocks[$0].chapter.chapter.id == finalChapter }) let finalStart = blocks.indices.first(where: { blocks[$0].chapter.chapter.id == finalChapter })
.map { frames[$0].minY } ?? top .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) { func install(frames: [CGRect], size: CGSize, correction: CGFloat) {
@@ -412,9 +421,9 @@ final class ReaderContinuousLayout: UICollectionViewLayout {
var low = 0, high = frames.count var low = 0, high = frames.count
while low < high { while low < high {
let middle = (low + high) / 2 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]? { override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
+28 -8
View File
@@ -18,6 +18,7 @@ struct ReaderView: View {
@StateObject private var speech = ReaderSpeechController() @StateObject private var speech = ReaderSpeechController()
@State private var showsTableOfContents = false @State private var showsTableOfContents = false
@State private var showsBookmarks = false @State private var showsBookmarks = false
@State private var illustrationChapter: BookChapter?
@State private var showsAutoReadControls = false @State private var showsAutoReadControls = false
@State private var showsStyleControls = false @State private var showsStyleControls = false
@State private var isAutoReading = false @State private var isAutoReading = false
@@ -257,6 +258,9 @@ struct ReaderView: View {
} }
.sheet(isPresented: $showsTableOfContents) { tableOfContents } .sheet(isPresented: $showsTableOfContents) { tableOfContents }
.sheet(isPresented: $showsBookmarks) { bookmarksSheet } .sheet(isPresented: $showsBookmarks) { bookmarksSheet }
.sheet(item: $illustrationChapter) { chapter in
EPUBIllustrationsView(book: book, chapter: chapter)
}
.sheet(isPresented: $showsAutoReadControls) { autoReadControls } .sheet(isPresented: $showsAutoReadControls) { autoReadControls }
.sheet(isPresented: $showsStyleControls) { styleControls } .sheet(isPresented: $showsStyleControls) { styleControls }
.sheet(isPresented: $showsContentSearch) { contentSearchSheet } .sheet(isPresented: $showsContentSearch) { contentSearchSheet }
@@ -492,7 +496,7 @@ struct ReaderView: View {
.accessibilityIdentifier("reader.failureChangeSource") .accessibilityIdentifier("reader.failureChangeSource")
} }
} }
.buttonStyle(.bordered) .readingSecondaryButtonStyle()
} }
private func openSourceSwitch() { private func openSourceSwitch() {
@@ -698,6 +702,7 @@ struct ReaderView: View {
|| showsReaderMenu || showsReaderMenu
|| showsTableOfContents || showsTableOfContents
|| showsBookmarks || showsBookmarks
|| illustrationChapter != nil
|| showsAutoReadControls || showsAutoReadControls
|| showsStyleControls || showsStyleControls
|| showsContentSearch || showsContentSearch
@@ -834,6 +839,12 @@ struct ReaderView: View {
showsReaderMenu = false showsReaderMenu = false
showsContentSearch = true 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") { Button("书签列表", systemImage: "bookmark") {
showsReaderMenu = false showsReaderMenu = false
showsBookmarks = true showsBookmarks = true
@@ -1118,7 +1129,7 @@ struct ReaderView: View {
Image(systemName: "textformat.size.smaller") Image(systemName: "textformat.size.smaller")
.frame(width: 38, height: 32) .frame(width: 38, height: 32)
} }
.buttonStyle(.bordered) .readingSecondaryButtonStyle()
VStack(spacing: 2) { VStack(spacing: 2) {
Text("字号") Text("字号")
@@ -1135,7 +1146,7 @@ struct ReaderView: View {
Image(systemName: "textformat.size.larger") Image(systemName: "textformat.size.larger")
.frame(width: 38, height: 32) .frame(width: 38, height: 32)
} }
.buttonStyle(.bordered) .readingSecondaryButtonStyle()
} }
HStack(spacing: 8) { HStack(spacing: 8) {
@@ -1285,14 +1296,19 @@ struct ReaderView: View {
Text(choice.title) Text(choice.title)
.font(.caption2) .font(.caption2)
} }
.foregroundStyle(pageAnimation == choice.value ? Color.white : Color.primary) .foregroundStyle(pageAnimation == choice.value ? ReadingTheme.onAccent : Color.primary)
.frame(width: 58, height: 54) .frame(width: 58, height: 54)
.background( .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) in: RoundedRectangle(cornerRadius: 12, style: .continuous)
) )
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(pageAnimation == choice.value ? ReadingTheme.selectionBorder : .clear, lineWidth: 1)
}
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityAddTraits(pageAnimation == choice.value ? .isSelected : [])
} }
} }
} }
@@ -1392,12 +1408,16 @@ struct ReaderView: View {
Button(action: action) { Button(action: action) {
Text(title) Text(title)
.font(.caption.weight(.medium)) .font(.caption.weight(.medium))
.foregroundStyle(selected ? Color.white : Color.primary) .foregroundStyle(selected ? ReadingTheme.onAccent : Color.primary)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 7) .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) .buttonStyle(.plain)
.accessibilityAddTraits(selected ? .isSelected : [])
} }
private func valueSlider( private func valueSlider(
@@ -1566,7 +1586,7 @@ struct ReaderView: View {
Text("正文") Text("正文")
} footer: { } footer: {
if !model.canEditContent { if !model.canEditContent {
Text("本地 TXT 直接来自原文件;为避免破坏章节偏移,本章编辑与网络刷新仅对网络书籍开放。") Text("本地 TXT / EPUB 直接读取原文件。本章编辑与网络刷新仅对网络书籍开放。")
} }
} }
@@ -39,7 +39,7 @@ struct QRCodeImportView: View {
} label: { } label: {
Label("从照片选择二维码", systemImage: "photo") Label("从照片选择二维码", systemImage: "photo")
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
}.buttonStyle(.borderedProminent).disabled(isReading) }.readingProminentButtonStyle().disabled(isReading)
Text("支持 HTTP(S) 链接、Legado 导入深链和 JSON 内容。识别后进入导入流程。") Text("支持 HTTP(S) 链接、Legado 导入深链和 JSON 内容。识别后进入导入流程。")
.font(.footnote).foregroundStyle(.secondary) .font(.footnote).foregroundStyle(.secondary)
if values.count > 1 { if values.count > 1 {
@@ -3,6 +3,26 @@ import UIKit
struct SyntaxCodeEditor: UIViewRepresentable { struct SyntaxCodeEditor: UIViewRepresentable {
enum Language { case json, rule, javascript, regex } 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 @Binding var text: String
var language: Language = .rule var language: Language = .rule
@@ -13,6 +33,7 @@ struct SyntaxCodeEditor: UIViewRepresentable {
view.delegate = context.coordinator view.delegate = context.coordinator
view.font = .monospacedSystemFont(ofSize: 14, weight: .regular) view.font = .monospacedSystemFont(ofSize: 14, weight: .regular)
view.backgroundColor = .secondarySystemGroupedBackground view.backgroundColor = .secondarySystemGroupedBackground
view.tintColor = UIColor(ReadingTheme.accent)
view.textContainerInset = UIEdgeInsets(top: 14, left: 12, bottom: 20, right: 12) view.textContainerInset = UIEdgeInsets(top: 14, left: 12, bottom: 20, right: 12)
view.autocorrectionType = .no view.autocorrectionType = .no
view.autocapitalizationType = .none view.autocapitalizationType = .none
@@ -24,6 +45,7 @@ struct SyntaxCodeEditor: UIViewRepresentable {
view.accessibilityIdentifier = "syntaxCodeEditor" view.accessibilityIdentifier = "syntaxCodeEditor"
if #available(iOS 16.0, *) { view.isFindInteractionEnabled = true } if #available(iOS 16.0, *) { view.isFindInteractionEnabled = true }
let bar = UIToolbar() let bar = UIToolbar()
bar.tintColor = UIColor(ReadingTheme.accent)
bar.sizeToFit() bar.sizeToFit()
bar.items = [ bar.items = [
UIBarButtonItem(image: UIImage(systemName: "magnifyingglass"), primaryAction: UIAction(title: "", subtitle: nil) { [weak view, coordinator = context.coordinator] _ in 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, UIBarButtonItem(title: symbol == "\n" ? "↵" : symbol == "\t" ? "⇥" : symbol,
primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.insertText(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() })] } + [UIBarButtonItem(systemItem: .flexibleSpace), UIBarButtonItem(title: "完成", primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.resignFirstResponder() })]
bar.items?.last?.accessibilityIdentifier = "syntaxCodeEditor.done"
if #available(iOS 26.0, *) {
view.inputAccessoryView = CodeInputAccessory(items: bar.items ?? [])
} else {
view.inputAccessoryView = bar view.inputAccessoryView = bar
}
view.text = text view.text = text
context.coordinator.highlight(view) context.coordinator.highlight(view)
return view return view
@@ -126,12 +153,12 @@ struct SyntaxCodeEditor: UIViewRepresentable {
storage.addAttributes([.font: UIFont.monospacedSystemFont(ofSize: 14, weight: .regular), .foregroundColor: UIColor.label], range: all) storage.addAttributes([.font: UIFont.monospacedSystemFont(ofSize: 14, weight: .regular), .foregroundColor: UIColor.label], range: all)
if storage.length <= 500_000 { if storage.length <= 500_000 {
let tokens: [(String, UIColor)] = [ 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(?: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"#, .systemOrange), (#"\b\d+(?:\.\d+)?\b"#, Palette.number),
(#"@(?:css|xpath|json|js|put|get):?|\$\.|&&|\|\||%%|##|\{\{|\}\}"#, .systemPink), (#"@(?:css|xpath|json|js|put|get):?|\$\.|&&|\|\||%%|##|\{\{|\}\}"#, Palette.operation),
(#"[{}\[\]()]"#, .systemTeal), (#"[{}\[\]()]"#, Palette.punctuation),
(#""(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`"#, .systemGreen), (#""(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`"#, Palette.string),
(#""(?:\\.|[^"\\])*"(?=\s*:)"#, .systemBlue), (#""(?:\\.|[^"\\])*"(?=\s*:)"#, Palette.key),
] ]
for (pattern, color) in tokens { for (pattern, color) in tokens {
guard let regex = try? NSRegularExpression(pattern: pattern) else { continue } guard let regex = try? NSRegularExpression(pattern: pattern) else { continue }
@@ -143,7 +170,7 @@ struct SyntaxCodeEditor: UIViewRepresentable {
for pattern in [#"[.*+?^$|]"#, #"\\[dDsSwWbB0-9]|\{[0-9,]+\}"#] { for pattern in [#"[.*+?^$|]"#, #"\\[dDsSwWbB0-9]|\{[0-9,]+\}"#] {
let regex = try? NSRegularExpression(pattern: pattern) let regex = try? NSRegularExpression(pattern: pattern)
regex?.enumerateMatches(in: view.text, range: all) { result, _, _ in 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 pattern = #"(?m)^\s*//.*$|/\*[\s\S]*?\*/"#
let regex = try? NSRegularExpression(pattern: pattern) let regex = try? NSRegularExpression(pattern: pattern)
regex?.enumerateMatches(in: view.text, range: all) { result, _, _ in 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 { struct RuleCodeFieldView: View {
let title: String let title: String
let path: String let path: String
@@ -92,9 +92,10 @@ struct ReplaceRuleEditorView: View {
RuleCodeFieldView(title: "匹配内容", path: "pattern", text: $rule.pattern, language: .regex) RuleCodeFieldView(title: "匹配内容", path: "pattern", text: $rule.pattern, language: .regex)
} label: { fieldPreview("匹配内容", value: rule.pattern) } } label: { fieldPreview("匹配内容", value: rule.pattern) }
NavigationLink { 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) } } label: { fieldPreview("替换为(留空即删除)", value: rule.replacement) }
Text("正则替换使用 $1、$2 引用捕获组;普通文本替换按字面匹配。") Text("正则替换使用 $1、$2 引用捕获组;以 @js: 开头可运行 JavaScript,result 为当前匹配文本,可读取 book 和 chapter。普通文本替换按字面匹配。")
.font(.footnote).foregroundStyle(.secondary) .font(.footnote).foregroundStyle(.secondary)
} }
Section("作用范围") { Section("作用范围") {
+2 -1
View File
@@ -64,7 +64,8 @@ final class SearchViewModel: ObservableObject {
pendingSourceIDs = Set(pendingSources.map(\.id)) pendingSourceIDs = Set(pendingSources.map(\.id))
pagedSources = pendingSources.filter { pagedSources = pendingSources.filter {
let url = $0.searchUrl ?? "" 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 }) sourceNames = Dictionary(pendingSources.map { ($0.id, $0.bookSourceName) }, uniquingKeysWith: { first, _ in first })
accumulator = .init() accumulator = .init()
+1
View File
@@ -80,6 +80,7 @@ private struct OpenSourceLicensesView: View {
license("小浣熊阅读 / Legado", resource: "Legado-LICENSE", detail: "GNU GPL v3") license("小浣熊阅读 / Legado", resource: "Legado-LICENSE", detail: "GNU GPL v3")
license("SwiftSoup", resource: "SwiftSoup-LICENSE", detail: "MIT License") license("SwiftSoup", resource: "SwiftSoup-LICENSE", detail: "MIT License")
license("Kanna", resource: "Kanna-LICENSE", detail: "MIT License") license("Kanna", resource: "Kanna-LICENSE", detail: "MIT License")
license("ZIPFoundation", resource: "ZIPFoundation-LICENSE", detail: "MIT License")
}.navigationTitle("开源许可").navigationBarTitleDisplayMode(.inline) }.navigationTitle("开源许可").navigationBarTitleDisplayMode(.inline)
} }
+1 -2
View File
@@ -81,8 +81,7 @@ struct SourceDebugView: View {
Label(model.isRunning ? "正在调试…" : "运行完整链路", systemImage: "play.fill") Label(model.isRunning ? "正在调试…" : "运行完整链路", systemImage: "play.fill")
.font(.headline).frame(maxWidth: .infinity) .font(.headline).frame(maxWidth: .infinity)
} }
.buttonStyle(.borderedProminent) .readingProminentButtonStyle()
.tint(ReadingTheme.accent)
.disabled(model.isRunning || model.keyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .disabled(model.isRunning || model.keyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
} }
} }
+3 -4
View File
@@ -44,11 +44,10 @@ struct SourceEditorView: View {
HStack { HStack {
ForEach(BookSourceEditorSchema.sections) { section in ForEach(BookSourceEditorSchema.sections) { section in
Button(section.title) { sectionID = section.id } Button(section.title) { sectionID = section.id }
.buttonStyle(.bordered) .readingChoiceButtonStyle(isSelected: sectionID == section.id)
.tint(sectionID == section.id ? ReadingTheme.accent : .secondary)
} }
Button("选项") { sectionID = "options" }.buttonStyle(.bordered) Button("选项") { sectionID = "options" }
.tint(sectionID == "options" ? ReadingTheme.accent : .secondary) .readingChoiceButtonStyle(isSelected: sectionID == "options")
}.padding(.horizontal) }.padding(.horizontal)
}.padding(.vertical, 6) }.padding(.vertical, 6)
Form { Form {
@@ -1,5 +1,6 @@
@preconcurrency import WebKit @preconcurrency import WebKit
import SwiftUI import SwiftUI
import UIKit
struct SourceLoginView: View { struct SourceLoginView: View {
let source: BookSource let source: BookSource
@@ -74,6 +75,9 @@ private struct SourceLoginWebView: UIViewRepresentable {
let configuration = WKWebViewConfiguration() let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .default() configuration.websiteDataStore = .default()
let webView = WKWebView(frame: .zero, configuration: configuration) 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.navigationDelegate = context.coordinator
context.coordinator.installExistingCookies(in: webView) { webView.load(request) } context.coordinator.installExistingCookies(in: webView) { webView.load(request) }
return webView return webView
@@ -61,7 +61,7 @@ struct SourceManagementView: View {
Text("新建书源,或从文件、链接、JSON、二维码导入。") Text("新建书源,或从文件、链接、JSON、二维码导入。")
} actions: { } actions: {
Button("导入书源", action: presentFileImporter) Button("导入书源", action: presentFileImporter)
.buttonStyle(.borderedProminent) .readingProminentButtonStyle()
} }
} else { } else {
List { List {
+18
View File
@@ -22,6 +22,24 @@
<string>public.data</string> <string>public.data</string>
</array> </array>
</dict> </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> </array>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
+10
View File
@@ -52,6 +52,7 @@ struct LegadoApp: App {
await bookLibrary.load() await bookLibrary.load()
await replaceRuleLibrary.load() await replaceRuleLibrary.load()
await readingActivity.load() await readingActivity.load()
await rssLibrary.load()
await ruleSubscriptions.load() await ruleSubscriptions.load()
if UserDefaults.standard.bool(forKey: "auto_refresh") { if UserDefaults.standard.bool(forKey: "auto_refresh") {
await bookLibrary.checkForUpdates(sources: sourceLibrary.sources, onlyRead: UserDefaults.standard.bool(forKey: "onlyUpdateRead")) await bookLibrary.checkForUpdates(sources: sourceLibrary.sources, onlyRead: UserDefaults.standard.bool(forKey: "onlyUpdateRead"))
@@ -60,6 +61,15 @@ struct LegadoApp: App {
} }
.onOpenURL { url in .onOpenURL { url in
Task { 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() await sourceLibrary.load()
if url.scheme?.lowercased() == "legado", url.path.lowercased() == "/replacerule" { if url.scheme?.lowercased() == "legado", url.path.lowercased() == "/replacerule" {
await replaceRuleLibrary.load() 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) SearchView(initialKeyword: automationSearchKeyword)
} else if requestedDemoPage == "sources" { } else if requestedDemoPage == "sources" {
SourceManagementView() 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 { } else {
BookshelfView() BookshelfView()
} }
@@ -99,7 +103,6 @@ struct RootTabView: View {
.tabItem { Label("我的", systemImage: "person.crop.circle") } .tabItem { Label("我的", systemImage: "person.crop.circle") }
.tag(Tab.my) .tag(Tab.my)
} }
.tint(ReadingTheme.accent)
.readingTabBarBackground() .readingTabBarBackground()
.preferredColorScheme(themeMode == "2" ? .dark : (themeMode == "1" || themeMode == "3" ? .light : nil)) .preferredColorScheme(themeMode == "2" ? .dark : (themeMode == "1" || themeMode == "3" ? .light : nil))
.transaction { transaction in .transaction { transaction in
@@ -123,6 +126,8 @@ struct RootTabView: View {
dismissButton: .default(Text("好")) { sourceLibrary.notice = nil } dismissButton: .default(Text("好")) { sourceLibrary.notice = nil }
) )
} }
.tint(ReadingTheme.accent)
.toggleStyle(SwitchToggleStyle(tint: ReadingTheme.toggleTint))
} }
private var requestedDemoPage: String? { private var requestedDemoPage: String? {
@@ -146,7 +151,7 @@ private struct BookshelfView: View {
@EnvironmentObject private var library: BookLibraryModel @EnvironmentObject private var library: BookLibraryModel
@EnvironmentObject private var sources: SourceLibraryModel @EnvironmentObject private var sources: SourceLibraryModel
@State private var showsLocalImporter = false @State private var showsLocalImporter = false
@State private var pendingLocalURLs: [URL] = [] @State private var pendingLocalFiles: [LocalBookImportFile] = []
@State private var selectedGroupID = BookGroup.idAll @State private var selectedGroupID = BookGroup.idAll
@State private var showsGroupManager = false @State private var showsGroupManager = false
@AppStorage("bookshelfLayout") private var bookshelfLayout = "grid3" @AppStorage("bookshelfLayout") private var bookshelfLayout = "grid3"
@@ -222,26 +227,17 @@ private struct BookshelfView: View {
} }
.background(ReadingTheme.canvas) .background(ReadingTheme.canvas)
.navigationBarHidden(true) .navigationBarHidden(true)
.fileImporter( .readingLocalBookFileImporter(isPresented: $showsLocalImporter) { files in
isPresented: $showsLocalImporter, pendingLocalFiles = files
allowedContentTypes: [.text, .plainText, .data], } onError: { error in
allowsMultipleSelection: true library.reportError(error)
) { 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)
}
} }
.sheet(isPresented: Binding( .sheet(isPresented: Binding(
get: { !pendingLocalURLs.isEmpty }, get: { !pendingLocalFiles.isEmpty },
set: { if !$0 { pendingLocalURLs = [] } } set: { if !$0 { pendingLocalFiles = [] } }
)) { )) {
LocalBookImportReviewView(urls: pendingLocalURLs) { LocalBookImportReviewView(files: pendingLocalFiles) {
pendingLocalURLs = [] pendingLocalFiles = []
} }
.environmentObject(library) .environmentObject(library)
} }
@@ -453,7 +449,7 @@ private struct BookshelfBookDestination: View {
let source: BookSource? let source: BookSource?
var body: some View { 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) ReaderView(demoBook: book, chapters: DemoContent.readerChapters, initialIndex: 0, content: DemoContent.readerText)
} else { } else {
BookshelfReadView(book: book, source: source) BookshelfReadView(book: book, source: source)
@@ -496,7 +492,7 @@ private struct ContinueReadingCard: View {
.foregroundStyle(ReadingTheme.onAccent) .foregroundStyle(ReadingTheme.onAccent)
.padding(.horizontal, 18) .padding(.horizontal, 18)
.frame(minHeight: 40) .frame(minHeight: 40)
.background(ReadingTheme.accent, in: Capsule()) .background(ReadingTheme.accentFill, in: Capsule())
.padding(.top, 20) .padding(.top, 20)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -646,15 +642,17 @@ private struct BookGroupSelectView: View {
private struct LocalBookImportReviewView: View { private struct LocalBookImportReviewView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@EnvironmentObject private var library: BookLibraryModel @EnvironmentObject private var library: BookLibraryModel
let urls: [URL] let files: [LocalBookImportFile]
let onFinish: () -> Void let onFinish: () -> Void
@State private var selectedPaths: Set<String> @State private var selectedPaths: Set<String>
@State private var isImporting = false @State private var isImporting = false
init(urls: [URL], onFinish: @escaping () -> Void) { private var urls: [URL] { files.map(\.url) }
self.urls = urls
init(files: [LocalBookImportFile], onFinish: @escaping () -> Void) {
self.files = files
self.onFinish = onFinish self.onFinish = onFinish
_selectedPaths = State(initialValue: Set(urls.map(\.path))) _selectedPaths = State(initialValue: Set(files.map { $0.url.path }))
} }
var body: some View { var body: some View {
@@ -690,9 +688,9 @@ private struct LocalBookImportReviewView: View {
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
Button("导入") { Button("导入") {
isImporting = true isImporting = true
let selected = urls.filter { selectedPaths.contains($0.path) } let selected = files.filter { selectedPaths.contains($0.url.path) }
Task { Task {
await library.importLocalFiles(selected) await library.importPickedLocalFiles(selected)
finish() finish()
} }
} }
@@ -726,7 +724,7 @@ private struct EmptyBookshelfHero: View {
} }
VStack(spacing: 12) { VStack(spacing: 12) {
Button("导入本地书", action: onImport) Button("导入本地书", action: onImport)
.buttonStyle(.borderedProminent) .readingProminentButtonStyle()
.controlSize(.large) .controlSize(.large)
NavigationLink("添加书源") { SourceManagementView() } NavigationLink("添加书源") { SourceManagementView() }
.font(.subheadline) .font(.subheadline)
@@ -914,6 +912,8 @@ struct SearchView: View {
} label: { } label: {
Label("搜索选项", systemImage: "slider.horizontal.3") Label("搜索选项", systemImage: "slider.horizontal.3")
} }
.accessibilityLabel("搜索选项")
.accessibilityIdentifier("search.options")
} }
} }
.sheet(isPresented: $showsScope) { .sheet(isPresented: $showsScope) {
@@ -993,7 +993,7 @@ struct SearchView: View {
Spacer() Spacer()
if model.isPaused { if model.isPaused {
Button("继续", systemImage: "play.fill") { model.resume() } Button("继续", systemImage: "play.fill") { model.resume() }
.buttonStyle(.bordered) .readingSecondaryButtonStyle()
.controlSize(.small) .controlSize(.small)
} else if model.state == .searching { } else if model.state == .searching {
StatusPill(text: "\(model.completedSourceCount)/\(model.totalSourceCount)", systemImage: "magnifyingglass") StatusPill(text: "\(model.completedSourceCount)/\(model.totalSourceCount)", systemImage: "magnifyingglass")
@@ -1044,10 +1044,10 @@ struct SearchView: View {
) { ) {
if model.isPaused { if model.isPaused {
Button("继续搜索", systemImage: "play.fill") { model.resume() } Button("继续搜索", systemImage: "play.fill") { model.resume() }
.buttonStyle(.borderedProminent).tint(ReadingTheme.accent) .readingProminentButtonStyle()
} }
Button("修改关键词", systemImage: "text.cursor") { searchIsPresented = true } Button("修改关键词", systemImage: "text.cursor") { searchIsPresented = true }
.buttonStyle(.bordered) .readingSecondaryButtonStyle()
} }
} }
@@ -1058,13 +1058,13 @@ struct SearchView: View {
systemImage: "network.slash" systemImage: "network.slash"
) { ) {
Button("调整范围", systemImage: "scope") { showsScope = true } Button("调整范围", systemImage: "scope") { showsScope = true }
.buttonStyle(.borderedProminent).tint(ReadingTheme.accent) .readingProminentButtonStyle()
NavigationLink { NavigationLink {
SourceManagementView() SourceManagementView()
} label: { } label: {
Label("管理书源", systemImage: "network") Label("管理书源", systemImage: "network")
} }
.buttonStyle(.bordered) .readingSecondaryButtonStyle()
} }
} }
@@ -1288,7 +1288,7 @@ private struct SettingsView: View {
.clipped() .clipped()
.overlay { .overlay {
if colorScheme == .dark { if colorScheme == .dark {
Color.black.opacity(0.62) Color.black.opacity(0.78)
} }
} }
.overlay(alignment: .bottom) { .overlay(alignment: .bottom) {
+18
View File
@@ -1,5 +1,23 @@
# 更新记录 # 更新记录
## 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) ## 0.1.10(2026-10-20)
- 修复书源文件选择及导入流程,支持 JSON 多选,调整文件访问权限和导入预览展示时机。 - 修复书源文件选择及导入流程,支持 JSON 多选,调整文件访问权限和导入预览展示时机。
+38 -6
View File
@@ -6,12 +6,15 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* 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; }; B10000000000000000000050 = {isa = PBXBuildFile; fileRef = B20000000000000000000050; };
B10000000000000000000030 /* ReaderScrollGeometryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000030; }; B10000000000000000000030 /* ReaderScrollGeometryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000030; };
BC1000000000000000000001 /* WebReaderBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC2000000000000000000001; }; BC1000000000000000000001 /* WebReaderBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC2000000000000000000001; };
B10000000000000000000029 /* ReaderScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000029 /* ReaderScrollView.swift */; }; B10000000000000000000029 /* ReaderScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000029 /* ReaderScrollView.swift */; };
F10000000000000000000001 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = F20000000000000000000001 /* SwiftSoup */; }; F10000000000000000000001 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = F20000000000000000000001 /* SwiftSoup */; };
F10000000000000000000002 /* Kanna in Frameworks */ = {isa = PBXBuildFile; productRef = F20000000000000000000002 /* Kanna */; }; 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 */; }; A10000000000000000000001 /* LegadoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000001 /* LegadoApp.swift */; };
A10000000000000000000002 /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000002 /* RootTabView.swift */; }; A10000000000000000000002 /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000002 /* RootTabView.swift */; };
A10000000000000000000003 /* SourceLibraryModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000003 /* SourceLibraryModel.swift */; }; A10000000000000000000003 /* SourceLibraryModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000003 /* SourceLibraryModel.swift */; };
@@ -121,9 +124,14 @@
AA1100000000000000000008 = {isa = PBXBuildFile; fileRef = AA2200000000000000000008; }; AA1100000000000000000008 = {isa = PBXBuildFile; fileRef = AA2200000000000000000008; };
AA1100000000000000000009 = {isa = PBXBuildFile; fileRef = AA2200000000000000000009; }; AA1100000000000000000009 = {isa = PBXBuildFile; fileRef = AA2200000000000000000009; };
AA1100000000000000000010 = {isa = PBXBuildFile; fileRef = AA2200000000000000000010; }; 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 */ /* End PBXBuildFile section */
/* Begin PBXFileReference 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; }; 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; }; 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; }; 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; }; 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; }; 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; }; 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 */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -252,6 +263,7 @@
files = ( files = (
F10000000000000000000001 /* SwiftSoup in Frameworks */, F10000000000000000000001 /* SwiftSoup in Frameworks */,
F10000000000000000000002 /* Kanna in Frameworks */, F10000000000000000000002 /* Kanna in Frameworks */,
F10000000000000000000003 /* ZIPFoundation in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -272,6 +284,8 @@
AA2200000000000000000010, AA2200000000000000000010,
B20000000000000000000030 /* ReaderScrollGeometryTests.swift */, B20000000000000000000030 /* ReaderScrollGeometryTests.swift */,
BC2000000000000000000001 /* WebReaderBridgeTests.swift */, BC2000000000000000000001 /* WebReaderBridgeTests.swift */,
DA2000000000000000000001 /* DarkModeUITests.swift */,
DA2000000000000000000002 /* AppearanceContrastTests.swift */,
A30000000000000000000001 /* App */, A30000000000000000000001 /* App */,
C30000000000000000000001 /* LegadoCore */, C30000000000000000000001 /* LegadoCore */,
A20000000000000000000024 /* txtTocRule.json */, A20000000000000000000024 /* txtTocRule.json */,
@@ -347,6 +361,7 @@
A20000000000000000000029 /* ReaderSpeechController.swift */, A20000000000000000000029 /* ReaderSpeechController.swift */,
A20000000000000000000028 /* ReaderAppearance.swift */, A20000000000000000000028 /* ReaderAppearance.swift */,
A2000000000000000000002B /* ReaderSupport.swift */, A2000000000000000000002B /* ReaderSupport.swift */,
EB2000000000000000000003 /* EPUBIllustrationsView.swift */,
); );
path = Features/Reader; path = Features/Reader;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -521,6 +536,7 @@
children = ( children = (
F21000000000000000000002 /* LocalFileService.swift */, F21000000000000000000002 /* LocalFileService.swift */,
C20000000000000000000018 /* LocalBookService.swift */, C20000000000000000000018 /* LocalBookService.swift */,
EB2000000000000000000001 /* EPUBBook.swift */,
); );
path = LocalBook; path = LocalBook;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -583,6 +599,7 @@
E20000000000000000000008 /* Legado-LICENSE.txt */, E20000000000000000000008 /* Legado-LICENSE.txt */,
E20000000000000000000009 /* SwiftSoup-LICENSE.txt */, E20000000000000000000009 /* SwiftSoup-LICENSE.txt */,
E2000000000000000000000A /* Kanna-LICENSE.txt */, E2000000000000000000000A /* Kanna-LICENSE.txt */,
EB2000000000000000000002 /* ZIPFoundation-LICENSE.txt */,
); );
path = Resources; path = Resources;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -616,6 +633,7 @@
packageProductDependencies = ( packageProductDependencies = (
F20000000000000000000001 /* SwiftSoup */, F20000000000000000000001 /* SwiftSoup */,
F20000000000000000000002 /* Kanna */, F20000000000000000000002 /* Kanna */,
F20000000000000000000003 /* ZIPFoundation */,
); );
productName = Legado; productName = Legado;
productReference = D20000000000000000000001 /* Legado.app */; productReference = D20000000000000000000001 /* Legado.app */;
@@ -653,6 +671,7 @@
packageReferences = ( packageReferences = (
F30000000000000000000001 /* XCRemoteSwiftPackageReference "SwiftSoup" */, F30000000000000000000001 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
F30000000000000000000002 /* XCRemoteSwiftPackageReference "Kanna" */, F30000000000000000000002 /* XCRemoteSwiftPackageReference "Kanna" */,
F30000000000000000000003 /* XCRemoteSwiftPackageReference "ZIPFoundation" */,
); );
productRefGroup = D40000000000000000000002 /* Products */; productRefGroup = D40000000000000000000002 /* Products */;
projectDirPath = ""; projectDirPath = "";
@@ -671,6 +690,7 @@
910000000000000000000008 /* Legado-LICENSE.txt in Resources */, 910000000000000000000008 /* Legado-LICENSE.txt in Resources */,
A10000000000000000000020 /* Assets.xcassets in Resources */, A10000000000000000000020 /* Assets.xcassets in Resources */,
A1000000000000000000001D /* txtTocRule.json in Resources */, A1000000000000000000001D /* txtTocRule.json in Resources */,
EB1000000000000000000002 /* ZIPFoundation-LICENSE.txt in Resources */,
A1000000000000000000001E /* httpTTS.json in Resources */, A1000000000000000000001E /* httpTTS.json in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -678,8 +698,8 @@
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
B30000000000000000000050 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B10000000000000000000050, AA1100000000000000000009, AA1100000000000000000010); runOnlyForDeploymentPostprocessing = 0; }; B30000000000000000000050 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B10000000000000000000050, AA1100000000000000000009, AA1100000000000000000010, DA1000000000000000000001); runOnlyForDeploymentPostprocessing = 0; };
B30000000000000000000030 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B10000000000000000000030, BC1000000000000000000001); runOnlyForDeploymentPostprocessing = 0; }; B30000000000000000000030 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B10000000000000000000030, BC1000000000000000000001, DA1000000000000000000002); runOnlyForDeploymentPostprocessing = 0; };
D30000000000000000000002 /* Sources */ = { D30000000000000000000002 /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -764,6 +784,8 @@
C10000000000000000000016 /* BookExploreService.swift in Sources */, C10000000000000000000016 /* BookExploreService.swift in Sources */,
C10000000000000000000017 /* BookSourceDebugger.swift in Sources */, C10000000000000000000017 /* BookSourceDebugger.swift in Sources */,
C10000000000000000000018 /* LocalBookService.swift in Sources */, C10000000000000000000018 /* LocalBookService.swift in Sources */,
EB1000000000000000000003 /* EPUBIllustrationsView.swift in Sources */,
EB1000000000000000000001 /* EPUBBook.swift in Sources */,
C10000000000000000000019 /* ContentSanitizer.swift in Sources */, C10000000000000000000019 /* ContentSanitizer.swift in Sources */,
C1000000000000000000001A /* ReplaceRuleRepository.swift in Sources */, C1000000000000000000001A /* ReplaceRuleRepository.swift in Sources */,
C1000000000000000000001B /* ReadingActivityRepository.swift in Sources */, C1000000000000000000001B /* ReadingActivityRepository.swift in Sources */,
@@ -858,12 +880,12 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 16; CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_ASSET_PATHS = "";
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 0.1.10; MARKETING_VERSION = 0.1.12;
OTHER_LDFLAGS = "$(inherited) -lsqlite3"; OTHER_LDFLAGS = "$(inherited) -lsqlite3";
PRODUCT_BUNDLE_IDENTIFIER = com.epiboly.legado; PRODUCT_BUNDLE_IDENTIFIER = com.epiboly.legado;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -879,11 +901,11 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 16; CURRENT_PROJECT_VERSION = 18;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 0.1.10; MARKETING_VERSION = 0.1.12;
OTHER_LDFLAGS = "$(inherited) -lsqlite3"; OTHER_LDFLAGS = "$(inherited) -lsqlite3";
PRODUCT_BUNDLE_IDENTIFIER = com.epiboly.legado; PRODUCT_BUNDLE_IDENTIFIER = com.epiboly.legado;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -924,6 +946,11 @@
repositoryURL = "https://github.com/tid-kijyun/Kanna.git"; repositoryURL = "https://github.com/tid-kijyun/Kanna.git";
requirement = { kind = exactVersion; version = 6.1.0; }; 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 */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
@@ -937,6 +964,11 @@
package = F30000000000000000000002 /* XCRemoteSwiftPackageReference "Kanna" */; package = F30000000000000000000002 /* XCRemoteSwiftPackageReference "Kanna" */;
productName = Kanna; productName = Kanna;
}; };
F20000000000000000000003 /* ZIPFoundation */ = {
isa = XCSwiftPackageProductDependency;
package = F30000000000000000000003 /* XCRemoteSwiftPackageReference "ZIPFoundation" */;
productName = ZIPFoundation;
};
/* End XCSwiftPackageProductDependency section */ /* End XCSwiftPackageProductDependency section */
}; };
rootObject = D70000000000000000000001 /* Project object */; rootObject = D70000000000000000000001 /* Project object */;
@@ -1,5 +1,5 @@
{ {
"originHash" : "5cf2eb5dea5b10523b3afc86a164d9a3315a7269a1aefdfe8c91f0530c3f3522", "originHash" : "a20ba014518540ad01fe4db62ea950304db8c0b8eb880b62b87a5f1d19569a3c",
"pins" : [ "pins" : [
{ {
"identity" : "kanna", "identity" : "kanna",
@@ -18,6 +18,15 @@
"revision" : "49dcadd93161f4a44b4994d3a3e8de9f085aface", "revision" : "49dcadd93161f4a44b4994d3a3e8de9f085aface",
"version" : "2.13.5" "version" : "2.13.5"
} }
},
{
"identity" : "zipfoundation",
"kind" : "remoteSourceControl",
"location" : "https://github.com/weichsel/ZIPFoundation.git",
"state" : {
"revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d",
"version" : "0.9.20"
}
} }
], ],
"version" : 3 "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" : [ "pins" : [
{ {
"identity" : "kanna", "identity" : "kanna",
@@ -18,6 +18,15 @@
"revision" : "49dcadd93161f4a44b4994d3a3e8de9f085aface", "revision" : "49dcadd93161f4a44b4994d3a3e8de9f085aface",
"version" : "2.13.5" "version" : "2.13.5"
} }
},
{
"identity" : "zipfoundation",
"kind" : "remoteSourceControl",
"location" : "https://github.com/weichsel/ZIPFoundation.git",
"state" : {
"revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d",
"version" : "0.9.20"
}
} }
], ],
"version" : 3 "version" : 3
+7 -5
View File
@@ -12,14 +12,16 @@ let package = Package(
], ],
dependencies: [ dependencies: [
.package(url: "https://github.com/scinfu/SwiftSoup.git", exact: "2.13.5"), .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: [ targets: [
.target( .target(
name: "LegadoCore", name: "LegadoCore",
dependencies: [ dependencies: [
.product(name: "SwiftSoup", package: "SwiftSoup"), .product(name: "SwiftSoup", package: "SwiftSoup"),
.product(name: "Kanna", package: "Kanna") .product(name: "Kanna", package: "Kanna"),
.product(name: "ZIPFoundation", package: "ZIPFoundation")
], ],
linkerSettings: [ linkerSettings: [
.linkedFramework("JavaScriptCore"), .linkedFramework("JavaScriptCore"),
@@ -29,7 +31,7 @@ let package = Package(
), ),
.testTarget( .testTarget(
name: "LegadoCoreTests", name: "LegadoCoreTests",
dependencies: ["LegadoCore"], dependencies: ["LegadoCore", .product(name: "ZIPFoundation", package: "ZIPFoundation")],
resources: [.copy("Fixtures")] resources: [.copy("Fixtures")]
), ),
.target( .target(
@@ -42,7 +44,7 @@ let package = Package(
"Rules/ReplaceRuleManagementView.swift", "Rules/ReplaceRuleEditorView.swift", "Rules/RuleSubscriptionView.swift", "Rules/TxtTocRuleManagementView.swift", "Rules/Editor", "Rules/ReplaceRuleManagementView.swift", "Rules/ReplaceRuleEditorView.swift", "Rules/RuleSubscriptionView.swift", "Rules/TxtTocRuleManagementView.swift", "Rules/Editor",
"Books/BookDetailView.swift", "Books/LocalBookDetailView.swift", "Books/SourceSwitchView.swift", "Books/BookDetailView.swift", "Books/LocalBookDetailView.swift", "Books/SourceSwitchView.swift",
"Reader/ReaderAppearance.swift", "Reader/ReaderSpeechController.swift", "Reader/ReaderSupport.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: [
"Sources/SourceLibraryModel.swift", "Rules/ReplaceRuleLibraryModel.swift", "Search/SearchViewModel.swift", "Sources/SourceLibraryModel.swift", "Rules/ReplaceRuleLibraryModel.swift", "Search/SearchViewModel.swift",
@@ -52,7 +54,7 @@ let package = Package(
), ),
.testTarget( .testTarget(
name: "LegadoAppModelsTests", name: "LegadoAppModelsTests",
dependencies: ["LegadoAppModels", "LegadoCore"] dependencies: ["LegadoAppModels", "LegadoCore", .product(name: "ZIPFoundation", package: "ZIPFoundation")]
) )
] ]
) )
+6 -3
View File
@@ -20,7 +20,7 @@
项目仍在边用边改,书源兼容和阅读体验会继续完善。 项目仍在边用边改,书源兼容和阅读体验会继续完善。
**小浣熊阅读不提供书源或在线书库。** 书源需由用户自行从网络公开分享获取,或自行编写后导入;也可以直接阅读本地 TXT。 **小浣熊阅读不提供书源或在线书库。** 书源需由用户自行从网络公开分享获取,或自行编写后导入;也可以直接阅读本地 TXT / EPUB。
## 下载安装 ## 下载安装
@@ -42,7 +42,7 @@
| 快速搜书 | 多书源并发搜索,结果陆续展示;重复搜索可先显示有效缓存,同书多源合并 | | 快速搜书 | 多书源并发搜索,结果陆续展示;重复搜索可先显示有效缓存,同书多源合并 |
| 书架管理 | 列表、网格、分组、章节更新、换源和本机阅读进度 | | 书架管理 | 列表、网格、分组、章节更新、换源和本机阅读进度 |
| 阅读排版 | 字号、间距、边距、日夜主题、滚动与分页阅读、目录和书签 | | 阅读排版 | 字号、间距、边距、日夜主题、滚动与分页阅读、目录和书签 |
| 本地 TXT | 导入本地文件、自定义目录规则和章节阅读 | | 本地 TXT / EPUB | TXT 自定义目录规则;EPUB 2/3 目录、封面、分章阅读与插图查看 |
| 语音朗读 | 系统朗读与 HTTP TTS 配置 | | 语音朗读 | 系统朗读与 HTTP TTS 配置 |
| 阅读工具 | 替换净化、阅读记录、时长统计和缓存管理 | | 阅读工具 | 替换净化、阅读记录、时长统计和缓存管理 |
| RSS 订阅 | 订阅源管理与内容阅读 | | RSS 订阅 | 订阅源管理与内容阅读 |
@@ -85,9 +85,12 @@
1. 自行准备你有权使用的阅读书源,安装后进入「我的 → 书源管理」导入。 1. 自行准备你有权使用的阅读书源,安装后进入「我的 → 书源管理」导入。
2. 在书架点击搜索,输入书名或作者,打开书籍详情后开始阅读。 2. 在书架点击搜索,输入书名或作者,打开书籍详情后开始阅读。
3. 将书籍加入书架,方便下次继续阅读;也可以从书架直接导入本地 TXT。 3. 将书籍加入书架,方便下次继续阅读;也可以从书架直接导入本地 TXT 或 EPUB。
4. 在阅读页调整排版、主题、书签和朗读设置。 4. 在阅读页调整排版、主题、书签和朗读设置。
EPUB 导入入口:书架 → 更多 → 本地书,或「我的 → 文件管理 → 导入 TXT / EPUB」。支持无 DRM 的 EPUB 2/3,识别书名、作者、封面、NCX/nav 目录和章节锚点。正文使用现有阅读器重新排版,支持阅读进度、书签、朗读和样式设置;插图从阅读菜单「更多 → 本章插图」查看。暂不还原原书 CSS、固定版式和交互内容。
应用支持 `legado://` 规则导入链接。来源网站改版或停止服务时,书源规则可能需要更新。 应用支持 `legado://` 规则导入链接。来源网站改版或停止服务时,书源规则可能需要更新。
## 书源说明 ## 书源说明
+10 -4
View File
@@ -115,9 +115,9 @@ public struct ContentSanitizer: Sendable {
var diagnostics: [RuleDiagnostic] = [] var diagnostics: [RuleDiagnostic] = []
for rule in ordered where rule.scopeTitle && applies(rule, to: book) { 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 diagnostics += result.diagnostics
if result.value != title { if result.value != title, !result.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
title = result.value title = result.value
effective.append(rule.id) effective.append(rule.id)
} }
@@ -146,7 +146,7 @@ public struct ContentSanitizer: Sendable {
} }
for rule in ordered where rule.scopeContent && applies(rule, to: book) { 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 diagnostics += result.diagnostics
if result.value != body { if result.value != body {
body = result.value 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)) 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, []) } guard !rule.pattern.isEmpty else { return (input, []) }
do { do {
if rule.isRegex { 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 (try regex.replacingStrict(pattern: rule.pattern, in: input, with: rule.replacement, timeoutMilliseconds: rule.timeoutMillisecond), [])
} }
return (input.replacingOccurrences(of: rule.pattern, with: rule.replacement), []) 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? { public var errorDescription: String? {
switch self { switch self {
case .unsupportedFormat: "当前仅支持 TXT 本地书" case .unsupportedFormat: "当前支持 TXT 和 EPUB 本地书"
case .oversized: "本地书超过当前配置允许的大小" case .oversized: "本地书超过当前配置允许的大小"
case .unreadableEncoding: "无法识别 TXT 编码" case .unreadableEncoding: "无法识别 TXT 编码"
case .missingFile: "本地书文件已丢失" case .missingFile: "本地书文件已丢失"
@@ -104,6 +104,49 @@ public struct LocalBookService: Sendable {
LocalBookService(tocRules: TxtTocRuleDefaults.load()) 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 { public func importTextFile(at sourceURL: URL, into directory: URL) throws -> LocalBookImportReport {
guard sourceURL.pathExtension.lowercased() == "txt" else { throw LocalBookError.unsupportedFormat } guard sourceURL.pathExtension.lowercased() == "txt" else { throw LocalBookError.unsupportedFormat }
let values = try sourceURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) let values = try sourceURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
@@ -181,6 +224,10 @@ public struct LocalBookService: Sendable {
} }
public func loadChapters(book: Book) throws -> ChapterListReport { 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 decoded = try read(book)
let selectionVolumeState = TxtVolumeState() let selectionVolumeState = TxtVolumeState()
let probeText = textProbe(decoded.data, encoding: decoded.encoding) 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 { 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, guard let url = URL(string: book.bookUrl), url.isFileURL,
FileManager.default.fileExists(atPath: url.path) else { throw LocalBookError.missingFile } FileManager.default.fileExists(atPath: url.path) else { throw LocalBookError.missingFile }
let fileSize = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 let fileSize = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
@@ -32,7 +32,7 @@ public struct StagedLocalFile: Sendable {
public actor LocalFileService { public actor LocalFileService {
public let directory: URL 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] { public func list() throws -> [LocalFileEntry] {
guard FileManager.default.fileExists(atPath: directory.path) else { return [] } guard FileManager.default.fileExists(atPath: directory.path) else { return [] }
@@ -41,13 +41,13 @@ public actor LocalFileService {
options: [.skipsHiddenFiles] options: [.skipsHiddenFiles]
).compactMap { url in ).compactMap { url in
let values = try url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey, .contentModificationDateKey]) 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) return .init(url: url, bytes: Int64(values.fileSize ?? 0), modified: values.contentModificationDate)
}.sorted { $0.url.lastPathComponent.localizedStandardCompare($1.url.lastPathComponent) == .orderedAscending } }.sorted { $0.url.lastPathComponent.localizedStandardCompare($1.url.lastPathComponent) == .orderedAscending }
} }
public func importFile(from source: URL) throws -> URL { 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]) let values = try source.resourceValues(forKeys: [.isRegularFileKey])
guard values.isRegularFile == true else { throw LocalFileError.missingFile } guard values.isRegularFile == true else { throw LocalFileError.missingFile }
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
@@ -55,7 +55,7 @@ public actor LocalFileService {
var target = directory.appendingPathComponent(source.lastPathComponent) var target = directory.appendingPathComponent(source.lastPathComponent)
var suffix = 2 var suffix = 2
while FileManager.default.fileExists(atPath: target.path) { while FileManager.default.fileExists(atPath: target.path) {
target = directory.appendingPathComponent("\(stem) (\(suffix)).txt") target = directory.appendingPathComponent("\(stem) (\(suffix)).\(source.pathExtension)")
suffix += 1 suffix += 1
} }
try FileManager.default.copyItem(at: source, to: target) 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 { !trimmed.contains(where: { $0 == "/" || $0 == "\\" || $0.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) }) else {
throw LocalFileError.invalidName 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 } guard filename.utf8.count <= 255 else { throw LocalFileError.invalidName }
let target = directory.appendingPathComponent(filename) let target = directory.appendingPathComponent(filename)
if source.standardizedFileURL == target.standardizedFileURL { return source } if source.standardizedFileURL == target.standardizedFileURL { return source }
@@ -83,7 +84,7 @@ public actor LocalFileService {
guard FileManager.default.fileExists(atPath: source.path) else { return nil } guard FileManager.default.fileExists(atPath: source.path) else { return nil }
let trash = directory.appendingPathComponent(".Trash", isDirectory: true) let trash = directory.appendingPathComponent(".Trash", isDirectory: true)
try FileManager.default.createDirectory(at: trash, withIntermediateDirectories: 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) try FileManager.default.moveItem(at: source, to: staged)
return .init(original: source, staged: staged) return .init(original: source, staged: staged)
} }
@@ -104,7 +105,7 @@ public actor LocalFileService {
} }
private func validate(_ url: URL, allowMissing: Bool = false) throws { 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.standardizedFileURL.deletingLastPathComponent() == directory,
url.resolvingSymlinksInPath().deletingLastPathComponent() == directory.resolvingSymlinksInPath() else { url.resolvingSymlinksInPath().deletingLastPathComponent() == directory.resolvingSymlinksInPath() else {
throw LocalFileError.outsideLibrary throw LocalFileError.outsideLibrary
@@ -131,7 +132,7 @@ public actor BookFileExporter {
} }
public func originalFile(_ source: URL, filename: String) throws -> BookFileExport { 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 { do {
try FileManager.default.copyItem(at: source, to: target) try FileManager.default.copyItem(at: source, to: target)
return .init(url: target, cachedChapters: 0, totalChapters: 0) return .init(url: target, cachedChapters: 0, totalChapters: 0)
@@ -178,11 +179,11 @@ public actor BookFileExporter {
try FileManager.default.removeItem(at: export.url.deletingLastPathComponent()) 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) let folder = directory.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
let safe = filename.components(separatedBy: CharacterSet(charactersIn: "/\\:").union(.controlCharacters)).joined(separator: "_") 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)) let stem = String(safe.replacingOccurrences(of: "." + fileExtension, with: "", options: [.caseInsensitive, .anchored, .backwards]).prefix(70))
return folder.appendingPathComponent((stem.isEmpty ? "书籍" : stem) + ".txt") return folder.appendingPathComponent((stem.isEmpty ? "书籍" : stem) + "." + fileExtension)
} }
} }
@@ -548,19 +548,34 @@ public struct LegadoURLBuilder: Sendable {
case "{", ",": case "{", ",":
output.append(character) output.append(character)
expectingKey = true expectingKey = true
case ":": case ":", "=":
output.append(character) // 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 expectingKey = false
if quoteUnquotedValues { do {
var start = index var start = index
while start < characters.count, characters[start].isWhitespace { start += 1 } while start < characters.count, characters[start].isWhitespace { start += 1 }
if start < characters.count, !["\"", "'", "{", "[", "}", ","].contains(characters[start]) { if start < characters.count, !["\"", "'", "{", "[", "}", ","].contains(characters[start]) {
var end = start var end = start
if quoteUnquotedValues {
// @put values are rules, including unquoted JSONPath expressions.
while end < characters.count, characters[end] != ",", characters[end] != "}" { end += 1 } while end < characters.count, characters[end] != ",", characters[end] != "}" { end += 1 }
output.append(escapedJSONString(String(characters[start..<end]).trimmingCharacters(in: .whitespacesAndNewlines))) } 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 index = end
} }
} }
}
case "}", "]": case "}", "]":
while output.last == "," { output.removeLast() } while output.last == "," { output.removeLast() }
output.append(character) output.append(character)
@@ -580,7 +595,7 @@ public struct LegadoURLBuilder: Sendable {
while peekPosition < characters.count, characters[peekPosition].isWhitespace { while peekPosition < characters.count, characters[peekPosition].isWhitespace {
peekPosition += 1 peekPosition += 1
} }
if peekPosition < characters.count, characters[peekPosition] == ":" { if peekPosition < characters.count, [":", "="].contains(characters[peekPosition]) {
output.removeLast() output.removeLast()
output.append(escapedJSONString(name)) output.append(escapedJSONString(name))
index = position index = position
@@ -611,7 +626,7 @@ public struct LegadoURLBuilder: Sendable {
let parsed: JSONValue let parsed: JSONValue
if case .string(let source) = value { if case .string(let source) = value {
do { do {
parsed = try JSONValue.decode(data: Data(source.utf8)) parsed = try Self.decodeLenientJSON(source)
} catch { } catch {
throw RuleEngineError.malformedRule(rule: rule, detail: "headers JSON 无效") throw RuleEngineError.malformedRule(rule: rule, detail: "headers JSON 无效")
} }
@@ -34,6 +34,12 @@ public struct RSSReadingPolicy: Sendable {
public struct RSSHTMLProcessor: 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;}" 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 init() {}
public func contentHTML(_ content: String, style: String?) -> String { 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 { 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>") { if let range = content.range(of: "</style>") {
return String(content[..<range.upperBound]) + tag + String(content[range.upperBound...]) return String(content[..<range.upperBound]) + tag + String(content[range.upperBound...])
} }
@@ -84,6 +84,13 @@ public struct RSSRuleParser: Sendable {
private func flatten(_ values: [JSONValue]) -> [JSONValue] { private func flatten(_ values: [JSONValue]) -> [JSONValue] {
values.flatMap { value in values.flatMap { value in
if case .array(let items) = value { return items } 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] return [value]
} }
} }
@@ -17,6 +17,48 @@ public struct JSONPathEvaluator: Sendable {
public init() {} 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] { public func evaluate(path: String, in root: JSONValue) throws -> [JSONValue] {
let segments = try parse(path) let segments = try parse(path)
var current = [root] var current = [root]
@@ -59,7 +101,10 @@ public struct JSONPathEvaluator: Sendable {
guard next < rule.endIndex else { guard next < rule.endIndex else {
throw RuleEngineError.malformedRule(rule: path, detail: "JSONPath 不能以点结尾") 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) index = rule.index(after: next)
let parsed = try parseDotSelector(rule, from: index, original: path) let parsed = try parseDotSelector(rule, from: index, original: path)
segments.append(.recursive(parsed.selector)) segments.append(.recursive(parsed.selector))
@@ -84,7 +84,8 @@ public extension JSONValue {
if value is NSNull { return .null } if value is NSNull { return .null }
if let value = value as? JSONValue { return value } if let value = value as? JSONValue { return value }
if let value = value as? String { return .string(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 let value = value as? NSNumber {
if CFGetTypeID(value) == CFBooleanGetTypeID() { return .bool(value.boolValue) } if CFGetTypeID(value) == CFBooleanGetTypeID() { return .bool(value.boolValue) }
return .number(value.doubleValue) return .number(value.doubleValue)
@@ -208,6 +208,33 @@ public struct JavaScriptRuleEvaluator: Sendable {
script: String, script: String,
result: JSONValue, result: JSONValue,
executionContext: RuleExecutionContext 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 { ) throws -> JavaScriptRuleResult {
guard script.utf8.count <= maximumScriptLength else { guard script.utf8.count <= maximumScriptLength else {
throw RuleEngineError.resourceLimit(rule: script, detail: "JavaScript 规则超过长度上限") throw RuleEngineError.resourceLimit(rule: script, detail: "JavaScript 规则超过长度上限")
@@ -215,8 +242,7 @@ public struct JavaScriptRuleEvaluator: Sendable {
try rejectUnboundedExecution(script) try rejectUnboundedExecution(script)
let box = JSVariableBox(executionContext.variables) let box = JSVariableBox(executionContext.variables)
let vm = JSVirtualMachine() guard let context = JSContext(virtualMachine: virtualMachine) else {
guard let context = JSContext(virtualMachine: vm) else {
throw RuleEngineError.invalidInput(rule: script, expected: "无法创建 JavaScriptCore 上下文") throw RuleEngineError.invalidInput(rule: script, expected: "无法创建 JavaScriptCore 上下文")
} }
var exceptionMessage: String? var exceptionMessage: String?
@@ -271,7 +297,28 @@ public struct JavaScriptRuleEvaluator: Sendable {
let normalized = evaluated.flatMap { let normalized = evaluated.flatMap {
context.objectForKeyedSubscript("__legadoUnboxString")?.call(withArguments: [$0]) 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 { guard value.stringValue.utf8.count <= maximumResultLength else {
throw RuleEngineError.resourceLimit(rule: script, detail: "JavaScript 结果超过长度上限") throw RuleEngineError.resourceLimit(rule: script, detail: "JavaScript 结果超过长度上限")
} }
@@ -974,10 +1021,16 @@ public struct JavaScriptRuleEvaluator: Sendable {
return resolveJavaScriptRuleURL(direct.stringValue, enabled: isURL, context: executionContext) 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]] = [] var branchResults: [[String]] = []
for branch in plan.branches { for branch in plan.branches {
var values: [String] var values: [String]
if branch.expression.isEmpty {
values = [content.stringValue]
} else {
switch branch.mode { switch branch.mode {
case .css: case .css:
values = try HTMLRuleEvaluator().extractCSS( values = try HTMLRuleEvaluator().extractCSS(
@@ -995,10 +1048,7 @@ public struct JavaScriptRuleEvaluator: Sendable {
} else { } else {
json = content json = content
} }
let path = branch.expression.hasPrefix("$") ? branch.expression : "." + branch.expression values = RuleEvaluation(values: try JSONPathEvaluator().evaluateRule(branch.expression, in: json)).strings
let normalizedPath = path.hasPrefix("$") ? path : "$" + path
values = try JSONPathEvaluator().evaluate(path: normalizedPath, in: json)
.map(\.stringValue)
case .regex: case .regex:
values = try RegexEvaluator().captureGroups(pattern: branch.expression, input: content.stringValue) values = try RegexEvaluator().captureGroups(pattern: branch.expression, input: content.stringValue)
.map { $0.count > 1 ? $0.dropFirst().joined() : $0.first ?? "" } .map { $0.count > 1 ? $0.dropFirst().joined() : $0.first ?? "" }
@@ -1011,16 +1061,8 @@ public struct JavaScriptRuleEvaluator: Sendable {
case .webJavaScript: case .webJavaScript:
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "java.getString WebView JavaScript") 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 plan.combination == .fallback, values.joined(separator: "\n").isEmpty { values = [] }
branchResults.append(values) branchResults.append(values)
if plan.combination == .fallback, !values.isEmpty { break } if plan.combination == .fallback, !values.isEmpty { break }
} }
@@ -1037,7 +1079,11 @@ public struct JavaScriptRuleEvaluator: Sendable {
branchResults.compactMap { $0.indices.contains(index) ? $0[index] : nil } 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( private func resolveJavaScriptRuleURL(
@@ -112,6 +112,25 @@ public struct RegexEvaluator: Sendable {
public func replacingStrict(pattern: String, in input: String, with replacement: String, public func replacingStrict(pattern: String, in input: String, with replacement: String,
timeoutMilliseconds: Int64 = 3_000) throws -> 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, guard pattern.count <= limits.maximumPatternLength, input.utf16.count <= limits.maximumInputLength,
!hasObviousNestedQuantifier(pattern) else { !hasObviousNestedQuantifier(pattern) else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则超过资源限制或包含高风险嵌套量词") throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则超过资源限制或包含高风险嵌套量词")
@@ -145,30 +164,51 @@ public struct RegexEvaluator: Sendable {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则替换超时(\(milliseconds) ms)") throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则替换超时(\(milliseconds) ms)")
} }
let untouched = original.substring(with: NSRange(location: offset, length: match.range.location - offset)) 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) let replaced = try replacement(expression, match)
output += untouched + replaced guard DispatchTime.now().uptimeNanoseconds < deadline else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则替换超时(\(milliseconds) ms)")
}
outputLength += untouched.utf16.count + replaced.utf16.count outputLength += untouched.utf16.count + replaced.utf16.count
offset = NSMaxRange(match.range) offset = NSMaxRange(match.range)
guard outputLength <= limits.maximumInputLength * 2 else { guard outputLength <= limits.maximumInputLength * 2 else {
throw RuleEngineError.resourceLimit(rule: pattern, detail: "替换结果超过长度限制") 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 return output
} }
private func hasObviousNestedQuantifier(_ pattern: String) -> Bool { private func hasObviousNestedQuantifier(_ pattern: String) -> Bool {
let risky = #"\(((?:[^()\\]|\\.)*[+*](?:[^()\\]|\\.)*)\)([+*]|\{[^}]+\})"# // Reject clear cases such as (a+)+ and (\w*)*. A '+' inside a character
guard let expression = try? NSRegularExpression(pattern: risky) else { return false } // class/escape is not a quantifier, and bounded or delimited repeated
return expression.matches(in: pattern, range: NSRange(pattern.startIndex..., in: pattern)).contains { match in // groups are not proof of catastrophic backtracking. Other expressions
guard let innerRange = Range(match.range(at: 1), in: pattern), // remain protected by enumerateMatches' progress/deadline checks.
let outerRange = Range(match.range(at: 2), in: pattern) else { return true } let atom = #"^(?:\?:)?(?:\\[dDsSwW]|\\[^pPkKgG0-9]|\[(?:\\.|[^\]\\])*\]|[^\[\]()\\?*+{}|^$])(?:[+*]|\{[0-9]+,\})\??$"#
let inner = String(pattern[innerRange]) guard let repeatedAtom = try? NSRegularExpression(pattern: atom),
let outer = String(pattern[outerRange]) let unbounded = try? NSRegularExpression(pattern: #"^(?:[+*]|\{[0-9]+,\})(?!\+)"#) else { return false }
if outer.range(of: #"^\{[1-9]\}$"#, options: .regularExpression) != nil { return false } var groups: [String.Index] = []
if !pattern.contains("(?s"), var classDepth = 0
inner.range(of: #"^(?:\\n[^\[\]()*+?\\]*\.\*|\.\+\\n)$"#, options: .regularExpression) != nil { return false } var escaped = false
return true 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) return literalEvaluation(rendered.rule, context: rendered.context)
} }
if rendered.context.variables != context.variables { 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 } result.variables = rendered.context.variables.merging(result.variables) { _, new in new }
return result 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 { } catch let error as RuleEngineError {
return .init(diagnostics: [error.diagnostic], variables: context.variables) return .init(diagnostics: [error.diagnostic], variables: context.variables)
} catch { } catch {
@@ -72,6 +72,7 @@ public struct RuleEngine: Sendable {
private func evaluatePrepared( private func evaluatePrepared(
_ rule: String, _ rule: String,
in input: JSONValue, in input: JSONValue,
listMode: Bool,
context: RuleExecutionContext context: RuleExecutionContext
) async -> RuleEvaluation { ) async -> RuleEvaluation {
do { do {
@@ -80,7 +81,8 @@ public struct RuleEngine: Sendable {
var diagnostics: [RuleDiagnostic] = [] var diagnostics: [RuleDiagnostic] = []
for branch in plan.branches { for branch in plan.branches {
do { 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) results.append(values)
if plan.combination == .fallback, !values.isEmpty { break } if plan.combination == .fallback, !values.isEmpty { break }
} catch let error as RuleEngineError { } catch let error as RuleEngineError {
@@ -97,7 +99,7 @@ public struct RuleEngine: Sendable {
} }
} }
return .init( return .init(
values: combine(results, with: plan.combination), values: try applyingReplacement(to: combine(results, with: plan.combination), plan: plan, listMode: listMode),
diagnostics: diagnostics, diagnostics: diagnostics,
variables: context.variables variables: context.variables
) )
@@ -174,6 +176,11 @@ public struct RuleEngine: Sendable {
listMode: Bool, listMode: Bool,
context: RuleExecutionContext context: RuleExecutionContext
) async -> RuleEvaluation { ) 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 { if rule.hasPrefix(":"), rule.count > 1 {
do { do {
let patterns = rule.dropFirst().components(separatedBy: "&&").filter { !$0.isEmpty } let patterns = rule.dropFirst().components(separatedBy: "&&").filter { !$0.isEmpty }
@@ -217,7 +224,7 @@ public struct RuleEngine: Sendable {
} }
} }
return .init( return .init(
values: combine(results, with: plan.combination), values: try applyingReplacement(to: combine(results, with: plan.combination), plan: plan, listMode: listMode),
diagnostics: diagnostics, diagnostics: diagnostics,
variables: context.variables variables: context.variables
) )
@@ -241,15 +248,11 @@ public struct RuleEngine: Sendable {
try Task.checkCancellation() try Task.checkCancellation()
let rawValues: [JSONValue] let rawValues: [JSONValue]
if branch.expression.isEmpty { if branch.expression.isEmpty {
return try [JSONValue.string(regex.replacing( return [input]
pattern: branch.replacePattern ?? "", in: input.stringValue,
with: branch.replacement, firstOnly: branch.replaceFirst
))]
} }
switch branch.mode { switch branch.mode {
case .jsonPath: case .jsonPath:
let path = branch.expression.hasPrefix("$") ? branch.expression : "$.\(branch.expression)" rawValues = try jsonPath.evaluateRule(branch.expression, in: input)
rawValues = try jsonPath.evaluate(path: path, in: input)
case .regex: case .regex:
let matches = try regex.captureGroups(pattern: branch.expression, input: input.stringValue) let matches = try regex.captureGroups(pattern: branch.expression, input: input.stringValue)
rawValues = matches.map { .array($0.map(JSONValue.string)) } rawValues = matches.map { .array($0.map(JSONValue.string)) }
@@ -266,15 +269,7 @@ public struct RuleEngine: Sendable {
case .webJavaScript: case .webJavaScript:
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "WKWebView JavaScript") throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "WKWebView JavaScript")
} }
guard let pattern = branch.replacePattern, !pattern.isEmpty else { return rawValues } return rawValues
return try rawValues.map {
.string(try regex.replacing(
pattern: pattern,
in: $0.stringValue,
with: branch.replacement,
firstOnly: branch.replaceFirst
))
}
} }
private func evaluateTextBranch( private func evaluateTextBranch(
@@ -287,10 +282,7 @@ public struct RuleEngine: Sendable {
try Task.checkCancellation() try Task.checkCancellation()
let raw: [String] let raw: [String]
if branch.expression.isEmpty { if branch.expression.isEmpty {
return try [regex.replacing( return [input]
pattern: branch.replacePattern ?? "", in: input,
with: branch.replacement, firstOnly: branch.replaceFirst
)]
} }
switch branch.mode { switch branch.mode {
case .css: case .css:
@@ -308,8 +300,7 @@ public struct RuleEngine: Sendable {
} catch { } catch {
throw RuleEngineError.invalidInput(rule: branch.expression, expected: "JSON") throw RuleEngineError.invalidInput(rule: branch.expression, expected: "JSON")
} }
let path = branch.expression.hasPrefix("$") ? branch.expression : "$.\(branch.expression)" raw = try jsonPath.evaluateRule(branch.expression, in: root).map(\.stringValue)
raw = try jsonPath.evaluate(path: path, in: root).map(\.stringValue)
case .regex: case .regex:
raw = try regex.captureGroups(pattern: branch.expression, input: input).map { captures in raw = try regex.captureGroups(pattern: branch.expression, input: input).map { captures in
captures.count > 1 ? captures.dropFirst().joined() : captures.first ?? "" captures.count > 1 ? captures.dropFirst().joined() : captures.first ?? ""
@@ -323,14 +314,22 @@ public struct RuleEngine: Sendable {
case .webJavaScript: case .webJavaScript:
throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "WKWebView JavaScript") throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "WKWebView JavaScript")
} }
guard let pattern = branch.replacePattern, !pattern.isEmpty else { return raw } return raw
return try raw.map { }
try regex.replacing(
pattern: pattern, private func isEmpty(_ values: [JSONValue], listMode: Bool) -> Bool {
in: $0, let result = RuleEvaluation(values: values)
with: branch.replacement, return listMode
firstOnly: branch.replaceFirst ? 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 { guard !normalized.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw RuleEngineError.malformedRule(rule: source, detail: "规则不能为空") throw RuleEngineError.malformedRule(rule: source, detail: "规则不能为空")
} }
let split = try splitAtTopLevel(normalized) // ## belongs to the whole selector stage. Regex/replacement text may
let branches = try split.parts.map { try parseBranch($0, inputLooksLikeJSON: inputLooksLikeJSON) } // 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) return .init(source: source, combination: split.combination, branches: branches)
} }
+6
View File
@@ -21,6 +21,12 @@
- 用途:XPath 解析。 - 用途:XPath 解析。
- 许可:MIT,完整版权及许可见 [Kanna-LICENSE.txt](App/Resources/Kanna-LICENSE.txt)。 - 许可: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)。演示模式使用虚构书籍元数据和项目内演示文本;发布截图不使用个人书架、登录信息或付费章节。 测试中的规则样本仅用于兼容验证,不打包为 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 @MainActor
struct LocalFileManagementTests { 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) { private func fixture(fails: Bool = false) async throws -> (URL, Book, BookLibraryModel) {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
@@ -5,6 +5,26 @@ import LegadoCore
@MainActor @MainActor
struct SearchRegressionTests { 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 { @Test func cachedResultsAppearBeforeNetworkAndFreshResultsReplaceTheirMetadata() async throws {
let source = source("slow") let source = source("slow")
let cached = SearchBook(bookURL: source.id + "/cached", origin: source.id, originName: "源", type: 0, name: "书", author: "作者") 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") #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 { @Test func rssSourceImporterSupportsObjectArrayManifestAndUpdateSelection() throws {
let importer = RSSSourceImporter() let importer = RSSSourceImporter()
let object = try importer.decode(Data(#"{"sourceUrl":"https://one","sourceName":"一"}"#.utf8)) let object = try importer.decode(Data(#"{"sourceUrl":"https://one","sourceName":"一"}"#.utf8))
@@ -440,7 +454,7 @@ struct LegadoDatabaseTests {
@Test func rssHTMLAndResourcePoliciesApplyRulesInOrder() { @Test func rssHTMLAndResourcePoliciesApplyRulesInOrder() {
let processor = RSSHTMLProcessor() 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("<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: "源") var source = RssSource(sourceUrl: "https://rss", sourceName: "源")
source.startJs = "window.ready=true"; source.startStyle = "p{font-size:20px}" source.startJs = "window.ready=true"; source.startStyle = "p{font-size:20px}"
let start = processor.startHTML("<body><p>x</p></body>", source: source) 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)
}
}
@@ -246,6 +246,54 @@ final class ReaderScrollGeometryTests: XCTestCase {
XCTAssertEqual(fixture.view.contentOffset.y, layout.frames[row].minY, accuracy: 1 / fixture.window.screen.scale) 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 { private func chapter(_ index: Int) -> ReaderScrollChapter {
.init(id: index, title: "第\(index)章", content: String(repeating: "正文包含中文、英文 words 与家庭表情👨‍👩‍👧‍👦,用于验证连续阅读的排版和位置。\n", count: 100)) .init(id: index, title: "第\(index)章", content: String(repeating: "正文包含中文、英文 words 与家庭表情👨‍👩‍👧‍👦,用于验证连续阅读的排版和位置。\n", count: 100))
} }
@@ -5,6 +5,56 @@ import XCTest
@MainActor @MainActor
final class WebReaderBridgeTests: XCTestCase { 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 { func testPageJavaScriptCanStoreVariablesAndEvaluateContentRules() async throws {
let source = RssSource(sourceUrl: "https://bridge-regression.test/\(UUID().uuidString)", sourceName: "测试") let source = RssSource(sourceUrl: "https://bridge-regression.test/\(UUID().uuidString)", sourceName: "测试")
let loaded = expectation(description: "HTML loaded") let loaded = expectation(description: "HTML loaded")
@@ -38,3 +88,159 @@ final class WebReaderBridgeTests: XCTestCase {
XCTAssertEqual(result as? String, "已保存|规则正文") 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() showReaderMenu()
app.buttons["换源"].tap() app.buttons["换源"].tap()
XCTAssertTrue(app.navigationBars["为《极道天魔》换源"].waitForExistence(timeout: 10)) 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)) XCTAssertTrue(replacement.waitForExistence(timeout: 240))
attach("07-source-candidates") attach("07-source-candidates")
replacement.tap() replacement.tap()
@@ -354,8 +354,13 @@ final class ReaderScrollGestureTests: XCTestCase {
app.launch() app.launch()
let reader = app.collectionViews["reader.continuous"] let reader = app.collectionViews["reader.continuous"]
XCTAssertTrue(reader.waitForExistence(timeout: 20)) XCTAssertTrue(reader.waitForExistence(timeout: 20))
var lastInitialState = "<not reported>"
let ready = expectation(for: NSPredicate { _, _ in 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 ") return text.contains("chapter=20 ") && !text.contains("chapters=1 ")
}, evaluatedWith: nil) }, evaluatedWith: nil)
wait(for: [ready], timeout: 20) wait(for: [ready], timeout: 20)
@@ -2,6 +2,171 @@ import XCTest
@MainActor @MainActor
final class RuleEditorUITests: XCTestCase { 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 { func testJSONFilePickerOffersOpenAndImportsSelectedFiles() throws {
continueAfterFailure = false continueAfterFailure = false
let app = XCUIApplication() let app = XCUIApplication()
@@ -60,7 +225,7 @@ final class RuleEditorUITests: XCTestCase {
attach(app, name: "JSON import review after file picker dismissal") attach(app, name: "JSON import review after file picker dismissal")
app.buttons["导入"].tap() app.buttons["导入"].tap()
if app.alerts.firstMatch.waitForExistence(timeout: 5) { app.alerts.buttons["好"].tap() } if app.alerts.firstMatch.waitForExistence(timeout: 5) { app.alerts.buttons["好"].tap() }
XCTAssertTrue(app.staticTexts["文件导入 JSON 测试"].waitForExistence(timeout: 10)) XCTAssertTrue(app.staticTexts["文件导入 JSON 测试"].waitForExistence(timeout: 10), app.debugDescription)
XCTAssertTrue(app.staticTexts["文件导入大写扩展名测试"].exists) XCTAssertTrue(app.staticTexts["文件导入大写扩展名测试"].exists)
} }
+46 -2
View File
@@ -41,7 +41,7 @@ sh scripts/self-check.sh
阅读器测试在 Xcode 选择 `LegadoReaderTests` / `LegadoReaderUITests` Scheme 和具体模拟器运行。联网书源测试需要显式配置对应环境变量,默认核心回归主要采用自造响应,不能将其通过解读为所有线上书源均可用。 阅读器测试在 Xcode 选择 `LegadoReaderTests` / `LegadoReaderUITests` Scheme 和具体模拟器运行。联网书源测试需要显式配置对应环境变量,默认核心回归主要采用自造响应,不能将其通过解读为所有线上书源均可用。
书源文件选择回归需要先在已启动的测试模拟器中准备文件共享应用(将下方 UUID 换成实际值): 书源和本地 TXT / EPUB 文件选择回归需要先在已启动的测试模拟器中准备文件共享应用(将下方 UUID 换成实际值):
```sh ```sh
LEGADO_TEST_DEVICE="模拟器 UUID" LEGADO_TEST_DEVICE="模拟器 UUID"
@@ -49,10 +49,14 @@ sh scripts/prepare-source-import-fixture.sh "$LEGADO_TEST_DEVICE"
xcodebuild -project Legado.xcodeproj -scheme LegadoReaderUITests \ xcodebuild -project Legado.xcodeproj -scheme LegadoReaderUITests \
-destination "platform=iOS Simulator,id=$LEGADO_TEST_DEVICE" \ -destination "platform=iOS Simulator,id=$LEGADO_TEST_DEVICE" \
-only-testing:LegadoReaderUITests/RuleEditorUITests/testJSONFilePickerOffersOpenAndImportsSelectedFiles \ -only-testing:LegadoReaderUITests/RuleEditorUITests/testJSONFilePickerOffersOpenAndImportsSelectedFiles \
-only-testing:LegadoReaderUITests/RuleEditorUITests/testBookshelfFilePickerImportsTXTAndEPUBAndDisablesJSON \
-only-testing:LegadoReaderUITests/RuleEditorUITests/testFileManagementFilePickerImportsTXTAndEPUBAndDisablesJSON \
-collect-test-diagnostics never CODE_SIGNING_ALLOWED=NO test -collect-test-diagnostics never CODE_SIGNING_ALLOWED=NO test
``` ```
独立测试应用只提供 `.json`、`.JSON` 和 `.txt` 样本,不会被打包进正式 App。此回归检查取消后重新打开、JSON 多选后的打开按钮、导入预检及最终保存。 独立测试应用提供 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 默认跳过此用例。
## 兼容性验证 ## 兼容性验证
@@ -75,3 +79,43 @@ python3 scripts/export-open-source.py /tmp/legado-ios-open-source
``` ```
目标目录必须尚不存在。导出后检查内容,再用独立仓库管理;`.gitignore` 不会自动清理已经跟踪的文件。 目标目录必须尚不存在。导出后检查内容,再用独立仓库管理;`.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": []
}
@@ -19,6 +19,14 @@ final class FixtureAppDelegate: UIResponder, UIApplicationDelegate {
try? data.write(to: documents.appendingPathComponent(name), options: .atomic) 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() let controller = UIViewController()
controller.view.backgroundColor = .systemBackground controller.view.backgroundColor = .systemBackground
let label = UILabel() let label = UILabel()
+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()
+2 -1
View File
@@ -40,6 +40,7 @@ xcrun --sdk iphonesimulator swiftc -parse-as-library -swift-version 6 \
-module-cache-path "$legado_fixture_repo/DerivedData/ModuleCache.noindex" \ -module-cache-path "$legado_fixture_repo/DerivedData/ModuleCache.noindex" \
"$legado_fixture_repo/scripts/fixtures/SourceImportFixture.swift" \ "$legado_fixture_repo/scripts/fixtures/SourceImportFixture.swift" \
-o "$legado_fixture_app/SourceImportFixtures" -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 install "$legado_fixture_device" "$legado_fixture_app"
xcrun simctl launch --terminate-running-process "$legado_fixture_device" com.epiboly.legado.source-import-fixtures xcrun simctl launch --terminate-running-process "$legado_fixture_device" com.epiboly.legado.source-import-fixtures
printf 'JSON file import fixtures are ready in Files > On My iPhone > 书源导入测试.\n' 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"} derived_data_path=${LEGADO_DERIVED_DATA_PATH:-"$workspace_dir/DerivedData"}
source_packages_path=${LEGADO_SOURCE_PACKAGES_PATH:-"$derived_data_path/SourcePackages"} 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/txtTocRule.json >/dev/null
python3 -m json.tool App/Resources/httpTTS.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" echo "[2/5] Check project and Swift formatting hazards"
plutil -lint Legado.xcodeproj/project.pbxproj >/dev/null 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()