3 Commits
Author SHA1 Message Date
Aminark 7daa6b5635 feat: 添加 AltStore 应用源及发布维护流程
- 根据已发布 Release 和实际 IPA 生成应用源,包含版本、下载地址、权限和截图
- 校验包标识、版本标签、文件大小与 SHA-256,补充生成脚本测试
- 记录手动更新应用源的维护流程,完善本地自检
- 忽略仅供本机使用的 GitHub 上传说明
2026-10-20 23:35:00 +08:00
Aminark 0915a144a7 fix: 修复书源导入、阅读外观和书签状态
- 修复书源文件选择、访问权限与导入预览流程,支持查看分组内的书源
- 修复背景图片、字体选择与主题切换,限制背景图片解码尺寸
- 根据已保存书签恢复当前位置状态,避免重复添加
- 补充模型、阅读器和文件选择回归测试及测试样本
- 更新应用版本为 0.1.10(build 16)

Fixes #1
2026-10-20 23:30:42 +08:00
Aminark 0bf332441d docs: add LINUX DO community link 2026-10-19 15:17:06 +08:00
23 changed files with 1259 additions and 102 deletions
+1
View File
@@ -4,6 +4,7 @@ DerivedData/
*.xcuserstate
xcuserdata/
reference/
/GITHUB_UPLOAD.local.md
artifacts/
*.xcresult/
+52 -16
View File
@@ -127,20 +127,37 @@ struct ReadingPhotoPicker<Label: View>: View {
var body: some View {
Button { isPresented = true } label: { label }
.sheet(isPresented: $isPresented) {
ReadingPhotoPickerController { result in
isPresented = false
guard let result else { return }
switch result {
case .success(let data): onData(data)
case .failure(let error): onError(error)
}
}
}
.readingPhotoPicker(isPresented: $isPresented, onData: onData, onError: onError)
}
}
private struct ReadingPhotoPickerController: UIViewControllerRepresentable {
private struct ReadingPhotoPickerModifier: ViewModifier {
@Binding var isPresented: Bool
let onData: (Data) -> Void
let onError: (Error) -> Void
@State private var pendingResult: Result<Data, Error>?
func body(content: Content) -> some View {
content.sheet(isPresented: $isPresented, onDismiss: {
let result = pendingResult
pendingResult = nil
switch result {
case .success(let data): onData(data)
case .failure(let error): onError(error)
case nil: break
}
}) {
ReadingPhotoPickerController { result in
// Apply changes after the picker finishes dismissing, so a
// Form update cannot remove its presenter mid-transition.
pendingResult = result
isPresented = false
}
}
}
}
struct ReadingPhotoPickerController: UIViewControllerRepresentable {
let onResult: @MainActor (Result<Data, Error>?) -> Void
func makeCoordinator() -> Coordinator { Coordinator(onResult: onResult) }
@@ -166,14 +183,25 @@ private struct ReadingPhotoPickerController: UIViewControllerRepresentable {
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
guard let provider = results.first?.itemProvider else { onResult(nil); return }
loadImage(from: provider)
}
func loadImage(from provider: NSItemProvider) {
let identifier = provider.registeredTypeIdentifiers.first {
UTType($0)?.conforms(to: .image) == true
} ?? UTType.image.identifier
provider.loadDataRepresentation(forTypeIdentifier: identifier) { [weak self] data, error in
Task { @MainActor in
if let data { self?.onResult(.success(data)) }
else { self?.onResult(.failure(error ?? CocoaError(.fileReadCorruptFile))) }
}
provider.loadDataRepresentation(forTypeIdentifier: identifier, completionHandler: didLoadImageData)
}
// Photos calls back on NSItemProvider-callback-queue. A method reference
// preserves explicit nonisolation in optimized device builds, whereas a
// closure here can infer MainActor and assert before reaching the Task.
nonisolated private func didLoadImageData(_ data: Data?, _ error: (any Error)?) {
let result: Result<Data, Error>
if let data { result = .success(data) }
else { result = .failure(error ?? CocoaError(.fileReadCorruptFile)) }
Task { @MainActor [weak self] in
self?.onResult(result)
}
}
}
@@ -182,6 +210,14 @@ private struct ReadingPhotoPickerController: UIViewControllerRepresentable {
enum ReadingSheetDetent { case medium, large }
extension View {
func readingPhotoPicker(
isPresented: Binding<Bool>,
onData: @escaping (Data) -> Void,
onError: @escaping (Error) -> Void
) -> some View {
modifier(ReadingPhotoPickerModifier(isPresented: isPresented, onData: onData, onError: onError))
}
@ViewBuilder
func readingNumericTransition() -> some View {
if #available(iOS 16.0, *) { contentTransition(.numericText()) }
+26 -7
View File
@@ -1,4 +1,5 @@
import CoreText
import ImageIO
import SwiftUI
import UIKit
@@ -54,25 +55,43 @@ private extension UIColor {
}
enum ReaderBackgroundStore {
static let maximumPixelSize = 2560
static var url: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Legado/reader-background.jpg")
}
static func load() -> UIImage? {
guard let data = try? Data(contentsOf: url) else { return nil }
return UIImage(data: data)
static func load(from source: URL = url) -> UIImage? {
guard let data = try? Data(contentsOf: source, options: .mappedIfSafe) else { return nil }
return try? downsample(data)
}
static func save(_ data: Data) throws -> UIImage {
guard let image = UIImage(data: data), let encoded = image.jpegData(compressionQuality: 0.9) else {
static func save(_ data: Data, to target: URL = url) throws -> UIImage {
let image = try downsample(data)
guard let encoded = image.jpegData(compressionQuality: 0.9) else {
throw CocoaError(.fileReadCorruptFile)
}
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
try encoded.write(to: url, options: .atomic)
try FileManager.default.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true)
try encoded.write(to: target, options: .atomic)
return image
}
private static func downsample(_ data: Data) throws -> UIImage {
// Decode only the size used by the reader, including backgrounds saved
// by older versions. Full-resolution camera photos can exhaust memory.
guard let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceThumbnailMaxPixelSize: maximumPixelSize
] as CFDictionary) else {
throw CocoaError(.fileReadCorruptFile)
}
return UIImage(cgImage: image)
}
static func remove() { try? FileManager.default.removeItem(at: url) }
}
+90 -41
View File
@@ -36,6 +36,7 @@ struct ReaderView: View {
@State private var contentEditError: String?
@State private var backgroundImage: UIImage?
@State private var backgroundError: String?
@State private var showsBackgroundPicker = false
@State private var showsFontImporter = false
@State private var fontError: String?
@State private var readerJumpPosition: Int?
@@ -55,7 +56,7 @@ struct ReaderView: View {
@State private var progressSaveGeneration = 0
@State private var speechStartGeneration = 0
@State private var speechTimerRemaining = 0
@State private var bookmarkSaved = false
@State private var isSavingBookmark = false
@ScaledMetric(relativeTo: .body) private var toolbarIconSize = 21
@AppStorage("textSize") private var fontSize = 20.0
@AppStorage("letterSpacing") private var letterSpacing = 0.1
@@ -282,19 +283,6 @@ struct ReaderView: View {
.sheet(isPresented: $showsReaderSettings) { readerSettings }
.sheet(isPresented: $showsClickActionSettings) { clickActionSettings }
.sheet(isPresented: $showsContentEditor) { contentEditor }
.fileImporter(
isPresented: $showsFontImporter,
allowedContentTypes: [.font, .data],
allowsMultipleSelection: false
) { result in
fontError = nil
do {
guard let source = try result.get().first else { return }
fontPath = try ReaderFontStore.install(from: source)
} catch {
fontError = error.localizedDescription
}
}
.task(id: model.chapterLoadGeneration) {
isWaitingForNextChapter = false
isWaitingForPreviousChapter = false
@@ -413,7 +401,7 @@ struct ReaderView: View {
.background {
ZStack {
backgroundColor
if themeMode != "3", useBackgroundImage, let backgroundImage {
if let backgroundImage = activeBackgroundImage {
Image(uiImage: backgroundImage)
.resizable()
.scaledToFill()
@@ -577,7 +565,7 @@ struct ReaderView: View {
pageAnimation: pageAnimation,
isEInkMode: themeMode == "3",
clickActions: clickActions,
backgroundColor: themeMode != "3" && useBackgroundImage && backgroundImage != nil ? .clear : resolvedStyle.background,
backgroundColor: activeBackgroundImage == nil ? resolvedStyle.background : .clear,
foregroundColor: resolvedStyle.foreground,
requestedPosition: pageAnimation == 3 ? readerPosition : model.requestedPosition,
jumpPosition: readerJumpPosition,
@@ -678,6 +666,10 @@ struct ReaderView: View {
return min(max(position, 0), max(length - 1, 0))
}
private var activeBackgroundImage: UIImage? {
themeMode != "3" && useBackgroundImage ? backgroundImage : nil
}
private var backgroundColor: Color {
Color(uiColor: resolvedStyle.background)
}
@@ -822,6 +814,7 @@ struct ReaderView: View {
addBookmark()
}
.accessibilityValue(bookmarkSaved ? "已添加" : "添加当前位置")
.disabled(isSavingBookmark)
readerMoreMenu
}
.foregroundStyle(readerChromeTint)
@@ -1160,11 +1153,13 @@ struct ReaderView: View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(ReaderStylePreset.legadoDefaults) { preset in
let isSelected = resolvedStyle.id == preset.id && activeBackgroundImage == nil
Button {
useBackgroundImage = false
theme = preset.id
} label: {
VStack(spacing: 6) {
RoundedRectangle(cornerRadius: 7)
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color(uiColor: preset.background))
.frame(width: 54, height: 72)
.overlay {
@@ -1173,31 +1168,46 @@ struct ReaderView: View {
.foregroundStyle(Color(uiColor: preset.foreground))
}
.overlay {
RoundedRectangle(cornerRadius: 7)
.stroke(resolvedStyle.id == preset.id ? ReadingTheme.accent : Color.secondary, lineWidth: resolvedStyle.id == preset.id ? 3 : 1)
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(
Color(uiColor: preset.foreground).opacity(isSelected ? 0.4 : 0.12),
lineWidth: isSelected ? 1.25 : 0.75
)
}
Text(preset.name).font(.caption2).foregroundStyle(.primary)
.overlay(alignment: .topTrailing) {
if isSelected {
Image(systemName: "checkmark")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(Color(uiColor: preset.foreground))
.padding(6)
}
}
Text(preset.name)
.font(.caption2)
.foregroundStyle(isSelected ? .primary : .secondary)
}
}
.buttonStyle(.plain)
.accessibilityLabel(preset.name)
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
}
}
ReadingPhotoPicker { data in
Button("选择背景图片", systemImage: "photo") {
backgroundError = nil
do {
backgroundImage = try ReaderBackgroundStore.save(data)
useBackgroundImage = true
} catch {
backgroundError = error.localizedDescription
}
} onError: { error in
backgroundError = error.localizedDescription
} label: {
Label("选择背景图片", systemImage: "photo")
showsBackgroundPicker = true
}
if backgroundImage != nil {
Toggle("使用背景图片", isOn: $useBackgroundImage)
HStack(spacing: 0) {
Text("使用背景图片")
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture { useBackgroundImage.toggle() }
.accessibilityHidden(true)
Toggle("使用背景图片", isOn: $useBackgroundImage)
.labelsHidden()
.fixedSize()
}
Button("删除背景图片", systemImage: "trash", role: .destructive) {
ReaderBackgroundStore.remove()
backgroundImage = nil
@@ -1219,8 +1229,15 @@ struct ReaderView: View {
Text("粗体").tag(1)
Text("细体").tag(2)
}
TextField("段落缩进字符", text: $paragraphIndent)
.textInputAutocapitalization(.never)
VStack(alignment: .leading, spacing: 6) {
Text("段落缩进")
TextField("请输入缩进字符", text: $paragraphIndent)
.textInputAutocapitalization(.never)
.accessibilityLabel("段落缩进字符")
Text("控制每段开头的缩进,默认两个全角空格;清空后不缩进。")
.font(.caption)
.foregroundStyle(.secondary)
}
Button(fontPath.isEmpty ? "选择字体文件" : "更换字体文件", systemImage: "textformat") {
showsFontImporter = true
}
@@ -1335,6 +1352,30 @@ struct ReaderView: View {
}
}
}
.readingPhotoPicker(isPresented: $showsBackgroundPicker) { data in
backgroundError = nil
do {
backgroundImage = try ReaderBackgroundStore.save(data)
useBackgroundImage = true
} catch {
backgroundError = error.localizedDescription
}
} onError: { error in
backgroundError = error.localizedDescription
}
.fileImporter(
isPresented: $showsFontImporter,
allowedContentTypes: [.font, .data],
allowsMultipleSelection: false
) { result in
fontError = nil
do {
guard let source = try result.get().first else { return }
fontPath = try ReaderFontStore.install(from: source)
} catch {
fontError = error.localizedDescription
}
}
.readingPresentationDetents([.medium])
.readingPresentationDragIndicator()
}
@@ -1866,18 +1907,26 @@ struct ReaderView: View {
}
}
private var bookmarkSaved: Bool {
let page = currentPage - 1
let range = pageAnimation != 3 && readerPageRanges.indices.contains(page) ? readerPageRanges[page] : nil
return !model.bookmarks(at: readerPosition, in: activity.bookmarks, pageRange: range).isEmpty
}
private func addBookmark() {
guard let chapter = model.currentChapter else { return }
bookmarkSaved = true
guard !isSavingBookmark, !bookmarkSaved, let chapter = model.currentChapter else { return }
let currentBook = book
let position = readerPosition
let content = model.content
isSavingBookmark = true
Task {
defer { isSavingBookmark = false }
await activity.addBookmark(
book: book,
book: currentBook,
chapter: chapter,
position: readerPosition,
content: model.content
position: position,
content: content
)
try? await Task.sleep(nanoseconds: 1_200_000_000)
bookmarkSaved = false
}
}
+12
View File
@@ -59,6 +59,18 @@ final class ReaderViewModel: ObservableObject {
private var sourceCacheNeedsInvalidation = false
private var pendingSourceContent: ChapterContentReport?
func bookmarks(at position: Int, in bookmarks: [Bookmark], pageRange: NSRange? = nil) -> [Bookmark] {
bookmarks.filter { bookmark in
guard bookmark.bookName == book.name, bookmark.bookAuthor == book.author,
bookmark.resolvedChapterIndex(in: chapters) == currentIndex else { return false }
if bookmark.chapterPosition == max(position, 0) { return true }
if let pageRange, pageRange.length > 0 {
return NSLocationInRange(bookmark.chapterPosition, pageRange)
}
return false
}
}
func prepareContinuousReading(rules: [ReplaceRule]) async {
guard hasLoadedCurrentChapter else { return }
if scrollChapters.isEmpty {
+36 -8
View File
@@ -4,6 +4,36 @@ import LegadoCore
import Foundation
import SwiftUI
final class SourceImportFile: @unchecked Sendable {
let url: URL
private let hasSecurityScope: Bool
init(url: URL) {
self.url = url
hasSecurityScope = url.startAccessingSecurityScopedResource()
}
deinit {
if hasSecurityScope { url.stopAccessingSecurityScopedResource() }
}
func read() throws -> Data {
var coordinationError: NSError?
var result: Result<Data, Error>?
NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &coordinationError) { readableURL in
result = Result {
guard try readableURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory != true else {
throw BookSourceImportError.invalidDocument("请选择书源 JSON 文件,而不是文件夹。")
}
return try Data(contentsOf: readableURL)
}
}
if let coordinationError { throw coordinationError }
guard let result else { throw CocoaError(.fileReadUnknown) }
return try result.get()
}
}
@MainActor
final class SourceLibraryModel: ObservableObject {
@Published private(set) var sources: [BookSource] = []
@@ -168,16 +198,18 @@ final class SourceLibraryModel: ObservableObject {
}
func previewFiles(_ urls: [URL]) async {
await previewPickedFiles(urls.map { SourceImportFile(url: $0) })
}
func previewPickedFiles(_ files: [SourceImportFile]) async {
isWorking = true
defer { isWorking = false }
do {
var incoming: [String: BookSource] = [:]
var invalidItems: [PendingImport.InvalidItem] = []
for url in urls {
let granted = url.startAccessingSecurityScopedResource()
defer { if granted { url.stopAccessingSecurityScopedResource() } }
for file in files {
let data = try await Task.detached(priority: .userInitiated) { [importer] in
try importer.normalizedData(Data(contentsOf: url))
try importer.normalizedData(file.read())
}.value
var budget = 100
let batch = try await resolveImport(String(decoding: data, as: UTF8.self), depth: 0, visited: [], budget: &budget)
@@ -186,10 +218,6 @@ final class SourceLibraryModel: ObservableObject {
incoming[source.id] = source
}
}
// UIDocumentPicker is still dismissing when its completion runs.
// Presenting the review sheet in that same frame is ignored by
// SwiftUI, so stage it after the picker has left the hierarchy.
try? await Task.sleep(nanoseconds: 300_000_000)
stage(Array(incoming.values), invalidItems: invalidItems)
} catch {
notice = .init(kind: .error, title: "书源导入失败", detail: error.localizedDescription)
+120 -23
View File
@@ -5,6 +5,8 @@ import UniformTypeIdentifiers
struct SourceManagementView: View {
@EnvironmentObject private var library: SourceLibraryModel
@State private var showsImporter = false
@State private var pickedImportFiles: [SourceImportFile] = []
@State private var importPickerHasDismissed = true
@State private var showsNewSource = false
@State private var showsQRImporter = false
@State private var showsTextImporter = false
@@ -58,7 +60,7 @@ struct SourceManagementView: View {
} description: {
Text("新建书源,或从文件、链接、JSON、二维码导入。")
} actions: {
Button("导入书源") { showsImporter = true }
Button("导入书源", action: presentFileImporter)
.buttonStyle(.borderedProminent)
}
} else {
@@ -142,7 +144,7 @@ struct SourceManagementView: View {
Menu("更多", systemImage: "ellipsis.circle") {
Button("新建书源", systemImage: "plus") { showsNewSource = true }
Button("二维码导入", systemImage: "qrcode.viewfinder") { showsQRImporter = true }
Button("从文件导入", systemImage: "doc.badge.plus") { showsImporter = true }
Button("从文件导入", systemImage: "doc.badge.plus", action: presentFileImporter)
Button("URL 或粘贴 JSON", systemImage: "link.badge.plus") { showsTextImporter = true }
Divider()
NavigationLink {
@@ -206,24 +208,71 @@ struct SourceManagementView: View {
.sheet(isPresented: $showsBatchGroups) {
SourceBatchGroupView(sourceIDs: selectedSourceIDs).environmentObject(library)
}
.fileImporter(
isPresented: $showsImporter,
allowedContentTypes: [.item],
allowsMultipleSelection: false
) { result in
switch result {
case .success(let urls):
let files = urls.filter { !$0.hasDirectoryPath }
if files.isEmpty {
library.notice = .init(kind: .error, title: "无法选择文件", detail: "请选择书源 JSON 文件,而不是文件夹。")
} else {
Task { await library.previewFiles(files) }
}
case .failure(let error):
library.notice = .init(kind: .error, title: "无法选择文件", detail: error.localizedDescription)
.sheet(isPresented: $showsImporter, onDismiss: {
importPickerHasDismissed = true
previewPickedFilesWhenReady()
}) {
SourceFileImportPicker { files in
pickedImportFiles = files
showsImporter = false
previewPickedFilesWhenReady()
} onCancel: {
showsImporter = false
}
}
}
private func presentFileImporter() {
pickedImportFiles = []
importPickerHasDismissed = false
showsImporter = true
}
private func previewPickedFilesWhenReady() {
// Import-copy mode can dismiss before returning URLs. Other providers
// return URLs first, so wait for both events instead of assuming order.
guard importPickerHasDismissed, !pickedImportFiles.isEmpty else { return }
let files = pickedImportFiles
pickedImportFiles = []
Task { await library.previewPickedFiles(files) }
}
}
private struct SourceFileImportPicker: UIViewControllerRepresentable {
let onPick: @MainActor ([SourceImportFile]) -> Void
let onCancel: @MainActor () -> Void
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
// Ask the provider to import copies, including JSON files whose provider
// does not support opening documents in place. Multi-select gives Files
// an explicit confirmation action for the selected files.
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.item], asCopy: true)
picker.allowsMultipleSelection = true
picker.shouldShowFileExtensions = true
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ controller: UIDocumentPickerViewController, context: Context) {}
@MainActor
final class Coordinator: NSObject, UIDocumentPickerDelegate {
private let parent: SourceFileImportPicker
init(parent: SourceFileImportPicker) { self.parent = parent }
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
parent.onCancel()
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
// Hold the picker grants before dismissing, until the model has
// finished its coordinated background reads.
parent.onPick(urls.map { SourceImportFile(url: $0) })
}
}
}
private struct SourceTextImportView: View {
@@ -337,16 +386,22 @@ private struct SourceGroupManagerView: View {
}
.disabled(BookSource.parseGroups(newGroup).isEmpty)
}
Text("新分组会分配给当前未分组书源,与 Legado 的分组管理行为一致。")
Text("添加后,所有未分组的书源都会加入该分组。")
.font(.caption).foregroundStyle(.secondary)
}
Section("已有分组") {
Section {
ForEach(library.groups, id: \.self) { group in
HStack {
Label(group, systemImage: "folder.fill")
Spacer()
Text("\(library.sources.filter { $0.hasGroup(group) }.count)")
.foregroundStyle(.secondary)
NavigationLink {
SourceGroupContentsView(group: group)
} label: {
HStack {
Label(group, systemImage: "folder.fill")
Spacer()
Text("\(library.sources.filter { $0.hasGroup(group) }.count) 个书源")
.foregroundStyle(.secondary)
}
}
Menu {
Button("重命名", systemImage: "pencil") {
renaming = group
@@ -356,8 +411,14 @@ private struct SourceGroupManagerView: View {
Task { await library.deleteGroup(group) }
}
} label: { Image(systemName: "ellipsis.circle") }
.buttonStyle(.borderless)
.accessibilityLabel("\(group)分组操作")
}
}
} header: {
Text("已有分组")
} footer: {
Text("点击分组可查看其中的书源。")
}
}
.navigationTitle("书源分组")
@@ -378,6 +439,42 @@ private struct SourceGroupManagerView: View {
}
}
private struct SourceGroupContentsView: View {
@EnvironmentObject private var library: SourceLibraryModel
let group: String
private var sources: [BookSource] {
library.sources.filter { $0.hasGroup(group) }
}
var body: some View {
List {
Section {
ForEach(sources) { source in
NavigationLink {
SourceDetailView(source: source)
} label: {
SourceRow(source: source) { enabled in
Task { await library.setEnabled(enabled, for: source.id) }
}
}
}
if sources.isEmpty {
Text("该分组暂无书源")
.foregroundStyle(.secondary)
}
} header: {
Text("\(sources.count) 个书源")
}
}
.listStyle(.insetGrouped)
.readingScrollContentBackgroundHidden()
.background(ReadingTheme.groupedCanvas)
.navigationTitle(group)
.navigationBarTitleDisplayMode(.inline)
}
}
private struct SourceBatchGroupView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var library: SourceLibraryModel
+1 -1
View File
@@ -34,7 +34,7 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>0.1.9</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
+8
View File
@@ -1,5 +1,13 @@
# 更新记录
## 0.1.10(2026-10-20)
- 修复书源文件选择及导入流程,支持 JSON 多选,调整文件访问权限和导入预览展示时机。
- 支持进入书源分组查看书源详情及切换启用状态。
- 修复阅读背景图片与字体选择,限制背景图片解码尺寸,完善图片与预设主题切换。
- 修复重新进入阅读器后的书签状态,避免重复添加。
- 应用版本为 `0.1.10`,构建号为 `16`。
## 0.1.9(2026-10-14)
- 个人中心采用阅读静物头图、暖白圆角卡片和黑金双色图标,适配深色模式。
+4 -4
View File
@@ -858,12 +858,12 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 13;
CURRENT_PROJECT_VERSION = 16;
DEVELOPMENT_ASSET_PATHS = "";
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 0.1.9;
MARKETING_VERSION = 0.1.10;
OTHER_LDFLAGS = "$(inherited) -lsqlite3";
PRODUCT_BUNDLE_IDENTIFIER = com.epiboly.legado;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -879,11 +879,11 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 13;
CURRENT_PROJECT_VERSION = 16;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 0.1.9;
MARKETING_VERSION = 0.1.10;
OTHER_LDFLAGS = "$(inherited) -lsqlite3";
PRODUCT_BUNDLE_IDENTIFIER = com.epiboly.legado;
PRODUCT_NAME = "$(TARGET_NAME)";
+4
View File
@@ -119,3 +119,7 @@
第三方材料的版权与许可见 [第三方声明](THIRD_PARTY_NOTICES.md)。
请遵守书源网站的使用规则和内容授权,支持正版阅读。
## 🔗 友情链接
[LINUX DO](https://linux.do/) —— 真诚分享、友好讨论的技术社区,本项目的交流与反馈也发布于此
@@ -207,6 +207,30 @@ struct ReaderRegressionTests {
#expect(bookmark.resolvedChapterIndex(in: chapters) == nil)
}
@Test func bookmarkSelectionUsesSavedPositionAndCurrentPageAfterReopening() {
let book = Book(bookUrl: "demo://bookmarks", name: "书", author: "作者")
let chapters = (0..<2).map { BookChapter(url: "demo://\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) }
var saved = Bookmark(time: 1, bookName: book.name, bookAuthor: book.author)
saved.chapterIndex = 0
saved.chapterName = chapters[0].title
saved.chapterPosition = 40
var otherBook = saved
otherBook.bookAuthor = "其他作者"
var otherChapter = saved
otherChapter.chapterIndex = 1
otherChapter.chapterName = chapters[1].title
let bookmarks = [saved, otherBook, otherChapter]
let reopened = ReaderViewModel(demoBook: book, chapters: chapters, initialIndex: 0, content: "正文")
#expect(reopened.bookmarks(at: 40, in: bookmarks) == [saved])
#expect(reopened.bookmarks(at: 0, in: bookmarks, pageRange: NSRange(location: 0, length: 80)) == [saved])
#expect(reopened.bookmarks(at: 80, in: bookmarks, pageRange: NSRange(location: 80, length: 80)).isEmpty)
#expect(reopened.bookmarks(at: 41, in: bookmarks).isEmpty)
#expect(reopened.bookmarks(at: 40, in: []).isEmpty)
reopened.select(index: 1)
#expect(reopened.bookmarks(at: 40, in: bookmarks) == [otherChapter])
}
@Test func continuousReadingExtendsPastThreeChaptersWithoutNavigating() async {
let book = Book(bookUrl: "demo://continuous", name: "书")
let chapters = (0..<20).map { BookChapter(url: "demo://\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) }
@@ -25,6 +25,51 @@ private actor ImportFixtureTransport: HTTPTransport {
@MainActor
struct RuleLibraryRegressionTests {
@Test func pickedJSONFilesAreReadTogetherAndReviewedBeforeImport() async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let inputs = [
("书源.json", #"{"bookSourceUrl":"https://picked-json.test","bookSourceName":"JSON 书源"}"#),
("大写扩展名.JSON", #"[{"bookSourceUrl":"https://picked-uppercase.test","bookSourceName":"大写扩展名"}]"#),
("书源.txt", #"{"bookSourceUrl":"https://picked-text.test","bookSourceName":"文本扩展名"}"#)
]
let files = try inputs.map { name, content in
let url = directory.appendingPathComponent(name)
try Data(content.utf8).write(to: url)
return SourceImportFile(url: url)
}
let model = SourceLibraryModel.demo(sources: [])
await model.previewPickedFiles(files)
#expect(model.notice == nil)
#expect(!model.isWorking)
#expect(model.pendingImport?.validCount == 3)
#expect(model.sources.isEmpty)
let selectedIDs = Set(try #require(model.pendingImport).items.map(\.id))
await model.commitPendingImport(selectedIDs: selectedIDs, keepOriginalName: false, keepOriginalGroup: false,
keepOriginalEnabled: false, customGroup: nil, addCustomGroup: true)
#expect(model.sources.count == 3)
#expect(model.pendingImport == nil)
}
@Test func missingOrDirectoryImportReportsFailureWithoutChangingSources() async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let original = BookSource(bookSourceUrl: "https://existing-source.test", bookSourceName: "原有书源")
for url in [directory, directory.appendingPathComponent("missing.json")] {
let model = SourceLibraryModel.demo(sources: [original])
await model.load()
await model.previewFiles([url])
#expect(model.notice?.kind == .error)
#expect(model.pendingImport == nil)
#expect(model.sources == [original])
#expect(!model.isWorking)
}
}
@Test func largeImportKeepsTheMainActorResponsive() async throws {
let comment = String(repeating: "规则说明", count: 1_024)
let entries = (0..<1_000).map { index in
@@ -1,8 +1,161 @@
import ImageIO
import SwiftUI
import UIKit
import XCTest
@testable import Legado
private final class BackgroundPhotoProvider: NSItemProvider, @unchecked Sendable {
let photoData: Data?
let photoError: (any Error)?
init(data: Data?, error: (any Error)? = nil) {
photoData = data
photoError = error
super.init()
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override var registeredTypeIdentifiers: [String] { ["public.png"] }
override func loadDataRepresentation(
forTypeIdentifier typeIdentifier: String,
completionHandler: @escaping @Sendable (Data?, (any Error)?) -> Void
) -> Progress {
DispatchQueue.global(qos: .userInitiated).async { [photoData, photoError] in
dispatchPrecondition(condition: .notOnQueue(.main))
completionHandler(photoData, photoError)
}
return Progress(totalUnitCount: 1)
}
}
@MainActor
final class ReadingPhotoPickerTests: XCTestCase {
func testPhotoDataReturnedOnBackgroundQueueReachesMainActor() async throws {
let expected = Data("photo-provider-data".utf8)
let provider = BackgroundPhotoProvider(data: expected)
let received = expectation(description: "Photo data delivered on main actor")
let coordinator = ReadingPhotoPickerController.Coordinator { result in
XCTAssertTrue(Thread.isMainThread)
XCTAssertEqual(try? result?.get(), expected)
received.fulfill()
}
coordinator.loadImage(from: provider)
await fulfillment(of: [received], timeout: 5)
withExtendedLifetime(coordinator) {}
}
func testPhotoProviderFailureOnBackgroundQueueReachesMainActor() async throws {
let errors: [(any Error)?] = [CocoaError(.fileReadNoPermission), nil]
for error in errors {
let provider = BackgroundPhotoProvider(data: nil, error: error)
let received = expectation(description: "Photo error delivered on main actor")
let coordinator = ReadingPhotoPickerController.Coordinator { result in
XCTAssertTrue(Thread.isMainThread)
guard case .failure(let failure)? = result else {
XCTFail("Expected an import error")
received.fulfill()
return
}
XCTAssertEqual((failure as NSError).domain, NSCocoaErrorDomain)
XCTAssertEqual((failure as NSError).code, (error as NSError?)?.code ?? NSFileReadCorruptFileError)
received.fulfill()
}
coordinator.loadImage(from: provider)
await fulfillment(of: [received], timeout: 5)
withExtendedLifetime(coordinator) {}
}
}
}
@MainActor
final class ReaderBackgroundStoreTests: XCTestCase {
func testLargeBackgroundIsDownsampledOnImportAndLegacyReload() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let target = directory.appendingPathComponent("background.jpg")
let format = UIGraphicsImageRendererFormat()
format.scale = 1
let data = UIGraphicsImageRenderer(size: CGSize(width: 4096, height: 3072), format: format).jpegData(withCompressionQuality: 0.9) { context in
UIColor.red.setFill()
context.fill(CGRect(x: 0, y: 0, width: 4096, height: 3072))
}
for _ in 0..<3 {
let imported = try ReaderBackgroundStore.save(data, to: target)
XCTAssertEqual(imported.size.width, CGFloat(ReaderBackgroundStore.maximumPixelSize))
XCTAssertEqual(imported.size.height / imported.size.width, 0.75, accuracy: 0.001)
let restored = try XCTUnwrap(ReaderBackgroundStore.load(from: target))
XCTAssertEqual(restored.size, imported.size)
}
try data.write(to: target)
let legacy = try XCTUnwrap(ReaderBackgroundStore.load(from: target))
XCTAssertEqual(legacy.size.width, CGFloat(ReaderBackgroundStore.maximumPixelSize))
}
func testCorruptReplacementPreservesExistingBackground() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let target = directory.appendingPathComponent("background.jpg")
let image = UIGraphicsImageRenderer(size: CGSize(width: 40, height: 60)).image { context in
UIColor.blue.setFill()
context.fill(CGRect(x: 0, y: 0, width: 40, height: 60))
}
_ = try ReaderBackgroundStore.save(try XCTUnwrap(image.pngData()), to: target)
let saved = try Data(contentsOf: target)
XCTAssertThrowsError(try ReaderBackgroundStore.save(Data("not an image".utf8), to: target))
XCTAssertEqual(try Data(contentsOf: target), saved)
XCTAssertNotNil(ReaderBackgroundStore.load(from: target))
}
func testBackgroundPreservesPhotoOrientation() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let image = UIGraphicsImageRenderer(size: CGSize(width: 120, height: 60)).image { context in
UIColor.green.setFill()
context.fill(CGRect(x: 0, y: 0, width: 120, height: 60))
}
let data = NSMutableData()
let destination = try XCTUnwrap(CGImageDestinationCreateWithData(data, "public.jpeg" as CFString, 1, nil))
CGImageDestinationAddImage(destination, try XCTUnwrap(image.cgImage), [kCGImagePropertyOrientation: 6] as CFDictionary)
XCTAssertTrue(CGImageDestinationFinalize(destination))
let saved = try ReaderBackgroundStore.save(data as Data, to: directory.appendingPathComponent("background.jpg"))
XCTAssertEqual(saved.imageOrientation, .up)
XCTAssertEqual(saved.size.height / saved.size.width, 2, accuracy: 0.001)
}
}
@MainActor
final class ReaderBookmarkPersistenceTests: XCTestCase {
func testBookmarkSurvivesNewActivityAndReaderInstances() async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let repository = FileReadingActivityRepository(
bookmarksURL: directory.appendingPathComponent("bookmarks.json"),
recordsURL: directory.appendingPathComponent("records.json")
)
let book = Book(bookUrl: "demo://saved-bookmark", name: "书", author: "作者")
let chapters = [BookChapter(url: "demo://chapter", title: "第一章", bookUrl: book.bookUrl, index: 0)]
let original = ReadingActivityModel(repository: repository, deviceID: "test")
await original.load()
await original.addBookmark(book: book, chapter: chapters[0], position: 40, content: String(repeating: "正文", count: 100))
XCTAssertNil(original.errorMessage)
let restored = ReadingActivityModel(repository: repository, deviceID: "test")
await restored.load()
let reader = ReaderViewModel(demoBook: book, chapters: chapters, initialIndex: 0, content: "正文")
XCTAssertEqual(reader.bookmarks(at: 40, in: restored.bookmarks).count, 1)
XCTAssertTrue(reader.bookmarks(at: 80, in: restored.bookmarks).isEmpty)
await restored.removeBookmark(try XCTUnwrap(restored.bookmarks.first))
XCTAssertTrue(reader.bookmarks(at: 40, in: restored.bookmarks).isEmpty)
}
}
@MainActor
final class ReaderScrollGeometryTests: XCTestCase {
func testPrependingAndRecyclingStayWithinOnePhysicalPixel() async throws {
@@ -116,7 +269,7 @@ final class ReaderScrollGeometryTests: XCTestCase {
.init(configuration: configuration(fontSize: fontSize), chapters: chapters,
resetID: 0, activeIndex: active, loadingAhead: false, loadingBehind: false,
onNeedChapters: { _ in }, onPositionChanged: { _, _ in },
onRetainChapters: { _ in }, onRetry: { _ in })
onRetainChapters: { _ in })
}
private func configuration(fontSize: Double = 20) -> ReaderTextView {
@@ -1,7 +1,185 @@
import XCTest
import UIKit
@MainActor
final class ReaderScrollGestureTests: XCTestCase {
func testBackgroundSwitchRestoresPresetWithoutDeletingPhoto() throws {
try exerciseBackgroundSwitch(readingMode: "0")
}
func testScrollBackgroundSwitchRestoresPresetWithoutDeletingPhoto() throws {
try exerciseBackgroundSwitch(readingMode: "3")
}
private func exerciseBackgroundSwitch(readingMode: String) throws {
continueAfterFailure = false
let app = launchAppearance(readingMode: readingMode)
let choose = app.buttons["选择背景图片"]
scrollToAppearanceElement(choose, in: app)
app.buttons["微信读书"].tap()
choose.tap()
let photo = app.images["PXGGridLayout-Info"].firstMatch
XCTAssertTrue(photo.waitForExistence(timeout: 10))
photo.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch
let dismissed = expectation(for: NSPredicate { _, _ in !cancel.exists }, evaluatedWith: nil)
wait(for: [dismissed], timeout: 15)
let useBackground = app.switches["使用背景图片"]
scrollToAppearanceElement(useBackground, in: app)
XCTAssertEqual(useBackground.value as? String, "1")
app.buttons["完成"].tap()
let imageColor = try backgroundRGB(in: app)
func openAppearance() {
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
app.buttons["外观"].tap()
XCTAssertTrue(app.navigationBars["阅读样式"].waitForExistence(timeout: 5))
scrollToAppearanceElement(useBackground, in: app)
}
openAppearance()
attachScreenshot(app)
app.coordinate(withNormalizedOffset: CGVector(
dx: 0.25, dy: useBackground.frame.midY / app.frame.height
)).tap()
XCTAssertEqual(useBackground.value as? String, "0")
XCTAssertTrue(app.buttons["删除背景图片"].exists)
app.buttons["完成"].tap()
XCTAssertEqual(try backgroundRGB(in: app), [192, 237, 198])
openAppearance()
useBackground.tap()
XCTAssertEqual(useBackground.value as? String, "1")
app.buttons["完成"].tap()
XCTAssertEqual(try backgroundRGB(in: app), imageColor)
openAppearance()
app.buttons["预设2"].tap()
XCTAssertEqual(useBackground.value as? String, "0")
app.buttons["完成"].tap()
XCTAssertEqual(try backgroundRGB(in: app), [221, 192, 144])
}
private func backgroundRGB(in app: XCUIApplication) throws -> [Int] {
let image = try XCTUnwrap(app.screenshot().image.cgImage)
let pixel = try XCTUnwrap(image.cropping(to: CGRect(
x: image.width / 50, y: image.height * 28 / 100, width: 1, height: 1
)))
var bytes = [UInt8](repeating: 0, count: 4)
try bytes.withUnsafeMutableBytes { buffer in
let context = try XCTUnwrap(CGContext(
data: buffer.baseAddress, width: 1, height: 1, bitsPerComponent: 8,
bytesPerRow: 4, space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue
))
context.draw(pixel, in: CGRect(x: 0, y: 0, width: 1, height: 1))
}
return bytes.prefix(3).map(Int.init)
}
func testBackgroundPickerCanBeOpenedRepeatedly() throws {
continueAfterFailure = false
let app = launchAppearance()
for _ in 0..<3 {
let choose = app.buttons["选择背景图片"]
scrollToAppearanceElement(choose, in: app)
choose.tap()
let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch
XCTAssertTrue(cancel.waitForExistence(timeout: 10))
cancel.tap()
XCTAssertTrue(app.navigationBars["阅读样式"].waitForExistence(timeout: 5))
}
}
func testBackgroundPhotoCanBeSelectedAndReplaced() throws {
try exerciseBackgroundPhotoSelection(usesLegacyUI: false)
}
func testLegacyBackgroundPhotoCanBeSelectedAndReplaced() throws {
try exerciseBackgroundPhotoSelection(usesLegacyUI: true)
}
private func exerciseBackgroundPhotoSelection(usesLegacyUI: Bool) throws {
continueAfterFailure = false
let app = launchAppearance(usesLegacyUI: usesLegacyUI)
for _ in 0..<2 {
let choose = app.buttons["选择背景图片"]
scrollToAppearanceElement(choose, in: app)
choose.tap()
let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch
XCTAssertTrue(cancel.waitForExistence(timeout: 10))
let photo = app.images["PXGGridLayout-Info"].firstMatch
XCTAssertTrue(photo.waitForExistence(timeout: 5), "Seed a demo image with simctl addmedia before running this test")
photo.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
let dismissed = expectation(for: NSPredicate { _, _ in !cancel.exists }, evaluatedWith: nil)
wait(for: [dismissed], timeout: 15)
XCTAssertTrue(app.navigationBars["阅读样式"].waitForExistence(timeout: 15))
let useBackground = app.switches["使用背景图片"]
scrollToAppearanceElement(useBackground, in: app)
XCTAssertEqual(useBackground.value as? String, "1")
}
}
func testFontPickerCanBePresentedAboveAppearanceAndReopened() throws {
continueAfterFailure = false
let app = launchAppearance()
for _ in 0..<2 {
let choose = app.buttons["选择字体文件"]
scrollToAppearanceElement(choose, in: app)
XCTAssertTrue(app.staticTexts["段落缩进"].exists)
choose.tap()
let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch
XCTAssertTrue(cancel.waitForExistence(timeout: 10))
cancel.tap()
XCTAssertTrue(app.navigationBars["阅读样式"].waitForExistence(timeout: 5))
}
}
func testBookmarkSelectionSurvivesLeavingAndReenteringReader() throws {
continueAfterFailure = false
let app = XCUIApplication()
app.launchArguments = ["-uiDemo", "-pageAnim", "0", "-themeMode", "1", "-textSize", "28"]
app.launch()
let book = app.staticTexts["山海入梦来"].firstMatch
XCTAssertTrue(book.waitForExistence(timeout: 20))
book.tap()
XCTAssertTrue(app.textViews["reader.text"].waitForExistence(timeout: 20))
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
let bookmark = app.buttons["书签"]
XCTAssertTrue(bookmark.waitForExistence(timeout: 5))
XCTAssertEqual(bookmark.value as? String, "添加当前位置")
bookmark.tap()
app.buttons["返回"].tap()
XCTAssertTrue(book.waitForExistence(timeout: 10))
book.tap()
XCTAssertTrue(app.textViews["reader.text"].waitForExistence(timeout: 10))
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
XCTAssertTrue(bookmark.waitForExistence(timeout: 5))
XCTAssertEqual(bookmark.value as? String, "已添加")
app.buttons["更多"].tap()
app.buttons["书签列表"].tap()
XCTAssertTrue(app.navigationBars["书签"].waitForExistence(timeout: 5))
XCTAssertTrue(app.staticTexts["第一章 · 雨夜"].firstMatch.exists)
}
private func launchAppearance(readingMode: String = "0", usesLegacyUI: Bool = false) -> XCUIApplication {
let app = XCUIApplication()
app.launchArguments = ["-uiDemo", "-uiDemoReader", "-uiDemoReaderStyle", "-pageAnim", readingMode, "-themeMode", "1", "-textFont", ""]
if usesLegacyUI { app.launchArguments.append("-uiLegacyCompatibility") }
app.launch()
XCTAssertTrue(app.navigationBars["阅读样式"].waitForExistence(timeout: 20))
return app
}
private func scrollToAppearanceElement(_ element: XCUIElement, in app: XCUIApplication) {
for _ in 0..<12 {
if element.exists && element.isHittable { return }
let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.88))
let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.72))
start.press(forDuration: 0.05, thenDragTo: end)
}
XCTAssertTrue(element.exists && element.isHittable)
}
func testFailedChaptersAllowRetryMenuAndSourceSwitchInBothReadingModes() throws {
continueAfterFailure = false
for mode in ["3", "0"] {
@@ -2,6 +2,68 @@ import XCTest
@MainActor
final class RuleEditorUITests: XCTestCase {
func testJSONFilePickerOffersOpenAndImportsSelectedFiles() throws {
continueAfterFailure = false
let app = XCUIApplication()
app.launchArguments = ["-uiDemo", "-uiDemoTab", "sources"]
app.launch()
XCTAssertTrue(app.navigationBars["书源"].waitForExistence(timeout: 20))
app.buttons["更多"].firstMatch.tap()
app.buttons["从文件导入"].tap()
let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch
XCTAssertTrue(cancel.waitForExistence(timeout: 10))
cancel.tap()
let pickerClosed = expectation(for: NSPredicate { _, _ in
!cancel.exists && app.navigationBars["书源"].buttons["更多"].isHittable
}, evaluatedWith: nil)
wait(for: [pickerClosed], timeout: 10)
app.buttons["更多"].firstMatch.tap()
app.buttons["从文件导入"].tap()
XCTAssertTrue(cancel.waitForExistence(timeout: 10))
let json = app.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", "JSON-Source")).firstMatch
let fixtures = app.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", "书源导入测试")).firstMatch
if !json.waitForExistence(timeout: 3) {
if !fixtures.exists {
let browse = app.buttons.matching(NSPredicate(format: "label IN %@", ["Browse", "浏览", "Locations", "位置"])).firstMatch
if browse.exists { browse.tap() }
if !fixtures.waitForExistence(timeout: 2) {
let local = app.descendants(matching: .any).matching(NSPredicate(
format: "label CONTAINS %@ OR label CONTAINS %@ OR label == %@", "我的 iPhone", "我的iPhone", "On My iPhone"
)).firstMatch
if !local.waitForExistence(timeout: 5) { print("[SourceFilePicker] \(app.debugDescription)") }
XCTAssertTrue(local.exists)
local.tap()
}
}
XCTAssertTrue(fixtures.waitForExistence(timeout: 10), "Run sh scripts/prepare-source-import-fixture.sh <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 {
try exerciseSourceEditor(usesLegacyUI: false)
}
+47
View File
@@ -41,6 +41,19 @@ sh scripts/self-check.sh
阅读器测试在 Xcode 选择 `LegadoReaderTests` / `LegadoReaderUITests` Scheme 和具体模拟器运行。联网书源测试需要显式配置对应环境变量,默认核心回归主要采用自造响应,不能将其通过解读为所有线上书源均可用。
书源文件选择回归需要先在已启动的测试模拟器中准备文件共享应用(将下方 UUID 换成实际值):
```sh
LEGADO_TEST_DEVICE="模拟器 UUID"
sh scripts/prepare-source-import-fixture.sh "$LEGADO_TEST_DEVICE"
xcodebuild -project Legado.xcodeproj -scheme LegadoReaderUITests \
-destination "platform=iOS Simulator,id=$LEGADO_TEST_DEVICE" \
-only-testing:LegadoReaderUITests/RuleEditorUITests/testJSONFilePickerOffersOpenAndImportsSelectedFiles \
-collect-test-diagnostics never CODE_SIGNING_ALLOWED=NO test
```
独立测试应用只提供 `.json`、`.JSON` 和 `.txt` 样本,不会被打包进正式 App。此回归检查取消后重新打开、JSON 多选后的打开按钮、导入预检及最终保存。
## 兼容性验证
最低部署版本为 iOS 15.0。`App/Compatibility.swift` 为导航、分享、照片选择和弹窗提供兼容实现;新增系统 API 时需保留最低版本路径。调试构建可用 `-uiLegacyCompatibility` 启动参数在新系统上执行旧版界面分支,对应回归位于 `Tests/LegadoReaderUITests/`。这些测试不能替代 iOS 15 真机或模拟器实测。
@@ -62,3 +75,37 @@ python3 scripts/export-open-source.py /tmp/legado-ios-open-source
```
目标目录必须尚不存在。导出后检查内容,再用独立仓库管理;`.gitignore` 不会自动清理已经跟踪的文件。
## 维护应用源
`docs/altstore.json` 是 AltStore Classic、SideStore 和 LiveContainer 共用的应用源,固定地址为:
```text
https://raw.githubusercontent.com/Aminark/legado-ios/main/docs/altstore.json
```
源仅列出最新正式版,同时提供 `versions` 和旧客户端使用的顶层版本字段。版本号、构建号、最低 iOS 版本和隐私权限从实际 IPA 的 `Info.plist` 读取,下载地址、发布日期和更新说明来自 GitHub Release。目前由维护者在发布正式版后手动运行生成脚本并提交源文件。
1. 新版发布时,先在 Release 草稿中上传唯一的未签名 `.ipa`,再发布正式版。
2. 下载 GitHub 标记的 latest 正式版元数据及 IPA,运行下方生成与测试命令,检查 `docs/altstore.json` 差异后提交到 `main`。
3. 源文件发布后,客户端刷新源即可获取该版本的下载与更新入口。只提交源码或上传未发布的草稿不会更新应用源。
本地生成(需要 Python 3.9+;获取 Release 元数据时使用已登录的 GitHub CLI):
```sh
source_update_dir=$(mktemp -d)
gh api repos/Aminark/legado-ios/releases/latest > "$source_update_dir/release.json"
source_release_tag=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["tag_name"])' "$source_update_dir/release.json")
gh release download "$source_release_tag" --repo Aminark/legado-ios \
--pattern '*.ipa' --dir "$source_update_dir"
python3 scripts/update-altstore-source.py \
--ipa "$source_update_dir/legado-ios-v0.1.10-build16-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)。
+63
View File
@@ -0,0 +1,63 @@
{
"name": "小浣熊阅读",
"identifier": "com.epiboly.legado.source",
"subtitle": "小浣熊阅读官方应用源",
"description": "小浣熊阅读的最新正式版,供 AltStore Classic、SideStore 和 LiveContainer 添加。",
"sourceURL": "https://raw.githubusercontent.com/Aminark/legado-ios/main/docs/altstore.json",
"website": "https://github.com/Aminark/legado-ios",
"iconURL": "https://raw.githubusercontent.com/Aminark/legado-ios/main/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png",
"tintColor": "#315C50",
"apps": [
{
"name": "小浣熊阅读",
"bundleIdentifier": "com.epiboly.legado",
"developerName": "Aminark",
"subtitle": "搜书快一点,读书舒服一点。",
"localizedDescription": "一款优先兼容阅读书源的开源 iOS 阅读器,支持书架、搜索、阅读排版、本地 TXT、语音朗读和 RSS。应用不提供书源或在线书库,书源需自行导入。",
"iconURL": "https://raw.githubusercontent.com/Aminark/legado-ios/main/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png",
"tintColor": "#315C50",
"category": "utilities",
"screenshots": [
{
"imageURL": "https://raw.githubusercontent.com/Aminark/legado-ios/main/docs/images/bookshelf.png",
"width": 1206,
"height": 2622
},
{
"imageURL": "https://raw.githubusercontent.com/Aminark/legado-ios/main/docs/images/search.png",
"width": 1206,
"height": 2622
},
{
"imageURL": "https://raw.githubusercontent.com/Aminark/legado-ios/main/docs/images/reader.png",
"width": 1206,
"height": 2622
}
],
"versions": [
{
"version": "0.1.10",
"buildVersion": "16",
"date": "2026-10-20T15:34:03Z",
"localizedDescription": "小浣熊阅读 **0.1.10**,构建号 **16**,支持 **iOS / iPadOS 15.0 及以上**。\n\n## 更新内容\n\n- 修复书源文件选择及导入流程:支持 JSON 多选,调整文件读取权限和导入预览展示时机(#1)。\n- 支持进入书源分组查看书源、打开详情及切换启用状态。\n- 修复阅读外观中的背景图片和字体选择;限制背景图片解码尺寸,降低内存占用。\n- 完善背景图片与预设主题切换,关闭背景图时保留已选图片。\n- 修复退出后重新进入阅读器的书签状态,避免重复添加书签。\n\n## 下载安装\n\n下载附件 **`legado-ios-v0.1.10-build16-unsigned.ipa`**,使用自己的签名工具安装。支持 TrollStore 的设备可自行安装;本项目暂不提供 App Store 或 TestFlight 分发。\n\n`Source code (zip / tar.gz)` 是源码,不能直接安装。附件 `SHA256SUMS` 可用于核对 IPA 完整性。\n\n## 验证与兼容\n\n本次通过 299 项核心及页面模型测试、12 项 iOS 阅读器测试、4 项文件导入与阅读操作 UI 回归,以及模拟器构建。IPA 由本次源码重新进行 iPhoneOS Release 构建。\n\niOS 15.7 真机尚未复测。若在该系统上仍遇到文件无法选择的问题,请继续在 [Issue #1](https://github.com/Aminark/legado-ios/issues/1) 反馈。\n\n**小浣熊阅读不提供、内置或分发网络书源,也不提供在线书库;书源需自行导入。**\n",
"downloadURL": "https://github.com/Aminark/legado-ios/releases/download/v0.1.10/legado-ios-v0.1.10-build16-unsigned.ipa",
"size": 12080513,
"minOSVersion": "15.0"
}
],
"version": "0.1.10",
"versionDate": "2026-10-20T15:34:03Z",
"versionDescription": "小浣熊阅读 **0.1.10**,构建号 **16**,支持 **iOS / iPadOS 15.0 及以上**。\n\n## 更新内容\n\n- 修复书源文件选择及导入流程:支持 JSON 多选,调整文件读取权限和导入预览展示时机(#1)。\n- 支持进入书源分组查看书源、打开详情及切换启用状态。\n- 修复阅读外观中的背景图片和字体选择;限制背景图片解码尺寸,降低内存占用。\n- 完善背景图片与预设主题切换,关闭背景图时保留已选图片。\n- 修复退出后重新进入阅读器的书签状态,避免重复添加书签。\n\n## 下载安装\n\n下载附件 **`legado-ios-v0.1.10-build16-unsigned.ipa`**,使用自己的签名工具安装。支持 TrollStore 的设备可自行安装;本项目暂不提供 App Store 或 TestFlight 分发。\n\n`Source code (zip / tar.gz)` 是源码,不能直接安装。附件 `SHA256SUMS` 可用于核对 IPA 完整性。\n\n## 验证与兼容\n\n本次通过 299 项核心及页面模型测试、12 项 iOS 阅读器测试、4 项文件导入与阅读操作 UI 回归,以及模拟器构建。IPA 由本次源码重新进行 iPhoneOS Release 构建。\n\niOS 15.7 真机尚未复测。若在该系统上仍遇到文件无法选择的问题,请继续在 [Issue #1](https://github.com/Aminark/legado-ios/issues/1) 反馈。\n\n**小浣熊阅读不提供、内置或分发网络书源,也不提供在线书库;书源需自行导入。**\n",
"downloadURL": "https://github.com/Aminark/legado-ios/releases/download/v0.1.10/legado-ios-v0.1.10-build16-unsigned.ipa",
"size": 12080513,
"appPermissions": {
"entitlements": [],
"privacy": {
"NSCameraUsageDescription": "用于扫描书源和净化规则的导入二维码。",
"NSPhotoLibraryAddUsageDescription": "用于保存阅读页面中的图片到照片图库。"
}
}
}
],
"news": []
}
@@ -0,0 +1,33 @@
import UIKit
// Standalone simulator-only file provider; never bundled with Legado.
@main
@MainActor
final class FixtureAppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let fixtures = [
("JSON-Source.json", "https://file-import.test/json", "文件导入 JSON 测试"),
("JSON-Uppercase.JSON", "https://file-import.test/uppercase", "文件导入大写扩展名测试"),
("JSON-Text.txt", "https://file-import.test/text", "文件导入文本扩展名测试")
]
for (name, url, title) in fixtures {
let source = [["bookSourceUrl": url, "bookSourceName": title]]
if let data = try? JSONSerialization.data(withJSONObject: source) {
try? data.write(to: documents.appendingPathComponent(name), options: .atomic)
}
}
let controller = UIViewController()
controller.view.backgroundColor = .systemBackground
let label = UILabel()
label.text = "书源导入测试文件已准备好"
label.frame = CGRect(x: 20, y: 100, width: 350, height: 80)
controller.view.addSubview(label)
window = UIWindow(frame: UIScreen.main.bounds)
window?.rootViewController = controller
window?.makeKeyAndVisible()
return true
}
}
+45
View File
@@ -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 file import fixtures are ready in Files > On My iPhone > 书源导入测试.\n'
+3 -1
View File
@@ -7,9 +7,11 @@ cd "$workspace_dir"
derived_data_path=${LEGADO_DERIVED_DATA_PATH:-"$workspace_dir/DerivedData"}
source_packages_path=${LEGADO_SOURCE_PACKAGES_PATH:-"$derived_data_path/SourcePackages"}
echo "[1/5] Validate bundled resources"
echo "[1/5] Validate bundled resources and application source"
python3 -m json.tool App/Resources/txtTocRule.json >/dev/null
python3 -m json.tool App/Resources/httpTTS.json >/dev/null
python3 -m json.tool docs/altstore.json >/dev/null
python3 -m unittest discover -s scripts/tests -v
echo "[2/5] Check project and Swift formatting hazards"
plutil -lint Legado.xcodeproj/project.pbxproj >/dev/null
+131
View File
@@ -0,0 +1,131 @@
import hashlib
import importlib.util
import plistlib
import tempfile
import unittest
import zipfile
from pathlib import Path
spec = importlib.util.spec_from_file_location("altstore_source", Path(__file__).parents[1] / "update-altstore-source.py")
altstore_source = importlib.util.module_from_spec(spec)
spec.loader.exec_module(altstore_source)
class AltStoreSourceTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.ipa = Path(temporary.name) / "reader-unsigned.ipa"
self.info = {
"CFBundleIdentifier": "com.epiboly.legado",
"CFBundleName": "Reader",
"CFBundleDisplayName": "小浣熊阅读",
"CFBundleShortVersionString": "1.2.3",
"CFBundleVersion": "42",
"MinimumOSVersion": "15.0",
"NSCameraUsageDescription": "扫描导入二维码",
"NSPhotoLibraryAddUsageDescription": "保存图片",
"NSAppTransportSecurity": {"NSAllowsArbitraryLoads": True},
}
self.release = {
"tag_name": "v1.2.3",
"published_at": "2026-10-19T01:02:03Z",
"draft": False,
"prerelease": False,
"body": "更新阅读体验。",
}
self.write_ipa()
def write_ipa(self, extra=None):
with zipfile.ZipFile(self.ipa, "w") as archive:
archive.writestr("Payload/Reader.app/Info.plist", plistlib.dumps(self.info, fmt=plistlib.FMT_BINARY))
if extra:
archive.writestr(extra, b"fixture")
self.release["assets"] = [{
"name": self.ipa.name,
"state": "uploaded",
"size": self.ipa.stat().st_size,
"digest": "sha256:" + hashlib.sha256(self.ipa.read_bytes()).hexdigest(),
"browser_download_url": "https://github.com/Aminark/legado-ios/releases/download/v1.2.3/reader-unsigned.ipa",
}]
def generate(self):
return altstore_source.make_source(self.ipa, self.release, "Aminark/legado-ios", "main")
def test_metadata_permissions_and_legacy_fields_match_ipa(self):
source = self.generate()
self.assertEqual(source["sourceURL"], "https://raw.githubusercontent.com/Aminark/legado-ios/main/docs/altstore.json")
app = source["apps"][0]
version = app["versions"][0]
self.assertEqual(app["bundleIdentifier"], self.info["CFBundleIdentifier"])
self.assertEqual(version["version"], "1.2.3")
self.assertEqual(version["buildVersion"], "42")
self.assertEqual(version["minOSVersion"], "15.0")
self.assertEqual(version["date"], self.release["published_at"])
for key in ("version", "size", "downloadURL"):
self.assertEqual(app[key], version[key])
self.assertEqual(app["appPermissions"], {
"entitlements": [],
"privacy": {
"NSCameraUsageDescription": "扫描导入二维码",
"NSPhotoLibraryAddUsageDescription": "保存图片",
},
})
def test_rejects_drafts_and_prereleases(self):
for key in ("draft", "prerelease"):
with self.subTest(key=key):
self.release[key] = True
with self.assertRaisesRegex(ValueError, "published stable"):
self.generate()
self.release[key] = False
def test_rejects_wrong_release_asset(self):
for key, value in (("size", 1), ("digest", "sha256:wrong"),
("browser_download_url", "https://example.com/wrong.ipa"),
("name", "another.ipa"), ("state", "new")):
with self.subTest(key=key):
self.write_ipa()
self.release["assets"][0][key] = value
with self.assertRaises(ValueError):
self.generate()
def test_older_github_metadata_without_digest_is_supported(self):
del self.release["assets"][0]["digest"]
self.assertEqual(self.generate()["apps"][0]["version"], "1.2.3")
def test_rejects_packages_needing_an_entitlement_audit(self):
for extra in ("Payload/Reader.app/_CodeSignature/CodeResources",
"Payload/Reader.app/embedded.mobileprovision",
"Payload/Reader.app/PlugIns/Widget.appex/Info.plist"):
with self.subTest(extra=extra):
self.write_ipa(extra)
with self.assertRaisesRegex(ValueError, "entitlement audit"):
self.generate()
def test_rejects_unresolved_build_metadata(self):
self.info["CFBundleVersion"] = "$(CURRENT_PROJECT_VERSION)"
self.write_ipa()
with self.assertRaisesRegex(ValueError, "CFBundleVersion"):
self.generate()
def test_rejects_release_and_package_mismatches(self):
self.info["CFBundleIdentifier"] = "com.example.another-app"
self.write_ipa()
with self.assertRaisesRegex(ValueError, "bundle identifier"):
self.generate()
self.info["CFBundleIdentifier"] = "com.epiboly.legado"
self.info["CFBundleShortVersionString"] = "1.2.4"
self.write_ipa()
with self.assertRaisesRegex(ValueError, "Release tag"):
self.generate()
def test_rejects_publication_dates_without_a_timezone(self):
self.release["published_at"] = "2026-10-19T01:02:03"
with self.assertRaisesRegex(ValueError, "timezone"):
self.generate()
if __name__ == "__main__":
unittest.main()
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Generate the AltStore/SideStore source from a published release's unsigned IPA."""
import argparse
import hashlib
import json
import plistlib
import re
import zipfile
from datetime import datetime
from pathlib import Path
from urllib.parse import quote
def make_source(ipa: Path, release: dict, repository: str, branch: str) -> dict:
if release.get("draft") or release.get("prerelease"):
raise ValueError("Only published stable releases belong in this source")
date = release["published_at"]
if datetime.fromisoformat(date.replace("Z", "+00:00")).tzinfo is None:
raise ValueError("Release publication date must include a timezone")
assets = [asset for asset in release["assets"] if asset["name"] == ipa.name]
if len(assets) != 1 or assets[0].get("state") != "uploaded":
raise ValueError("IPA must match exactly one uploaded release asset")
asset = assets[0]
if ipa.stat().st_size != asset["size"]:
raise ValueError("IPA size differs from the published release asset")
digest = asset.get("digest")
if digest and digest != "sha256:" + hashlib.sha256(ipa.read_bytes()).hexdigest():
raise ValueError("IPA checksum differs from the published release asset")
with zipfile.ZipFile(ipa) as archive:
names = archive.namelist()
info_paths = [name for name in names if re.fullmatch(r"Payload/[^/]+\.app/Info\.plist", name)]
if len(info_paths) != 1:
raise ValueError("Expected exactly one app in Payload")
if any("/_CodeSignature/" in name or name.endswith("/embedded.mobileprovision")
or ".appex/" in name for name in names):
raise ValueError("Use an unsigned IPA without extensions; signed apps need an entitlement audit")
info = plistlib.loads(archive.read(info_paths[0]))
for key in ("CFBundleIdentifier", "CFBundleShortVersionString", "CFBundleVersion", "MinimumOSVersion"):
if not isinstance(info.get(key), str) or not info[key] or "$(" in info[key]:
raise ValueError(f"IPA has an invalid or unresolved {key}")
if info["CFBundleIdentifier"] != "com.epiboly.legado":
raise ValueError("IPA bundle identifier does not belong to this app")
if release["tag_name"] != "v" + info["CFBundleShortVersionString"]:
raise ValueError("Release tag does not match the IPA version")
website = f"https://github.com/{repository}"
download_url = f"{website}/releases/download/{quote(release['tag_name'], safe='')}/{quote(ipa.name, safe='')}"
if asset["browser_download_url"] != download_url:
raise ValueError("Release asset URL does not match the repository, tag and IPA name")
raw_url = f"https://raw.githubusercontent.com/{repository}/{quote(branch, safe='')}"
icon_url = f"{raw_url}/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png"
version = {
"version": info["CFBundleShortVersionString"],
"buildVersion": info["CFBundleVersion"],
"date": date,
"localizedDescription": release.get("body") or release.get("name") or release["tag_name"],
"downloadURL": download_url,
"size": asset["size"],
"minOSVersion": info["MinimumOSVersion"],
}
return {
"name": "小浣熊阅读",
"identifier": "com.epiboly.legado.source",
"subtitle": "小浣熊阅读官方应用源",
"description": "小浣熊阅读的最新正式版,供 AltStore Classic、SideStore 和 LiveContainer 添加。",
"sourceURL": f"{raw_url}/docs/altstore.json",
"website": website,
"iconURL": icon_url,
"tintColor": "#315C50",
"apps": [{
"name": info.get("CFBundleDisplayName") or info["CFBundleName"],
"bundleIdentifier": info["CFBundleIdentifier"],
"developerName": repository.split("/")[0],
"subtitle": "搜书快一点,读书舒服一点。",
"localizedDescription": "一款优先兼容阅读书源的开源 iOS 阅读器,支持书架、搜索、阅读排版、本地 TXT、语音朗读和 RSS。应用不提供书源或在线书库,书源需自行导入。",
"iconURL": icon_url,
"tintColor": "#315C50",
"category": "utilities",
"screenshots": [
{"imageURL": f"{raw_url}/docs/images/{name}.png", "width": 1206, "height": 2622}
for name in ("bookshelf", "search", "reader")
],
"versions": [version],
"version": version["version"],
"versionDate": date,
"versionDescription": version["localizedDescription"],
"downloadURL": download_url,
"size": asset["size"],
"appPermissions": {
"entitlements": [],
"privacy": {key: value for key, value in info.items()
if key.startswith("NS") and "UsageDescription" in key},
},
}],
"news": [],
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--ipa", required=True, type=Path)
parser.add_argument("--release", required=True, type=Path, help="GitHub REST release JSON")
parser.add_argument("--repository", default="Aminark/legado-ios")
parser.add_argument("--branch", default="main", help="Branch hosting the source and images")
parser.add_argument("--output", type=Path, default=Path("docs/altstore.json"))
args = parser.parse_args()
try:
source = make_source(args.ipa, json.loads(args.release.read_text(encoding="utf-8")),
args.repository, args.branch)
except (ValueError, KeyError, OSError, zipfile.BadZipFile) as error:
parser.error(str(error))
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(source, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"Generated {args.output} from {args.ipa.name}")
if __name__ == "__main__":
main()