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:
Serhiy Mytrovtsiy
2025-03-22 12:59:23 +01:00
parent 75912f513a
commit 69ccc54910
11 changed files with 732 additions and 12 deletions

View File

@@ -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
- 1800

View File

@@ -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()
}
}

View File

@@ -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
View 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()
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 */,

View File

@@ -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>

View File

@@ -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
View 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()
}
}

View File

@@ -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>