feat: open-source 小浣熊阅读 for iOS

This commit is contained in:
Aminark
2026-10-14 11:37:36 +08:00
commit ffc9e9d91d
193 changed files with 41382 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
import SwiftUI
import UIKit
enum AppIdentity {
static let name = "小浣熊阅读"
static let authorURL = URL(string: "https://github.com/Aminark")!
static let projectURL = URL(string: "https://github.com/Aminark/legado-ios")!
}
struct AboutView: View {
private var name: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? AppIdentity.name }
private var version: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "—" }
private var build: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "—" }
private var diagnosticSummary: String {
"\(name) \(version) (\(build))\niOS \(UIDevice.current.systemVersion)\n数据库版本 \(LegadoDatabase.schemaVersion)"
}
var body: some View {
List {
Section {
VStack(spacing: 12) {
Image("BrandIcon").resizable().scaledToFit()
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 18))
Text(name).font(.title2.weight(.semibold))
Text("快速搜书,自由阅读").font(.subheadline).foregroundStyle(.secondary)
Text("兼容阅读书源的 iOS 阅读应用")
.font(.footnote).foregroundStyle(.secondary)
}.frame(maxWidth: .infinity).padding(.vertical, 16)
ReadingLabeledContent("版本", value: version)
ReadingLabeledContent("构建", value: build)
ReadingLabeledContent("系统", value: "iOS \(UIDevice.current.systemVersion)")
ReadingShareLink(item: diagnosticSummary) { Label("分享版本信息", systemImage: "square.and.arrow.up") }
}
Section {
Link(destination: AppIdentity.authorURL) {
Label("开发者 · Aminark", systemImage: "person.crop.circle")
}
Link(destination: AppIdentity.projectURL) {
Label("项目源码 · legado-ios", systemImage: "chevron.left.forwardslash.chevron.right")
}
ReadingShareLink(item: AppIdentity.projectURL) {
Label("分享项目地址", systemImage: "square.and.arrow.up")
}
ReadingLabeledContent("许可证", value: "GPL-3.0")
ReadingLabeledContent("安装方式", value: "IPA 自行签名")
ReadingLabeledContent("App Store / TestFlight", value: "暂不提供")
} header: {
Text("项目与开发者")
} footer: {
Text("IPA 发布后可从项目 GitHub Releases 获取,并自行签名安装。项目暂不上架 App Store,也不提供 TestFlight。")
}
Section("开源许可") {
NavigationLink("开源许可") { OpenSourceLicensesView() }
}
Section("免责协议") {
Text("小浣熊阅读不提供、内置或分发网络书源,也不提供在线书库。书源由用户自行从网络公开分享获取,或自行编写后导入;在线书籍及订阅内容由对应的第三方网站提供。")
Text("书籍正文、封面、简介等内容的版权归原作者或合法权利人所有。书源可被导入、内容可被访问,不代表已获得版权授权;请仅在获得授权或法律允许的范围内使用,并支持正版阅读。")
Text("第三方书源及其内容不受本应用控制,可能因网站调整、网络环境等原因失效。本应用不保证第三方内容的准确性、完整性、安全性或书源的持续可用性,请自行核实来源。")
Text("用户应对自行导入的书源及使用行为负责,遵守适用法律法规和来源网站的使用规则,不得利用本应用侵犯他人权益、传播违法内容或未经授权复制、分发作品。")
Text("如发现涉嫌侵权内容,请停止访问、删除相关书源及本地缓存,并向内容来源网站反馈。本协议不免除任何一方依法应承担的责任。")
}
.font(.footnote)
.lineSpacing(4)
.textSelection(.enabled)
Section("数据与隐私") {
Text("书架、阅读进度、书源和诊断日志保存在本机。导入书源、阅读在线内容和刷新订阅时,会连接相应来源的网站。")
Text("应用不会自动上传诊断日志;可在日志页面手动导出。")
Text("应用不提供内置网络书源或在线书库。请使用你有权访问的内容与书源。")
}.font(.footnote)
}
.navigationTitle("关于")
.navigationBarTitleDisplayMode(.inline)
}
}
private struct OpenSourceLicensesView: View {
var body: some View {
List {
license("小浣熊阅读 / Legado", resource: "Legado-LICENSE", detail: "GNU GPL v3")
license("SwiftSoup", resource: "SwiftSoup-LICENSE", detail: "MIT License")
license("Kanna", resource: "Kanna-LICENSE", detail: "MIT License")
}.navigationTitle("开源许可").navigationBarTitleDisplayMode(.inline)
}
private func license(_ title: String, resource: String, detail: String) -> some View {
NavigationLink {
ScrollView {
Text((Bundle.main.url(forResource: resource, withExtension: "txt")
.flatMap { try? String(contentsOf: $0, encoding: .utf8) }) ?? "许可文件无法读取")
.font(.system(.footnote, design: .monospaced)).textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading).padding()
}.navigationTitle(title).navigationBarTitleDisplayMode(.inline)
} label: { ReadingLabeledContent(title, value: detail) }
}
}
@@ -0,0 +1,22 @@
import Foundation
import MetricKit
final class AppDiagnostics: NSObject, MXMetricManagerSubscriber, @unchecked Sendable {
static let shared = AppDiagnostics()
func start() {
MXMetricManager.shared.add(self)
DiagnosticLog.shared.record(category: "应用", message: "启动 \(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "")")
}
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
for crash in payload.crashDiagnostics ?? [] {
DiagnosticLog.shared.record(.error, category: "系统诊断", message: "崩溃诊断:\(String(decoding: crash.jsonRepresentation(), as: UTF8.self))")
}
for hang in payload.hangDiagnostics ?? [] {
DiagnosticLog.shared.record(.warning, category: "系统诊断", message: "卡顿诊断:\(String(decoding: hang.jsonRepresentation(), as: UTF8.self))")
}
}
}
}
@@ -0,0 +1,98 @@
import SwiftUI
import UniformTypeIdentifiers
struct DiagnosticLogView: View {
@State private var entries: [DiagnosticLogEntry] = []
@State private var query = ""
@State private var level = "all"
@State private var confirmsClear = false
@State private var exportsLog = false
@State private var exportText = ""
@State private var errorMessage: String?
private var filtered: [DiagnosticLogEntry] {
entries.filter {
(level == "all" || $0.level.rawValue == level)
&& (query.isEmpty || "\($0.category) \($0.message)".localizedCaseInsensitiveContains(query))
}
}
var body: some View {
List {
Section {
Picker("级别", selection: $level) {
Text("全部").tag("all")
Text("信息").tag("info")
Text("警告").tag("warning")
Text("错误").tag("error")
}.pickerStyle(.segmented)
Text("\(entries.count) 条日志 · 下拉刷新")
.font(.footnote).foregroundStyle(.secondary)
} footer: {
Text("在“其他设置”开启记录后,会保存网络请求、JS 输出和运行错误。崩溃诊断由 iOS 延迟提供,可搜索“系统诊断”查看。")
}
if filtered.isEmpty {
ReadingUnavailableView(query.isEmpty ? "暂无日志" : "没有匹配的日志", systemImage: "doc.text.magnifyingglass")
} else {
ForEach(filtered) { entry in
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(entry.category).font(.caption.weight(.semibold))
Text(levelTitle(entry.level)).font(.caption)
.foregroundStyle(entry.level == .error ? .red : .secondary)
Spacer()
Text(entry.date, format: .dateTime.month().day().hour().minute().second())
.font(.caption2).foregroundStyle(.secondary)
}
Text(entry.message).font(.system(.footnote, design: .monospaced)).textSelection(.enabled)
}.padding(.vertical, 3)
}
}
}
.navigationTitle("日志与诊断")
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $query, prompt: "搜索内容或分类")
.task { reload() }
.refreshable { reload() }
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu("日志操作", systemImage: "ellipsis") {
Button("刷新", systemImage: "arrow.clockwise") { reload() }
Button("导出全部日志", systemImage: "square.and.arrow.up") {
exportText = DiagnosticLog.shared.export()
exportsLog = true
}.disabled(entries.isEmpty)
Button("清空日志", systemImage: "trash", role: .destructive) { confirmsClear = true }
.disabled(entries.isEmpty)
}
}
}
.confirmationDialog("清空全部日志?", isPresented: $confirmsClear, titleVisibility: .visible) {
Button("清空日志", role: .destructive) {
do { try DiagnosticLog.shared.clear(); reload() }
catch { errorMessage = error.localizedDescription }
}
}
.fileExporter(isPresented: $exportsLog, document: DiagnosticLogDocument(text: exportText), contentType: .plainText, defaultFilename: "阅读诊断日志") { result in
if case .failure(let error) = result { errorMessage = error.localizedDescription }
}
.alert("日志操作失败", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
Button("好") { errorMessage = nil }
} message: { Text(errorMessage ?? "") }
}
private func reload() { entries = DiagnosticLog.shared.snapshot() }
private func levelTitle(_ level: DiagnosticLogEntry.Level) -> String {
switch level { case .info: "信息"; case .warning: "警告"; case .error: "错误" }
}
}
private struct DiagnosticLogDocument: FileDocument {
static var readableContentTypes: [UTType] { [.plainText] }
var text: String
init(text: String) { self.text = text }
init(configuration: ReadConfiguration) throws {
text = String(decoding: configuration.file.regularFileContents ?? Data(), as: UTF8.self)
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { .init(regularFileWithContents: Data(text.utf8)) }
}
@@ -0,0 +1,179 @@
import SwiftUI
import WebKit
struct OtherSettingsView: View {
@AppStorage("auto_refresh") private var autoRefresh = false
@AppStorage("onlyUpdateRead") private var onlyUpdateRead = false
@AppStorage("defaultHomePage") private var defaultHomePage = "bookshelf"
@State private var settings = ApplicationSettingsStore.shared.settings
@State private var maintenance: Maintenance?
@State private var isWorking = false
@State private var message: String?
private enum Maintenance: String, Identifiable {
case webCache = "清理网页缓存"
case database = "整理数据库"
var id: String { rawValue }
}
var body: some View {
Form {
Section("启动与书架") {
Picker("启动页面", selection: $defaultHomePage) {
Text("书架").tag("bookshelf")
Text("发现").tag("explore")
Text("统计").tag("statistics")
Text("设置").tag("settings")
}
Toggle("启动时更新书架", isOn: $autoRefresh)
Toggle("自动更新仅检查读过的书", isOn: $onlyUpdateRead).disabled(!autoRefresh)
}
Section("网络与存储") {
NavigationLink {
NetworkSettingsView()
} label: { Label("网络与缓存设置", systemImage: "network") }
NavigationLink {
CacheManagementView()
} label: { Label("章节缓存管理", systemImage: "internaldrive") }
Button("清理网页缓存", systemImage: "globe") { maintenance = .webCache }
Button("整理数据库", systemImage: "externaldrive.badge.checkmark") { maintenance = .database }
}
Section {
Toggle("记录运行日志", isOn: Binding(get: { settings.recordLog }, set: { enabled in
var value = ApplicationSettingsStore.shared.settings
value.recordLog = enabled
do { try ApplicationSettingsStore.shared.save(value); settings = value }
catch { message = error.localizedDescription }
}))
NavigationLink {
DiagnosticLogView()
} label: { Label("日志与诊断", systemImage: "doc.text.magnifyingglass") }
} header: { Text("诊断") } footer: {
Text("日志仅保存在本机,最多保留 500 条。网络日志不记录请求正文与 Header,URL 查询参数和常见凭据会隐藏。")
}
}
.navigationTitle("其他设置")
.navigationBarTitleDisplayMode(.inline)
.disabled(isWorking)
.overlay { if isWorking { ProgressView("正在处理…").padding().background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) } }
.onAppear { settings = ApplicationSettingsStore.shared.settings }
.confirmationDialog(maintenance?.rawValue ?? "", isPresented: Binding(
get: { maintenance != nil }, set: { if !$0 { maintenance = nil } }
), titleVisibility: .visible) {
if let operation = maintenance {
Button(operation.rawValue) { Task { await perform(operation) } }
}
Button("取消", role: .cancel) { maintenance = nil }
} message: {
Text(maintenance == .webCache ? "清理网页和图片的临时缓存,保留登录 Cookie、书架和阅读进度。" : "回收数据库中的空闲空间,保留全部书籍、规则和阅读记录。")
}
.alert("操作结果", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) {
Button("好") { message = nil }
} message: { Text(message ?? "") }
}
@MainActor private func perform(_ operation: Maintenance) async {
maintenance = nil
isWorking = true
defer { isWorking = false }
do {
switch operation {
case .webCache:
await WKWebsiteDataStore.default().removeData(ofTypes: [WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache, WKWebsiteDataTypeOfflineWebApplicationCache], modifiedSince: .distantPast)
URLCache.shared.removeAllCachedResponses()
case .database:
let database = try LegadoDatabase(url: LegadoDatabase.defaultURL())
try await database.compact()
}
DiagnosticLog.shared.record(category: "存储", message: "\(operation.rawValue)完成")
message = "\(operation.rawValue)完成"
} catch {
DiagnosticLog.shared.record(.error, category: "存储", message: error.localizedDescription)
message = error.localizedDescription
}
}
}
struct NetworkSettingsView: View {
@Environment(\.dismiss) private var dismiss
@State private var settings = ApplicationSettingsStore.shared.settings
@State private var errorMessage: String?
@State private var isSaving = false
var body: some View {
Form {
Section {
ReadingTextField("留空使用默认浏览器 UA", text: $settings.userAgent, axis: .vertical)
.textInputAutocapitalization(.never).disableAutocorrection(true)
.accessibilityLabel("User-Agent")
} header: { Text("默认 User-Agent") } footer: {
Text("书源或 RSS 单独设置的 UA 优先于此项。")
}
Section {
TextField("例如 http://127.0.0.1:7890", text: $settings.proxy)
.keyboardType(.URL).textInputAutocapitalization(.never).disableAutocorrection(true)
.accessibilityLabel("全局代理")
} header: { Text("全局代理") } footer: {
Text("支持 HTTP、HTTPS 和 SOCKS5 地址,不含账号密码。书源代理优先于全局代理;使用代理时由代理解析域名。")
}
Section {
TextEditor(text: $settings.hosts)
.font(.system(.footnote, design: .monospaced)).frame(minHeight: 130)
.textInputAutocapitalization(.never).disableAutocorrection(true)
.accessibilityLabel("Hosts")
} header: { Text("自定义 Hosts") } footer: {
Text("每行填写 IP地址 域名,例如 192.168.1.2 books.example.com。支持 IPv4、IPv6 和 # 注释。适用于应用内 HTTP/HTTPS 请求;系统网页、登录网页和系统媒体播放器使用系统网络设置。")
}
Section("连接") {
Picker("请求超时", selection: $settings.timeoutSeconds) {
Text("跟随书源").tag(0)
ForEach([5, 10, 15, 30, 60, 120, 180, 300], id: \.self) { Text("\($0) 秒").tag($0) }
}
Stepper("搜索并发数:\(settings.concurrentSources)", value: $settings.concurrentSources, in: 1...64)
Toggle("兼容无效证书的书源", isOn: $settings.ignoreCertificateErrors)
Text("证书兼容开启时允许自签名或过期证书;关闭后由系统验证 HTTPS 证书。")
.font(.footnote).foregroundStyle(.secondary)
}
Section("章节缓存") {
Picker("容量上限", selection: $settings.chapterCacheMB) {
ForEach([16, 32, 64, 128, 256, 512, 1024, 2048, 4096], id: \.self) {
Text($0 >= 1024 ? "\($0 / 1024) GB" : "\($0) MB").tag($0)
}
}
Stepper(settings.prefetchChapters == 0 ? "关闭章节预加载" : "预加载 \(settings.prefetchChapters) 章", value: $settings.prefetchChapters, in: 0...20)
Text("超出上限时清理最久未读取的章节;容量调整在保存时生效。")
.font(.footnote).foregroundStyle(.secondary)
}
Section {
Button("恢复本页默认值") {
let recordLog = settings.recordLog
settings = .init()
settings.recordLog = recordLog
}
}
}
.navigationTitle("网络与缓存")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("保存") { Task { await save() } }.disabled(isSaving)
}
}
.disabled(isSaving)
.alert("保存失败", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
Button("好") { errorMessage = nil }
} message: { Text(errorMessage ?? "") }
}
@MainActor private func save() async {
isSaving = true
defer { isSaving = false }
do {
settings.recordLog = ApplicationSettingsStore.shared.settings.recordLog
try ApplicationSettingsStore.shared.save(settings)
try await ChapterContentCache.live().pruneIfNeeded()
DiagnosticLog.shared.record(category: "设置", message: "网络与缓存设置已保存")
dismiss()
} catch { errorMessage = error.localizedDescription }
}
}