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,225 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
import Vision
|
||||
import VisionKit
|
||||
|
||||
struct QRCodeImportView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let onValue: (String) -> Void
|
||||
@State private var cameraAuthorized = false
|
||||
@State private var cameraMessage = "正在检查相机…"
|
||||
@State private var values: [String] = []
|
||||
@State private var errorMessage: String?
|
||||
@State private var isReading = false
|
||||
@State private var scanError: String?
|
||||
@State private var delivered = false
|
||||
|
||||
var body: some View {
|
||||
ReadingNavigationStack {
|
||||
VStack(spacing: 16) {
|
||||
if cameraAuthorized, scanError == nil {
|
||||
cameraScanner
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||
.overlay(alignment: .bottom) {
|
||||
Text("将书源或净化规则二维码放入取景框").font(.footnote)
|
||||
.padding(10).background(.ultraThinMaterial, in: Capsule()).padding()
|
||||
}
|
||||
} else {
|
||||
ReadingUnavailableView("相机扫描暂不可用", systemImage: "qrcode.viewfinder",
|
||||
description: Text(scanError ?? cameraMessage))
|
||||
if AVCaptureDevice.authorizationStatus(for: .video) == .denied {
|
||||
Link("打开系统设置", destination: URL(string: UIApplication.openSettingsURLString)!)
|
||||
}
|
||||
}
|
||||
if isReading { ProgressView("识别图片中的二维码…") }
|
||||
ReadingPhotoPicker { data in
|
||||
readPhoto(data)
|
||||
} onError: { error in
|
||||
errorMessage = error.localizedDescription
|
||||
} label: {
|
||||
Label("从照片选择二维码", systemImage: "photo")
|
||||
.frame(maxWidth: .infinity)
|
||||
}.buttonStyle(.borderedProminent).disabled(isReading)
|
||||
Text("支持 HTTP(S) 链接、Legado 导入深链和 JSON 内容。识别后进入导入流程。")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
if values.count > 1 {
|
||||
List(values, id: \.self) { value in
|
||||
Button { accept(value) } label: { Text(value).font(.caption).lineLimit(3) }
|
||||
}.frame(maxHeight: 220)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("二维码导入")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } } }
|
||||
.task {
|
||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||
case .authorized: cameraAuthorized = true
|
||||
case .notDetermined: cameraAuthorized = await AVCaptureDevice.requestAccess(for: .video)
|
||||
default: break
|
||||
}
|
||||
cameraMessage = cameraAuthorized ? "当前设备不支持实时扫描,请从照片选择二维码。" : "未获得相机权限,仍可从照片选择二维码。"
|
||||
}
|
||||
.alert("识别失败", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
|
||||
Button("好", role: .cancel) {}
|
||||
} message: { Text(errorMessage ?? "") }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var cameraScanner: some View {
|
||||
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI,
|
||||
DataScannerViewController.isSupported, DataScannerViewController.isAvailable {
|
||||
QRScanner { accept($0) } onError: { scanError = $0 }
|
||||
} else {
|
||||
LegacyQRScanner { accept($0) } onError: { scanError = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
private func readPhoto(_ data: Data) {
|
||||
Task {
|
||||
isReading = true
|
||||
defer { isReading = false }
|
||||
do {
|
||||
let found = try await Task.detached(priority: .userInitiated) {
|
||||
try QRCodeDecoder().detect(in: data)
|
||||
}.value
|
||||
values = found
|
||||
if found.count == 1 { accept(found[0]) }
|
||||
else if found.isEmpty { errorMessage = "图片中没有识别到二维码,请选择清晰的原图。" }
|
||||
} catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
|
||||
private func accept(_ value: String) {
|
||||
guard !delivered else { return }
|
||||
delivered = true
|
||||
dismiss()
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 400_000_000)
|
||||
onValue(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
private struct QRScanner: UIViewControllerRepresentable {
|
||||
let onValue: (String) -> Void
|
||||
let onError: (String) -> Void
|
||||
func makeCoordinator() -> Coordinator { Coordinator(onValue: onValue) }
|
||||
func makeUIViewController(context: Context) -> DataScannerViewController {
|
||||
let scanner = DataScannerViewController(recognizedDataTypes: [.barcode(symbologies: [.qr])],
|
||||
qualityLevel: .balanced, recognizesMultipleItems: false, isHighFrameRateTrackingEnabled: false,
|
||||
isPinchToZoomEnabled: true, isGuidanceEnabled: true, isHighlightingEnabled: true)
|
||||
scanner.delegate = context.coordinator
|
||||
return scanner
|
||||
}
|
||||
func updateUIViewController(_ scanner: DataScannerViewController, context: Context) {
|
||||
guard !scanner.isScanning else { return }
|
||||
do { try scanner.startScanning() }
|
||||
catch { Task { @MainActor in onError(error.localizedDescription) } }
|
||||
}
|
||||
static func dismantleUIViewController(_ scanner: DataScannerViewController, coordinator: Coordinator) { scanner.stopScanning() }
|
||||
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
|
||||
let onValue: (String) -> Void
|
||||
init(onValue: @escaping (String) -> Void) { self.onValue = onValue }
|
||||
func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) {
|
||||
for item in addedItems {
|
||||
if case .barcode(let code) = item, let value = code.payloadStringValue {
|
||||
dataScanner.stopScanning()
|
||||
onValue(value)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LegacyQRScanner: UIViewControllerRepresentable {
|
||||
let onValue: (String) -> Void
|
||||
let onError: (String) -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> Controller {
|
||||
Controller(onValue: onValue, onError: onError)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ controller: Controller, context: Context) {}
|
||||
|
||||
static func dismantleUIViewController(_ controller: Controller, coordinator: Void) {
|
||||
controller.capture.stop()
|
||||
}
|
||||
|
||||
final class Controller: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
|
||||
let capture = VideoCapture()
|
||||
let onValue: (String) -> Void
|
||||
let onError: (String) -> Void
|
||||
private var preview: AVCaptureVideoPreviewLayer?
|
||||
private var didDeliver = false
|
||||
|
||||
init(onValue: @escaping (String) -> Void, onError: @escaping (String) -> Void) {
|
||||
self.onValue = onValue
|
||||
self.onError = onError
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
do {
|
||||
guard let device = AVCaptureDevice.default(for: .video) else { throw AVError(.deviceNotConnected) }
|
||||
let input = try AVCaptureDeviceInput(device: device)
|
||||
let output = AVCaptureMetadataOutput()
|
||||
capture.session.beginConfiguration()
|
||||
defer { capture.session.commitConfiguration() }
|
||||
guard capture.session.canAddInput(input), capture.session.canAddOutput(output) else {
|
||||
throw AVError(.deviceInUseByAnotherApplication)
|
||||
}
|
||||
capture.session.addInput(input)
|
||||
capture.session.addOutput(output)
|
||||
output.setMetadataObjectsDelegate(self, queue: .main)
|
||||
output.metadataObjectTypes = [.qr]
|
||||
let preview = AVCaptureVideoPreviewLayer(session: capture.session)
|
||||
preview.videoGravity = .resizeAspectFill
|
||||
view.layer.addSublayer(preview)
|
||||
self.preview = preview
|
||||
} catch {
|
||||
let message = error.localizedDescription
|
||||
DispatchQueue.main.async { self.onError(message) }
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
preview?.frame = view.bounds
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
if preview != nil { capture.start() }
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
capture.stop()
|
||||
}
|
||||
|
||||
nonisolated func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput objects: [AVMetadataObject], from connection: AVCaptureConnection) {
|
||||
guard let value = objects.compactMap({ ($0 as? AVMetadataMachineReadableCodeObject)?.stringValue }).first else { return }
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, !didDeliver else { return }
|
||||
didDeliver = true
|
||||
capture.stop()
|
||||
onValue(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class VideoCapture: @unchecked Sendable {
|
||||
let session = AVCaptureSession()
|
||||
private let queue = DispatchQueue(label: "com.epiboly.legado.qr-camera")
|
||||
func start() { queue.async { self.session.startRunning() } }
|
||||
func stop() { queue.async { self.session.stopRunning() } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct SyntaxCodeEditor: UIViewRepresentable {
|
||||
enum Language { case json, rule, javascript, regex }
|
||||
@Binding var text: String
|
||||
var language: Language = .rule
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
||||
|
||||
func makeUIView(context: Context) -> UITextView {
|
||||
let view = UITextView()
|
||||
view.delegate = context.coordinator
|
||||
view.font = .monospacedSystemFont(ofSize: 14, weight: .regular)
|
||||
view.backgroundColor = .secondarySystemGroupedBackground
|
||||
view.textContainerInset = UIEdgeInsets(top: 14, left: 12, bottom: 20, right: 12)
|
||||
view.autocorrectionType = .no
|
||||
view.autocapitalizationType = .none
|
||||
view.smartQuotesType = .no
|
||||
view.smartDashesType = .no
|
||||
view.smartInsertDeleteType = .no
|
||||
view.keyboardDismissMode = .interactive
|
||||
view.alwaysBounceVertical = true
|
||||
view.accessibilityIdentifier = "syntaxCodeEditor"
|
||||
if #available(iOS 16.0, *) { view.isFindInteractionEnabled = true }
|
||||
let bar = UIToolbar()
|
||||
bar.sizeToFit()
|
||||
bar.items = [
|
||||
UIBarButtonItem(image: UIImage(systemName: "magnifyingglass"), primaryAction: UIAction(title: "", subtitle: nil) { [weak view, coordinator = context.coordinator] _ in
|
||||
guard let view else { return }
|
||||
if #available(iOS 16.0, *) { view.findInteraction?.presentFindNavigator(showingReplace: true) }
|
||||
else { coordinator.presentFindAndReplace(in: view) }
|
||||
}),
|
||||
UIBarButtonItem(image: UIImage(systemName: "arrow.uturn.backward"), primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.undoManager?.undo() }),
|
||||
UIBarButtonItem(image: UIImage(systemName: "arrow.uturn.forward"), primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.undoManager?.redo() }),
|
||||
] + ["@", "$", "{}", "[]", "##", "\n", "\t"].map { symbol in
|
||||
UIBarButtonItem(title: symbol == "\n" ? "↵" : symbol == "\t" ? "⇥" : symbol,
|
||||
primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.insertText(symbol) })
|
||||
} + [UIBarButtonItem(systemItem: .flexibleSpace), UIBarButtonItem(title: "完成", primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.resignFirstResponder() })]
|
||||
view.inputAccessoryView = bar
|
||||
view.text = text
|
||||
context.coordinator.highlight(view)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ view: UITextView, context: Context) {
|
||||
context.coordinator.parent = self
|
||||
guard view.markedTextRange == nil else { return }
|
||||
// SwiftUI can render an older binding snapshot while the next native
|
||||
// keystroke is already in the text view. Wait for acknowledgement of
|
||||
// the latest local edit instead of replacing it with that snapshot.
|
||||
if let pending = context.coordinator.pendingLocalText {
|
||||
guard text == pending else { return }
|
||||
context.coordinator.pendingLocalText = nil
|
||||
}
|
||||
if view.text != text {
|
||||
let selection = view.selectedRange
|
||||
view.text = text
|
||||
view.selectedRange = NSRange(location: min(selection.location, view.text.utf16.count), length: 0)
|
||||
}
|
||||
context.coordinator.highlight(view)
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, UITextViewDelegate {
|
||||
var parent: SyntaxCodeEditor
|
||||
private var highlightedText: String?
|
||||
private var highlightedStyle: UIUserInterfaceStyle?
|
||||
private var highlightedLanguage: Language?
|
||||
var pendingLocalText: String?
|
||||
init(_ parent: SyntaxCodeEditor) { self.parent = parent }
|
||||
|
||||
func presentFindAndReplace(in view: UITextView) {
|
||||
guard var presenter = view.window?.rootViewController else { return }
|
||||
while let presented = presenter.presentedViewController { presenter = presented }
|
||||
let alert = UIAlertController(title: "查找与替换", message: nil, preferredStyle: .alert)
|
||||
alert.addTextField { $0.placeholder = "查找内容" }
|
||||
alert.addTextField { $0.placeholder = "替换为(可留空)" }
|
||||
alert.addAction(UIAlertAction(title: "查找下一个", style: .default) { [weak view, weak alert] _ in
|
||||
guard let view, let query = alert?.textFields?.first?.text, !query.isEmpty else { return }
|
||||
let text = (view.text ?? "") as NSString
|
||||
let start = min(NSMaxRange(view.selectedRange), text.length)
|
||||
var range = text.range(of: query, range: NSRange(location: start, length: text.length - start))
|
||||
if range.location == NSNotFound { range = text.range(of: query) }
|
||||
guard range.location != NSNotFound else { return }
|
||||
view.becomeFirstResponder()
|
||||
view.selectedRange = range
|
||||
view.scrollRangeToVisible(range)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "全部替换", style: .default) { [weak view, weak alert] _ in
|
||||
guard let view, let fields = alert?.textFields,
|
||||
let query = fields.first?.text, !query.isEmpty else { return }
|
||||
let original = view.text ?? ""
|
||||
let replacement = original.replacingOccurrences(of: query, with: fields.last?.text ?? "")
|
||||
guard replacement != original else { return }
|
||||
view.becomeFirstResponder()
|
||||
view.selectedRange = NSRange(location: 0, length: original.utf16.count)
|
||||
view.insertText(replacement)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
presenter.present(alert, animated: true)
|
||||
}
|
||||
|
||||
func textViewDidChange(_ view: UITextView) {
|
||||
guard view.markedTextRange == nil else { return }
|
||||
pendingLocalText = view.text
|
||||
parent.text = view.text
|
||||
highlight(view)
|
||||
}
|
||||
|
||||
func textViewDidEndEditing(_ view: UITextView) {
|
||||
pendingLocalText = nil
|
||||
parent.text = view.text
|
||||
highlight(view)
|
||||
}
|
||||
|
||||
func highlight(_ view: UITextView) {
|
||||
guard view.markedTextRange == nil,
|
||||
highlightedText != view.text || highlightedStyle != view.traitCollection.userInterfaceStyle || highlightedLanguage != parent.language else { return }
|
||||
highlightedText = view.text
|
||||
highlightedStyle = view.traitCollection.userInterfaceStyle
|
||||
highlightedLanguage = parent.language
|
||||
let storage = view.textStorage
|
||||
let selection = view.selectedRange
|
||||
let all = NSRange(location: 0, length: storage.length)
|
||||
storage.beginEditing()
|
||||
storage.addAttributes([.font: UIFont.monospacedSystemFont(ofSize: 14, weight: .regular), .foregroundColor: UIColor.label], range: all)
|
||||
if storage.length <= 500_000 {
|
||||
let tokens: [(String, UIColor)] = [
|
||||
(#"\b(?:true|false|null|undefined|return|var|let|const|if|else|function|new|for|while|try|catch|throw|await|async)\b"#, .systemPurple),
|
||||
(#"\b\d+(?:\.\d+)?\b"#, .systemOrange),
|
||||
(#"@(?:css|xpath|json|js|put|get):?|\$\.|&&|\|\||%%|##|\{\{|\}\}"#, .systemPink),
|
||||
(#"[{}\[\]()]"#, .systemTeal),
|
||||
(#""(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`"#, .systemGreen),
|
||||
(#""(?:\\.|[^"\\])*"(?=\s*:)"#, .systemBlue),
|
||||
]
|
||||
for (pattern, color) in tokens {
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { continue }
|
||||
regex.enumerateMatches(in: view.text, range: all) { result, _, _ in
|
||||
if let result { storage.addAttribute(.foregroundColor, value: color, range: result.range) }
|
||||
}
|
||||
}
|
||||
if parent.language == .regex {
|
||||
for pattern in [#"[.*+?^$|]"#, #"\\[dDsSwWbB0-9]|\{[0-9,]+\}"#] {
|
||||
let regex = try? NSRegularExpression(pattern: pattern)
|
||||
regex?.enumerateMatches(in: view.text, range: all) { result, _, _ in
|
||||
if let result { storage.addAttribute(.foregroundColor, value: UIColor.systemPink, range: result.range) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if parent.language == .javascript {
|
||||
let pattern = #"(?m)^\s*//.*$|/\*[\s\S]*?\*/"#
|
||||
let regex = try? NSRegularExpression(pattern: pattern)
|
||||
regex?.enumerateMatches(in: view.text, range: all) { result, _, _ in
|
||||
if let result { storage.addAttribute(.foregroundColor, value: UIColor.secondaryLabel, range: result.range) }
|
||||
}
|
||||
}
|
||||
}
|
||||
storage.endEditing()
|
||||
view.selectedRange = selection
|
||||
view.typingAttributes = [.font: UIFont.monospacedSystemFont(ofSize: 14, weight: .regular), .foregroundColor: UIColor.label]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RuleCodeFieldView: View {
|
||||
let title: String
|
||||
let path: String
|
||||
@Binding var text: String
|
||||
var language: SyntaxCodeEditor.Language = .rule
|
||||
var help: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Text(path).font(.caption.monospaced()).foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading).padding(12)
|
||||
if let help {
|
||||
Text(help).font(.footnote).foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 12).padding(.bottom, 10)
|
||||
}
|
||||
SyntaxCodeEditor(text: $text, language: language)
|
||||
HStack {
|
||||
Text("\(text.components(separatedBy: "\n").count) 行 · \(text.utf16.count) 字符")
|
||||
Spacer()
|
||||
if language == .rule { Text("CSS · XPath · JSONPath · JS") }
|
||||
}.font(.caption).foregroundStyle(.secondary).padding(12)
|
||||
}
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user