SBPlatformDestination.swift 23 KB

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