From 69ccc549103f28d90d1f877a14487dc36267c971 Mon Sep 17 00:00:00 2001 From: Serhiy Mytrovtsiy Date: Sat, 22 Mar 2025 12:59:23 +0100 Subject: [PATCH] feat: added Remote module that allows remote monitoring of the system (not publicly available since it's in the early stage) --- .swiftlint.yml | 4 +- Kit/module/reader.swift | 6 +- Kit/plugins/DB.swift | 2 +- Kit/plugins/Remote.swift | 369 ++++++++++++++++++++++++++++ Kit/plugins/SystemKit.swift | 12 +- Kit/types.swift | 7 + Stats.xcodeproj/project.pbxproj | 8 + Stats/Supporting Files/Info.plist | 2 +- Stats/Views/AppSettings.swift | 45 ++++ Stats/Views/Login.swift | 287 ++++++++++++++++++++++ Widgets/Supporting Files/Info.plist | 2 +- 11 files changed, 732 insertions(+), 12 deletions(-) create mode 100644 Kit/plugins/Remote.swift create mode 100644 Stats/Views/Login.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 6ddda253..4ed5d06d 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -43,6 +43,8 @@ identifier_name: - LineChartHistory - SpeedPictogramColor - SensorsWidgetValue + - access_token + - refresh_token line_length: 200 @@ -52,4 +54,4 @@ type_body_length: file_length: - 1400 - - 1800 \ No newline at end of file + - 1800 diff --git a/Kit/module/reader.swift b/Kit/module/reader.swift index 4e8d5778..b2bae748 100644 --- a/Kit/module/reader.swift +++ b/Kit/module/reader.swift @@ -103,14 +103,16 @@ open class Reader: NSObject, ReaderInternal_p { } public func callback(_ value: T?) { + let moduleKey = "\(self.module.stringValue)@\(self.name)" self.value = value if let value { self.callbackHandler(value) + Remote.shared.send(key: moduleKey, value: value) if let ts = self.lastDBWrite, let interval = self.interval, Date().timeIntervalSince(ts) > interval * 10 { - DB.shared.insert(key: "\(self.module.stringValue)@\(self.name)", value: value, ts: self.history) + DB.shared.insert(key: moduleKey, value: value, ts: self.history) self.lastDBWrite = Date() } else if self.lastDBWrite == nil { - DB.shared.insert(key: "\(self.module.stringValue)@\(self.name)", value: value, ts: self.history) + DB.shared.insert(key: moduleKey, value: value, ts: self.history) self.lastDBWrite = Date() } } diff --git a/Kit/plugins/DB.swift b/Kit/plugins/DB.swift index e9dd700d..67d547bc 100644 --- a/Kit/plugins/DB.swift +++ b/Kit/plugins/DB.swift @@ -76,7 +76,7 @@ public class DB { public func insert(key: String, value: Codable, ts: Bool = true, force: Bool = false) { self.values[key] = value - guard let blobData = try? JSONEncoder().encode(value), let str = String(data: blobData, encoding: .utf8)else { return } + guard let blobData = try? JSONEncoder().encode(value), let str = String(data: blobData, encoding: .utf8) else { return } if ts { self.lldb?.insert("\(key)@\(Date().currentTimeSeconds())", value: str) diff --git a/Kit/plugins/Remote.swift b/Kit/plugins/Remote.swift new file mode 100644 index 00000000..69306f1f --- /dev/null +++ b/Kit/plugins/Remote.swift @@ -0,0 +1,369 @@ +// +// Remote.swift +// Stats +// +// Created by Serhiy Mytrovtsiy on 16/03/2025 +// Using Swift 6.0 +// Running on macOS 15.3 +// +// Copyright © 2025 Serhiy Mytrovtsiy. All rights reserved. +// + +import Foundation +import Cocoa + +public class Remote { + public static let shared = Remote() + static public var host = URL(string: "https://api.mac-stats.com")! // https://api.mac-stats.com http://localhost:8008 + + public var state: Bool { + get { Store.shared.bool(key: "remote_state", defaultValue: false) } + set { + Store.shared.set(key: "remote_state", value: newValue) + if newValue { + self.start() + } else { + self.stop() + } + } + } + public let id: UUID + public var isAuthorized: Bool = false + public var auth: RemoteAuth = RemoteAuth() + + private var ws: WebSocketManager = WebSocketManager() + private var wsURL: URL? + private var isConnecting = false + + public init() { + self.id = UUID(uuidString: Store.shared.string(key: "telemetry_id", defaultValue: UUID().uuidString)) ?? UUID() + + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + if self.state { + self.start() + } else { + self.stop() + } + } + + NotificationCenter.default.addObserver(self, selector: #selector(self.successLogin), name: .remoteLoginSuccess, object: nil) + } + + deinit { + self.ws.disconnect() + NotificationCenter.default.removeObserver(self, name: .remoteLoginSuccess, object: nil) + } + + public func logout() { + self.auth.logout() + self.isAuthorized = false + self.state = false + self.ws.disconnect() + NotificationCenter.default.post(name: .remoteState, object: nil, userInfo: ["auth": self.isAuthorized, "state": self.state]) + } + + public func send(key: String, value: Codable) { + guard self.state && self.isAuthorized, + let blobData = try? JSONEncoder().encode(value) else { return } + self.ws.send(key: key, data: blobData) + } + + @objc private func successLogin() { + self.isAuthorized = true + NotificationCenter.default.post(name: .remoteState, object: nil, userInfo: ["auth": self.isAuthorized, "state": self.state]) + + if self.state { + self.ws.connect() + } + } + + public func start() { + self.auth.isAuthorized { [weak self] status in + guard let self else { return } + + self.isAuthorized = status + NotificationCenter.default.post(name: .remoteState, object: nil, userInfo: ["auth": self.isAuthorized, "state": self.state]) + + if status { + self.ws.connect() + } + } + } + + private func stop() { + self.ws.disconnect() + NotificationCenter.default.post(name: .remoteState, object: nil, userInfo: ["auth": self.isAuthorized, "state": self.state]) + } +} + +public class RemoteAuth { + public var accessToken: String { + get { Store.shared.string(key: "access_token", defaultValue: "") } + set { Store.shared.set(key: "access_token", value: newValue) } + } + private var refreshToken: String { + get { Store.shared.string(key: "refresh_token", defaultValue: "") } + set { Store.shared.set(key: "refresh_token", value: newValue) } + } + + public init() { + NotificationCenter.default.addObserver(self, selector: #selector(self.successLogin), name: .remoteLoginSuccess, object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self, name: .remoteLoginSuccess, object: nil) + } + + public func isAuthorized(completion: @escaping (Bool) -> Void) { + self.validate(completion) + } + + public func logout() { + self.accessToken = "" + self.refreshToken = "" + } + + private func validate(_ completion: @escaping (Bool) -> Void) { + guard !self.accessToken.isEmpty && !self.refreshToken.isEmpty, let url = URL(string: "\(Remote.host)/auth/me") else { + completion(false) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(self.accessToken)", forHTTPHeaderField: "Authorization") + + URLSession.shared.dataTask(with: request) { [weak self] _, response, error in + guard let self = self, error == nil, let httpResponse = response as? HTTPURLResponse else { + completion(false) + return + } + + if httpResponse.statusCode == 401 { + self.refreshTokenFunc { ok in + completion(ok ?? false) + } + } else if httpResponse.statusCode == 200 { + completion(true) + } + }.resume() + } + + private func refreshTokenFunc(completion: @escaping (Bool?) -> Void) { + guard let url = URL(string: "\(Remote.host)/auth/token") else { + completion(nil) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + let body = "grant_type=refresh_token&refresh_token=\(self.refreshToken)" + .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) + request.httpBody = body?.data(using: .utf8) + + URLSession.shared.dataTask(with: request) { data, response, error in + guard error == nil, let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200, + let data = data, let token = try? JSONDecoder().decode(TokenResponse.self, from: data) else { + completion(nil) + return + } + self.accessToken = token.access_token + self.refreshToken = token.refresh_token + completion(true) + }.resume() + } + + @objc private func successLogin(_ notification: Notification) { + guard let userInfo = notification.userInfo, + let accessToken = userInfo["access_token"] as? String, + let refreshToken = userInfo["refresh_token"] as? String else { return } + + self.accessToken = accessToken + self.refreshToken = refreshToken + } +} + +struct WebSocketMessage: Codable { + let name: String + let data: Data + + enum CodingKeys: String, CodingKey { + case name + case data + } +} + +class WebSocketManager: NSObject { + private var webSocket: URLSessionWebSocketTask? + private var session: URLSession? + private var isConnected = false + private var isDisconnected = false + private let reconnectDelay: TimeInterval = 3.0 + private var pingTimer: Timer? + private var reachability: Reachability = Reachability(start: true) + + override init() { + super.init() + + self.session = URLSession(configuration: .default, delegate: self, delegateQueue: .main) + + self.reachability.reachable = { + if Remote.shared.state { + self.connect() + } + } + self.reachability.unreachable = { + if self.isConnected { + self.disconnect() + } + } + } + + public func connect() { + guard !self.isConnected else { return } + + Remote.shared.auth.isAuthorized { [weak self] status in + guard status, let self else { return } + + var wsHost = Remote.host.absoluteString + wsHost = wsHost.replacingOccurrences(of: "https", with: "wss").replacingOccurrences(of: "http", with: "ws") + let url = URL(string: "\(wsHost)/remote?jwt=\(Remote.shared.auth.accessToken)&device_id=\(Remote.shared.id.uuidString)")! + + self.webSocket = self.session?.webSocketTask(with: url) + self.webSocket?.resume() + self.receiveMessage() + self.isDisconnected = false + } + } + + public func disconnect() { + self.isDisconnected = true + self.webSocket?.cancel(with: .normalClosure, reason: nil) + self.webSocket = nil + self.isConnected = false + } + + private func reconnect() { + guard !self.isDisconnected else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + self.reconnectDelay) { [weak self] in + self?.connect() + } + } + + private func sendDetails() { + struct Details: Codable { + let version: String + let system: System + let hardware: Hardware + } + + struct OS: Codable { + let name: String? + let version: String? + let build: String? + } + + struct System: Codable { + let platform: String + let vendor: String? + let model: String? + let modelID: String? + let os: OS + } + + struct Hardware: Codable { + let cpu: cpu_s? + let gpu: [gpu_s]? + let ram: [dimm_s]? + } + + let details = Details( + version: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown", + system: System( + platform: "macOS", + vendor: "Apple", + model: SystemKit.shared.device.model.name, + modelID: SystemKit.shared.device.model.id, + os: OS( + name: SystemKit.shared.device.os?.name, + version: SystemKit.shared.device.os?.version.getFullVersion(), + build: SystemKit.shared.device.os?.build + ) + ), + hardware: Hardware( + cpu: SystemKit.shared.device.info.cpu, + gpu: SystemKit.shared.device.info.gpu, + ram: SystemKit.shared.device.info.ram?.dimms + ) + ) + let jsonData = try? JSONEncoder().encode(details) + self.send(key: "details", data: jsonData ?? Data()) + } + + public func send(key: String, data: Data) { + if key != "details" && !key.contains("CPU@") && !key.contains("GPU@") && !key.contains("RAM@") && !key.contains("Network@") && !key.contains("Sensors@") { + return + } + if !self.isConnected { return } + let message = WebSocketMessage(name: key, data: data) + guard let messageData = try? JSONEncoder().encode(message) else { return } + self.webSocket?.send(.data(messageData)) { error in + if let error = error { + print("Error sending message: \(error)") + } + } + } + + private func receiveMessage() { + self.webSocket?.receive { [weak self] result in + switch result { + case .failure(let error): + self?.isConnected = false + self?.handleWebSocketError(error) + case .success: + self?.receiveMessage() + } + } + } + + private func startPingTimer() { + self.stopPingTimer() + self.pingTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in + self?.ping() + } + } + + private func stopPingTimer() { + self.pingTimer?.invalidate() + self.pingTimer = nil + } + + private func ping() { + self.webSocket?.sendPing { [weak self] _ in + self?.isConnected = false + self?.reconnect() + } + } + + private func handleWebSocketError(_ error: Error) { + if let urlError = error as? URLError, urlError.code.rawValue == 401 { + Remote.shared.start() + } else { + self.reconnect() + } + } +} + +extension WebSocketManager: URLSessionWebSocketDelegate { + func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { + self.isConnected = true + self.sendDetails() + } + + func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + self.isConnected = false + self.reconnect() + } +} diff --git a/Kit/plugins/SystemKit.swift b/Kit/plugins/SystemKit.swift index 19b87ebd..752d087b 100644 --- a/Kit/plugins/SystemKit.swift +++ b/Kit/plugins/SystemKit.swift @@ -77,7 +77,7 @@ public enum deviceType: String { } } -public enum coreType: Int { +public enum coreType: Int, Codable { case unknown = -1 case efficiency = 1 case performance = 2 @@ -97,12 +97,12 @@ public struct os_s { public let build: String } -public struct core_s { +public struct core_s: Codable { public var id: Int32 public var type: coreType } -public struct cpu_s { +public struct cpu_s: Codable { public var name: String? = nil public var physicalCores: Int8? = nil public var logicalCores: Int8? = nil @@ -113,7 +113,7 @@ public struct cpu_s { public var pCoreFrequencies: [Int32]? = nil } -public struct dimm_s { +public struct dimm_s: Codable { public var bank: Int? = nil public var channel: String? = nil public var type: String? = nil @@ -121,11 +121,11 @@ public struct dimm_s { public var speed: String? = nil } -public struct ram_s { +public struct ram_s: Codable { public var dimms: [dimm_s] = [] } -public struct gpu_s { +public struct gpu_s: Codable { public var name: String? = nil public var vendor: String? = nil public var vram: String? = nil diff --git a/Kit/types.swift b/Kit/types.swift index 577f144c..4553c53f 100644 --- a/Kit/types.swift +++ b/Kit/types.swift @@ -278,6 +278,8 @@ public extension Notification.Name { static let pause = Notification.Name("pause") static let toggleFanControl = Notification.Name("toggleFanControl") static let combinedModulesPopup = Notification.Name("combinedModulesPopup") + static let remoteLoginSuccess = Notification.Name("remoteLoginSuccess") + static let remoteState = Notification.Name("remoteState") } public var isARM: Bool { @@ -411,3 +413,8 @@ public enum RAMPressure: String, Codable { } } } + +public struct TokenResponse: Codable { + public let access_token: String + public let refresh_token: String +} diff --git a/Stats.xcodeproj/project.pbxproj b/Stats.xcodeproj/project.pbxproj index e028ee78..1249a08f 100644 --- a/Stats.xcodeproj/project.pbxproj +++ b/Stats.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 5C038CF62D86EE8A00516809 /* Remote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C038CF52D86EE8700516809 /* Remote.swift */; }; + 5C038CF82D8702D800516809 /* Login.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C038CF72D8702D600516809 /* Login.swift */; }; 5C044F7A2B3DE6F3005F6951 /* portal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C044F792B3DE6F3005F6951 /* portal.swift */; }; 5C0A2A8A292A5B4D009B4C1F /* SMJobBlessUtil.py in Resources */ = {isa = PBXBuildFile; fileRef = 5C0A2A89292A5B4D009B4C1F /* SMJobBlessUtil.py */; }; 5C0A9CA22C467AA300EE6A89 /* widget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0A9CA12C467AA300EE6A89 /* widget.swift */; }; @@ -495,6 +497,8 @@ 47665544298DC92F00F7B709 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; 4921436D25319699000A1C47 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; 4F92E6432D0F293100EA593F /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; + 5C038CF52D86EE8700516809 /* Remote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Remote.swift; sourceTree = ""; }; + 5C038CF72D8702D600516809 /* Login.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Login.swift; sourceTree = ""; }; 5C044F792B3DE6F3005F6951 /* portal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = portal.swift; sourceTree = ""; }; 5C0A2A89292A5B4D009B4C1F /* SMJobBlessUtil.py */ = {isa = PBXFileReference; lastKnownFileType = text.script.python; path = SMJobBlessUtil.py; sourceTree = ""; }; 5C0A9CA12C467AA300EE6A89 /* widget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = widget.swift; sourceTree = ""; }; @@ -1113,6 +1117,7 @@ 9A81C74A24499C4B00825D92 /* Views */ = { isa = PBXGroup; children = ( + 5C038CF72D8702D600516809 /* Login.swift */, 9A81C74C24499C7000825D92 /* Settings.swift */, 9A045EB62594F8D100ED58F2 /* Dashboard.swift */, 9A81C74B24499C7000825D92 /* AppSettings.swift */, @@ -1190,6 +1195,7 @@ 9AA81547266A9ACA008C01D0 /* plugins */ = { isa = PBXGroup; children = ( + 5C038CF52D86EE8700516809 /* Remote.swift */, 9A2848022666AB2F00EC1F6D /* Store.swift */, 9A2848042666AB2F00EC1F6D /* Charts.swift */, 9A2848032666AB2F00EC1F6D /* SystemKit.swift */, @@ -2057,6 +2063,7 @@ buildActionMask = 2147483647; files = ( 9AABEB7A243FD26200668CB0 /* AppDelegate.swift in Sources */, + 5C038CF82D8702D800516809 /* Login.swift in Sources */, 9A9EA9452476D34500E3B883 /* Update.swift in Sources */, 9A045EB72594F8D100ED58F2 /* Dashboard.swift in Sources */, 9A81C74E24499C7000825D92 /* Settings.swift in Sources */, @@ -2078,6 +2085,7 @@ 9A2847612666AA2700EC1F6D /* PieChart.swift in Sources */, 9A2847672666AA2700EC1F6D /* BarChart.swift in Sources */, 9A28477B2666AA5000EC1F6D /* popup.swift in Sources */, + 5C038CF62D86EE8A00516809 /* Remote.swift in Sources */, 5C5647F82A3F6B100098FFE9 /* Telemetry.swift in Sources */, 9A2848202666AB3600EC1F6D /* types.swift in Sources */, 9A28481E2666AB3600EC1F6D /* extensions.swift in Sources */, diff --git a/Stats/Supporting Files/Info.plist b/Stats/Supporting Files/Info.plist index f8bb3f82..140aa205 100755 --- a/Stats/Supporting Files/Info.plist +++ b/Stats/Supporting Files/Info.plist @@ -17,7 +17,7 @@ CFBundleShortVersionString $(MARKETING_VERSION) CFBundleVersion - 682 + 687 Description Simple macOS system monitor in your menu bar LSApplicationCategoryType diff --git a/Stats/Views/AppSettings.swift b/Stats/Views/AppSettings.swift index 94c42c3f..efc4e5c1 100644 --- a/Stats/Views/AppSettings.swift +++ b/Stats/Views/AppSettings.swift @@ -42,12 +42,15 @@ class ApplicationSettings: NSStackView { private var updateSelector: NSPopUpButton? private var startAtLoginBtn: NSSwitch? private var telemetryBtn: NSSwitch? + private var remoteBtn: NSSwitch? private var combinedModulesView: PreferencesSection? private var fanHelperView: PreferencesSection? + private var remoteView: PreferencesSection? private let updateWindow: UpdateWindow = UpdateWindow() private let moduleSelector: ModuleSelectorView = ModuleSelectorView() + private let loginWindow: LoginWindow = LoginWindow() private var CPUeButton: NSButton? private var CPUpButton: NSButton? @@ -127,6 +130,18 @@ class ApplicationSettings: NSStackView { self.combinedModulesView?.setRowVisibility(3, newState: self.combinedModulesState) self.combinedModulesView?.setRowVisibility(4, newState: self.combinedModulesState) + self.remoteBtn = switchView( + action: #selector(self.toggleRemoteState), + state: Remote.shared.state + ) + + self.remoteView = PreferencesSection(label: localizedString("Stats Remote"), [ + PreferencesRow(localizedString("Monitoring"), component: self.remoteBtn!), + PreferencesRow(localizedString("Identificator"), component: textView(Remote.shared.id.uuidString)), + PreferencesRow(component: buttonView(#selector(self.logoutFromRemote), text: localizedString("Logout"))) + ]) + scrollView.stackView.addArrangedSubview(self.remoteView!) + scrollView.stackView.addArrangedSubview(PreferencesSection(label: localizedString("Settings"), [ PreferencesRow( localizedString("Export settings"), @@ -170,6 +185,7 @@ class ApplicationSettings: NSStackView { scrollView.stackView.addArrangedSubview(PreferencesSection(label: localizedString("Stress tests"), tests)) NotificationCenter.default.addObserver(self, selector: #selector(self.toggleUninstallHelperButton), name: .fanHelperState, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(self.remoteState), name: .remoteState, object: nil) } required init?(coder: NSCoder) { @@ -404,6 +420,35 @@ class ApplicationSettings: NSStackView { self.GPUButton?.title = localizedString("Stop") } } + + @objc private func logoutFromRemote() { + Remote.shared.logout() + } + + @objc private func remoteState(_ notification: Notification) { + guard let state = notification.userInfo?["state"] as? Bool, let auth = notification.userInfo?["auth"] as? Bool else { return } + self.setRemoteSettings(state, auth) + } + + private func setRemoteSettings(_ state: Bool, _ auth: Bool) { + DispatchQueue.main.async { + if state && auth { + self.remoteBtn?.state = .on + self.remoteView?.setRowVisibility(1, newState: true) + self.remoteView?.setRowVisibility(2, newState: true) + return + } else if state && !auth { + self.loginWindow.open() + } + self.remoteBtn?.state = .off + self.remoteView?.setRowVisibility(1, newState: false) + self.remoteView?.setRowVisibility(2, newState: false) + } + } + + @objc private func toggleRemoteState(_ sender: NSButton) { + Remote.shared.state = sender.state == NSControl.StateValue.on + } } private class ModuleSelectorView: NSStackView { diff --git a/Stats/Views/Login.swift b/Stats/Views/Login.swift new file mode 100644 index 00000000..ce71cb4a --- /dev/null +++ b/Stats/Views/Login.swift @@ -0,0 +1,287 @@ +// +// Login.swift +// Stats +// +// Created by Serhiy Mytrovtsiy on 16/03/2025 +// Using Swift 6.0 +// Running on macOS 15.3 +// +// Copyright © 2025 Serhiy Mytrovtsiy. All rights reserved. +// + +import Cocoa +import Kit + +internal class LoginWindow: NSWindow, NSWindowDelegate { + private let viewController: LoginViewController = LoginViewController() + + init() { + super.init( + contentRect: NSRect( + x: NSScreen.main!.frame.width - self.viewController.view.frame.width, + y: NSScreen.main!.frame.height - self.viewController.view.frame.height, + width: self.viewController.view.frame.width, + height: self.viewController.view.frame.height + ), + styleMask: [.closable, .titled], + backing: .buffered, + defer: true + ) + + self.title = localizedString("Stats Remote") + self.contentViewController = self.viewController + self.titlebarAppearsTransparent = true + self.positionCenter() + self.setIsVisible(false) + + let windowController = NSWindowController() + windowController.window = self + windowController.loadWindow() + } + + internal func open() { + guard !self.isVisible else { return } + self.setIsVisible(true) + self.makeKeyAndOrderFront(nil) + } + + private func positionCenter() { + self.setFrameOrigin(NSPoint( + x: (NSScreen.main!.frame.width - self.viewController.view.frame.width)/2, + y: (NSScreen.main!.frame.height - self.viewController.view.frame.height)/1.75 + )) + } +} + +private class LoginViewController: NSViewController { + private var _view: LoginView + + public init() { + self._view = LoginView(frame: NSRect(x: 0, y: 0, width: 320, height: 170)) + super.init(nibName: nil, bundle: nil) + self.view = self._view + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private class LoginView: NSView { + private let stackView: NSStackView = { + let stack = NSStackView() + stack.orientation = .vertical + stack.spacing = 12 + stack.alignment = .centerX + return stack + }() + + private let formStack: NSStackView = { + let stack = NSStackView() + stack.orientation = .vertical + stack.spacing = 8 + return stack + }() + + private let usernameTextField: NSTextField = { + let textField = NSTextField() + textField.placeholderString = localizedString("Username") + textField.bezelStyle = .roundedBezel + textField.font = .systemFont(ofSize: 13) + if #available(macOS 11.0, *) { + textField.controlSize = .large + } + return textField + }() + + private let passwordTextField: NSSecureTextField = { + let textField = NSSecureTextField() + textField.placeholderString = localizedString("Password") + textField.bezelStyle = .roundedBezel + textField.font = .systemFont(ofSize: 13) + if #available(macOS 11.0, *) { + textField.controlSize = .large + } + return textField + }() + + private let loginButton: NSButton = { + let button = NSButton(title: localizedString("Login"), target: nil, action: #selector(loginButtonTapped)) + button.bezelStyle = .rounded + if #available(macOS 11.0, *) { + button.controlSize = .large + } + button.font = .systemFont(ofSize: 13, weight: .semibold) + button.keyEquivalent = "\r" + return button + }() + + private let registerStack: NSStackView = { + let stack = NSStackView() + stack.orientation = .horizontal + stack.spacing = 5 + return stack + }() + + private let registerLabel: NSTextField = { + let label = NSTextField(labelWithString: localizedString("Don't have an account?")) + label.isEditable = false + label.isBordered = false + label.backgroundColor = .clear + label.font = .systemFont(ofSize: 12) + label.textColor = .secondaryLabelColor + return label + }() + + private let registerButton: NSButton = { + let button = NSButton(title: localizedString("Register here"), target: nil, action: #selector(registerButtonTapped)) + button.bezelStyle = .inline + button.font = .systemFont(ofSize: 12) + button.contentTintColor = .systemBlue + return button + }() + + private let errorLabel: NSTextField = { + let label = NSTextField(labelWithString: "") + label.textColor = .systemRed + label.font = .systemFont(ofSize: 12) + label.alignment = .center + label.isEditable = false + label.isBordered = false + label.backgroundColor = .clear + return label + }() + + override init(frame: NSRect) { + super.init(frame: CGRect(x: frame.origin.x, y: frame.origin.y, width: frame.width, height: frame.height)) + self.wantsLayer = true + + let sidebar = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: self.frame.width, height: self.frame.height)) + sidebar.material = .sidebar + sidebar.blendingMode = .behindWindow + sidebar.state = .active + + self.addSubview(sidebar) + self.setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupLayout() { + self.formStack.addArrangedSubview(self.usernameTextField) + self.formStack.addArrangedSubview(self.passwordTextField) + self.formStack.addArrangedSubview(self.errorLabel) + + self.registerStack.addArrangedSubview(self.registerLabel) + self.registerStack.addArrangedSubview(self.registerButton) + + self.stackView.addArrangedSubview(self.formStack) + self.stackView.addArrangedSubview(self.loginButton) +// self.stackView.addArrangedSubview(self.registerStack) + + addSubview(self.stackView) + self.stackView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + self.stackView.topAnchor.constraint(equalTo: topAnchor, constant: 20), + self.stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20), + self.stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20), + + self.formStack.widthAnchor.constraint(equalTo: self.stackView.widthAnchor), + self.loginButton.widthAnchor.constraint(equalToConstant: 100) + ]) + + self.loginButton.target = self + self.registerButton.target = self + } + + @objc private func loginButtonTapped() { + let username = self.usernameTextField.stringValue.trimmingCharacters(in: .whitespaces) + let password = self.passwordTextField.stringValue + + guard username.count >= 3 else { + showError("Username must be at least 3 characters") + return + } + guard username.count <= 30 else { + self.showError("Username must be less than 30 characters") + return + } + + guard password.count >= 6 else { + self.showError("Password must be at least 6 characters") + return + } + guard password.count <= 50 else { + self.showError("Password must be less than 50 characters") + return + } + + self.authenticateUser(username: username, password: password) + } + + @objc private func registerButtonTapped() { + if let url = URL(string: "\(Remote.host)/register") { + NSWorkspace.shared.open(url) + } + } + + private func authenticateUser(username: String, password: String) { + guard let url = URL(string: "\(Remote.host)/auth/token") else { return } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + let body = "grant_type=password&username=\(username)&password=\(password)" + .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) + request.httpBody = body?.data(using: .utf8) + + NSCursor.pointingHand.push() + self.loginButton.isEnabled = false + + URLSession.shared.dataTask(with: request) { [weak self] data, response, error in + DispatchQueue.main.async { + NSCursor.pop() + self?.loginButton.isEnabled = true + + if let error = error { + self?.showError(error.localizedDescription) + return + } + + guard let httpResponse = response as? HTTPURLResponse else { + self?.showError("Invalid server response") + return + } + + if httpResponse.statusCode == 200 { + guard let data = data, let tokenResponse = try? JSONDecoder().decode(TokenResponse.self, from: data) else { + self?.showError("Invalid response format") + return + } + + NotificationCenter.default.post(name: .remoteLoginSuccess, object: nil, userInfo: [ + "access_token": tokenResponse.access_token, + "refresh_token": tokenResponse.refresh_token + ]) + + self?.errorLabel.isHidden = true + self?.window?.close() + } else { + self?.showError("Invalid username or password") + } + } + }.resume() + } + + private func showError(_ message: String) { + self.errorLabel.stringValue = message + } + + @objc private func close() { + self.window?.close() + } +} diff --git a/Widgets/Supporting Files/Info.plist b/Widgets/Supporting Files/Info.plist index fa93f37b..10f94778 100644 --- a/Widgets/Supporting Files/Info.plist +++ b/Widgets/Supporting Files/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 2.11.35 CFBundleVersion - 682 + 687 NSExtension NSExtensionPointIdentifier