diff --git a/App/Compatibility.swift b/App/Compatibility.swift index accce9e..5dadf34 100644 --- a/App/Compatibility.swift +++ b/App/Compatibility.swift @@ -127,20 +127,37 @@ struct ReadingPhotoPicker: View { var body: some View { Button { isPresented = true } label: { label } - .sheet(isPresented: $isPresented) { - ReadingPhotoPickerController { result in - isPresented = false - guard let result else { return } - switch result { - case .success(let data): onData(data) - case .failure(let error): onError(error) - } - } - } + .readingPhotoPicker(isPresented: $isPresented, onData: onData, onError: onError) } } -private struct ReadingPhotoPickerController: UIViewControllerRepresentable { +private struct ReadingPhotoPickerModifier: ViewModifier { + @Binding var isPresented: Bool + let onData: (Data) -> Void + let onError: (Error) -> Void + @State private var pendingResult: Result? + + func body(content: Content) -> some View { + content.sheet(isPresented: $isPresented, onDismiss: { + let result = pendingResult + pendingResult = nil + switch result { + case .success(let data): onData(data) + case .failure(let error): onError(error) + case nil: break + } + }) { + ReadingPhotoPickerController { result in + // Apply changes after the picker finishes dismissing, so a + // Form update cannot remove its presenter mid-transition. + pendingResult = result + isPresented = false + } + } + } +} + +struct ReadingPhotoPickerController: UIViewControllerRepresentable { let onResult: @MainActor (Result?) -> Void func makeCoordinator() -> Coordinator { Coordinator(onResult: onResult) } @@ -166,14 +183,25 @@ private struct ReadingPhotoPickerController: UIViewControllerRepresentable { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { guard let provider = results.first?.itemProvider else { onResult(nil); return } + loadImage(from: provider) + } + + func loadImage(from provider: NSItemProvider) { let identifier = provider.registeredTypeIdentifiers.first { UTType($0)?.conforms(to: .image) == true } ?? UTType.image.identifier - provider.loadDataRepresentation(forTypeIdentifier: identifier) { [weak self] data, error in - Task { @MainActor in - if let data { self?.onResult(.success(data)) } - else { self?.onResult(.failure(error ?? CocoaError(.fileReadCorruptFile))) } - } + provider.loadDataRepresentation(forTypeIdentifier: identifier, completionHandler: didLoadImageData) + } + + // Photos calls back on NSItemProvider-callback-queue. A method reference + // preserves explicit nonisolation in optimized device builds, whereas a + // closure here can infer MainActor and assert before reaching the Task. + nonisolated private func didLoadImageData(_ data: Data?, _ error: (any Error)?) { + let result: Result + if let data { result = .success(data) } + else { result = .failure(error ?? CocoaError(.fileReadCorruptFile)) } + Task { @MainActor [weak self] in + self?.onResult(result) } } } @@ -182,6 +210,14 @@ private struct ReadingPhotoPickerController: UIViewControllerRepresentable { enum ReadingSheetDetent { case medium, large } extension View { + func readingPhotoPicker( + isPresented: Binding, + onData: @escaping (Data) -> Void, + onError: @escaping (Error) -> Void + ) -> some View { + modifier(ReadingPhotoPickerModifier(isPresented: isPresented, onData: onData, onError: onError)) + } + @ViewBuilder func readingNumericTransition() -> some View { if #available(iOS 16.0, *) { contentTransition(.numericText()) } diff --git a/App/Features/Reader/ReaderAppearance.swift b/App/Features/Reader/ReaderAppearance.swift index 4df091b..59d1c16 100644 --- a/App/Features/Reader/ReaderAppearance.swift +++ b/App/Features/Reader/ReaderAppearance.swift @@ -1,4 +1,5 @@ import CoreText +import ImageIO import SwiftUI import UIKit @@ -54,25 +55,43 @@ private extension UIColor { } enum ReaderBackgroundStore { + static let maximumPixelSize = 2560 + static var url: URL { FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent("Legado/reader-background.jpg") } - static func load() -> UIImage? { - guard let data = try? Data(contentsOf: url) else { return nil } - return UIImage(data: data) + static func load(from source: URL = url) -> UIImage? { + guard let data = try? Data(contentsOf: source, options: .mappedIfSafe) else { return nil } + return try? downsample(data) } - static func save(_ data: Data) throws -> UIImage { - guard let image = UIImage(data: data), let encoded = image.jpegData(compressionQuality: 0.9) else { + static func save(_ data: Data, to target: URL = url) throws -> UIImage { + let image = try downsample(data) + guard let encoded = image.jpegData(compressionQuality: 0.9) else { throw CocoaError(.fileReadCorruptFile) } - try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) - try encoded.write(to: url, options: .atomic) + try FileManager.default.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true) + try encoded.write(to: target, options: .atomic) return image } + private static func downsample(_ data: Data) throws -> UIImage { + // Decode only the size used by the reader, including backgrounds saved + // by older versions. Full-resolution camera photos can exhaust memory. + guard let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary), + let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceThumbnailMaxPixelSize: maximumPixelSize + ] as CFDictionary) else { + throw CocoaError(.fileReadCorruptFile) + } + return UIImage(cgImage: image) + } + static func remove() { try? FileManager.default.removeItem(at: url) } } diff --git a/App/Features/Reader/ReaderView.swift b/App/Features/Reader/ReaderView.swift index 6d72a38..5caef36 100644 --- a/App/Features/Reader/ReaderView.swift +++ b/App/Features/Reader/ReaderView.swift @@ -36,6 +36,7 @@ struct ReaderView: View { @State private var contentEditError: String? @State private var backgroundImage: UIImage? @State private var backgroundError: String? + @State private var showsBackgroundPicker = false @State private var showsFontImporter = false @State private var fontError: String? @State private var readerJumpPosition: Int? @@ -55,7 +56,7 @@ struct ReaderView: View { @State private var progressSaveGeneration = 0 @State private var speechStartGeneration = 0 @State private var speechTimerRemaining = 0 - @State private var bookmarkSaved = false + @State private var isSavingBookmark = false @ScaledMetric(relativeTo: .body) private var toolbarIconSize = 21 @AppStorage("textSize") private var fontSize = 20.0 @AppStorage("letterSpacing") private var letterSpacing = 0.1 @@ -282,19 +283,6 @@ struct ReaderView: View { .sheet(isPresented: $showsReaderSettings) { readerSettings } .sheet(isPresented: $showsClickActionSettings) { clickActionSettings } .sheet(isPresented: $showsContentEditor) { contentEditor } - .fileImporter( - isPresented: $showsFontImporter, - allowedContentTypes: [.font, .data], - allowsMultipleSelection: false - ) { result in - fontError = nil - do { - guard let source = try result.get().first else { return } - fontPath = try ReaderFontStore.install(from: source) - } catch { - fontError = error.localizedDescription - } - } .task(id: model.chapterLoadGeneration) { isWaitingForNextChapter = false isWaitingForPreviousChapter = false @@ -413,7 +401,7 @@ struct ReaderView: View { .background { ZStack { backgroundColor - if themeMode != "3", useBackgroundImage, let backgroundImage { + if let backgroundImage = activeBackgroundImage { Image(uiImage: backgroundImage) .resizable() .scaledToFill() @@ -577,7 +565,7 @@ struct ReaderView: View { pageAnimation: pageAnimation, isEInkMode: themeMode == "3", clickActions: clickActions, - backgroundColor: themeMode != "3" && useBackgroundImage && backgroundImage != nil ? .clear : resolvedStyle.background, + backgroundColor: activeBackgroundImage == nil ? resolvedStyle.background : .clear, foregroundColor: resolvedStyle.foreground, requestedPosition: pageAnimation == 3 ? readerPosition : model.requestedPosition, jumpPosition: readerJumpPosition, @@ -678,6 +666,10 @@ struct ReaderView: View { return min(max(position, 0), max(length - 1, 0)) } + private var activeBackgroundImage: UIImage? { + themeMode != "3" && useBackgroundImage ? backgroundImage : nil + } + private var backgroundColor: Color { Color(uiColor: resolvedStyle.background) } @@ -822,6 +814,7 @@ struct ReaderView: View { addBookmark() } .accessibilityValue(bookmarkSaved ? "已添加" : "添加当前位置") + .disabled(isSavingBookmark) readerMoreMenu } .foregroundStyle(readerChromeTint) @@ -1160,11 +1153,13 @@ struct ReaderView: View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { ForEach(ReaderStylePreset.legadoDefaults) { preset in + let isSelected = resolvedStyle.id == preset.id && activeBackgroundImage == nil Button { + useBackgroundImage = false theme = preset.id } label: { VStack(spacing: 6) { - RoundedRectangle(cornerRadius: 7) + RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(Color(uiColor: preset.background)) .frame(width: 54, height: 72) .overlay { @@ -1173,31 +1168,46 @@ struct ReaderView: View { .foregroundStyle(Color(uiColor: preset.foreground)) } .overlay { - RoundedRectangle(cornerRadius: 7) - .stroke(resolvedStyle.id == preset.id ? ReadingTheme.accent : Color.secondary, lineWidth: resolvedStyle.id == preset.id ? 3 : 1) + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder( + Color(uiColor: preset.foreground).opacity(isSelected ? 0.4 : 0.12), + lineWidth: isSelected ? 1.25 : 0.75 + ) } - Text(preset.name).font(.caption2).foregroundStyle(.primary) + .overlay(alignment: .topTrailing) { + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(Color(uiColor: preset.foreground)) + .padding(6) + } + } + Text(preset.name) + .font(.caption2) + .foregroundStyle(isSelected ? .primary : .secondary) } } .buttonStyle(.plain) + .accessibilityLabel(preset.name) + .accessibilityAddTraits(isSelected ? .isSelected : []) } } } - ReadingPhotoPicker { data in + Button("选择背景图片", systemImage: "photo") { backgroundError = nil - do { - backgroundImage = try ReaderBackgroundStore.save(data) - useBackgroundImage = true - } catch { - backgroundError = error.localizedDescription - } - } onError: { error in - backgroundError = error.localizedDescription - } label: { - Label("选择背景图片", systemImage: "photo") + showsBackgroundPicker = true } if backgroundImage != nil { - Toggle("使用背景图片", isOn: $useBackgroundImage) + HStack(spacing: 0) { + Text("使用背景图片") + .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { useBackgroundImage.toggle() } + .accessibilityHidden(true) + Toggle("使用背景图片", isOn: $useBackgroundImage) + .labelsHidden() + .fixedSize() + } Button("删除背景图片", systemImage: "trash", role: .destructive) { ReaderBackgroundStore.remove() backgroundImage = nil @@ -1219,8 +1229,15 @@ struct ReaderView: View { Text("粗体").tag(1) Text("细体").tag(2) } - TextField("段落缩进字符", text: $paragraphIndent) - .textInputAutocapitalization(.never) + VStack(alignment: .leading, spacing: 6) { + Text("段落缩进") + TextField("请输入缩进字符", text: $paragraphIndent) + .textInputAutocapitalization(.never) + .accessibilityLabel("段落缩进字符") + Text("控制每段开头的缩进,默认两个全角空格;清空后不缩进。") + .font(.caption) + .foregroundStyle(.secondary) + } Button(fontPath.isEmpty ? "选择字体文件" : "更换字体文件", systemImage: "textformat") { showsFontImporter = true } @@ -1335,6 +1352,30 @@ struct ReaderView: View { } } } + .readingPhotoPicker(isPresented: $showsBackgroundPicker) { data in + backgroundError = nil + do { + backgroundImage = try ReaderBackgroundStore.save(data) + useBackgroundImage = true + } catch { + backgroundError = error.localizedDescription + } + } onError: { error in + backgroundError = error.localizedDescription + } + .fileImporter( + isPresented: $showsFontImporter, + allowedContentTypes: [.font, .data], + allowsMultipleSelection: false + ) { result in + fontError = nil + do { + guard let source = try result.get().first else { return } + fontPath = try ReaderFontStore.install(from: source) + } catch { + fontError = error.localizedDescription + } + } .readingPresentationDetents([.medium]) .readingPresentationDragIndicator() } @@ -1866,18 +1907,26 @@ struct ReaderView: View { } } + private var bookmarkSaved: Bool { + let page = currentPage - 1 + let range = pageAnimation != 3 && readerPageRanges.indices.contains(page) ? readerPageRanges[page] : nil + return !model.bookmarks(at: readerPosition, in: activity.bookmarks, pageRange: range).isEmpty + } + private func addBookmark() { - guard let chapter = model.currentChapter else { return } - bookmarkSaved = true + guard !isSavingBookmark, !bookmarkSaved, let chapter = model.currentChapter else { return } + let currentBook = book + let position = readerPosition + let content = model.content + isSavingBookmark = true Task { + defer { isSavingBookmark = false } await activity.addBookmark( - book: book, + book: currentBook, chapter: chapter, - position: readerPosition, - content: model.content + position: position, + content: content ) - try? await Task.sleep(nanoseconds: 1_200_000_000) - bookmarkSaved = false } } diff --git a/App/Features/Reader/ReaderViewModel.swift b/App/Features/Reader/ReaderViewModel.swift index 6405c4d..f73bb5e 100644 --- a/App/Features/Reader/ReaderViewModel.swift +++ b/App/Features/Reader/ReaderViewModel.swift @@ -59,6 +59,18 @@ final class ReaderViewModel: ObservableObject { private var sourceCacheNeedsInvalidation = false private var pendingSourceContent: ChapterContentReport? + func bookmarks(at position: Int, in bookmarks: [Bookmark], pageRange: NSRange? = nil) -> [Bookmark] { + bookmarks.filter { bookmark in + guard bookmark.bookName == book.name, bookmark.bookAuthor == book.author, + bookmark.resolvedChapterIndex(in: chapters) == currentIndex else { return false } + if bookmark.chapterPosition == max(position, 0) { return true } + if let pageRange, pageRange.length > 0 { + return NSLocationInRange(bookmark.chapterPosition, pageRange) + } + return false + } + } + func prepareContinuousReading(rules: [ReplaceRule]) async { guard hasLoadedCurrentChapter else { return } if scrollChapters.isEmpty { diff --git a/App/Features/Sources/SourceLibraryModel.swift b/App/Features/Sources/SourceLibraryModel.swift index 4214c96..4d39470 100644 --- a/App/Features/Sources/SourceLibraryModel.swift +++ b/App/Features/Sources/SourceLibraryModel.swift @@ -4,6 +4,36 @@ import LegadoCore import Foundation import SwiftUI +final class SourceImportFile: @unchecked Sendable { + let url: URL + private let hasSecurityScope: Bool + + init(url: URL) { + self.url = url + hasSecurityScope = url.startAccessingSecurityScopedResource() + } + + deinit { + if hasSecurityScope { url.stopAccessingSecurityScopedResource() } + } + + func read() throws -> Data { + var coordinationError: NSError? + var result: Result? + NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &coordinationError) { readableURL in + result = Result { + guard try readableURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory != true else { + throw BookSourceImportError.invalidDocument("请选择书源 JSON 文件,而不是文件夹。") + } + return try Data(contentsOf: readableURL) + } + } + if let coordinationError { throw coordinationError } + guard let result else { throw CocoaError(.fileReadUnknown) } + return try result.get() + } +} + @MainActor final class SourceLibraryModel: ObservableObject { @Published private(set) var sources: [BookSource] = [] @@ -168,16 +198,18 @@ final class SourceLibraryModel: ObservableObject { } func previewFiles(_ urls: [URL]) async { + await previewPickedFiles(urls.map { SourceImportFile(url: $0) }) + } + + func previewPickedFiles(_ files: [SourceImportFile]) async { isWorking = true defer { isWorking = false } do { var incoming: [String: BookSource] = [:] var invalidItems: [PendingImport.InvalidItem] = [] - for url in urls { - let granted = url.startAccessingSecurityScopedResource() - defer { if granted { url.stopAccessingSecurityScopedResource() } } + for file in files { let data = try await Task.detached(priority: .userInitiated) { [importer] in - try importer.normalizedData(Data(contentsOf: url)) + try importer.normalizedData(file.read()) }.value var budget = 100 let batch = try await resolveImport(String(decoding: data, as: UTF8.self), depth: 0, visited: [], budget: &budget) @@ -186,10 +218,6 @@ final class SourceLibraryModel: ObservableObject { incoming[source.id] = source } } - // UIDocumentPicker is still dismissing when its completion runs. - // Presenting the review sheet in that same frame is ignored by - // SwiftUI, so stage it after the picker has left the hierarchy. - try? await Task.sleep(nanoseconds: 300_000_000) stage(Array(incoming.values), invalidItems: invalidItems) } catch { notice = .init(kind: .error, title: "书源导入失败", detail: error.localizedDescription) diff --git a/App/Features/Sources/SourceManagementView.swift b/App/Features/Sources/SourceManagementView.swift index 6aca0bf..92a4712 100644 --- a/App/Features/Sources/SourceManagementView.swift +++ b/App/Features/Sources/SourceManagementView.swift @@ -5,6 +5,8 @@ import UniformTypeIdentifiers struct SourceManagementView: View { @EnvironmentObject private var library: SourceLibraryModel @State private var showsImporter = false + @State private var pickedImportFiles: [SourceImportFile] = [] + @State private var importPickerHasDismissed = true @State private var showsNewSource = false @State private var showsQRImporter = false @State private var showsTextImporter = false @@ -58,7 +60,7 @@ struct SourceManagementView: View { } description: { Text("新建书源,或从文件、链接、JSON、二维码导入。") } actions: { - Button("导入书源") { showsImporter = true } + Button("导入书源", action: presentFileImporter) .buttonStyle(.borderedProminent) } } else { @@ -142,7 +144,7 @@ struct SourceManagementView: View { Menu("更多", systemImage: "ellipsis.circle") { Button("新建书源", systemImage: "plus") { showsNewSource = true } Button("二维码导入", systemImage: "qrcode.viewfinder") { showsQRImporter = true } - Button("从文件导入", systemImage: "doc.badge.plus") { showsImporter = true } + Button("从文件导入", systemImage: "doc.badge.plus", action: presentFileImporter) Button("URL 或粘贴 JSON", systemImage: "link.badge.plus") { showsTextImporter = true } Divider() NavigationLink { @@ -206,24 +208,71 @@ struct SourceManagementView: View { .sheet(isPresented: $showsBatchGroups) { SourceBatchGroupView(sourceIDs: selectedSourceIDs).environmentObject(library) } - .fileImporter( - isPresented: $showsImporter, - allowedContentTypes: [.item], - allowsMultipleSelection: false - ) { result in - switch result { - case .success(let urls): - let files = urls.filter { !$0.hasDirectoryPath } - if files.isEmpty { - library.notice = .init(kind: .error, title: "无法选择文件", detail: "请选择书源 JSON 文件,而不是文件夹。") - } else { - Task { await library.previewFiles(files) } - } - case .failure(let error): - library.notice = .init(kind: .error, title: "无法选择文件", detail: error.localizedDescription) + .sheet(isPresented: $showsImporter, onDismiss: { + importPickerHasDismissed = true + previewPickedFilesWhenReady() + }) { + SourceFileImportPicker { files in + pickedImportFiles = files + showsImporter = false + previewPickedFilesWhenReady() + } onCancel: { + showsImporter = false } } } + + private func presentFileImporter() { + pickedImportFiles = [] + importPickerHasDismissed = false + showsImporter = true + } + + private func previewPickedFilesWhenReady() { + // Import-copy mode can dismiss before returning URLs. Other providers + // return URLs first, so wait for both events instead of assuming order. + guard importPickerHasDismissed, !pickedImportFiles.isEmpty else { return } + let files = pickedImportFiles + pickedImportFiles = [] + Task { await library.previewPickedFiles(files) } + } +} + +private struct SourceFileImportPicker: UIViewControllerRepresentable { + let onPick: @MainActor ([SourceImportFile]) -> Void + let onCancel: @MainActor () -> Void + + func makeCoordinator() -> Coordinator { Coordinator(parent: self) } + + func makeUIViewController(context: Context) -> UIDocumentPickerViewController { + // Ask the provider to import copies, including JSON files whose provider + // does not support opening documents in place. Multi-select gives Files + // an explicit confirmation action for the selected files. + let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.item], asCopy: true) + picker.allowsMultipleSelection = true + picker.shouldShowFileExtensions = true + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ controller: UIDocumentPickerViewController, context: Context) {} + + @MainActor + final class Coordinator: NSObject, UIDocumentPickerDelegate { + private let parent: SourceFileImportPicker + + init(parent: SourceFileImportPicker) { self.parent = parent } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + parent.onCancel() + } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + // Hold the picker grants before dismissing, until the model has + // finished its coordinated background reads. + parent.onPick(urls.map { SourceImportFile(url: $0) }) + } + } } private struct SourceTextImportView: View { @@ -337,16 +386,22 @@ private struct SourceGroupManagerView: View { } .disabled(BookSource.parseGroups(newGroup).isEmpty) } - Text("新分组会分配给当前未分组书源,与 Legado 的分组管理行为一致。") + Text("添加后,所有未分组的书源都会加入该分组。") .font(.caption).foregroundStyle(.secondary) } - Section("已有分组") { + Section { ForEach(library.groups, id: \.self) { group in HStack { - Label(group, systemImage: "folder.fill") - Spacer() - Text("\(library.sources.filter { $0.hasGroup(group) }.count)") - .foregroundStyle(.secondary) + NavigationLink { + SourceGroupContentsView(group: group) + } label: { + HStack { + Label(group, systemImage: "folder.fill") + Spacer() + Text("\(library.sources.filter { $0.hasGroup(group) }.count) 个书源") + .foregroundStyle(.secondary) + } + } Menu { Button("重命名", systemImage: "pencil") { renaming = group @@ -356,8 +411,14 @@ private struct SourceGroupManagerView: View { Task { await library.deleteGroup(group) } } } label: { Image(systemName: "ellipsis.circle") } + .buttonStyle(.borderless) + .accessibilityLabel("\(group)分组操作") } } + } header: { + Text("已有分组") + } footer: { + Text("点击分组可查看其中的书源。") } } .navigationTitle("书源分组") @@ -378,6 +439,42 @@ private struct SourceGroupManagerView: View { } } +private struct SourceGroupContentsView: View { + @EnvironmentObject private var library: SourceLibraryModel + let group: String + + private var sources: [BookSource] { + library.sources.filter { $0.hasGroup(group) } + } + + var body: some View { + List { + Section { + ForEach(sources) { source in + NavigationLink { + SourceDetailView(source: source) + } label: { + SourceRow(source: source) { enabled in + Task { await library.setEnabled(enabled, for: source.id) } + } + } + } + if sources.isEmpty { + Text("该分组暂无书源") + .foregroundStyle(.secondary) + } + } header: { + Text("\(sources.count) 个书源") + } + } + .listStyle(.insetGrouped) + .readingScrollContentBackgroundHidden() + .background(ReadingTheme.groupedCanvas) + .navigationTitle(group) + .navigationBarTitleDisplayMode(.inline) + } +} + private struct SourceBatchGroupView: View { @Environment(\.dismiss) private var dismiss @EnvironmentObject private var library: SourceLibraryModel diff --git a/App/Info.plist b/App/Info.plist index a22ace5..bb272a2 100644 --- a/App/Info.plist +++ b/App/Info.plist @@ -34,7 +34,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 0.1.9 + $(MARKETING_VERSION) CFBundleURLTypes diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bdb2c4..3e21fe5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 更新记录 +## 0.1.10(2026-10-20) + +- 修复书源文件选择及导入流程,支持 JSON 多选,调整文件访问权限和导入预览展示时机。 +- 支持进入书源分组查看书源详情及切换启用状态。 +- 修复阅读背景图片与字体选择,限制背景图片解码尺寸,完善图片与预设主题切换。 +- 修复重新进入阅读器后的书签状态,避免重复添加。 +- 应用版本为 `0.1.10`,构建号为 `16`。 + ## 0.1.9(2026-10-14) - 个人中心采用阅读静物头图、暖白圆角卡片和黑金双色图标,适配深色模式。 diff --git a/Legado.xcodeproj/project.pbxproj b/Legado.xcodeproj/project.pbxproj index 726264f..ca12a8b 100644 --- a/Legado.xcodeproj/project.pbxproj +++ b/Legado.xcodeproj/project.pbxproj @@ -858,12 +858,12 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 16; DEVELOPMENT_ASSET_PATHS = ""; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 0.1.9; + MARKETING_VERSION = 0.1.10; OTHER_LDFLAGS = "$(inherited) -lsqlite3"; PRODUCT_BUNDLE_IDENTIFIER = com.epiboly.legado; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -879,11 +879,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 16; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 0.1.9; + MARKETING_VERSION = 0.1.10; OTHER_LDFLAGS = "$(inherited) -lsqlite3"; PRODUCT_BUNDLE_IDENTIFIER = com.epiboly.legado; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/Tests/LegadoAppModelsTests/ReaderRegressionTests.swift b/Tests/LegadoAppModelsTests/ReaderRegressionTests.swift index fe17032..dadc68d 100644 --- a/Tests/LegadoAppModelsTests/ReaderRegressionTests.swift +++ b/Tests/LegadoAppModelsTests/ReaderRegressionTests.swift @@ -207,6 +207,30 @@ struct ReaderRegressionTests { #expect(bookmark.resolvedChapterIndex(in: chapters) == nil) } + @Test func bookmarkSelectionUsesSavedPositionAndCurrentPageAfterReopening() { + let book = Book(bookUrl: "demo://bookmarks", name: "书", author: "作者") + let chapters = (0..<2).map { BookChapter(url: "demo://\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) } + var saved = Bookmark(time: 1, bookName: book.name, bookAuthor: book.author) + saved.chapterIndex = 0 + saved.chapterName = chapters[0].title + saved.chapterPosition = 40 + var otherBook = saved + otherBook.bookAuthor = "其他作者" + var otherChapter = saved + otherChapter.chapterIndex = 1 + otherChapter.chapterName = chapters[1].title + let bookmarks = [saved, otherBook, otherChapter] + let reopened = ReaderViewModel(demoBook: book, chapters: chapters, initialIndex: 0, content: "正文") + + #expect(reopened.bookmarks(at: 40, in: bookmarks) == [saved]) + #expect(reopened.bookmarks(at: 0, in: bookmarks, pageRange: NSRange(location: 0, length: 80)) == [saved]) + #expect(reopened.bookmarks(at: 80, in: bookmarks, pageRange: NSRange(location: 80, length: 80)).isEmpty) + #expect(reopened.bookmarks(at: 41, in: bookmarks).isEmpty) + #expect(reopened.bookmarks(at: 40, in: []).isEmpty) + reopened.select(index: 1) + #expect(reopened.bookmarks(at: 40, in: bookmarks) == [otherChapter]) + } + @Test func continuousReadingExtendsPastThreeChaptersWithoutNavigating() async { let book = Book(bookUrl: "demo://continuous", name: "书") let chapters = (0..<20).map { BookChapter(url: "demo://\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) } diff --git a/Tests/LegadoAppModelsTests/RuleLibraryRegressionTests.swift b/Tests/LegadoAppModelsTests/RuleLibraryRegressionTests.swift index c13fb8a..6f43805 100644 --- a/Tests/LegadoAppModelsTests/RuleLibraryRegressionTests.swift +++ b/Tests/LegadoAppModelsTests/RuleLibraryRegressionTests.swift @@ -25,6 +25,51 @@ private actor ImportFixtureTransport: HTTPTransport { @MainActor struct RuleLibraryRegressionTests { + @Test func pickedJSONFilesAreReadTogetherAndReviewedBeforeImport() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let inputs = [ + ("书源.json", #"{"bookSourceUrl":"https://picked-json.test","bookSourceName":"JSON 书源"}"#), + ("大写扩展名.JSON", #"[{"bookSourceUrl":"https://picked-uppercase.test","bookSourceName":"大写扩展名"}]"#), + ("书源.txt", #"{"bookSourceUrl":"https://picked-text.test","bookSourceName":"文本扩展名"}"#) + ] + let files = try inputs.map { name, content in + let url = directory.appendingPathComponent(name) + try Data(content.utf8).write(to: url) + return SourceImportFile(url: url) + } + let model = SourceLibraryModel.demo(sources: []) + + await model.previewPickedFiles(files) + + #expect(model.notice == nil) + #expect(!model.isWorking) + #expect(model.pendingImport?.validCount == 3) + #expect(model.sources.isEmpty) + let selectedIDs = Set(try #require(model.pendingImport).items.map(\.id)) + await model.commitPendingImport(selectedIDs: selectedIDs, keepOriginalName: false, keepOriginalGroup: false, + keepOriginalEnabled: false, customGroup: nil, addCustomGroup: true) + #expect(model.sources.count == 3) + #expect(model.pendingImport == nil) + } + + @Test func missingOrDirectoryImportReportsFailureWithoutChangingSources() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let original = BookSource(bookSourceUrl: "https://existing-source.test", bookSourceName: "原有书源") + for url in [directory, directory.appendingPathComponent("missing.json")] { + let model = SourceLibraryModel.demo(sources: [original]) + await model.load() + await model.previewFiles([url]) + #expect(model.notice?.kind == .error) + #expect(model.pendingImport == nil) + #expect(model.sources == [original]) + #expect(!model.isWorking) + } + } + @Test func largeImportKeepsTheMainActorResponsive() async throws { let comment = String(repeating: "规则说明", count: 1_024) let entries = (0..<1_000).map { index in diff --git a/Tests/LegadoReaderTests/ReaderScrollGeometryTests.swift b/Tests/LegadoReaderTests/ReaderScrollGeometryTests.swift index 0519fab..f8ad78f 100644 --- a/Tests/LegadoReaderTests/ReaderScrollGeometryTests.swift +++ b/Tests/LegadoReaderTests/ReaderScrollGeometryTests.swift @@ -1,8 +1,161 @@ +import ImageIO import SwiftUI import UIKit import XCTest @testable import Legado +private final class BackgroundPhotoProvider: NSItemProvider, @unchecked Sendable { + let photoData: Data? + let photoError: (any Error)? + + init(data: Data?, error: (any Error)? = nil) { + photoData = data + photoError = error + super.init() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override var registeredTypeIdentifiers: [String] { ["public.png"] } + + override func loadDataRepresentation( + forTypeIdentifier typeIdentifier: String, + completionHandler: @escaping @Sendable (Data?, (any Error)?) -> Void + ) -> Progress { + DispatchQueue.global(qos: .userInitiated).async { [photoData, photoError] in + dispatchPrecondition(condition: .notOnQueue(.main)) + completionHandler(photoData, photoError) + } + return Progress(totalUnitCount: 1) + } +} + +@MainActor +final class ReadingPhotoPickerTests: XCTestCase { + func testPhotoDataReturnedOnBackgroundQueueReachesMainActor() async throws { + let expected = Data("photo-provider-data".utf8) + let provider = BackgroundPhotoProvider(data: expected) + let received = expectation(description: "Photo data delivered on main actor") + let coordinator = ReadingPhotoPickerController.Coordinator { result in + XCTAssertTrue(Thread.isMainThread) + XCTAssertEqual(try? result?.get(), expected) + received.fulfill() + } + + coordinator.loadImage(from: provider) + await fulfillment(of: [received], timeout: 5) + withExtendedLifetime(coordinator) {} + } + + func testPhotoProviderFailureOnBackgroundQueueReachesMainActor() async throws { + let errors: [(any Error)?] = [CocoaError(.fileReadNoPermission), nil] + for error in errors { + let provider = BackgroundPhotoProvider(data: nil, error: error) + let received = expectation(description: "Photo error delivered on main actor") + let coordinator = ReadingPhotoPickerController.Coordinator { result in + XCTAssertTrue(Thread.isMainThread) + guard case .failure(let failure)? = result else { + XCTFail("Expected an import error") + received.fulfill() + return + } + XCTAssertEqual((failure as NSError).domain, NSCocoaErrorDomain) + XCTAssertEqual((failure as NSError).code, (error as NSError?)?.code ?? NSFileReadCorruptFileError) + received.fulfill() + } + + coordinator.loadImage(from: provider) + await fulfillment(of: [received], timeout: 5) + withExtendedLifetime(coordinator) {} + } + } +} + +@MainActor +final class ReaderBackgroundStoreTests: XCTestCase { + func testLargeBackgroundIsDownsampledOnImportAndLegacyReload() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let target = directory.appendingPathComponent("background.jpg") + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let data = UIGraphicsImageRenderer(size: CGSize(width: 4096, height: 3072), format: format).jpegData(withCompressionQuality: 0.9) { context in + UIColor.red.setFill() + context.fill(CGRect(x: 0, y: 0, width: 4096, height: 3072)) + } + + for _ in 0..<3 { + let imported = try ReaderBackgroundStore.save(data, to: target) + XCTAssertEqual(imported.size.width, CGFloat(ReaderBackgroundStore.maximumPixelSize)) + XCTAssertEqual(imported.size.height / imported.size.width, 0.75, accuracy: 0.001) + let restored = try XCTUnwrap(ReaderBackgroundStore.load(from: target)) + XCTAssertEqual(restored.size, imported.size) + } + try data.write(to: target) + let legacy = try XCTUnwrap(ReaderBackgroundStore.load(from: target)) + XCTAssertEqual(legacy.size.width, CGFloat(ReaderBackgroundStore.maximumPixelSize)) + } + + func testCorruptReplacementPreservesExistingBackground() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let target = directory.appendingPathComponent("background.jpg") + let image = UIGraphicsImageRenderer(size: CGSize(width: 40, height: 60)).image { context in + UIColor.blue.setFill() + context.fill(CGRect(x: 0, y: 0, width: 40, height: 60)) + } + _ = try ReaderBackgroundStore.save(try XCTUnwrap(image.pngData()), to: target) + let saved = try Data(contentsOf: target) + + XCTAssertThrowsError(try ReaderBackgroundStore.save(Data("not an image".utf8), to: target)) + XCTAssertEqual(try Data(contentsOf: target), saved) + XCTAssertNotNil(ReaderBackgroundStore.load(from: target)) + } + + func testBackgroundPreservesPhotoOrientation() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let image = UIGraphicsImageRenderer(size: CGSize(width: 120, height: 60)).image { context in + UIColor.green.setFill() + context.fill(CGRect(x: 0, y: 0, width: 120, height: 60)) + } + let data = NSMutableData() + let destination = try XCTUnwrap(CGImageDestinationCreateWithData(data, "public.jpeg" as CFString, 1, nil)) + CGImageDestinationAddImage(destination, try XCTUnwrap(image.cgImage), [kCGImagePropertyOrientation: 6] as CFDictionary) + XCTAssertTrue(CGImageDestinationFinalize(destination)) + + let saved = try ReaderBackgroundStore.save(data as Data, to: directory.appendingPathComponent("background.jpg")) + XCTAssertEqual(saved.imageOrientation, .up) + XCTAssertEqual(saved.size.height / saved.size.width, 2, accuracy: 0.001) + } +} + +@MainActor +final class ReaderBookmarkPersistenceTests: XCTestCase { + func testBookmarkSurvivesNewActivityAndReaderInstances() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let repository = FileReadingActivityRepository( + bookmarksURL: directory.appendingPathComponent("bookmarks.json"), + recordsURL: directory.appendingPathComponent("records.json") + ) + let book = Book(bookUrl: "demo://saved-bookmark", name: "书", author: "作者") + let chapters = [BookChapter(url: "demo://chapter", title: "第一章", bookUrl: book.bookUrl, index: 0)] + let original = ReadingActivityModel(repository: repository, deviceID: "test") + await original.load() + await original.addBookmark(book: book, chapter: chapters[0], position: 40, content: String(repeating: "正文", count: 100)) + XCTAssertNil(original.errorMessage) + + let restored = ReadingActivityModel(repository: repository, deviceID: "test") + await restored.load() + let reader = ReaderViewModel(demoBook: book, chapters: chapters, initialIndex: 0, content: "正文") + XCTAssertEqual(reader.bookmarks(at: 40, in: restored.bookmarks).count, 1) + XCTAssertTrue(reader.bookmarks(at: 80, in: restored.bookmarks).isEmpty) + await restored.removeBookmark(try XCTUnwrap(restored.bookmarks.first)) + XCTAssertTrue(reader.bookmarks(at: 40, in: restored.bookmarks).isEmpty) + } +} + @MainActor final class ReaderScrollGeometryTests: XCTestCase { func testPrependingAndRecyclingStayWithinOnePhysicalPixel() async throws { @@ -116,7 +269,7 @@ final class ReaderScrollGeometryTests: XCTestCase { .init(configuration: configuration(fontSize: fontSize), chapters: chapters, resetID: 0, activeIndex: active, loadingAhead: false, loadingBehind: false, onNeedChapters: { _ in }, onPositionChanged: { _, _ in }, - onRetainChapters: { _ in }, onRetry: { _ in }) + onRetainChapters: { _ in }) } private func configuration(fontSize: Double = 20) -> ReaderTextView { diff --git a/Tests/LegadoReaderUITests/ReaderScrollGestureTests.swift b/Tests/LegadoReaderUITests/ReaderScrollGestureTests.swift index 9073b7a..7360e0b 100644 --- a/Tests/LegadoReaderUITests/ReaderScrollGestureTests.swift +++ b/Tests/LegadoReaderUITests/ReaderScrollGestureTests.swift @@ -1,7 +1,185 @@ import XCTest +import UIKit @MainActor final class ReaderScrollGestureTests: XCTestCase { + func testBackgroundSwitchRestoresPresetWithoutDeletingPhoto() throws { + try exerciseBackgroundSwitch(readingMode: "0") + } + + func testScrollBackgroundSwitchRestoresPresetWithoutDeletingPhoto() throws { + try exerciseBackgroundSwitch(readingMode: "3") + } + + private func exerciseBackgroundSwitch(readingMode: String) throws { + continueAfterFailure = false + let app = launchAppearance(readingMode: readingMode) + let choose = app.buttons["选择背景图片"] + scrollToAppearanceElement(choose, in: app) + app.buttons["微信读书"].tap() + choose.tap() + let photo = app.images["PXGGridLayout-Info"].firstMatch + XCTAssertTrue(photo.waitForExistence(timeout: 10)) + photo.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch + let dismissed = expectation(for: NSPredicate { _, _ in !cancel.exists }, evaluatedWith: nil) + wait(for: [dismissed], timeout: 15) + let useBackground = app.switches["使用背景图片"] + scrollToAppearanceElement(useBackground, in: app) + XCTAssertEqual(useBackground.value as? String, "1") + app.buttons["完成"].tap() + let imageColor = try backgroundRGB(in: app) + + func openAppearance() { + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + app.buttons["外观"].tap() + XCTAssertTrue(app.navigationBars["阅读样式"].waitForExistence(timeout: 5)) + scrollToAppearanceElement(useBackground, in: app) + } + openAppearance() + attachScreenshot(app) + app.coordinate(withNormalizedOffset: CGVector( + dx: 0.25, dy: useBackground.frame.midY / app.frame.height + )).tap() + XCTAssertEqual(useBackground.value as? String, "0") + XCTAssertTrue(app.buttons["删除背景图片"].exists) + app.buttons["完成"].tap() + XCTAssertEqual(try backgroundRGB(in: app), [192, 237, 198]) + + openAppearance() + useBackground.tap() + XCTAssertEqual(useBackground.value as? String, "1") + app.buttons["完成"].tap() + XCTAssertEqual(try backgroundRGB(in: app), imageColor) + + openAppearance() + app.buttons["预设2"].tap() + XCTAssertEqual(useBackground.value as? String, "0") + app.buttons["完成"].tap() + XCTAssertEqual(try backgroundRGB(in: app), [221, 192, 144]) + } + + private func backgroundRGB(in app: XCUIApplication) throws -> [Int] { + let image = try XCTUnwrap(app.screenshot().image.cgImage) + let pixel = try XCTUnwrap(image.cropping(to: CGRect( + x: image.width / 50, y: image.height * 28 / 100, width: 1, height: 1 + ))) + var bytes = [UInt8](repeating: 0, count: 4) + try bytes.withUnsafeMutableBytes { buffer in + let context = try XCTUnwrap(CGContext( + data: buffer.baseAddress, width: 1, height: 1, bitsPerComponent: 8, + bytesPerRow: 4, space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue + )) + context.draw(pixel, in: CGRect(x: 0, y: 0, width: 1, height: 1)) + } + return bytes.prefix(3).map(Int.init) + } + + func testBackgroundPickerCanBeOpenedRepeatedly() throws { + continueAfterFailure = false + let app = launchAppearance() + for _ in 0..<3 { + let choose = app.buttons["选择背景图片"] + scrollToAppearanceElement(choose, in: app) + choose.tap() + let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch + XCTAssertTrue(cancel.waitForExistence(timeout: 10)) + cancel.tap() + XCTAssertTrue(app.navigationBars["阅读样式"].waitForExistence(timeout: 5)) + } + } + + func testBackgroundPhotoCanBeSelectedAndReplaced() throws { + try exerciseBackgroundPhotoSelection(usesLegacyUI: false) + } + + func testLegacyBackgroundPhotoCanBeSelectedAndReplaced() throws { + try exerciseBackgroundPhotoSelection(usesLegacyUI: true) + } + + private func exerciseBackgroundPhotoSelection(usesLegacyUI: Bool) throws { + continueAfterFailure = false + let app = launchAppearance(usesLegacyUI: usesLegacyUI) + for _ in 0..<2 { + let choose = app.buttons["选择背景图片"] + scrollToAppearanceElement(choose, in: app) + choose.tap() + let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch + XCTAssertTrue(cancel.waitForExistence(timeout: 10)) + let photo = app.images["PXGGridLayout-Info"].firstMatch + XCTAssertTrue(photo.waitForExistence(timeout: 5), "Seed a demo image with simctl addmedia before running this test") + photo.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + let dismissed = expectation(for: NSPredicate { _, _ in !cancel.exists }, evaluatedWith: nil) + wait(for: [dismissed], timeout: 15) + XCTAssertTrue(app.navigationBars["阅读样式"].waitForExistence(timeout: 15)) + let useBackground = app.switches["使用背景图片"] + scrollToAppearanceElement(useBackground, in: app) + XCTAssertEqual(useBackground.value as? String, "1") + } + } + + func testFontPickerCanBePresentedAboveAppearanceAndReopened() throws { + continueAfterFailure = false + let app = launchAppearance() + for _ in 0..<2 { + let choose = app.buttons["选择字体文件"] + scrollToAppearanceElement(choose, in: app) + XCTAssertTrue(app.staticTexts["段落缩进"].exists) + choose.tap() + let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch + XCTAssertTrue(cancel.waitForExistence(timeout: 10)) + cancel.tap() + XCTAssertTrue(app.navigationBars["阅读样式"].waitForExistence(timeout: 5)) + } + } + + func testBookmarkSelectionSurvivesLeavingAndReenteringReader() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-uiDemo", "-pageAnim", "0", "-themeMode", "1", "-textSize", "28"] + app.launch() + let book = app.staticTexts["山海入梦来"].firstMatch + XCTAssertTrue(book.waitForExistence(timeout: 20)) + book.tap() + XCTAssertTrue(app.textViews["reader.text"].waitForExistence(timeout: 20)) + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + let bookmark = app.buttons["书签"] + XCTAssertTrue(bookmark.waitForExistence(timeout: 5)) + XCTAssertEqual(bookmark.value as? String, "添加当前位置") + bookmark.tap() + app.buttons["返回"].tap() + XCTAssertTrue(book.waitForExistence(timeout: 10)) + book.tap() + XCTAssertTrue(app.textViews["reader.text"].waitForExistence(timeout: 10)) + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + XCTAssertTrue(bookmark.waitForExistence(timeout: 5)) + XCTAssertEqual(bookmark.value as? String, "已添加") + app.buttons["更多"].tap() + app.buttons["书签列表"].tap() + XCTAssertTrue(app.navigationBars["书签"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["第一章 · 雨夜"].firstMatch.exists) + } + + private func launchAppearance(readingMode: String = "0", usesLegacyUI: Bool = false) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = ["-uiDemo", "-uiDemoReader", "-uiDemoReaderStyle", "-pageAnim", readingMode, "-themeMode", "1", "-textFont", ""] + if usesLegacyUI { app.launchArguments.append("-uiLegacyCompatibility") } + app.launch() + XCTAssertTrue(app.navigationBars["阅读样式"].waitForExistence(timeout: 20)) + return app + } + + private func scrollToAppearanceElement(_ element: XCUIElement, in app: XCUIApplication) { + for _ in 0..<12 { + if element.exists && element.isHittable { return } + let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.88)) + let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.72)) + start.press(forDuration: 0.05, thenDragTo: end) + } + XCTAssertTrue(element.exists && element.isHittable) + } + func testFailedChaptersAllowRetryMenuAndSourceSwitchInBothReadingModes() throws { continueAfterFailure = false for mode in ["3", "0"] { diff --git a/Tests/LegadoReaderUITests/RuleEditorUITests.swift b/Tests/LegadoReaderUITests/RuleEditorUITests.swift index edfbcd4..471ec97 100644 --- a/Tests/LegadoReaderUITests/RuleEditorUITests.swift +++ b/Tests/LegadoReaderUITests/RuleEditorUITests.swift @@ -2,6 +2,68 @@ import XCTest @MainActor final class RuleEditorUITests: XCTestCase { + func testJSONFilePickerOffersOpenAndImportsSelectedFiles() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-uiDemo", "-uiDemoTab", "sources"] + app.launch() + XCTAssertTrue(app.navigationBars["书源"].waitForExistence(timeout: 20)) + app.buttons["更多"].firstMatch.tap() + app.buttons["从文件导入"].tap() + let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch + XCTAssertTrue(cancel.waitForExistence(timeout: 10)) + cancel.tap() + let pickerClosed = expectation(for: NSPredicate { _, _ in + !cancel.exists && app.navigationBars["书源"].buttons["更多"].isHittable + }, evaluatedWith: nil) + wait(for: [pickerClosed], timeout: 10) + app.buttons["更多"].firstMatch.tap() + app.buttons["从文件导入"].tap() + XCTAssertTrue(cancel.waitForExistence(timeout: 10)) + + let json = app.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", "JSON-Source")).firstMatch + let fixtures = app.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", "书源导入测试")).firstMatch + if !json.waitForExistence(timeout: 3) { + if !fixtures.exists { + let browse = app.buttons.matching(NSPredicate(format: "label IN %@", ["Browse", "浏览", "Locations", "位置"])).firstMatch + if browse.exists { browse.tap() } + if !fixtures.waitForExistence(timeout: 2) { + let local = app.descendants(matching: .any).matching(NSPredicate( + format: "label CONTAINS %@ OR label CONTAINS %@ OR label == %@", "我的 iPhone", "我的iPhone", "On My iPhone" + )).firstMatch + if !local.waitForExistence(timeout: 5) { print("[SourceFilePicker] \(app.debugDescription)") } + XCTAssertTrue(local.exists) + local.tap() + } + } + XCTAssertTrue(fixtures.waitForExistence(timeout: 10), "Run sh scripts/prepare-source-import-fixture.sh before this test") + fixtures.tap() + } + XCTAssertTrue(json.waitForExistence(timeout: 10)) + json.tap() + let uppercase = app.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", "JSON-Uppercase")).firstMatch + XCTAssertTrue(uppercase.waitForExistence(timeout: 5)) + uppercase.tap() + let open = app.buttons.matching(NSPredicate(format: "label IN %@", ["Open", "打开", "Import", "导入"])).firstMatch + XCTAssertTrue(open.waitForExistence(timeout: 5)) + XCTAssertTrue(open.isEnabled) + attach(app, name: "JSON files selected with Open available") + open.tap() + + let reviewAppeared = app.navigationBars["书源导入预检"].waitForExistence(timeout: 15) + if !reviewAppeared { + print("[SourceFilePickerAfterOpen] \(app.debugDescription)") + attach(app, name: "File picker after Open") + } + XCTAssertTrue(reviewAppeared) + XCTAssertTrue(app.buttons["导入"].isEnabled) + attach(app, name: "JSON import review after file picker dismissal") + app.buttons["导入"].tap() + if app.alerts.firstMatch.waitForExistence(timeout: 5) { app.alerts.buttons["好"].tap() } + XCTAssertTrue(app.staticTexts["文件导入 JSON 测试"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.staticTexts["文件导入大写扩展名测试"].exists) + } + func testSourceFormJSONSwitchKeepsHighlightedTextAndSaves() throws { try exerciseSourceEditor(usesLegacyUI: false) } diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 943cc9c..d6a2177 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -41,6 +41,19 @@ sh scripts/self-check.sh 阅读器测试在 Xcode 选择 `LegadoReaderTests` / `LegadoReaderUITests` Scheme 和具体模拟器运行。联网书源测试需要显式配置对应环境变量,默认核心回归主要采用自造响应,不能将其通过解读为所有线上书源均可用。 +书源文件选择回归需要先在已启动的测试模拟器中准备文件共享应用(将下方 UUID 换成实际值): + +```sh +LEGADO_TEST_DEVICE="模拟器 UUID" +sh scripts/prepare-source-import-fixture.sh "$LEGADO_TEST_DEVICE" +xcodebuild -project Legado.xcodeproj -scheme LegadoReaderUITests \ + -destination "platform=iOS Simulator,id=$LEGADO_TEST_DEVICE" \ + -only-testing:LegadoReaderUITests/RuleEditorUITests/testJSONFilePickerOffersOpenAndImportsSelectedFiles \ + -collect-test-diagnostics never CODE_SIGNING_ALLOWED=NO test +``` + +独立测试应用只提供 `.json`、`.JSON` 和 `.txt` 样本,不会被打包进正式 App。此回归检查取消后重新打开、JSON 多选后的打开按钮、导入预检及最终保存。 + ## 兼容性验证 最低部署版本为 iOS 15.0。`App/Compatibility.swift` 为导航、分享、照片选择和弹窗提供兼容实现;新增系统 API 时需保留最低版本路径。调试构建可用 `-uiLegacyCompatibility` 启动参数在新系统上执行旧版界面分支,对应回归位于 `Tests/LegadoReaderUITests/`。这些测试不能替代 iOS 15 真机或模拟器实测。 diff --git a/scripts/fixtures/SourceImportFixture.swift b/scripts/fixtures/SourceImportFixture.swift new file mode 100644 index 0000000..3c05ea9 --- /dev/null +++ b/scripts/fixtures/SourceImportFixture.swift @@ -0,0 +1,33 @@ +import UIKit + +// Standalone simulator-only file provider; never bundled with Legado. +@main +@MainActor +final class FixtureAppDelegate: UIResponder, UIApplicationDelegate { + var window: UIWindow? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let fixtures = [ + ("JSON-Source.json", "https://file-import.test/json", "文件导入 JSON 测试"), + ("JSON-Uppercase.JSON", "https://file-import.test/uppercase", "文件导入大写扩展名测试"), + ("JSON-Text.txt", "https://file-import.test/text", "文件导入文本扩展名测试") + ] + for (name, url, title) in fixtures { + let source = [["bookSourceUrl": url, "bookSourceName": title]] + if let data = try? JSONSerialization.data(withJSONObject: source) { + try? data.write(to: documents.appendingPathComponent(name), options: .atomic) + } + } + let controller = UIViewController() + controller.view.backgroundColor = .systemBackground + let label = UILabel() + label.text = "书源导入测试文件已准备好" + label.frame = CGRect(x: 20, y: 100, width: 350, height: 80) + controller.view.addSubview(label) + window = UIWindow(frame: UIScreen.main.bounds) + window?.rootViewController = controller + window?.makeKeyAndVisible() + return true + } +} diff --git a/scripts/prepare-source-import-fixture.sh b/scripts/prepare-source-import-fixture.sh new file mode 100644 index 0000000..89b7290 --- /dev/null +++ b/scripts/prepare-source-import-fixture.sh @@ -0,0 +1,45 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 1 ]; then + printf 'Usage: sh scripts/prepare-source-import-fixture.sh \n' >&2 + exit 2 +fi + +legado_fixture_device="$1" +legado_fixture_repo="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +legado_fixture_build="$(mktemp -d "${TMPDIR:-/tmp}/legado-source-import.XXXXXX")" +trap 'rm -rf "$legado_fixture_build"' EXIT HUP INT TERM +legado_fixture_app="$legado_fixture_build/SourceImportFixtures.app" +legado_fixture_sdk="$(xcrun --sdk iphonesimulator --show-sdk-path)" +legado_fixture_arch="$(uname -m)" +mkdir -p "$legado_fixture_app" + +cat > "$legado_fixture_app/Info.plist" <<'PLIST' + + + + CFBundleIdentifiercom.epiboly.legado.source-import-fixtures + CFBundleExecutableSourceImportFixtures + CFBundleNameSourceImportFixtures + CFBundleDisplayName书源导入测试 + CFBundlePackageTypeAPPL + CFBundleVersion1 + CFBundleShortVersionString1.0 + MinimumOSVersion15.0 + LSRequiresIPhoneOS + UIFileSharingEnabled + LSSupportsOpeningDocumentsInPlace + UILaunchScreen + UISupportedInterfaceOrientationsUIInterfaceOrientationPortrait + +PLIST + +xcrun --sdk iphonesimulator swiftc -parse-as-library -swift-version 6 \ + -sdk "$legado_fixture_sdk" -target "$legado_fixture_arch-apple-ios15.0-simulator" \ + -module-cache-path "$legado_fixture_repo/DerivedData/ModuleCache.noindex" \ + "$legado_fixture_repo/scripts/fixtures/SourceImportFixture.swift" \ + -o "$legado_fixture_app/SourceImportFixtures" +xcrun simctl install "$legado_fixture_device" "$legado_fixture_app" +xcrun simctl launch --terminate-running-process "$legado_fixture_device" com.epiboly.legado.source-import-fixtures +printf 'JSON file import fixtures are ready in Files > On My iPhone > 书源导入测试.\n'