IOS Companion for @LimesKey/Serviceberry (on github)
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

more work

+470 -56
+4 -4
serviceberry.xcodeproj/project.pbxproj
··· 255 255 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 256 256 CODE_SIGN_IDENTITY = "Apple Development"; 257 257 CODE_SIGN_STYLE = Automatic; 258 - CURRENT_PROJECT_VERSION = 1; 258 + CURRENT_PROJECT_VERSION = 7; 259 259 DEVELOPMENT_TEAM = M67B42LX8D; 260 260 ENABLE_PREVIEWS = YES; 261 261 GENERATE_INFOPLIST_FILE = NO; ··· 267 267 INFOPLIST_KEY_UILaunchScreen_Generation = YES; 268 268 INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 269 269 INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 270 - IPHONEOS_DEPLOYMENT_TARGET = 16.6; 270 + IPHONEOS_DEPLOYMENT_TARGET = 17.6; 271 271 LD_RUNPATH_SEARCH_PATHS = ( 272 272 "$(inherited)", 273 273 "@executable_path/Frameworks", ··· 297 297 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 298 298 CODE_SIGN_IDENTITY = "Apple Development"; 299 299 CODE_SIGN_STYLE = Automatic; 300 - CURRENT_PROJECT_VERSION = 1; 300 + CURRENT_PROJECT_VERSION = 7; 301 301 DEVELOPMENT_TEAM = M67B42LX8D; 302 302 ENABLE_PREVIEWS = YES; 303 303 GENERATE_INFOPLIST_FILE = NO; ··· 309 309 INFOPLIST_KEY_UILaunchScreen_Generation = YES; 310 310 INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 311 311 INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 312 - IPHONEOS_DEPLOYMENT_TARGET = 16.6; 312 + IPHONEOS_DEPLOYMENT_TARGET = 17.6; 313 313 LD_RUNPATH_SEARCH_PATHS = ( 314 314 "$(inherited)", 315 315 "@executable_path/Frameworks",
+8 -1
serviceberry/Services/BLETransport.swift
··· 34 34 func startScanning() { 35 35 guard centralManager.state == .poweredOn else { return } 36 36 discoveredPeripherals = [] 37 + LogManager.shared.info("Scanning for BLE peripherals", source: "BLE") 37 38 centralManager.scanForPeripherals( 38 39 withServices: [Constants.serviceUUID], 39 40 options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] ··· 129 130 func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, 130 131 advertisementData: [String: Any], rssi RSSI: NSNumber) { 131 132 if !discoveredPeripherals.contains(where: { $0.identifier == peripheral.identifier }) { 133 + LogManager.shared.debug("Discovered: \(peripheral.name ?? "Unknown") (RSSI: \(RSSI))", source: "BLE") 132 134 discoveredPeripherals.append(peripheral) 133 135 } 134 136 } 135 137 136 138 func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { 139 + LogManager.shared.info("Connected to \(peripheral.name ?? "Unknown")", source: "BLE") 137 140 peripheral.delegate = self 138 141 peripheral.discoverServices([Constants.serviceUUID]) 139 142 } 140 143 141 144 func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { 145 + LogManager.shared.error("Connection failed: \(error?.localizedDescription ?? "Unknown")", source: "BLE") 142 146 updateState(.error(error?.localizedDescription ?? "Connection failed")) 143 147 connectionContinuation?.resume(throwing: TransportError.connectionFailed(error?.localizedDescription ?? "Unknown error")) 144 148 connectionContinuation = nil 145 149 } 146 150 147 151 func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { 152 + LogManager.shared.warning("Disconnected from peripheral", source: "BLE") 148 153 updateState(.disconnected) 149 154 characteristic = nil 150 155 } ··· 219 224 220 225 func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { 221 226 if let error = error { 222 - print("Failed to subscribe to notifications: \(error.localizedDescription)") 227 + LogManager.shared.error("Failed to subscribe to notifications: \(error.localizedDescription)", source: "BLE") 228 + } else { 229 + LogManager.shared.debug("Subscribed to notifications", source: "BLE") 223 230 } 224 231 } 225 232 }
+21 -4
serviceberry/Services/LANTransport.swift
··· 35 35 throw TransportError.connectionFailed("Invalid server URL") 36 36 } 37 37 38 + Task { @MainActor in 39 + LogManager.shared.info("Connecting to \(serverInfo.host):\(serverInfo.port)", source: "LAN") 40 + } 41 + 38 42 do { 39 43 let (_, response) = try await session.data(from: statusURL) 40 44 ··· 43 47 throw TransportError.connectionFailed("Server returned error") 44 48 } 45 49 50 + Task { @MainActor in 51 + LogManager.shared.info("Connected successfully", source: "LAN") 52 + } 46 53 updateState(.connected) 47 54 startPolling() 48 55 } catch { 56 + Task { @MainActor in 57 + LogManager.shared.error("Connection failed: \(error.localizedDescription)", source: "LAN") 58 + } 49 59 updateState(.error(error.localizedDescription)) 50 60 throw error 51 61 } ··· 104 114 // Check if server is requesting location 105 115 if let responseText = String(data: data, encoding: .utf8), 106 116 responseText.lowercased().contains("request") { 117 + Task { @MainActor in 118 + LogManager.shared.debug("Server requested location", source: "LAN") 119 + } 107 120 await onLocationRequest?() 108 121 } 109 122 } catch { 110 123 // Polling error - don't disconnect, just log 111 - print("Polling error: \(error.localizedDescription)") 124 + Task { @MainActor in 125 + LogManager.shared.warning("Poll error: \(error.localizedDescription)", source: "LAN") 126 + } 112 127 } 113 128 } 114 129 ··· 155 170 let credential = URLCredential(trust: serverTrust) 156 171 completionHandler(.useCredential, credential) 157 172 } else { 158 - print("Certificate mismatch!") 159 - print("Expected: \(serverInfo.certFingerprint)") 160 - print("Got: \(serverFingerprint)") 173 + Task { @MainActor in 174 + LogManager.shared.error("Certificate mismatch!", source: "LAN") 175 + LogManager.shared.debug("Expected: \(serverInfo.certFingerprint)", source: "LAN") 176 + LogManager.shared.debug("Got: \(serverFingerprint)", source: "LAN") 177 + } 161 178 completionHandler(.cancelAuthenticationChallenge, nil) 162 179 } 163 180 }
+68 -40
serviceberry/Services/MDNSDiscovery.swift
··· 3 3 import Combine 4 4 5 5 /// Service for discovering Serviceberry servers via mDNS/Bonjour 6 + /// Delegate for NetService resolution 7 + class ServiceResolverDelegate: NSObject, NetServiceDelegate { 8 + private let completion: (String?, UInt16) -> Void 9 + 10 + init(completion: @escaping (String?, UInt16) -> Void) { 11 + self.completion = completion 12 + super.init() 13 + } 14 + 15 + func netServiceDidResolveAddress(_ sender: NetService) { 16 + let host = sender.hostName?.trimmingCharacters(in: CharacterSet(charactersIn: ".")) ?? sender.hostName 17 + let port = UInt16(sender.port) 18 + completion(host, port) 19 + } 20 + 21 + func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) { 22 + LogManager.shared.error("NetService resolution error: \(errorDict)", source: "mDNS") 23 + completion(nil, 0) 24 + } 25 + } 26 + 6 27 @MainActor 7 28 class MDNSDiscovery: ObservableObject { 8 29 private var browser: NWBrowser? 9 30 private var connections: [NWConnection] = [] 31 + private var activeNetServices: [NetService] = [] 32 + private var resolverDelegates: [ServiceResolverDelegate] = [] 10 33 11 34 @Published var discoveredServers: [ServerInfo] = [] 12 35 @Published var isSearching = false ··· 37 60 let parameters = NWParameters() 38 61 parameters.includePeerToPeer = true 39 62 40 - print("[mDNS] Creating browser for type: \(Constants.bonjourServiceType)") 63 + LogManager.shared.info("Starting mDNS browser for: \(Constants.bonjourServiceType)", source: "mDNS") 41 64 browser = NWBrowser(for: descriptor, using: parameters) 42 65 43 66 browser?.stateUpdateHandler = { [weak self] state in ··· 45 68 guard let self else { return } 46 69 switch state { 47 70 case .ready: 48 - print("[mDNS] ✅ Browser ready, searching for \(Constants.bonjourServiceType)") 71 + LogManager.shared.info("Browser ready", source: "mDNS") 49 72 self.debugState = "ready" 50 73 case .failed(let error): 51 - print("[mDNS] ❌ Browser failed: \(error)") 52 - print("[mDNS] ❌ Error code: \(error.debugDescription)") 74 + LogManager.shared.error("Browser failed: \(error.localizedDescription)", source: "mDNS") 53 75 self.debugState = "failed: \(error.localizedDescription)" 54 76 self.lastError = error 55 77 self.isSearching = false ··· 59 81 self.debugState = "retrying (\(self.retryCount)/\(self.maxRetries))..." 60 82 Task { 61 83 try? await Task.sleep(nanoseconds: 2_000_000_000) 62 - print("[mDNS] Auto-retrying (\(self.retryCount)/\(self.maxRetries))...") 84 + LogManager.shared.info("Auto-retrying (\(self.retryCount)/\(self.maxRetries))...", source: "mDNS") 63 85 self.startBrowsingInternal() 64 86 } 65 87 } 66 88 case .cancelled: 67 - print("[mDNS] Browser cancelled") 89 + LogManager.shared.debug("Browser cancelled", source: "mDNS") 68 90 self.debugState = "cancelled" 69 91 self.isSearching = false 70 92 case .waiting(let error): 71 - print("[mDNS] ⏳ Browser waiting: \(error)") 93 + LogManager.shared.warning("Browser waiting: \(error.localizedDescription)", source: "mDNS") 72 94 self.debugState = "waiting: \(error.localizedDescription)" 73 95 case .setup: 74 - print("[mDNS] Browser setup...") 96 + LogManager.shared.debug("Browser setup...", source: "mDNS") 75 97 self.debugState = "setup" 76 98 @unknown default: 77 - print("[mDNS] Browser unknown state: \(state)") 99 + LogManager.shared.warning("Browser unknown state", source: "mDNS") 78 100 self.debugState = "unknown" 79 101 } 80 102 } ··· 83 105 browser?.browseResultsChangedHandler = { [weak self] results, _ in 84 106 Task { @MainActor [weak self] in 85 107 guard let self else { return } 86 - print("[mDNS] Found \(results.count) service(s)") 108 + LogManager.shared.info("Found \(results.count) service(s)", source: "mDNS") 87 109 self.processBrowseResults(results) 88 110 } 89 111 } ··· 99 121 conn.cancel() 100 122 } 101 123 connections = [] 124 + for service in activeNetServices { 125 + service.stop() 126 + } 127 + activeNetServices = [] 128 + resolverDelegates = [] 102 129 isSearching = false 103 130 debugState = "stopped" 104 131 } ··· 106 133 private func processBrowseResults(_ results: Set<NWBrowser.Result>) { 107 134 for result in results { 108 135 guard case .service(let name, let type, let domain, _) = result.endpoint else { continue } 109 - print("[mDNS] Service: '\(name)' type: '\(type)' domain: '\(domain)'") 136 + LogManager.shared.debug("Service: '\(name)' type: '\(type)' domain: '\(domain)'", source: "mDNS") 110 137 111 138 // Extract TXT records from metadata 112 139 var version = "unknown" ··· 120 147 if let pathsStr = dict["paths"] { 121 148 paths = pathsStr.components(separatedBy: ", ") 122 149 } 150 + LogManager.shared.debug("TXT: \(dict)", source: "mDNS") 123 151 } 124 152 125 - // Resolve the service to get the actual host 153 + // Resolve the service to get actual hostname 126 154 resolveService( 127 155 name: name, 128 156 type: type, ··· 142 170 paths: [String], 143 171 certFingerprint: String 144 172 ) { 145 - // Create a connection to resolve the service endpoint 146 - let endpoint = NWEndpoint.service(name: name, type: type, domain: domain, interface: nil) 147 - let parameters = NWParameters.tcp 148 - let connection = NWConnection(to: endpoint, using: parameters) 173 + LogManager.shared.debug("Resolving service '\(name)'...", source: "mDNS") 149 174 150 - connection.stateUpdateHandler = { [weak self] state in 151 - Task { @MainActor [weak self] in 152 - guard let self else { return } 175 + // Use NetService for resolution (more reliable than NWConnection for mDNS) 176 + let netService = NetService(domain: domain, type: type, name: name) 177 + let delegate = ServiceResolverDelegate { [weak self] host, port in 178 + Task { @MainActor in 179 + guard let self = self else { return } 153 180 154 - switch state { 155 - case .ready: 156 - // Get the resolved endpoint 157 - if let resolvedEndpoint = connection.currentPath?.remoteEndpoint { 158 - self.handleResolvedEndpoint( 159 - resolvedEndpoint, 160 - serviceName: name, 161 - version: version, 162 - paths: paths, 163 - certFingerprint: certFingerprint 164 - ) 165 - } 166 - connection.cancel() 181 + if let host = host { 182 + LogManager.shared.info("Resolved '\(name)' -> \(host):\(port)", source: "mDNS") 167 183 168 - case .failed, .cancelled: 169 - connection.cancel() 184 + let serverInfo = ServerInfo( 185 + name: name, 186 + host: host, 187 + port: port, 188 + certFingerprint: certFingerprint, 189 + version: version, 190 + paths: paths 191 + ) 170 192 171 - default: 172 - break 193 + if !self.discoveredServers.contains(where: { $0.host == host }) { 194 + self.discoveredServers.append(serverInfo) 195 + } 196 + } else { 197 + LogManager.shared.warning("Failed to resolve '\(name)'", source: "mDNS") 173 198 } 174 199 } 175 200 } 176 201 177 - connections.append(connection) 178 - connection.start(queue: .main) 202 + // Store delegate to prevent deallocation 203 + resolverDelegates.append(delegate) 204 + netService.delegate = delegate 205 + netService.resolve(withTimeout: 10.0) 206 + activeNetServices.append(netService) 179 207 } 180 208 181 209 private func handleResolvedEndpoint( ··· 219 247 } 220 248 221 249 guard !host.isEmpty else { 222 - print("[mDNS] Failed to resolve host for '\(serviceName)'") 250 + LogManager.shared.warning("Failed to resolve host for '\(serviceName)'", source: "mDNS") 223 251 return 224 252 } 225 253 226 - print("[mDNS] Resolved '\(serviceName)' -> \(host):\(port)") 254 + LogManager.shared.info("Resolved '\(serviceName)' -> \(host):\(port)", source: "mDNS") 227 255 228 256 let serverInfo = ServerInfo( 229 257 name: serviceName,
+8 -1
serviceberry/Services/TransportManager.swift
··· 26 26 self.mode = mode 27 27 self.serverInfo = serverInfo 28 28 29 + LogManager.shared.info("Configuring transport: \(mode.displayName)", source: "Transport") 30 + 29 31 switch mode { 30 32 case .bluetooth: 31 33 let ble = BLETransport() ··· 34 36 35 37 case .lan: 36 38 guard let info = serverInfo else { 39 + LogManager.shared.error("No server info provided", source: "Transport") 37 40 connectionState = .error("No server info provided") 38 41 return 39 42 } 43 + LogManager.shared.debug("Server: \(info.host):\(info.port)", source: "Transport") 40 44 let lan = LANTransport(serverInfo: info) 41 45 setupTransport(lan) 42 46 transport = lan ··· 74 78 75 79 /// Handle incoming location request from server 76 80 private func handleLocationRequest() async { 81 + LogManager.shared.info("Location request received from server", source: "Transport") 77 82 do { 78 83 let position = try await locationService.requestLocation() 84 + LogManager.shared.debug("Got position: \(position.latitude), \(position.longitude)", source: "Transport") 79 85 let payload = LocationPayload(position: position) 80 86 try await sendLocation(payload) 81 87 } catch { 82 - print("Failed to handle location request: \(error.localizedDescription)") 88 + LogManager.shared.error("Failed to handle location request: \(error.localizedDescription)", source: "Transport") 83 89 } 84 90 } 85 91 ··· 99 105 try await transport.sendLocation(payload) 100 106 lastSubmissionTime = Date() 101 107 submissionCount += 1 108 + LogManager.shared.info("Location sent successfully (#\(submissionCount))", source: "Transport") 102 109 } 103 110 104 111 /// Get BLE transport for peripheral selection (only valid if mode is bluetooth)
+119
serviceberry/Utilities/LogManager.swift
··· 1 + import Foundation 2 + import Combine 3 + import OSLog 4 + 5 + /// Log level for categorizing messages 6 + enum LogLevel: String { 7 + case debug = "DEBUG" 8 + case info = "INFO" 9 + case warning = "WARN" 10 + case error = "ERROR" 11 + 12 + var emoji: String { 13 + switch self { 14 + case .debug: return "🔍" 15 + case .info: return "ℹ️" 16 + case .warning: return "⚠️" 17 + case .error: return "❌" 18 + } 19 + } 20 + 21 + var osLogType: OSLogType { 22 + switch self { 23 + case .debug: return .debug 24 + case .info: return .info 25 + case .warning: return .default 26 + case .error: return .error 27 + } 28 + } 29 + } 30 + 31 + /// A single log entry 32 + struct LogEntry: Identifiable { 33 + let id = UUID() 34 + let timestamp: Date 35 + let level: LogLevel 36 + let message: String 37 + let source: String? 38 + 39 + var formattedTime: String { 40 + let formatter = DateFormatter() 41 + formatter.dateFormat = "HH:mm:ss.SSS" 42 + return formatter.string(from: timestamp) 43 + } 44 + } 45 + 46 + /// Global log manager for the app 47 + @MainActor 48 + class LogManager: ObservableObject { 49 + static let shared = LogManager() 50 + 51 + @Published private(set) var entries: [LogEntry] = [] 52 + @Published var isOverlayVisible: Bool = false 53 + 54 + private let maxEntries = 500 55 + 56 + // OSLog loggers for Console.app streaming 57 + private let defaultLogger = Logger(subsystem: "org.limeskey.serviceberry", category: "app") 58 + private var loggers: [String: Logger] = [:] 59 + 60 + private init() {} 61 + 62 + private func logger(for source: String?) -> Logger { 63 + guard let source = source else { return defaultLogger } 64 + if let existing = loggers[source] { 65 + return existing 66 + } 67 + let newLogger = Logger(subsystem: "org.limeskey.serviceberry", category: source) 68 + loggers[source] = newLogger 69 + return newLogger 70 + } 71 + 72 + func log(_ message: String, level: LogLevel = .info, source: String? = nil) { 73 + let entry = LogEntry(timestamp: Date(), level: level, message: message, source: source) 74 + entries.append(entry) 75 + 76 + // Trim old entries if needed 77 + if entries.count > maxEntries { 78 + entries.removeFirst(entries.count - maxEntries) 79 + } 80 + 81 + // Send to OSLog for Console.app streaming 82 + let osLogger = logger(for: source) 83 + osLogger.log(level: level.osLogType, "\(message)") 84 + 85 + // Also print to console for Xcode debugging 86 + let sourcePrefix = source.map { "[\($0)] " } ?? "" 87 + print("\(entry.formattedTime) \(level.emoji) \(sourcePrefix)\(message)") 88 + } 89 + 90 + func debug(_ message: String, source: String? = nil) { 91 + log(message, level: .debug, source: source) 92 + } 93 + 94 + func info(_ message: String, source: String? = nil) { 95 + log(message, level: .info, source: source) 96 + } 97 + 98 + func warning(_ message: String, source: String? = nil) { 99 + log(message, level: .warning, source: source) 100 + } 101 + 102 + func error(_ message: String, source: String? = nil) { 103 + log(message, level: .error, source: source) 104 + } 105 + 106 + func clear() { 107 + entries.removeAll() 108 + } 109 + 110 + func toggleOverlay() { 111 + isOverlayVisible.toggle() 112 + } 113 + } 114 + 115 + /// Convenience global logging functions 116 + @MainActor 117 + func appLog(_ message: String, level: LogLevel = .info, source: String? = nil) { 118 + LogManager.shared.log(message, level: level, source: source) 119 + }
+5
serviceberry/Views/Main/DashboardView.swift
··· 29 29 } 30 30 .navigationTitle("Serviceberry") 31 31 .toolbar { 32 + ToolbarItem(placement: .navigationBarLeading) { 33 + Button(action: { LogManager.shared.toggleOverlay() }) { 34 + Image(systemName: "doc.text") 35 + } 36 + } 32 37 ToolbarItem(placement: .navigationBarTrailing) { 33 38 Button(action: { showSettings = true }) { 34 39 Image(systemName: "gear")
+204
serviceberry/Views/Main/LogOverlayView.swift
··· 1 + import SwiftUI 2 + 3 + /// Floating overlay that displays app logs 4 + struct LogOverlayView: View { 5 + @ObservedObject var logManager = LogManager.shared 6 + @State private var isExpanded = true 7 + @State private var dragOffset: CGSize = .zero 8 + @State private var position: CGPoint = CGPoint(x: 20, y: 100) 9 + 10 + var body: some View { 11 + if logManager.isOverlayVisible { 12 + VStack(spacing: 0) { 13 + // Header bar 14 + headerBar 15 + 16 + if isExpanded { 17 + // Log content 18 + logContent 19 + 20 + // Footer with controls 21 + footerBar 22 + } 23 + } 24 + .frame(width: isExpanded ? 340 : 120) 25 + .background(Color(.systemBackground).opacity(0.95)) 26 + .cornerRadius(12) 27 + .shadow(color: .black.opacity(0.3), radius: 10, x: 0, y: 5) 28 + .overlay( 29 + RoundedRectangle(cornerRadius: 12) 30 + .stroke(Color(.systemGray3), lineWidth: 1) 31 + ) 32 + .position(x: position.x + (isExpanded ? 170 : 60), y: position.y) 33 + .offset(dragOffset) 34 + .gesture( 35 + DragGesture() 36 + .onChanged { value in 37 + dragOffset = value.translation 38 + } 39 + .onEnded { value in 40 + position.x += value.translation.width 41 + position.y += value.translation.height 42 + dragOffset = .zero 43 + } 44 + ) 45 + .animation(.easeInOut(duration: 0.2), value: isExpanded) 46 + } 47 + } 48 + 49 + private var headerBar: some View { 50 + HStack { 51 + Image(systemName: "doc.text") 52 + .foregroundStyle(.secondary) 53 + 54 + if isExpanded { 55 + Text("Logs") 56 + .font(.headline) 57 + .foregroundStyle(.primary) 58 + 59 + Spacer() 60 + 61 + Text("\(logManager.entries.count)") 62 + .font(.caption) 63 + .foregroundStyle(.secondary) 64 + .padding(.horizontal, 8) 65 + .padding(.vertical, 2) 66 + .background(Color(.systemGray5)) 67 + .cornerRadius(8) 68 + } 69 + 70 + Button(action: { isExpanded.toggle() }) { 71 + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") 72 + .foregroundStyle(.secondary) 73 + } 74 + 75 + Button(action: { logManager.isOverlayVisible = false }) { 76 + Image(systemName: "xmark") 77 + .foregroundStyle(.secondary) 78 + } 79 + } 80 + .padding(.horizontal, 12) 81 + .padding(.vertical, 10) 82 + .background(Color(.systemGray6)) 83 + } 84 + 85 + private var logContent: some View { 86 + ScrollViewReader { proxy in 87 + ScrollView { 88 + LazyVStack(alignment: .leading, spacing: 4) { 89 + ForEach(logManager.entries) { entry in 90 + LogEntryRow(entry: entry) 91 + .id(entry.id) 92 + } 93 + } 94 + .padding(.horizontal, 8) 95 + .padding(.vertical, 4) 96 + } 97 + .frame(height: 200) 98 + .onChange(of: logManager.entries.count) { _ in 99 + if let lastEntry = logManager.entries.last { 100 + withAnimation { 101 + proxy.scrollTo(lastEntry.id, anchor: .bottom) 102 + } 103 + } 104 + } 105 + } 106 + } 107 + 108 + private var footerBar: some View { 109 + HStack { 110 + Button(action: { logManager.clear() }) { 111 + HStack(spacing: 4) { 112 + Image(systemName: "trash") 113 + Text("Clear") 114 + } 115 + .font(.caption) 116 + .foregroundStyle(.red) 117 + } 118 + 119 + Spacer() 120 + 121 + Button(action: { copyLogs() }) { 122 + HStack(spacing: 4) { 123 + Image(systemName: "doc.on.doc") 124 + Text("Copy") 125 + } 126 + .font(.caption) 127 + .foregroundStyle(.blue) 128 + } 129 + } 130 + .padding(.horizontal, 12) 131 + .padding(.vertical, 8) 132 + .background(Color(.systemGray6)) 133 + } 134 + 135 + private func copyLogs() { 136 + let logText = logManager.entries.map { entry in 137 + let source = entry.source.map { "[\($0)] " } ?? "" 138 + return "\(entry.formattedTime) \(entry.level.rawValue) \(source)\(entry.message)" 139 + }.joined(separator: "\n") 140 + 141 + UIPasteboard.general.string = logText 142 + } 143 + } 144 + 145 + struct LogEntryRow: View { 146 + let entry: LogEntry 147 + 148 + var body: some View { 149 + HStack(alignment: .top, spacing: 6) { 150 + Text(entry.level.emoji) 151 + .font(.system(size: 10)) 152 + 153 + VStack(alignment: .leading, spacing: 2) { 154 + HStack(spacing: 4) { 155 + Text(entry.formattedTime) 156 + .font(.system(size: 9, design: .monospaced)) 157 + .foregroundStyle(.secondary) 158 + 159 + if let source = entry.source { 160 + Text(source) 161 + .font(.system(size: 9, weight: .medium)) 162 + .foregroundStyle(sourceColor) 163 + } 164 + } 165 + 166 + Text(entry.message) 167 + .font(.system(size: 11, design: .monospaced)) 168 + .foregroundStyle(messageColor) 169 + .lineLimit(3) 170 + } 171 + } 172 + .padding(.vertical, 2) 173 + } 174 + 175 + private var messageColor: Color { 176 + switch entry.level { 177 + case .debug: return .secondary 178 + case .info: return .primary 179 + case .warning: return .orange 180 + case .error: return .red 181 + } 182 + } 183 + 184 + private var sourceColor: Color { 185 + .blue.opacity(0.8) 186 + } 187 + } 188 + 189 + #Preview { 190 + ZStack { 191 + Color.gray.opacity(0.3) 192 + .ignoresSafeArea() 193 + 194 + LogOverlayView() 195 + .onAppear { 196 + LogManager.shared.isOverlayVisible = true 197 + LogManager.shared.info("App started", source: "App") 198 + LogManager.shared.debug("Checking permissions", source: "Location") 199 + LogManager.shared.info("Connected to server", source: "LAN") 200 + LogManager.shared.warning("Weak signal detected", source: "BLE") 201 + LogManager.shared.error("Connection failed: timeout", source: "Transport") 202 + } 203 + } 204 + }
+33 -6
serviceberry/serviceberryApp.swift
··· 11 11 struct serviceberryApp: App { 12 12 @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 13 13 @StateObject private var appState = AppState() 14 + @StateObject private var logManager = LogManager.shared 14 15 15 16 var body: some Scene { 16 17 WindowGroup { 17 - if appState.isOnboarded { 18 - DashboardView() 19 - .environmentObject(appState) 20 - } else { 21 - OnboardingContainerView() 22 - .environmentObject(appState) 18 + ZStack { 19 + if appState.isOnboarded { 20 + DashboardView() 21 + .environmentObject(appState) 22 + } else { 23 + OnboardingContainerView() 24 + .environmentObject(appState) 25 + } 26 + 27 + LogOverlayView() 28 + 29 + // Floating log toggle button (bottom-right corner) 30 + VStack { 31 + Spacer() 32 + HStack { 33 + Spacer() 34 + Button(action: { LogManager.shared.toggleOverlay() }) { 35 + Image(systemName: logManager.isOverlayVisible ? "doc.text.fill" : "doc.text") 36 + .font(.system(size: 16, weight: .medium)) 37 + .foregroundColor(.white) 38 + .frame(width: 44, height: 44) 39 + .background(Color.black.opacity(0.6)) 40 + .clipShape(Circle()) 41 + } 42 + .padding(.trailing, 16) 43 + .padding(.bottom, 100) 44 + } 45 + } 46 + } 47 + .environmentObject(logManager) 48 + .onAppear { 49 + LogManager.shared.info("App launched", source: "App") 23 50 } 24 51 } 25 52 }