mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-23 20:31:22 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3322fd71db | ||
|
|
b28c28ed0e | ||
|
|
45cf5b5482 | ||
|
|
bf72ebc894 | ||
|
|
7daa6b5635 | ||
|
|
0915a144a7 | ||
|
|
0bf332441d |
@@ -4,6 +4,7 @@ DerivedData/
|
|||||||
*.xcuserstate
|
*.xcuserstate
|
||||||
xcuserdata/
|
xcuserdata/
|
||||||
reference/
|
reference/
|
||||||
|
/GITHUB_UPLOAD.local.md
|
||||||
|
|
||||||
artifacts/
|
artifacts/
|
||||||
*.xcresult/
|
*.xcresult/
|
||||||
|
|||||||
+140
-9
@@ -127,20 +127,37 @@ struct ReadingPhotoPicker<Label: View>: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button { isPresented = true } label: { label }
|
Button { isPresented = true } label: { label }
|
||||||
.sheet(isPresented: $isPresented) {
|
.readingPhotoPicker(isPresented: $isPresented, onData: onData, onError: onError)
|
||||||
ReadingPhotoPickerController { result in
|
}
|
||||||
isPresented = false
|
}
|
||||||
guard let result else { return }
|
|
||||||
|
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 {
|
switch result {
|
||||||
case .success(let data): onData(data)
|
case .success(let data): onData(data)
|
||||||
case .failure(let error): onError(error)
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ReadingPhotoPickerController: UIViewControllerRepresentable {
|
struct ReadingPhotoPickerController: UIViewControllerRepresentable {
|
||||||
let onResult: @MainActor (Result<Data, Error>?) -> Void
|
let onResult: @MainActor (Result<Data, Error>?) -> Void
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator { Coordinator(onResult: onResult) }
|
func makeCoordinator() -> Coordinator { Coordinator(onResult: onResult) }
|
||||||
@@ -166,22 +183,120 @@ private struct ReadingPhotoPickerController: UIViewControllerRepresentable {
|
|||||||
|
|
||||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||||
guard let provider = results.first?.itemProvider else { onResult(nil); return }
|
guard let provider = results.first?.itemProvider else { onResult(nil); return }
|
||||||
|
loadImage(from: provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadImage(from provider: NSItemProvider) {
|
||||||
let identifier = provider.registeredTypeIdentifiers.first {
|
let identifier = provider.registeredTypeIdentifiers.first {
|
||||||
UTType($0)?.conforms(to: .image) == true
|
UTType($0)?.conforms(to: .image) == true
|
||||||
} ?? UTType.image.identifier
|
} ?? UTType.image.identifier
|
||||||
provider.loadDataRepresentation(forTypeIdentifier: identifier) { [weak self] data, error in
|
provider.loadDataRepresentation(forTypeIdentifier: identifier, completionHandler: didLoadImageData)
|
||||||
Task { @MainActor in
|
}
|
||||||
if let data { self?.onResult(.success(data)) }
|
|
||||||
else { self?.onResult(.failure(error ?? CocoaError(.fileReadCorruptFile))) }
|
// Photos calls back on NSItemProvider-callback-queue. A method reference
|
||||||
|
// preserves explicit nonisolation in optimized device builds, whereas a
|
||||||
|
// closure here can infer MainActor and assert before reaching the Task.
|
||||||
|
nonisolated private func didLoadImageData(_ data: Data?, _ error: (any Error)?) {
|
||||||
|
let result: Result<Data, Error>
|
||||||
|
if let data { result = .success(data) }
|
||||||
|
else { result = .failure(error ?? CocoaError(.fileReadCorruptFile)) }
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.onResult(result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct ReadingTXTFileImporterModifier: 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()
|
||||||
|
}) {
|
||||||
|
ReadingTXTFilePicker { 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({ $0.url.pathExtension.lowercased() == "txt" && !$0.url.hasDirectoryPath }) else {
|
||||||
|
onError(LocalBookError.unsupportedFormat)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onFiles(files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ReadingTXTFilePicker: UIViewControllerRepresentable {
|
||||||
|
let onPick: @MainActor ([LocalBookImportFile]) -> Void
|
||||||
|
let onCancel: @MainActor () -> Void
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
||||||
|
// Plain text includes UTF-8/UTF-16 TXT, while .text and .data also allow JSON.
|
||||||
|
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.plainText], 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: ReadingTXTFilePicker
|
||||||
|
|
||||||
|
init(parent: ReadingTXTFilePicker) { 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 readingTXTFileImporter(
|
||||||
|
isPresented: Binding<Bool>,
|
||||||
|
onFiles: @escaping ([LocalBookImportFile]) -> Void,
|
||||||
|
onError: @escaping (Error) -> Void
|
||||||
|
) -> some View {
|
||||||
|
modifier(ReadingTXTFileImporterModifier(isPresented: isPresented, onFiles: onFiles, onError: onError))
|
||||||
|
}
|
||||||
|
|
||||||
|
func readingPhotoPicker(
|
||||||
|
isPresented: Binding<Bool>,
|
||||||
|
onData: @escaping (Data) -> Void,
|
||||||
|
onError: @escaping (Error) -> Void
|
||||||
|
) -> some View {
|
||||||
|
modifier(ReadingPhotoPickerModifier(isPresented: isPresented, onData: onData, onError: onError))
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func readingNumericTransition() -> some View {
|
func readingNumericTransition() -> some View {
|
||||||
if #available(iOS 16.0, *) { contentTransition(.numericText()) }
|
if #available(iOS 16.0, *) { contentTransition(.numericText()) }
|
||||||
@@ -225,6 +340,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 {
|
||||||
|
|||||||
+29
-2
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
@@ -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
|
.readingTXTFileImporter(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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -435,7 +435,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 +464,7 @@ 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 {
|
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,9 +480,13 @@ 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)
|
||||||
|
.readingNavigationBarBackground()
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .primaryAction) { Button("保存到照片", systemImage: "square.and.arrow.down") { save() }.disabled(image == nil) }
|
ToolbarItem(placement: .primaryAction) { Button("保存到照片", systemImage: "square.and.arrow.down") { save() }.disabled(image == nil) }
|
||||||
ToolbarItem(placement: .confirmationAction) { Button("完成") { dismiss() } }
|
ToolbarItem(placement: .confirmationAction) { Button("完成") { dismiss() } }
|
||||||
@@ -490,6 +494,7 @@ private struct RSSPhotoView: View {
|
|||||||
.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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import CoreText
|
import CoreText
|
||||||
|
import ImageIO
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@@ -54,25 +55,43 @@ private extension UIColor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum ReaderBackgroundStore {
|
enum ReaderBackgroundStore {
|
||||||
|
static let maximumPixelSize = 2560
|
||||||
|
|
||||||
static var url: URL {
|
static var url: URL {
|
||||||
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
.appendingPathComponent("Legado/reader-background.jpg")
|
.appendingPathComponent("Legado/reader-background.jpg")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func load() -> UIImage? {
|
static func load(from source: URL = url) -> UIImage? {
|
||||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
guard let data = try? Data(contentsOf: source, options: .mappedIfSafe) else { return nil }
|
||||||
return UIImage(data: data)
|
return try? downsample(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func save(_ data: Data) throws -> UIImage {
|
static func save(_ data: Data, to target: URL = url) throws -> UIImage {
|
||||||
guard let image = UIImage(data: data), let encoded = image.jpegData(compressionQuality: 0.9) else {
|
let image = try downsample(data)
|
||||||
|
guard let encoded = image.jpegData(compressionQuality: 0.9) else {
|
||||||
throw CocoaError(.fileReadCorruptFile)
|
throw CocoaError(.fileReadCorruptFile)
|
||||||
}
|
}
|
||||||
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
try encoded.write(to: url, options: .atomic)
|
try encoded.write(to: target, options: .atomic)
|
||||||
return image
|
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) }
|
static func remove() { try? FileManager.default.removeItem(at: url) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ struct ReaderView: View {
|
|||||||
@State private var contentEditError: String?
|
@State private var contentEditError: String?
|
||||||
@State private var backgroundImage: UIImage?
|
@State private var backgroundImage: UIImage?
|
||||||
@State private var backgroundError: String?
|
@State private var backgroundError: String?
|
||||||
|
@State private var showsBackgroundPicker = false
|
||||||
@State private var showsFontImporter = false
|
@State private var showsFontImporter = false
|
||||||
@State private var fontError: String?
|
@State private var fontError: String?
|
||||||
@State private var readerJumpPosition: Int?
|
@State private var readerJumpPosition: Int?
|
||||||
@@ -55,7 +56,7 @@ struct ReaderView: View {
|
|||||||
@State private var progressSaveGeneration = 0
|
@State private var progressSaveGeneration = 0
|
||||||
@State private var speechStartGeneration = 0
|
@State private var speechStartGeneration = 0
|
||||||
@State private var speechTimerRemaining = 0
|
@State private var speechTimerRemaining = 0
|
||||||
@State private var bookmarkSaved = false
|
@State private var isSavingBookmark = false
|
||||||
@ScaledMetric(relativeTo: .body) private var toolbarIconSize = 21
|
@ScaledMetric(relativeTo: .body) private var toolbarIconSize = 21
|
||||||
@AppStorage("textSize") private var fontSize = 20.0
|
@AppStorage("textSize") private var fontSize = 20.0
|
||||||
@AppStorage("letterSpacing") private var letterSpacing = 0.1
|
@AppStorage("letterSpacing") private var letterSpacing = 0.1
|
||||||
@@ -282,19 +283,6 @@ struct ReaderView: View {
|
|||||||
.sheet(isPresented: $showsReaderSettings) { readerSettings }
|
.sheet(isPresented: $showsReaderSettings) { readerSettings }
|
||||||
.sheet(isPresented: $showsClickActionSettings) { clickActionSettings }
|
.sheet(isPresented: $showsClickActionSettings) { clickActionSettings }
|
||||||
.sheet(isPresented: $showsContentEditor) { contentEditor }
|
.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) {
|
.task(id: model.chapterLoadGeneration) {
|
||||||
isWaitingForNextChapter = false
|
isWaitingForNextChapter = false
|
||||||
isWaitingForPreviousChapter = false
|
isWaitingForPreviousChapter = false
|
||||||
@@ -413,7 +401,7 @@ struct ReaderView: View {
|
|||||||
.background {
|
.background {
|
||||||
ZStack {
|
ZStack {
|
||||||
backgroundColor
|
backgroundColor
|
||||||
if themeMode != "3", useBackgroundImage, let backgroundImage {
|
if let backgroundImage = activeBackgroundImage {
|
||||||
Image(uiImage: backgroundImage)
|
Image(uiImage: backgroundImage)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFill()
|
.scaledToFill()
|
||||||
@@ -504,7 +492,7 @@ struct ReaderView: View {
|
|||||||
.accessibilityIdentifier("reader.failureChangeSource")
|
.accessibilityIdentifier("reader.failureChangeSource")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.readingSecondaryButtonStyle()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openSourceSwitch() {
|
private func openSourceSwitch() {
|
||||||
@@ -577,7 +565,7 @@ struct ReaderView: View {
|
|||||||
pageAnimation: pageAnimation,
|
pageAnimation: pageAnimation,
|
||||||
isEInkMode: themeMode == "3",
|
isEInkMode: themeMode == "3",
|
||||||
clickActions: clickActions,
|
clickActions: clickActions,
|
||||||
backgroundColor: themeMode != "3" && useBackgroundImage && backgroundImage != nil ? .clear : resolvedStyle.background,
|
backgroundColor: activeBackgroundImage == nil ? resolvedStyle.background : .clear,
|
||||||
foregroundColor: resolvedStyle.foreground,
|
foregroundColor: resolvedStyle.foreground,
|
||||||
requestedPosition: pageAnimation == 3 ? readerPosition : model.requestedPosition,
|
requestedPosition: pageAnimation == 3 ? readerPosition : model.requestedPosition,
|
||||||
jumpPosition: readerJumpPosition,
|
jumpPosition: readerJumpPosition,
|
||||||
@@ -678,6 +666,10 @@ struct ReaderView: View {
|
|||||||
return min(max(position, 0), max(length - 1, 0))
|
return min(max(position, 0), max(length - 1, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var activeBackgroundImage: UIImage? {
|
||||||
|
themeMode != "3" && useBackgroundImage ? backgroundImage : nil
|
||||||
|
}
|
||||||
|
|
||||||
private var backgroundColor: Color {
|
private var backgroundColor: Color {
|
||||||
Color(uiColor: resolvedStyle.background)
|
Color(uiColor: resolvedStyle.background)
|
||||||
}
|
}
|
||||||
@@ -822,6 +814,7 @@ struct ReaderView: View {
|
|||||||
addBookmark()
|
addBookmark()
|
||||||
}
|
}
|
||||||
.accessibilityValue(bookmarkSaved ? "已添加" : "添加当前位置")
|
.accessibilityValue(bookmarkSaved ? "已添加" : "添加当前位置")
|
||||||
|
.disabled(isSavingBookmark)
|
||||||
readerMoreMenu
|
readerMoreMenu
|
||||||
}
|
}
|
||||||
.foregroundStyle(readerChromeTint)
|
.foregroundStyle(readerChromeTint)
|
||||||
@@ -1125,7 +1118,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("字号")
|
||||||
@@ -1142,7 +1135,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) {
|
||||||
@@ -1160,11 +1153,13 @@ struct ReaderView: View {
|
|||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
ForEach(ReaderStylePreset.legadoDefaults) { preset in
|
ForEach(ReaderStylePreset.legadoDefaults) { preset in
|
||||||
|
let isSelected = resolvedStyle.id == preset.id && activeBackgroundImage == nil
|
||||||
Button {
|
Button {
|
||||||
|
useBackgroundImage = false
|
||||||
theme = preset.id
|
theme = preset.id
|
||||||
} label: {
|
} label: {
|
||||||
VStack(spacing: 6) {
|
VStack(spacing: 6) {
|
||||||
RoundedRectangle(cornerRadius: 7)
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
.fill(Color(uiColor: preset.background))
|
.fill(Color(uiColor: preset.background))
|
||||||
.frame(width: 54, height: 72)
|
.frame(width: 54, height: 72)
|
||||||
.overlay {
|
.overlay {
|
||||||
@@ -1173,31 +1168,46 @@ struct ReaderView: View {
|
|||||||
.foregroundStyle(Color(uiColor: preset.foreground))
|
.foregroundStyle(Color(uiColor: preset.foreground))
|
||||||
}
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: 7)
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
.stroke(resolvedStyle.id == preset.id ? ReadingTheme.accent : Color.secondary, lineWidth: resolvedStyle.id == preset.id ? 3 : 1)
|
.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)
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(preset.name)
|
||||||
|
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ReadingPhotoPicker { data in
|
Button("选择背景图片", systemImage: "photo") {
|
||||||
backgroundError = nil
|
backgroundError = nil
|
||||||
do {
|
showsBackgroundPicker = true
|
||||||
backgroundImage = try ReaderBackgroundStore.save(data)
|
|
||||||
useBackgroundImage = true
|
|
||||||
} catch {
|
|
||||||
backgroundError = error.localizedDescription
|
|
||||||
}
|
|
||||||
} onError: { error in
|
|
||||||
backgroundError = error.localizedDescription
|
|
||||||
} label: {
|
|
||||||
Label("选择背景图片", systemImage: "photo")
|
|
||||||
}
|
}
|
||||||
if backgroundImage != nil {
|
if backgroundImage != nil {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Text("使用背景图片")
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { useBackgroundImage.toggle() }
|
||||||
|
.accessibilityHidden(true)
|
||||||
Toggle("使用背景图片", isOn: $useBackgroundImage)
|
Toggle("使用背景图片", isOn: $useBackgroundImage)
|
||||||
|
.labelsHidden()
|
||||||
|
.fixedSize()
|
||||||
|
}
|
||||||
Button("删除背景图片", systemImage: "trash", role: .destructive) {
|
Button("删除背景图片", systemImage: "trash", role: .destructive) {
|
||||||
ReaderBackgroundStore.remove()
|
ReaderBackgroundStore.remove()
|
||||||
backgroundImage = nil
|
backgroundImage = nil
|
||||||
@@ -1219,8 +1229,15 @@ struct ReaderView: View {
|
|||||||
Text("粗体").tag(1)
|
Text("粗体").tag(1)
|
||||||
Text("细体").tag(2)
|
Text("细体").tag(2)
|
||||||
}
|
}
|
||||||
TextField("段落缩进字符", text: $paragraphIndent)
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("段落缩进")
|
||||||
|
TextField("请输入缩进字符", text: $paragraphIndent)
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
|
.accessibilityLabel("段落缩进字符")
|
||||||
|
Text("控制每段开头的缩进,默认两个全角空格;清空后不缩进。")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
Button(fontPath.isEmpty ? "选择字体文件" : "更换字体文件", systemImage: "textformat") {
|
Button(fontPath.isEmpty ? "选择字体文件" : "更换字体文件", systemImage: "textformat") {
|
||||||
showsFontImporter = true
|
showsFontImporter = true
|
||||||
}
|
}
|
||||||
@@ -1268,14 +1285,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 : [])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1335,6 +1357,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])
|
.readingPresentationDetents([.medium])
|
||||||
.readingPresentationDragIndicator()
|
.readingPresentationDragIndicator()
|
||||||
}
|
}
|
||||||
@@ -1351,12 +1397,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(
|
||||||
@@ -1866,18 +1916,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() {
|
private func addBookmark() {
|
||||||
guard let chapter = model.currentChapter else { return }
|
guard !isSavingBookmark, !bookmarkSaved, let chapter = model.currentChapter else { return }
|
||||||
bookmarkSaved = true
|
let currentBook = book
|
||||||
|
let position = readerPosition
|
||||||
|
let content = model.content
|
||||||
|
isSavingBookmark = true
|
||||||
Task {
|
Task {
|
||||||
|
defer { isSavingBookmark = false }
|
||||||
await activity.addBookmark(
|
await activity.addBookmark(
|
||||||
book: book,
|
book: currentBook,
|
||||||
chapter: chapter,
|
chapter: chapter,
|
||||||
position: readerPosition,
|
position: position,
|
||||||
content: model.content
|
content: content
|
||||||
)
|
)
|
||||||
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
|
||||||
bookmarkSaved = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,18 @@ final class ReaderViewModel: ObservableObject {
|
|||||||
private var sourceCacheNeedsInvalidation = false
|
private var sourceCacheNeedsInvalidation = false
|
||||||
private var pendingSourceContent: ChapterContentReport?
|
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 {
|
func prepareContinuousReading(rules: [ReplaceRule]) async {
|
||||||
guard hasLoadedCurrentChapter else { return }
|
guard hasLoadedCurrentChapter else { return }
|
||||||
if scrollChapters.isEmpty {
|
if scrollChapters.isEmpty {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -126,12 +148,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 +165,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 +173,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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -4,6 +4,36 @@ import LegadoCore
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
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
|
@MainActor
|
||||||
final class SourceLibraryModel: ObservableObject {
|
final class SourceLibraryModel: ObservableObject {
|
||||||
@Published private(set) var sources: [BookSource] = []
|
@Published private(set) var sources: [BookSource] = []
|
||||||
@@ -168,16 +198,18 @@ final class SourceLibraryModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func previewFiles(_ urls: [URL]) async {
|
func previewFiles(_ urls: [URL]) async {
|
||||||
|
await previewPickedFiles(urls.map { SourceImportFile(url: $0) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func previewPickedFiles(_ files: [SourceImportFile]) async {
|
||||||
isWorking = true
|
isWorking = true
|
||||||
defer { isWorking = false }
|
defer { isWorking = false }
|
||||||
do {
|
do {
|
||||||
var incoming: [String: BookSource] = [:]
|
var incoming: [String: BookSource] = [:]
|
||||||
var invalidItems: [PendingImport.InvalidItem] = []
|
var invalidItems: [PendingImport.InvalidItem] = []
|
||||||
for url in urls {
|
for file in files {
|
||||||
let granted = url.startAccessingSecurityScopedResource()
|
|
||||||
defer { if granted { url.stopAccessingSecurityScopedResource() } }
|
|
||||||
let data = try await Task.detached(priority: .userInitiated) { [importer] in
|
let data = try await Task.detached(priority: .userInitiated) { [importer] in
|
||||||
try importer.normalizedData(Data(contentsOf: url))
|
try importer.normalizedData(file.read())
|
||||||
}.value
|
}.value
|
||||||
var budget = 100
|
var budget = 100
|
||||||
let batch = try await resolveImport(String(decoding: data, as: UTF8.self), depth: 0, visited: [], budget: &budget)
|
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
|
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)
|
stage(Array(incoming.values), invalidItems: invalidItems)
|
||||||
} catch {
|
} catch {
|
||||||
notice = .init(kind: .error, title: "书源导入失败", detail: error.localizedDescription)
|
notice = .init(kind: .error, title: "书源导入失败", detail: error.localizedDescription)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import UniformTypeIdentifiers
|
|||||||
struct SourceManagementView: View {
|
struct SourceManagementView: View {
|
||||||
@EnvironmentObject private var library: SourceLibraryModel
|
@EnvironmentObject private var library: SourceLibraryModel
|
||||||
@State private var showsImporter = false
|
@State private var showsImporter = false
|
||||||
|
@State private var pickedImportFiles: [SourceImportFile] = []
|
||||||
|
@State private var importPickerHasDismissed = true
|
||||||
@State private var showsNewSource = false
|
@State private var showsNewSource = false
|
||||||
@State private var showsQRImporter = false
|
@State private var showsQRImporter = false
|
||||||
@State private var showsTextImporter = false
|
@State private var showsTextImporter = false
|
||||||
@@ -58,8 +60,8 @@ struct SourceManagementView: View {
|
|||||||
} description: {
|
} description: {
|
||||||
Text("新建书源,或从文件、链接、JSON、二维码导入。")
|
Text("新建书源,或从文件、链接、JSON、二维码导入。")
|
||||||
} actions: {
|
} actions: {
|
||||||
Button("导入书源") { showsImporter = true }
|
Button("导入书源", action: presentFileImporter)
|
||||||
.buttonStyle(.borderedProminent)
|
.readingProminentButtonStyle()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
List {
|
List {
|
||||||
@@ -142,7 +144,7 @@ struct SourceManagementView: View {
|
|||||||
Menu("更多", systemImage: "ellipsis.circle") {
|
Menu("更多", systemImage: "ellipsis.circle") {
|
||||||
Button("新建书源", systemImage: "plus") { showsNewSource = true }
|
Button("新建书源", systemImage: "plus") { showsNewSource = true }
|
||||||
Button("二维码导入", systemImage: "qrcode.viewfinder") { showsQRImporter = 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 }
|
Button("URL 或粘贴 JSON", systemImage: "link.badge.plus") { showsTextImporter = true }
|
||||||
Divider()
|
Divider()
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
@@ -206,23 +208,70 @@ struct SourceManagementView: View {
|
|||||||
.sheet(isPresented: $showsBatchGroups) {
|
.sheet(isPresented: $showsBatchGroups) {
|
||||||
SourceBatchGroupView(sourceIDs: selectedSourceIDs).environmentObject(library)
|
SourceBatchGroupView(sourceIDs: selectedSourceIDs).environmentObject(library)
|
||||||
}
|
}
|
||||||
.fileImporter(
|
.sheet(isPresented: $showsImporter, onDismiss: {
|
||||||
isPresented: $showsImporter,
|
importPickerHasDismissed = true
|
||||||
allowedContentTypes: [.item],
|
previewPickedFilesWhenReady()
|
||||||
allowsMultipleSelection: false
|
}) {
|
||||||
) { result in
|
SourceFileImportPicker { files in
|
||||||
switch result {
|
pickedImportFiles = files
|
||||||
case .success(let urls):
|
showsImporter = false
|
||||||
let files = urls.filter { !$0.hasDirectoryPath }
|
previewPickedFilesWhenReady()
|
||||||
if files.isEmpty {
|
} onCancel: {
|
||||||
library.notice = .init(kind: .error, title: "无法选择文件", detail: "请选择书源 JSON 文件,而不是文件夹。")
|
showsImporter = false
|
||||||
} else {
|
|
||||||
Task { await library.previewFiles(files) }
|
|
||||||
}
|
}
|
||||||
case .failure(let error):
|
|
||||||
library.notice = .init(kind: .error, title: "无法选择文件", detail: error.localizedDescription)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,16 +386,22 @@ private struct SourceGroupManagerView: View {
|
|||||||
}
|
}
|
||||||
.disabled(BookSource.parseGroups(newGroup).isEmpty)
|
.disabled(BookSource.parseGroups(newGroup).isEmpty)
|
||||||
}
|
}
|
||||||
Text("新分组会分配给当前未分组书源,与 Legado 的分组管理行为一致。")
|
Text("添加后,所有未分组的书源都会加入该分组。")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Section("已有分组") {
|
Section {
|
||||||
ForEach(library.groups, id: \.self) { group in
|
ForEach(library.groups, id: \.self) { group in
|
||||||
|
HStack {
|
||||||
|
NavigationLink {
|
||||||
|
SourceGroupContentsView(group: group)
|
||||||
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Label(group, systemImage: "folder.fill")
|
Label(group, systemImage: "folder.fill")
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("\(library.sources.filter { $0.hasGroup(group) }.count)")
|
Text("\(library.sources.filter { $0.hasGroup(group) }.count) 个书源")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
Menu {
|
Menu {
|
||||||
Button("重命名", systemImage: "pencil") {
|
Button("重命名", systemImage: "pencil") {
|
||||||
renaming = group
|
renaming = group
|
||||||
@@ -356,8 +411,14 @@ private struct SourceGroupManagerView: View {
|
|||||||
Task { await library.deleteGroup(group) }
|
Task { await library.deleteGroup(group) }
|
||||||
}
|
}
|
||||||
} label: { Image(systemName: "ellipsis.circle") }
|
} label: { Image(systemName: "ellipsis.circle") }
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.accessibilityLabel("\(group)分组操作")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} header: {
|
||||||
|
Text("已有分组")
|
||||||
|
} footer: {
|
||||||
|
Text("点击分组可查看其中的书源。")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("书源分组")
|
.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 {
|
private struct SourceBatchGroupView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@EnvironmentObject private var library: SourceLibraryModel
|
@EnvironmentObject private var library: SourceLibraryModel
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>0.1.9</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
|
|||||||
+31
-33
@@ -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(
|
.readingTXTFileImporter(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)
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
@@ -993,7 +991,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 +1042,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 +1056,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 +1286,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) {
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
# 更新记录
|
# 更新记录
|
||||||
|
|
||||||
|
## 0.1.11(2026-10-22)
|
||||||
|
|
||||||
|
- 修复书架和文件管理中的本地 TXT 选择、导入确认与文件访问权限,支持大小写扩展名和多选,并排除 JSON 书源。
|
||||||
|
- 完善深色模式下的按钮、阅读菜单、统计卡片和编辑器语法高亮,改善文字与选中态对比度。
|
||||||
|
- 适配 RSS 正文、登录网页和图片查看页外观,保留书源自定义样式。
|
||||||
|
- 应用版本为 `0.1.11`,构建号为 `17`。
|
||||||
|
|
||||||
|
## 0.1.10(2026-10-20)
|
||||||
|
|
||||||
|
- 修复书源文件选择及导入流程,支持 JSON 多选,调整文件访问权限和导入预览展示时机。
|
||||||
|
- 支持进入书源分组查看书源详情及切换启用状态。
|
||||||
|
- 修复阅读背景图片与字体选择,限制背景图片解码尺寸,完善图片与预设主题切换。
|
||||||
|
- 修复重新进入阅读器后的书签状态,避免重复添加。
|
||||||
|
- 应用版本为 `0.1.10`,构建号为 `16`。
|
||||||
|
|
||||||
## 0.1.9(2026-10-14)
|
## 0.1.9(2026-10-14)
|
||||||
|
|
||||||
- 个人中心采用阅读静物头图、暖白圆角卡片和黑金双色图标,适配深色模式。
|
- 个人中心采用阅读静物头图、暖白圆角卡片和黑金双色图标,适配深色模式。
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
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; };
|
||||||
@@ -124,6 +126,8 @@
|
|||||||
/* 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; };
|
||||||
@@ -272,6 +276,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 */,
|
||||||
@@ -678,8 +684,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;
|
||||||
@@ -858,12 +864,12 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 13;
|
CURRENT_PROJECT_VERSION = 17;
|
||||||
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.9;
|
MARKETING_VERSION = 0.1.11;
|
||||||
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 +885,11 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 13;
|
CURRENT_PROJECT_VERSION = 17;
|
||||||
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.9;
|
MARKETING_VERSION = 0.1.11;
|
||||||
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)";
|
||||||
|
|||||||
@@ -119,3 +119,7 @@
|
|||||||
第三方材料的版权与许可见 [第三方声明](THIRD_PARTY_NOTICES.md)。
|
第三方材料的版权与许可见 [第三方声明](THIRD_PARTY_NOTICES.md)。
|
||||||
|
|
||||||
请遵守书源网站的使用规则和内容授权,支持正版阅读。
|
请遵守书源网站的使用规则和内容授权,支持正版阅读。
|
||||||
|
|
||||||
|
## 🔗 友情链接
|
||||||
|
|
||||||
|
[LINUX DO](https://linux.do/) —— 真诚分享、友好讨论的技术社区,本项目的交流与反馈也发布于此
|
||||||
|
|||||||
@@ -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...])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -207,6 +207,30 @@ struct ReaderRegressionTests {
|
|||||||
#expect(bookmark.resolvedChapterIndex(in: chapters) == nil)
|
#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 {
|
@Test func continuousReadingExtendsPastThreeChaptersWithoutNavigating() async {
|
||||||
let book = Book(bookUrl: "demo://continuous", name: "书")
|
let book = Book(bookUrl: "demo://continuous", name: "书")
|
||||||
let chapters = (0..<20).map { BookChapter(url: "demo://\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) }
|
let chapters = (0..<20).map { BookChapter(url: "demo://\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) }
|
||||||
|
|||||||
@@ -25,6 +25,51 @@ private actor ImportFixtureTransport: HTTPTransport {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct RuleLibraryRegressionTests {
|
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 {
|
@Test func largeImportKeepsTheMainActorResponsive() async throws {
|
||||||
let comment = String(repeating: "规则说明", count: 1_024)
|
let comment = String(repeating: "规则说明", count: 1_024)
|
||||||
let entries = (0..<1_000).map { index in
|
let entries = (0..<1_000).map { index in
|
||||||
|
|||||||
@@ -440,7 +440,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,86 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
import WebKit
|
||||||
|
import XCTest
|
||||||
|
@testable import Legado
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AppearanceContrastTests: XCTestCase {
|
||||||
|
func testLoginWebViewUsesAdaptiveBackgroundBeforeContentArrives() async throws {
|
||||||
|
var source = BookSource(bookSourceUrl: "https://appearance.invalid", bookSourceName: "外观测试")
|
||||||
|
source.loginUrl = "about:blank"
|
||||||
|
let controller = UIHostingController(rootView: SourceLoginView(source: source))
|
||||||
|
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
|
||||||
|
window.overrideUserInterfaceStyle = .dark
|
||||||
|
window.rootViewController = controller
|
||||||
|
window.makeKeyAndVisible()
|
||||||
|
defer { window.isHidden = true }
|
||||||
|
func webView(in view: UIView) -> WKWebView? {
|
||||||
|
if let view = view as? WKWebView { return view }
|
||||||
|
return view.subviews.lazy.compactMap { webView(in: $0) }.first
|
||||||
|
}
|
||||||
|
window.layoutIfNeeded()
|
||||||
|
for _ in 0..<20 {
|
||||||
|
if webView(in: controller.view) != nil { break }
|
||||||
|
try await Task.sleep(nanoseconds: 50_000_000)
|
||||||
|
}
|
||||||
|
let web = try XCTUnwrap(webView(in: controller.view))
|
||||||
|
XCTAssertFalse(web.isOpaque)
|
||||||
|
for style in [UIUserInterfaceStyle.dark, .light] {
|
||||||
|
window.overrideUserInterfaceStyle = style
|
||||||
|
window.layoutIfNeeded()
|
||||||
|
let traits = UITraitCollection(userInterfaceStyle: style)
|
||||||
|
let background = try XCTUnwrap(web.backgroundColor).resolvedColor(with: traits)
|
||||||
|
let scroll = try XCTUnwrap(web.scrollView.backgroundColor).resolvedColor(with: traits)
|
||||||
|
if style == .dark {
|
||||||
|
XCTAssertLessThan(luminance(background), 0.15)
|
||||||
|
XCTAssertLessThan(luminance(scroll), 0.15)
|
||||||
|
} else {
|
||||||
|
XCTAssertGreaterThan(luminance(background), 0.6)
|
||||||
|
XCTAssertGreaterThan(luminance(scroll), 0.6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSyntaxTokensHaveReadableContrastInBaseAndElevatedAppearances() {
|
||||||
|
let samples: [(SyntaxCodeEditor.Language, String)] = [
|
||||||
|
(.json, #"{"title":"正文","count":12,"enabled":true,"empty":null}"#),
|
||||||
|
(.rule, "@css:div && $.title {{key}} ##"),
|
||||||
|
(.regex, #"\d+.*(word){2,4}"#),
|
||||||
|
(.javascript, "// comment\nconst value = 'text'; return 123; /* comment */")
|
||||||
|
]
|
||||||
|
for style in [UIUserInterfaceStyle.light, .dark] {
|
||||||
|
for level in [UIUserInterfaceLevel.base, .elevated] {
|
||||||
|
let traits = UITraitCollection(traitsFrom: [
|
||||||
|
UITraitCollection(userInterfaceStyle: style),
|
||||||
|
UITraitCollection(userInterfaceLevel: level)
|
||||||
|
])
|
||||||
|
for (language, text) in samples {
|
||||||
|
let editor = SyntaxCodeEditor(text: .constant(text), language: language)
|
||||||
|
let view = UITextView()
|
||||||
|
view.overrideUserInterfaceStyle = style
|
||||||
|
view.text = text
|
||||||
|
editor.makeCoordinator().highlight(view)
|
||||||
|
let background = UIColor.secondarySystemGroupedBackground.resolvedColor(with: traits)
|
||||||
|
view.textStorage.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: view.textStorage.length)) { color, _, _ in
|
||||||
|
guard let color = color as? UIColor else { return XCTFail("Missing syntax color") }
|
||||||
|
let foreground = color.resolvedColor(with: traits)
|
||||||
|
let values = [luminance(foreground), luminance(background)].sorted()
|
||||||
|
XCTAssertGreaterThanOrEqual((values[1] + 0.05) / (values[0] + 0.05), 4.5,
|
||||||
|
"Low contrast for \(language), \(style), \(level)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func luminance(_ color: UIColor) -> Double {
|
||||||
|
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||||
|
XCTAssertTrue(color.getRed(&r, green: &g, blue: &b, alpha: &a))
|
||||||
|
func linear(_ value: CGFloat) -> Double {
|
||||||
|
let value = Double(value)
|
||||||
|
return value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4)
|
||||||
|
}
|
||||||
|
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,161 @@
|
|||||||
|
import ImageIO
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
import XCTest
|
import XCTest
|
||||||
@testable import Legado
|
@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
|
@MainActor
|
||||||
final class ReaderScrollGeometryTests: XCTestCase {
|
final class ReaderScrollGeometryTests: XCTestCase {
|
||||||
func testPrependingAndRecyclingStayWithinOnePhysicalPixel() async throws {
|
func testPrependingAndRecyclingStayWithinOnePhysicalPixel() async throws {
|
||||||
@@ -116,7 +269,7 @@ final class ReaderScrollGeometryTests: XCTestCase {
|
|||||||
.init(configuration: configuration(fontSize: fontSize), chapters: chapters,
|
.init(configuration: configuration(fontSize: fontSize), chapters: chapters,
|
||||||
resetID: 0, activeIndex: active, loadingAhead: false, loadingBehind: false,
|
resetID: 0, activeIndex: active, loadingAhead: false, loadingBehind: false,
|
||||||
onNeedChapters: { _ in }, onPositionChanged: { _, _ in },
|
onNeedChapters: { _ in }, onPositionChanged: { _, _ in },
|
||||||
onRetainChapters: { _ in }, onRetry: { _ in })
|
onRetainChapters: { _ in })
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configuration(fontSize: Double = 20) -> ReaderTextView {
|
private func configuration(fontSize: Double = 20) -> ReaderTextView {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
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["完成"]
|
||||||
|
let accessoryFrame = accessory.frame.intersection(app.frame)
|
||||||
|
if accessory.isHittable && !accessoryFrame.isNull && accessoryFrame.width > 20 && accessoryFrame.height > 20 {
|
||||||
|
try assertReadableDarkFill(accessory, in: app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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["搜索选项"].waitForExistence(timeout: 10))
|
||||||
|
app.buttons["搜索选项"].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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,185 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
import UIKit
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class ReaderScrollGestureTests: XCTestCase {
|
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 {
|
func testFailedChaptersAllowRetryMenuAndSourceSwitchInBothReadingModes() throws {
|
||||||
continueAfterFailure = false
|
continueAfterFailure = false
|
||||||
for mode in ["3", "0"] {
|
for mode in ["3", "0"] {
|
||||||
|
|||||||
@@ -2,6 +2,154 @@ import XCTest
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class RuleEditorUITests: XCTestCase {
|
final class RuleEditorUITests: XCTestCase {
|
||||||
|
func testBookshelfTXTFilePickerImportsTXTAndDisablesJSON() throws {
|
||||||
|
try exerciseLocalTXTImport(fromFileManagement: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFileManagementTXTFilePickerImportsTXTAndDisablesJSON() throws {
|
||||||
|
try exerciseLocalTXTImport(fromFileManagement: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exerciseLocalTXTImport(fromFileManagement: Bool) throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launchArguments = ["-uiDemo", "-themeMode", "1", "-bookshelfLayout", "grid3"]
|
||||||
|
if fromFileManagement { app.launchArguments += ["-uiDemoTab", "settings", "-uiDemoFiles"] }
|
||||||
|
app.launch()
|
||||||
|
let importTitle = fromFileManagement ? "导入 TXT 文件" : "本地书"
|
||||||
|
let more = fromFileManagement
|
||||||
|
? app.navigationBars["文件管理"].buttons["更多"].firstMatch
|
||||||
|
: app.buttons["更多"].firstMatch
|
||||||
|
XCTAssertTrue(more.waitForExistence(timeout: 20))
|
||||||
|
more.tap()
|
||||||
|
app.buttons[importTitle].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].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()
|
||||||
|
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"].exists)
|
||||||
|
XCTAssertTrue(app.staticTexts["TXT-Uppercase.TXT"].exists)
|
||||||
|
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))
|
||||||
|
attach(app, name: "Local TXT books imported onto shelf")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testJSONFilePickerOffersOpenAndImportsSelectedFiles() throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launchArguments = ["-uiDemo", "-uiDemoTab", "sources"]
|
||||||
|
app.launch()
|
||||||
|
XCTAssertTrue(app.navigationBars["书源"].waitForExistence(timeout: 20))
|
||||||
|
app.buttons["更多"].firstMatch.tap()
|
||||||
|
app.buttons["从文件导入"].tap()
|
||||||
|
let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch
|
||||||
|
XCTAssertTrue(cancel.waitForExistence(timeout: 10))
|
||||||
|
cancel.tap()
|
||||||
|
let pickerClosed = expectation(for: NSPredicate { _, _ in
|
||||||
|
!cancel.exists && app.navigationBars["书源"].buttons["更多"].isHittable
|
||||||
|
}, evaluatedWith: nil)
|
||||||
|
wait(for: [pickerClosed], timeout: 10)
|
||||||
|
app.buttons["更多"].firstMatch.tap()
|
||||||
|
app.buttons["从文件导入"].tap()
|
||||||
|
XCTAssertTrue(cancel.waitForExistence(timeout: 10))
|
||||||
|
|
||||||
|
let json = app.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", "JSON-Source")).firstMatch
|
||||||
|
let fixtures = app.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", "书源导入测试")).firstMatch
|
||||||
|
if !json.waitForExistence(timeout: 3) {
|
||||||
|
if !fixtures.exists {
|
||||||
|
let browse = app.buttons.matching(NSPredicate(format: "label IN %@", ["Browse", "浏览", "Locations", "位置"])).firstMatch
|
||||||
|
if browse.exists { browse.tap() }
|
||||||
|
if !fixtures.waitForExistence(timeout: 2) {
|
||||||
|
let local = app.descendants(matching: .any).matching(NSPredicate(
|
||||||
|
format: "label CONTAINS %@ OR label CONTAINS %@ OR label == %@", "我的 iPhone", "我的iPhone", "On My iPhone"
|
||||||
|
)).firstMatch
|
||||||
|
if !local.waitForExistence(timeout: 5) { print("[SourceFilePicker] \(app.debugDescription)") }
|
||||||
|
XCTAssertTrue(local.exists)
|
||||||
|
local.tap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XCTAssertTrue(fixtures.waitForExistence(timeout: 10), "Run sh scripts/prepare-source-import-fixture.sh <simulator-UUID> before this test")
|
||||||
|
fixtures.tap()
|
||||||
|
}
|
||||||
|
XCTAssertTrue(json.waitForExistence(timeout: 10))
|
||||||
|
json.tap()
|
||||||
|
let uppercase = app.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", "JSON-Uppercase")).firstMatch
|
||||||
|
XCTAssertTrue(uppercase.waitForExistence(timeout: 5))
|
||||||
|
uppercase.tap()
|
||||||
|
let open = app.buttons.matching(NSPredicate(format: "label IN %@", ["Open", "打开", "Import", "导入"])).firstMatch
|
||||||
|
XCTAssertTrue(open.waitForExistence(timeout: 5))
|
||||||
|
XCTAssertTrue(open.isEnabled)
|
||||||
|
attach(app, name: "JSON files selected with Open available")
|
||||||
|
open.tap()
|
||||||
|
|
||||||
|
let reviewAppeared = app.navigationBars["书源导入预检"].waitForExistence(timeout: 15)
|
||||||
|
if !reviewAppeared {
|
||||||
|
print("[SourceFilePickerAfterOpen] \(app.debugDescription)")
|
||||||
|
attach(app, name: "File picker after Open")
|
||||||
|
}
|
||||||
|
XCTAssertTrue(reviewAppeared)
|
||||||
|
XCTAssertTrue(app.buttons["导入"].isEnabled)
|
||||||
|
attach(app, name: "JSON import review after file picker dismissal")
|
||||||
|
app.buttons["导入"].tap()
|
||||||
|
if app.alerts.firstMatch.waitForExistence(timeout: 5) { app.alerts.buttons["好"].tap() }
|
||||||
|
XCTAssertTrue(app.staticTexts["文件导入 JSON 测试"].waitForExistence(timeout: 10))
|
||||||
|
XCTAssertTrue(app.staticTexts["文件导入大写扩展名测试"].exists)
|
||||||
|
}
|
||||||
|
|
||||||
func testSourceFormJSONSwitchKeepsHighlightedTextAndSaves() throws {
|
func testSourceFormJSONSwitchKeepsHighlightedTextAndSaves() throws {
|
||||||
try exerciseSourceEditor(usesLegacyUI: false)
|
try exerciseSourceEditor(usesLegacyUI: false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,21 @@ sh scripts/self-check.sh
|
|||||||
|
|
||||||
阅读器测试在 Xcode 选择 `LegadoReaderTests` / `LegadoReaderUITests` Scheme 和具体模拟器运行。联网书源测试需要显式配置对应环境变量,默认核心回归主要采用自造响应,不能将其通过解读为所有线上书源均可用。
|
阅读器测试在 Xcode 选择 `LegadoReaderTests` / `LegadoReaderUITests` Scheme 和具体模拟器运行。联网书源测试需要显式配置对应环境变量,默认核心回归主要采用自造响应,不能将其通过解读为所有线上书源均可用。
|
||||||
|
|
||||||
|
书源和本地 TXT 文件选择回归需要先在已启动的测试模拟器中准备文件共享应用(将下方 UUID 换成实际值):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
LEGADO_TEST_DEVICE="模拟器 UUID"
|
||||||
|
sh scripts/prepare-source-import-fixture.sh "$LEGADO_TEST_DEVICE"
|
||||||
|
xcodebuild -project Legado.xcodeproj -scheme LegadoReaderUITests \
|
||||||
|
-destination "platform=iOS Simulator,id=$LEGADO_TEST_DEVICE" \
|
||||||
|
-only-testing:LegadoReaderUITests/RuleEditorUITests/testJSONFilePickerOffersOpenAndImportsSelectedFiles \
|
||||||
|
-only-testing:LegadoReaderUITests/RuleEditorUITests/testBookshelfTXTFilePickerImportsTXTAndDisablesJSON \
|
||||||
|
-only-testing:LegadoReaderUITests/RuleEditorUITests/testFileManagementTXTFilePickerImportsTXTAndDisablesJSON \
|
||||||
|
-collect-test-diagnostics never CODE_SIGNING_ALLOWED=NO test
|
||||||
|
```
|
||||||
|
|
||||||
|
独立测试应用提供 JSON 书源和 `.txt`、`.TXT` 小说样本,不会被打包进正式 App。此回归检查取消后重新打开、书源 JSON 多选、本地 TXT 可选且 JSON 不可选、导入确认及最终保存。
|
||||||
|
|
||||||
## 兼容性验证
|
## 兼容性验证
|
||||||
|
|
||||||
最低部署版本为 iOS 15.0。`App/Compatibility.swift` 为导航、分享、照片选择和弹窗提供兼容实现;新增系统 API 时需保留最低版本路径。调试构建可用 `-uiLegacyCompatibility` 启动参数在新系统上执行旧版界面分支,对应回归位于 `Tests/LegadoReaderUITests/`。这些测试不能替代 iOS 15 真机或模拟器实测。
|
最低部署版本为 iOS 15.0。`App/Compatibility.swift` 为导航、分享、照片选择和弹窗提供兼容实现;新增系统 API 时需保留最低版本路径。调试构建可用 `-uiLegacyCompatibility` 启动参数在新系统上执行旧版界面分支,对应回归位于 `Tests/LegadoReaderUITests/`。这些测试不能替代 iOS 15 真机或模拟器实测。
|
||||||
@@ -62,3 +77,37 @@ 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.11-build17-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)。
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"name": "小浣熊阅读",
|
||||||
|
"identifier": "com.epiboly.legado.source",
|
||||||
|
"subtitle": "小浣熊阅读官方应用源",
|
||||||
|
"description": "小浣熊阅读的最新正式版,供 AltStore Classic、SideStore 和 LiveContainer 添加。",
|
||||||
|
"sourceURL": "https://raw.githubusercontent.com/Aminark/legado-ios/main/docs/altstore.json",
|
||||||
|
"website": "https://github.com/Aminark/legado-ios",
|
||||||
|
"iconURL": "https://raw.githubusercontent.com/Aminark/legado-ios/main/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png",
|
||||||
|
"tintColor": "#315C50",
|
||||||
|
"apps": [
|
||||||
|
{
|
||||||
|
"name": "小浣熊阅读",
|
||||||
|
"bundleIdentifier": "com.epiboly.legado",
|
||||||
|
"developerName": "Aminark",
|
||||||
|
"subtitle": "搜书快一点,读书舒服一点。",
|
||||||
|
"localizedDescription": "一款优先兼容阅读书源的开源 iOS 阅读器,支持书架、搜索、阅读排版、本地 TXT、语音朗读和 RSS。应用不提供书源或在线书库,书源需自行导入。",
|
||||||
|
"iconURL": "https://raw.githubusercontent.com/Aminark/legado-ios/main/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png",
|
||||||
|
"tintColor": "#315C50",
|
||||||
|
"category": "utilities",
|
||||||
|
"screenshots": [
|
||||||
|
{
|
||||||
|
"imageURL": "https://raw.githubusercontent.com/Aminark/legado-ios/main/docs/images/bookshelf.png",
|
||||||
|
"width": 1206,
|
||||||
|
"height": 2622
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"imageURL": "https://raw.githubusercontent.com/Aminark/legado-ios/main/docs/images/search.png",
|
||||||
|
"width": 1206,
|
||||||
|
"height": 2622
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"imageURL": "https://raw.githubusercontent.com/Aminark/legado-ios/main/docs/images/reader.png",
|
||||||
|
"width": 1206,
|
||||||
|
"height": 2622
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "0.1.11",
|
||||||
|
"buildVersion": "17",
|
||||||
|
"date": "2026-10-22T03:06:37Z",
|
||||||
|
"localizedDescription": "小浣熊阅读 **0.1.11**,构建号 **17**,支持 **iOS / iPadOS 15.0 及以上**。\n\n## 更新内容\n\n- 修复书架和文件管理中的本地 TXT 文件选择及导入流程,支持 `.txt`、`.TXT` 多选,排除 JSON 书源文件。\n- 保留所选文件访问权限至导入完成,调整取消后重新选择和导入确认的展示流程。\n- 完善深色模式下的主按钮、选中态、阅读菜单、统计卡片和管理页面,改善文字对比度。\n- 调整规则编辑器的语法高亮、光标和工具栏外观。\n- 适配 RSS 正文、登录网页和图片查看页的背景与导航操作,保留书源自定义样式和页面状态。\n\n## 下载安装\n\n下载附件 **`legado-ios-v0.1.11-build17-unsigned.ipa`**,使用自己的签名工具安装。支持 TrollStore 的设备可自行尝试安装;本项目暂不提供 App Store 或 TestFlight 分发。\n\n`Source code (zip / tar.gz)` 是源码压缩包,不能直接安装。`SHA256SUMS` 可用于核对 IPA 完整性。\n\n## 验证与兼容\n\n本次功能代码已通过 300 项核心与页面模型测试、15 项 iOS 测试及 11 个相关 UI 回归用例。IPA 从对应源码重新进行 iPhoneOS Release 构建。\n\nUI 回归在 iOS 18.3 模拟器完成;iOS 15 真机仍未覆盖。如有兼容问题,请在 [Issues](https://github.com/Aminark/legado-ios/issues) 反馈版本、设备、系统及复现步骤。\n\n**小浣熊阅读不提供、内置或分发网络书源,也不提供在线书库;书源需自行导入。**\n",
|
||||||
|
"downloadURL": "https://github.com/Aminark/legado-ios/releases/download/v0.1.11/legado-ios-v0.1.11-build17-unsigned.ipa",
|
||||||
|
"size": 12424247,
|
||||||
|
"minOSVersion": "15.0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version": "0.1.11",
|
||||||
|
"versionDate": "2026-10-22T03:06:37Z",
|
||||||
|
"versionDescription": "小浣熊阅读 **0.1.11**,构建号 **17**,支持 **iOS / iPadOS 15.0 及以上**。\n\n## 更新内容\n\n- 修复书架和文件管理中的本地 TXT 文件选择及导入流程,支持 `.txt`、`.TXT` 多选,排除 JSON 书源文件。\n- 保留所选文件访问权限至导入完成,调整取消后重新选择和导入确认的展示流程。\n- 完善深色模式下的主按钮、选中态、阅读菜单、统计卡片和管理页面,改善文字对比度。\n- 调整规则编辑器的语法高亮、光标和工具栏外观。\n- 适配 RSS 正文、登录网页和图片查看页的背景与导航操作,保留书源自定义样式和页面状态。\n\n## 下载安装\n\n下载附件 **`legado-ios-v0.1.11-build17-unsigned.ipa`**,使用自己的签名工具安装。支持 TrollStore 的设备可自行尝试安装;本项目暂不提供 App Store 或 TestFlight 分发。\n\n`Source code (zip / tar.gz)` 是源码压缩包,不能直接安装。`SHA256SUMS` 可用于核对 IPA 完整性。\n\n## 验证与兼容\n\n本次功能代码已通过 300 项核心与页面模型测试、15 项 iOS 测试及 11 个相关 UI 回归用例。IPA 从对应源码重新进行 iPhoneOS Release 构建。\n\nUI 回归在 iOS 18.3 模拟器完成;iOS 15 真机仍未覆盖。如有兼容问题,请在 [Issues](https://github.com/Aminark/legado-ios/issues) 反馈版本、设备、系统及复现步骤。\n\n**小浣熊阅读不提供、内置或分发网络书源,也不提供在线书库;书源需自行导入。**\n",
|
||||||
|
"downloadURL": "https://github.com/Aminark/legado-ios/releases/download/v0.1.11/legado-ios-v0.1.11-build17-unsigned.ipa",
|
||||||
|
"size": 12424247,
|
||||||
|
"appPermissions": {
|
||||||
|
"entitlements": [],
|
||||||
|
"privacy": {
|
||||||
|
"NSCameraUsageDescription": "用于扫描书源和净化规则的导入二维码。",
|
||||||
|
"NSPhotoLibraryAddUsageDescription": "用于保存阅读页面中的图片到照片图库。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"news": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
// Standalone simulator-only file provider; never bundled with Legado.
|
||||||
|
@main
|
||||||
|
@MainActor
|
||||||
|
final class FixtureAppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
var window: UIWindow?
|
||||||
|
|
||||||
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||||
|
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
|
let fixtures = [
|
||||||
|
("JSON-Source.json", "https://file-import.test/json", "文件导入 JSON 测试"),
|
||||||
|
("JSON-Uppercase.JSON", "https://file-import.test/uppercase", "文件导入大写扩展名测试"),
|
||||||
|
("JSON-Text.txt", "https://file-import.test/text", "文件导入文本扩展名测试")
|
||||||
|
]
|
||||||
|
for (name, url, title) in fixtures {
|
||||||
|
let source = [["bookSourceUrl": url, "bookSourceName": title]]
|
||||||
|
if let data = try? JSONSerialization.data(withJSONObject: source) {
|
||||||
|
try? data.write(to: documents.appendingPathComponent(name), options: .atomic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name in ["TXT-Book.txt", "TXT-Uppercase.TXT"] {
|
||||||
|
try? Data("第一章 开始\n这是用于验证本地 TXT 导入的小说正文。\n第二章 后续\n正文继续。".utf8)
|
||||||
|
.write(to: documents.appendingPathComponent(name), options: .atomic)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [ "$#" -ne 1 ]; then
|
||||||
|
printf 'Usage: sh scripts/prepare-source-import-fixture.sh <booted-simulator-UUID>\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
legado_fixture_device="$1"
|
||||||
|
legado_fixture_repo="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||||
|
legado_fixture_build="$(mktemp -d "${TMPDIR:-/tmp}/legado-source-import.XXXXXX")"
|
||||||
|
trap 'rm -rf "$legado_fixture_build"' EXIT HUP INT TERM
|
||||||
|
legado_fixture_app="$legado_fixture_build/SourceImportFixtures.app"
|
||||||
|
legado_fixture_sdk="$(xcrun --sdk iphonesimulator --show-sdk-path)"
|
||||||
|
legado_fixture_arch="$(uname -m)"
|
||||||
|
mkdir -p "$legado_fixture_app"
|
||||||
|
|
||||||
|
cat > "$legado_fixture_app/Info.plist" <<'PLIST'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0"><dict>
|
||||||
|
<key>CFBundleIdentifier</key><string>com.epiboly.legado.source-import-fixtures</string>
|
||||||
|
<key>CFBundleExecutable</key><string>SourceImportFixtures</string>
|
||||||
|
<key>CFBundleName</key><string>SourceImportFixtures</string>
|
||||||
|
<key>CFBundleDisplayName</key><string>书源导入测试</string>
|
||||||
|
<key>CFBundlePackageType</key><string>APPL</string>
|
||||||
|
<key>CFBundleVersion</key><string>1</string>
|
||||||
|
<key>CFBundleShortVersionString</key><string>1.0</string>
|
||||||
|
<key>MinimumOSVersion</key><string>15.0</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key><true/>
|
||||||
|
<key>UIFileSharingEnabled</key><true/>
|
||||||
|
<key>LSSupportsOpeningDocumentsInPlace</key><true/>
|
||||||
|
<key>UILaunchScreen</key><dict/>
|
||||||
|
<key>UISupportedInterfaceOrientations</key><array><string>UIInterfaceOrientationPortrait</string></array>
|
||||||
|
</dict></plist>
|
||||||
|
PLIST
|
||||||
|
|
||||||
|
xcrun --sdk iphonesimulator swiftc -parse-as-library -swift-version 6 \
|
||||||
|
-sdk "$legado_fixture_sdk" -target "$legado_fixture_arch-apple-ios15.0-simulator" \
|
||||||
|
-module-cache-path "$legado_fixture_repo/DerivedData/ModuleCache.noindex" \
|
||||||
|
"$legado_fixture_repo/scripts/fixtures/SourceImportFixture.swift" \
|
||||||
|
-o "$legado_fixture_app/SourceImportFixtures"
|
||||||
|
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 and TXT import fixtures are ready in Files > On My iPhone > 书源导入测试.\n'
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user