Jelajahi Sumber

Redact secrets from log output (#623)

Adds a LogRedactor helper and applies it across every known leaky log
site so users can share logs without leaking APNs tokens, p8 keys,
Nightscout URLs and tokens, Dexcom usernames, key/team IDs, or bundle
identifiers. LogManager.log also runs a safety-net sweep that catches
PEM PRIVATE KEY blocks, ?token= query values, and JWTs regardless of
the call site.
Jonas Björkert 2 bulan lalu
induk
melakukan
2e8607fbbf

+ 4 - 0
LoopFollow.xcodeproj/project.pbxproj

@@ -250,6 +250,7 @@
 		DDD10F0B2C54192A00D76A8E /* TemporaryTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD10F0A2C54192A00D76A8E /* TemporaryTarget.swift */; };
 		DDDB86F12DF7223C00AADDAC /* DeleteAlarmSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDB86F02DF7223C00AADDAC /* DeleteAlarmSection.swift */; };
 		DDDC01DD2E244B3100D9975C /* JWTManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC01DC2E244B3100D9975C /* JWTManager.swift */; };
+		A1A1A10002000000A0CFEED1 /* LogRedactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10002000000A0CFEED2 /* LogRedactor.swift */; };
 		DDDC31CC2E13A7DF009EA0F3 /* AddAlarmSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC31CB2E13A7DF009EA0F3 /* AddAlarmSheet.swift */; };
 		DDDC31CE2E13A811009EA0F3 /* AlarmTile.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC31CD2E13A811009EA0F3 /* AlarmTile.swift */; };
 		DDDF6F492D479AF000884336 /* NoRemoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDF6F482D479AEF00884336 /* NoRemoteView.swift */; };
@@ -705,6 +706,7 @@
 		DDD10F0A2C54192A00D76A8E /* TemporaryTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryTarget.swift; sourceTree = "<group>"; };
 		DDDB86F02DF7223C00AADDAC /* DeleteAlarmSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAlarmSection.swift; sourceTree = "<group>"; };
 		DDDC01DC2E244B3100D9975C /* JWTManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JWTManager.swift; sourceTree = "<group>"; };
+		A1A1A10002000000A0CFEED2 /* LogRedactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogRedactor.swift; sourceTree = "<group>"; };
 		DDDC31CB2E13A7DF009EA0F3 /* AddAlarmSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAlarmSheet.swift; sourceTree = "<group>"; };
 		DDDC31CD2E13A811009EA0F3 /* AlarmTile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmTile.swift; sourceTree = "<group>"; };
 		DDDF6F482D479AEF00884336 /* NoRemoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoRemoteView.swift; sourceTree = "<group>"; };
@@ -1706,6 +1708,7 @@
 				DDF699952C5582290058A8D9 /* TextFieldWithToolBar.swift */,
 				DDC7E5372DBD887400EB1127 /* isOnPhoneCall.swift */,
 				DDDC01DC2E244B3100D9975C /* JWTManager.swift */,
+				A1A1A10002000000A0CFEED2 /* LogRedactor.swift */,
 				6541341B2E1DC28000BDBE08 /* DateExtensions.swift */,
 			);
 			path = Helpers;
@@ -2296,6 +2299,7 @@
 				DD4A407E2E6AFEE6007B318B /* AuthService.swift in Sources */,
 				654134182E1DC09700BDBE08 /* OverridePresetsView.swift in Sources */,
 				DDDC01DD2E244B3100D9975C /* JWTManager.swift in Sources */,
+				A1A1A10002000000A0CFEED1 /* LogRedactor.swift in Sources */,
 				DDD10F072C529DE800D76A8E /* Observable.swift in Sources */,
 				DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */,
 				DDCF9A882D85FD33004DF4DD /* AlarmData.swift in Sources */,

+ 5 - 3
LoopFollow/Application/AppDelegate.swift

@@ -87,7 +87,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
 
         Observable.shared.loopFollowDeviceToken.value = tokenString
 
-        LogManager.shared.log(category: .apns, message: "Successfully registered for remote notifications with token: \(tokenString)")
+        LogManager.shared.log(category: .apns, message: "Successfully registered for remote notifications with token: \(LogRedactor.tail(tokenString))")
     }
 
     /// Called when failed to register for remote notifications
@@ -97,7 +97,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
 
     /// Called when a remote notification is received
     func application(_: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
-        LogManager.shared.log(category: .apns, message: "Received remote notification: \(userInfo)")
+        let userInfoKeys = userInfo.keys.compactMap { $0 as? String }.sorted()
+        LogManager.shared.log(category: .apns, message: "Received remote notification: keys=\(userInfoKeys)")
 
         // Check if this is a response notification from Loop or Trio
         if let aps = userInfo["aps"] as? [String: Any] {
@@ -232,7 +233,8 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
     {
         // Log the notification
         let userInfo = notification.request.content.userInfo
-        LogManager.shared.log(category: .general, message: "Will present notification: \(userInfo)")
+        let userInfoKeys = userInfo.keys.compactMap { $0 as? String }.sorted()
+        LogManager.shared.log(category: .general, message: "Will present notification: keys=\(userInfoKeys)")
 
         // Show the notification even when app is in foreground
         completionHandler([.banner, .sound, .badge])

+ 1 - 1
LoopFollow/Helpers/JWTManager.swift

@@ -45,7 +45,7 @@ class JWTManager {
             let signedJWT = "\(signingInput).\(signatureBase64)"
 
             cache[cacheKey] = CachedToken(jwt: signedJWT, expiresAt: Date().addingTimeInterval(ttl))
-            LogManager.shared.log(category: .apns, message: "JWT generated for key \(keyId) (TTL 55 min)")
+            LogManager.shared.log(category: .apns, message: "JWT generated for key \(LogRedactor.keyId(keyId)) (TTL 55 min)")
             return signedJWT
         } catch {
             LogManager.shared.log(category: .apns, message: "Failed to sign JWT: \(error.localizedDescription)")

+ 170 - 0
LoopFollow/Helpers/LogRedactor.swift

@@ -0,0 +1,170 @@
+// LoopFollow
+// LogRedactor.swift
+
+import CryptoKit
+import Foundation
+
+/// Helpers for masking secrets before they hit the log file. The "share logs"
+/// feature exposes the on-disk log to the user, so anything sensitive that
+/// flows through `LogManager.log` must be reduced to a non-recoverable form
+/// while keeping enough signal (short suffix, host, fingerprint) to correlate
+/// events during debugging.
+enum LogRedactor {
+    /// Last `keep` characters of `secret`, prefixed with `…`. Matches the
+    /// existing `.suffix(8)` convention used in `LiveActivityManager`.
+    static func tail(_ secret: String, keep: Int = 8) -> String {
+        if secret.isEmpty { return "(empty)" }
+        if secret.count <= keep { return "(redacted)" }
+        return "…\(secret.suffix(keep))"
+    }
+
+    /// First `keep` characters of `secret`, suffixed with `…`. Matches the
+    /// existing `.prefix(8)` convention used in `LoopAPNSService`.
+    static func head(_ secret: String, keep: Int = 8) -> String {
+        if secret.isEmpty { return "(empty)" }
+        if secret.count <= keep { return "(redacted)" }
+        return "\(secret.prefix(keep))…"
+    }
+
+    /// Known managed-Nightscout host suffixes. When a URL's host ends in one
+    /// of these, the leading subdomain (which identifies the user) is masked
+    /// and the suffix is kept so engineers can tell which platform the user
+    /// is on. Anything else is treated as self-hosted and reduced to the TLD.
+    private static let knownHostSuffixes: [String] = [
+        "nightscoutpro.com",
+        "10be.de",
+        "herokuapp.com",
+    ]
+
+    /// Keep scheme + a redacted host hint, drop path and query. The Nightscout
+    /// token rides in `?token=` and the host itself identifies the user when
+    /// they're on a managed platform, so we mask the subdomain and keep only
+    /// the platform suffix (or just the TLD for self-hosted setups).
+    static func url(_ raw: String) -> String {
+        if raw.isEmpty { return "(empty)" }
+        if let u = URL(string: raw), let host = u.host {
+            let scheme = u.scheme.map { "\($0)://" } ?? ""
+            return "\(scheme)\(maskHost(host))/…"
+        }
+        return "(redacted)"
+    }
+
+    private static func maskHost(_ host: String) -> String {
+        // IPv4 / IPv6 / bracketed — drop entirely.
+        if host.range(of: "^\\d+\\.\\d+\\.\\d+\\.\\d+$", options: .regularExpression) != nil { return "***" }
+        if host.contains(":") || host.hasPrefix("[") { return "***" }
+
+        let lower = host.lowercased()
+        for suffix in knownHostSuffixes {
+            if lower == suffix || lower.hasSuffix("." + suffix) {
+                return "***." + suffix
+            }
+        }
+
+        let parts = host.split(separator: ".", omittingEmptySubsequences: false)
+        if parts.count >= 2, let tld = parts.last, !tld.isEmpty {
+            return "***." + String(tld)
+        }
+        return "***"
+    }
+
+    /// Apple Developer Key ID — 10-char uppercase alphanumeric. Reveals
+    /// last 2 chars only.
+    static func keyId(_ keyId: String) -> String {
+        if keyId.isEmpty { return "(empty)" }
+        if keyId.count <= 2 { return "(redacted)" }
+        return "…\(keyId.suffix(2))"
+    }
+
+    /// Apple Team ID — 10-char uppercase alphanumeric. Reveals last 2 chars.
+    static func teamId(_ teamId: String) -> String {
+        keyId(teamId)
+    }
+
+    /// App bundle id ("com.example.MyApp"). Mask the middle component(s) but
+    /// keep the leading TLD and trailing app name so suffixes like
+    /// `.watchkitapp` or `.push-type.liveactivity` remain visible.
+    static func bundleId(_ id: String) -> String {
+        if id.isEmpty { return "(empty)" }
+        let parts = id.split(separator: ".", omittingEmptySubsequences: false)
+        guard parts.count >= 3 else { return "(redacted)" }
+        var masked = [String]()
+        masked.append(String(parts[0]))
+        for _ in 1 ..< parts.count - 1 {
+            masked.append("***")
+        }
+        masked.append(String(parts[parts.count - 1]))
+        return masked.joined(separator: ".")
+    }
+
+    /// Username (Dexcom Share, etc.). Preserves first character and any
+    /// `@domain` suffix shape so engineers can tell email-shaped from not.
+    static func username(_ name: String) -> String {
+        if name.isEmpty { return "(empty)" }
+        if name.contains("@") {
+            let parts = name.split(separator: "@", maxSplits: 1).map(String.init)
+            let local = parts[0]
+            let domain = parts.count > 1 ? parts[1] : ""
+            let firstLocal = local.first.map(String.init) ?? "?"
+            let firstDomain = domain.first.map(String.init) ?? "?"
+            return "\(firstLocal)***@\(firstDomain)***"
+        }
+        let first = name.first.map(String.init) ?? "?"
+        return "\(first)***"
+    }
+
+    /// Sweep an arbitrary message string for high-confidence secret shapes.
+    /// Idempotent. Run by `LogManager.log` on every line before write.
+    static func sweep(_ message: String) -> String {
+        var out = message
+        out = redactPEM(out)
+        out = redactTokenQuery(out)
+        out = redactJWT(out)
+        return out
+    }
+
+    /// Replace any `?token=…` or `&token=…` value with `***` (case-insensitive).
+    private static func redactTokenQuery(_ s: String) -> String {
+        guard let regex = try? NSRegularExpression(
+            pattern: "([?&]token=)[^&\\s\"'<>]+",
+            options: [.caseInsensitive]
+        ) else { return s }
+        let range = NSRange(s.startIndex ..< s.endIndex, in: s)
+        return regex.stringByReplacingMatches(in: s, options: [], range: range, withTemplate: "$1***")
+    }
+
+    /// Collapse the body of a PEM PRIVATE KEY block to `(redacted)`.
+    private static func redactPEM(_ s: String) -> String {
+        guard let regex = try? NSRegularExpression(
+            pattern: "-----BEGIN [A-Z ]*PRIVATE KEY-----[\\s\\S]*?-----END [A-Z ]*PRIVATE KEY-----",
+            options: []
+        ) else { return s }
+        let range = NSRange(s.startIndex ..< s.endIndex, in: s)
+        return regex.stringByReplacingMatches(
+            in: s, options: [], range: range,
+            withTemplate: "-----BEGIN PRIVATE KEY----- (redacted) -----END PRIVATE KEY-----"
+        )
+    }
+
+    /// Collapse the middle segment of a JWT (`ey…\.ey…\.…`).
+    private static func redactJWT(_ s: String) -> String {
+        guard let regex = try? NSRegularExpression(
+            pattern: "ey[A-Za-z0-9_-]{8,}\\.ey[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}",
+            options: []
+        ) else { return s }
+        let range = NSRange(s.startIndex ..< s.endIndex, in: s)
+        return regex.stringByReplacingMatches(in: s, options: [], range: range, withTemplate: "ey…<jwt>…")
+    }
+
+    /// Non-reversible fingerprint for opaque blobs we can't safely log
+    /// (settings JSON, scanned QR code contents, etc.).
+    static func fingerprint(_ data: Data) -> String {
+        let digest = SHA256.hash(data: data)
+        let hex = digest.compactMap { String(format: "%02x", $0) }.joined()
+        return "\(data.count) bytes, sha256=\(hex.prefix(8))…"
+    }
+
+    static func fingerprint(_ string: String) -> String {
+        fingerprint(Data(string.utf8))
+    }
+}

+ 8 - 11
LoopFollow/Helpers/NightscoutUtils.swift

@@ -86,25 +86,22 @@ class NightscoutUtils {
                     completion(.success(decodedObject))
                 }
             } catch let decodingError as DecodingError {
-                print("[ERROR] Failed to decode \(T.self):")
+                let typeName = String(describing: T.self)
                 switch decodingError {
                 case let .typeMismatch(type, context):
-                    print("Type mismatch for type \(type), context: \(context.debugDescription)")
-                    print("Coding path:", context.codingPath)
+                    LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) typeMismatch: \(type) at \(context.codingPath.map { $0.stringValue }.joined(separator: "."))", isDebug: true)
                 case let .valueNotFound(type, context):
-                    print("Value not found for type \(type), context: \(context.debugDescription)")
-                    print("Coding path:", context.codingPath)
+                    LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) valueNotFound: \(type) at \(context.codingPath.map { $0.stringValue }.joined(separator: "."))", isDebug: true)
                 case let .keyNotFound(key, context):
-                    print("Key '\(key.stringValue)' not found, context: \(context.debugDescription)")
-                    print("Coding path:", context.codingPath)
-                case let .dataCorrupted(context):
-                    print("Data corrupted, context: \(context.debugDescription)")
+                    LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) keyNotFound: '\(key.stringValue)' at \(context.codingPath.map { $0.stringValue }.joined(separator: "."))", isDebug: true)
+                case .dataCorrupted:
+                    LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) dataCorrupted", isDebug: true)
                 @unknown default:
-                    print("Unknown decoding error")
+                    LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) unknown error", isDebug: true)
                 }
                 completion(.failure(decodingError))
             } catch {
-                print("[ERROR] General error:", error)
+                LogManager.shared.log(category: .nightscout, message: "Decode \(T.self) general error: \(String(describing: type(of: error)))", isDebug: true)
                 completion(.failure(error))
             }
         }

+ 2 - 1
LoopFollow/Log/LogManager.swift

@@ -56,7 +56,8 @@ class LogManager {
     ///   - limitIdentifier: Optional key to rate-limit similar log messages.
     ///   - limitInterval: Time interval (in seconds) to wait before logging the same type again.
     func log(category: Category, message: String, isDebug: Bool = false, limitIdentifier: String? = nil, limitInterval: TimeInterval = 300) {
-        let logMessage = formattedLogMessage(for: category, message: message)
+        let safeMessage = LogRedactor.sweep(message)
+        let logMessage = formattedLogMessage(for: category, message: safeMessage)
 
         consoleQueue.async {
             print(logMessage)

+ 2 - 2
LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift

@@ -162,7 +162,7 @@ class LoopAPNSService {
 
         // Encrypt and include return notification info using OTP
         if let returnInfo = createReturnNotificationInfo() {
-            LogManager.shared.log(category: .apns, message: "Created return notification info for carbs - deviceToken: \(returnInfo.deviceToken.prefix(8))..., bundleId: \(returnInfo.bundleId)")
+            LogManager.shared.log(category: .apns, message: "Created return notification info for carbs - deviceToken: \(LogRedactor.head(returnInfo.deviceToken)), bundleId: \(LogRedactor.bundleId(returnInfo.bundleId))")
             if let encryptedReturnInfo = encryptReturnNotificationInfo(returnInfo: returnInfo, otpCode: String(payload.otp)) {
                 finalPayload["encrypted_return_notification"] = encryptedReturnInfo
                 LogManager.shared.log(category: .apns, message: "Added encrypted_return_notification to carbs payload, length: \(encryptedReturnInfo.count)")
@@ -227,7 +227,7 @@ class LoopAPNSService {
 
         // Encrypt and include return notification info using OTP
         if let returnInfo = createReturnNotificationInfo() {
-            LogManager.shared.log(category: .apns, message: "Created return notification info for carbs - deviceToken: \(returnInfo.deviceToken.prefix(8))..., bundleId: \(returnInfo.bundleId)")
+            LogManager.shared.log(category: .apns, message: "Created return notification info for carbs - deviceToken: \(LogRedactor.head(returnInfo.deviceToken)), bundleId: \(LogRedactor.bundleId(returnInfo.bundleId))")
             if let encryptedReturnInfo = encryptReturnNotificationInfo(returnInfo: returnInfo, otpCode: String(payload.otp)) {
                 finalPayload["encrypted_return_notification"] = encryptedReturnInfo
                 LogManager.shared.log(category: .apns, message: "Added encrypted_return_notification to carbs payload, length: \(encryptedReturnInfo.count)")

+ 1 - 1
LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift

@@ -201,7 +201,7 @@ class RemoteSettingsViewModel: ObservableObject {
                 self.remoteType = .loopAPNS
                 self.isLoopDevice = true
                 self.isTrioDevice = false
-                LogManager.shared.log(category: .apns, message: "Loop APNS QR code scanned: \(code)")
+                LogManager.shared.log(category: .apns, message: "Loop APNS QR code scanned: \(LogRedactor.fingerprint(code))")
             case let .failure(error):
                 self.loopAPNSErrorMessage = "Scanning failed: \(error.localizedDescription)"
             }

+ 2 - 3
LoopFollow/Remote/TRC/PushNotificationManager.swift

@@ -276,12 +276,11 @@ class PushNotificationManager {
                 }
 
                 if let httpResponse = response as? HTTPURLResponse {
-                    print("Push notification sent.")
-                    print("Status code: \(httpResponse.statusCode)")
+                    LogManager.shared.log(category: .apns, message: "Push notification sent. Status code: \(httpResponse.statusCode)", isDebug: true)
 
                     var responseBodyMessage = ""
                     if let data = data, let responseBody = String(data: data, encoding: .utf8) {
-                        print("Response body: \(responseBody)")
+                        LogManager.shared.log(category: .apns, message: "Response body: \(responseBody)", isDebug: true)
                         if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
                            let reason = json["reason"] as? String
                         {

+ 5 - 4
LoopFollow/Settings/ImportExport/ImportExportSettingsViewModel.swift

@@ -66,7 +66,7 @@ class ImportExportSettingsViewModel: ObservableObject {
 
     private func processImportedSettings(_ jsonString: String) {
         do {
-            LogManager.shared.log(category: .general, message: "Processing QR code data: \(jsonString.prefix(200))...")
+            LogManager.shared.log(category: .general, message: "Processing QR code data: \(LogRedactor.fingerprint(jsonString))")
 
             guard let data = jsonString.data(using: .utf8) else {
                 qrCodeErrorMessage = "Invalid QR code data"
@@ -78,7 +78,8 @@ class ImportExportSettingsViewModel: ObservableObject {
             // Try to decode as JSON first to see what we get
             do {
                 let jsonObject = try JSONSerialization.jsonObject(with: data, options: [])
-                LogManager.shared.log(category: .general, message: "JSON parsing successful: \(jsonObject)")
+                let topLevelKeys = (jsonObject as? [String: Any]).map { Array($0.keys).sorted() } ?? []
+                LogManager.shared.log(category: .general, message: "JSON parsing successful: keys=\(topLevelKeys)")
             } catch {
                 LogManager.shared.log(category: .general, message: "JSON parsing failed: \(error.localizedDescription)")
             }
@@ -263,7 +264,7 @@ class ImportExportSettingsViewModel: ObservableObject {
             (remoteType != nil && !remoteType!.isEmpty && remoteType != "None") ||
             alarmCount > 0
 
-        LogManager.shared.log(category: .general, message: "Import preview check - nightscoutURL: \(nightscoutURL ?? "nil"), dexcomUsername: \(dexcomUsername ?? "nil"), remoteType: \(remoteType ?? "nil"), alarmCount: \(alarmCount), hasAnySettings: \(hasAnySettings)")
+        LogManager.shared.log(category: .general, message: "Import preview check - nightscoutURL: \(nightscoutURL.map { LogRedactor.url($0) } ?? "nil"), dexcomUsername: \(dexcomUsername.map { LogRedactor.username($0) } ?? "nil"), remoteType: \(remoteType ?? "nil"), alarmCount: \(alarmCount), hasAnySettings: \(hasAnySettings)")
 
         if hasAnySettings {
             LogManager.shared.log(category: .general, message: "Creating import preview with settings")
@@ -274,7 +275,7 @@ class ImportExportSettingsViewModel: ObservableObject {
                 alarmCount: alarmCount,
                 alarmNames: alarmNames
             )
-            LogManager.shared.log(category: .general, message: "Created importPreview - nightscoutURL: \(importPreview?.nightscoutURL ?? "nil"), remoteType: \(importPreview?.remoteType ?? "nil"), alarmCount: \(importPreview?.alarmCount ?? 0)")
+            LogManager.shared.log(category: .general, message: "Created importPreview - nightscoutURL: \(importPreview?.nightscoutURL.map { LogRedactor.url($0) } ?? "nil"), remoteType: \(importPreview?.remoteType ?? "nil"), alarmCount: \(importPreview?.alarmCount ?? 0)")
             showImportConfirmation = true
             LogManager.shared.log(category: .general, message: "Set showImportConfirmation = true")
         } else {

+ 7 - 8
LoopFollow/Settings/ImportExport/SettingsMigrationManager.swift

@@ -14,11 +14,10 @@ class SettingsMigrationManager {
         // Try to decode with the current version
         do {
             let currentSettings = try JSONDecoder().decode(CombinedSettingsExport.self, from: data)
-            print("✅ Successfully decoded CombinedSettingsExport")
+            LogManager.shared.log(category: .general, message: "Successfully decoded CombinedSettingsExport", isDebug: true)
             return currentSettings
         } catch {
-            print("❌ Failed to decode CombinedSettingsExport: \(error)")
-            print("❌ Error details: \(error.localizedDescription)")
+            LogManager.shared.log(category: .general, message: "Failed to decode CombinedSettingsExport: \(String(describing: type(of: error)))", isDebug: true)
 
             // Try to decode as individual components
             return tryDecodeIndividualComponents(data)
@@ -28,7 +27,7 @@ class SettingsMigrationManager {
     private static func tryDecodeIndividualComponents(_ data: Data) -> CombinedSettingsExport? {
         // Try to decode as AlarmSettingsExport
         if let alarmSettings = try? JSONDecoder().decode(AlarmSettingsExport.self, from: data) {
-            print("✅ Successfully decoded as AlarmSettingsExport")
+            LogManager.shared.log(category: .general, message: "Successfully decoded as AlarmSettingsExport", isDebug: true)
             return CombinedSettingsExport(
                 alarms: alarmSettings,
                 exportType: "Alarm Settings"
@@ -37,7 +36,7 @@ class SettingsMigrationManager {
 
         // Try to decode as NightscoutSettingsExport
         if let nightscoutSettings = try? JSONDecoder().decode(NightscoutSettingsExport.self, from: data) {
-            print("✅ Successfully decoded as NightscoutSettingsExport")
+            LogManager.shared.log(category: .general, message: "Successfully decoded as NightscoutSettingsExport", isDebug: true)
             return CombinedSettingsExport(
                 nightscout: nightscoutSettings,
                 exportType: "Nightscout Settings"
@@ -46,7 +45,7 @@ class SettingsMigrationManager {
 
         // Try to decode as DexcomSettingsExport
         if let dexcomSettings = try? JSONDecoder().decode(DexcomSettingsExport.self, from: data) {
-            print("✅ Successfully decoded as DexcomSettingsExport")
+            LogManager.shared.log(category: .general, message: "Successfully decoded as DexcomSettingsExport", isDebug: true)
             return CombinedSettingsExport(
                 dexcom: dexcomSettings,
                 exportType: "Dexcom Settings"
@@ -55,14 +54,14 @@ class SettingsMigrationManager {
 
         // Try to decode as RemoteSettingsExport
         if let remoteSettings = try? JSONDecoder().decode(RemoteSettingsExport.self, from: data) {
-            print("✅ Successfully decoded as RemoteSettingsExport")
+            LogManager.shared.log(category: .general, message: "Successfully decoded as RemoteSettingsExport", isDebug: true)
             return CombinedSettingsExport(
                 remote: remoteSettings,
                 exportType: "Remote Settings"
             )
         }
 
-        print("❌ Failed to decode as any known component")
+        LogManager.shared.log(category: .general, message: "Failed to decode as any known component", isDebug: true)
         return nil
     }
 

+ 0 - 2
LoopFollow/Storage/Framework/ObservableUserDefaultsValue.swift

@@ -37,8 +37,6 @@ class ObservableUserDefaultsValue<T: AnyConvertible & Equatable>: ObservableObje
 
                 // Notify UserDefaultsValueGroups that value has changed
                 UserDefaultsValueGroups.valueChanged(self)
-
-                print("Value for \(self.key) changed to \(self.value)") // Logging
             }
         }
     }

+ 0 - 1
LoopFollow/Storage/Framework/ObservableValue.swift

@@ -14,7 +14,6 @@ class ObservableValue<T>: ObservableObject {
     }
 
     func set(_ newValue: T) {
-        print("Setting new value: \(newValue)")
         DispatchQueue.main.async {
             self.value = newValue
         }

+ 2 - 2
LoopFollow/ViewControllers/MainViewController.swift

@@ -1096,12 +1096,12 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
         // after a reboot), all StorageValues were cached from encrypted UserDefaults and hold
         // their defaults. Reload everything from disk now that the device is unlocked, firing
         // Combine observers only for values that actually changed.
-        LogManager.shared.log(category: .general, message: "appCameToForeground: needsBFUReload=\(Storage.shared.needsBFUReload), url='\(Storage.shared.url.value)'")
+        LogManager.shared.log(category: .general, message: "appCameToForeground: needsBFUReload=\(Storage.shared.needsBFUReload), url='\(LogRedactor.url(Storage.shared.url.value))'")
         if Storage.shared.needsBFUReload {
             Storage.shared.needsBFUReload = false
             LogManager.shared.log(category: .general, message: "BFU reload triggered — reloading all StorageValues")
             Storage.shared.reloadAll()
-            LogManager.shared.log(category: .general, message: "BFU reload complete: url='\(Storage.shared.url.value)'")
+            LogManager.shared.log(category: .general, message: "BFU reload complete: url='\(LogRedactor.url(Storage.shared.url.value))'")
             // Show the loading overlay so the user sees feedback during the 2-5s
             // while tasks re-run with the now-correct credentials.
             loadingStates = ["bg": false, "profile": false, "deviceStatus": false]

+ 1 - 1
LoopFollow/ViewControllers/NightScoutViewController.swift

@@ -120,7 +120,7 @@ extension NightscoutViewController: WKNavigationDelegate, WKUIDelegate {
             return false
         }
 
-        NSLog("Should start: \(url.absoluteString)")
+        LogManager.shared.log(category: .nightscout, message: "Web shouldStart: \(LogRedactor.url(url.absoluteString))", isDebug: true)
         return true
     }