mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-24 12:51:22 +08:00
feat: open-source 小浣熊阅读 for iOS
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user