diff --git a/ios/ConduitModule/AppLogger.swift b/ios/ConduitModule/AppLogger.swift new file mode 100644 index 0000000..6e0c78c --- /dev/null +++ b/ios/ConduitModule/AppLogger.swift @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2024, Psiphon Inc. + * All rights reserved. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + + +import Foundation +import Puppy +import Logging + +struct JSONLogFormatter : LogFormattable { + + let jsonEncoder: JSONEncoder = JSONEncoder() + + func formatMessage(_ level: LogLevel, message: String, tag: String, function: String, + file: String, line: UInt, swiftLogInfo: [String: String], + label: String, date: Date, threadID: UInt64) -> String { + + let category = swiftLogInfo["label"]! // Category must always be set. + + do { + var metadata: GenericJSON? = nil + if let metadataString = swiftLogInfo["metadata"] { + // TODO: Puppy API limits us to encoding and decoding metadata. + let jsonObj = try JSONSerialization.jsonObject(with: metadataString.data(using: .utf8)!) + metadata = try GenericJSON(jsonObj) + } + + let log = Log( + timestamp: date, + level: FeedbackLogLevel(from: level), + category: category, + message: message, + data: metadata + ) + + let jsonData = try self.jsonEncoder.encode(log) + guard let logString = String(data: jsonData, encoding: .utf8) else { + throw Err("Failed to convert JSON data to String") + } + return logString + + } catch { + os_log(.fault, "Failed to encode log") + return "Error formatting log: \(String(describing: error))" + } + } +} + + +/// Manages an instance of Puppy as backend for swift-log. +enum AppLogger { + + #if DEBUG + static let minLogLevel = Logging.Logger.Level.trace + #else + static let minLogLevel = Logging.Logger.Level.info + #endif + + static let subsystem: String = Bundle.main.bundleIdentifier! + private static let maxArchivedCount: UInt8 = 2 + + private static let baseFileURL = { + var appSupportDir = try getApplicationSupportDirectory() + if #available(iOS 16.0, *) { + appSupportDir.append(components: Self.subsystem, "appLogs", "app.log") + } else { + appSupportDir.appendPathComponent(Self.subsystem) + appSupportDir.appendPathComponent("appLogs") + appSupportDir.appendPathComponent("app.log") + } + + return appSupportDir.absoluteURL + } + + static func initializePuppy() -> Puppy { + var puppy = Puppy() + + let fileLogger = try! FileRotationLogger( + "\(AppLogger.subsystem).log.file", // DispatchQueue label + logLevel: Self.minLogLevel.toPuppy(), + logFormat: JSONLogFormatter(), + fileURL: AppLogger.baseFileURL(), + filePermission: "600", + rotationConfig: RotationConfig( + suffixExtension: .numbering, + maxFileSize: 100 * 1024, + maxArchivedFilesCount: AppLogger.maxArchivedCount + ) + ) + + puppy.add(fileLogger) + return puppy + } + + static func readLogs() throws -> ([Log], [ParseError]) { + + var files = [URL]() + for i in 0...maxArchivedCount { + var fileURL = try! baseFileURL() + if i > 0 { + fileURL = fileURL.appendingPathExtension("\(i)") + } + files.append(fileURL) + } + + return try readLogFiles(withLogType: Log.self, paths: files, transform: { $0 }) + } + +} diff --git a/ios/ConduitModule/ConduitManager.swift b/ios/ConduitModule/ConduitManager.swift index 8cc549d..dc37221 100644 --- a/ios/ConduitModule/ConduitManager.swift +++ b/ios/ConduitModule/ConduitManager.swift @@ -17,16 +17,14 @@ * */ +import Collections import Foundation import PsiphonTunnel -import Collections +import Logging -extension Logger { - private static var subsystem = Bundle.main.bundleIdentifier! - - static let conduitMan = Logger(subsystem: subsystem, category: "ConduitManager") - - static let psiphonTunnel = Logger(subsystem: subsystem, category: "PsiphonTunnel") +extension Logging.Logger { + static let conduitMan = Logger(label: "ConduitManager") + static let psiphonTunnel = Logger(label: "PsiphonTunnel") } struct ConduitParams: Equatable { @@ -331,7 +329,7 @@ fileprivate final class PsiphonTunnelListener: NSObject, TunneledAppDelegate { let data = try Data(contentsOf: ResourceFile.embeddedServerEntries.url) return String(data: data, encoding: .utf8) } catch { - Logger.conduitMan.fault("Failed to read embedded server entries") + Logger.conduitMan.critical("Failed to read embedded server entries") return nil } } @@ -371,7 +369,7 @@ fileprivate final class PsiphonTunnelListener: NSObject, TunneledAppDelegate { return config } catch { - Logger.conduitMan.error("getPsiphonConfig failed: \(error, privacy: .public)") + Logger.conduitMan.error("getPsiphonConfig failed", metadata: ["error": "\(error)"]) return nil } } @@ -401,7 +399,7 @@ fileprivate final class PsiphonTunnelListener: NSObject, TunneledAppDelegate { } func onDiagnosticMessage(_ message: String, withTimestamp timestamp: String) { - Logger.psiphonTunnel.debug("\(message, privacy: .public)") + Logger.psiphonTunnel.debug("\(message)") } } diff --git a/ios/ConduitModule/ConduitModule.swift b/ios/ConduitModule/ConduitModule.swift index 82016de..b0cbdd4 100644 --- a/ios/ConduitModule/ConduitModule.swift +++ b/ios/ConduitModule/ConduitModule.swift @@ -19,15 +19,11 @@ import Foundation import PsiphonTunnel -import OSLog +import Logging - -extension Logger { - private static var subsystem = Bundle.main.bundleIdentifier! - - static let conduitModule = Logger(subsystem: subsystem, category: "ConduitModule") - - static let feedbackUploadService = Logger(subsystem: subsystem, category: "FeedbackUploadService") +extension Logging.Logger { + static let conduitModule = Logger(label: "ConduitModule") + static let feedbackUploadService = Logger(label: "FeedbackUploadService") } /// A type that is used for cross-langauge interaction with JavaScript codebase. @@ -188,8 +184,20 @@ final class ConduitModule: RCTEventEmitter { // synchronous execution to reuse the same thread. // Note that using `.sync` and targeting the same queue will result in a deadlock. let dispatchQueue: dispatch_queue_t - + override init() { + + LoggingSystem.bootstrap { label in + MultiplexLogHandler([ + OSLogger(subsystem: AppLogger.subsystem, + label: label, + logLevel: AppLogger.minLogLevel), + PsiphonLogHandler(label: label, + logLevel: AppLogger.minLogLevel, + puppy: AppLogger.initializePuppy()), + ]) + } + dispatchQueue = DispatchQueue(label: "ca.psiphon.conduit.module", qos: .default) super.init() @@ -223,7 +231,7 @@ final class ConduitModule: RCTEventEmitter { func sendEvent(_ event: ConduitEvent) { sendEvent(withName: ConduitEvent.eventName, body: event.asDictionary) - Logger.conduitModule.debug("ConduitEvent: \(String(describing: event))") + Logger.conduitModule.trace("ConduitEvent", metadata: ["event": "\(String(describing: event))"]) } } @@ -252,8 +260,7 @@ extension ConduitModule { } } catch { sendEvent(.proxyError(.inProxyStartFailed)) - Logger.conduitModule.error( - "Proxy start failed: \(String(describing: error), privacy: .public)") + Logger.conduitModule.error( "Proxy start failed", metadata: ["error": "\(error)"]) } case .started: await self.conduitManager.stopConduit() @@ -316,7 +323,6 @@ extension ConduitModule { ) { do { - // Read psiphon-tunnel-core notices. let dataRootDirectory = try getApplicationSupportDirectory() @@ -326,21 +332,30 @@ extension ConduitModule { olderNoticesFilePath(dataRootDirectory: dataRootDirectory) ] - let (tunnelCoreEntries, parseErrors) = try readDiagnosticLogFiles( - TunnelCoreLog.self, + let (tunnelCoreLogs, parseErrors) = try readLogFiles( + withLogType: TunnelCoreLog.self, paths: tunnelCoreNoticesPath, - transform: DiagnosticEntry.create(from:)) - + transform: Log.create(from:)) if parseErrors.count > 0 { - Logger.conduitModule.error( - "Log parse errors: \(String(describing: parseErrors), privacy: .public)") + os_log(.error, "Log parse error: \(parseErrors)") + } + + let (appLogs, appLogParseErrors) = try AppLogger.readLogs() + if appLogParseErrors.count > 0 { + os_log(.error, "Log parse error: \(parseErrors)") } + var allLogs = tunnelCoreLogs + allLogs.append(contentsOf: appLogs) + allLogs.append(contentsOf: (parseErrors + appLogParseErrors).map { + Log(timestamp: Date(), level: .error, category: "LogParser", message: $0.message) + }) + allLogs.sort() // Prepare Feedback Diagnostic Report let feedbackId = try generateFeedbackId() - Logger.conduitModule.info("Preparing feedback report with ID = \(feedbackId, privacy: .public)") + Logger.conduitModule.info("Preparing feedback report", metadata: ["feedback.id": "\(feedbackId)"]) let psiphonConfig = try defaultPsiphonConfig() @@ -358,26 +373,26 @@ extension ConduitModule { inproxyId: inproxyId ) - let report = FeedbackDiagnosticReport( - metadata: Metadata( + let report = FeedbackDiagnosticReportV2( + metadata: MetadataV2( id: feedbackId, appName: "conduit", - platform: ClientPlatform.platformString, - date: Date() + platform: ClientPlatform.platformString ), - feedback: nil, - diagnosticInfo: DiagnosticInfo( - systemInformation: SystemInformation( - build: DeviceInfo.gatherDeviceInfo(device: .current), - tunnelCoreBuildInfo: PsiphonTunnel.getBuildInfo(), - psiphonInfo: psiphonInfo, - isAppStoreBuild: true, - isJailbroken: false, - language: getLanguageMinimalIdentifier(), - // TODO: get networkTypeName - networkTypeName: "WIFI"), - diagnosticHistory: tunnelCoreEntries - )) + systemInformation: SystemInformationV2( + build: DeviceInfo.gatherDeviceInfo(device: .current), + tunnelCoreBuildInfo: PsiphonTunnel.getBuildInfo(), + isAppStoreBuild: true, + isJailbroken: false, + language: getLanguageMinimalIdentifier(), + // TODO: get networkTypeName + networkTypeName: "WIFI"), + psiphonInfo: psiphonInfo, + applicationInfo: ApplicationInfo( + applicationId: getApplicagtionId(), + clientVersion: getClientVersion()), + logs: allLogs + ) let json = String(data: try JSONEncoder().encode(report), encoding: .utf8)! @@ -391,42 +406,45 @@ extension ConduitModule { uploadPath: "") resolve(nil) - Logger.conduitModule.info("Finished uploading feedback diagnostic report.") + Logger.conduitModule.info("Finished uploading feedback diagnostic report", metadata: ["feedback.id": "\(feedbackId)"]) } catch { reject("error", "Feedback upload failed", nil) - Logger.conduitModule.error( - "Feedback upload failed: \(String(describing: error), privacy: .public)") + Logger.conduitModule.error("Feedback upload failed", metadata: [ + "feedback.id": "\(feedbackId)", + "error": "\(error)" + ]) } } } catch { - reject("error", "Feedback upload failed", nil) - Logger.conduitModule.error( - "Feedback upload failed: \(String(describing: error), privacy: .public)") + reject("error", "Feedback preparation failed", nil) + Logger.conduitModule.error("Feedback preparation failed", metadata: ["error": "\(error)"]) } } @objc(logInfo:msg:withResolver:withRejecter:) func logInfo(_ tag: String, msg: String, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) { - Logger.conduitModule.info("\(tag, privacy: .public): \(msg, privacy: .public)") + let logger = Logger(label: tag) + logger.info("\(msg)") resolve(nil) } @objc(logWarn:msg:withResolver:withRejecter:) func logWarn(_ tag: String, msg: String, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) { - Logger.conduitModule.info("\(tag, privacy: .public): \(msg, privacy: .public)") + let logger = Logger(label: tag) + logger.warning("\(msg)") resolve(nil) } @objc(logError:msg:withResolver:withRejecter:) func logError(_ tag: String, msg: String, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) { - Logger.conduitModule.info("\(tag, privacy: .public): \(msg, privacy: .public)") + let logger = Logger(label: tag) + logger.error("\(msg)") resolve(nil) } } - extension ConduitModule: ConduitManager.Listener { func onConduitStatusUpdate(_ status: ConduitManager.ConduitStatus, @@ -480,7 +498,7 @@ extension ConduitModule: ConduitManager.Listener { extension ConduitModule: FeedbackUploadService.Listener { func onDiagnosticMessage(_ message: String, withTimestamp timestamp: String) { - Logger.feedbackUploadService.info("DiagnosticMessage: \(timestamp, privacy: .public) \(message, privacy: .public)") + Logger.feedbackUploadService.info("DiagnosticMessage", metadata: ["timestamp": "\(timestamp)", "message": "\(message)"]) } } diff --git a/ios/ConduitModule/Feedback.swift b/ios/ConduitModule/Feedback/Feedback.swift similarity index 77% rename from ios/ConduitModule/Feedback.swift rename to ios/ConduitModule/Feedback/Feedback.swift index 46c0cf4..52f0378 100644 --- a/ios/ConduitModule/Feedback.swift +++ b/ios/ConduitModule/Feedback/Feedback.swift @@ -19,40 +19,7 @@ import Foundation -// MARK: - Feedback types - -public struct Metadata : Codable { - - /// Feedback version type. - let version: Int = 1 - - /// Feedback ID. - let id: String - - /// Client platform. - let platform: String - - /// App name. - let appName: String - - @ISO1806MilliCodedDate var date: Date - - init(id: String, appName: String, platform: String, date: Date) { - self.id = id - self.appName = appName - self.platform = platform - self.date = date - } - - enum CodingKeys : String, CodingKey { - case version = "version" - case id = "id" - case platform = "platform" - case appName = "appName" - case date = "date!!timestamp" - } - -} +// MARK: - Common types public struct PsiphonInfo : Codable { @@ -70,68 +37,6 @@ public struct PsiphonInfo : Codable { } -public struct SystemInformation : Codable { - - let build: DeviceInfo - let tunnelCoreBuildInfo: String - let psiphonInfo: PsiphonInfo - let isAppStoreBuild: Bool - let isJailbroken: Bool - - /// BCP-47 identifier in a minimalist form. Script and region may be omitted. For example, "zh-TW", "en" - let language: String - - let networkTypeName: String - - enum CodingKeys : String, CodingKey { - case build = "Build" - case tunnelCoreBuildInfo = "buildInfo" - case psiphonInfo = "PsiphonInfo" - case isAppStoreBuild = "isAppStoreBuild" - case isJailbroken = "isJailbroken" - case language = "language" - case networkTypeName = "networkTypeName" - } - -} - -public struct DiagnosticEntry : Codable { - - let message: String - let data: GenericJSON - @ISO1806MilliCodedDate var timestamp: Date - - enum CodingKeys : String, CodingKey { - case message = "msg" - case data = "data" - case timestamp = "timestamp!!timestamp" - } - - init(message: String, data: GenericJSON = .object([:]), timestamp: Date) { - self.message = message - self.data = data - self.timestamp = timestamp - } - -} - -public struct DiagnosticInfo : Codable { - - let systemInformation: SystemInformation - let diagnosticHistory: [DiagnosticEntry] - - // TODO: This field is not used but is required to exist to maintain - // compatibility with the feedback server, preventing formatting errors. - private let statusHistory: [String] = [] - - enum CodingKeys : String, CodingKey { - case systemInformation = "SystemInformation" - case statusHistory = "StatusHistory" - case diagnosticHistory = "DiagnosticHistory" - } - -} - public struct SurveyResponse : Codable { let title: String @@ -216,18 +121,7 @@ public struct DeviceInfo : Codable { } } -public struct FeedbackDiagnosticReport : Codable { - - let metadata: Metadata - let feedback: Feedback? - let diagnosticInfo: DiagnosticInfo - - enum CodingKeys : String, CodingKey { - case metadata = "Metadata" - case feedback = "Feedback" - case diagnosticInfo = "DiagnosticInfo" - } -} + public enum ClientPlatform { @@ -262,6 +156,277 @@ func generateFeedbackId() throws -> String { return feedbackID } + +// MARK: - Version 1 + + +public struct SystemInformationV1 : Codable { + + let build: DeviceInfo + let tunnelCoreBuildInfo: String + let psiphonInfo: PsiphonInfo + let isAppStoreBuild: Bool + let isJailbroken: Bool + + /// BCP-47 identifier in a minimalist form. Script and region may be omitted. For example, "zh-TW", "en" + let language: String + + let networkTypeName: String + + enum CodingKeys : String, CodingKey { + case build = "Build" + case tunnelCoreBuildInfo = "buildInfo" + case psiphonInfo = "PsiphonInfo" + case isAppStoreBuild = "isAppStoreBuild" + case isJailbroken = "isJailbroken" + case language = "language" + case networkTypeName = "networkTypeName" + } + +} + +public struct DiagnosticEntry : Codable { + + let message: String + let data: GenericJSON + @ISO1806MilliCodedDate var timestamp: Date + + enum CodingKeys : String, CodingKey { + case message = "msg" + case data = "data" + case timestamp = "timestamp!!timestamp" + } + + init(message: String, data: GenericJSON = .object([:]), timestamp: Date) { + self.message = message + self.data = data + self.timestamp = timestamp + } + + public static func < (lhs: DiagnosticEntry, rhs: DiagnosticEntry) -> Bool { + return lhs.timestamp < rhs.timestamp + } + +} + +public extension DiagnosticEntry { + + static func create(from tunnelCoreLog: TunnelCoreLog) throws -> DiagnosticEntry { + DiagnosticEntry( + message: "", + data: try GenericJSON( + ["noticeType": tunnelCoreLog.noticeType, + "data": tunnelCoreLog.data, + ]), + timestamp: tunnelCoreLog.timestamp + ) + } + +} + +public struct DiagnosticInfo : Codable { + + let systemInformation: SystemInformationV1 + let diagnosticHistory: [DiagnosticEntry] + private let statusHistory: [String] + + enum CodingKeys : String, CodingKey { + case systemInformation = "SystemInformation" + case statusHistory = "StatusHistory" + case diagnosticHistory = "DiagnosticHistory" + } + +} + +public struct MetadataV1 : Codable { + + /// Feedback version type. + let version: Int = 1 + + /// Feedback ID. + let id: String + + /// Client platform. + let platform: String + + /// App name. + let appName: String + + @ISO1806MilliCodedDate var date: Date + + init(id: String, appName: String, platform: String, date: Date) { + self.id = id + self.appName = appName + self.platform = platform + self.date = date + } + + enum CodingKeys : String, CodingKey { + case version = "version" + case id = "id" + case platform = "platform" + case appName = "appName" + case date = "date!!timestamp" + } + +} + +public struct FeedbackDiagnosticReportV1 : Codable { + + let metadata: MetadataV1 + let feedback: Feedback? + let diagnosticInfo: DiagnosticInfo + + enum CodingKeys : String, CodingKey { + case metadata = "Metadata" + case feedback = "Feedback" + case diagnosticInfo = "DiagnosticInfo" + } +} + +// MARK: - Version 2 + +public struct ApplicationInfo : Codable { + + let applicationId: String + let clientVersion: String + + enum CodingKeys : String, CodingKey { + case applicationId = "applicationId" + case clientVersion = "clientVersion" + } + +} + +public struct SystemInformationV2 : Codable { + + let build: DeviceInfo + let tunnelCoreBuildInfo: String + let isAppStoreBuild: Bool + let isJailbroken: Bool + + /// BCP-47 identifier in a minimalist form. Script and region may be omitted. For example, "zh-TW", "en" + let language: String + + let networkTypeName: String + + enum CodingKeys : String, CodingKey { + case build = "Build" + case tunnelCoreBuildInfo = "buildInfo" + case isAppStoreBuild = "isAppStoreBuild" + case isJailbroken = "isJailbroken" + case language = "language" + case networkTypeName = "networkTypeName" + } + +} + +public enum FeedbackLogLevel : String, Codable { + case trace = "Trace" + case debug = "Debug" + case info = "Info" + case notice = "Notice" + case warning = "Warning" + case error = "Error" + case critical = "Critical" +} + +public struct Log : Comparable, Codable { + + @ISO1806MilliCodedDate var timestamp: Date + let level: FeedbackLogLevel? + let category: String + let message: String? + let data: GenericJSON? + + enum CodingKeys : String, CodingKey { + case timestamp = "timestamp!!timestamp" + case level = "level" + case category = "category" + case message = "message" + case data = "data" + } + + init(timestamp: Date, level: FeedbackLogLevel?, category: String, message: String?, data: GenericJSON? = nil) { + self.timestamp = timestamp + self.level = level + self.category = category + self.message = message + self.data = data + } + + public static func < (lhs: Log, rhs: Log) -> Bool { + return lhs.timestamp < rhs.timestamp + } + +} + +public extension Log { + + static func create(from tunnelCoreLog: TunnelCoreLog) throws -> Log { + Log( + timestamp: tunnelCoreLog.timestamp, + level: nil, + category: "tunnel-core", + message: nil, + data: try GenericJSON( + ["noticeType": tunnelCoreLog.noticeType, + "data": tunnelCoreLog.data, + ]) + ) + } + +} + +public struct MetadataV2 : Codable { + + /// Feedback version type. + let version: Int = 2 + + /// Feedback ID. + let id: String + + /// Client platform. + let platform: String + + /// App name. + let appName: String + + + init(id: String, appName: String, platform: String) { + self.id = id + self.appName = appName + self.platform = platform + } + + enum CodingKeys : String, CodingKey { + case version = "version" + case id = "id" + case platform = "platform" + case appName = "appName" + } + +} + +public struct FeedbackDiagnosticReportV2 : Codable { + + let metadata: MetadataV2 + let systemInformation: SystemInformationV2 + let psiphonInfo: PsiphonInfo + let applicationInfo: ApplicationInfo + let logs: [Log] + + + enum CodingKeys : String, CodingKey { + case metadata = "Metadata" + case systemInformation = "SystemInformation" + case psiphonInfo = "PsiphonInfo" + case applicationInfo = "ApplicationInfo" + case logs = "Logs" + } + +} + // MARK: - // The section below is too platform dependent, @@ -292,21 +457,6 @@ public struct TunnelCoreLog : Codable { @ISO1806MilliCodedDate var timestamp: Date } -public extension DiagnosticEntry { - - static func create(from tunnelCoreLog: TunnelCoreLog) throws -> DiagnosticEntry { - DiagnosticEntry( - message: "", - data: try GenericJSON( - ["noticeType": tunnelCoreLog.noticeType, - "data": tunnelCoreLog.data, - ]), - timestamp: tunnelCoreLog.timestamp - ) - } - -} - #if canImport(PsiphonTunnel) import PsiphonTunnel @@ -414,6 +564,32 @@ extension FeedbackUploadService : PsiphonTunnelLoggerDelegate, PsiphonTunnelFeed #endif +#if canImport(Puppy) +import Puppy + +extension FeedbackLogLevel { + + init(from puppyLogLevel: LogLevel) { + switch puppyLogLevel { + case .trace, .verbose, .debug: + self = .debug + case .info: + self = .info + case .notice: + self = .info + case .warning: + self = .warning + case .error: + self = .error + case .critical: + self = .critical + } + } + +} + +#endif + // MARK: - Basic type @propertyWrapper diff --git a/ios/ConduitModule/FeedbackLogReader.swift b/ios/ConduitModule/Feedback/FeedbackLogReader.swift similarity index 72% rename from ios/ConduitModule/FeedbackLogReader.swift rename to ios/ConduitModule/Feedback/FeedbackLogReader.swift index b0e0b3a..ea2bda0 100644 --- a/ios/ConduitModule/FeedbackLogReader.swift +++ b/ios/ConduitModule/Feedback/FeedbackLogReader.swift @@ -34,7 +34,7 @@ func parseJSONLines( let decoder = JSONDecoder() - for logLine in data.components(separatedBy: "\n") { + for logLine in data.components(separatedBy: .newlines) { guard !logLine.isEmpty else { continue @@ -56,39 +56,32 @@ func parseJSONLines( return (entries, parseErrors) } -/// Reads logs from `paths` of type `T` and converts to `DiagnosticEntry` using the `transform` function. +/// Reads logs from `paths` of type `T` and converts to `LogType` using the `transform` function. /// Paths that do not exist are ignored. -/// The returned `DiagnosticEntry` array is sorted by timestamp in ascending order. -func readDiagnosticLogFiles( - _ type: T.Type, +func readLogFiles( + withLogType type: T.Type, paths: [URL], - transform: (T) throws -> DiagnosticEntry -) throws -> ([DiagnosticEntry], [ParseError]) { + transform: (T) throws -> LogType +) throws -> ([LogType], [ParseError]) { - var diagnosticEntries = [DiagnosticEntry]() + var logs = [LogType]() var parseErrors = [ParseError]() for path in paths { // Ignore paths that don't exist. if !FileManager.default.fileExists(atPath: try path.filePath()) { - Logger.conduitModule.info("No diagnostic file at path: \(path, privacy: .private)") + parseErrors.append(ParseError(message: "No diagnostic file at path: [redacted]/\(path.lastPathComponent)")) continue } let data = try Data(contentsOf: path) let (entries, errs) = parseJSONLines(T.self, data: String(data: data, encoding: .utf8)!) - diagnosticEntries.append(contentsOf: try entries.map(transform)) + logs.append(contentsOf: try entries.map(transform)) parseErrors.append(contentsOf: errs) - - } - - // Sorts the entries by timestamp in ascending order. - diagnosticEntries.sort { - $0.timestamp < $1.timestamp } - return (diagnosticEntries, parseErrors) + return (logs, parseErrors) } diff --git a/ios/ConduitModule/Feedback/LogHandler.swift b/ios/ConduitModule/Feedback/LogHandler.swift new file mode 100644 index 0000000..815d97d --- /dev/null +++ b/ios/ConduitModule/Feedback/LogHandler.swift @@ -0,0 +1,225 @@ +/* + * Copyright (c) 2024, Psiphon Inc. + * All rights reserved. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +/* + The MIT License (MIT) + + Copyright (c) 2020-2023 Koichi Yokota + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + + +import Foundation + +#if canImport(Logging) +import Logging + +public struct OSLogger: LogHandler { + + public var metadata: Logging.Logger.Metadata + public var logLevel: Logging.Logger.Level + public let label: String + public let queue: DispatchQueue + private let osLog: os.Logger + + public init(subsystem: String, label: String, logLevel: Logging.Logger.Level = .trace) { + self.metadata = [:] + self.label = label + self.queue = DispatchQueue(label: label) + self.logLevel = logLevel + self.osLog = os.Logger(subsystem: subsystem, category: label) + } + + public subscript(metadataKey key: String) -> Logging.Logger.Metadata.Value? { + get { + return metadata[key] + } + set(newValue) { + metadata[key] = newValue + } + } + + public func log( + level: Logging.Logger.Level, + message: Logging.Logger.Message, + metadata: Logging.Logger.Metadata?, + source: String, file: String, function: String, line: UInt) + { + let osMsg: String + if let metadata = metadata { + osMsg = "\(message) \(String(describing: metadata))" + } else { + osMsg = "\(message)" + } + + self.osLog.log(level: logType(level), "\(osMsg, privacy: .public)") + } + + private func logType(_ level: Logging.Logger.Level) -> OSLogType { + switch level { + case .trace: + // `OSLog` doesn't have `trace`, so use `debug` instead. + return .debug + case .debug: + return .debug + case .info: + return .info + case .notice: + // `OSLog` doesn't have `notice`, so use `info` instead. + return .info + case .warning: + // `OSLog` doesn't have `warning`, so use `default` instead. + return .default + case .error: + return .error + case .critical: + // `OSLog` doesn't have `critical`, so use `.fault` instead. + return .fault + } + } +} + +#endif // canImport(Logging) + +#if canImport(Logging) && canImport(Puppy) +import Logging +import Puppy + +/// Logs to both OSLog and Puppy. +public struct PsiphonLogHandler: LogHandler { + + public var logLevel: Logging.Logger.Level + public var metadata: Logging.Logger.Metadata + + private let label: String + private let puppy: Puppy + private let metadataEncoder: JSONEncoder + + public init(label: String, logLevel: Logging.Logger.Level, puppy: Puppy, metadata: Logging.Logger.Metadata = [:]) { + self.label = label + self.logLevel = logLevel + self.puppy = puppy + self.metadata = metadata + self.metadataEncoder = JSONEncoder() + } + + public subscript(metadataKey key: String) -> Logging.Logger.Metadata.Value? { + get { + return metadata[key] + } + set(newValue) { + metadata[key] = newValue + } + } + + public func log( + level: Logging.Logger.Level, + message: Logging.Logger.Message, + metadata: Logging.Logger.Metadata?, + source: String, file: String, function: String, line: UInt) + { + + // Log with Puppy + do { + let metadata = mergedMetadata(metadata) + let encodedMetadata = try metadataEncoder.encode(metadata) + var swiftLogInfo = ["label": label, "source": source] + + if let encodedMetadata = String(data: encodedMetadata, encoding: .utf8) { + swiftLogInfo["metadata"] = encodedMetadata + } + puppy.logMessage(level.toPuppy(), message: "\(message)", tag: "swiftlog", function: function, file: file, line: line, swiftLogInfo: swiftLogInfo) + } catch { + os_log(.fault, "failed to encode metadata") + } + + } + + private func mergedMetadata(_ metadata: Logging.Logger.Metadata?) -> Logging.Logger.Metadata { + var mergedMetadata: Logging.Logger.Metadata + if let metadata = metadata { + mergedMetadata = self.metadata.merging(metadata, uniquingKeysWith: { _, new in new }) + } else { + mergedMetadata = self.metadata + } + return mergedMetadata + } + +} + +extension Logging.Logger.MetadataValue : Encodable { + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .stringConvertible(let stringConvertible): + try container.encode(stringConvertible.description) + case .string(let string): + try container.encode(string) + case .array(let array): + try container.encode(array) + case .dictionary(let dict): + try container.encode(dict) + } + } + +} + +extension Logging.Logger.Level { + + func toPuppy() -> LogLevel { + switch self { + case .trace: + return .trace + case .debug: + return .debug + case .info: + return .info + case .notice: + return .notice + case .warning: + return .warning + case .error: + return .error + case .critical: + return .critical + } + } + +} + +#endif // canImport(Logging) & canImport(Puppy) + diff --git a/ios/ConduitModule/Resources.swift b/ios/ConduitModule/Resources.swift index f259a66..2f244f8 100644 --- a/ios/ConduitModule/Resources.swift +++ b/ios/ConduitModule/Resources.swift @@ -85,6 +85,11 @@ func getClientVersion() -> String { Bundle.main.infoDictionary!["CFBundleVersion"] as! String } +/// Returns bundle identifier (`CFBundleIdentifier` defined in `Info.plist` file). +func getApplicagtionId() -> String { + Bundle.main.bundleIdentifier! +} + extension URL { /// Returns valid file path if this URL points to a local file. diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 67cec83..e8b9ae9 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1010,7 +1010,7 @@ PODS: - React-debug - react-native-safe-area-context (4.10.5): - React-Core - - react-native-skia (1.3.13): + - react-native-skia (1.5.0): - DoubleConversion - glog - hermes-engine @@ -1623,7 +1623,7 @@ SPEC CHECKSUMS: React-logger: 257858bd55f3a4e1bc0cf07ddc8fb9faba6f8c7c React-Mapbuffer: 6c1cacdbf40b531f549eba249e531a7d0bfd8e7f react-native-safe-area-context: a240ad4b683349e48b1d51fed1611138d1bdad97 - react-native-skia: dca6ed315f74bd4ae2b26368f7029dc8d17ba7e7 + react-native-skia: dd503e4426d5dcd578a2d11c9261fb591501924b React-nativeconfig: ba9a2e54e2f0882cf7882698825052793ed4c851 React-NativeModulesApple: 8d11ff8955181540585c944cf48e9e7236952697 React-perflogger: ed4e0c65781521e0424f2e5e40b40cc7879d737e diff --git a/ios/conduit.xcodeproj/project.pbxproj b/ios/conduit.xcodeproj/project.pbxproj index a034c13..ce67cb0 100644 --- a/ios/conduit.xcodeproj/project.pbxproj +++ b/ios/conduit.xcodeproj/project.pbxproj @@ -13,6 +13,9 @@ 293BD6822CB6FA3C006B04D3 /* Feedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 293BD6812CB6FA3C006B04D3 /* Feedback.swift */; }; 293BD69A2CB84246006B04D3 /* FeedbackLogReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 293BD6992CB84246006B04D3 /* FeedbackLogReader.swift */; }; 294484BD2CACF05C00451B61 /* Resources.swift in Sources */ = {isa = PBXBuildFile; fileRef = 294484BC2CACF05C00451B61 /* Resources.swift */; }; + 2974F3652CD95382000E8D3F /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 2974F3642CD95382000E8D3F /* Logging */; }; + 2974F3682CDAA7F6000E8D3F /* LogHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2974F3672CDAA7F6000E8D3F /* LogHandler.swift */; }; + 2974F36A2CDAC4CB000E8D3F /* AppLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2974F3692CDAC4CB000E8D3F /* AppLogger.swift */; }; 29A9C9FD2CA5FFDD0054431A /* ConduitModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29A9C9FC2CA5FFDD0054431A /* ConduitModule.swift */; }; 29A9C9FF2CA601D20054431A /* ConduitModule.mm in Sources */ = {isa = PBXBuildFile; fileRef = 29A9C9FE2CA600220054431A /* ConduitModule.mm */; }; 29A9CA032CA715720054431A /* PsiphonTunnel.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 29A9CA012CA715410054431A /* PsiphonTunnel.xcframework */; }; @@ -21,6 +24,7 @@ 29EB55312CA726790042B1B4 /* ios_embedded_server_entries in Resources */ = {isa = PBXBuildFile; fileRef = 29EB552F2CA726790042B1B4 /* ios_embedded_server_entries */; }; 29EB55322CA726790042B1B4 /* ios_psiphon_config in Resources */ = {isa = PBXBuildFile; fileRef = 29EB55302CA726790042B1B4 /* ios_psiphon_config */; }; 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; + 6773BCF42CD2C7B800CF4ADE /* Puppy in Frameworks */ = {isa = PBXBuildFile; productRef = 6773BCF32CD2C7B800CF4ADE /* Puppy */; }; 67A1F0232CC027D600AE7C39 /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = 67A1F0222CC027D600AE7C39 /* Collections */; }; 96905EF65AED1B983A6B3ABC /* libPods-conduit.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-conduit.a */; }; A86CEE5837C5BBF9CD8BB436 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 23B8187366DE26611566EC73 /* PrivacyInfo.xcprivacy */; }; @@ -55,6 +59,8 @@ 293BD6812CB6FA3C006B04D3 /* Feedback.swift */ = {isa = PBXFileReference; fileEncoding = 4; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = Feedback.swift; sourceTree = ""; tabWidth = 4; }; 293BD6992CB84246006B04D3 /* FeedbackLogReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackLogReader.swift; sourceTree = ""; }; 294484BC2CACF05C00451B61 /* Resources.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = Resources.swift; sourceTree = ""; tabWidth = 4; }; + 2974F3672CDAA7F6000E8D3F /* LogHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogHandler.swift; sourceTree = ""; }; + 2974F3692CDAC4CB000E8D3F /* AppLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLogger.swift; sourceTree = ""; }; 29A9C9FC2CA5FFDD0054431A /* ConduitModule.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = ConduitModule.swift; sourceTree = ""; tabWidth = 4; }; 29A9C9FE2CA600220054431A /* ConduitModule.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = ConduitModule.mm; sourceTree = ""; }; 29A9CA002CA602340054431A /* ConduitModule-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "ConduitModule-Bridging-Header.h"; sourceTree = ""; }; @@ -80,6 +86,8 @@ 67A1F0232CC027D600AE7C39 /* Collections in Frameworks */, 96905EF65AED1B983A6B3ABC /* libPods-conduit.a in Frameworks */, 29A9CA032CA715720054431A /* PsiphonTunnel.xcframework in Frameworks */, + 6773BCF42CD2C7B800CF4ADE /* Puppy in Frameworks */, + 2974F3652CD95382000E8D3F /* Logging in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -107,16 +115,26 @@ name = conduit; sourceTree = ""; }; - 29A9C9FB2CA5FF1C0054431A /* ConduitModule */ = { + 2974F3662CDAA7DE000E8D3F /* Feedback */ = { isa = PBXGroup; children = ( 293BD6812CB6FA3C006B04D3 /* Feedback.swift */, + 293BD6992CB84246006B04D3 /* FeedbackLogReader.swift */, + 2974F3672CDAA7F6000E8D3F /* LogHandler.swift */, + ); + path = Feedback; + sourceTree = ""; + }; + 29A9C9FB2CA5FF1C0054431A /* ConduitModule */ = { + isa = PBXGroup; + children = ( + 2974F3662CDAA7DE000E8D3F /* Feedback */, 29A9C9FC2CA5FFDD0054431A /* ConduitModule.swift */, + 2974F3692CDAC4CB000E8D3F /* AppLogger.swift */, 29A9C9FE2CA600220054431A /* ConduitModule.mm */, 29A9CA002CA602340054431A /* ConduitModule-Bridging-Header.h */, 29A9CA062CA715DF0054431A /* ConduitManager.swift */, 294484BC2CACF05C00451B61 /* Resources.swift */, - 293BD6992CB84246006B04D3 /* FeedbackLogReader.swift */, ); path = ConduitModule; sourceTree = ""; @@ -218,6 +236,8 @@ name = conduit; packageProductDependencies = ( 67A1F0222CC027D600AE7C39 /* Collections */, + 6773BCF32CD2C7B800CF4ADE /* Puppy */, + 2974F3642CD95382000E8D3F /* Logging */, ); productName = conduit; productReference = 13B07F961A680F5B00A75B9A /* conduit.app */; @@ -247,6 +267,8 @@ mainGroup = 83CBB9F61A601CBA00E9B192; packageReferences = ( 673EB98A2CC01E25003A3E39 /* XCRemoteSwiftPackageReference "swift-collections" */, + 6773BCF22CD2C7B800CF4ADE /* XCRemoteSwiftPackageReference "Puppy" */, + 2974F3632CD95382000E8D3F /* XCRemoteSwiftPackageReference "swift-log" */, ); productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; projectDirPath = ""; @@ -388,7 +410,9 @@ buildActionMask = 2147483647; files = ( 29A9C9FF2CA601D20054431A /* ConduitModule.mm in Sources */, + 2974F3682CDAA7F6000E8D3F /* LogHandler.swift in Sources */, 293BD6822CB6FA3C006B04D3 /* Feedback.swift in Sources */, + 2974F36A2CDAC4CB000E8D3F /* AppLogger.swift in Sources */, 13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */, 13B07FC11A68108700A75B9A /* main.m in Sources */, B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */, @@ -417,6 +441,7 @@ "$(inherited)", "FB_SONARKIT_ENABLED=1", ); + GCC_PREPROCESSOR_DEFINITIONS_NOT_USED_IN_PRECOMPS = ""; INFOPLIST_FILE = conduit/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -432,6 +457,7 @@ OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; PRODUCT_BUNDLE_IDENTIFIER = ca.psiphon.conduit; PRODUCT_NAME = conduit; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OBJC_BRIDGING_HEADER = "conduit/conduit-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -449,6 +475,10 @@ CODE_SIGN_ENTITLEMENTS = conduit/conduit.entitlements; CURRENT_PROJECT_VERSION = 11; DEVELOPMENT_TEAM = Q6HLNEX92A; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "COCOAPODS=1", + ); INFOPLIST_FILE = conduit/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -530,10 +560,7 @@ LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; @@ -591,10 +618,7 @@ ); LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; MTL_ENABLE_DEBUG_INFO = NO; - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; @@ -626,6 +650,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 2974F3632CD95382000E8D3F /* XCRemoteSwiftPackageReference "swift-log" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-log/"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.5.2; + }; + }; 673EB98A2CC01E25003A3E39 /* XCRemoteSwiftPackageReference "swift-collections" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/apple/swift-collections.git"; @@ -634,9 +666,27 @@ minimumVersion = 1.1.4; }; }; + 6773BCF22CD2C7B800CF4ADE /* XCRemoteSwiftPackageReference "Puppy" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sushichop/Puppy"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.7.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 2974F3642CD95382000E8D3F /* Logging */ = { + isa = XCSwiftPackageProductDependency; + package = 2974F3632CD95382000E8D3F /* XCRemoteSwiftPackageReference "swift-log" */; + productName = Logging; + }; + 6773BCF32CD2C7B800CF4ADE /* Puppy */ = { + isa = XCSwiftPackageProductDependency; + package = 6773BCF22CD2C7B800CF4ADE /* XCRemoteSwiftPackageReference "Puppy" */; + productName = Puppy; + }; 67A1F0222CC027D600AE7C39 /* Collections */ = { isa = XCSwiftPackageProductDependency; package = 673EB98A2CC01E25003A3E39 /* XCRemoteSwiftPackageReference "swift-collections" */; diff --git a/ios/conduit.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/conduit.xcworkspace/xcshareddata/swiftpm/Package.resolved index cdd6991..7338b39 100644 --- a/ios/conduit.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/conduit.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "51f90653b2c9f9f7064c0d52159b40bf7d222e5f314be23e62fe28520fec03db", + "originHash" : "67cf71632c338117d70d4442c3c3b300089147fbe270082672adb70d1ab91346", "pins" : [ + { + "identity" : "puppy", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sushichop/Puppy", + "state" : { + "revision" : "b5af02a72a5a1f92a68e6eceee19cac804067ad9", + "version" : "0.7.0" + } + }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", @@ -9,6 +18,15 @@ "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", "version" : "1.1.4" } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "e97a6fcb1ab07462881ac165fdbb37f067e205d5", + "version" : "1.5.4" + } } ], "version" : 3