From 7e90f6557749f7a457f5f4b7e63d63d349799f4b Mon Sep 17 00:00:00 2001 From: Serhiy Mytrovtsiy Date: Fri, 23 Jun 2023 17:23:46 +0200 Subject: [PATCH] feat: added telemetry module --- Kit/plugins/Telemetry.swift | 93 +++++++++++++++++++++++++++++++++ Stats.xcodeproj/project.pbxproj | 4 ++ Stats/AppDelegate.swift | 1 + Stats/Views/AppSettings.swift | 16 +++++- Stats/Views/Setup.swift | 66 ++++++++++++++++++++++- 5 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 Kit/plugins/Telemetry.swift diff --git a/Kit/plugins/Telemetry.swift b/Kit/plugins/Telemetry.swift new file mode 100644 index 00000000..05974490 --- /dev/null +++ b/Kit/plugins/Telemetry.swift @@ -0,0 +1,93 @@ +// +// Telemetry.swift +// Kit +// +// Created by Serhiy Mytrovtsiy on 18/06/2023 +// Using Swift 5.0 +// Running on macOS 13.4 +// +// Copyright © 2023 Serhiy Mytrovtsiy. All rights reserved. +// + +import Foundation + +private struct Report: Codable { + let deviceID: UUID + + let modules: [String] + + let language: String? + let device: String? + let macOS: String? + let version: String? +} + +public class Telemetry { + public var isEnabled: Bool { + get { + self._isEnabled + } + set { + self.toggle(newValue) + } + } + + private var url: URL = URL(string: "https://api.serhiy.io/v1/stats/telemetry")! + + private var _isEnabled: Bool = true + + private let id: UUID + private let repeater = NSBackgroundActivityScheduler(identifier: "eu.exelban.Stats.Telemetry") + private var modules: UnsafePointer<[Module]> + + public init(_ modules: UnsafePointer<[Module]>) { + self._isEnabled = Store.shared.bool(key: "telemetry", defaultValue: true) + self.id = UUID(uuidString: Store.shared.string(key: "telemetry_id", defaultValue: UUID().uuidString)) ?? UUID() + self.modules = modules + + if !Store.shared.exist(key: "telemetry_id") { + Store.shared.set(key: "telemetry_id", value: self.id.uuidString) + self.toggle(self.isEnabled) + } + + self.report() + } + + @objc public func report() { + guard self.isEnabled else { return } + + let obj: Report = Report( + deviceID: self.id, + modules: self.modules.pointee.filter({ $0.available && $0.enabled }).compactMap({ $0.name }), + language: Locale.current.languageCode, + device: SystemKit.shared.device.model.id, + macOS: SystemKit.shared.device.os?.version.getFullVersion(), + version: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + ) + let jsonData = try? JSONEncoder().encode(obj) + + var request = URLRequest(url: self.url) + request.httpMethod = "POST" + request.httpBody = jsonData + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let task = URLSession.shared.dataTask(with: request) + task.resume() + } + + private func toggle(_ newValue: Bool) { + self._isEnabled = newValue + Store.shared.set(key: "telemetry", value: newValue) + + self.repeater.invalidate() + + if newValue { + self.repeater.repeats = true + self.repeater.interval = 60 * 60 * 24 + self.repeater.schedule { (completion: @escaping NSBackgroundActivityScheduler.CompletionHandler) in + self.report() + completion(NSBackgroundActivityScheduler.Result.finished) + } + } + } +} diff --git a/Stats.xcodeproj/project.pbxproj b/Stats.xcodeproj/project.pbxproj index d49d4dd0..5a64ff37 100644 --- a/Stats.xcodeproj/project.pbxproj +++ b/Stats.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 5C23BC0A29A0EDA300DBA990 /* portal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C23BC0929A0EDA300DBA990 /* portal.swift */; }; 5C23BC0C29A10BE000DBA990 /* portal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C23BC0B29A10BE000DBA990 /* portal.swift */; }; 5C23BC1029A3B5AE00DBA990 /* portal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C23BC0F29A3B5AE00DBA990 /* portal.swift */; }; + 5C5647F82A3F6B100098FFE9 /* Telemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5647F72A3F6B100098FFE9 /* Telemetry.swift */; }; 5C8E001029269C7F0027C75A /* protocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE493829265055000F2856 /* protocol.swift */; }; 5CFE492A29264DF1000F2856 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE492929264DF1000F2856 /* main.swift */; }; 5CFE493929265055000F2856 /* protocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE493829265055000F2856 /* protocol.swift */; }; @@ -390,6 +391,7 @@ 5C23BC0929A0EDA300DBA990 /* portal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = portal.swift; sourceTree = ""; }; 5C23BC0B29A10BE000DBA990 /* portal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = portal.swift; sourceTree = ""; }; 5C23BC0F29A3B5AE00DBA990 /* portal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = portal.swift; sourceTree = ""; }; + 5C5647F72A3F6B100098FFE9 /* Telemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Telemetry.swift; sourceTree = ""; }; 5CFE492729264DF1000F2856 /* eu.exelban.Stats.SMC.Helper */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = eu.exelban.Stats.SMC.Helper; sourceTree = BUILT_PRODUCTS_DIR; }; 5CFE492929264DF1000F2856 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; 5CFE493829265055000F2856 /* protocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = protocol.swift; sourceTree = ""; }; @@ -935,6 +937,7 @@ 9A6EEBBD2685259500897371 /* Logger.swift */, 9A5A8446271895B700BC40A4 /* Reachability.swift */, 9A302613286A2A3B00B41D57 /* Repeater.swift */, + 5C5647F72A3F6B100098FFE9 /* Telemetry.swift */, ); path = plugins; sourceTree = ""; @@ -1726,6 +1729,7 @@ 9A2847612666AA2700EC1F6D /* PieChart.swift in Sources */, 9A2847672666AA2700EC1F6D /* BarChart.swift in Sources */, 9A28477B2666AA5000EC1F6D /* popup.swift in Sources */, + 5C5647F82A3F6B100098FFE9 /* Telemetry.swift in Sources */, 9A2848202666AB3600EC1F6D /* types.swift in Sources */, 9A28481E2666AB3600EC1F6D /* extensions.swift in Sources */, 9A2848092666AB3000EC1F6D /* Store.swift in Sources */, diff --git a/Stats/AppDelegate.swift b/Stats/AppDelegate.swift index 20d5c591..4c9f1c6b 100755 --- a/Stats/AppDelegate.swift +++ b/Stats/AppDelegate.swift @@ -33,6 +33,7 @@ var modules: [Module] = [ Bluetooth(), Clock() ] +let telemetry: Telemetry = Telemetry(&modules) @main class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { diff --git a/Stats/Views/AppSettings.swift b/Stats/Views/AppSettings.swift index de25aec1..02d914dd 100644 --- a/Stats/Views/AppSettings.swift +++ b/Stats/Views/AppSettings.swift @@ -49,6 +49,7 @@ class ApplicationSettings: NSStackView { private var startAtLoginBtn: NSButton? private var uninstallHelperButton: NSButton? private var buttonsContainer: NSStackView? + private var telemetryBtn: NSButton? private var combinedModules: NSView? private var combinedModulesSeparator: NSView? @@ -88,6 +89,7 @@ class ApplicationSettings: NSStackView { public func viewWillAppear() { self.startAtLoginBtn?.state = LaunchAtLogin.isEnabled ? .on : .off + self.telemetryBtn?.state = telemetry.isEnabled ? .on : .off var idx = self.updateSelector?.indexOfSelectedItem ?? 0 if let items = self.updateSelector?.menu?.items { @@ -202,6 +204,14 @@ class ApplicationSettings: NSStackView { text: localizedString("Start at login") ) grid.addRow(with: [NSGridCell.emptyContentView, self.startAtLoginBtn!]) + + self.telemetryBtn = self.toggleView( + action: #selector(self.toggleTelemetry), + state: telemetry.isEnabled, + text: localizedString("Share anonymous telemetry") + ) + grid.addRow(with: [NSGridCell.emptyContentView, self.telemetryBtn!]) + grid.addRow(with: [NSGridCell.emptyContentView, self.toggleView( action: #selector(self.toggleCombinedModules), state: self.combinedModulesState, @@ -417,6 +427,10 @@ class ApplicationSettings: NSStackView { self.combinedModulesSpacing = key NotificationCenter.default.post(name: .moduleRearrange, object: nil, userInfo: nil) } + + @objc private func toggleTelemetry(_ sender: NSButton) { + telemetry.isEnabled = sender.state == NSControl.StateValue.on + } } private class ModuleSelectorView: NSStackView { @@ -550,7 +564,7 @@ internal class ModulePreview: NSStackView { self.wantsLayer = true self.layer?.cornerRadius = 2 - self.layer?.borderColor = NSColor(hexString: "#dddddd").cgColor + self.layer?.borderColor = NSColor(red: 221/255, green: 221/255, blue: 221/255, alpha: 1).cgColor self.layer?.borderWidth = 1 self.layer?.backgroundColor = NSColor.white.cgColor diff --git a/Stats/Views/Setup.swift b/Stats/Views/Setup.swift index 415ca993..a7021ed9 100644 --- a/Stats/Views/Setup.swift +++ b/Stats/Views/Setup.swift @@ -71,7 +71,7 @@ internal class SetupWindow: NSWindow, NSWindowDelegate { } private class SetupContainer: NSStackView { - private let pages: [NSView] = [SetupView_1(), SetupView_2(), SetupView_3(), SetupView_4()] + private let pages: [NSView] = [SetupView_1(), SetupView_2(), SetupView_3(), SetupView_4(), SetupView_end()] private var main: NSView = NSView() private var prevBtn: NSButton = NSButton() @@ -373,6 +373,70 @@ private class SetupView_3: NSStackView { } private class SetupView_4: NSStackView { + init() { + super.init(frame: NSRect(x: 0, y: 0, width: setupSize.width, height: setupSize.height - 60)) + + let container: NSGridView = NSGridView() + container.rowSpacing = 0 + container.yPlacement = .center + container.xPlacement = .center + + let title: NSTextField = TextView(frame: NSRect(x: 0, y: 0, width: container.frame.width, height: 22)) + title.alignment = .center + title.font = NSFont.systemFont(ofSize: 20, weight: .semibold) + title.stringValue = localizedString("Share anonymous telemetry for better development decisions") + title.toolTip = localizedString("Share anonymous telemetry for better development decisions") + title.isSelectable = false + + container.addRow(with: [title]) + container.addRow(with: [self.content()]) + + container.row(at: 0).height = 100 + + self.addArrangedSubview(container) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func content() -> NSView { + let container: NSGridView = NSGridView() + + container.addRow(with: [self.option( + tag: 1, + state: telemetry.isEnabled, + text: localizedString("Share anonymous telemetry data") + )]) + container.addRow(with: [self.option( + tag: 2, + state: !telemetry.isEnabled, + text: localizedString("Do not share anonymous telemetry data") + )]) + + return container + } + + private func option(tag: Int, state: Bool, text: String) -> NSView { + let button: NSButton = NSButton(frame: NSRect(x: 0, y: 0, width: 30, height: 20)) + button.setButtonType(.radio) + button.state = state ? .on : .off + button.title = text + button.action = #selector(self.toggle) + button.isBordered = false + button.isTransparent = false + button.target = self + button.tag = tag + + return button + } + + @objc private func toggle(_ sender: NSButton) { + telemetry.isEnabled = sender.tag == 1 + } +} + +private class SetupView_end: NSStackView { init() { super.init(frame: NSRect(x: 0, y: 0, width: setupSize.width, height: setupSize.height - 60))