SBPlatformDestination.swift 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621
  1. //
  2. // SBPlatformDestination
  3. // SwiftyBeaver
  4. //
  5. // Created by Sebastian Kreutzberger on 22.01.16.
  6. // Copyright © 2016 Sebastian Kreutzberger
  7. // Some rights reserved: http://opensource.org/licenses/MIT
  8. //
  9. import Foundation
  10. // platform-dependent import frameworks to get device details
  11. // valid values for os(): OSX, iOS, watchOS, tvOS, Linux
  12. // in Swift 3 the following were added: FreeBSD, Windows, Android
  13. #if os(iOS) || os(tvOS) || os(watchOS)
  14. import UIKit
  15. var DEVICE_MODEL: String {
  16. get {
  17. var systemInfo = utsname()
  18. uname(&systemInfo)
  19. let machineMirror = Mirror(reflecting: systemInfo.machine)
  20. let identifier = machineMirror.children.reduce("") { identifier, element in
  21. guard let value = element.value as? Int8, value != 0 else { return identifier }
  22. return identifier + String(UnicodeScalar(UInt8(value)))
  23. }
  24. return identifier
  25. }
  26. }
  27. #else
  28. let DEVICE_MODEL = ""
  29. #endif
  30. #if os(iOS) || os(tvOS)
  31. var DEVICE_NAME = UIDevice.current.name
  32. #else
  33. // under watchOS UIDevice is not existing, http://apple.co/26ch5J1
  34. let DEVICE_NAME = ""
  35. #endif
  36. public class SBPlatformDestination: BaseDestination {
  37. public var appID = ""
  38. public var appSecret = ""
  39. public var encryptionKey = ""
  40. public var analyticsUserName = "" // user email, ID, name, etc.
  41. public var analyticsUUID: String { return uuid }
  42. // when to send to server
  43. public struct SendingPoints {
  44. public var verbose = 0
  45. public var debug = 1
  46. public var info = 5
  47. public var warning = 8
  48. public var error = 10
  49. public var threshold = 10 // send to server if points reach that value
  50. }
  51. public var sendingPoints = SendingPoints()
  52. public var showNSLog = false // executes toNSLog statements to debug the class
  53. var points = 0
  54. public var serverURL = URL(string: "https://api.swiftybeaver.com/api/entries/") // optional
  55. public var entriesFileURL = URL(fileURLWithPath: "") // not optional
  56. public var sendingFileURL = URL(fileURLWithPath: "")
  57. public var analyticsFileURL = URL(fileURLWithPath: "")
  58. private let minAllowedThreshold = 1 // over-rules SendingPoints.Threshold
  59. private let maxAllowedThreshold = 1000 // over-rules SendingPoints.Threshold
  60. private var sendingInProgress = false
  61. private var initialSending = true
  62. // analytics
  63. var uuid = ""
  64. // destination
  65. override public var defaultHashValue: Int {return 3}
  66. let fileManager = FileManager.default
  67. let isoDateFormatter = DateFormatter()
  68. /// init platform with default internal filenames
  69. public init(appID: String, appSecret: String, encryptionKey: String,
  70. serverURL: URL? = URL(string: "https://api.swiftybeaver.com/api/entries/"),
  71. entriesFileName: String = "sbplatform_entries.json",
  72. sendingfileName: String = "sbplatform_entries_sending.json",
  73. analyticsFileName: String = "sbplatform_analytics.json") {
  74. super.init()
  75. self.serverURL = serverURL
  76. self.appID = appID
  77. self.appSecret = appSecret
  78. self.encryptionKey = encryptionKey
  79. // setup where to write the json files
  80. var baseURL: URL?
  81. #if os(OSX)
  82. if let url = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
  83. baseURL = url
  84. // try to use ~/Library/Application Support/APP NAME instead of ~/Library/Application Support
  85. if let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleExecutable") as? String {
  86. do {
  87. if let appURL = baseURL?.appendingPathComponent(appName, isDirectory: true) {
  88. try fileManager.createDirectory(at: appURL,
  89. withIntermediateDirectories: true, attributes: nil)
  90. baseURL = appURL
  91. }
  92. } catch {
  93. // it is too early in the class lifetime to be able to use toNSLog()
  94. print("Warning! Could not create folder ~/Library/Application Support/\(appName).")
  95. }
  96. }
  97. }
  98. #else
  99. #if os(tvOS)
  100. // tvOS can just use the caches directory
  101. if let url = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first {
  102. baseURL = url
  103. }
  104. #elseif os(Linux)
  105. // Linux is using /var/cache
  106. let baseDir = "/var/cache/"
  107. entriesFileURL = URL(fileURLWithPath: baseDir + entriesFileName)
  108. sendingFileURL = URL(fileURLWithPath: baseDir + sendingfileName)
  109. analyticsFileURL = URL(fileURLWithPath: baseDir + analyticsFileName)
  110. #else
  111. // iOS and watchOS are using the app’s document directory
  112. if let url = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
  113. baseURL = url
  114. }
  115. #endif
  116. #endif
  117. #if os(Linux)
  118. // get, update loaded and save analytics data to file on start
  119. let dict = analytics(analyticsFileURL, update: true)
  120. _ = saveDictToFile(dict, url: analyticsFileURL)
  121. #else
  122. if let baseURL = baseURL {
  123. // is just set for everything but not Linux
  124. entriesFileURL = baseURL.appendingPathComponent(entriesFileName,
  125. isDirectory: false)
  126. sendingFileURL = baseURL.appendingPathComponent(sendingfileName,
  127. isDirectory: false)
  128. analyticsFileURL = baseURL.appendingPathComponent(analyticsFileName,
  129. isDirectory: false)
  130. // get, update loaded and save analytics data to file on start
  131. let dict = analytics(analyticsFileURL, update: true)
  132. _ = saveDictToFile(dict, url: analyticsFileURL)
  133. }
  134. #endif
  135. }
  136. // append to file, each line is a JSON dict
  137. override public func send(_ level: SwiftyBeaver.Level, msg: String, thread: String,
  138. file: String, function: String, line: Int, context: Any? = nil) -> String? {
  139. var jsonString: String?
  140. let dict: [String: Any] = [
  141. "timestamp": Date().timeIntervalSince1970,
  142. "level": level.rawValue,
  143. "message": msg,
  144. "thread": thread,
  145. "fileName": file.components(separatedBy: "/").last!,
  146. "function": function,
  147. "line": line]
  148. jsonString = jsonStringFromDict(dict)
  149. if let str = jsonString {
  150. toNSLog("saving '\(msg)' to \(entriesFileURL)")
  151. _ = saveToFile(str, url: entriesFileURL)
  152. //toNSLog(entriesFileURL.path!)
  153. // now decide if the stored log entries should be sent to the server
  154. // add level points to current points amount and send to server if threshold is hit
  155. let newPoints = sendingPointsForLevel(level)
  156. points += newPoints
  157. toNSLog("current sending points: \(points)")
  158. if (points >= sendingPoints.threshold && points >= minAllowedThreshold) || points > maxAllowedThreshold {
  159. toNSLog("\(points) points is >= threshold")
  160. // above threshold, send to server
  161. sendNow()
  162. } else if initialSending {
  163. initialSending = false
  164. // first logging at this session
  165. // send if json file still contains old log entries
  166. if let logEntries = logsFromFile(entriesFileURL) {
  167. let lines = logEntries.count
  168. if lines > 1 {
  169. var msg = "initialSending: \(points) points is below threshold "
  170. msg += "but json file already has \(lines) lines."
  171. toNSLog(msg)
  172. sendNow()
  173. }
  174. }
  175. }
  176. }
  177. return jsonString
  178. }
  179. // MARK: Send-to-Server Logic
  180. /// does a (manual) sending attempt of all unsent log entries to SwiftyBeaver Platform
  181. public func sendNow() {
  182. if sendFileExists() {
  183. toNSLog("reset points to 0")
  184. points = 0
  185. } else {
  186. if !renameJsonToSendFile() {
  187. return
  188. }
  189. }
  190. if !sendingInProgress {
  191. sendingInProgress = true
  192. //let (jsonString, lines) = logsFromFile(sendingFileURL)
  193. var lines = 0
  194. guard let logEntries = logsFromFile(sendingFileURL) else {
  195. sendingInProgress = false
  196. return
  197. }
  198. lines = logEntries.count
  199. if lines > 0 {
  200. var payload = [String: Any]()
  201. // merge device and analytics dictionaries
  202. let deviceDetailsDict = deviceDetails()
  203. var analyticsDict = analytics(analyticsFileURL)
  204. for key in deviceDetailsDict.keys {
  205. analyticsDict[key] = deviceDetailsDict[key]
  206. }
  207. payload["device"] = analyticsDict
  208. payload["entries"] = logEntries
  209. if let str = jsonStringFromDict(payload) {
  210. //toNSLog(str) // uncomment to see full payload
  211. toNSLog("Encrypting \(lines) log entries ...")
  212. if let encryptedStr = encrypt(str) {
  213. var msg = "Sending \(lines) encrypted log entries "
  214. msg += "(\(encryptedStr.length) chars) to server ..."
  215. toNSLog(msg)
  216. sendToServerAsync(encryptedStr) { ok, _ in
  217. self.toNSLog("Sent \(lines) encrypted log entries to server, received ok: \(ok)")
  218. if ok {
  219. _ = self.deleteFile(self.sendingFileURL)
  220. }
  221. self.sendingInProgress = false
  222. self.points = 0
  223. }
  224. }
  225. }
  226. } else {
  227. sendingInProgress = false
  228. }
  229. }
  230. }
  231. /// sends a string to the SwiftyBeaver Platform server, returns ok if status 200 and HTTP status
  232. func sendToServerAsync(_ str: String?, complete: @escaping (_ ok: Bool, _ status: Int) -> Void) {
  233. let timeout = 10.0
  234. if let payload = str, let queue = self.queue, let serverURL = serverURL {
  235. // create operation queue which uses current serial queue of destination
  236. let operationQueue = OperationQueue()
  237. operationQueue.underlyingQueue = queue
  238. let session = URLSession(configuration:
  239. URLSessionConfiguration.default,
  240. delegate: nil, delegateQueue: operationQueue)
  241. toNSLog("assembling request ...")
  242. // assemble request
  243. var request = URLRequest(url: serverURL,
  244. cachePolicy: .reloadIgnoringLocalAndRemoteCacheData,
  245. timeoutInterval: timeout)
  246. request.httpMethod = "POST"
  247. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  248. request.addValue("application/json", forHTTPHeaderField: "Accept")
  249. // basic auth header (just works on Linux for Swift 3.1+, macOS is fine)
  250. guard let credentials = "\(appID):\(appSecret)".data(using: String.Encoding.utf8) else {
  251. toNSLog("Error! Could not set basic auth header")
  252. return complete(false, 0)
  253. }
  254. #if os(Linux)
  255. let base64Credentials = Base64.encode([UInt8](credentials))
  256. #else
  257. let base64Credentials = credentials.base64EncodedString(options: [])
  258. #endif
  259. request.setValue("Basic \(base64Credentials)", forHTTPHeaderField: "Authorization")
  260. //toNSLog("\nrequest:")
  261. //print(request)
  262. // POST parameters
  263. let params = ["payload": payload]
  264. do {
  265. request.httpBody = try JSONSerialization.data(withJSONObject: params, options: [])
  266. } catch {
  267. toNSLog("Error! Could not create JSON for server payload.")
  268. return complete(false, 0)
  269. }
  270. toNSLog("sending params: \(params)")
  271. toNSLog("sending ...")
  272. sendingInProgress = true
  273. // send request async to server on destination queue
  274. let task = session.dataTask(with: request) { _, response, error in
  275. var ok = false
  276. var status = 0
  277. self.toNSLog("received response from server")
  278. if let error = error {
  279. // an error did occur
  280. self.toNSLog("Error! Could not send entries to server. \(error)")
  281. } else {
  282. if let response = response as? HTTPURLResponse {
  283. status = response.statusCode
  284. if status == 200 {
  285. // all went well, entries were uploaded to server
  286. ok = true
  287. } else {
  288. // status code was not 200
  289. var msg = "Error! Sending entries to server failed "
  290. msg += "with status code \(status)"
  291. self.toNSLog(msg)
  292. }
  293. }
  294. }
  295. return complete(ok, status)
  296. }
  297. task.resume()
  298. session.finishTasksAndInvalidate()
  299. //while true {} // commenting this line causes a crash on Linux unit tests?!?
  300. }
  301. }
  302. /// returns sending points based on level
  303. func sendingPointsForLevel(_ level: SwiftyBeaver.Level) -> Int {
  304. switch level {
  305. case .debug:
  306. return sendingPoints.debug
  307. case .info:
  308. return sendingPoints.info
  309. case .warning:
  310. return sendingPoints.warning
  311. case .error:
  312. return sendingPoints.error
  313. default:
  314. return sendingPoints.verbose
  315. }
  316. }
  317. // MARK: File Handling
  318. /// appends a string as line to a file.
  319. /// returns boolean about success
  320. func saveToFile(_ str: String, url: URL, overwrite: Bool = false) -> Bool {
  321. do {
  322. if fileManager.fileExists(atPath: url.path) == false || overwrite {
  323. // create file if not existing
  324. let line = str + "\n"
  325. try line.write(to: url, atomically: true, encoding: String.Encoding.utf8)
  326. } else {
  327. // append to end of file
  328. let fileHandle = try FileHandle(forWritingTo: url)
  329. _ = fileHandle.seekToEndOfFile()
  330. let line = str + "\n"
  331. if let data = line.data(using: String.Encoding.utf8) {
  332. fileHandle.write(data)
  333. fileHandle.closeFile()
  334. }
  335. }
  336. return true
  337. } catch {
  338. toNSLog("Error! Could not write to file \(url).")
  339. return false
  340. }
  341. }
  342. func sendFileExists() -> Bool {
  343. return fileManager.fileExists(atPath: sendingFileURL.path)
  344. }
  345. func renameJsonToSendFile() -> Bool {
  346. do {
  347. try fileManager.moveItem(at: entriesFileURL, to: sendingFileURL)
  348. return true
  349. } catch {
  350. toNSLog("SwiftyBeaver Platform Destination could not rename json file.")
  351. return false
  352. }
  353. }
  354. /// returns optional array of log dicts from a file which has 1 json string per line
  355. func logsFromFile(_ url: URL) -> [[String:Any]]? {
  356. var lines = 0
  357. do {
  358. // try to read file, decode every JSON line and put dict from each line in array
  359. let fileContent = try String(contentsOfFile: url.path, encoding: .utf8)
  360. let linesArray = fileContent.components(separatedBy: "\n")
  361. var dicts = [[String: Any]()] // array of dictionaries
  362. for lineJSON in linesArray {
  363. lines += 1
  364. if lineJSON.firstChar == "{" && lineJSON.lastChar == "}" {
  365. // try to parse json string into dict
  366. if let data = lineJSON.data(using: .utf8) {
  367. do {
  368. if let dict = try JSONSerialization.jsonObject(with: data,
  369. options: .mutableContainers) as? [String:Any] {
  370. if !dict.isEmpty {
  371. dicts.append(dict)
  372. }
  373. }
  374. } catch {
  375. var msg = "Error! Could not parse "
  376. msg += "line \(lines) in file \(url)."
  377. toNSLog(msg)
  378. }
  379. }
  380. }
  381. }
  382. dicts.removeFirst()
  383. return dicts
  384. } catch {
  385. toNSLog("Error! Could not read file \(url).")
  386. }
  387. return nil
  388. }
  389. /// returns AES-256 CBC encrypted optional string
  390. func encrypt(_ str: String) -> String? {
  391. return AES256CBC.encryptString(str, password: encryptionKey)
  392. }
  393. /// Delete file to get started again
  394. func deleteFile(_ url: URL) -> Bool {
  395. do {
  396. try FileManager.default.removeItem(at: url)
  397. return true
  398. } catch {
  399. toNSLog("Warning! Could not delete file \(url).")
  400. }
  401. return false
  402. }
  403. // MARK: Device & Analytics
  404. // returns dict with device details. Amount depends on platform
  405. func deviceDetails() -> [String: String] {
  406. var details = [String: String]()
  407. details["os"] = OS
  408. let osVersion = ProcessInfo.processInfo.operatingSystemVersion
  409. // becomes for example 10.11.2 for El Capitan
  410. var osVersionStr = String(osVersion.majorVersion)
  411. osVersionStr += "." + String(osVersion.minorVersion)
  412. osVersionStr += "." + String(osVersion.patchVersion)
  413. details["osVersion"] = osVersionStr
  414. details["hostName"] = ProcessInfo.processInfo.hostName
  415. details["deviceName"] = ""
  416. details["deviceModel"] = ""
  417. if DEVICE_NAME != "" {
  418. details["deviceName"] = DEVICE_NAME
  419. }
  420. if DEVICE_MODEL != "" {
  421. details["deviceModel"] = DEVICE_MODEL
  422. }
  423. return details
  424. }
  425. /// returns (updated) analytics dict, optionally loaded from file.
  426. func analytics(_ url: URL, update: Bool = false) -> [String:Any] {
  427. var dict = [String: Any]()
  428. let now = NSDate().timeIntervalSince1970
  429. uuid = NSUUID().uuidString
  430. dict["uuid"] = uuid
  431. dict["firstStart"] = now
  432. dict["lastStart"] = now
  433. dict["starts"] = 1
  434. dict["userName"] = analyticsUserName
  435. dict["firstAppVersion"] = appVersion()
  436. dict["appVersion"] = appVersion()
  437. dict["firstAppBuild"] = appBuild()
  438. dict["appBuild"] = appBuild()
  439. if let loadedDict = dictFromFile(analyticsFileURL) {
  440. if let val = loadedDict["firstStart"] as? Double {
  441. dict["firstStart"] = val
  442. }
  443. if let val = loadedDict["lastStart"] as? Double {
  444. if update {
  445. dict["lastStart"] = now
  446. } else {
  447. dict["lastStart"] = val
  448. }
  449. }
  450. if let val = loadedDict["starts"] as? Int {
  451. if update {
  452. dict["starts"] = val + 1
  453. } else {
  454. dict["starts"] = val
  455. }
  456. }
  457. if let val = loadedDict["uuid"] as? String {
  458. dict["uuid"] = val
  459. uuid = val
  460. }
  461. if let val = loadedDict["userName"] as? String {
  462. if update && !analyticsUserName.isEmpty {
  463. dict["userName"] = analyticsUserName
  464. } else {
  465. if !val.isEmpty {
  466. dict["userName"] = val
  467. }
  468. }
  469. }
  470. if let val = loadedDict["firstAppVersion"] as? String {
  471. dict["firstAppVersion"] = val
  472. }
  473. if let val = loadedDict["firstAppBuild"] as? Int {
  474. dict["firstAppBuild"] = val
  475. }
  476. }
  477. return dict
  478. }
  479. /// Returns the current app version string (like 1.2.5) or empty string on error
  480. func appVersion() -> String {
  481. if let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String {
  482. return version
  483. }
  484. return ""
  485. }
  486. /// Returns the current app build as integer (like 563, always incrementing) or 0 on error
  487. func appBuild() -> Int {
  488. if let version = Bundle.main.infoDictionary?["CFBundleVersion"] as? String {
  489. if let intVersion = Int(version) {
  490. return intVersion
  491. }
  492. }
  493. return 0
  494. }
  495. /// returns optional dict from a json encoded file
  496. func dictFromFile(_ url: URL) -> [String:Any]? {
  497. do {
  498. let fileContent = try String(contentsOfFile: url.path, encoding: .utf8)
  499. if let data = fileContent.data(using: .utf8) {
  500. return try JSONSerialization.jsonObject(with: data,
  501. options: .mutableContainers) as? [String:Any]
  502. }
  503. } catch {
  504. toNSLog("SwiftyBeaver Platform Destination could not read file \(url)")
  505. }
  506. return nil
  507. }
  508. // turns dict into JSON and saves it to file
  509. func saveDictToFile(_ dict: [String: Any], url: URL) -> Bool {
  510. let jsonString = jsonStringFromDict(dict)
  511. if let str = jsonString {
  512. toNSLog("saving '\(str)' to \(url)")
  513. return saveToFile(str, url: url, overwrite: true)
  514. }
  515. return false
  516. }
  517. // MARK: Debug Helpers
  518. /// log String to toNSLog. Used to debug the class logic
  519. func toNSLog(_ str: String) {
  520. if showNSLog {
  521. #if os(Linux)
  522. print("SBPlatform: \(str)")
  523. #else
  524. NSLog("SBPlatform: \(str)")
  525. #endif
  526. }
  527. }
  528. /// returns the current thread name
  529. class func threadName() -> String {
  530. #if os(Linux)
  531. // on 9/30/2016 not yet implemented in server-side Swift:
  532. // > import Foundation
  533. // > Thread.isMainThread
  534. return ""
  535. #else
  536. if Thread.isMainThread {
  537. return ""
  538. } else {
  539. let threadName = Thread.current.name
  540. if let threadName = threadName, !threadName.isEmpty {
  541. return threadName
  542. } else {
  543. return String(format: "%p", Thread.current)
  544. }
  545. }
  546. #endif
  547. }
  548. }