Files
allsochen 79f99bd4f1 feat: major rehaul of the preferences!
closes #351

BREAKING CHANGE: The old preferences panel has been replaced with a brand new one

Preferences are now much simpler, full of visual illustrations, and should provide a much better experience. Pick between 3 styles (thumbnails, app-icons, titles), sizes, dark/light themes, high visibility options, and more!
2024-10-10 11:19:39 +02:00

626 lines
23 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// SBPlatformDestination
// SwiftyBeaver
//
// Created by Sebastian Kreutzberger on 22.01.16.
// Copyright © 2016 Sebastian Kreutzberger
// Some rights reserved: http://opensource.org/licenses/MIT
//
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
// platform-dependent import frameworks to get device details
// valid values for os(): OSX, iOS, watchOS, tvOS, Linux
// in Swift 3 the following were added: FreeBSD, Windows, Android
#if os(iOS) || os(tvOS) || os(watchOS)
import UIKit
var DEVICE_MODEL: String {
get {
var systemInfo = utsname()
uname(&systemInfo)
let machineMirror = Mirror(reflecting: systemInfo.machine)
let identifier = machineMirror.children.reduce("") { identifier, element in
guard let value = element.value as? Int8, value != 0 else { return identifier }
return identifier + String(UnicodeScalar(UInt8(value)))
}
return identifier
}
}
#else
let DEVICE_MODEL = ""
#endif
#if os(iOS) || os(tvOS)
var DEVICE_NAME = UIDevice.current.name
#else
// under watchOS UIDevice is not existing, http://apple.co/26ch5J1
let DEVICE_NAME = ""
#endif
public class SBPlatformDestination: BaseDestination {
public var appID = ""
public var appSecret = ""
public var encryptionKey = ""
public var analyticsUserName = "" // user email, ID, name, etc.
public var analyticsUUID: String { return uuid }
// when to send to server
public struct SendingPoints {
public var verbose = 0
public var debug = 1
public var info = 5
public var warning = 8
public var error = 10
public var threshold = 10 // send to server if points reach that value
}
public var sendingPoints = SendingPoints()
public var showNSLog = false // executes toNSLog statements to debug the class
var points = 0
public var serverURL = URL(string: "https://api.swiftybeaver.com/api/entries/") // optional
public var entriesFileURL = URL(fileURLWithPath: "") // not optional
public var sendingFileURL = URL(fileURLWithPath: "")
public var analyticsFileURL = URL(fileURLWithPath: "")
private let minAllowedThreshold = 1 // over-rules SendingPoints.Threshold
private let maxAllowedThreshold = 1000 // over-rules SendingPoints.Threshold
private var sendingInProgress = false
private var initialSending = true
// analytics
var uuid = ""
// destination
override public var defaultHashValue: Int {return 3}
let fileManager = FileManager.default
let isoDateFormatter = DateFormatter()
/// init platform with default internal filenames
public init(appID: String, appSecret: String, encryptionKey: String,
serverURL: URL? = URL(string: "https://api.swiftybeaver.com/api/entries/"),
entriesFileName: String = "sbplatform_entries.json",
sendingfileName: String = "sbplatform_entries_sending.json",
analyticsFileName: String = "sbplatform_analytics.json") {
super.init()
self.serverURL = serverURL
self.appID = appID
self.appSecret = appSecret
self.encryptionKey = encryptionKey
// setup where to write the json files
var baseURL: URL?
#if os(OSX)
if let url = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
baseURL = url
// try to use ~/Library/Application Support/APP NAME instead of ~/Library/Application Support
if let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleExecutable") as? String {
do {
if let appURL = baseURL?.appendingPathComponent(appName, isDirectory: true) {
try fileManager.createDirectory(at: appURL,
withIntermediateDirectories: true, attributes: nil)
baseURL = appURL
}
} catch {
// it is too early in the class lifetime to be able to use toNSLog()
print("Warning! Could not create folder ~/Library/Application Support/\(appName).")
}
}
}
#else
#if os(tvOS)
// tvOS can just use the caches directory
if let url = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first {
baseURL = url
}
#elseif os(Linux)
// Linux is using /var/cache
let baseDir = "/var/cache/"
entriesFileURL = URL(fileURLWithPath: baseDir + entriesFileName)
sendingFileURL = URL(fileURLWithPath: baseDir + sendingfileName)
analyticsFileURL = URL(fileURLWithPath: baseDir + analyticsFileName)
#else
// iOS and watchOS are using the app’s document directory
if let url = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
baseURL = url
}
#endif
#endif
#if os(Linux)
// get, update loaded and save analytics data to file on start
let dict = analytics(analyticsFileURL, update: true)
_ = saveDictToFile(dict, url: analyticsFileURL)
#else
if let baseURL = baseURL {
// is just set for everything but not Linux
entriesFileURL = baseURL.appendingPathComponent(entriesFileName,
isDirectory: false)
sendingFileURL = baseURL.appendingPathComponent(sendingfileName,
isDirectory: false)
analyticsFileURL = baseURL.appendingPathComponent(analyticsFileName,
isDirectory: false)
// get, update loaded and save analytics data to file on start
let dict = analytics(analyticsFileURL, update: true)
_ = saveDictToFile(dict, url: analyticsFileURL)
}
#endif
}
// append to file, each line is a JSON dict
override public func send(_ level: SwiftyBeaver.Level, msg: String, thread: String,
file: String, function: String, line: Int, context: Any? = nil) -> String? {
var jsonString: String?
let dict: [String: Any] = [
"timestamp": Date().timeIntervalSince1970,
"level": level.rawValue,
"message": msg,
"thread": thread,
"fileName": file.components(separatedBy: "/").last!,
"function": function,
"line": line]
jsonString = jsonStringFromDict(dict)
if let str = jsonString {
toNSLog("saving '\(msg)' to \(entriesFileURL)")
_ = saveToFile(str, url: entriesFileURL)
//toNSLog(entriesFileURL.path!)
// now decide if the stored log entries should be sent to the server
// add level points to current points amount and send to server if threshold is hit
let newPoints = sendingPointsForLevel(level)
points += newPoints
toNSLog("current sending points: \(points)")
if (points >= sendingPoints.threshold && points >= minAllowedThreshold) || points > maxAllowedThreshold {
toNSLog("\(points) points is >= threshold")
// above threshold, send to server
sendNow()
} else if initialSending {
initialSending = false
// first logging at this session
// send if json file still contains old log entries
if let logEntries = logsFromFile(entriesFileURL) {
let lines = logEntries.count
if lines > 1 {
var msg = "initialSending: \(points) points is below threshold "
msg += "but json file already has \(lines) lines."
toNSLog(msg)
sendNow()
}
}
}
}
return jsonString
}
// MARK: Send-to-Server Logic
/// does a (manual) sending attempt of all unsent log entries to SwiftyBeaver Platform
public func sendNow() {
if sendFileExists() {
toNSLog("reset points to 0")
points = 0
} else {
if !renameJsonToSendFile() {
return
}
}
if !sendingInProgress {
sendingInProgress = true
//let (jsonString, lines) = logsFromFile(sendingFileURL)
var lines = 0
guard let logEntries = logsFromFile(sendingFileURL) else {
sendingInProgress = false
return
}
lines = logEntries.count
if lines > 0 {
var payload = [String: Any]()
// merge device and analytics dictionaries
let deviceDetailsDict = deviceDetails()
var analyticsDict = analytics(analyticsFileURL)
for key in deviceDetailsDict.keys {
analyticsDict[key] = deviceDetailsDict[key]
}
payload["device"] = analyticsDict
payload["entries"] = logEntries
if let str = jsonStringFromDict(payload) {
//toNSLog(str) // uncomment to see full payload
toNSLog("Encrypting \(lines) log entries ...")
if let encryptedStr = encrypt(str) {
var msg = "Sending \(lines) encrypted log entries "
msg += "(\(encryptedStr.length) chars) to server ..."
toNSLog(msg)
sendToServerAsync(encryptedStr) { ok, _ in
self.toNSLog("Sent \(lines) encrypted log entries to server, received ok: \(ok)")
if ok {
_ = self.deleteFile(self.sendingFileURL)
}
self.sendingInProgress = false
self.points = 0
}
}
}
} else {
sendingInProgress = false
}
}
}
/// sends a string to the SwiftyBeaver Platform server, returns ok if status 200 and HTTP status
func sendToServerAsync(_ str: String?, complete: @escaping (_ ok: Bool, _ status: Int) -> Void) {
let timeout = 10.0
if let payload = str, let queue = self.queue, let serverURL = serverURL {
// create operation queue which uses current serial queue of destination
let operationQueue = OperationQueue()
operationQueue.underlyingQueue = queue
let session = URLSession(configuration:
URLSessionConfiguration.default,
delegate: nil, delegateQueue: operationQueue)
toNSLog("assembling request ...")
// assemble request
var request = URLRequest(url: serverURL,
cachePolicy: .reloadIgnoringLocalAndRemoteCacheData,
timeoutInterval: timeout)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.addValue("application/json", forHTTPHeaderField: "Accept")
// basic auth header (just works on Linux for Swift 3.1+, macOS is fine)
guard let credentials = "\(appID):\(appSecret)".data(using: String.Encoding.utf8) else {
toNSLog("Error! Could not set basic auth header")
return complete(false, 0)
}
#if os(Linux)
let base64Credentials = Base64.encode([UInt8](credentials))
#else
let base64Credentials = credentials.base64EncodedString(options: [])
#endif
request.setValue("Basic \(base64Credentials)", forHTTPHeaderField: "Authorization")
//toNSLog("\nrequest:")
//print(request)
// POST parameters
let params = ["payload": payload]
do {
request.httpBody = try JSONSerialization.data(withJSONObject: params, options: [])
} catch {
toNSLog("Error! Could not create JSON for server payload.")
return complete(false, 0)
}
toNSLog("sending params: \(params)")
toNSLog("sending ...")
sendingInProgress = true
// send request async to server on destination queue
let task = session.dataTask(with: request) { _, response, error in
var ok = false
var status = 0
self.toNSLog("received response from server")
if let error = error {
// an error did occur
self.toNSLog("Error! Could not send entries to server. \(error)")
} else {
if let response = response as? HTTPURLResponse {
status = response.statusCode
if status == 200 {
// all went well, entries were uploaded to server
ok = true
} else {
// status code was not 200
var msg = "Error! Sending entries to server failed "
msg += "with status code \(status)"
self.toNSLog(msg)
}
}
}
return complete(ok, status)
}
task.resume()
session.finishTasksAndInvalidate()
//while true {} // commenting this line causes a crash on Linux unit tests?!?
}
}
/// returns sending points based on level
func sendingPointsForLevel(_ level: SwiftyBeaver.Level) -> Int {
switch level {
case .debug:
return sendingPoints.debug
case .info:
return sendingPoints.info
case .warning:
return sendingPoints.warning
case .error:
return sendingPoints.error
default:
return sendingPoints.verbose
}
}
// MARK: File Handling
/// appends a string as line to a file.
/// returns boolean about success
func saveToFile(_ str: String, url: URL, overwrite: Bool = false) -> Bool {
do {
if fileManager.fileExists(atPath: url.path) == false || overwrite {
// create file if not existing
let line = str + "\n"
try line.write(to: url, atomically: true, encoding: String.Encoding.utf8)
} else {
// append to end of file
let fileHandle = try FileHandle(forWritingTo: url)
_ = fileHandle.seekToEndOfFile()
let line = str + "\n"
if let data = line.data(using: String.Encoding.utf8) {
fileHandle.write(data)
fileHandle.closeFile()
}
}
return true
} catch {
toNSLog("Error! Could not write to file \(url).")
return false
}
}
func sendFileExists() -> Bool {
return fileManager.fileExists(atPath: sendingFileURL.path)
}
func renameJsonToSendFile() -> Bool {
do {
try fileManager.moveItem(at: entriesFileURL, to: sendingFileURL)
return true
} catch {
toNSLog("SwiftyBeaver Platform Destination could not rename json file.")
return false
}
}
/// returns optional array of log dicts from a file which has 1 json string per line
func logsFromFile(_ url: URL) -> [[String: Any]]? {
var lines = 0
do {
// try to read file, decode every JSON line and put dict from each line in array
let fileContent = try String(contentsOfFile: url.path, encoding: .utf8)
let linesArray = fileContent.components(separatedBy: "\n")
var dicts = [[String: Any]()] // array of dictionaries
for lineJSON in linesArray {
lines += 1
if lineJSON.firstChar == "{" && lineJSON.lastChar == "}" {
// try to parse json string into dict
if let data = lineJSON.data(using: .utf8) {
do {
if let dict = try JSONSerialization.jsonObject(with: data,
options: .mutableContainers) as? [String: Any] {
if !dict.isEmpty {
dicts.append(dict)
}
}
} catch {
var msg = "Error! Could not parse "
msg += "line \(lines) in file \(url)."
toNSLog(msg)
}
}
}
}
dicts.removeFirst()
return dicts
} catch {
toNSLog("Error! Could not read file \(url).")
}
return nil
}
/// returns AES-256 CBC encrypted optional string
func encrypt(_ str: String) -> String? {
return AES256CBC.encryptString(str, password: encryptionKey)
}
/// Delete file to get started again
func deleteFile(_ url: URL) -> Bool {
do {
try FileManager.default.removeItem(at: url)
return true
} catch {
toNSLog("Warning! Could not delete file \(url).")
}
return false
}
// MARK: Device & Analytics
// returns dict with device details. Amount depends on platform
func deviceDetails() -> [String: String] {
var details = [String: String]()
details["os"] = OS
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
// becomes for example 10.11.2 for El Capitan
var osVersionStr = String(osVersion.majorVersion)
osVersionStr += "." + String(osVersion.minorVersion)
osVersionStr += "." + String(osVersion.patchVersion)
details["osVersion"] = osVersionStr
details["hostName"] = ProcessInfo.processInfo.hostName
details["deviceName"] = ""
details["deviceModel"] = ""
if DEVICE_NAME != "" {
details["deviceName"] = DEVICE_NAME
}
if DEVICE_MODEL != "" {
details["deviceModel"] = DEVICE_MODEL
}
return details
}
/// returns (updated) analytics dict, optionally loaded from file.
func analytics(_ url: URL, update: Bool = false) -> [String: Any] {
var dict = [String: Any]()
let now = NSDate().timeIntervalSince1970
uuid = NSUUID().uuidString
dict["uuid"] = uuid
dict["firstStart"] = now
dict["lastStart"] = now
dict["starts"] = 1
dict["userName"] = analyticsUserName
dict["firstAppVersion"] = appVersion()
dict["appVersion"] = appVersion()
dict["firstAppBuild"] = appBuild()
dict["appBuild"] = appBuild()
if let loadedDict = dictFromFile(analyticsFileURL) {
if let val = loadedDict["firstStart"] as? Double {
dict["firstStart"] = val
}
if let val = loadedDict["lastStart"] as? Double {
if update {
dict["lastStart"] = now
} else {
dict["lastStart"] = val
}
}
if let val = loadedDict["starts"] as? Int {
if update {
dict["starts"] = val + 1
} else {
dict["starts"] = val
}
}
if let val = loadedDict["uuid"] as? String {
dict["uuid"] = val
uuid = val
}
if let val = loadedDict["userName"] as? String {
if update && !analyticsUserName.isEmpty {
dict["userName"] = analyticsUserName
} else {
if !val.isEmpty {
dict["userName"] = val
}
}
}
if let val = loadedDict["firstAppVersion"] as? String {
dict["firstAppVersion"] = val
}
if let val = loadedDict["firstAppBuild"] as? Int {
dict["firstAppBuild"] = val
}
}
return dict
}
/// Returns the current app version string (like 1.2.5) or empty string on error
func appVersion() -> String {
if let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String {
return version
}
return ""
}
/// Returns the current app build as integer (like 563, always incrementing) or 0 on error
func appBuild() -> Int {
if let version = Bundle.main.infoDictionary?["CFBundleVersion"] as? String {
if let intVersion = Int(version) {
return intVersion
}
}
return 0
}
/// returns optional dict from a json encoded file
func dictFromFile(_ url: URL) -> [String: Any]? {
do {
let fileContent = try String(contentsOfFile: url.path, encoding: .utf8)
if let data = fileContent.data(using: .utf8) {
return try JSONSerialization.jsonObject(with: data,
options: .mutableContainers) as? [String: Any]
}
} catch {
toNSLog("SwiftyBeaver Platform Destination could not read file \(url)")
}
return nil
}
// turns dict into JSON and saves it to file
func saveDictToFile(_ dict: [String: Any], url: URL) -> Bool {
let jsonString = jsonStringFromDict(dict)
if let str = jsonString {
toNSLog("saving '\(str)' to \(url)")
return saveToFile(str, url: url, overwrite: true)
}
return false
}
// MARK: Debug Helpers
/// log String to toNSLog. Used to debug the class logic
func toNSLog(_ str: String) {
if showNSLog {
#if os(Linux)
print("SBPlatform: \(str)")
#else
NSLog("SBPlatform: \(str)")
#endif
}
}
/// returns the current thread name
class func threadName() -> String {
#if os(Linux)
// on 9/30/2016 not yet implemented in server-side Swift:
// > import Foundation
// > Thread.isMainThread
return ""
#else
if Thread.isMainThread {
return ""
} else {
let threadName = Thread.current.name
if let threadName = threadName, !threadName.isEmpty {
return threadName
} else {
return String(format: "%p", Thread.current)
}
}
#endif
}
}