DeveloperLogOverlay.swift 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. import SwiftUI
  2. import UIKit
  3. struct DeveloperLogEntry: Identifiable, Equatable {
  4. enum Level: String {
  5. case info = "信息"
  6. case success = "成功"
  7. case warning = "警告"
  8. case error = "错误"
  9. case transmit = "发送"
  10. case receive = "接收"
  11. }
  12. let id = UUID()
  13. let timestamp: Date
  14. let category: String
  15. let level: Level
  16. let message: String
  17. }
  18. final class DeveloperLogStore: ObservableObject, @unchecked Sendable {
  19. static let shared = DeveloperLogStore()
  20. static let enabledKey = "com.celestia.trace.developer_mode"
  21. private static let maximumEntryCount = 2_000
  22. @Published private(set) var entries: [DeveloperLogEntry] = []
  23. private init() {}
  24. static func log(
  25. _ category: String,
  26. _ message: String,
  27. level: DeveloperLogEntry.Level = .info
  28. ) {
  29. guard UserDefaults.standard.bool(forKey: enabledKey) else { return }
  30. let entry = DeveloperLogEntry(
  31. timestamp: Date(),
  32. category: category,
  33. level: level,
  34. message: message
  35. )
  36. DispatchQueue.main.async {
  37. shared.entries.append(entry)
  38. if shared.entries.count > maximumEntryCount {
  39. shared.entries.removeFirst(shared.entries.count - maximumEntryCount)
  40. }
  41. }
  42. }
  43. func clear() {
  44. entries.removeAll()
  45. }
  46. var copyableText: String {
  47. entries.map { entry in
  48. "\(Self.timestampFormatter.string(from: entry.timestamp)) [\(entry.category)] [\(entry.level.rawValue)] \(entry.message)"
  49. }
  50. .joined(separator: "\n")
  51. }
  52. private static let timestampFormatter: DateFormatter = {
  53. let formatter = DateFormatter()
  54. formatter.locale = Locale(identifier: "zh_CN")
  55. formatter.dateFormat = "HH:mm:ss.SSS"
  56. return formatter
  57. }()
  58. }
  59. struct DeveloperLogOverlay: View {
  60. @ObservedObject private var logStore = DeveloperLogStore.shared
  61. @State private var isCollapsed = false
  62. @State private var dragOffset: CGSize = .zero
  63. @State private var dragStartOffset: CGSize = .zero
  64. var body: some View {
  65. VStack(spacing: 0) {
  66. header
  67. if !isCollapsed {
  68. Divider()
  69. .overlay(Color.white.opacity(0.12))
  70. ScrollViewReader { proxy in
  71. ScrollView {
  72. LazyVStack(alignment: .leading, spacing: 7) {
  73. if logStore.entries.isEmpty {
  74. Text("等待蓝牙、同步及 App 运行日志…")
  75. .foregroundStyle(.white.opacity(0.55))
  76. } else {
  77. ForEach(logStore.entries) { entry in
  78. logRow(entry)
  79. .id(entry.id)
  80. }
  81. }
  82. }
  83. .frame(maxWidth: .infinity, alignment: .leading)
  84. .padding(10)
  85. }
  86. .frame(height: 210)
  87. .onChange(of: logStore.entries.last?.id) { _, id in
  88. guard let id else { return }
  89. withAnimation(.easeOut(duration: 0.15)) {
  90. proxy.scrollTo(id, anchor: .bottom)
  91. }
  92. }
  93. }
  94. }
  95. }
  96. .containerRelativeFrame(.horizontal) { availableWidth, _ in
  97. min(max(availableWidth - 24, 240), 340)
  98. }
  99. .background(.black.opacity(0.88), in: RoundedRectangle(cornerRadius: 12))
  100. .overlay {
  101. RoundedRectangle(cornerRadius: 12)
  102. .stroke(Color.celestiaCyan.opacity(0.65), lineWidth: 1)
  103. }
  104. .shadow(color: .black.opacity(0.35), radius: 12, y: 5)
  105. .offset(dragOffset)
  106. .padding(.horizontal, 12)
  107. .padding(.bottom, 62)
  108. .accessibilityElement(children: .contain)
  109. .accessibilityLabel("开发者日志悬浮框")
  110. }
  111. private var header: some View {
  112. HStack(spacing: 10) {
  113. Circle()
  114. .fill(Color.green)
  115. .frame(width: 7, height: 7)
  116. Text("开发者日志")
  117. .font(.system(size: 13, weight: .semibold, design: .monospaced))
  118. .foregroundStyle(.white)
  119. Spacer()
  120. Text("\(logStore.entries.count)")
  121. .font(.system(size: 11, design: .monospaced))
  122. .foregroundStyle(.white.opacity(0.55))
  123. Button {
  124. UIPasteboard.general.string = logStore.copyableText
  125. } label: {
  126. Image(systemName: "doc.on.doc")
  127. }
  128. .disabled(logStore.entries.isEmpty)
  129. Button {
  130. logStore.clear()
  131. } label: {
  132. Image(systemName: "trash")
  133. }
  134. .disabled(logStore.entries.isEmpty)
  135. Button {
  136. withAnimation(.easeInOut(duration: 0.18)) {
  137. isCollapsed.toggle()
  138. }
  139. } label: {
  140. Image(systemName: isCollapsed ? "chevron.up" : "chevron.down")
  141. }
  142. }
  143. .font(.system(size: 12, weight: .medium))
  144. .foregroundStyle(Color.celestiaCyan)
  145. .padding(.horizontal, 11)
  146. .frame(height: 40)
  147. .contentShape(Rectangle())
  148. .gesture(
  149. DragGesture()
  150. .onChanged { value in
  151. dragOffset = CGSize(
  152. width: dragStartOffset.width + value.translation.width,
  153. height: dragStartOffset.height + value.translation.height
  154. )
  155. }
  156. .onEnded { _ in
  157. dragStartOffset = dragOffset
  158. }
  159. )
  160. }
  161. private func logRow(_ entry: DeveloperLogEntry) -> some View {
  162. VStack(alignment: .leading, spacing: 2) {
  163. HStack(spacing: 5) {
  164. Text(Self.timeFormatter.string(from: entry.timestamp))
  165. .foregroundStyle(.white.opacity(0.46))
  166. Text(entry.category)
  167. .foregroundStyle(Color.celestiaCyan)
  168. Text(entry.level.rawValue)
  169. .foregroundStyle(color(for: entry.level))
  170. }
  171. Text(entry.message)
  172. .foregroundStyle(.white.opacity(0.9))
  173. .fixedSize(horizontal: false, vertical: true)
  174. }
  175. .font(.system(size: 10, design: .monospaced))
  176. .textSelection(.enabled)
  177. }
  178. private func color(for level: DeveloperLogEntry.Level) -> Color {
  179. switch level {
  180. case .info: .white.opacity(0.7)
  181. case .success: .green
  182. case .warning: .yellow
  183. case .error: .red
  184. case .transmit: .orange
  185. case .receive: .mint
  186. }
  187. }
  188. private static let timeFormatter: DateFormatter = {
  189. let formatter = DateFormatter()
  190. formatter.locale = Locale(identifier: "zh_CN")
  191. formatter.dateFormat = "HH:mm:ss"
  192. return formatter
  193. }()
  194. }