| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216 |
- import SwiftUI
- import UIKit
- struct DeveloperLogEntry: Identifiable, Equatable {
- enum Level: String {
- case info = "信息"
- case success = "成功"
- case warning = "警告"
- case error = "错误"
- case transmit = "发送"
- case receive = "接收"
- }
- let id = UUID()
- let timestamp: Date
- let category: String
- let level: Level
- let message: String
- }
- final class DeveloperLogStore: ObservableObject, @unchecked Sendable {
- static let shared = DeveloperLogStore()
- static let enabledKey = "com.celestia.trace.developer_mode"
- private static let maximumEntryCount = 2_000
- @Published private(set) var entries: [DeveloperLogEntry] = []
- private init() {}
- static func log(
- _ category: String,
- _ message: String,
- level: DeveloperLogEntry.Level = .info
- ) {
- guard UserDefaults.standard.bool(forKey: enabledKey) else { return }
- let entry = DeveloperLogEntry(
- timestamp: Date(),
- category: category,
- level: level,
- message: message
- )
- DispatchQueue.main.async {
- shared.entries.append(entry)
- if shared.entries.count > maximumEntryCount {
- shared.entries.removeFirst(shared.entries.count - maximumEntryCount)
- }
- }
- }
- func clear() {
- entries.removeAll()
- }
- var copyableText: String {
- entries.map { entry in
- "\(Self.timestampFormatter.string(from: entry.timestamp)) [\(entry.category)] [\(entry.level.rawValue)] \(entry.message)"
- }
- .joined(separator: "\n")
- }
- private static let timestampFormatter: DateFormatter = {
- let formatter = DateFormatter()
- formatter.locale = Locale(identifier: "zh_CN")
- formatter.dateFormat = "HH:mm:ss.SSS"
- return formatter
- }()
- }
- struct DeveloperLogOverlay: View {
- @ObservedObject private var logStore = DeveloperLogStore.shared
- @State private var isCollapsed = false
- @State private var dragOffset: CGSize = .zero
- @State private var dragStartOffset: CGSize = .zero
- var body: some View {
- VStack(spacing: 0) {
- header
- if !isCollapsed {
- Divider()
- .overlay(Color.white.opacity(0.12))
- ScrollViewReader { proxy in
- ScrollView {
- LazyVStack(alignment: .leading, spacing: 7) {
- if logStore.entries.isEmpty {
- Text("等待蓝牙、同步及 App 运行日志…")
- .foregroundStyle(.white.opacity(0.55))
- } else {
- ForEach(logStore.entries) { entry in
- logRow(entry)
- .id(entry.id)
- }
- }
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(10)
- }
- .frame(height: 210)
- .onChange(of: logStore.entries.last?.id) { _, id in
- guard let id else { return }
- withAnimation(.easeOut(duration: 0.15)) {
- proxy.scrollTo(id, anchor: .bottom)
- }
- }
- }
- }
- }
- .containerRelativeFrame(.horizontal) { availableWidth, _ in
- min(max(availableWidth - 24, 240), 340)
- }
- .background(.black.opacity(0.88), in: RoundedRectangle(cornerRadius: 12))
- .overlay {
- RoundedRectangle(cornerRadius: 12)
- .stroke(Color.celestiaCyan.opacity(0.65), lineWidth: 1)
- }
- .shadow(color: .black.opacity(0.35), radius: 12, y: 5)
- .offset(dragOffset)
- .padding(.horizontal, 12)
- .padding(.bottom, 62)
- .accessibilityElement(children: .contain)
- .accessibilityLabel("开发者日志悬浮框")
- }
- private var header: some View {
- HStack(spacing: 10) {
- Circle()
- .fill(Color.green)
- .frame(width: 7, height: 7)
- Text("开发者日志")
- .font(.system(size: 13, weight: .semibold, design: .monospaced))
- .foregroundStyle(.white)
- Spacer()
- Text("\(logStore.entries.count)")
- .font(.system(size: 11, design: .monospaced))
- .foregroundStyle(.white.opacity(0.55))
- Button {
- UIPasteboard.general.string = logStore.copyableText
- } label: {
- Image(systemName: "doc.on.doc")
- }
- .disabled(logStore.entries.isEmpty)
- Button {
- logStore.clear()
- } label: {
- Image(systemName: "trash")
- }
- .disabled(logStore.entries.isEmpty)
- Button {
- withAnimation(.easeInOut(duration: 0.18)) {
- isCollapsed.toggle()
- }
- } label: {
- Image(systemName: isCollapsed ? "chevron.up" : "chevron.down")
- }
- }
- .font(.system(size: 12, weight: .medium))
- .foregroundStyle(Color.celestiaCyan)
- .padding(.horizontal, 11)
- .frame(height: 40)
- .contentShape(Rectangle())
- .gesture(
- DragGesture()
- .onChanged { value in
- dragOffset = CGSize(
- width: dragStartOffset.width + value.translation.width,
- height: dragStartOffset.height + value.translation.height
- )
- }
- .onEnded { _ in
- dragStartOffset = dragOffset
- }
- )
- }
- private func logRow(_ entry: DeveloperLogEntry) -> some View {
- VStack(alignment: .leading, spacing: 2) {
- HStack(spacing: 5) {
- Text(Self.timeFormatter.string(from: entry.timestamp))
- .foregroundStyle(.white.opacity(0.46))
- Text(entry.category)
- .foregroundStyle(Color.celestiaCyan)
- Text(entry.level.rawValue)
- .foregroundStyle(color(for: entry.level))
- }
- Text(entry.message)
- .foregroundStyle(.white.opacity(0.9))
- .fixedSize(horizontal: false, vertical: true)
- }
- .font(.system(size: 10, design: .monospaced))
- .textSelection(.enabled)
- }
- private func color(for level: DeveloperLogEntry.Level) -> Color {
- switch level {
- case .info: .white.opacity(0.7)
- case .success: .green
- case .warning: .yellow
- case .error: .red
- case .transmit: .orange
- case .receive: .mint
- }
- }
- private static let timeFormatter: DateFormatter = {
- let formatter = DateFormatter()
- formatter.locale = Locale(identifier: "zh_CN")
- formatter.dateFormat = "HH:mm:ss"
- return formatter
- }()
- }
|