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
@@ -0,0 +1,91 @@
import Foundation
public enum ReplaceRuleImportError: Error, LocalizedError, Equatable {
case emptyDocument
case documentTooLarge(Int)
case invalidDocument(String)
case emptyPattern(String)
public var errorDescription: String? {
switch self {
case .emptyDocument: "净化规则内容为空"
case .documentTooLarge(let maximum): "净化规则文件超过 \(maximum / 1_024 / 1_024) MB 上限"
case .invalidDocument(let detail): "无法解析净化规则 JSON:\(detail)"
case .emptyPattern(let name): "净化规则“\(name)”没有匹配内容"
}
}
}
public struct ReplaceRuleImporter: Sendable {
public static let maximumDocumentBytes = 10 * 1_024 * 1_024
public init() {}
public func decode(_ data: Data) throws -> [ReplaceRule] {
guard data.count <= Self.maximumDocumentBytes else {
throw ReplaceRuleImportError.documentTooLarge(Self.maximumDocumentBytes)
}
let bom = Data([0xEF, 0xBB, 0xBF])
let cleaned = data.starts(with: bom) ? Data(data.dropFirst(3)) : data
guard !cleaned.isEmpty else { throw ReplaceRuleImportError.emptyDocument }
do {
let decoder = JSONDecoder()
let rules = if let array = try? decoder.decode([ReplaceRule].self, from: cleaned) {
array
} else {
[try decoder.decode(ReplaceRule.self, from: cleaned)]
}
for rule in rules { try ReplaceRuleValidator.validate(rule) }
return rules
} catch let error as ReplaceRuleImportError {
throw error
} catch {
throw ReplaceRuleImportError.invalidDocument(error.localizedDescription)
}
}
public func merge(_ incoming: [ReplaceRule], into existing: [ReplaceRule]) -> [ReplaceRule] {
var result = existing
var indexes = Dictionary(uniqueKeysWithValues: result.enumerated().map { ($1.id, $0) })
for rule in incoming {
if let index = indexes[rule.id] {
result[index] = rule
} else {
indexes[rule.id] = result.count
result.append(rule)
}
}
return result.sorted { $0.sortOrder < $1.sortOrder }
}
}
public protocol ReplaceRuleRepository: Sendable {
func load() async throws -> [ReplaceRule]
func save(_ rules: [ReplaceRule]) async throws
}
public actor FileReplaceRuleRepository: ReplaceRuleRepository {
private let fileURL: URL
public init(fileURL: URL) { self.fileURL = fileURL }
public func load() throws -> [ReplaceRule] {
guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] }
return try JSONDecoder().decode([ReplaceRule].self, from: Data(contentsOf: fileURL))
}
public func save(_ rules: [ReplaceRule]) throws {
let directory = fileURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try encoder.encode(rules).write(to: fileURL, options: .atomic)
}
}
public actor InMemoryReplaceRuleRepository: ReplaceRuleRepository {
private var rules: [ReplaceRule]
public init(rules: [ReplaceRule] = []) { self.rules = rules }
public func load() -> [ReplaceRule] { rules }
public func save(_ rules: [ReplaceRule]) { self.rules = rules }
}