fix: 修复书源导入、阅读外观和书签状态

- 修复书源文件选择、访问权限与导入预览流程,支持查看分组内的书源
- 修复背景图片、字体选择与主题切换,限制背景图片解码尺寸
- 根据已保存书签恢复当前位置状态,避免重复添加
- 补充模型、阅读器和文件选择回归测试及测试样本
- 更新应用版本为 0.1.10(build 16)

Fixes #1
This commit is contained in:
Aminark
2026-10-20 23:30:42 +08:00
parent 0bf332441d
commit 0915a144a7
17 changed files with 903 additions and 101 deletions
+52 -16
View File
@@ -127,20 +127,37 @@ struct ReadingPhotoPicker<Label: View>: View {
var body: some View {
Button { isPresented = true } label: { label }
.sheet(isPresented: $isPresented) {
ReadingPhotoPickerController { result in
isPresented = false
guard let result else { return }
switch result {
case .success(let data): onData(data)
case .failure(let error): onError(error)
}
}
}
.readingPhotoPicker(isPresented: $isPresented, onData: onData, onError: onError)
}
}
private struct ReadingPhotoPickerController: UIViewControllerRepresentable {
private struct ReadingPhotoPickerModifier: ViewModifier {
@Binding var isPresented: Bool
let onData: (Data) -> Void
let onError: (Error) -> Void
@State private var pendingResult: Result<Data, Error>?
func body(content: Content) -> some View {
content.sheet(isPresented: $isPresented, onDismiss: {
let result = pendingResult
pendingResult = nil
switch result {
case .success(let data): onData(data)
case .failure(let error): onError(error)
case nil: break
}
}) {
ReadingPhotoPickerController { result in
// Apply changes after the picker finishes dismissing, so a
// Form update cannot remove its presenter mid-transition.
pendingResult = result
isPresented = false
}
}
}
}
struct ReadingPhotoPickerController: UIViewControllerRepresentable {
let onResult: @MainActor (Result<Data, Error>?) -> Void
func makeCoordinator() -> Coordinator { Coordinator(onResult: onResult) }
@@ -166,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<Data, Error>
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<Bool>,
onData: @escaping (Data) -> Void,
onError: @escaping (Error) -> Void
) -> some View {
modifier(ReadingPhotoPickerModifier(isPresented: isPresented, onData: onData, onError: onError))
}
@ViewBuilder
func readingNumericTransition() -> some View {
if #available(iOS 16.0, *) { contentTransition(.numericText()) }
+26 -7
View File
@@ -1,4 +1,5 @@
import CoreText
import ImageIO
import SwiftUI
import UIKit
@@ -54,25 +55,43 @@ private extension UIColor {
}
enum ReaderBackgroundStore {
static let maximumPixelSize = 2560
static var url: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Legado/reader-background.jpg")
}
static func load() -> UIImage? {
guard let data = try? Data(contentsOf: url) else { return nil }
return UIImage(data: data)
static func load(from source: URL = url) -> UIImage? {
guard let data = try? Data(contentsOf: source, options: .mappedIfSafe) else { return nil }
return try? downsample(data)
}
static func save(_ data: Data) throws -> UIImage {
guard let image = UIImage(data: data), let encoded = image.jpegData(compressionQuality: 0.9) else {
static func save(_ data: Data, to target: URL = url) throws -> UIImage {
let image = try downsample(data)
guard let encoded = image.jpegData(compressionQuality: 0.9) else {
throw CocoaError(.fileReadCorruptFile)
}
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
try encoded.write(to: url, options: .atomic)
try FileManager.default.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true)
try encoded.write(to: target, options: .atomic)
return image
}
private static func downsample(_ data: Data) throws -> UIImage {
// Decode only the size used by the reader, including backgrounds saved
// by older versions. Full-resolution camera photos can exhaust memory.
guard let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceThumbnailMaxPixelSize: maximumPixelSize
] as CFDictionary) else {
throw CocoaError(.fileReadCorruptFile)
}
return UIImage(cgImage: image)
}
static func remove() { try? FileManager.default.removeItem(at: url) }
}
+90 -41
View File
@@ -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
}
}
+12
View File
@@ -59,6 +59,18 @@ final class ReaderViewModel: ObservableObject {
private var sourceCacheNeedsInvalidation = false
private var pendingSourceContent: ChapterContentReport?
func bookmarks(at position: Int, in bookmarks: [Bookmark], pageRange: NSRange? = nil) -> [Bookmark] {
bookmarks.filter { bookmark in
guard bookmark.bookName == book.name, bookmark.bookAuthor == book.author,
bookmark.resolvedChapterIndex(in: chapters) == currentIndex else { return false }
if bookmark.chapterPosition == max(position, 0) { return true }
if let pageRange, pageRange.length > 0 {
return NSLocationInRange(bookmark.chapterPosition, pageRange)
}
return false
}
}
func prepareContinuousReading(rules: [ReplaceRule]) async {
guard hasLoadedCurrentChapter else { return }
if scrollChapters.isEmpty {
+36 -8
View File
@@ -4,6 +4,36 @@ import LegadoCore
import Foundation
import SwiftUI
final class SourceImportFile: @unchecked Sendable {
let url: URL
private let hasSecurityScope: Bool
init(url: URL) {
self.url = url
hasSecurityScope = url.startAccessingSecurityScopedResource()
}
deinit {
if hasSecurityScope { url.stopAccessingSecurityScopedResource() }
}
func read() throws -> Data {
var coordinationError: NSError?
var result: Result<Data, Error>?
NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &coordinationError) { readableURL in
result = Result {
guard try readableURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory != true else {
throw BookSourceImportError.invalidDocument("请选择书源 JSON 文件,而不是文件夹。")
}
return try Data(contentsOf: readableURL)
}
}
if let coordinationError { throw coordinationError }
guard let result else { throw CocoaError(.fileReadUnknown) }
return try result.get()
}
}
@MainActor
final class SourceLibraryModel: ObservableObject {
@Published private(set) var sources: [BookSource] = []
@@ -168,16 +198,18 @@ final class SourceLibraryModel: ObservableObject {
}
func previewFiles(_ urls: [URL]) async {
await previewPickedFiles(urls.map { SourceImportFile(url: $0) })
}
func previewPickedFiles(_ files: [SourceImportFile]) async {
isWorking = true
defer { isWorking = false }
do {
var incoming: [String: BookSource] = [:]
var invalidItems: [PendingImport.InvalidItem] = []
for url in urls {
let granted = url.startAccessingSecurityScopedResource()
defer { if granted { url.stopAccessingSecurityScopedResource() } }
for file in files {
let data = try await Task.detached(priority: .userInitiated) { [importer] in
try importer.normalizedData(Data(contentsOf: url))
try importer.normalizedData(file.read())
}.value
var budget = 100
let batch = try await resolveImport(String(decoding: data, as: UTF8.self), depth: 0, visited: [], budget: &budget)
@@ -186,10 +218,6 @@ final class SourceLibraryModel: ObservableObject {
incoming[source.id] = source
}
}
// UIDocumentPicker is still dismissing when its completion runs.
// Presenting the review sheet in that same frame is ignored by
// SwiftUI, so stage it after the picker has left the hierarchy.
try? await Task.sleep(nanoseconds: 300_000_000)
stage(Array(incoming.values), invalidItems: invalidItems)
} catch {
notice = .init(kind: .error, title: "书源导入失败", detail: error.localizedDescription)
+120 -23
View File
@@ -5,6 +5,8 @@ import UniformTypeIdentifiers
struct SourceManagementView: View {
@EnvironmentObject private var library: SourceLibraryModel
@State private var showsImporter = false
@State private var pickedImportFiles: [SourceImportFile] = []
@State private var importPickerHasDismissed = true
@State private var showsNewSource = false
@State private var showsQRImporter = false
@State private var showsTextImporter = false
@@ -58,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
+1 -1
View File
@@ -34,7 +34,7 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>0.1.9</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>