mirror of
https://github.com/morgan9e/macos-stats
synced 2026-04-14 00:04:15 +09:00
feat: added Remote module that allows remote monitoring of the system (not publicly available since it's in the early stage)
This commit is contained in:
@@ -43,6 +43,8 @@ identifier_name:
|
||||
- LineChartHistory
|
||||
- SpeedPictogramColor
|
||||
- SensorsWidgetValue
|
||||
- access_token
|
||||
- refresh_token
|
||||
|
||||
line_length: 200
|
||||
|
||||
|
||||
@@ -103,14 +103,16 @@ open class Reader<T: Codable>: 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
369
Kit/plugins/Remote.swift
Normal file
369
Kit/plugins/Remote.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = "<group>"; };
|
||||
4921436D25319699000A1C47 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
4F92E6432D0F293100EA593F /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
5C038CF52D86EE8700516809 /* Remote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Remote.swift; sourceTree = "<group>"; };
|
||||
5C038CF72D8702D600516809 /* Login.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Login.swift; sourceTree = "<group>"; };
|
||||
5C044F792B3DE6F3005F6951 /* portal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = portal.swift; sourceTree = "<group>"; };
|
||||
5C0A2A89292A5B4D009B4C1F /* SMJobBlessUtil.py */ = {isa = PBXFileReference; lastKnownFileType = text.script.python; path = SMJobBlessUtil.py; sourceTree = "<group>"; };
|
||||
5C0A9CA12C467AA300EE6A89 /* widget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = widget.swift; sourceTree = "<group>"; };
|
||||
@@ -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 */,
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>682</string>
|
||||
<string>687</string>
|
||||
<key>Description</key>
|
||||
<string>Simple macOS system monitor in your menu bar</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
287
Stats/Views/Login.swift
Normal file
287
Stats/Views/Login.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.11.35</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>682</string>
|
||||
<string>687</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
|
||||
Reference in New Issue
Block a user